diff --git a/CHANGELOG.md b/CHANGELOG.md index 80db1ab..af693a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index a549495..9841fe8 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/src/acp/link.ts b/src/acp/link.ts index 2b52aaf..8bf67a5 100644 --- a/src/acp/link.ts +++ b/src/acp/link.ts @@ -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 { 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 { // 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 { 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", () => {}) }) diff --git a/src/lembas/client.ts b/src/lembas/client.ts index ba36dc7..a18bd2e 100644 --- a/src/lembas/client.ts +++ b/src/lembas/client.ts @@ -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 { +/** 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 { try { - const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } }, tls) + const headers: Record = { 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 diff --git a/src/lembas/login.ts b/src/lembas/login.ts index 1fdb8a7..4648a0b 100644 --- a/src/lembas/login.ts +++ b/src/lembas/login.ts @@ -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 { - 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 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] } diff --git a/tests/acp.test.ts b/tests/acp.test.ts index de0514b..4ba5d93 100644 --- a/tests/acp.test.ts +++ b/tests/acp.test.ts @@ -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;") +}) diff --git a/tests/lembas-login.test.ts b/tests/lembas-login.test.ts index 54b1efc..10d8c96 100644 --- a/tests/lembas-login.test.ts +++ b/tests/lembas-login.test.ts @@ -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 {}, polls: 0, revoked: [], + replacedBy: [], approveAfter: o.approveAfter ?? 1, deny: o.deny, protocol: o.protocol ?? PROTOCOL, @@ -98,6 +101,8 @@ function lembas(o: Partial { 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")