Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03d46cf74
|
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