- 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>
229 lines
9.8 KiB
TypeScript
229 lines
9.8 KiB
TypeScript
// `lembas remote`: the remote: block managed without editing YAML, and a running link that follows it.
|
|
import { afterEach, expect, test } from "bun:test"
|
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import { refusalFor } from "../src/acp/agent.ts"
|
|
import { limitsFromConfig, runLink, type LinkStatus } from "../src/acp/link.ts"
|
|
import { paths } from "../src/config/paths.ts"
|
|
import { remoteCommand } from "../src/remote.ts"
|
|
import { setTrust } from "../src/project/root.ts"
|
|
|
|
const stops: (() => void)[] = []
|
|
afterEach(() => {
|
|
for (const s of stops.splice(0)) s()
|
|
})
|
|
|
|
const file = () => join(paths.config, "config.yaml")
|
|
|
|
function fresh(text = "") {
|
|
mkdirSync(paths.config, { recursive: true })
|
|
writeFileSync(file(), text)
|
|
}
|
|
|
|
function dir(): string {
|
|
return realpathSync(mkdtempSync(join(tmpdir(), "lembas-remote-")))
|
|
}
|
|
|
|
function run(...args: string[]) {
|
|
const out: string[] = []
|
|
const err: string[] = []
|
|
const code = remoteCommand(args, { out: (l) => void out.push(l), err: (l) => void err.push(l) })
|
|
return { code, out, err, json: () => JSON.parse(out.join("\n")) }
|
|
}
|
|
|
|
test("list --json: one object, the defaults applied", () => {
|
|
fresh()
|
|
const r = run("list", "--json")
|
|
expect(r.code).toBe(0)
|
|
expect(r.out).toHaveLength(1)
|
|
expect(r.json()).toEqual({ enabled: false, roots: [], max_mode: "edit", terminal: false })
|
|
// Without --json, lines a person reads.
|
|
const plain = run("list")
|
|
expect(plain.out.join("\n")).toContain("remote work: off")
|
|
expect(plain.out.join("\n")).toContain("max mode: edit")
|
|
})
|
|
|
|
test("add: absolute real paths, turns remote work on, skips duplicates and covered dirs", () => {
|
|
fresh()
|
|
const a = dir()
|
|
const b = dir()
|
|
mkdirSync(join(a, "sub"))
|
|
const link = join(dir(), "link")
|
|
symlinkSync(b, link)
|
|
const r = run("add", a, link, "--json")
|
|
expect(r.code).toBe(0)
|
|
// The link is stored as where it leads.
|
|
expect(r.json()).toEqual({ enabled: true, roots: [a, b], max_mode: "edit", terminal: false })
|
|
// Again, and a directory inside one: nothing more.
|
|
const again = run("add", a, join(a, "sub"))
|
|
expect(again.code).toBe(0)
|
|
expect(again.out.join("\n")).toContain("already a root")
|
|
expect(again.out.join("\n")).toContain(`already covered by ${a}`)
|
|
expect(run("list", "--json").json().roots).toEqual([a, b])
|
|
// Said in words when it turned remote work on; not again.
|
|
fresh()
|
|
expect(run("add", a).out.join("\n")).toContain("remote work is now on")
|
|
expect(run("add", b).out.join("\n")).not.toContain("remote work is now on")
|
|
})
|
|
|
|
test("add: a missing directory or a file refuses the whole command, writing nothing", () => {
|
|
fresh("remote:\n roots: []\n")
|
|
const a = dir()
|
|
writeFileSync(join(a, "f"), "")
|
|
const r = run("add", a, join(a, "missing"), join(a, "f"), "--json")
|
|
expect(r.code).toBe(1)
|
|
expect(r.out).toEqual([])
|
|
expect(r.err.join("\n")).toContain("no such directory")
|
|
expect(r.err.join("\n")).toContain("not a directory")
|
|
expect(readFileSync(file(), "utf8")).toBe("remote:\n roots: []\n")
|
|
})
|
|
|
|
test("remove: compared after resolving; an unknown one is an error and changes nothing", () => {
|
|
const a = dir()
|
|
const b = dir()
|
|
fresh(`remote:\n enabled: true\n roots: [${a}, ${b}]\n`)
|
|
const link = join(dir(), "to-a")
|
|
symlinkSync(a, link)
|
|
const r = run("remove", link, "--json")
|
|
expect(r.code).toBe(0)
|
|
expect(r.json().roots).toEqual([b])
|
|
const bad = run("remove", b, "/nowhere/at/all")
|
|
expect(bad.code).toBe(1)
|
|
expect(bad.err.join("\n")).toContain("not one of the remote roots")
|
|
expect(run("list", "--json").json().roots).toEqual([b])
|
|
// A root whose directory is gone can still be removed.
|
|
fresh("remote:\n roots: [/gone/long/ago]\n")
|
|
expect(run("remove", "/gone/long/ago", "--json").json().roots).toEqual([])
|
|
})
|
|
|
|
test("enable, disable, mode and terminal; usage errors", () => {
|
|
fresh()
|
|
expect(run("enable", "--json").json().enabled).toBe(true)
|
|
expect(run("disable", "--json").json().enabled).toBe(false)
|
|
expect(run("mode", "plan", "--json").json().max_mode).toBe("plan")
|
|
expect(run("terminal", "on", "--json").json().terminal).toBe(true)
|
|
expect(run("terminal", "off", "--json").json().terminal).toBe(false)
|
|
expect(run("mode", "yolo").code).toBe(2)
|
|
expect(run("terminal", "maybe").code).toBe(2)
|
|
expect(run("frobnicate").code).toBe(2)
|
|
})
|
|
|
|
test("the file's comments and layout are kept", () => {
|
|
const a = dir()
|
|
fresh("# mine\ntheme: dark # the dark one\nremote:\n # where remote work may happen\n max_mode: manual\n")
|
|
run("add", a)
|
|
run("terminal", "on")
|
|
const text = readFileSync(file(), "utf8")
|
|
expect(text).toContain("# mine")
|
|
expect(text).toContain("theme: dark # the dark one")
|
|
expect(text).toContain("# where remote work may happen")
|
|
expect(text).toContain("max_mode: manual")
|
|
expect(text).toContain(a)
|
|
})
|
|
|
|
test("the limits a running link holds follow the config without a restart", () => {
|
|
const a = dir()
|
|
setTrust(a, "trusted")
|
|
fresh()
|
|
const L = limitsFromConfig()
|
|
expect(refusalFor(L, a)).toContain("remote work is off")
|
|
run("add", a)
|
|
expect(L.enabled).toBe(true)
|
|
expect(L.roots).toEqual([a])
|
|
expect(refusalFor(L, a)).toBeUndefined()
|
|
run("mode", "plan")
|
|
expect(L.maxMode).toBe("plan")
|
|
run("disable")
|
|
expect(refusalFor(L, a)).toContain("remote work is off")
|
|
// A file broken mid-edit keeps what was read last, rather than opening or closing anything.
|
|
writeFileSync(file(), "remote: [unterminated\n")
|
|
expect(L.maxMode).toBe("plan")
|
|
// /remote's link while remote work is off reaches only what its terminal shares; once on, the roots.
|
|
fresh(`remote:\n roots: [${a}]\n`)
|
|
const shared = limitsFromConfig({ sharedOnly: true })
|
|
expect(shared.sharedOnly).toBe(true)
|
|
expect(shared.roots).toEqual([])
|
|
run("enable")
|
|
expect(shared.sharedOnly).toBe(false)
|
|
expect(shared.roots).toEqual([a])
|
|
})
|
|
|
|
test("the service links with remote work off, says no roots and refuses everything, and serves once a folder is added — no restart", async () => {
|
|
const a = dir()
|
|
setTrust(a, "trusted")
|
|
fresh()
|
|
const hellos: any[] = []
|
|
/** Answers by request id, from the link that is up now. */
|
|
let answers = new Map<number, any>()
|
|
let current: any
|
|
let next = 1
|
|
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.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
|
|
return new Response("no", { status: 404 })
|
|
},
|
|
websocket: {
|
|
open(ws) {
|
|
current = ws
|
|
answers = new Map()
|
|
},
|
|
message(_ws, msg) {
|
|
const m = JSON.parse(String(msg))
|
|
if (m.method === "_lembas/hello") hellos.push(m.params)
|
|
else if (m.id !== undefined && !m.method) answers.set(m.id, m)
|
|
},
|
|
},
|
|
})
|
|
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 stop = new AbortController()
|
|
stops.push(() => stop.abort())
|
|
const states: LinkStatus[] = []
|
|
const until = async (ok: () => boolean, what: string) => {
|
|
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(25)
|
|
if (!ok()) throw new Error(`timed out waiting for ${what}: ${JSON.stringify(states.map((s) => [s.state, s.detail]))}`)
|
|
}
|
|
/** A request from the instance over the link that is up, and its answer. */
|
|
const ask = async (method: string, params: unknown) => {
|
|
const id = next++
|
|
current.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
|
|
await until(() => answers.has(id), method)
|
|
return answers.get(id)
|
|
}
|
|
const done = runLink({ pollMs: 20, backoffMs: [10, 20], signal: stop.signal, onStatus: (s) => void states.push(s) })
|
|
// Off: linked all the same, saying it allows no folder, and refusing whatever is asked.
|
|
await until(() => hellos.length === 1, "the first hello")
|
|
expect(hellos[0].limits.roots).toEqual([])
|
|
const init = await ask("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
|
|
expect(init.result._meta.lembas.limits.roots).toEqual([])
|
|
expect((await ask("_lembas/directories", {})).result.places).toEqual([])
|
|
expect((await ask("_lembas/directories", { path: a })).result?.accepted ?? false).toBe(false)
|
|
const refused = await ask("session/new", { cwd: a, mcpServers: [] })
|
|
expect(refused.error.message).toContain("remote work is off")
|
|
// Turned on from outside, as Desktop does: it links again, saying the new roots, and serves.
|
|
run("add", a)
|
|
await until(() => hellos.length === 2, "a second hello")
|
|
expect(hellos[1].limits).toEqual({ roots: [a], max_mode: "edit", require_trust: true })
|
|
expect(states.some((s) => s.detail === "the remote settings changed; linking again")).toBe(true)
|
|
await ask("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
|
|
expect((await ask("_lembas/directories", {})).result.places.map((p: any) => [p.path, p.accepted])).toEqual([[a, true]])
|
|
// A change the instance was told about, and turned off again: each time a new hello.
|
|
run("mode", "plan")
|
|
await until(() => hellos.length === 3, "a third hello")
|
|
expect(hellos[2].limits.max_mode).toBe("plan")
|
|
run("disable")
|
|
await until(() => hellos.length === 4, "a fourth hello")
|
|
expect(hellos[3].limits.roots).toEqual([])
|
|
stop.abort()
|
|
expect((await done).state).toBe("stopped")
|
|
})
|