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

+6
View File
@@ -60,6 +60,12 @@ instruction file on the way down to where it was started. Harness spec 3.0.0.
a summary the model also gets a table of the files it had read.
- **`show`**, in a session the web UI or LLeMbas Desktop opened: the model can open a file it made
— a page, a document — in your viewer beside the chat.
- **`lembas login --token-stdin --server <url>`**: sign in with a token a program already holds —
LLeMbas Desktop, from the one approval that gives it the agent's token beside its own. The token
is read from stdin only, never from an argument or the environment, and checked against the
server first: it must speak protocol 3, accept the token, and say it carries `link`. The login
is then stored exactly as a sign-in by code (an earlier one replaced and its token signed out),
and `--json` prints the same `done` (or `error`) line.
## [1.5.2] — 2026-10-25
+4
View File
@@ -27,6 +27,10 @@ lembas login https://your-server # sign in to your LLeMbas server
cd your-project && lembas # the TUI, on the server's models
```
A program that already holds an agent's token for this machine (LLeMbas Desktop does, from the
same approval as its own) hands it over on stdin, never as an argument:
`… | lembas login --token-stdin --server https://your-server [--json]`.
From 2.0 the CLI talks only to the LLeMbas server it is logged in to; the custom connections of 1.x
(`dialect:` entries in `connections.yaml`) are gone.
+11
View File
@@ -546,6 +546,17 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
as `<connection>/<served id>` when no `embedding:` is set, and never replaces one that is;
`embedding: <served id>` also finds it. Voice uses the instance's own speech endpoints, not these
entries. Any other kind is ignored.
- **`GET /api/v1/instance`** (the `models` scope) →
`{ service, version, name, connection, account: { email, name }, scope, default_model, services }`.
**`scope`** is what the token presented may do, space-separated, as its login granted it
(`models memory link`). `lembas login --token-stdin --server <url>` takes a token another
program already holds (LLeMbas Desktop: the `agent_token` of the one approval, App API) on
stdin, never from an argument or the environment, and proves it here before writing anything:
discovery must list protocol 3 (else the device login's own refusal), a 401 is a token the
instance does not accept, a 404 or an answer without `scope` an older server, and a `scope`
without `link` is refused. Then the login is stored exactly as a device login's, and `--json`
prints the device login's `done` line: `{ event: "done", fresh: true, connection, base_url,
version, models, changes }` (or `{ event: "error", message }`).
- **`GET /v1/usage`** →
`{ month: { from, tokens_in, tokens_out, cost_money: number | null, cost_by_currency: { [currency]: number } }, device: { tokens_in, tokens_out }, currency, admin, unit: "money" }`.
Counting and money, no credits (spec 3.0.0): `cost_by_currency` is the month's cost in each
+30 -3
View File
@@ -38,6 +38,9 @@ Usage:
sign in to a LLeMbas instance: its models, voice and search
(again: read them now; --default pins its default model;
--json: one JSON object per line, for a program to read)
… | lembas login --token-stdin --server <url> [--ca file] [--json]
sign in with a token a program already holds (lmb_…, read
from stdin only), checked against the server first
lembas logout [connection] sign out of one, and remove what login wrote
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
lembas service install | uninstall | status | logs [-f] | run
@@ -95,16 +98,40 @@ function fail(msg: string, code = 1): never {
}
async function loginCommand(cmd: "login" | "logout", args: string[]) {
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, logout, sync, LembasError } = await import("./lembas/login.ts")
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError } = await import("./lembas/login.ts")
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: { ca: { type: "string" }, default: { type: "boolean" }, "keep-services": { type: "boolean" }, json: { type: "boolean" } },
options: {
ca: { type: "string" },
default: { type: "boolean" },
"keep-services": { type: "boolean" },
json: { type: "boolean" },
// The token only ever from stdin: an argument or a variable would show in a process list.
"token-stdin": { type: "boolean" },
server: { type: "string" },
},
})
const known = instances()
const json = cmd === "login" && values.json
const emit = (o: object) => console.log(JSON.stringify(o))
const sameBase = (a: string, b: string) => a.replace(/^https?:\/\//, "").replace(/\/+$/, "") === b.replace(/^https?:\/\//, "").replace(/\/+$/, "")
if (cmd === "login" && (values["token-stdin"] || values.server !== undefined)) {
const usage = "usage: … | lembas login --token-stdin --server <url> [--json] [--ca file]"
if (!values["token-stdin"]) fail(`--server goes with --token-stdin (${usage}); a sign-in by code is lembas login <address>`, 2)
if (!values.server) fail(usage, 2)
if (positionals.length) fail(`--token-stdin takes the address as --server <url> (${usage})`, 2)
if (process.stdin.isTTY) fail(`--token-stdin reads the token from a pipe, never from the terminal or an argument (${usage})`, 2)
}
try {
if (cmd === "login" && values["token-stdin"]) {
const token = (await Bun.stdin.text()).slice(0, 65536)
const before = Object.values(known).find((i) => sameBase(i.base_url, values.server!))
const r = await loginWithToken(values.server!, token, { ca: values.ca ?? before?.ca, setDefault: values.default, keepServices: values["keep-services"] })
if (json) emit(doneEvent(r, true))
else console.log(loginSummary(r, true).join("\n"))
return
}
if (cmd === "logout") {
const name = positionals[0] ?? (Object.keys(known).length === 1 ? Object.keys(known)[0]! : "")
if (!name) fail(Object.keys(known).length ? `which one? lembas logout <${Object.keys(known).join("|")}>` : "not logged in to any instance", 2)
@@ -115,7 +142,7 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) {
}
let address = positionals[0] ?? ""
// `login` again with no address refreshes the one instance there is.
const again = address ? Object.values(known).find((i) => i.base_url.replace(/^https?:\/\//, "") === address.replace(/^https?:\/\//, "").replace(/\/+$/, "")) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
if (again && !values.ca && isWebuiEntry(again.connection)) {
const r = await sync(again.connection, { setDefault: values.default })
if (json) emit(doneEvent(r, false))
+32
View File
@@ -236,6 +236,34 @@ export async function awaitToken(d: Discovery, start: DeviceStart, tls: Tls | un
throw new LembasError("the code expired before it was approved — run login again")
}
/** The scopes a token carries, as the instance granted them (`models memory link`). */
export function scopesOf(scope: string | undefined): string[] {
return (scope ?? "").split(/\s+/).filter(Boolean)
}
/** `lembas login --token-stdin`: prove a token handed over by another program — GET
* /api/v1/instance with it, which says the account and what the token may do (`scope`) — and
* return it as a device login's poll would have. A token that cannot link is refused: it would
* give this machine models but no agent chats, and the program handing it over is after the
* agent. Nothing is written by this. */
export async function checkToken(d: Discovery, token: string, tls: Tls | undefined): Promise<Granted> {
const base = d.base_url
const host = new URL(base).host
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls)
const older = `${base} runs LLeMbas ${d.version}, which cannot say what a token may do: lembas login --token-stdin needs a newer LLeMbas 2.0 server. Update the server, or sign in with lembas login ${base}`
if (res.status === 401) throw new LembasError(`${host} does not accept this token: it is revoked, expired, or not one of this instance's`)
if (res.status === 403) throw new LembasError(`${host} refused this token for its models: it needs the models scope, and the account the “Use the API” permission`)
if (res.status === 404) throw new LembasError(older)
if (!res.ok) throw new LembasError(`checking the token: ${res.status} ${res.statusText}`)
const body = (await res.json().catch(() => undefined)) as (InstanceInfo & { scope?: unknown; account?: { email?: string; name?: string } }) | undefined
if (!body || typeof body !== "object" || typeof body.scope !== "string") throw new LembasError(older)
const scopes = scopesOf(body.scope)
if (!scopes.includes("link"))
throw new LembasError(`this token cannot link this machine to ${host}: it carries ${scopes.length ? scopes.join(" ") : "no scope"}, and LLeMbas CLI's needs link (models memory link). Sign in with lembas login ${base} instead`)
const account = body.account && typeof body.account === "object" ? { email: body.account.email, name: body.account.name } : {}
return { access_token: token, scope: scopes.join(" "), account, base_url: base }
}
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
@@ -249,6 +277,10 @@ export async function models(d: { api: { openai: string } }, token: string, tls:
* which of voice and web search it may use through the API. */
export interface InstanceInfo {
name?: string
/** The account the token belongs to. */
account?: { email?: string; name?: string }
/** What the token presented may do, space-separated, as its login granted it. */
scope?: string
connection?: string
version?: string
default_model?: string | null
+23 -1
View File
@@ -13,7 +13,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
import { join, resolve } from "node:path"
import { parseDocument } from "yaml"
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 { awaitToken, checkToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
export interface Instance {
@@ -397,7 +397,29 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
const start = await startDevice(discovery, tls)
o.onCode(start, discovery)
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
return store(discovery, tls, granted, o)
}
/** `lembas login --token-stdin`: what a program that already holds an agent's token — LLeMbas
* Desktop, from the one approval that gave it its own token too — hands over. Checked against the
* instance before anything is written: it must speak this CLI's protocol, accept the token, and
* say the token carries `link`. Then stored exactly as a device login's is. */
export async function loginWithToken(address: string, token: string, o: Omit<LoginOptions, "onCode" | "sleep" | "signal"> = {}): Promise<LoginResult> {
const raw = token.trim()
if (!TOKEN.test(raw)) throw new LembasError("expected one LLeMbas token (lmb_…) on stdin, and nothing else")
const ca = o.ca ? resolve(expandHome(o.ca)) : undefined
const { discovery, tls } = await discover(address, ca ? { ca } : undefined)
const granted = await checkToken(discovery, raw, tls)
return store(discovery, tls, granted, o)
}
/** A token as the instance makes them: `lmb_` and URL-safe base64. */
const TOKEN = /^lmb_[A-Za-z0-9_-]+$/
/** What both kinds of login do once they hold a token: write it, replace an earlier login to the
* same instance (signing its token out, naming this one), sign out of any other instance, and set
* up the connection, voice, search and embedding. */
async function store(discovery: Discovery, tls: Tls | undefined, granted: Granted, o: Pick<LoginOptions, "setDefault" | "keepServices">): Promise<LoginResult> {
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]
+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)
})