lembas -l / --local: everything in the project, nothing written elsewhere
Config, data and state are rooted at <project>/.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 <noreply@anthropic.com>
This commit is contained in:
27 files changed
+636
-105
No files matched your search
+58
-27
@@ -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<string, string> = {}
|
||||
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<string, unknown> = {}
|
||||
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 `<provider>/<model>` 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
|
||||
|
||||
Reference in new issue
Block a user