LLeMbas CLI 1.5.2: a login again leaves one device; the link says why it cannot connect
ci / check (push) Canceled after 0s
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:
7 files changed
+120
-13
No files matched your search
+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]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user