From 9499e93bc45d31edb42fff3f71bd2e62bdf56f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Mon, 9 Nov 2026 16:26:55 +0000 Subject: [PATCH] lembas login: a refused sign-in starts a new one; 2.0.0-beta.4 With a stored login for the same server, login only refreshed it; a 401 said "run login again", which did the same again. A SignInRejected from the refresh now falls through to a fresh device login, which replaces the old one. The installer's last line points at lembas login. Co-Authored-By: Claude Opus 5.5 --- install.sh | 2 +- package.json | 2 +- src/cli.ts | 19 ++++++++++++++----- src/lembas/client.ts | 12 +++++++----- src/lembas/login.ts | 4 ++-- tests/lembas-login.test.ts | 14 ++++++++++++++ 6 files changed, 39 insertions(+), 14 deletions(-) diff --git a/install.sh b/install.sh index 850c07c..22ea42e 100755 --- a/install.sh +++ b/install.sh @@ -324,5 +324,5 @@ say "" say "Done. Open a new shell (or: source your rc file), then run: lembas" [ -n "$extra" ] && say "Aliases: $(printf '%s' "$extra" | sed -n "s/^alias \([a-z]*\)=.*/\1/p" | paste -sd' ')" if [ "$starter" -eq 1 ] && [ -z "$(sed -n 's/^model: *//p' "$CONFIG/config.yaml")" ]; then - say "Next: add a connection to $CONFIG/connections.yaml and set model: in config.yaml." + say "Next: lembas login " fi diff --git a/package.json b/package.json index 416e8a1..bef024f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lembas-cli", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.", "license": "MIT", "private": true, diff --git a/src/cli.ts b/src/cli.ts index 52d186e..42d7f83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -98,7 +98,7 @@ function fail(msg: string, code = 1): never { } async function loginCommand(cmd: "login" | "logout", args: string[]) { - const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError } = await import("./lembas/login.ts") + const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError, SignInRejected } = await import("./lembas/login.ts") const { values, positionals } = parseArgs({ args, allowPositionals: true, @@ -144,10 +144,19 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) { // `login` again with no address refreshes the one instance there is. const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined if (again && !values.ca && isWebuiEntry(again.connection)) { - const r = await sync(again.connection, { setDefault: values.default }) - if (json) emit(doneEvent(r, false)) - else console.log(loginSummary(r, false).join("\n")) - return + try { + const r = await sync(again.connection, { setDefault: values.default }) + if (json) emit(doneEvent(r, false)) + else console.log(loginSummary(r, false).join("\n")) + return + } catch (e) { + // The token here was revoked or is unknown to the instance: refreshing it can never work, + // and "run login again" would send the person straight back here. Sign in afresh instead; + // the new login replaces the old one. + if (!(e instanceof SignInRejected)) throw e + if (!json) console.error("The instance no longer accepts the sign-in kept here: signing in again.") + address = again.base_url + } } // An older kind of login (the models copied into connections.yaml) is replaced by signing in // again, at the address it had. diff --git a/src/lembas/client.ts b/src/lembas/client.ts index b872fe8..2782e9f 100644 --- a/src/lembas/client.ts +++ b/src/lembas/client.ts @@ -27,6 +27,8 @@ export function protocolMismatch(base: string, d: Pick { const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) - if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again") + if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again") if (res.status === 403) throw new LembasError("this account may not use the instance's models through the API (the “Use the API” permission)") if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`) const body = (await res.json()) as { data?: ServedModel[] } @@ -293,7 +295,7 @@ export interface InstanceInfo { export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise { const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) if (res.status === 404) return undefined - if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again") + if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again") if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`) const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined return body && typeof body === "object" ? body : undefined @@ -341,7 +343,7 @@ export interface Usage { export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise { const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) if (res.status === 404) return undefined - if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again") + if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again") if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`) const body = (await res.json().catch(() => undefined)) as Usage | undefined return body && typeof body === "object" ? body : undefined @@ -369,7 +371,7 @@ function asPersonalization(b: unknown): Personalization | undefined { export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise { const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) if (res.status === 404) return undefined - if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again") + if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again") if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`) return asPersonalization(await res.json().catch(() => undefined)) } @@ -393,7 +395,7 @@ export async function savePersonalization(base: string, token: string, tls: Tls if (res.status === 422) throw new LembasError(`the instance would not take that personalization${detail ? `: ${detail}` : ""}`) throw new LembasError(detail ? `the instance did not allow it: ${detail}` : "not allowed: the instance refused to save the personalization") } - if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again") + if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again") if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`) return asPersonalization(await res.json().catch(() => undefined)) } diff --git a/src/lembas/login.ts b/src/lembas/login.ts index c8120f5..c00f4f1 100644 --- a/src/lembas/login.ts +++ b/src/lembas/login.ts @@ -13,7 +13,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } import { join, resolve } from "node:path" import { parseDocument } from "yaml" import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts" -import { awaitToken, checkToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts" +import { awaitToken, checkToken, discover, LembasError, SignInRejected, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts" import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts" export interface Instance { @@ -557,4 +557,4 @@ export async function logout(connection: string): Promise { return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined } } -export { LembasError, readCache, specFor } +export { LembasError, readCache, SignInRejected, specFor } diff --git a/tests/lembas-login.test.ts b/tests/lembas-login.test.ts index 2f4f194..1a1a4e4 100644 --- a/tests/lembas-login.test.ts +++ b/tests/lembas-login.test.ts @@ -555,3 +555,17 @@ test("lembas login --token-stdin --server --json: the token from stdin, the devi expect((await cli(["login", "--token-stdin", fake.url], "lmb_secret")).code).toBe(2) expect((await cli(["login", "--server", fake.url], "lmb_secret")).code).toBe(2) }) + +test("lembas login over a sign-in the instance no longer accepts signs in afresh, not in a loop", async () => { + fake = lembas() + await login(fake.url, { ...quick, onCode: () => {} }) + // The instance forgot the token kept here (revoked, or another instance's database). + fake.token = "lmb_fresh" + fake.polls = 0 + const r = await cli(["login", fake.url, "--json"], "") + expect(r.code).toBe(0) + const lines = r.out.trim().split("\n").map((l) => JSON.parse(l)) + expect(lines[0]).toMatchObject({ event: "code" }) + expect(lines.at(-1)).toMatchObject({ event: "done", fresh: true, connection: "example" }) + expect(readFileSync(keyFile("example"), "utf8").trim()).toBe("lmb_fresh") +})