Link protocol 3; a session the client opens runs on the chat's model or not at all

- LEMBAS_PROTOCOL, the device protocol and the protocols the CLI logs in to
  and links with are 3 alone (harness/acp.md *Versions*). A server listing
  only 1 and 2 is refused at login and at every dial — before anything is
  dialled — with its version and protocols and "update the server, or use
  LLeMbas CLI 1.x with it"; a newer one is told to update the CLI
  (ProtocolError). A 4400 stops the link with the same advice. The old
  downgrade to protocol 1 is gone.
- session/new's result says `instance`: whether the session's model is the
  link's instance's.
- `lembas_model` is the model, full stop: a device not logged in to the link's
  instance, a link with no instance (serve --stdio), or a model the instance
  does not serve to this device is -32602 with the reason, never a session on
  the device's own model. `_lembas/configure` naming such a model is refused
  likewise and the session keeps its model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-27 21:18:37 +00:00
1 parent d5043ab0db
commit ef209f41a1
7 files changed
+151 -66

No files matched your search

+54 -10
View File
@@ -44,10 +44,10 @@ import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
export const ACP_VERSION = 1 export const ACP_VERSION = 1
/** The `_lembas/*` extension's version (LLeMbas `api/devices.py:PROTOCOL`). 2: every /** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
* feature beyond 1 is also named in `capabilities`, so a protocol 1 peer keeps working with * LLeMbas CLI 2.0 and LLeMbas 2.0, which speak it alone — a 1.x peer (1 and 2) and a 2.0 one refuse
* what it knows (harness/acp.md). */ * each other, each saying what to update. Within 3, every feature is still named in `capabilities`. */
export const LEMBAS_PROTOCOL = 2 export const LEMBAS_PROTOCOL = 3
export interface Limits { export interface Limits {
/** Directories a session may be opened in (and anything under them). Empty: none at all. */ /** Directories a session may be opened in (and anything under them). Empty: none at all. */
@@ -580,10 +580,25 @@ export class AcpAgent {
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a // No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
// title request would be a second call on the account's credits for nothing. // title request would be a second call on the account's credits for nothing.
// The chat's model and effort: `lembas_model` is the instance's own name for it, the // The chat's model and effort: `lembas_model` is the instance's own name for it, the
// one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. // one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. A session
const model = this.refFor(p?._meta?.lembas_model) ?? (typeof p?._meta?.model === "string" ? p._meta.model : undefined) // the client opens on its model runs on that model and no other (spec 3.0.0): one this device
// cannot speak to is refused, never answered on a model of the device's own.
const served = p?._meta?.lembas_model
const model = served !== undefined ? this.chatModel(served) : typeof p?._meta?.model === "string" ? p._meta.model : undefined
const effort = effortOf(p?._meta?.effort) const effort = effortOf(p?._meta?.effort)
session.app = createApp({ cwd, mode, asker: this.asker(session), modelTitles: false, model, ...(effort !== undefined ? { effort } : {}) }) try {
session.app = createApp({ cwd, mode, asker: this.asker(session), modelTitles: false, model, ...(effort !== undefined ? { effort } : {}) })
} catch (e) {
throw new RpcError(INVALID_PARAMS, (e as Error).message)
}
if (served !== undefined) {
const why = this.notServed(session.app, model!)
if (why) {
session.app.discardIfEmpty()
void session.app.close()
throw new RpcError(INVALID_PARAMS, why)
}
}
// The store's id: the same session the terminals here list and can open. // The store's id: the same session the terminals here list and can open.
const id = session.app.engine.sessionId ?? crypto.randomUUID() const id = session.app.engine.sessionId ?? crypto.randomUUID()
session.id = id session.id = id
@@ -600,10 +615,33 @@ export class AcpAgent {
return { return {
sessionId: id, sessionId: id,
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) }, modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off" } }, // `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says.
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off", instance: this.isInstance(session.app.engine.model.connectionName) } },
} }
} }
/** The ref of the model the instance names (`lembas_model`), or the reason it cannot be run here:
* thrown as -32602 (harness/acp.md, *The chat's model, full stop*). */
private chatModel(served: unknown): string {
if (typeof served !== "string" || !served.trim()) throw new RpcError(INVALID_PARAMS, "lembas_model must be the instance's name of a model")
const ref = this.refFor(served)
if (!ref) throw new RpcError(INVALID_PARAMS, `${served.trim()} is the instance's model, and this device is not linked to an instance: it cannot run it`)
return ref
}
/** Why the instance's model cannot run in `app`, or undefined when it can: the device is not
* logged in to this link's instance, or that instance does not serve the model to it. */
private notServed(app: App, ref: string): string | undefined {
const name = this.instanceConnection!
const conn = app.loaded.connections[name]
const served = ref.slice(name.length + 1)
if (!conn) return `this device is not logged in to ${name}, the instance this link is to, so it cannot run its model ${served} (lembas login there)`
const m = app.engine.model
if (m.connectionName !== name) return `${served} is not a model of ${name} on this device`
if (Object.keys(conn.models).length && !conn.models[m.id]) return `${conn.webui?.name ?? name} does not serve ${served} to this device (it serves: ${Object.keys(conn.models).slice(0, 12).join(", ")})`
return undefined
}
/** Whether a model spoken to through `connection` is this link's instance's. */ /** Whether a model spoken to through `connection` is this link's instance's. */
isInstance(connection: string | undefined): boolean { isInstance(connection: string | undefined): boolean {
return Boolean(connection && this.instanceConnection && connection === this.instanceConnection) return Boolean(connection && this.instanceConnection && connection === this.instanceConnection)
@@ -624,8 +662,14 @@ export class AcpAgent {
const t = this.terminalFor(p) const t = this.terminalFor(p)
if (t) return t.client.request("hub/configure", { sessionId: t.sessionId, model: this.refFor(p?.model), effort: p?.effort }, 30_000) if (t) return t.client.request("hub/configure", { sessionId: t.sessionId, model: this.refFor(p?.model), effort: p?.effort }, 30_000)
const s = this.get(p) const s = this.get(p)
const ref = this.refFor(p?.model) const ref = p?.model !== undefined ? this.chatModel(p.model) : undefined
if (ref && s.app.canonicalRef(ref) !== s.app.engine.model.ref) s.app.switchModel(ref) if (ref && s.app.canonicalRef(ref) !== s.app.engine.model.ref) {
const conn = s.app.loaded.connections[this.instanceConnection!]
const id = s.app.canonicalRef(ref)
if (!conn) throw new RpcError(INVALID_PARAMS, `this device is not logged in to ${this.instanceConnection}, so it cannot run ${p.model}`)
if (Object.keys(conn.models).length && !conn.models[id]) throw new RpcError(INVALID_PARAMS, `${conn.webui?.name ?? this.instanceConnection} does not serve ${p.model} to this device`)
s.app.switchModel(ref)
}
const effort = effortOf(p?.effort) const effort = effortOf(p?.effort)
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" } return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" }
+10 -11
View File
@@ -15,7 +15,7 @@ import { refusedLocally } from "../project/local.ts"
import type { Mode } from "../config/schema.ts" import type { Mode } from "../config/schema.ts"
import { HARNESS_VERSION } from "../harness.ts" import { HARNESS_VERSION } from "../harness.ts"
import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts" import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts"
import { discover, linkProtocol, spoken } from "../lembas/client.ts" import { discover, linkProtocol, ProtocolError, spoken } from "../lembas/client.ts"
import { VERSION } from "../version.ts" import { VERSION } from "../version.ts"
import { AcpAgent, type Limits } from "./agent.ts" import { AcpAgent, type Limits } from "./agent.ts"
import type { Hub } from "./hub.ts" import type { Hub } from "./hub.ts"
@@ -150,9 +150,6 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
const caGone = caPath && !existsSync(caPath) ? caPath : undefined const caGone = caPath && !existsSync(caPath) ? caPath : undefined
const loginCa = caPath && !caGone ? readFileSync(caPath, "utf8") : 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
while (!o.signal?.aborted) { while (!o.signal?.aborted) {
attempts++ attempts++
@@ -165,6 +162,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// private CA). Why discovery failed is why the dial will, said in the status. // private CA). Why discovery failed is why the dial will, said in the status.
let ca = loginCa let ca = loginCa
let why: string | undefined let why: string | undefined
let older: string | undefined
try { try {
const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined) const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
protocols = spoken(found.discovery) protocols = spoken(found.discovery)
@@ -172,9 +170,15 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8") if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
} catch (e) { } catch (e) {
why = (e as Error).message why = (e as Error).message
// A server that answers and speaks another protocol: no dial would get further.
if (e instanceof ProtocolError) older = why
}
if (older) {
clearInterval(freshen)
return status("stopped", older)
} }
const unreachable = [caGone && `the CA file given at login, ${caGone}, is not there`, why ?? "could not reach the instance"].filter(Boolean).join("; ") 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 protocol = linkProtocol(protocols)
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => { const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
let opened = false let opened = false
let changed = false let changed = false
@@ -216,7 +220,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// 4401/4403: the instance's word that this token is done; 1008 policy, likewise. // 4401/4403: the instance's word that this token is done; 1008 policy, likewise.
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" }) 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. // 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 }) if (e.code === 4400) return resolve({ refused: `the instance does not speak link protocol ${protocol}, which this LLeMbas CLI ${VERSION} speaks: it needs a LLeMbas 2.0 server — update the server, or use LLeMbas CLI 1.x with it${e.reason ? ` (${e.reason})` : ""}`, final: true })
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable }) resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable })
}) })
ws.addEventListener("error", () => {}) ws.addEventListener("error", () => {})
@@ -227,11 +231,6 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
wait = first wait = first
continue continue
} }
if (outcome.final && protocol === 2 && !downgraded) {
downgraded = true
status("waiting", "the instance does not take link protocol 2; trying again with 1")
continue
}
if (outcome.refused) { if (outcome.refused) {
clearInterval(freshen) clearInterval(freshen)
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`) return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
+22 -10
View File
@@ -9,14 +9,26 @@ import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os" import { hostname } from "node:os"
import { VERSION } from "../version.ts" import { VERSION } from "../version.ts"
/** The device protocol this build speaks (LLeMbas `api/devices.py:PROTOCOL`): 2, the /** The device protocol this build speaks (LLeMbas `services/device_link.py:PROTOCOL`): 3, the
* same number as the link's `_lembas/*` extension (acp/agent.ts LEMBAS_PROTOCOL). */ * same number as the link's `_lembas/*` extension (acp/agent.ts LEMBAS_PROTOCOL). */
export const PROTOCOL = 2 export const PROTOCOL = 3
/** The ones it can log in to: protocol 2 only adds what both sides name in their capabilities, so an /** The ones it can log in to and link with: 3 alone. LLeMbas CLI 2.0 needs a LLeMbas 2.0 server
* instance still at 1 works with what it has. */ * (its tree, its memory, its models only); a 1.x server speaks 1 and 2, and is refused with a word
export const PROTOCOLS = [1, 2] * on what to update (harness/acp.md *Versions*). */
export const PROTOCOLS = [3]
/** What this CLI says to a server that does not speak its protocol — at login, and at a dial: a
* 1.x server (1 and 2) needs updating, a newer one needs a newer CLI. */
export function protocolMismatch(base: string, d: Pick<Discovery, "protocol" | "protocols" | "version">): string {
const theirs = Array.isArray(d.protocols) && d.protocols.length ? d.protocols : [d.protocol]
const said = `${base} runs LLeMbas ${d.version ?? "(an unknown version)"}, which speaks link protocol ${theirs.join(" and ")}`
if (theirs.every((p) => p > PROTOCOL)) return `${said}; this LLeMbas CLI ${VERSION} speaks ${PROTOCOL}. Update LLeMbas CLI (lembas update).`
return `${said}; this LLeMbas CLI ${VERSION} needs a LLeMbas 2.0 server (protocol ${PROTOCOL}). Update the server, or use LLeMbas CLI 1.x with it.`
}
export class LembasError extends Error {} export class LembasError extends Error {}
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
export class ProtocolError extends LembasError {}
export interface Discovery { export interface Discovery {
service: "lembas" service: "lembas"
@@ -116,10 +128,10 @@ export function spoken(d: Pick<Discovery, "protocol" | "protocols">): number[] {
return PROTOCOLS.filter((p) => theirs.includes(p)) return PROTOCOLS.filter((p) => theirs.includes(p))
} }
/** The `_lembas` protocol to say in the link's hello: 2 where the instance lists it, else /** The `_lembas` protocol to say in the link's hello: 3, the only one this build speaks. An
* 1 — an instance that speaks only 1 closes a link that says 2 (4400). Features stay gated by capabilities. */ * instance that does not take it closes the link with 4400. Features stay gated by capabilities. */
export function linkProtocol(protocols: number[] | undefined): number { export function linkProtocol(_protocols?: number[]): number {
return protocols?.includes(2) ? 2 : 1 return PROTOCOL
} }
export async function discover(address: string, tls?: Tls): Promise<{ discovery: Discovery; tls?: Tls }> { export async function discover(address: string, tls?: Tls): Promise<{ discovery: Discovery; tls?: Tls }> {
@@ -144,7 +156,7 @@ export async function discover(address: string, tls?: Tls): Promise<{ discovery:
throw new LembasError(`${base} answered /.well-known/lembas.json with something that is not JSON`) throw new LembasError(`${base} answered /.well-known/lembas.json with something that is not JSON`)
} }
if (body?.service !== "lembas") throw new LembasError(`${base} is not a LLeMbas instance`) if (body?.service !== "lembas") throw new LembasError(`${base} is not a LLeMbas instance`)
if (!spoken(body).length) throw new LembasError(`${base} speaks device protocol ${body.protocol}; this LLeMbas CLI speaks ${PROTOCOLS.join(" and ")}. Update the older of the two.`) if (!spoken(body).length) throw new ProtocolError(protocolMismatch(base, body))
if (!body.login?.device?.code) throw new LembasError(`${base} offers no device login`) if (!body.login?.device?.code) throw new LembasError(`${base} offers no device login`)
return { discovery: body, tls: used } return { discovery: body, tls: used }
} }
+2 -2
View File
@@ -82,12 +82,12 @@ async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number;
// §0 // §0
test("initialize: protocol 2, the new capabilities, images taken, the login's connection named", async () => { test("initialize: protocol 3, the capabilities, images taken, the login's connection named", async () => {
fake = fakeProvider([]) fake = fakeProvider([])
config(fake.url) config(fake.url)
const root = project() const root = project()
const { init } = await client(limitsFor(root, { terminal: true }), { instance: "example" }) const { init } = await client(limitsFor(root, { terminal: true }), { instance: "example" })
expect(init._meta.lembas.protocol).toBe(2) expect(init._meta.lembas.protocol).toBe(3)
for (const cap of ["files", "commands", "attachments", "ask", "plan", "model", "turns", "terminal", "shell"]) expect(init._meta.lembas.capabilities).toContain(cap) for (const cap of ["files", "commands", "attachments", "ask", "plan", "model", "turns", "terminal", "shell"]) expect(init._meta.lembas.capabilities).toContain(cap)
expect(init.agentCapabilities.promptCapabilities).toEqual({ image: true, audio: false, embeddedContext: true }) expect(init.agentCapabilities.promptCapabilities).toEqual({ image: true, audio: false, embeddedContext: true })
expect(init._meta.lembas.connection).toBe("example") expect(init._meta.lembas.connection).toBe("example")
+57 -31
View File
@@ -228,6 +228,37 @@ test("the chat's model and effort: named by the instance, at the start and betwe
expect(JSON.stringify(fake.requests.at(-1))).not.toContain("reasoning_effort") expect(JSON.stringify(fake.requests.at(-1))).not.toContain("reasoning_effort")
}) })
test("the chat's model, full stop (spec 3.0.0): `instance` on session/new; a model the device cannot run is -32602, never its own", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n example:\n type: webui\n url: ${fake.url}\n models: { bonsai: {}, deepseek-flash: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: bonsai\n")
const root = project()
const limits = { roots: [root], maxMode: "auto" as const, approvalTimeoutMs: 50, requireTrust: true }
const refusal = async (p: Promise<unknown>) => p.then(() => undefined, (e: any) => ({ code: e.code, message: String(e.message) }))
const { c } = await client(limits, undefined, "example")
const s: any = await c.request("session/new", { cwd: root, _meta: { lembas_model: "deepseek-flash" } })
expect(s._meta.lembas).toMatchObject({ model: "deepseek-flash", instance: true })
// Not served to this device: refused, not run on bonsai.
const missing = await refusal(c.request("session/new", { cwd: root, _meta: { lembas_model: "gpt-9" } }))
expect(missing).toMatchObject({ code: -32602 })
expect(missing!.message).toContain("does not serve gpt-9 to this device")
// Changing to one it cannot run: refused, and the session keeps its model.
expect(await refusal(c.request("_lembas/configure", { sessionId: s.sessionId, model: "gpt-9" }))).toMatchObject({ code: -32602 })
expect(await c.request<any>("_lembas/configure", { sessionId: s.sessionId })).toMatchObject({ model: "deepseek-flash" })
// A link to an instance this device is not logged in to.
const other = await client(limits, undefined, "elsewhere")
const notLoggedIn = await refusal(other.c.request("session/new", { cwd: root, _meta: { lembas_model: "bonsai" } }))
expect(notLoggedIn).toMatchObject({ code: -32602 })
expect(notLoggedIn!.message).toContain("not logged in to elsewhere")
// An editor on serve --stdio (no instance): the instance's model cannot be meant.
const editor = await client(limits)
expect(await refusal(editor.c.request("session/new", { cwd: root, _meta: { lembas_model: "bonsai" } }))).toMatchObject({ code: -32602 })
// Without lembas_model, the device's own default — and `instance` says whose it is.
const own: any = await editor.c.request("session/new", { cwd: root })
expect(own._meta.lembas).toMatchObject({ model: "bonsai", instance: false })
})
test("the terminal: a real shell through the link, only where remote.terminal says so", async () => { test("the terminal: a real shell through the link, only where remote.terminal says so", async () => {
fake = fakeProvider([]) fake = fakeProvider([])
config(fake.url) config(fake.url)
@@ -312,9 +343,9 @@ test("the link dials out with the token, says hello, is driven, and stops when r
port: 0, port: 0,
fetch(req, srv) { fetch(req, srv) {
const base = `http://${new URL(req.url).host}` const base = `http://${new URL(req.url).host}`
// A discovery saying protocol 1 for older CLIs, protocols [1, 2] for this one. // A LLeMbas 2.0 discovery: protocol 3, alone.
if (new URL(req.url).pathname === "/.well-known/lembas.json") if (new URL(req.url).pathname === "/.well-known/lembas.json")
return Response.json({ service: "lembas", version: "2.2.0", protocol: 1, protocols: [1, 2], harness_spec: "2.1.0", base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } }) return Response.json({ service: "lembas", version: "2.0.0", protocol: 3, protocols: [3], harness_spec: "3.0.0", base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } })
authorization = req.headers.get("authorization") ?? "" authorization = req.headers.get("authorization") ?? ""
if (new URL(req.url).pathname === "/api/devices/link" && srv.upgrade(req)) return if (new URL(req.url).pathname === "/api/devices/link" && srv.upgrade(req)) return
return new Response("no", { status: 404 }) return new Response("no", { status: 404 })
@@ -350,7 +381,7 @@ test("the link dials out with the token, says hello, is driven, and stops when r
const states: string[] = [] const states: string[] = []
const end = await runLink({ backoffMs: [10, 20], onStatus: (s) => states.push(s.state) }) const end = await runLink({ backoffMs: [10, 20], onStatus: (s) => states.push(s.state) })
expect(authorization).toBe("Bearer lmb_device") expect(authorization).toBe("Bearer lmb_device")
expect(hello.protocol).toBe(2) expect(hello.protocol).toBe(3)
expect(hello.limits.max_mode).toBe("edit") expect(hello.limits.max_mode).toBe("edit")
expect(reply).toBe("linked reply") expect(reply).toBe("linked reply")
expect(end.state).toBe("stopped") expect(end.state).toBe("stopped")
@@ -367,21 +398,21 @@ test("the service unit runs the link as the user, restarted on failure", () => {
expect(unit).not.toContain("User=") expect(unit).not.toContain("User=")
}) })
test("the link says protocol 1 to an instance that lists only 1, and stops on 4400 instead of redialling", async () => { test("link protocol 3: a 1.x server is refused before any dial; a 4400 stops the link, saying what to update", async () => {
const root = project() const root = project()
mkdirSync(paths.config, { recursive: true }) mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "config.yaml"), `remote:\n enabled: true\n roots: [${root}]\n`) writeFileSync(join(paths.config, "config.yaml"), `remote:\n enabled: true\n roots: [${root}]\n`)
const hellos: number[] = [] const hellos: number[] = []
let dials = 0 let dials = 0
let lists = [1] // What discovery says: a 1.x server first, a 2.0 one after.
let everything4400 = false let discovery: Record<string, unknown> = { version: "1.7.3", protocol: 1, protocols: [1, 2] }
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: 0,
fetch(req, srv) { fetch(req, srv) {
const u = new URL(req.url) const u = new URL(req.url)
const base = `http://${u.host}` const base = `http://${u.host}`
if (u.pathname === "/.well-known/lembas.json") if (u.pathname === "/.well-known/lembas.json")
return Response.json({ service: "lembas", version: "2.1.0", protocol: 1, ...(lists.length > 1 ? { protocols: lists } : {}), harness_spec: "2.0.1", base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } }) return Response.json({ service: "lembas", harness_spec: "3.0.0", ...discovery, base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } })
if (u.pathname === "/api/devices/link" && srv.upgrade(req)) return if (u.pathname === "/api/devices/link" && srv.upgrade(req)) return
return new Response("no", { status: 404 }) return new Response("no", { status: 404 })
}, },
@@ -393,9 +424,7 @@ test("the link says protocol 1 to an instance that lists only 1, and stops on 44
const m = JSON.parse(String(msg)) const m = JSON.parse(String(msg))
if (m.method !== "_lembas/hello") return if (m.method !== "_lembas/hello") return
hellos.push(m.params.protocol) hellos.push(m.params.protocol)
// LLeMbas 2.1.0: a hello saying a protocol it does not speak is closed with 4400. ws.close(4400, "protocol")
if (m.params.protocol !== 1 || everything4400) ws.close(4400, "protocol")
else ws.close(4401, "revoked")
}, },
}, },
}) })
@@ -403,27 +432,24 @@ test("the link says protocol 1 to an instance that lists only 1, and stops on 44
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: `http://127.0.0.1:${server.port}`, connection: "fake", logged_in_at: "" } } })) writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: `http://127.0.0.1:${server.port}`, connection: "fake", logged_in_at: "" } } }))
mkdirSync(join(paths.config, "lembas"), { recursive: true }) mkdirSync(join(paths.config, "lembas"), { recursive: true })
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 }) writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
const one = await runLink({ backoffMs: [10, 20] }) // A 1.x server: stopped at discovery, nothing dialled, the words say to update the server.
expect(hellos).toEqual([1]) const old = await runLink({ backoffMs: [10, 20] })
expect(one.detail).toContain("revoked") expect(dials).toBe(0)
// The instance updated since: discovery lists 2 now, the next link says 2 (and the index keeps it). expect(old.state).toBe("stopped")
lists = [1, 2] expect(old.detail).toContain("runs LLeMbas 1.7.3, which speaks link protocol 1 and 2")
const old = (await import("../src/lembas/login.ts")).instances().fake expect(old.detail).toContain("needs a LLeMbas 2.0 server (protocol 3). Update the server, or use LLeMbas CLI 1.x with it.")
expect(old?.protocols).toEqual([1]) // Discovery lists 3, but the link refuses the hello anyway: one dial, stopped, said plainly.
// An instance that closes a hello of 2 with 4400 anyway (discovery said more than its link takes): discovery = { version: "2.0.0", protocol: 3, protocols: [3] }
// one dial again at once, saying 1 — here refused for the token, which stops it as usual. const refused = await runLink({ backoffMs: [10, 20] })
const two = await runLink({ backoffMs: [10, 20] }) expect(hellos).toEqual([3])
expect(hellos).toEqual([1, 2, 1]) expect(dials).toBe(1)
expect(two.state).toBe("stopped") expect(refused.detail).toContain("the instance does not speak link protocol 3")
expect(two.detail).toContain("revoked") expect(refused.detail).toContain("update the server, or use LLeMbas CLI 1.x with it")
expect(dials).toBe(3) // A newer server than this CLI: the CLI is what needs updating.
expect((await import("../src/lembas/login.ts")).instances().fake?.protocols).toEqual([1, 2]) discovery = { version: "3.0.0", protocol: 4, protocols: [4] }
// A 4400 to protocol 1 as well: stopped, said plainly, not dialled again. const newer = await runLink({ backoffMs: [10, 20] })
everything4400 = true expect(dials).toBe(1)
const three = await runLink({ backoffMs: [10, 20] }) expect(newer.detail).toContain("Update LLeMbas CLI (lembas update).")
expect(hellos).toEqual([1, 2, 1, 2, 1])
expect(three.detail).toBe("the instance and this CLI speak different link protocols — update one of them")
expect(dials).toBe(5)
}) })
test("a link that cannot dial says why: a CA file gone since the login, and what discovery met", async () => { test("a link that cannot dial says why: a CA file gone since the login, and what discovery met", async () => {
+5 -1
View File
@@ -396,8 +396,12 @@ test("a denial, another service, and another protocol are each said plainly", as
await expect(login(fake.url, { ...quick, onCode: () => {} })).rejects.toThrow("denied") await expect(login(fake.url, { ...quick, onCode: () => {} })).rejects.toThrow("denied")
fake.stop() fake.stop()
// A newer server: the CLI needs updating; a 1.x server (1 and 2): the server does (2.0 speaks 3 alone).
fake = lembas({ protocol: PROTOCOL + 1 }) fake = lembas({ protocol: PROTOCOL + 1 })
await expect(login(fake.url, { ...quick, onCode: () => {} })).rejects.toThrow("device protocol") await expect(login(fake.url, { ...quick, onCode: () => {} })).rejects.toThrow("Update LLeMbas CLI")
fake.stop()
fake = lembas({ protocol: 2 })
await expect(login(fake.url, { ...quick, onCode: () => {} })).rejects.toThrow("needs a LLeMbas 2.0 server (protocol 3). Update the server, or use LLeMbas CLI 1.x with it.")
fake.stop() fake.stop()
const other = Bun.serve({ port: 0, fetch: () => new Response("hello", { status: 404 }) }) const other = Bun.serve({ port: 0, fetch: () => new Response("hello", { status: 404 }) })
+1 -1
View File
@@ -164,7 +164,7 @@ test("the service links with remote work off, says no roots and refuses everythi
const u = new URL(req.url) const u = new URL(req.url)
const base = `http://${u.host}` const base = `http://${u.host}`
if (u.pathname === "/.well-known/lembas.json") if (u.pathname === "/.well-known/lembas.json")
return Response.json({ service: "lembas", version: "2.2.0", protocol: 1, protocols: [1, 2], harness_spec: "2.1.0", base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } }) return Response.json({ service: "lembas", version: "2.0.0", protocol: 3, protocols: [3], harness_spec: "3.0.0", base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } })
if (u.pathname === "/api/devices/link" && srv.upgrade(req)) return if (u.pathname === "/api/devices/link" && srv.upgrade(req)) return
return new Response("no", { status: 404 }) return new Response("no", { status: 404 })
}, },