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
+44
-18
@@ -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<string, Instance>
|
||||
}
|
||||
|
||||
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<string, Instance> {
|
||||
function readIndex(dir: string): Record<string, Instance> {
|
||||
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<string, Instance> {
|
||||
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<string, Instance> {
|
||||
return readIndex(paths.config)
|
||||
}
|
||||
|
||||
function saveIndex(all: Record<string, Instance>) {
|
||||
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<string, unknown> = {
|
||||
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<LoginResu
|
||||
|
||||
const connection = connectionNameFor(discovery.base_url, discovery.connection)
|
||||
mkdirSync(keyDir(), { recursive: true, mode: 0o700 })
|
||||
writeFileSync(keyFile(connection), granted.access_token + "\n", { mode: 0o600 })
|
||||
chmodSync(keyFile(connection), 0o600)
|
||||
writeFileSync(ownKeyFile(connection), granted.access_token + "\n", { mode: 0o600 })
|
||||
chmodSync(ownKeyFile(connection), 0o600)
|
||||
const fetched = await fetchWebui(connection, discovery.base_url, granted.access_token, tls)
|
||||
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => 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<LogoutResult> {
|
||||
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<LogoutResult> {
|
||||
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())
|
||||
|
||||
Reference in new issue
Block a user