Files
HomerandClaude Opus 5.5 28ff205ed0 Custom connections are gone: the CLI talks to the LLeMbas server it is logged in to
LLeMbas CLI 2.0 is LLeMbas-server-specific (CLI rule 2). Removed: the
anthropic, gemini, ollama and responses clients, context discovery and
llama-swap unloading (discover.ts), the Connection schema with its dialect,
auth, key_cmd, discover, unload_url and one_model_at_a_time, the model
`fallback` (it only ever moved to another server), effort_map and cache, and
the multi-connection refs. Kept: openai-chat, http, sse, think, effort,
tokens and all of src/lembas.

- connections.yaml holds the login (`type: webui`, as lembas login writes
  it). A custom entry from 1.x is read past with a warning that says how to
  log in, so an old file still starts; with several logins left, the latest
  is used and the others named.
- A model's ref is the id the server serves it under; the older forms
  (`<login>/<id>`, `<login>/<model>`) still resolve and config.yaml is
  rewritten once. Without a login there is no model, and the error (and the
  first-run screen, now the sign-in alone) says `lembas login <address>`.
- A login to another server signs this machine out of the previous one.
- The window is the server's word (no discovery); a capacity group comes
  from the server's `capacity.group` alone; embeddings use the server's
  /v1/embeddings.
- `lembas models` lists the server's models; install.sh's starter files, the
  README and the JSON schemas say the same.

Tests: every fixture is a login now (the fake server lists models, answers
other GETs with 404, and can hand one model to another script, as a server
with several backends); the dialect suites and the cross-connection tests are
gone with their code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:00:05 +00:00

183 lines
9.7 KiB
TypeScript

// Local mode (`lembas -l`): everything under the project, the user's login read and nothing more,
// and what would reach or be kept on a server turned off.
import { afterEach, expect, test } from "bun:test"
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { addPendingDeleted, pendingDeleted } from "../src/acp/pending.ts"
import { connectHub, Hub } from "../src/acp/hub.ts"
import { limitsFromConfig, LinkError, runLink } from "../src/acp/link.ts"
import { loadConfig } from "../src/config/load.ts"
import { globalPaths, isLocal, makeTempDir, paths, setLocalRoot } from "../src/config/paths.ts"
import { instances, keyFile, logout } from "../src/lembas/login.ts"
import { pushPersonalization } from "../src/lembas/personal.ts"
import { evaluate } from "../src/permission/evaluate.ts"
import { protectedPath } from "../src/permission/hardline.ts"
import { enterLocal, LocalError } from "../src/project/local.ts"
import { delta, fakeProvider } from "./fake-provider.ts"
const cli = join(import.meta.dir, "../src/cli.ts")
const xdgData = process.env.XDG_DATA_HOME
afterEach(() => {
setLocalRoot(undefined)
if (xdgData === undefined) delete process.env.XDG_DATA_HOME
else process.env.XDG_DATA_HOME = xdgData
})
function repo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-local-")))
Bun.spawnSync(["git", "init", "-q", dir])
return dir
}
/** The user's own files: a connection and a config.yaml local mode must not read. */
function userFiles(extraConfig = "") {
mkdirSync(globalPaths.config, { recursive: true })
writeFileSync(join(globalPaths.config, "connections.yaml"), "connections:\n mine:\n type: webui\n url: http://127.0.0.1:9/v1\n models: { mine/m: {} }\n", { mode: 0o600 })
writeFileSync(join(globalPaths.config, "config.yaml"), `model: mine/m\ntheme: forest\n${extraConfig}`)
}
test("every directory is under the project, kept out of git; .lembas where .agent is another tool's", () => {
const dir = repo()
mkdirSync(join(dir, "sub"))
const { root, notes } = enterLocal(join(dir, "sub"))
expect(root).toBe(join(dir, ".agent/local/lembas"))
expect(isLocal()).toBe(true)
for (const p of [paths.config, paths.data, paths.state]) expect(p.startsWith(`${root}/`)).toBe(true)
for (const sub of ["config", "data", "state", "state/tmp"]) expect(existsSync(join(root, sub))).toBe(true)
expect(readFileSync(join(dir, ".gitignore"), "utf8")).toContain("/.agent/local/")
expect(notes.join("\n")).toContain(".agent/local/lembas")
// Its own temporary files are made there too.
expect(makeTempDir("x-").startsWith(join(root, "state/tmp"))).toBe(true)
setLocalRoot(undefined)
const other = repo()
mkdirSync(join(other, ".agent/workflows"), { recursive: true })
writeFileSync(join(other, ".agent/workflows/x.md"), "someone else's")
expect(enterLocal(other).root).toBe(join(other, ".lembas/local/lembas"))
})
test("a local directory that came with the repository is refused", () => {
const dir = repo()
mkdirSync(join(dir, ".agent/local/lembas/config"), { recursive: true })
writeFileSync(join(dir, ".agent/local/lembas/config/config.yaml"), "mode: auto\n")
Bun.spawnSync(["git", "-C", dir, "add", "-f", ".agent/local/lembas/config/config.yaml"])
expect(() => enterLocal(dir)).toThrow(LocalError)
expect(isLocal()).toBe(false)
})
test("the user's config.yaml is not read; their login is, and a local one wins by name", () => {
userFiles()
const dir = repo()
enterLocal(dir)
let loaded = loadConfig()
expect(loaded.config.model).toBeUndefined()
expect(loaded.config.theme).toBeUndefined()
expect(Object.keys(loaded.connections)).toEqual(["mine"])
writeFileSync(join(paths.config, "config.yaml"), "model: mine/m\n")
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n mine:\n type: webui\n url: http://127.0.0.1:8/v1\n models: { mine/n: {} }\n", { mode: 0o600 })
loaded = loadConfig()
expect(loaded.config.model).toBe("mine/m")
expect(Object.keys(loaded.connections.mine!.models)).toEqual(["mine/n"])
})
test("a login made outside local mode is read, never written or signed out", async () => {
mkdirSync(join(globalPaths.config, "lembas"), { recursive: true })
writeFileSync(join(globalPaths.config, "lembas.json"), JSON.stringify({ instances: { ex: { base_url: "http://127.0.0.1:9", connection: "ex", logged_in_at: "" } } }))
writeFileSync(join(globalPaths.config, "lembas", "ex.key"), "lmb_x\n", { mode: 0o600 })
enterLocal(repo())
expect(Object.keys(instances())).toEqual(["ex"])
expect(keyFile("ex")).toBe(join(globalPaths.config, "lembas", "ex.key"))
await expect(logout("ex")).rejects.toThrow("outside local mode")
expect(existsSync(join(globalPaths.config, "lembas", "ex.key"))).toBe(true)
await expect(pushPersonalization("personality", "concise")).rejects.toThrow("local mode")
})
test("the instance is not used for search, fetch, embeddings or the library; the whole hardline stays", () => {
mkdirSync(globalPaths.config, { recursive: true })
writeFileSync(join(globalPaths.config, "connections.yaml"), "connections:\n ex:\n type: webui\n url: http://127.0.0.1:9\n api_key: k\n", { mode: 0o600 })
enterLocal(repo())
writeFileSync(
join(paths.config, "config.yaml"),
"search:\n order: [webui, ddg]\n fetch: webui\nlibrary: lembas\nembedding: ex/nomic\nhardline_disable: [rm-root]\n",
)
const loaded = loadConfig()
expect(loaded.config.search?.order).toEqual(["ddg"])
expect(loaded.config.search?.fetch).toBe("local")
expect(loaded.config.search?.webui).toBeUndefined()
expect(loaded.config.library).toBe("local")
expect(loaded.config.embedding).toBeUndefined()
expect(loaded.config.hardline_disable).toBeUndefined()
expect(loaded.warnings.join("\n")).toContain("local mode")
})
test("the link, the hub, /remote's socket and deletes for the server are off", async () => {
enterLocal(repo())
writeFileSync(join(paths.config, "config.yaml"), `remote:\n enabled: true\n roots: [/]\n`)
await expect(runLink()).rejects.toBeInstanceOf(LinkError)
await expect(runLink({ sharedOnly: true })).rejects.toThrow("local mode")
expect(await connectHub()).toBeUndefined()
expect(await new Hub({ service: true, limits: limitsFromConfig() }).listen()).toBe(false)
addPendingDeleted(["s1"])
expect(pendingDeleted()).toEqual([])
})
test("tools never write local mode's own files, and read them only as outside the project", () => {
const dir = repo()
const { root } = enterLocal(dir)
expect(protectedPath(join(root, "config/config.yaml"))).toBe("local mode's own files")
expect(protectedPath(join(root, "state/trust.json"))).toBe("local mode's own files")
expect(protectedPath(join(root, "data/worktrees/abc/x.ts"))).toBeUndefined()
// The user's connections.yaml stays guarded as well.
expect(protectedPath(join(globalPaths.config, "connections.yaml"))).toBe("connections.yaml")
const ctx = { mode: "edit" as const, root: dir, rules: [], hardline: [] }
expect(evaluate({ permission: "write", class: "write", patterns: ["x"], paths: [join(root, "config/config.yaml")] }, ctx).action).toBe("deny")
expect(evaluate({ permission: "read", class: "read", patterns: ["x"], paths: [join(root, "config/lembas/ex.key")] }, ctx).action).not.toBe("allow")
expect(evaluate({ permission: "read", class: "read", patterns: ["x"], paths: [join(dir, "hello.txt")] }, ctx).action).toBe("allow")
})
/** Every file under `dir` — but the runtime's own cache: this test runs the CLI from source with
* `bun`, which keeps one under HOME (the compiled binary does not; the smoke test checks that). */
function listing(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((p) => p !== ".bun" && !p.startsWith(".bun/"))
.sort()
}
test("the CLI: -l refuses what it turns off, and a run writes nothing outside the project", async () => {
const fake = fakeProvider([{ chunks: [delta({ content: "local hi" })] }])
try {
const home = realpathSync(mkdtempSync(join(tmpdir(), "lembas-local-home-")))
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "lembas-local-tmp-")))
mkdirSync(join(home, ".config/lembas"), { recursive: true })
writeFileSync(join(home, ".config/lembas/connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models: { fake/m: {} }\n`, { mode: 0o600 })
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: nowhere/x\n")
const dir = repo()
const env = { ...process.env, LEMBAS_HOME: home, HOME: home, TMPDIR: tmp, XDG_DATA_HOME: join(home, ".local/share"), XDG_STATE_HOME: join(home, ".local/state") }
const before = { home: listing(home), tmp: listing(tmp) }
const run = async (...args: string[]) => {
const p = Bun.spawn(["bun", cli, ...args], { cwd: dir, env, stdout: "pipe", stderr: "pipe" })
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
return { out, err, code }
}
for (const cmd of ["service", "update", "uninstall", "remote"]) {
const r = await run("-l", cmd)
expect(r.code).toBe(1)
expect(r.err).toContain("off in local mode")
}
// The user's config.yaml names a model that does not exist: read, it would fail.
const r = await run("--local", "run", "hello")
expect(r.err).not.toContain("nowhere")
expect(r.out).toBe("local hi\n")
expect(r.code).toBe(0)
const help = await run("-l", "--help")
expect(help.out).toContain("Local mode")
expect(existsSync(join(dir, ".agent/local/lembas/data/sessions.db"))).toBe(true)
expect({ home: listing(home), tmp: listing(tmp) }).toEqual(before)
} finally {
fake.stop()
}
}, 60_000)