LLeMbas CLI 1.5.2: a login again leaves one device; the link says why it cannot connect
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:
7 files changed
+120
-13
No files matched your search
@@ -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
@@ -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
@@ -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", () => {})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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;")
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user