LLeMbas CLI 1.5.2: a login again leaves one device; the link says why it cannot connect
ci / check (push) Canceled after 0s

A second `lembas login` to the same instance overwrote the old token without
signing it out, leaving an offline twin of the device holding its agent chats.
The old token is now signed out over the TLS that just worked, naming the new
one, so an instance that knows `replaced_by` moves the chats; a failed sign-out
is said.

`login --ca` kept the path as typed, so a service started in another directory
dialled with no CA. It is kept in full now, the link trusts what discovery got
through with (the system's bundle included), and "could not reach the instance"
says why: a CA file that is gone, or what discovery met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-25 19:48:50 +00:00
1 parent 82918ba5a8
commit a03d46cf74
7 files changed
+120 -13

No files matched your search

+19
View File
@@ -6,6 +6,25 @@ All notable changes to LLeMbas CLI. The format follows
## [Unreleased]
## [1.5.2] — 2026-10-25
Logging in again leaves one device, and a link that cannot connect says why.
### Fixed
- **Logging in again left an offline twin of this device on the instance.** A second
`lembas login` to the same instance overwrote the old token without signing it out, so the web
UI showed the device twice -- the new one linked, the old one offline and holding its agent
chats. The old token is now signed out, naming the new one, and a LLeMbas server 1.7.2 or later
moves the old device's agent chats to it. A sign-out that fails is said, with where to revoke it.
- **`lembas service` could not link when the login was given its CA as a relative path.** `login
--ca` kept the path as typed, and the service, started in another directory, did not find it and
dialled with no CA at all. The path is now kept in full, and a link to an instance whose
certificate is signed by a CA in the system's store gets through even without `--ca`, as the
rest of the CLI already did.
- **"Could not reach the instance" now says why**: a CA file given at login that is gone, a
certificate that is not trusted, or what else the connection met.
## [1.5.1] — 2026-10-23
A screenshot no longer makes the session compact.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lembas-cli",
"version": "1.5.1",
"version": "1.5.2",
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
"license": "MIT",
"private": true,
+20 -5
View File
@@ -8,6 +8,8 @@
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os"
import { resolve as resolvePath } from "node:path"
import { expandHome } from "../config/paths.ts"
import { remoteBlock } from "../config/remote.ts"
import { refusedLocally } from "../project/local.ts"
import type { Mode } from "../config/schema.ts"
@@ -143,7 +145,11 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
o.onStatus?.(s)
return s
}
const ca = instance.ca && existsSync(instance.ca) ? readFileSync(instance.ca, "utf8") : undefined
// The CA the login was given, as a full path (an older login kept what was typed: relative, or
// with ~, which a service started elsewhere does not find). One that is gone is said, not skipped.
const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
const caGone = caPath && !existsSync(caPath) ? caPath : undefined
const loginCa = caPath && !caGone ? readFileSync(caPath, "utf8") : undefined
/** A 4400 to a hello that said 2: the protocols known were stale (or discovery said more than the
* link takes) — the next dial, at once, says 1; only a 4400 to that stops the link. */
let downgraded = false
@@ -154,11 +160,20 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// What the instance speaks now: asked at every dial, since it may have been updated
// since the login; what was said last when it cannot be asked.
let protocols = instances()[instance.connection]?.protocols ?? instance.protocols
// What the socket trusts is what discovery just got through with: the login's CA, or the
// system's bundle when the instance's certificate needs it (Bun's own roots do not hold a
// private CA). Why discovery failed is why the dial will, said in the status.
let ca = loginCa
let why: string | undefined
try {
const { discovery } = await discover(instance.base_url, ca ? { ca: instance.ca } : undefined)
protocols = spoken(discovery)
const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
protocols = spoken(found.discovery)
rememberProtocols(instance.connection, protocols)
} catch {}
if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
} catch (e) {
why = (e as Error).message
}
const unreachable = [caGone && `the CA file given at login, ${caGone}, is not there`, why ?? "could not reach the instance"].filter(Boolean).join("; ")
const protocol = downgraded ? 1 : linkProtocol(protocols)
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
let opened = false
@@ -202,7 +217,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" })
// 4400: the hello's protocol is not one it speaks. Dialling again would say the same.
if (e.code === 4400) return resolve({ refused: "the instance and this CLI speak different link protocols — update one of them", final: true })
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : "could not reach the instance" })
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable })
})
ws.addEventListener("error", () => {})
})
+8 -2
View File
@@ -255,9 +255,15 @@ export async function instanceInfo(base: string, token: string, tls: Tls | undef
/** Revoke this machine's token on the instance. Best-effort: signing out locally goes ahead even
* when the instance cannot be reached, and says so. */
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined): Promise<boolean> {
/** DELETE /api/v1/token: the token revokes itself. `replacedBy`, a login again's new token: an
* instance that knows it hands the old device's agent chats to the new one first (one that does
* not ignores the body and only revokes). */
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined, replacedBy?: string): Promise<boolean> {
try {
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } }, tls)
const headers: Record<string, string> = { authorization: `Bearer ${token}` }
if (replacedBy) headers["content-type"] = "application/json"
const body = replacedBy ? JSON.stringify({ replaced_by: replacedBy }) : undefined
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers, body }, tls)
return res.ok || res.status === 401
} catch {
return false
+17 -5
View File
@@ -9,9 +9,9 @@
// `login` again refreshes them and `logout` removes exactly them. Without a login nothing here is
// read, and LLeMbas CLI runs on its hand-written config as before.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { join, resolve } from "node:path"
import { parseDocument } from "yaml"
import { globalPaths, isLocal, paths } from "../config/paths.ts"
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
@@ -390,12 +390,17 @@ function webuiCount(): number {
}
export async function login(address: string, o: LoginOptions): Promise<LoginResult> {
const { discovery, tls } = await discover(address, o.ca ? { ca: o.ca } : undefined)
// Kept as a full path: `lembas service` and Desktop read it from another directory.
const ca = o.ca ? resolve(expandHome(o.ca)) : undefined
const { discovery, tls } = await discover(address, ca ? { ca } : undefined)
const start = await startDevice(discovery, tls)
o.onCode(start, discovery)
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
const connection = connectionNameFor(discovery.base_url, discovery.connection)
// Logging in again under the same name: the token being replaced, read before its file is.
const before = ownInstances()[connection]
const replaced = before?.base_url === discovery.base_url && existsSync(ownKeyFile(connection)) ? readFileSync(ownKeyFile(connection), "utf8").trim() : ""
mkdirSync(keyDir(), { recursive: true, mode: 0o700 })
writeFileSync(ownKeyFile(connection), granted.access_token + "\n", { mode: 0o600 })
chmodSync(ownKeyFile(connection), 0o600)
@@ -403,13 +408,20 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => m.default)?.id ?? undefined
const changes: string[] = []
// The old token is signed out too: this machine holds one, the new one. Over the TLS that has
// just worked (the old login's CA may be what was wrong), naming the new token, so the instance
// moves the old device's agent chats to it instead of keeping an offline twin.
const handOver = async (old: string) => {
if (old && old !== granted.access_token && !(await signOut(discovery.base_url, old, tls, granted.access_token)))
changes.push("the previous login could not be signed out: revoke it under User → Security → Devices on the instance")
}
await handOver(replaced)
// Only logins this mode made: in local mode, the user's own login to the same instance is not
// signed out or replaced — it is not this project's.
const known = ownInstances()
for (const [name, old] of Object.entries(known))
if (name !== connection && old.base_url === discovery.base_url) {
// The old token is signed out too: this machine holds one, the new one.
if (existsSync(ownKeyFile(name))) await signOut(old.base_url, readFileSync(ownKeyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined)
if (existsSync(ownKeyFile(name))) await handOver(readFileSync(ownKeyFile(name), "utf8").trim())
changes.push(...replaceOld(name, connection, instanceDefault))
delete known[name]
}
+23
View File
@@ -425,3 +425,26 @@ test("the link says protocol 1 to an instance that lists only 1, and stops on 44
expect(three.detail).toBe("the instance and this CLI speak different link protocols — update one of them")
expect(dials).toBe(5)
})
test("a link that cannot dial says why: a CA file gone since the login, and what discovery met", async () => {
mkdirSync(paths.config, { recursive: true })
const closed = Bun.serve({ port: 0, fetch: () => new Response("no") })
const port = closed.port
closed.stop(true)
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: `http://127.0.0.1:${port}`, connection: "fake", ca: "gone/ca.crt", logged_in_at: "" } } }))
mkdirSync(join(paths.config, "lembas"), { recursive: true })
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
const stop = new AbortController()
const waits: string[] = []
await runLink({
backoffMs: [10, 20],
signal: stop.signal,
onStatus: (s) => {
if (s.state !== "waiting") return
waits.push(s.detail ?? "")
stop.abort()
},
})
expect(waits[0]).toContain(`the CA file given at login, ${join(process.cwd(), "gone/ca.crt")}, is not there`)
expect(waits[0]).not.toContain("could not reach the instance;")
})
+32
View File
@@ -22,6 +22,8 @@ interface Fake {
stop(): void
polls: number
revoked: string[]
/** What each DELETE /api/v1/token said it was replaced by. */
replacedBy: (string | undefined)[]
models: unknown[]
approveAfter: number
deny?: boolean
@@ -52,6 +54,7 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
stop: () => {},
polls: 0,
revoked: [],
replacedBy: [],
approveAfter: o.approveAfter ?? 1,
deny: o.deny,
protocol: o.protocol ?? PROTOCOL,
@@ -98,6 +101,8 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
}
if (u.pathname === "/api/v1/token" && req.method === "DELETE") {
state.revoked.push(auth ?? "")
const text = await req.text()
state.replacedBy.push(text ? (JSON.parse(text) as { replaced_by?: string }).replaced_by : undefined)
return new Response(null, { status: 204 })
}
if (!ours) return new Response("no", { status: 401 })
@@ -340,6 +345,33 @@ test("login again reads the models now", async () => {
expect(JSON.parse(readFileSync(cacheFile("example"), "utf8")).models[0].id).toBe("llama")
})
test("login again signs the old token out, naming the new one, so its chats follow", async () => {
fake = lembas()
await login(fake.url, { ...quick, onCode: () => {} })
fake.token = "lmb_newer"
fake.polls = 0
const r = await login(fake.url, { ...quick, onCode: () => {} })
expect(fake.revoked).toEqual(["Bearer lmb_secret"])
expect(fake.replacedBy).toEqual(["lmb_newer"])
expect(readFileSync(keyFile(r.connection), "utf8").trim()).toBe("lmb_newer")
expect(r.changes.join("\n")).not.toContain("could not be signed out")
})
test("a CA given as a relative path is kept as a full one", async () => {
fake = lembas()
const dir = join(paths.config, "certs")
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, "ca.crt"), "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n")
const cwd = process.cwd()
process.chdir(paths.config)
try {
const r = await login(fake.url, { ...quick, ca: "certs/ca.crt", onCode: () => {} })
expect(instances()[r.connection]!.ca).toBe(join(dir, "ca.crt"))
} finally {
process.chdir(cwd)
}
})
test("logout revokes the token and removes exactly what login wrote", async () => {
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n local:\n dialect: openai-chat\n base_url: http://x/v1\n models: { m: {} }\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "voice:\n stt:\n provider: openai\n base_url: http://stt.local/v1\n")