login --token-stdin: a token a program already holds, checked, then stored as a device login
ci / check (push) Canceled after 0s
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:
7 files changed
+211
-6
No files matched your search
+30
-3
@@ -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))
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user