The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
@@ -0,0 +1,435 @@
|
||||
// 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 { LinkError, 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 dialect: openai-chat\n base_url: ${url}\n models: { 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 dialect: openai-chat\n base_url: ${fake.url}\n models: { bonsai: {}, deepseek-flash: { efforts: [low, medium, high] } }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: example/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: "example/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: "example/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 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"])
|
||||
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: off unless the device says so", async () => {
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "")
|
||||
await expect(runLink()).rejects.toBeInstanceOf(LinkError)
|
||||
writeFileSync(join(paths.config, "config.yaml"), "remote:\n enabled: true\n")
|
||||
await expect(runLink()).rejects.toThrow("remote.roots is empty")
|
||||
})
|
||||
|
||||
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 discovery saying protocol 1 for older CLIs, protocols [1, 2] for this one.
|
||||
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` } } })
|
||||
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(2)
|
||||
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("the link says protocol 1 to an instance that lists only 1, and stops on 4400 instead of redialling", 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
|
||||
let lists = [1]
|
||||
let everything4400 = false
|
||||
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", 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` } } })
|
||||
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)
|
||||
// LLeMbas 2.1.0: a hello saying a protocol it does not speak is closed with 4400.
|
||||
if (m.params.protocol !== 1 || everything4400) ws.close(4400, "protocol")
|
||||
else ws.close(4401, "revoked")
|
||||
},
|
||||
},
|
||||
})
|
||||
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 })
|
||||
const one = await runLink({ backoffMs: [10, 20] })
|
||||
expect(hellos).toEqual([1])
|
||||
expect(one.detail).toContain("revoked")
|
||||
// The instance updated since: discovery lists 2 now, the next link says 2 (and the index keeps it).
|
||||
lists = [1, 2]
|
||||
const old = (await import("../src/lembas/login.ts")).instances().fake
|
||||
expect(old?.protocols).toEqual([1])
|
||||
// An instance that closes a hello of 2 with 4400 anyway (discovery said more than its link takes):
|
||||
// one dial again at once, saying 1 — here refused for the token, which stops it as usual.
|
||||
const two = await runLink({ backoffMs: [10, 20] })
|
||||
expect(hellos).toEqual([1, 2, 1])
|
||||
expect(two.state).toBe("stopped")
|
||||
expect(two.detail).toContain("revoked")
|
||||
expect(dials).toBe(3)
|
||||
expect((await import("../src/lembas/login.ts")).instances().fake?.protocols).toEqual([1, 2])
|
||||
// A 4400 to protocol 1 as well: stopped, said plainly, not dialled again.
|
||||
everything4400 = true
|
||||
const three = await runLink({ backoffMs: [10, 20] })
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user