Files
HomerandClaude Opus 5.5 ef209f41a1 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>
2026-10-27 21:18:37 +00:00

477 lines
26 KiB
TypeScript

// ACP: the JSON-RPC peer, LLeMbas CLI as an agent (serve --stdio and the LLeMbas link
// share it), the device's own limits on remote work, the link itself against a fake LLeMbas, the
// service unit, and library: lembas.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { AcpAgent, clampMode, underRoots, type Limits } from "../src/acp/agent.ts"
import { runLink } from "../src/acp/link.ts"
import { Peer, type Transport } from "../src/acp/rpc.ts"
import { paths } from "../src/config/paths.ts"
import { setTrust } from "../src/project/root.ts"
import { unitText } from "../src/service.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
const stops: (() => void)[] = []
afterEach(() => {
fake?.stop()
for (const s of stops.splice(0)) s()
})
/** Two transports wired to each other, as a pipe would be. */
function pair(): [Transport, Transport] {
const make = () => ({ msg: (_: string) => {}, end: () => {} })
const a = make()
const b = make()
const side = (me: typeof a, other: typeof a): Transport => ({
send: (t) => queueMicrotask(() => other.msg(t)),
onMessage: (fn) => (me.msg = fn),
onClose: (fn) => (me.end = fn),
close: () => (me.end(), other.end()),
})
return [side(a, b), side(b, a)]
}
function config(url: string, extra = "") {
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${url}\n models: { f/m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: f/m\n${extra}`)
}
function project(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "ph-acp-")))
setTrust(dir, "trusted")
return dir
}
test("the peer: requests both ways, notifications, errors, and a close", async () => {
const [ta, tb] = pair()
const a = new Peer(ta)
const b = new Peer(tb)
b.handle("add", (p) => p.x + p.y)
b.handle("boom", () => {
throw new Error("no")
})
const heard: unknown[] = []
b.on("note", (p) => void heard.push(p))
expect(await a.request<number>("add", { x: 2, y: 3 })).toBe(5)
await expect(a.request("boom")).rejects.toThrow("no")
await expect(a.request("missing")).rejects.toThrow("Method not found")
a.notify("note", { n: 1 })
await Bun.sleep(5)
expect(heard).toEqual([{ n: 1 }])
a.handle("slow", () => new Promise(() => {}))
const pending = b.request("slow")
ta.close()
await expect(pending).rejects.toThrow("closed")
})
test("limits: roots, and a mode never above the device's", () => {
expect(underRoots("/srv/app/src", ["/srv/app"])).toBe(true)
expect(underRoots("/srv/application", ["/srv/app"])).toBe(false)
expect(underRoots("/etc", [])).toBe(false)
expect(clampMode("auto", "edit")).toBe("edit")
expect(clampMode("plan", "edit")).toBe("plan")
expect(clampMode("manual", "edit")).toBe("manual")
})
async function client(limits?: Limits, answer?: (p: any) => unknown, instanceConnection?: string) {
const [ta, tb] = pair()
const agentSide = new Peer(ta)
new AcpAgent(agentSide, limits, instanceConnection)
const c = new Peer(tb)
const updates: any[] = []
const events: any[] = []
c.on("session/update", (p) => void updates.push(p.update))
c.on("_lembas/event", (p) => void events.push(p.event))
c.handle("session/request_permission", (p) => answer?.(p) ?? { outcome: { outcome: "selected", optionId: "once" } })
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: true } } })
return { c, updates, events, init }
}
test("a prompt streams its reply and ends with a stop reason", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "Hello from " }), delta({ content: "the device." })] }])
config(fake.url)
const { c, updates, events, init } = await client()
expect(init.protocolVersion).toBe(1)
expect(init.agentInfo.name).toBe("LLeMbas CLI")
const s: any = await c.request("session/new", { cwd: project(), mcpServers: [] })
const r: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }] })
expect(r.stopReason).toBe("end_turn")
const text = updates.filter((u) => u.sessionUpdate === "agent_message_chunk").map((u) => u.content.text).join("")
expect(text).toBe("Hello from the device.")
// LLeMbas asked for the full events, and got them.
expect(events.some((e) => e.type === "done")).toBe(true)
})
test("an approval is asked of the client, and its answer decides", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "echo approved-run", description: "say it" }))] },
{ chunks: [delta({ content: "done" })] },
{ chunks: [toolCall(0, "t2", "bash", JSON.stringify({ command: "echo denied-run", description: "say it" }))] },
{ chunks: [delta({ content: "fine" })] },
])
config(fake.url, "mode: manual\n")
let asked = 0
const { c, updates } = await client(undefined, (p) => {
asked++
expect(p.toolCall.title).toContain("echo")
return asked === 1 ? { outcome: { outcome: "selected", optionId: "once" } } : { outcome: { outcome: "selected", optionId: "deny" } }
})
const s: any = await c.request("session/new", { cwd: project() })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "run it" }] })
const done = updates.find((u) => u.sessionUpdate === "tool_call_update" && u.toolCallId === "t1")
expect(done.status).toBe("completed")
expect(done.content[0].content.text).toContain("approved-run")
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
const refused = updates.find((u) => u.sessionUpdate === "tool_call_update" && u.toolCallId === "t2")
expect(refused.status).toBe("failed")
expect(asked).toBe(2)
})
test("the device's limits: outside the roots, untrusted, a higher mode", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project()
const limits: Limits = { roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true }
const { c, init } = await client(limits)
expect(init._meta.lembas.limits).toEqual({ roots: [root], max_mode: "edit" })
await expect(c.request("session/new", { cwd: tmpdir() })).rejects.toThrow("outside the directories")
const untrusted = join(root, "..", `ph-untrusted-${Date.now()}`)
mkdirSync(untrusted)
await expect(c.request("session/new", { cwd: untrusted })).rejects.toThrow("outside")
const s: any = await c.request("session/new", { cwd: root, _meta: { mode: "auto" } })
expect(s.modes.currentModeId).toBe("edit")
expect(s.modes.availableModes.map((m: any) => m.id).sort()).toEqual(["edit", "manual", "plan"])
await c.request("session/set_mode", { sessionId: s.sessionId, modeId: "auto" })
})
test("inside a trusted directory is trusted for remote work, a git repository in it too", async () => {
fake = fakeProvider([])
config(fake.url)
const home = project()
const repo = join(home, "Ambitious")
mkdirSync(join(repo, ".git", "objects"), { recursive: true })
writeFileSync(join(repo, ".git", "HEAD"), "ref: refs/heads/main\n")
const { c } = await client({ roots: [home], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true })
const s: any = await c.request("session/new", { cwd: repo })
expect(s.sessionId).toBeTruthy()
})
test("_lembas/directories: the trusted places, then what is in one, each said accepted or not", async () => {
fake = fakeProvider([])
config(fake.url)
const parent = realpathSync(mkdtempSync(join(tmpdir(), "ph-dirs-")))
const home = join(parent, "home")
const other = join(parent, "elsewhere")
for (const d of [join(home, "Ambitious", "src"), join(home, ".hidden"), join(home, "node_modules"), other]) mkdirSync(d, { recursive: true })
writeFileSync(join(home, "a-file.txt"), "x")
setTrust(home, "trusted")
setTrust(other, "trusted") // trusted, but not under the roots: never offered
const { c } = await client({ roots: [home], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true })
const places: any = await c.request("_lembas/directories", {})
expect(places.places).toEqual([{ path: home, name: "home", accepted: true }])
const inside: any = await c.request("_lembas/directories", { path: home })
expect(inside.accepted).toBe(true)
expect(inside.parent).toBe("")
// Directories only, no hidden ones, no node_modules.
expect(inside.entries).toEqual([{ path: join(home, "Ambitious"), name: "Ambitious", accepted: true }])
const deeper: any = await c.request("_lembas/directories", { path: join(home, "Ambitious") })
expect(deeper.parent).toBe(home)
expect(deeper.entries.map((e: any) => e.name)).toEqual(["src"])
await expect(c.request("_lembas/directories", { path: other })).rejects.toThrow("outside the directories")
await expect(c.request("_lembas/directories", { path: "relative/path" })).rejects.toThrow("absolute")
})
test("_lembas/directories: an untrusted directory is listed, and said to be refused", async () => {
fake = fakeProvider([])
config(fake.url)
const root = realpathSync(mkdtempSync(join(tmpdir(), "ph-dirs-untrusted-")))
mkdirSync(join(root, "sub"))
const { c } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
const places: any = await c.request("_lembas/directories", {})
expect(places.places[0].accepted).toBe(false)
expect(places.places[0].reason).toContain("not in a trusted project")
const inside: any = await c.request("_lembas/directories", { path: root })
expect(inside.entries[0]).toMatchObject({ name: "sub", accepted: false })
})
test("_lembas/directories is for the link only: an editor's ACP session has no roots to offer", async () => {
const { c } = await client()
await expect(c.request("_lembas/directories", {})).rejects.toThrow("LLeMbas link only")
})
test("the chat's model and effort: named by the instance, at the start and between prompts", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }])
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: { efforts: [low, medium, high] } }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: bonsai\n")
const root = project()
const { c } = await client({ roots: [root], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true }, undefined, "example")
// Not the device's own default: the model the chat was started on there.
const s: any = await c.request("session/new", { cwd: root, _meta: { mode: "auto", lembas_model: "deepseek-flash", effort: "high" } })
expect(s._meta.lembas).toMatchObject({ model: "deepseek-flash", effort: "high" })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }] })
expect(fake.requests[0].model).toBe("deepseek-flash")
expect(JSON.stringify(fake.requests[0])).toContain('"reasoning_effort":"high"')
// Changed in the web UI between two prompts.
const changed: any = await c.request("_lembas/configure", { sessionId: s.sessionId, model: "bonsai", effort: "off" })
expect(changed).toEqual({ model: "bonsai", effort: "off" })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
expect(fake.requests.at(-1).model).toBe("bonsai")
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 () => {
fake = fakeProvider([])
config(fake.url)
const root = project()
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show"])
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
expect(init._meta.lembas.capabilities).toContain("terminal")
expect(init._meta.lembas.capabilities).toContain("shell")
await expect(c.request("_lembas/terminal/open", { cwd: tmpdir() })).rejects.toThrow("outside")
let out = ""
let exited: any
c.on("_lembas/terminal/output", (p) => void (out += Buffer.from(p.data, "base64").toString()))
c.on("_lembas/terminal/exit", (p) => void (exited = p))
const t: any = await c.request("_lembas/terminal/open", { cwd: root, cols: 100, rows: 30 })
expect(t.cwd).toBe(root)
// Which shell, and whether its prompts are marked: bash, zsh and fish are, by default.
const kind = ["bash", "zsh", "fish"].includes((process.env.SHELL || "/bin/bash").split("/").pop()!) ? (process.env.SHELL || "/bin/bash").split("/").pop() : "other"
expect(t.shell).toBe(kind)
expect(t.integration).toBe(kind !== "other")
c.notify("_lembas/terminal/input", { terminalId: t.terminalId, data: Buffer.from("pwd; stty size; exit\n").toString("base64") })
for (let i = 0; i < 100 && !exited; i++) await Bun.sleep(50)
expect(out).toContain(root)
expect(out).toContain("30 100")
expect(exited.terminalId).toBe(t.terminalId)
})
test("a message sent mid-task reaches the model at its next step, as in the TUI", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "done" })] }, { chunks: [delta({ content: "and that too" })] }])
config(fake.url)
const root = project()
const { c } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
const s: any = await c.request("session/new", { cwd: root })
await expect(c.request("_lembas/steer", { sessionId: s.sessionId, text: "also this" })).rejects.toThrow("not working on anything")
const running = c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }] })
const queued: any = await c.request("_lembas/steer", { sessionId: s.sessionId, text: "also this" })
expect(queued.queued).toBe(1)
const r: any = await running
// Taken in at the next step boundary — the first one, here, or after the answer — never lost.
expect(r._meta.lembas.unsent).toBeUndefined()
expect(fake.requests.some((q: any) => JSON.stringify(q.messages).includes("also this"))).toBe(true)
})
test("with busy_input: queue, a message the task never took is handed back as unsent", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "done" })] }])
config(fake.url, "busy_input: queue\n")
const root = project()
const { c } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
const s: any = await c.request("session/new", { cwd: root })
const running = c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }] })
await c.request("_lembas/steer", { sessionId: s.sessionId, text: "later" })
const r: any = await running
expect(r._meta.lembas.unsent).toEqual(["later"])
})
test("an approval nobody answers is a denial", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "echo never", description: "x" }))] },
{ chunks: [delta({ content: "ok" })] },
])
config(fake.url, "mode: manual\n")
const root = project()
const { c, updates } = await client({ roots: [root], maxMode: "manual", approvalTimeoutMs: 50, requireTrust: true }, () => new Promise(() => {}))
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "x" }] })
const u = updates.find((x) => x.sessionUpdate === "tool_call_update" && x.toolCallId === "t1")
expect(u.status).toBe("failed")
expect(fake.requests[1].messages.at(-1).content).toContain("Nobody answered")
})
test("the link dials out with the token, says hello, is driven, and stops when refused", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "linked reply" })] }])
const root = project()
config(fake.url, `remote:\n enabled: true\n roots: [${root}]\n max_mode: edit\n`)
let hello: any
let authorization = ""
let connections = 0
let reply = ""
const server = Bun.serve({
port: 0,
fetch(req, srv) {
const base = `http://${new URL(req.url).host}`
// A LLeMbas 2.0 discovery: protocol 3, alone.
if (new URL(req.url).pathname === "/.well-known/lembas.json")
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") ?? ""
if (new URL(req.url).pathname === "/api/devices/link" && srv.upgrade(req)) return
return new Response("no", { status: 404 })
},
websocket: {
async open(ws) {
connections++
if (connections > 1) return ws.close(4401, "revoked")
const [mine, theirs] = pair()
// Bridge: the fake instance is an ACP client over this socket.
;(ws as any).data = theirs
const peer = new Peer({ send: (t) => ws.send(t), onMessage: (fn) => ((ws as any).recv = fn), onClose: () => {}, close: () => ws.close() })
void mine
peer.on("_lembas/hello", (p) => void (hello = p))
peer.on("session/update", (p) => {
if (p.update.sessionUpdate === "agent_message_chunk") reply += p.update.content.text
})
await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {} })
const s: any = await peer.request("session/new", { cwd: root })
await peer.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }] })
ws.close(1000, "done")
},
message(ws, msg) {
;(ws as any).recv?.(typeof msg === "string" ? msg : new TextDecoder().decode(msg))
},
},
})
stops.push(() => server.stop(true))
const base = `http://127.0.0.1:${server.port}`
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: base, connection: "fake", logged_in_at: "" } } }))
mkdirSync(join(paths.config, "lembas"), { recursive: true })
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
const states: string[] = []
const end = await runLink({ backoffMs: [10, 20], onStatus: (s) => states.push(s.state) })
expect(authorization).toBe("Bearer lmb_device")
expect(hello.protocol).toBe(3)
expect(hello.limits.max_mode).toBe("edit")
expect(reply).toBe("linked reply")
expect(end.state).toBe("stopped")
expect(end.detail).toContain("revoked")
expect(states).toContain("linked")
expect(states).toContain("waiting")
})
test("the service unit runs the link as the user, restarted on failure", () => {
const unit = unitText("/usr/local/bin/lembas service run")
expect(unit).toContain("ExecStart=/usr/local/bin/lembas service run")
expect(unit).toContain("Restart=on-failure")
expect(unit).toContain("WantedBy=default.target")
expect(unit).not.toContain("User=")
})
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()
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "config.yaml"), `remote:\n enabled: true\n roots: [${root}]\n`)
const hellos: number[] = []
let dials = 0
// What discovery says: a 1.x server first, a 2.0 one after.
let discovery: Record<string, unknown> = { version: "1.7.3", protocol: 1, protocols: [1, 2] }
const server = Bun.serve({
port: 0,
fetch(req, srv) {
const u = new URL(req.url)
const base = `http://${u.host}`
if (u.pathname === "/.well-known/lembas.json")
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
return new Response("no", { status: 404 })
},
websocket: {
open() {
dials++
},
message(ws, msg) {
const m = JSON.parse(String(msg))
if (m.method !== "_lembas/hello") return
hellos.push(m.params.protocol)
ws.close(4400, "protocol")
},
},
})
stops.push(() => server.stop(true))
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 })
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
// A 1.x server: stopped at discovery, nothing dialled, the words say to update the server.
const old = await runLink({ backoffMs: [10, 20] })
expect(dials).toBe(0)
expect(old.state).toBe("stopped")
expect(old.detail).toContain("runs LLeMbas 1.7.3, which speaks link protocol 1 and 2")
expect(old.detail).toContain("needs a LLeMbas 2.0 server (protocol 3). Update the server, or use LLeMbas CLI 1.x with it.")
// Discovery lists 3, but the link refuses the hello anyway: one dial, stopped, said plainly.
discovery = { version: "2.0.0", protocol: 3, protocols: [3] }
const refused = await runLink({ backoffMs: [10, 20] })
expect(hellos).toEqual([3])
expect(dials).toBe(1)
expect(refused.detail).toContain("the instance does not speak link protocol 3")
expect(refused.detail).toContain("update the server, or use LLeMbas CLI 1.x with it")
// A newer server than this CLI: the CLI is what needs updating.
discovery = { version: "3.0.0", protocol: 4, protocols: [4] }
const newer = await runLink({ backoffMs: [10, 20] })
expect(dials).toBe(1)
expect(newer.detail).toContain("Update LLeMbas CLI (lembas update).")
})
test("a link that cannot dial says why: a CA file gone since the login, and what discovery met", async () => {
mkdirSync(paths.config, { recursive: true })
const closed = Bun.serve({ port: 0, fetch: () => new Response("no") })
const port = closed.port
closed.stop(true)
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: `http://127.0.0.1:${port}`, connection: "fake", ca: "gone/ca.crt", logged_in_at: "" } } }))
mkdirSync(join(paths.config, "lembas"), { recursive: true })
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
const stop = new AbortController()
const waits: string[] = []
await runLink({
backoffMs: [10, 20],
signal: stop.signal,
onStatus: (s) => {
if (s.state !== "waiting") return
waits.push(s.detail ?? "")
stop.abort()
},
})
expect(waits[0]).toContain(`the CA file given at login, ${join(process.cwd(), "gone/ca.crt")}, is not there`)
expect(waits[0]).not.toContain("could not reach the instance;")
})