Author SHA1 Message Date
HomerandClaude Opus 5.5 9499e93bc4 lembas login: a refused sign-in starts a new one; 2.0.0-beta.4
ci / check (push) Waiting to run
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 <noreply@anthropic.com>
2026-11-09 16:26:55 +00:00
6 changed files with 35 additions and 10 deletions

No files matched your search

+1 -1
View File
@@ -324,5 +324,5 @@ say ""
say "Done. Open a new shell (or: source your rc file), then run: lembas" 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' ')" [ -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 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 <your LLeMbas server address>"
fi fi
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lembas-cli", "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.", "description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
+10 -1
View File
@@ -98,7 +98,7 @@ function fail(msg: string, code = 1): never {
} }
async function loginCommand(cmd: "login" | "logout", args: string[]) { 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({ const { values, positionals } = parseArgs({
args, args,
allowPositionals: true, 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. // `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 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)) { if (again && !values.ca && isWebuiEntry(again.connection)) {
try {
const r = await sync(again.connection, { setDefault: values.default }) const r = await sync(again.connection, { setDefault: values.default })
if (json) emit(doneEvent(r, false)) if (json) emit(doneEvent(r, false))
else console.log(loginSummary(r, false).join("\n")) else console.log(loginSummary(r, false).join("\n"))
return 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 // An older kind of login (the models copied into connections.yaml) is replaced by signing in
// again, at the address it had. // again, at the address it had.
+7 -5
View File
@@ -27,6 +27,8 @@ export function protocolMismatch(base: string, d: Pick<Discovery, "protocol" | "
} }
export class LembasError extends Error {} export class LembasError extends Error {}
/** The instance answered 401: the token this machine holds is revoked or unknown there. `login` signs in afresh. */
export class SignInRejected extends LembasError {}
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */ /** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
export class ProtocolError extends LembasError {} export class ProtocolError extends LembasError {}
@@ -268,7 +270,7 @@ export async function checkToken(d: Discovery, token: string, tls: Tls | undefin
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> { export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) 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.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}`) if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`)
const body = (await res.json()) as { data?: ServedModel[] } 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<InstanceInfo | undefined> { export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<InstanceInfo | undefined> {
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) 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 === 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}`) if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`)
const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined
return body && typeof body === "object" ? body : 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<Usage | undefined> { export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Usage | undefined> {
const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) 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 === 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}`) if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`)
const body = (await res.json().catch(() => undefined)) as Usage | undefined const body = (await res.json().catch(() => undefined)) as Usage | undefined
return body && typeof body === "object" ? body : 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<Personalization | undefined> { export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Personalization | undefined> {
const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs) 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 === 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}`) if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`)
return asPersonalization(await res.json().catch(() => undefined)) 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}` : ""}`) 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") 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}`) if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`)
return asPersonalization(await res.json().catch(() => undefined)) return asPersonalization(await res.json().catch(() => undefined))
} }
+2 -2
View File
@@ -13,7 +13,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
import { join, resolve } from "node:path" import { join, resolve } from "node:path"
import { parseDocument } from "yaml" import { parseDocument } from "yaml"
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts" 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" import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
export interface Instance { export interface Instance {
@@ -557,4 +557,4 @@ export async function logout(connection: string): Promise<LogoutResult> {
return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined } return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined }
} }
export { LembasError, readCache, specFor } export { LembasError, readCache, SignInRejected, specFor }
+14
View File
@@ -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", "--token-stdin", fake.url], "lmb_secret")).code).toBe(2)
expect((await cli(["login", "--server", 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")
})