diff --git a/CHANGELOG.md b/CHANGELOG.md index f34cf7b..be862f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `**: 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 diff --git a/README.md b/README.md index 0105878..10c291d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/harness/acp.md b/harness/acp.md index 610be57..d44c592 100644 --- a/harness/acp.md +++ b/harness/acp.md @@ -546,6 +546,17 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`: as `/` when no `embedding:` is set, and never replaces one that is; `embedding: ` 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 ` 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 diff --git a/src/cli.ts b/src/cli.ts index 9834566..ca53ef2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 [--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 [--json] [--ca file]" + if (!values["token-stdin"]) fail(`--server goes with --token-stdin (${usage}); a sign-in by code is lembas login
`, 2) + if (!values.server) fail(usage, 2) + if (positionals.length) fail(`--token-stdin takes the address as --server (${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)) diff --git a/src/lembas/client.ts b/src/lembas/client.ts index 1a55318..64f9e33 100644 --- a/src/lembas/client.ts +++ b/src/lembas/client.ts @@ -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 { + 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 { 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 diff --git a/src/lembas/login.ts b/src/lembas/login.ts index 7c3fd43..c8120f5 100644 --- a/src/lembas/login.ts +++ b/src/lembas/login.ts @@ -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 = {}): Promise { + 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): Promise { 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] diff --git a/tests/lembas-login.test.ts b/tests/lembas-login.test.ts index 01894cf..2f4f194 100644 --- a/tests/lembas-login.test.ts +++ b/tests/lembas-login.test.ts @@ -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 }[] 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 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) +})