From b75923e724bf7165bf46b4d543841cd1f66c4c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Wed, 14 Oct 2026 18:16:28 +0000 Subject: [PATCH] lembas -l / --local: everything in the project, nothing written elsewhere Config, data and state are rooted at /.agent/local/lembas (or .lembas/), kept out of git; a local directory the repository carries is refused. The user's logins and connections.yaml are read, never written; the user's config.yaml is not read. The TUI is started with TMPDIR inside it (the compiled binary unpacks its native library there) and OpenTUI's cache goes there too; the agent's commands keep the user's TMPDIR. An instance serves models, chat, speech and the balance only: the link, /remote, the service and the hub are off, deletes are not sent, and the server library, search, fetch, embeddings and personalization are not used. Self-update and uninstall are refused. hardline_disable is ignored, and tools treat the local directory as outside the project. The smoke test runs a whole local session in the compiled binary and checks that HOME, the XDG directories and TMPDIR gained nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++ scripts/drive.ts | 4 +- scripts/smoke.ts | 44 +++++++- src/acp/hub.ts | 6 +- src/acp/link.ts | 3 + src/acp/pending.ts | 5 +- src/acp/share.ts | 5 + src/cli.ts | 57 ++++++++++- src/config/load.ts | 85 +++++++++++----- src/config/paths.ts | 50 +++++++++- src/git/snapshot.ts | 5 +- src/lembas/login.ts | 62 ++++++++---- src/lembas/personal.ts | 2 + src/lembas/webui.ts | 21 ++-- src/permission/evaluate.ts | 12 ++- src/permission/hardline.ts | 23 +++-- src/project/local.ts | 80 +++++++++++++++ src/provider/common.ts | 18 +++- src/reload.ts | 26 +++-- src/settings.ts | 3 +- src/tool/bash.ts | 9 +- src/tui/app.tsx | 3 +- src/tui/components/status.tsx | 5 +- src/tui/index.tsx | 4 +- src/voice/audio.ts | 6 +- src/voice/speech.ts | 7 +- tests/local.test.ts | 182 ++++++++++++++++++++++++++++++++++ 27 files changed, 636 insertions(+), 105 deletions(-) create mode 100644 src/project/local.ts create mode 100644 tests/local.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24fcd8d..2018b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ All notable changes to LLeMbas CLI. The format follows (nothing is written), and turns remote work on. `--json` prints one object, `{"enabled", "roots", "max_mode", "terminal"}`, with the defaults applied — for a program such as LLeMbas Desktop. +- `lembas -l` / `lembas --local`: local mode. Config, sessions, memory, skills, the library, + snapshots, worktrees, caches, trust and history live in the project's git-ignored + `.agent/local/lembas/` (`.lembas/` where `.agent` is another tool's), and nothing is written + anywhere else — not even temporary files: the TUI starts with TMPDIR there, and the TUI library's + cache goes there too (the commands the agent runs keep your TMPDIR). Your logins and + connections.yaml are read, never written; your config.yaml is not read. A LLeMbas instance is + used for its models, chat, speech in and out and the balance only: no link, `/remote`, service or + hub, no deletes sent, no server library, no search, fetch or embeddings through it, no account + personalization, and no self-update. The instance still records each request's usage, as any API + does. `-l` does not trust the project by itself; the first start asks, and the answer is kept in + the local directory. A local directory the repository carries is refused, `hardline_disable` is + ignored, and tools treat the local directory as outside the project: no tool writes there (but + in its worktrees), and reading it asks as anything outside the project does. The status bar says + `local`, and `lembas -l --help` explains it. ### Changed diff --git a/scripts/drive.ts b/scripts/drive.ts index 7c4b1d7..9b31ff4 100644 --- a/scripts/drive.ts +++ b/scripts/drive.ts @@ -20,11 +20,11 @@ export interface Driver { modes(): { mouse: string; altScreen: boolean; cursorHidden: boolean } } -export async function drive(opts: { cols: number; rows: number; bin?: string; home: string; cwd: string; args?: string[] }): Promise { +export async function drive(opts: { cols: number; rows: number; bin?: string; home: string; cwd: string; args?: string[]; env?: Record }): Promise { const cmd = opts.bin ? resolve(opts.bin) : `bun --config=${resolve(import.meta.dir, "../bunfig.toml")} ${resolve(import.meta.dir, "../src/cli.ts")}` const p = Bun.spawn(["script", "-qfec", `stty cols ${opts.cols} rows ${opts.rows}; exec ${cmd} ${(opts.args ?? []).join(" ")}`, "/dev/null"], { cwd: opts.cwd, - env: { ...process.env, LEMBAS_HOME: opts.home, TERM: "xterm-256color" }, + env: { ...process.env, LEMBAS_HOME: opts.home, TERM: "xterm-256color", ...opts.env }, stdin: "pipe", stdout: "pipe", }) diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 5b3da3f..865224b 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -2,7 +2,7 @@ // Smoke test of the COMPILED binary — what `bun test` cannot see: that the bundle, OpenTUI's // native library and bun:sqlite FTS5 all survive `bun build --compile`. // bun run scripts/smoke.ts [path/to/binary] -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { delta, fakeProvider, toolCall, usage } from "../tests/fake-provider.ts" @@ -192,4 +192,46 @@ for (const [cols, rows] of [ check("/reload: the terminal is given back on quit", modes.mouse === "none" && !modes.altScreen, JSON.stringify(modes)) } +// 6. local mode (-l): everything in the project, nothing written anywhere else — the user's +// directories, HOME and TMPDIR are checked file by file before and after a whole session. +{ + const { fake: f, home: h, cwd } = fakeHome([ + { chunks: [delta({ content: "Writing it." }), toolCall(0, "c1", "bash", '{"command":"echo local > out.txt","description":"Write out.txt"}'), usage(900, 40)] }, + { chunks: [delta({ content: "local done" }), usage(1200, 20)] }, + ]) + const tmp = mkdtempSync(join(tmpdir(), "lembas-smoke-tmp-")) + const listing = (dir: string): string[] => { + const out: string[] = [] + for (const e of readdirSync(dir, { withFileTypes: true, recursive: true })) out.push(join(e.parentPath, e.name)) + return out.sort() + } + const before = { home: listing(h), tmp: listing(tmp) } + const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd, args: ["-l"], env: { HOME: h, TMPDIR: tmp, XDG_CONFIG_HOME: join(h, ".config"), XDG_DATA_HOME: join(h, ".local/share"), XDG_STATE_HOME: join(h, ".local/state") } }) + const firstRun = await d.waitFor("Trust this directory?") + await d.key("\r") + const drawn = await d.waitFor("☤ LLeMbas", 15000) + await d.type("make a file") + await d.key("\r") + const asked = await d.waitFor("Allow once") + await d.key("y") + const answered = await d.waitFor("local done") + await d.type("/remote") + await d.key("\r") + const refused = await d.waitFor("off in local mode") + const screen = d.screen() + await d.key("\x03") + await d.key("\x03") + const code = await d.close() + f.stop() + const after = { home: listing(h), tmp: listing(tmp) } + const grew = [...after.home.filter((p) => !before.home.includes(p)), ...after.tmp.filter((p) => !before.tmp.includes(p))] + const kept = ["data/sessions.db", "state/trust.json"].every((p) => existsSync(join(cwd, ".agent/local/lembas", p))) + check( + "local mode: a whole session works from the project", + firstRun && drawn && asked && answered && refused && kept && code === 0 && (screen[29] ?? "").includes("local"), + `firstRun=${firstRun} drawn=${drawn} asked=${asked} answered=${answered} refused=${refused} kept=${kept} exit=${code}\n${screen.join("\n")}`, + ) + check("local mode: nothing written outside the project (home, XDG dirs, TMPDIR)", grew.length === 0, grew.join(", ")) +} + process.exit(failed ? 1 : 0) diff --git a/src/acp/hub.ts b/src/acp/hub.ts index 66c7861..8a9c486 100644 --- a/src/acp/hub.ts +++ b/src/acp/hub.ts @@ -20,7 +20,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs" import { connect, createServer, type Server, type Socket } from "node:net" import { join } from "node:path" -import { paths } from "../config/paths.ts" +import { isLocal, paths } from "../config/paths.ts" import type { Mode } from "../config/schema.ts" import type { CommandInfo } from "../session/commands.ts" import { clampMode, refusalFor, type AcpAgent, type Limits } from "./agent.ts" @@ -77,7 +77,8 @@ export function answers(path = hubSocket(), timeoutMs = 1000): Promise /** Connect to the hub, if one runs. */ export function connectHub(path = hubSocket()): Promise { - if (!existsSync(path)) return Promise.resolve(undefined) + // Local mode shares nothing: no hub is reached, and none is held. + if (isLocal() || !existsSync(path)) return Promise.resolve(undefined) return new Promise((resolve) => { const sock = connect(path) const timer = setTimeout(() => (sock.destroy(), resolve(undefined)), 2000) @@ -138,6 +139,7 @@ export class Hub { /** Take the socket. False: another hub is alive on it. */ async listen(path = hubSocket()): Promise { + if (isLocal()) return false mkdirSync(paths.state, { recursive: true }) if (existsSync(path)) { if (await answers(path)) return false diff --git a/src/acp/link.ts b/src/acp/link.ts index ab247fb..2b52aaf 100644 --- a/src/acp/link.ts +++ b/src/acp/link.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs" import { hostname } from "node:os" import { remoteBlock } from "../config/remote.ts" +import { refusedLocally } from "../project/local.ts" import type { Mode } from "../config/schema.ts" import { HARNESS_VERSION } from "../harness.ts" import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts" @@ -124,6 +125,8 @@ export interface LinkOptions { * `roots: []` and can tell a device that allows no folder yet from one that is not running, and a * `lembas remote add` or `enable` makes it serve, without a restart. */ export async function runLink(o: LinkOptions = {}): Promise { + const local = refusedLocally("the link to a LLeMbas instance (lembas service, /remote)") + if (local) throw new LinkError(local) const limits = limitsFromConfig({ sharedOnly: o.sharedOnly }) const poll = o.pollMs ?? 2000 const instance = pickInstance(o.instance) diff --git a/src/acp/pending.ts b/src/acp/pending.ts index 38fc190..dd26b79 100644 --- a/src/acp/pending.ts +++ b/src/acp/pending.ts @@ -14,7 +14,7 @@ // out of anybody's mind. import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" -import { paths } from "../config/paths.ts" +import { isLocal, paths } from "../config/paths.ts" export const DELETED_MAX = 500 @@ -43,7 +43,8 @@ function write(ids: string[]) { * directory that cannot be written to only loses the word. */ export function addPendingDeleted(ids: string[]) { const add = ids.filter(Boolean) - if (!add.length) return + // Local mode tells the instance nothing, now or later. + if (!add.length || isLocal()) return try { // An id already waiting moves to the end: it is the newest word about it. const list = [...pendingDeleted().filter((x) => !add.includes(x)), ...new Set(add)] diff --git a/src/acp/share.ts b/src/acp/share.ts index cc86bb1..ec2d5c2 100644 --- a/src/acp/share.ts +++ b/src/acp/share.ts @@ -18,6 +18,8 @@ import { clampMode, effortOf, markRemote, permissionParams, planParams, planRepl import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts" import { dropAttachments, preparePrompt } from "./prompt.ts" import { connectHub, Hub } from "./hub.ts" +import { isLocal } from "../config/paths.ts" +import { localRefusal, refusedLocally } from "../project/local.ts" import { addPendingDeleted } from "./pending.ts" import { REMOTE_META, type Store } from "../session/store.ts" import { limitsFromConfig, runLink } from "./link.ts" @@ -193,6 +195,7 @@ export class Share { } status(): string { + if (isLocal()) return localRefusal("sharing with the web UI") if (this.sharedId) return `this session is shared with the web UI ${this.own ? "— the link is held by this terminal, so it lasts as long as the terminal does" : "through the service"}` if (this.hub && this.service) return "not shared: this session is outside what the service shares (remote.roots, trust) — /remote shares it anyway" if (this.hub) return "not shared — /remote shares it" @@ -342,6 +345,8 @@ export class Share { /** Hold the link in this terminal, for /remote while no service runs. Returns why not, if not. */ private async becomeHub(): Promise { + const local = refusedLocally("/remote") + if (local) return local const { instances } = await import("../lembas/login.ts") if (!Object.keys(instances()).length) return "not logged in to a LLeMbas instance — /login first" const hub = new Hub({ service: false, limits: limitsFromConfig() }) diff --git a/src/cli.ts b/src/cli.ts index cf90d67..307c981 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,8 +6,10 @@ import { createApp } from "./app.ts" import { attachmentsFor } from "./project/attach.ts" import { ConfigError, loadConfig } from "./config/load.ts" import { asMode, EFFORTS, MODES, type Effort, type Mode } from "./config/schema.ts" +import { isLocal, localRoot } from "./config/paths.ts" import { SettingError } from "./config/settings.ts" import { renderJson, renderPlain, terminalAsker } from "./headless.ts" +import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts" import { findProject, setTrust, trustOf } from "./project/root.ts" import { effortsFor } from "./provider/effort.ts" import { clientFor, ModelError, resolveModel } from "./provider/index.ts" @@ -18,7 +20,7 @@ import { VERSION } from "./version.ts" const HELP = `lembas ${VERSION} — terminal coding agent and project manager Usage: - lembas [-m model] [--mode M] [-c | -s id] + lembas [-l] [-m model] [--mode M] [-c | -s id] the TUI (-c continues the last session here, -s resumes one) lembas run [options] "…" one prompt, headless lembas models [--discover] list configured models @@ -61,6 +63,21 @@ Usage: lembas --version lembas --licenses the licences of everything this binary contains +Local mode: + lembas -l | --local [command …] + keep everything in this project: config, sessions, memory, skills, + the library, snapshots, worktrees, caches and trust live in + .agent/local/lembas/ (git-ignored), and nothing is written outside + it. Your logins and connections.yaml are read, never written; your + config.yaml is not read (the local one is used; lembas -l config set + … writes it). A LLeMbas instance is used only for its models, chat, + speech in and out (/v1/audio) and the balance: no link, /remote or + service, no sessions or deletes sent to it, no server library, + search, fetch or embeddings, no account personalization, and no + self-update. The instance still records the usage of each request, + as any API does. The project is not trusted by -l itself: the first + start asks, and the answer is kept in the local directory. + Options for run: -m, --model conn/model model to use (default: config.yaml \`model\`) --mode MODE manual | edit | auto | plan @@ -134,8 +151,33 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) { } async function main() { - const argv = process.argv.slice(2) + // The environment as it was given, before local mode sets anything in it. + const startEnv = { ...process.env } + let argv = process.argv.slice(2) + // -l / --local: before the command, or anywhere among the TUI's options. + let local = false + while (LOCAL_FLAGS.includes(argv[0] ?? "")) { + local = true + argv = argv.slice(1) + } + if ((!argv[0] || argv[0].startsWith("-")) && argv.some((a) => LOCAL_FLAGS.includes(a))) { + local = true + argv = argv.filter((a) => !LOCAL_FLAGS.includes(a)) + } const cmd = argv[0] + if (local && (cmd === "-h" || cmd === "--help" || cmd === "help")) + return void process.stdout.write(`Local mode here keeps everything in ${localFor(process.cwd()).root}\n\n${HELP}`) + if (local && !["-v", "--version", "--licenses", "licenses", "-h", "--help", "help"].includes(cmd ?? "")) { + try { + for (const note of enterLocal(process.cwd()).notes) process.stderr.write(`${note}\n`) + } catch (e) { + if (e instanceof LocalError) fail(e.message) + throw e + } + // What local mode turns off, said in one line. + const off: Record = { service: "lembas service", update: "lembas update", uninstall: "lembas uninstall (run it without -l)", remote: "lembas remote" } + if (cmd && off[cmd]) fail(localRefusal(off[cmd])) + } // install.sh asks where the three directories are before it writes anything into them. // Internal, so not in --help; it prints where each one is, and install.sh writes where it says. if (cmd === "__paths") { @@ -157,6 +199,12 @@ async function main() { }, }) if (!process.stdin.isTTY || !process.stdout.isTTY) fail('the TUI needs a terminal; use: lembas run "…"', 2) + // Local mode: the TUI starts again with TMPDIR inside the project, which the runtime reads + // only as it starts (the native library it unpacks goes there). The user's TMPDIR is passed on + // for the commands the agent runs. + const root = localRoot() + if (root && process.env.TMPDIR !== localTmp(root)) + await (await import("./reload.ts")).restart(["-l", ...argv], { ...startEnv, TMPDIR: localTmp(root), LEMBAS_OUTER_TMPDIR: startEnv.TMPDIR ?? "" }) if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2) await (await import("./lembas/webui.ts")).refreshWebui({ timeoutMs: 3000 }) const { runTui } = await import("./tui/index.tsx") @@ -399,15 +447,16 @@ async function main() { if (key === "model" || key === "small_model") resolveModel(loadConfig(), String(v)) }, }) + const label = (src: string) => (src === "global" && isLocal() ? "local" : src) if (argv[1] === "list") { - for (const e of settings.list()) console.log(`${e.def.key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${e.source}]`}${e.note ? ` (${e.note})` : ""}`) + for (const e of settings.list()) console.log(`${e.def.key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${label(e.source)}]`}${e.note ? ` (${e.note})` : ""}`) return } const [key, ...rest] = args if (!key) fail(`usage: lembas config ${argv[1]} ${argv[1] === "set" ? " [--project]" : ""}`, 2) if (argv[1] === "get") { const e = settings.get(key) - console.log(`${key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${e.source}]`}`) + console.log(`${key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${label(e.source)}]`}`) if (e.note) console.log(`⚠ ${e.note}`) const d = describe(e.def) if (d) console.log(d) diff --git a/src/config/load.ts b/src/config/load.ts index ffaf316..c1afdaa 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn import { join } from "node:path" import { isScalar, parse, parseDocument } from "yaml" import { z } from "zod" -import { paths } from "./paths.ts" +import { connectionFiles, isLocal, paths } from "./paths.ts" import { asMode, Config, Connection, ConnectionsFile, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type PermissionConfig } from "./schema.ts" import { runKeyCmd, substituteDeep } from "./substitute.ts" import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts" @@ -260,37 +260,38 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean warnings.push("config: `skin` is now called `theme`; rename it (the value is used meanwhile)") } - const connFile = join(paths.config, "connections.yaml") const connections: Loaded["connections"] = {} const broken: Record = {} - if (existsSync(connFile)) { + // Validate each raw file, then substitute each connection on its own: a missing variable for one + // connection must not stop the others from working. In local mode, the user's own file first + // (read, never written), then the local one, whose entries win by name. + const raw: Record = {} + for (const connFile of connectionFiles()) { files.push(connFile) const mode = statSync(connFile).mode & 0o777 if (mode & 0o077) warnings.push(`${connFile} is mode ${mode.toString(8)}; chmod 600 it — it references credentials`) - // Validate the raw file, then substitute each connection on its own: a missing - // variable for one connection must not stop the others from working. - const raw = validate(ConnectionsFile, readYaml(connFile), connFile).connections - for (const [name, c] of Object.entries(raw)) { - try { - if ((c as { type?: string }).type === "webui") { - const w = WebuiConnection.safeParse(substituteDeep(c, `connections.${name}`)) - if (w.success) { - const c = (connections[name] = expandWebui(name, w.data)) - if (refreshFailures[name]) - warnings.push( - `${name}: the instance could not be asked for its models (${refreshFailures[name]}); ${c.webui.fetched_at ? `using what it said at ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "none known yet"}`, - ) - } - else broken[name] = `once {env:}/{file:} are filled in: ${w.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}` - continue + Object.assign(raw, validate(ConnectionsFile, readYaml(connFile), connFile).connections) + } + for (const [name, c] of Object.entries(raw)) { + try { + if ((c as { type?: string }).type === "webui") { + const w = WebuiConnection.safeParse(substituteDeep(c, `connections.${name}`)) + if (w.success) { + const c = (connections[name] = expandWebui(name, w.data)) + if (refreshFailures[name]) + warnings.push( + `${name}: the instance could not be asked for its models (${refreshFailures[name]}); ${c.webui.fetched_at ? `using what it said at ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "none known yet"}`, + ) } - // Again once filled in: "{env:LLM_URL}" passed as a placeholder; what it became must be a URL. - const r = Connection.safeParse(substituteDeep(c, `connections.${name}`)) - if (r.success) connections[name] = r.data - else broken[name] = `once {env:}/{file:} are filled in: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}` - } catch (e) { - broken[name] = (e as Error).message + else broken[name] = `once {env:}/{file:} are filled in: ${w.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}` + continue } + // Again once filled in: "{env:LLM_URL}" passed as a placeholder; what it became must be a URL. + const r = Connection.safeParse(substituteDeep(c, `connections.${name}`)) + if (r.success) connections[name] = r.data + else broken[name] = `once {env:}/{file:} are filled in: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}` + } catch (e) { + broken[name] = (e as Error).message } } @@ -388,11 +389,16 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean config = { ...config, search: sr } } + // Local mode: an instance serves models, chat, speech and the balance, and nothing that is kept + // or shown there — no search or fetch through it, no embeddings, no library, no personalization + // (the local keys apply) — and the whole hardline stays. + if (isLocal()) config = localOnly(config, connections, warnings) + // Logged in to one instance: the account's personalization in place of the local keys, // where the instance offers it; and its library when nobody chose one. - const personal = withInstancePersonalization(config) + const personal = isLocal() ? { config } : withInstancePersonalization(config) config = personal.config - if (config.library === undefined && defaultLibrary()) config = { ...config, library: "lembas" } + if (config.library === undefined && !isLocal() && defaultLibrary()) config = { ...config, library: "lembas" } // An instance that speaks protocol 2 serves `/` ids. const logins = instances() @@ -441,6 +447,31 @@ function migrateRef(file: string, key: string, old: string, now: string) { } catch {} } +/** What local mode takes out of a loaded config, saying so where something was asked for. */ +function localOnly(config: Config, connections: Loaded["connections"], warnings: string[]): Config { + const out = { ...config } + if (out.search) { + const sr = { ...out.search } + if (sr.order?.includes("webui") || sr.webui || sr.fetch === "webui") warnings.push("local mode: web search and fetch do not go through the LLeMbas instance") + if (sr.order) sr.order = sr.order.filter((p) => p !== "webui") + delete sr.webui + if (sr.fetch === "webui") sr.fetch = "local" + out.search = sr + } + if (out.library === "lembas") warnings.push("local mode: library: lembas is ignored; this project's own library is used") + out.library = "local" + const emb = out.embedding?.split("/")[0] + if (out.embedding && emb && connections[emb]?.webui) { + warnings.push(`local mode: embedding ${out.embedding} would go through the LLeMbas instance; the library is searched by words only`) + delete out.embedding + } + if (out.hardline_disable?.length) { + warnings.push("local mode: hardline_disable is ignored; the whole hardline applies") + delete out.hardline_disable + } + return out +} + /** The API key for a connection: `api_key` (already substituted) or the output of `key_cmd`. */ export function resolveKey(name: string, c: Connection): string | undefined { if (c.api_key) return c.api_key diff --git a/src/config/paths.ts b/src/config/paths.ts index fb469f0..bf696dc 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,4 +1,5 @@ -import { homedir } from "node:os" +import { existsSync, mkdirSync, mkdtempSync } from "node:fs" +import { homedir, tmpdir } from "node:os" import { isAbsolute, join } from "node:path" /** An environment variable under its LLeMbas CLI name: LEMBAS_. */ @@ -17,7 +18,22 @@ function base(envVar: string, fallback: string): string { const dir = (parent: string): string => join(parent, "lembas") -export const paths = { +/** Local mode (`lembas -l`): the one directory everything of LLeMbas CLI's lives in — config, + * data and state — inside the project (project/local.ts). Unset: the usual three. */ +let local: string | undefined + +export function setLocalRoot(root: string | undefined) { + local = root +} + +/** The local root, in local mode. */ +export const localRoot = (): string | undefined => local + +export const isLocal = (): boolean => local !== undefined + +/** The user's own three directories, whatever the mode. In local mode only the login is read from + * them (connections.yaml, lembas.json and the key files it names), and nothing is written there. */ +export const globalPaths = { get config() { return dir(base("XDG_CONFIG_HOME", ".config")) }, @@ -29,8 +45,38 @@ export const paths = { }, } +/** Where LLeMbas CLI reads and writes its own files: the user's three directories, or the + * local root's in local mode. */ +export const paths = { + get config() { + return local ? join(local, "config") : globalPaths.config + }, + get data() { + return local ? join(local, "data") : globalPaths.data + }, + get state() { + return local ? join(local, "state") : globalPaths.state + }, +} + export function expandHome(p: string): string { if (p === "~") return homedir() if (p.startsWith("~/")) return join(homedir(), p.slice(2)) return p } + +/** A fresh temporary directory for LLeMbas CLI's own scratch files (speech being played or + * converted): the system's, or `state/tmp` in local mode, so nothing is left outside the project. */ +export function makeTempDir(prefix: string): string { + const parent = local ? join(local, "state", "tmp") : tmpdir() + if (local) mkdirSync(parent, { recursive: true, mode: 0o700 }) + return mkdtempSync(join(parent, prefix)) +} + +/** The connections files read, in order: in local mode the user's own (read-only) and then the + * local one; otherwise the one. Only those that exist. */ +export function connectionFiles(): string[] { + const own = join(paths.config, "connections.yaml") + const all = isLocal() ? [join(globalPaths.config, "connections.yaml"), own] : [own] + return all.filter((f) => existsSync(f)) +} diff --git a/src/git/snapshot.ts b/src/git/snapshot.ts index cbb8537..bdbacfc 100644 --- a/src/git/snapshot.ts +++ b/src/git/snapshot.ts @@ -1,6 +1,7 @@ // Snapshots of the working tree in a shadow git repository — the idea of OpenCode's -// snapshot/index.ts, written small. The shadow repo lives in ~/.local/share/lembas/snapshot/, -// never inside the project, and works whether or not the project itself uses git. +// snapshot/index.ts, written small. The shadow repo lives in ~/.local/share/lembas/snapshot/ — +// in local mode (-l) in the project's git-ignored local directory, which it leaves out of every +// snapshot — and works whether or not the project itself uses git. // // track() → a tree hash for the working tree as it is now // changed(a, b) → paths that differ between two trees diff --git a/src/lembas/login.ts b/src/lembas/login.ts index e4098b0..1fdb8a7 100644 --- a/src/lembas/login.ts +++ b/src/lembas/login.ts @@ -11,7 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { parseDocument } from "yaml" -import { paths } from "../config/paths.ts" +import { globalPaths, isLocal, paths } from "../config/paths.ts" import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts" import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts" @@ -36,27 +36,47 @@ interface Index { instances: Record } -const indexFile = () => join(paths.config, "lembas.json") +const indexFile = (dir = paths.config) => join(dir, "lembas.json") const keyDir = () => join(paths.config, "lembas") -export const keyFile = (connection: string) => join(keyDir(), `${connection}.key`) +/** Where a login made here keeps its key. */ +const ownKeyFile = (connection: string) => join(keyDir(), `${connection}.key`) +/** The key of a login: this mode's own, or — in local mode, for a login made outside it — the + * user's, read where it is. */ +export const keyFile = (connection: string) => { + const own = ownKeyFile(connection) + if (!isLocal() || existsSync(own)) return own + const theirs = join(globalPaths.config, "lembas", `${connection}.key`) + return existsSync(theirs) ? theirs : own +} -/** What a later look at the instance said it speaks, kept with the login. */ +/** What a later look at the instance said it speaks, kept with the login (not for a login local + * mode only reads). */ export function rememberProtocols(connection: string, protocols: number[]) { - const all = instances() + const all = ownInstances() const one = all[connection] if (!one || JSON.stringify(one.protocols) === JSON.stringify(protocols)) return saveIndex({ ...all, [connection]: { ...one, protocols } }) } -export function instances(): Record { +function readIndex(dir: string): Record { try { - const parsed = JSON.parse(readFileSync(indexFile(), "utf8")) as Index + const parsed = JSON.parse(readFileSync(indexFile(dir), "utf8")) as Index return parsed.instances ?? {} } catch { return {} } } +/** Every login in force: in local mode the user's own (read only) and the ones made here. */ +export function instances(): Record { + return isLocal() ? { ...readIndex(globalPaths.config), ...readIndex(paths.config) } : readIndex(paths.config) +} + +/** The logins this mode wrote, and may change: all of them, or in local mode the local ones. */ +export function ownInstances(): Record { + return readIndex(paths.config) +} + function saveIndex(all: Record) { mkdirSync(paths.config, { recursive: true }) writeFileSync(indexFile(), JSON.stringify({ instances: all }, null, 2) + "\n", { mode: 0o600 }) @@ -99,7 +119,7 @@ export function writeConnection(connection: string, d: Discovery, tls: Tls | und const entry: Record = { type: "webui", url: d.base_url, - api_key: `{file:${keyFile(connection)}}`, + api_key: `{file:${ownKeyFile(connection)}}`, ...(tls?.ca ? { tls: { ca: tls.ca } } : {}), } doc.setIn(["connections", connection], doc.createNode(entry)) @@ -125,7 +145,7 @@ function replaceOld(old: string, now: string, instanceDefault: string | undefine conns.deleteIn(["connections", old]) writeYamlDoc(connectionsFile(), conns, 0o600) } - rmSync(keyFile(old), { force: true }) + rmSync(ownKeyFile(old), { force: true }) dropCache(old) said.push(`the connection ${old} from an earlier login is now ${now}`) if (!existsSync(configFile())) return said @@ -176,7 +196,8 @@ export function useServices(connection: string, services: InstanceInfo["services cfg.setIn(["voice", half], cfg.createNode({ provider: "webui", ...named, ...(now ? { fallback: before } : {}) })) said.push(`${words} through ${connection}${now ? ` (what was set up stays as its fallback)` : ""}`) } - if (services.search) { + // Local mode searches without the instance (loadConfig would take it out again). + if (services.search && !isLocal()) { const search = plain(["search"]) ?? {} const order = Array.isArray(search.order) ? (search.order as string[]) : undefined if (!order?.includes("webui")) { @@ -232,7 +253,8 @@ function dropServices(connection: string, several: boolean): void { * already made with it are only found again with it. Returns what changed, in words. */ export function useEmbedding(connection: string, models: ServedModel[]): string[] { const first = models.find((m) => m.kind === "embedding") - if (!first) return [] + // Local mode embeds nothing through the instance. + if (!first || isLocal()) return [] const cfg = readYamlDoc(configFile()) const now = cfg.get("embedding") if (typeof now === "string" && now.trim()) return [] @@ -375,17 +397,19 @@ export async function login(address: string, o: LoginOptions): Promise m.default)?.id ?? undefined const changes: string[] = [] - const known = instances() + // Only logins this mode made: in local mode, the user's own login to the same instance is not + // signed out or replaced — it is not this project's. + const known = ownInstances() for (const [name, old] of Object.entries(known)) if (name !== connection && old.base_url === discovery.base_url) { // The old token is signed out too: this machine holds one, the new one. - if (existsSync(keyFile(name))) await signOut(old.base_url, readFileSync(keyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined) + if (existsSync(ownKeyFile(name))) await signOut(old.base_url, readFileSync(ownKeyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined) changes.push(...replaceOld(name, connection, instanceDefault)) delete known[name] } @@ -454,7 +478,9 @@ export interface LogoutResult { } export async function logout(connection: string): Promise { - const all = instances() + const all = ownInstances() + if (!all[connection] && instances()[connection]) + throw new LembasError(`${connection} was signed in outside local mode, and local mode only reads that sign-in — lembas logout without -l signs it out`) const instance = all[connection] if (!instance) throw new LembasError(`${connection} was not made by lembas login${Object.keys(all).length ? ` — logged in: ${Object.keys(all).join(", ")}` : ""}`) // Whether config.yaml starts on one of its models — by provider (`deepseek/x`) the ref @@ -468,9 +494,9 @@ export async function logout(connection: string): Promise { ours = Boolean(m && findRef(loaded.connections, loaded.refs, m)?.connection === connection) } catch {} let revoked = false - if (existsSync(keyFile(connection))) { + if (existsSync(ownKeyFile(connection))) { revoked = await signOut(instance.base_url, tokenOf(connection), instance.ca ? { ca: instance.ca } : undefined) - rmSync(keyFile(connection), { force: true }) + rmSync(ownKeyFile(connection), { force: true }) } const several = webuiCount() > 1 const doc = readYamlDoc(connectionsFile()) diff --git a/src/lembas/personal.ts b/src/lembas/personal.ts index 5854d3b..44f767b 100644 --- a/src/lembas/personal.ts +++ b/src/lembas/personal.ts @@ -10,6 +10,7 @@ // library — with no `library:` written anywhere, a login whose token has the `library` scope // means the account's library (`lembas`); `library: local` written keeps this machine's. import { existsSync, readFileSync } from "node:fs" +import { isLocal } from "../config/paths.ts" import type { Config } from "../config/schema.ts" import { PERSONALITIES } from "../config/schema.ts" import { LembasError, savePersonalization, usage, type Personalization, type Usage } from "./client.ts" @@ -59,6 +60,7 @@ export function withInstancePersonalization(config: Config): { config: Config; c * web UI has), and a PUT of the session's copy would wipe them. The cache takes what the instance * answered. */ export async function pushPersonalization(key: (typeof PERSONAL_KEYS)[number], value: string): Promise { + if (isLocal()) throw new LembasError("local mode (-l) keeps settings in the project, not in the account") const one = soleInstance() if (!one) throw new LembasError("not logged in to one LLeMbas instance") const saved = await savePersonalization(one.instance.base_url, one.token, one.instance.ca ? { ca: one.instance.ca } : undefined, { enabled: true, [key]: value }) diff --git a/src/lembas/webui.ts b/src/lembas/webui.ts index 8849c2b..38fa2c5 100644 --- a/src/lembas/webui.ts +++ b/src/lembas/webui.ts @@ -14,10 +14,10 @@ // LLeMbas CLI speaks to it as an ordinary openai-chat connection at /v1. What the instance said // last is kept in the state directory, so a start without the network still knows the models; a // start that can reach the instance reads them again first (`refreshWebui`). -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { parse } from "yaml" -import { paths } from "../config/paths.ts" +import { connectionFiles, isLocal, paths } from "../config/paths.ts" import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.ts" import { runKeyCmd, substituteDeep } from "../config/substitute.ts" import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts" @@ -170,7 +170,8 @@ export async function fetchWebui(name: string, url: string, token: string, tls: listModels({ api: { openai: `${base}/v1` } }, token, tls, timeoutMs), instanceInfo(base, token, tls, timeoutMs), // Best-effort: an instance may not have it, and a failure here must not cost the models. - personalization(base, token, tls, timeoutMs).catch(() => undefined), + // Not in local mode: the account's settings are not taken into the project. + isLocal() ? undefined : personalization(base, token, tls, timeoutMs).catch(() => undefined), ]) const cache: Cache = { fetched_at: new Date().toISOString(), url: base, ...(instance ? { instance } : {}), models: served, ...(personal ? { personalization: personal } : {}) } writeCache(name, cache) @@ -192,14 +193,12 @@ export interface Refreshed { * first, so the models are the instance's as they are now. Never throws: an instance that cannot * be reached leaves what was read last, and says so. */ export async function refreshWebui(o: { timeoutMs?: number; only?: string } = {}): Promise { - const file = join(paths.config, "connections.yaml") - if (!existsSync(file)) return [] - let raw: Record - try { - raw = ((parse(readFileSync(file, "utf8")) ?? {}) as { connections?: Record }).connections ?? {} - } catch { - return [] - } + // In local mode the user's own connections too, read only; the local file's entries win. + const raw: Record = {} + for (const file of connectionFiles()) + try { + Object.assign(raw, ((parse(readFileSync(file, "utf8")) ?? {}) as { connections?: Record }).connections ?? {}) + } catch {} const entries = Object.entries(raw).filter(([name, c]) => (!o.only || o.only === name) && (c as { type?: string })?.type === "webui") return Promise.all( entries.map(async ([name, c]): Promise => { diff --git a/src/permission/evaluate.ts b/src/permission/evaluate.ts index d3e2ba2..6a1cd9a 100644 --- a/src/permission/evaluate.ts +++ b/src/permission/evaluate.ts @@ -3,6 +3,7 @@ import { lstatSync, readlinkSync, realpathSync } from "node:fs" import { homedir } from "node:os" import { basename, dirname, join, relative, resolve } from "node:path" +import { localRoot } from "../config/paths.ts" import type { Action, Mode, PermissionConfig } from "../config/schema.ts" import { prefix } from "./arity.ts" import { splitCommand, words } from "./bash.ts" @@ -222,6 +223,11 @@ function evaluateRules(req: PermissionRequest, ctx: Context): Decision { } // Where each path really is: a symlink is judged by what it points to. const root = realPath(ctx.root) + // Local mode's own files are inside the project but not of it: judged as ~/.config is + // otherwise, as outside — save its worktrees, where a session works. + const own = localRoot() + const local = own === undefined ? undefined : realPath(own) + const inRoot = (p: string) => inside(p, root) && !(local && inside(p, local) && !inside(p, join(local, "data", "worktrees"))) const real = (req.paths ?? []).map(realPath) if (req.class === "write") { for (const [i, p] of real.entries()) { @@ -259,12 +265,12 @@ function evaluateRules(req: PermissionRequest, ctx: Context): Decision { // Reading a file by another tool (grep, glob, list) or a command follows the read rules too. if (req.permission !== "read" && (req.class === "read" || req.command !== undefined)) for (const p of [...(req.command === undefined ? real : []), ...named]) - if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules)) + if (inRoot(p)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules)) // And read by where it really is: a link named notes.txt that points at .env reads .env. if (req.permission === "read") - for (const p of real) if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules)) + for (const p of real) if (inRoot(p)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules)) - const external = [...real, ...named].filter((p) => !inside(p, root)) + const external = [...real, ...named].filter((p) => !inRoot(p)) let externalAction: Action = "allow" for (const p of external) externalAction = strictest(externalAction, lookup("external_directory", p, ctx.rules)) diff --git a/src/permission/hardline.ts b/src/permission/hardline.ts index 619ee21..81c4e00 100644 --- a/src/permission/hardline.ts +++ b/src/permission/hardline.ts @@ -2,8 +2,8 @@ // project config can re-enable. Patterns ported from Hermes Agent tools/approval_detection.py // HARDLINE_PATTERNS (MIT, © 2025 Nous Research), plus LLeMbas CLI's own (force-push to main). import { homedir } from "node:os" -import { basename, posix, resolve } from "node:path" -import { paths } from "../config/paths.ts" +import { basename, join, posix, resolve } from "node:path" +import { globalPaths, localRoot, paths } from "../config/paths.ts" import { splitCommand, words } from "./bash.ts" import SPEC from "../../harness/permission/hardline.json" @@ -162,13 +162,22 @@ export function substitutions(line: string): string[] { return out } -/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml). */ +/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml). + * `/` is guarded in the user's config directory and, in local mode, in the local one too. */ export function protectedPath(abs: string): string | undefined { const home = homedir() - const guarded: [string, string][] = SPEC.protected_paths.map((g) => [ - g.path.startsWith("/") ? resolve(paths.config, g.path.slice("/".length)) : g.path.startsWith("~/") ? resolve(home, g.path.slice(2)) : g.path, - g.label ?? g.path, - ]) + const configs = [...new Set([paths.config, globalPaths.config])] + const guarded: [string, string][] = SPEC.protected_paths.flatMap((g) => + g.path.startsWith("/") + ? configs.map((c): [string, string] => [resolve(c, g.path.slice("/".length)), g.label ?? g.path]) + : [[g.path.startsWith("~/") ? resolve(home, g.path.slice(2)) : g.path, g.label ?? g.path] as [string, string]], + ) for (const [p, label] of guarded) if (abs === p || abs.startsWith(p + "/")) return label + // Local mode keeps its config, keys, trust and sessions inside the project, where edit mode would + // otherwise let a tool rewrite them: no tool writes there — the program does, as it does in + // ~/.config. Except the worktrees, which are where a session may work. + const root = localRoot() + const inside = (p: string) => abs === p || abs.startsWith(p + "/") + if (root && inside(root) && !inside(join(root, "data", "worktrees"))) return "local mode's own files" return undefined } diff --git a/src/project/local.ts b/src/project/local.ts new file mode 100644 index 0000000..16b24a7 --- /dev/null +++ b/src/project/local.ts @@ -0,0 +1,80 @@ +// Local mode: `lembas -l` / `lembas --local`. Everything LLeMbas CLI keeps — config, sessions, +// memory, skills, the library, snapshots, worktrees, caches, trust, history — lives in the project, +// under its per-user directory (`.agent/local/lembas/`, git-ignored), and nothing is written +// anywhere else. +// +// What it reads from outside: only the login — the user's connections.yaml, lembas.json and the key +// files they name — read-only, so the connections and sign-ins work without being copied. The +// user's config.yaml is not read; the local one is this mode's global config. A LLeMbas instance +// is used for models, chat, speech in and out, and the balance, and for nothing that is kept there +// or shown there: no link, no shared sessions, no deletes sent, no server library, no search or +// fetch through it, no embeddings through it, no account personalization. +import { chmodSync, existsSync, mkdirSync } from "node:fs" +import { join, relative } from "node:path" +import { isLocal, localRoot, setLocalRoot } from "../config/paths.ts" +import { ignoreLocal } from "./init.ts" +import { findProject, type Project } from "./root.ts" + +export class LocalError extends Error {} + +/** The flags that turn it on. */ +export const LOCAL_FLAGS = ["-l", "--local"] + +/** What `-l` would use for `cwd`: the project's root (the nearest directory of ours, else the git + * root, else `cwd` itself) and, under its per-user directory, the local root. */ +export function localFor(cwd: string): { project: Project; root: string } { + const project = findProject(cwd) + return { project, root: join(project.dir, "local", "lembas") } +} + +/** Files of the local root the repository carries: settings, keys or sessions that came with a + * clone. Local mode refuses them — they would be somebody else's config passed off as yours. */ +function tracked(project: Project, root: string): string[] { + if (!project.gitRoot) return [] + const r = Bun.spawnSync(["git", "-C", project.gitRoot, "ls-files", "--", relative(project.gitRoot, root)], { stdout: "pipe", stderr: "pipe" }) + if (r.exitCode !== 0) return [] + return r.stdout.toString().split("\n").filter(Boolean) +} + +/** Turn local mode on for `cwd`: make the local root (mode 700), keep it out of git, and root + * config, data and state there for the rest of this process. Returns what was done, in words. */ +export function enterLocal(cwd: string): { project: Project; root: string; notes: string[] } { + const { project, root } = localFor(cwd) + const carried = tracked(project, root) + if (carried.length) + throw new LocalError( + `${relative(project.root, root)} is in the repository (${carried.slice(0, 3).join(", ")}${carried.length > 3 ? ", …" : ""}): local mode keeps your own settings and keys there, and will not use files that came with a clone — remove them from git first`, + ) + const notes: string[] = [] + const fresh = !existsSync(root) + for (const sub of ["config", "data", "state"]) mkdirSync(join(root, sub), { recursive: true, mode: 0o700 }) + try { + chmodSync(root, 0o700) + } catch {} + if (fresh) notes.push(`local mode: everything is kept in ${relative(project.root, root) || "."}`) + if (ignoreLocal(project)) notes.push(`added the per-user ${relative(project.root, join(project.dir, "local"))}/ to .gitignore`) + setLocalRoot(root) + mkdirSync(localTmp(root), { recursive: true, mode: 0o700 }) + // The TUI library's own cache (its tree-sitter parsers, under XDG_DATA_HOME/opentui), set + // before it is loaded. The commands the agent runs keep the environment they were started with: + // tool/bash.ts takes it before this runs. + process.env.XDG_DATA_HOME = join(root, "data") + return { project, root, notes } +} + +/** Local mode's temporary directory. The TUI runs with TMPDIR set to it from its very start — the + * compiled binary unpacks its native library there, and reads TMPDIR only once, as it starts + * (cli.ts starts it again with it set). */ +export function localTmp(root: string): string { + return join(root, "state", "tmp") +} + +/** The one-line refusal for what local mode turns off. */ +export function localRefusal(what: string): string { + return `${what} is off in local mode (-l): nothing is shared with or kept on a server, and nothing is written outside ${localRoot() ?? "the project"}` +} + +/** Refuse when in local mode: the refusal, or undefined. */ +export function refusedLocally(what: string): string | undefined { + return isLocal() ? localRefusal(what) : undefined +} diff --git a/src/provider/common.ts b/src/provider/common.ts index 677d62a..4b15f18 100644 --- a/src/provider/common.ts +++ b/src/provider/common.ts @@ -2,7 +2,8 @@ // llama-swap's loading banner. import type { ReasoningPart, StreamEvent, TextPart, ToolCallPart, Message } from "./types.ts" import { ProviderError } from "./types.ts" -import { env } from "../config/paths.ts" +import { resolve } from "node:path" +import { env, localRoot } from "../config/paths.ts" type Assistant = Extract @@ -71,12 +72,21 @@ export function statusOf(err: any): number | undefined { return undefined } +/** LEMBAS_RECORD, where it may be written: in local mode only inside the project's local root. */ +function recordDir(): string | undefined { + const dir = env("RECORD") + const root = localRoot() + if (!dir || !root) return dir + const abs = resolve(dir) + return abs === root || abs.startsWith(`${root}/`) ? abs : undefined +} + export function recordName(ref: string) { - return `${env("RECORD")}/${new Date().toISOString().replace(/[:.]/g, "-")}-${ref.replace(/[^\w.-]+/g, "_")}` + return `${recordDir()}/${new Date().toISOString().replace(/[:.]/g, "-")}-${ref.replace(/[^\w.-]+/g, "_")}` } export function recordFailure(ref: string, request: unknown, e: unknown) { - if (!env("RECORD")) return + if (!recordDir()) return const name = recordName(ref) void Bun.write(`${name}.request.json`, JSON.stringify(request, null, 2)) const err = e as ProviderError @@ -85,7 +95,7 @@ export function recordFailure(ref: string, request: unknown, e: unknown) { /** LEMBAS_RECORD=: keep each request body and its raw SSE, to become test fixtures. */ export function record(body: ReadableStream, ref: string, request: unknown): ReadableStream { - const dir = env("RECORD") + const dir = recordDir() if (!dir) return body const [a, b] = body.tee() const name = recordName(ref) diff --git a/src/reload.ts b/src/reload.ts index a57b61c..ae61568 100644 --- a/src/reload.ts +++ b/src/reload.ts @@ -10,11 +10,14 @@ export function selfCommand(args: string[]): string[] { return compiled ? [process.execPath, ...args] : [process.execPath, Bun.main, ...args] } -function execv(argv: string[]): Error { +/** execv, or execve with `env` when given (local mode's start: TMPDIR must be set before the + * runtime reads it). */ +function execv(argv: string[], env?: Record): Error { try { const { dlopen, FFIType, ptr } = require("bun:ffi") as typeof import("bun:ffi") const libc = dlopen("libc.so.6", { execv: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + execve: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 }, }) // Everything above stdio closes on exec: an open database, an MCP server's pipe would otherwise @@ -35,10 +38,17 @@ function execv(argv: string[]): Error { libc.symbols.fcntl(fd, F_SETFD, FD_CLOEXEC) } catch {} } - const strings = argv.map((a) => Buffer.from(`${a}\0`)) - const table = new BigUint64Array(strings.length + 1) - strings.forEach((s, i) => (table[i] = BigInt(ptr(s)))) - libc.symbols.execv(ptr(strings[0]!), ptr(table)) + const table = (items: string[]) => { + const strings = items.map((a) => Buffer.from(`${a}\0`)) + const t = new BigUint64Array(strings.length + 1) + strings.forEach((s, i) => (t[i] = BigInt(ptr(s)))) + return { strings, t } + } + const args = table(argv) + if (env) { + const vars = table(Object.entries(env).flatMap(([k, v]) => (v === undefined ? [] : [`${k}=${v}`]))) + libc.symbols.execve(ptr(args.strings[0]!), ptr(args.t), ptr(vars.t)) + } else libc.symbols.execv(ptr(args.strings[0]!), ptr(args.t)) // execv returns only when it failed. return new Error("execv failed") } catch (e) { @@ -47,9 +57,9 @@ function execv(argv: string[]): Error { } /** Never returns: the new program runs, in this process or a child this one waits for. */ -export async function restart(args: string[]): Promise { +export async function restart(args: string[], env?: Record): Promise { const argv = selfCommand(args) - execv(argv) - const child = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"] }) + execv(argv, env) + const child = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"], ...(env ? { env } : {}) }) process.exit(await child.exited) } diff --git a/src/settings.ts b/src/settings.ts index 7fe0a9f..e12536b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,6 +5,7 @@ import { mkdirSync, writeFileSync } from "node:fs" import { assertProjectFile } from "./project/safe.ts" import { dirname, join } from "node:path" import { findSetting, globalConfigFile, legacyInstructionFiles, parseValue, readRaw, SettingError, SETTINGS, show, sourceOf, writeKey, type Entry, type Scope, type SettingDef } from "./config/settings.ts" +import { isLocal } from "./config/paths.ts" export interface SettingsHost { /** The project's config.yaml, when the project is trusted (else project scope is refused). */ @@ -87,7 +88,7 @@ export class Settings { } if (!def.reload) this.host.apply(key, value) - const where = scope === "session" ? "for this session" : elsewhere ? "for your account" : scope === "global" ? "in the global config" : "in this project's config" + const where = scope === "session" ? "for this session" : elsewhere ? "for your account" : scope === "global" ? (isLocal() ? "in local mode's config" : "in the global config") : "in this project's config" let message = `${key} = ${show(value)} ${where}` if (def.reload) message += "; it takes effect from the next start (/reload)" for (const n of notes) message += `; ${n}` diff --git a/src/tool/bash.ts b/src/tool/bash.ts index 4de97c0..b38af27 100644 --- a/src/tool/bash.ts +++ b/src/tool/bash.ts @@ -14,8 +14,15 @@ const GIT_SAFE: [string, string][] = [ ["core.pager", "cat"], ["safe.bareRepository", "explicit"], ] +// Local mode starts the TUI with TMPDIR inside the project, for this program's own files; the +// commands keep the TMPDIR they had (LEMBAS_OUTER_TMPDIR, empty when there was none). +const { LEMBAS_OUTER_TMPDIR: outerTmp, ...startEnv } = process.env +if (outerTmp !== undefined) { + if (outerTmp) startEnv.TMPDIR = outerTmp + else delete startEnv.TMPDIR +} const ENV = { - ...process.env, + ...startEnv, // A command can tell it runs under the agent. LEMBAS: "1", GIT_PAGER: "cat", diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 811412f..3f384cb 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -3,6 +3,7 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" import { jobNote, onJobExit } from "../tool/jobs.ts" import { stricterMode, type App as Runtime } from "../app.ts" +import { isLocal } from "../config/paths.ts" import type { Effort, Mode } from "../config/schema.ts" import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts" import { Dialog, type DialogSpec } from "./components/dialog.tsx" @@ -178,7 +179,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => if (!p.onReload) return view.info("/reload is not available here", "warn") // Back on this session, with what it was using; everything else comes from the files again. const e = app.engine - const args = [...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"] + const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"] void app .close() .catch(() => {}) diff --git a/src/tui/components/status.tsx b/src/tui/components/status.tsx index 72e1cf6..89dc204 100644 --- a/src/tui/components/status.tsx +++ b/src/tui/components/status.tsx @@ -1,5 +1,6 @@ import { basename } from "node:path" import { Show } from "solid-js" +import { isLocal } from "../../config/paths.ts" import { useTui } from "../context.ts" const MODE_LABEL = { manual: "manual", edit: "edit", auto: "AUTO", plan: "plan" } as const @@ -41,7 +42,9 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort: {view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""} {p.credits?.() ? `${p.credits()} · ` : ""} {ctx().text} - · {basename(app.project.root)}{p.branch ? `@${p.branch}` : ""} + · + {isLocal() ? "local " : ""} + {basename(app.project.root)}{p.branch ? `@${p.branch}` : ""} } > diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 7138ba9..f09ac4a 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -5,6 +5,7 @@ import { createApp, type AppOptions, type App } from "../app.ts" import { ConfigError, loadConfig } from "../config/load.ts" import { ModelError } from "../provider/index.ts" import { autoUpdate } from "../update/index.ts" +import { isLocal } from "../config/paths.ts" import { Setup } from "./components/setup.tsx" import { Bus } from "../bus/index.ts" import { findProject, trustOf } from "../project/root.ts" @@ -78,8 +79,9 @@ export function Boot(p: { options: TuiOptions; onQuit: () => void; onReload?: (a setApp(a) p.onApp?.(a) // An update, looked for once the session is up, and only said when there is something to say. + // Not in local mode: an update writes outside the project. const update = a.loaded.config.update - void autoUpdate({ config: update }).then((r) => { + if (!isLocal()) void autoUpdate({ config: update }).then((r) => { if (r.kind === "installed") { view.info(`Updated to ${r.version} — /reload to restart in the latest ${update?.channel ?? "stable"}`) bus.emit({ type: "flash", text: `updated to ${r.version} · /reload` }) diff --git a/src/voice/audio.ts b/src/voice/audio.ts index 9dd8501..151e659 100644 --- a/src/voice/audio.ts +++ b/src/voice/audio.ts @@ -3,8 +3,8 @@ // it comes (the level meter, and stopping on silence, as Hermes Agent's voice mode does: // tools/voice_mode.py, MIT, © Nous Research) and the WAV file is written here. import { spawn, type ChildProcess } from "node:child_process" -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" +import { rmSync, writeFileSync } from "node:fs" +import { makeTempDir } from "../config/paths.ts" import { join } from "node:path" import type { Voice } from "../config/schema.ts" @@ -270,7 +270,7 @@ export class Recorder { /** Play a WAV; `stop()` cuts it off. */ export function play(command: string[], audio: Uint8Array): { done: Promise; stop(): void } { - const dir = mkdtempSync(join(tmpdir(), "lembas-say-")) + const dir = makeTempDir("lembas-say-") tempDirs.add(dir) const clean = () => { tempDirs.delete(dir) diff --git a/src/voice/speech.ts b/src/voice/speech.ts index 54a31d0..b7e0522 100644 --- a/src/voice/speech.ts +++ b/src/voice/speech.ts @@ -1,11 +1,10 @@ // Speech to text and text to speech, over the providers voice.stt / voice.tts name. `webui` is a // LLeMbas instance's own (OpenAI's shapes at /v1, filled in by the loader); a half with a // `fallback` uses it when its provider cannot be reached. -import { mkdtempSync, readFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" +import { readFileSync, rmSync } from "node:fs" import { join } from "node:path" import type { Voice } from "../config/schema.ts" -import { expandHome } from "../config/paths.ts" +import { expandHome, makeTempDir } from "../config/paths.ts" import { runKeyCmd } from "../config/substitute.ts" import { joinUrl, request, tlsFor } from "../provider/http.ts" @@ -83,7 +82,7 @@ async function synthesizeWith(t: Tts, text: string, signal?: AbortSignal): Promi const provider = t.provider ?? "openai" if (provider === "piper-cli") { if (!t.model_path) throw new Error("voice.tts.model_path (a piper .onnx voice) is not set") - const dir = mkdtempSync(join(tmpdir(), "lembas-piper-")) + const dir = makeTempDir("lembas-piper-") const out = join(dir, "say.wav") try { const args = [t.command ?? "piper", "-m", expandHome(t.model_path), "-f", out, ...(t.speed && t.speed !== 1 ? ["--length-scale", String(1 / t.speed)] : [])] diff --git a/tests/local.test.ts b/tests/local.test.ts new file mode 100644 index 0000000..7da8f5b --- /dev/null +++ b/tests/local.test.ts @@ -0,0 +1,182 @@ +// 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 dialect: openai-chat\n base_url: http://127.0.0.1:9/v1\n models: { 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 connections are, 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 dialect: openai-chat\n base_url: http://127.0.0.1:8/v1\n models: { n: {} }\n", { mode: 0o600 }) + loaded = loadConfig() + expect(loaded.config.model).toBe("mine/m") + expect(Object.keys(loaded.connections.mine!.models)).toEqual(["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 dialect: openai-chat\n base_url: ${fake.url}\n models: { 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)