login --token-stdin: a token a program already holds, checked, then stored as a device login
ci / check (push) Canceled after 0s

`lembas login --token-stdin --server <url> [--ca file] [--json]` reads one lmb_ token from
stdin (never an argument or the environment), checks discovery's protocol and then
GET /api/v1/instance with the token, whose `scope` must include `link` (a 404 or no `scope`
is an older server), and stores the login through the same path as the device code:
the key file, the webui entry, lembas.json, an earlier login replaced with its token signed
out naming the new one. --json prints the device login's `done` / `error` line. For LLeMbas
Desktop, which gets the agent's token from the one approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-03 14:48:02 +00:00
1 parent 059d34cec1
commit a029ee6f07
7 files changed
+211 -6

No files matched your search

+105 -2
View File
@@ -9,7 +9,7 @@ import { parse } from "yaml"
import { loadConfig } from "../src/config/load.ts"
import { paths } from "../src/config/paths.ts"
import { normalise, PROTOCOL } from "../src/lembas/client.ts"
import { codeEvent, codeInstructions, doneEvent, instances, keyFile, login, logout, specFor, sync } from "../src/lembas/login.ts"
import { codeEvent, codeInstructions, doneEvent, instances, keyFile, login, loginWithToken, logout, specFor, sync } from "../src/lembas/login.ts"
import { cacheFile, refreshWebui } from "../src/lembas/webui.ts"
import { resolveModel } from "../src/provider/index.ts"
import { search } from "../src/search/index.ts"
@@ -33,6 +33,8 @@ interface Fake {
services: { stt?: boolean; tts?: boolean; search?: boolean; fetch?: boolean }
heard: { path: string; auth: string | null; body?: any; fields?: Record<string, string> }[]
token: string
/** What /api/v1/instance says the token may do; null: an instance that does not say. */
scope: string | null
}
let fake: Fake | undefined
@@ -62,6 +64,7 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
services: o.services ?? { stt: true, tts: true, search: true, fetch: true },
heard: [],
token: "lmb_secret",
scope: "models memory link",
models: o.models ?? [
{ id: "gemma", object: "model", context: 32768, vision: true, capacity: { group: "gpu", single_session: true } },
{ id: "qwen", object: "model", name: "Qwen", context: 131072, temperature: 0.6, top_p: 0.95, efforts: ["low", "high"], tools: true, capacity: { group: "gpu" }, default: true },
@@ -110,7 +113,7 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
if (u.pathname === "/api/v1/instance") {
if (state.old) return new Response("not found", { status: 404 })
const def = (state.models as { id: string; default?: boolean }[]).find((m) => m.default)?.id ?? null
return Response.json({ service: "lembas", version: "1.21.0", name: "Example", connection: "example", default_model: def, services: state.services })
return Response.json({ service: "lembas", version: "1.21.0", name: "Example", connection: "example", account: { email: "frodo@shire.test", name: "Frodo" }, ...(state.scope === null ? {} : { scope: state.scope }), default_model: def, services: state.services })
}
if (u.pathname === "/v1/audio/transcriptions") {
const f = await req.formData()
@@ -452,3 +455,103 @@ test("only chat models are models; the first embedding one becomes the library's
await logout("example")
expect(cfg().embedding).toBeUndefined()
})
// `lembas login --token-stdin`: a token a program already holds (Desktop, from the one approval
// that gave it the agent's token beside its own), checked first, then stored as a device login is.
test("--token-stdin stores the login exactly as a device login does, with no code", async () => {
fake = lembas()
const viaCode = await login(fake.url, { ...quick, onCode: () => {} })
const deviceFiles = { conns: conns(), cfg: cfg(), key: readFileSync(keyFile("example"), "utf8") }
const deviceDone = doneEvent(viaCode, true)
const { logged_in_at: _, ...deviceInstance } = instances().example!
rmSync(paths.config, { recursive: true, force: true })
mkdirSync(paths.config, { recursive: true })
fake.polls = 0
fake.revoked = []
const r = await loginWithToken(fake.url, "lmb_secret\n")
expect(fake.polls).toBe(0)
expect({ conns: conns(), cfg: cfg(), key: readFileSync(keyFile("example"), "utf8") }).toEqual(deviceFiles)
expect(statSync(keyFile("example")).mode & 0o777).toBe(0o600)
const { logged_in_at: __, ...instance } = instances().example!
// The same login, but the scope is the token's own (the fake's device poll says only models).
expect(instance).toEqual({ ...deviceInstance, scope: "models memory link" })
expect(instance.email).toBe("frodo@shire.test")
// The same --json line, so a program reads both kinds alike.
expect(doneEvent(r, true)).toEqual(deviceDone)
expect(JSON.stringify(doneEvent(r, true))).not.toContain("lmb_secret")
expect(fake.revoked).toEqual([])
})
test("--token-stdin over an earlier login replaces it, signing the old token out naming the new", async () => {
fake = lembas()
await login(fake.url, { ...quick, onCode: () => {} })
fake.token = "lmb_agent-From_Desktop"
const r = await loginWithToken(fake.url, "lmb_agent-From_Desktop")
expect(fake.revoked).toEqual(["Bearer lmb_secret"])
expect(fake.replacedBy).toEqual(["lmb_agent-From_Desktop"])
expect(readFileSync(keyFile(r.connection), "utf8").trim()).toBe("lmb_agent-From_Desktop")
expect(Object.keys(instances())).toEqual(["example"])
// The same token again: nothing to sign out.
await loginWithToken(fake.url, "lmb_agent-From_Desktop")
expect(fake.revoked).toEqual(["Bearer lmb_secret"])
})
test("--token-stdin refuses, writing nothing: no link, a token not accepted, an older server, not a token", async () => {
fake = lembas()
fake.scope = "app models"
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("this token cannot link this machine to")
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("it carries app models, and LLeMbas CLI's needs link")
fake.scope = ""
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("it carries no scope")
fake.scope = "models memory link"
await expect(loginWithToken(fake.url, "lmb_wrong")).rejects.toThrow("does not accept this token")
// An instance that does not say what a token may do, or has no /api/v1/instance at all.
fake.scope = null
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("needs a newer LLeMbas 2.0 server")
fake.stop()
fake = lembas({ old: true })
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("needs a newer LLeMbas 2.0 server")
fake.stop()
// Another protocol: the device login's own words.
fake = lembas({ protocol: 2 })
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("needs a LLeMbas 2.0 server (protocol 3). Update the server, or use LLeMbas CLI 1.x with it.")
fake.stop()
fake = lembas({ protocol: PROTOCOL + 1 })
await expect(loginWithToken(fake.url, "lmb_secret")).rejects.toThrow("Update LLeMbas CLI")
for (const bad of ["", "secret", "lmb_a lmb_b", "lmb_a\nlmb_b", "Bearer lmb_a"])
await expect(loginWithToken(fake.url, bad)).rejects.toThrow("expected one LLeMbas token (lmb_…) on stdin")
expect(existsSync(join(paths.config, "connections.yaml"))).toBe(false)
expect(instances()).toEqual({})
})
async function cli(args: string[], stdin: string) {
const p = Bun.spawn(["bun", join(import.meta.dir, "../src/cli.ts"), ...args], { env: { ...process.env }, stdin: new TextEncoder().encode(stdin), stdout: "pipe", stderr: "pipe" })
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
return { out, err, code }
}
test("lembas login --token-stdin --server --json: the token from stdin, the device login's done line", async () => {
fake = lembas()
const ok = await cli(["login", "--token-stdin", "--server", fake.url, "--json"], "lmb_secret\n")
expect(ok.code).toBe(0)
const lines = ok.out.trim().split("\n").map((l) => JSON.parse(l))
expect(lines).toHaveLength(1)
expect(lines[0]).toMatchObject({ event: "done", fresh: true, connection: "example", base_url: fake.url, version: "1.21.0", models: 2 })
expect(Object.keys(lines[0]).sort()).toEqual(["base_url", "changes", "connection", "event", "fresh", "models", "version"])
expect(ok.out + ok.err).not.toContain("lmb_secret")
expect(readFileSync(keyFile("example"), "utf8").trim()).toBe("lmb_secret")
// A refusal is the device login's error line, and nothing changes.
fake.scope = "models"
const no = await cli(["login", "--token-stdin", "--server", fake.url, "--json"], "lmb_secret")
expect(no.code).toBe(1)
expect(JSON.parse(no.out.trim())).toMatchObject({ event: "error" })
expect(JSON.parse(no.out.trim()).message).toContain("cannot link")
// Never from an argument: there is no --token, and the address is --server's.
expect((await cli(["login", "--token", "lmb_secret", "--server", fake.url], "")).code).not.toBe(0)
expect((await cli(["login", "--token-stdin", fake.url], "lmb_secret")).code).toBe(2)
expect((await cli(["login", "--server", fake.url], "lmb_secret")).code).toBe(2)
})