Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e70ff46d0
|
||
|
|
b75923e724
|
||
|
|
479f647cc9
|
No files matched your search
@@ -6,6 +6,43 @@ All notable changes to LLeMbas CLI. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] — 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- `lembas remote list | add <dir>… | remove <dir>… | enable | disable | mode <m> | terminal <on|off>`
|
||||
manages the `remote:` block without editing YAML, keeping the file's comments. `add` stores each
|
||||
directory as an absolute real path, skips one already covered by a root, refuses a missing one
|
||||
(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
|
||||
|
||||
- A running `lembas service` follows the `remote:` block without a restart: roots, mode, trust,
|
||||
terminals and `enabled` are read again whenever they are used (a stat of config.yaml; parsed only
|
||||
when it changed). When what the instance was told changes, the link is dialled again once nothing
|
||||
is working, so the web UI hears the new limits.
|
||||
- `lembas service` links even before any folder is allowed; it then accepts no session until one is
|
||||
added. With remote work off or no roots it tells the instance `roots: []` and refuses every
|
||||
session, directory and terminal; before, `service run` exited at once and the instance could not
|
||||
tell "remote off" from "not running".
|
||||
- Harness spec 1.0.1: `acp.md` says so.
|
||||
|
||||
## [1.1.0] — 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -37,6 +37,13 @@ page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-p
|
||||
protected path's `scope`.
|
||||
- A tool's other names, per project, are listed in its `aliases` and work as the tool.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
`acp.md`: a device links even with remote work off or no roots, saying `limits.roots: []` and
|
||||
refusing every session, directory and terminal; and one whose limits change dials the link again so
|
||||
the client hears the new ones. No change for a client: it already takes empty roots and a link that
|
||||
drops and comes back.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
The spec as described here: the shared tools with their scopes, risk classes, call blocks and
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.0.1
|
||||
@@ -217,6 +217,17 @@ Direction is who sends it. "→ result" marks a request; the others are notifica
|
||||
Sent once when the link opens, before `initialize`:
|
||||
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }`.
|
||||
|
||||
A device links whether its remote work is on or not, and with no roots at all. Off, or with no
|
||||
roots, it says `limits.roots: []` here and in `initialize`, and refuses every `session/new`, every
|
||||
`_lembas/directories` path and every `_lembas/terminal/open` (`-32602`, with the reason) as it does
|
||||
a directory outside its roots — so the client can tell a device that allows no folder yet from one
|
||||
that is not linked.
|
||||
|
||||
The device's limits apply to every request as they are at that moment. When what the hello and
|
||||
`initialize` said changes (the device's `remote:` block was edited — a folder added, remote work
|
||||
turned on), the device closes the link once no session is working and dials again at once, so the
|
||||
client hears the new limits.
|
||||
|
||||
### `_lembas/event` — agent → client
|
||||
|
||||
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lembas-cli",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
+2
-2
@@ -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<Driver> {
|
||||
export async function drive(opts: { cols: number; rows: number; bin?: string; home: string; cwd: string; args?: string[]; env?: Record<string, string> }): Promise<Driver> {
|
||||
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",
|
||||
})
|
||||
|
||||
+43
-1
@@ -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)
|
||||
@@ -58,6 +58,9 @@ export interface Limits {
|
||||
/** Its shell marks prompts, commands and the directory (`remote.terminal_integration`).
|
||||
* Absent: yes. */
|
||||
terminalIntegration?: boolean
|
||||
/** `remote.enabled`, where the limits are the device's (the link, the service's hub). False:
|
||||
* nothing new is opened or shared, whatever the roots say. Absent: not judged here. */
|
||||
enabled?: boolean
|
||||
/** The link a terminal holds for /remote on a machine whose remote work is off: only
|
||||
* the sessions shared from that terminal can be reached; the web UI opens none of its own. */
|
||||
sharedOnly?: boolean
|
||||
@@ -67,6 +70,8 @@ export interface Limits {
|
||||
export function refusalFor(L: Limits | undefined, dir: string): string | undefined {
|
||||
if (!L) return undefined
|
||||
if (L.sharedOnly) return "remote work is off on this machine (remote.enabled); only the sessions its terminal shares (/remote) can be reached"
|
||||
if (L.enabled === false) return "remote work is off on this machine — lembas remote enable there (remote.enabled)"
|
||||
if (!L.roots.length) return "this device allows no folders for remote work yet — lembas remote add <dir> there (remote.roots)"
|
||||
if (!underRoots(dir, L.roots)) return `${dir} is outside the directories this device accepts remote work in (remote.roots)`
|
||||
if (L.requireTrust && trustOf(findProject(dir).root) !== "trusted" && !trustedWithin(real(dir)))
|
||||
return `${dir} is not in a trusted project on this device — trust it there first (lembas trust)`
|
||||
@@ -95,6 +100,7 @@ export { REMOTE_META }
|
||||
export function deletableFor(L: Limits | undefined, root: string, shown: boolean): string | undefined {
|
||||
if (!L || shown) return undefined
|
||||
if (L.sharedOnly) return "remote work is off on this machine (remote.enabled); only the sessions its terminal shares (/remote) can be reached"
|
||||
if (L.enabled === false) return "remote work is off on this machine — lembas remote enable there (remote.enabled)"
|
||||
if (!underRoots(root, L.roots)) return `${root} is outside the directories this device accepts remote work in (remote.roots)`
|
||||
return undefined
|
||||
}
|
||||
|
||||
+4
-2
@@ -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<boolean>
|
||||
|
||||
/** Connect to the hub, if one runs. */
|
||||
export function connectHub(path = hubSocket()): Promise<Peer | undefined> {
|
||||
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<boolean> {
|
||||
if (isLocal()) return false
|
||||
mkdirSync(paths.state, { recursive: true })
|
||||
if (existsSync(path)) {
|
||||
if (await answers(path)) return false
|
||||
|
||||
+88
-17
@@ -8,7 +8,8 @@
|
||||
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { hostname } from "node:os"
|
||||
import { loadConfig } from "../config/load.ts"
|
||||
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"
|
||||
@@ -20,19 +21,51 @@ import { Peer, type Transport } from "./rpc.ts"
|
||||
|
||||
export class LinkError extends Error {}
|
||||
|
||||
export function limitsFromConfig(): Limits & { enabled: boolean } {
|
||||
const r = loadConfig().config.remote ?? {}
|
||||
/** The device's limits, read from the global `remote:` block each time one is looked at — so a
|
||||
* running service follows `lembas remote …` (or an edit of config.yaml) without a restart. Every
|
||||
* property is a getter: never spread this object, or it stops following.
|
||||
*
|
||||
* With remote work off the roots are none, whatever `remote.roots` lists: the link is up, the
|
||||
* instance is told `roots: []`, and every session, directory and terminal is refused.
|
||||
*
|
||||
* `sharedOnly`: the link a terminal holds for /remote. While remote work is off it reaches only
|
||||
* what that terminal shares; once it is on, the roots count like the service's. */
|
||||
export function limitsFromConfig(o: { sharedOnly?: boolean } = {}): Limits & { enabled: boolean } {
|
||||
const r = () => remoteBlock()
|
||||
return {
|
||||
enabled: r.enabled === true,
|
||||
roots: r.roots ?? [],
|
||||
maxMode: (r.max_mode ?? "edit") as Mode,
|
||||
approvalTimeoutMs: (r.approval_timeout ?? 600) * 1000,
|
||||
requireTrust: r.require_trust !== false,
|
||||
terminal: r.terminal === true,
|
||||
terminalIntegration: r.terminal_integration !== false,
|
||||
get enabled() {
|
||||
return r().enabled === true
|
||||
},
|
||||
get sharedOnly() {
|
||||
return o.sharedOnly === true && r().enabled !== true
|
||||
},
|
||||
get roots() {
|
||||
return r().enabled === true ? (r().roots ?? []) : []
|
||||
},
|
||||
get maxMode() {
|
||||
return (r().max_mode ?? "edit") as Mode
|
||||
},
|
||||
get approvalTimeoutMs() {
|
||||
return (r().approval_timeout ?? 600) * 1000
|
||||
},
|
||||
get requireTrust() {
|
||||
return r().require_trust !== false
|
||||
},
|
||||
get terminal() {
|
||||
return r().terminal === true
|
||||
},
|
||||
get terminalIntegration() {
|
||||
return r().terminal_integration !== false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** What the instance is told when the link opens (the hello, initialize's limits and
|
||||
* capabilities): when it changes, the link is dialled again so the instance hears the new one. */
|
||||
function announced(L: Limits & { enabled: boolean }): string {
|
||||
return JSON.stringify([L.enabled, L.sharedOnly, L.roots, L.maxMode, L.requireTrust, L.terminal])
|
||||
}
|
||||
|
||||
export function pickInstance(name?: string): Instance {
|
||||
const all = instances()
|
||||
if (name) {
|
||||
@@ -82,14 +115,20 @@ export interface LinkOptions {
|
||||
/** A terminal's link for /remote: it runs whatever `remote.enabled` says; with remote
|
||||
* work off, only the sessions the terminal shares can be reached. */
|
||||
sharedOnly?: boolean
|
||||
/** Only for tests: how often the config is looked at while linked (default 2 s). */
|
||||
pollMs?: number
|
||||
}
|
||||
|
||||
/** Dial, serve, and dial again, until stopped or refused. Resolves when it stops for good. */
|
||||
/** Dial, serve, and dial again, until stopped or refused. Resolves when it stops for good.
|
||||
*
|
||||
* It links whether remote work is on or not, and with no roots at all: the instance then hears
|
||||
* `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<LinkStatus> {
|
||||
const configured = limitsFromConfig()
|
||||
const limits = o.sharedOnly && !configured.enabled ? { ...configured, roots: [], sharedOnly: true } : configured
|
||||
if (!o.sharedOnly && !limits.enabled) throw new LinkError("remote work is off on this machine — set remote.enabled: true (and remote.roots) in ~/.config/lembas/config.yaml")
|
||||
if (!o.sharedOnly && !limits.roots.length) throw new LinkError("remote.roots is empty, so no directory could be worked in — list the directories a remote session may use")
|
||||
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)
|
||||
const token = readFileSync(keyFile(instance.connection), "utf8").trim()
|
||||
// A service runs for weeks: the webui connections' models are read again every ten minutes, so a
|
||||
@@ -121,8 +160,10 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
||||
rememberProtocols(instance.connection, protocols)
|
||||
} catch {}
|
||||
const protocol = downgraded ? 1 : linkProtocol(protocols)
|
||||
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean }>((resolve) => {
|
||||
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
|
||||
let opened = false
|
||||
let changed = false
|
||||
let watch: ReturnType<typeof setInterval> | undefined
|
||||
const ws = new WebSocket(linkUrl(instance), {
|
||||
headers: { authorization: `Bearer ${token}`, "user-agent": `lembas-cli/${VERSION}` },
|
||||
...(ca ? { tls: { ca } } : {}),
|
||||
@@ -142,9 +183,21 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
||||
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust },
|
||||
})
|
||||
status("linked")
|
||||
// The limits apply at once, each time they are looked at. What the instance was told at
|
||||
// the start is told again by dialling again, once nothing is working here: the sessions
|
||||
// are the hub's, so they outlive the link either way.
|
||||
const said = announced(limits)
|
||||
watch = setInterval(() => {
|
||||
if (changed || announced(limits) === said || o.hub?.busy()) return
|
||||
changed = true
|
||||
ws.close()
|
||||
}, poll)
|
||||
watch.unref?.()
|
||||
})
|
||||
ws.addEventListener("close", (e) => {
|
||||
o.signal?.removeEventListener("abort", stop)
|
||||
clearInterval(watch)
|
||||
if (changed) return resolve({ changed: true })
|
||||
// 4401/4403: the instance's word that this token is done; 1008 policy, likewise.
|
||||
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" })
|
||||
// 4400: the hello's protocol is not one it speaks. Dialling again would say the same.
|
||||
@@ -154,6 +207,11 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
||||
ws.addEventListener("error", () => {})
|
||||
})
|
||||
if (o.signal?.aborted) break
|
||||
if (outcome.changed) {
|
||||
status("waiting", "the remote settings changed; linking again")
|
||||
wait = first
|
||||
continue
|
||||
}
|
||||
if (outcome.final && protocol === 2 && !downgraded) {
|
||||
downgraded = true
|
||||
status("waiting", "the instance does not take link protocol 2; trying again with 1")
|
||||
@@ -164,9 +222,22 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
||||
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
|
||||
}
|
||||
status("waiting", `${outcome.dropped}; trying again in ${Math.round(wait / 1000)}s`)
|
||||
await new Promise((r) => setTimeout(r, wait))
|
||||
await sleep(wait, o.signal)
|
||||
wait = Math.min(ceiling, wait * 2)
|
||||
}
|
||||
clearInterval(freshen)
|
||||
return status("stopped", "stopped")
|
||||
}
|
||||
|
||||
/** Wait, or stop waiting when `signal` aborts. */
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((r) => {
|
||||
const t = setTimeout(done, ms)
|
||||
function done() {
|
||||
clearTimeout(t)
|
||||
signal?.removeEventListener("abort", done)
|
||||
r()
|
||||
}
|
||||
signal?.addEventListener("abort", done, { once: true })
|
||||
})
|
||||
}
|
||||
+3
-2
@@ -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)]
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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() })
|
||||
|
||||
+61
-4
@@ -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
|
||||
@@ -40,6 +42,12 @@ Usage:
|
||||
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
||||
lembas service install | uninstall | status | logs [-f] | run
|
||||
work from a LLeMbas instance as a background service (remote:)
|
||||
lembas remote list | add <dir>… | remove <dir>… | enable | disable
|
||||
| mode <manual|edit|auto|plan> | terminal <on|off> [--json]
|
||||
what the instance may do here (remote:): the directories it may
|
||||
work in (add turns remote work on), the most permissive mode,
|
||||
terminals; a running service applies a change within seconds
|
||||
(--json: one object, {enabled, roots, max_mode, terminal})
|
||||
lembas mcp [list] connect the MCP servers and list their tools and prompts
|
||||
lembas mcp auth <server> sign in to a remote MCP server (OAuth)
|
||||
lembas mcp logout <server> forget a server's stored sign-in
|
||||
@@ -55,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
|
||||
@@ -128,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<string, string> = { 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") {
|
||||
@@ -151,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")
|
||||
@@ -168,6 +222,8 @@ async function main() {
|
||||
|
||||
if (cmd === "login" || cmd === "logout") return void (await loginCommand(cmd, argv.slice(1)))
|
||||
|
||||
if (cmd === "remote") process.exit((await import("./remote.ts")).remoteCommand(argv.slice(1)))
|
||||
|
||||
// A webui connection's models are the instance's as they are now: read before anything that
|
||||
// loads the configuration to use it. A few seconds at most; an instance that does not answer
|
||||
// leaves what it said last.
|
||||
@@ -391,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]} <key>${argv[1] === "set" ? " <value> [--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)
|
||||
|
||||
+40
-9
@@ -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,16 +260,18 @@ 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
|
||||
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") {
|
||||
@@ -292,7 +294,6 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
|
||||
broken[name] = (e as Error).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The webui connection a voice half or the search means: the one named, or the only one. A
|
||||
* string says why there is none. */
|
||||
@@ -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
|
||||
|
||||
+48
-2
@@ -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_<name>. */
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// The global `remote:` block, read on its own and cheaply: what a LLeMbas instance may ask of this
|
||||
// machine over the link. A running service asks for it whenever it decides something (a session
|
||||
// opened, a directory listed, a terminal shared), so `lembas remote …` or an edit of config.yaml
|
||||
// applies without a restart. The file is parsed again only when its mtime or size changed.
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
||||
import { isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { parse } from "yaml"
|
||||
import { ConfigError } from "./load.ts"
|
||||
import { expandHome, paths } from "./paths.ts"
|
||||
import { Config, type Mode } from "./schema.ts"
|
||||
import { substituteDeep } from "./substitute.ts"
|
||||
|
||||
export type RemoteBlock = NonNullable<Config["remote"]>
|
||||
|
||||
const RemoteSchema = Config.shape.remote
|
||||
|
||||
let cache: { file: string; stamp: string; block: RemoteBlock; error?: string } | undefined
|
||||
|
||||
function stampOf(file: string): string {
|
||||
try {
|
||||
const s = statSync(file)
|
||||
return `${s.mtimeMs}:${s.size}:${s.ino}`
|
||||
} catch {
|
||||
return "absent"
|
||||
}
|
||||
}
|
||||
|
||||
/** The `remote:` block of the global config.yaml (the local one in local mode). A file that cannot
|
||||
* be read or is invalid keeps what was read from it last (nothing, the first time: remote work off),
|
||||
* so a half-written file never opens or closes anything; `strict` throws instead. */
|
||||
export function remoteBlock(o: { strict?: boolean } = {}): RemoteBlock {
|
||||
const file = join(paths.config, "config.yaml")
|
||||
const stamp = stampOf(file)
|
||||
if (!cache || cache.file !== file || cache.stamp !== stamp) {
|
||||
const last = cache?.file === file ? cache.block : {}
|
||||
let block: RemoteBlock = {}
|
||||
let error: string | undefined
|
||||
if (stamp !== "absent") {
|
||||
try {
|
||||
const raw = parse(readFileSync(file, "utf8")) as { remote?: unknown } | null
|
||||
const value = raw && typeof raw === "object" ? raw.remote : undefined
|
||||
const r = RemoteSchema.safeParse(value === undefined || value === null ? undefined : substituteDeep(value, "remote"))
|
||||
if (r.success) block = r.data ?? {}
|
||||
else {
|
||||
error = `${file}: remote is invalid: ${r.error.issues.map((i) => `${["remote", ...i.path].join(".")}: ${i.message}`).join("; ")}`
|
||||
block = last
|
||||
}
|
||||
} catch (e) {
|
||||
error = `${file}: ${(e as Error).message}`
|
||||
block = last
|
||||
}
|
||||
}
|
||||
cache = { file, stamp, block, ...(error ? { error } : {}) }
|
||||
}
|
||||
if (o.strict && cache.error) throw new ConfigError(cache.error)
|
||||
return cache.block
|
||||
}
|
||||
|
||||
/** What `lembas remote list --json` prints: the values in force, defaults applied. */
|
||||
export interface RemoteState {
|
||||
enabled: boolean
|
||||
roots: string[]
|
||||
max_mode: Mode
|
||||
terminal: boolean
|
||||
}
|
||||
|
||||
export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
|
||||
return {
|
||||
enabled: b.enabled === true,
|
||||
roots: (b.roots ?? []).map((r) => resolve(expandHome(r))),
|
||||
max_mode: b.max_mode ?? "edit",
|
||||
terminal: b.terminal === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** A path as it really is (links followed), or as written where it does not exist. */
|
||||
export function realOrResolved(p: string): string {
|
||||
const abs = resolve(expandHome(p))
|
||||
try {
|
||||
return realpathSync(abs)
|
||||
} catch {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
|
||||
/** `dir` is one of `roots` or under one of them. */
|
||||
export function coveredBy(dir: string, roots: string[]): string | undefined {
|
||||
const target = realOrResolved(dir)
|
||||
return roots.find((r) => {
|
||||
const rel = relative(realOrResolved(r), target)
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
||||
})
|
||||
}
|
||||
|
||||
/** A directory given on the command line, as stored in `remote.roots`: absolute, links followed.
|
||||
* A string says why it cannot be one. */
|
||||
export function rootFor(dir: string, cwd = process.cwd()): string | { error: string } {
|
||||
const abs = resolve(cwd, expandHome(dir))
|
||||
if (!existsSync(abs)) return { error: `${dir}: no such directory` }
|
||||
if (!statSync(abs).isDirectory()) return { error: `${dir}: not a directory` }
|
||||
return realpathSync(abs)
|
||||
}
|
||||
+10
-3
@@ -1,15 +1,22 @@
|
||||
// Settings the TUI remembers (/personality, /theme) go into the global config.yaml, keeping the
|
||||
// comments and layout of whatever the user wrote there.
|
||||
// Settings the TUI remembers (/personality, /theme) and `lembas remote` go into the global
|
||||
// config.yaml, keeping the comments and layout of whatever the user wrote there.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { parseDocument } from "yaml"
|
||||
import { paths } from "./paths.ts"
|
||||
|
||||
export function setGlobalConfig(path: string[], value: unknown) {
|
||||
updateGlobalConfig([[path, value]])
|
||||
}
|
||||
|
||||
/** Several values at once, in one write: a reader (a running service) never sees half of them.
|
||||
* Written in place, not renamed over: a config.yaml that is a link into a dotfiles repository
|
||||
* stays one, and keeps its mode. A reader that catches it half written keeps what it read last. */
|
||||
export function updateGlobalConfig(edits: [string[], unknown][]) {
|
||||
const file = join(paths.config, "config.yaml")
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
|
||||
if (!doc.contents) doc.contents = doc.createNode({}) as never
|
||||
doc.setIn(path, value)
|
||||
for (const [path, value] of edits) doc.setIn(path, value)
|
||||
writeFileSync(file, doc.toString())
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
|
||||
+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())
|
||||
|
||||
@@ -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<Personalization | undefined> {
|
||||
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 })
|
||||
|
||||
+9
-10
@@ -14,10 +14,10 @@
|
||||
// LLeMbas CLI speaks to it as an ordinary openai-chat connection at <url>/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<Refreshed[]> {
|
||||
const file = join(paths.config, "connections.yaml")
|
||||
if (!existsSync(file)) return []
|
||||
let raw: Record<string, unknown>
|
||||
// In local mode the user's own connections too, read only; the local file's entries win.
|
||||
const raw: Record<string, unknown> = {}
|
||||
for (const file of connectionFiles())
|
||||
try {
|
||||
raw = ((parse(readFileSync(file, "utf8")) ?? {}) as { connections?: Record<string, unknown> }).connections ?? {}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
Object.assign(raw, ((parse(readFileSync(file, "utf8")) ?? {}) as { connections?: Record<string, unknown> }).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<Refreshed> => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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).
|
||||
* `<config>/` 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("<config>/") ? resolve(paths.config, g.path.slice("<config>/".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("<config>/")
|
||||
? configs.map((c): [string, string] => [resolve(c, g.path.slice("<config>/".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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+14
-4
@@ -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<Message, { role: "assistant" }>
|
||||
|
||||
@@ -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=<dir>: keep each request body and its raw SSE, to become test fixtures. */
|
||||
export function record(body: ReadableStream<Uint8Array>, ref: string, request: unknown): ReadableStream<Uint8Array> {
|
||||
const dir = env("RECORD")
|
||||
const dir = recordDir()
|
||||
if (!dir) return body
|
||||
const [a, b] = body.tee()
|
||||
const name = recordName(ref)
|
||||
|
||||
+18
-8
@@ -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<string, string | undefined>): 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<never> {
|
||||
export async function restart(args: string[], env?: Record<string, string | undefined>): Promise<never> {
|
||||
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)
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// `lembas remote`: the global `remote:` block — what a LLeMbas instance may ask of this machine over
|
||||
// the link — managed without editing YAML, for people and for programs (LLeMbas Desktop) alike.
|
||||
//
|
||||
// lembas remote list [--json] what is in force, defaults applied
|
||||
// lembas remote add <dir>… [--json] allow remote sessions there (turns remote work on)
|
||||
// lembas remote remove <dir>… [--json] stop allowing them there
|
||||
// lembas remote enable | disable [--json] remote.enabled
|
||||
// lembas remote mode <manual|edit|auto|plan> remote.max_mode
|
||||
// lembas remote terminal <on|off> remote.terminal
|
||||
//
|
||||
// Every change goes through the global config writer, which keeps the file's comments. A running
|
||||
// `lembas service` reads the block again whenever it uses it, so a change applies to it within a few
|
||||
// seconds, no restart (acp/link.ts). --json prints one object: {enabled, roots, max_mode, terminal}.
|
||||
import { resolve } from "node:path"
|
||||
import { ConfigError } from "./config/load.ts"
|
||||
import { expandHome } from "./config/paths.ts"
|
||||
import { coveredBy, realOrResolved, remoteBlock, remoteState, rootFor, type RemoteState } from "./config/remote.ts"
|
||||
import { MODES, type Mode } from "./config/schema.ts"
|
||||
import { updateGlobalConfig } from "./config/write.ts"
|
||||
|
||||
export const REMOTE_USAGE = `usage: lembas remote list | add <dir>… | remove <dir>… | enable | disable | mode <${MODES.join("|")}> | terminal <on|off> [--json]`
|
||||
|
||||
export interface Io {
|
||||
out: (line: string) => void
|
||||
err: (line: string) => void
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
const stdio: Io = { out: (l) => console.log(l), err: (l) => process.stderr.write(`lembas: ${l}\n`) }
|
||||
|
||||
function describe(s: RemoteState): string[] {
|
||||
return [
|
||||
`remote work: ${s.enabled ? "on" : "off"}`,
|
||||
s.roots.length ? `roots:\n${s.roots.map((r) => ` ${r}`).join("\n")}` : "roots: (none: no directory accepts remote work)",
|
||||
`max mode: ${s.max_mode}`,
|
||||
`terminal: ${s.terminal ? "on" : "off"}`,
|
||||
]
|
||||
}
|
||||
|
||||
/** Runs one `lembas remote …` command; the exit status. */
|
||||
export function remoteCommand(argv: string[], io: Io = stdio): number {
|
||||
const json = argv.includes("--json")
|
||||
const args = argv.filter((a) => a !== "--json")
|
||||
const [sub = "list", ...rest] = args
|
||||
const said: string[] = []
|
||||
let wrote = false
|
||||
const show = () => {
|
||||
const s = remoteState(remoteBlock())
|
||||
if (json) io.out(JSON.stringify(s))
|
||||
else for (const l of [...said, ...describe(s), ...(wrote ? ["(a running lembas service applies this within seconds)"] : [])]) io.out(l)
|
||||
return 0
|
||||
}
|
||||
let block
|
||||
try {
|
||||
block = remoteBlock({ strict: true })
|
||||
} catch (e) {
|
||||
if (e instanceof ConfigError) return io.err(e.message), 1
|
||||
throw e
|
||||
}
|
||||
const write = (edits: [string[], unknown][]) => {
|
||||
updateGlobalConfig(edits.map(([k, v]) => [["remote", ...k], v]))
|
||||
wrote = true
|
||||
}
|
||||
|
||||
if (sub === "list") {
|
||||
if (rest.length) return io.err(REMOTE_USAGE), 2
|
||||
return show()
|
||||
}
|
||||
|
||||
if (sub === "add") {
|
||||
if (!rest.length) return io.err("usage: lembas remote add <dir>…"), 2
|
||||
const roots = [...(block.roots ?? [])]
|
||||
const added: string[] = []
|
||||
const bad: string[] = []
|
||||
for (const dir of rest) {
|
||||
const r = rootFor(dir, io.cwd)
|
||||
if (typeof r !== "string") {
|
||||
bad.push(r.error)
|
||||
continue
|
||||
}
|
||||
const by = coveredBy(r, roots)
|
||||
if (by) {
|
||||
said.push(realOrResolved(by) === r ? `${r}: already a root` : `${r}: already covered by ${by}`)
|
||||
continue
|
||||
}
|
||||
roots.push(r)
|
||||
added.push(r)
|
||||
}
|
||||
// All or nothing: a program that asked for three directories never gets two of them.
|
||||
if (bad.length) {
|
||||
for (const b of bad) io.err(b)
|
||||
return 1
|
||||
}
|
||||
const edits: [string[], unknown][] = []
|
||||
if (added.length) {
|
||||
edits.push([["roots"], roots])
|
||||
for (const r of added) said.push(`added ${r}`)
|
||||
}
|
||||
if (block.enabled !== true) {
|
||||
edits.push([["enabled"], true])
|
||||
said.push("remote work is now on (remote.enabled)")
|
||||
}
|
||||
if (edits.length) write(edits)
|
||||
return show()
|
||||
}
|
||||
|
||||
if (sub === "remove") {
|
||||
if (!rest.length) return io.err("usage: lembas remote remove <dir>…"), 2
|
||||
const roots = block.roots ?? []
|
||||
const gone = new Set<string>()
|
||||
const bad: string[] = []
|
||||
for (const dir of rest) {
|
||||
const want = realOrResolved(resolve(io.cwd ?? process.cwd(), expandHome(dir)))
|
||||
const match = roots.filter((r) => realOrResolved(r) === want)
|
||||
if (!match.length) bad.push(`${dir}: not one of the remote roots${roots.length ? ` (${roots.join(", ")})` : ""}`)
|
||||
for (const m of match) gone.add(m)
|
||||
}
|
||||
if (bad.length) {
|
||||
for (const b of bad) io.err(b)
|
||||
return 1
|
||||
}
|
||||
write([[["roots"], roots.filter((r) => !gone.has(r))]])
|
||||
for (const r of gone) said.push(`removed ${r}`)
|
||||
if (gone.size === roots.length && block.enabled === true) said.push("no roots are left: remote sessions cannot be opened anywhere until one is added")
|
||||
return show()
|
||||
}
|
||||
|
||||
if (sub === "enable" || sub === "disable") {
|
||||
if (rest.length) return io.err(REMOTE_USAGE), 2
|
||||
write([[["enabled"], sub === "enable"]])
|
||||
if (sub === "enable" && !(block.roots ?? []).length) said.push("no roots yet: lembas remote add <dir> to say where remote sessions may work")
|
||||
return show()
|
||||
}
|
||||
|
||||
if (sub === "mode") {
|
||||
const m = rest[0] as Mode | undefined
|
||||
if (rest.length !== 1 || !(MODES as readonly string[]).includes(m!)) return io.err(`usage: lembas remote mode <${MODES.join("|")}>`), 2
|
||||
write([[["max_mode"], m]])
|
||||
return show()
|
||||
}
|
||||
|
||||
if (sub === "terminal") {
|
||||
const v = rest[0]
|
||||
if (rest.length !== 1 || (v !== "on" && v !== "off")) return io.err("usage: lembas remote terminal <on|off>"), 2
|
||||
write([[["terminal"], v === "on"]])
|
||||
return show()
|
||||
}
|
||||
|
||||
io.err(REMOTE_USAGE)
|
||||
return 2
|
||||
}
|
||||
+5
-2
@@ -170,6 +170,7 @@ export async function run(instance?: string): Promise<number> {
|
||||
// The hub: the terminals on this machine share their sessions through the service. A
|
||||
// terminal that holds the link itself for /remote keeps it until it closes; one link per device,
|
||||
// so the service waits for it rather than knocking it off.
|
||||
// The limits are live: `lembas remote …` applies to this process within seconds (link.ts).
|
||||
const hub = new Hub({ service: true, limits: limitsFromConfig() })
|
||||
let said = false
|
||||
while (!stop.signal.aborted && !(await hub.listen())) {
|
||||
@@ -198,6 +199,8 @@ export async function run(instance?: string): Promise<number> {
|
||||
}, 30_000)
|
||||
watch?.unref?.()
|
||||
try {
|
||||
// Remote work off or no roots: linked all the same, telling the instance `roots: []` and
|
||||
// refusing every session; `lembas remote add <dir>` or `enable` makes it serve, no restart.
|
||||
const end = await runLink({
|
||||
instance,
|
||||
hub,
|
||||
@@ -212,8 +215,8 @@ export async function run(instance?: string): Promise<number> {
|
||||
return updated ? RESTART_FOR_UPDATE : 0
|
||||
} catch (e) {
|
||||
console.error(`lembas service: ${(e as Error).message}`)
|
||||
// A configuration that cannot work is not fixed by restarting: exit 0 so Restart=on-failure
|
||||
// leaves it, and the journal says why.
|
||||
// A configuration that cannot work (not logged in) is not fixed by restarting: exit 0 so
|
||||
// Restart=on-failure leaves it, and the journal says why.
|
||||
return e instanceof LinkError ? 0 : 1
|
||||
} finally {
|
||||
clearInterval(watch)
|
||||
|
||||
+2
-1
@@ -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}`
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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(() => {})
|
||||
|
||||
@@ -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:
|
||||
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
||||
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
|
||||
<span style={{ fg: ctx().color }}>{ctx().text}</span>
|
||||
<span style={{ fg: c().statusDim }}> · {basename(app.project.root)}{p.branch ? `@${p.branch}` : ""}</span>
|
||||
<span style={{ fg: c().statusDim }}> · </span>
|
||||
<span style={{ fg: c().info }}>{isLocal() ? "local " : ""}</span>
|
||||
<span style={{ fg: c().statusDim }}>{basename(app.project.root)}{p.branch ? `@${p.branch}` : ""}</span>
|
||||
</text>
|
||||
}
|
||||
>
|
||||
|
||||
+3
-1
@@ -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` })
|
||||
|
||||
+3
-3
@@ -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<void>; stop(): void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lembas-say-"))
|
||||
const dir = makeTempDir("lembas-say-")
|
||||
tempDirs.add(dir)
|
||||
const clean = () => {
|
||||
tempDirs.delete(dir)
|
||||
|
||||
+3
-4
@@ -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 <url>/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)] : [])]
|
||||
|
||||
+1
-9
@@ -6,7 +6,7 @@ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { AcpAgent, clampMode, underRoots, type Limits } from "../src/acp/agent.ts"
|
||||
import { LinkError, runLink } from "../src/acp/link.ts"
|
||||
import { runLink } from "../src/acp/link.ts"
|
||||
import { Peer, type Transport } from "../src/acp/rpc.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import { setTrust } from "../src/project/root.ts"
|
||||
@@ -300,14 +300,6 @@ test("an approval nobody answers is a denial", async () => {
|
||||
expect(fake.requests[1].messages.at(-1).content).toContain("Nobody answered")
|
||||
})
|
||||
|
||||
test("the link: off unless the device says so", async () => {
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "")
|
||||
await expect(runLink()).rejects.toBeInstanceOf(LinkError)
|
||||
writeFileSync(join(paths.config, "config.yaml"), "remote:\n enabled: true\n")
|
||||
await expect(runLink()).rejects.toThrow("remote.roots is empty")
|
||||
})
|
||||
|
||||
test("the link dials out with the token, says hello, is driven, and stops when refused", async () => {
|
||||
fake = fakeProvider([{ chunks: [delta({ content: "linked reply" })] }])
|
||||
const root = project()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,228 @@
|
||||
// `lembas remote`: the remote: block managed without editing YAML, and a running link that follows it.
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { refusalFor } from "../src/acp/agent.ts"
|
||||
import { limitsFromConfig, runLink, type LinkStatus } from "../src/acp/link.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import { remoteCommand } from "../src/remote.ts"
|
||||
import { setTrust } from "../src/project/root.ts"
|
||||
|
||||
const stops: (() => void)[] = []
|
||||
afterEach(() => {
|
||||
for (const s of stops.splice(0)) s()
|
||||
})
|
||||
|
||||
const file = () => join(paths.config, "config.yaml")
|
||||
|
||||
function fresh(text = "") {
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(file(), text)
|
||||
}
|
||||
|
||||
function dir(): string {
|
||||
return realpathSync(mkdtempSync(join(tmpdir(), "lembas-remote-")))
|
||||
}
|
||||
|
||||
function run(...args: string[]) {
|
||||
const out: string[] = []
|
||||
const err: string[] = []
|
||||
const code = remoteCommand(args, { out: (l) => void out.push(l), err: (l) => void err.push(l) })
|
||||
return { code, out, err, json: () => JSON.parse(out.join("\n")) }
|
||||
}
|
||||
|
||||
test("list --json: one object, the defaults applied", () => {
|
||||
fresh()
|
||||
const r = run("list", "--json")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.out).toHaveLength(1)
|
||||
expect(r.json()).toEqual({ enabled: false, roots: [], max_mode: "edit", terminal: false })
|
||||
// Without --json, lines a person reads.
|
||||
const plain = run("list")
|
||||
expect(plain.out.join("\n")).toContain("remote work: off")
|
||||
expect(plain.out.join("\n")).toContain("max mode: edit")
|
||||
})
|
||||
|
||||
test("add: absolute real paths, turns remote work on, skips duplicates and covered dirs", () => {
|
||||
fresh()
|
||||
const a = dir()
|
||||
const b = dir()
|
||||
mkdirSync(join(a, "sub"))
|
||||
const link = join(dir(), "link")
|
||||
symlinkSync(b, link)
|
||||
const r = run("add", a, link, "--json")
|
||||
expect(r.code).toBe(0)
|
||||
// The link is stored as where it leads.
|
||||
expect(r.json()).toEqual({ enabled: true, roots: [a, b], max_mode: "edit", terminal: false })
|
||||
// Again, and a directory inside one: nothing more.
|
||||
const again = run("add", a, join(a, "sub"))
|
||||
expect(again.code).toBe(0)
|
||||
expect(again.out.join("\n")).toContain("already a root")
|
||||
expect(again.out.join("\n")).toContain(`already covered by ${a}`)
|
||||
expect(run("list", "--json").json().roots).toEqual([a, b])
|
||||
// Said in words when it turned remote work on; not again.
|
||||
fresh()
|
||||
expect(run("add", a).out.join("\n")).toContain("remote work is now on")
|
||||
expect(run("add", b).out.join("\n")).not.toContain("remote work is now on")
|
||||
})
|
||||
|
||||
test("add: a missing directory or a file refuses the whole command, writing nothing", () => {
|
||||
fresh("remote:\n roots: []\n")
|
||||
const a = dir()
|
||||
writeFileSync(join(a, "f"), "")
|
||||
const r = run("add", a, join(a, "missing"), join(a, "f"), "--json")
|
||||
expect(r.code).toBe(1)
|
||||
expect(r.out).toEqual([])
|
||||
expect(r.err.join("\n")).toContain("no such directory")
|
||||
expect(r.err.join("\n")).toContain("not a directory")
|
||||
expect(readFileSync(file(), "utf8")).toBe("remote:\n roots: []\n")
|
||||
})
|
||||
|
||||
test("remove: compared after resolving; an unknown one is an error and changes nothing", () => {
|
||||
const a = dir()
|
||||
const b = dir()
|
||||
fresh(`remote:\n enabled: true\n roots: [${a}, ${b}]\n`)
|
||||
const link = join(dir(), "to-a")
|
||||
symlinkSync(a, link)
|
||||
const r = run("remove", link, "--json")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.json().roots).toEqual([b])
|
||||
const bad = run("remove", b, "/nowhere/at/all")
|
||||
expect(bad.code).toBe(1)
|
||||
expect(bad.err.join("\n")).toContain("not one of the remote roots")
|
||||
expect(run("list", "--json").json().roots).toEqual([b])
|
||||
// A root whose directory is gone can still be removed.
|
||||
fresh("remote:\n roots: [/gone/long/ago]\n")
|
||||
expect(run("remove", "/gone/long/ago", "--json").json().roots).toEqual([])
|
||||
})
|
||||
|
||||
test("enable, disable, mode and terminal; usage errors", () => {
|
||||
fresh()
|
||||
expect(run("enable", "--json").json().enabled).toBe(true)
|
||||
expect(run("disable", "--json").json().enabled).toBe(false)
|
||||
expect(run("mode", "plan", "--json").json().max_mode).toBe("plan")
|
||||
expect(run("terminal", "on", "--json").json().terminal).toBe(true)
|
||||
expect(run("terminal", "off", "--json").json().terminal).toBe(false)
|
||||
expect(run("mode", "yolo").code).toBe(2)
|
||||
expect(run("terminal", "maybe").code).toBe(2)
|
||||
expect(run("frobnicate").code).toBe(2)
|
||||
})
|
||||
|
||||
test("the file's comments and layout are kept", () => {
|
||||
const a = dir()
|
||||
fresh("# mine\ntheme: dark # the dark one\nremote:\n # where remote work may happen\n max_mode: manual\n")
|
||||
run("add", a)
|
||||
run("terminal", "on")
|
||||
const text = readFileSync(file(), "utf8")
|
||||
expect(text).toContain("# mine")
|
||||
expect(text).toContain("theme: dark # the dark one")
|
||||
expect(text).toContain("# where remote work may happen")
|
||||
expect(text).toContain("max_mode: manual")
|
||||
expect(text).toContain(a)
|
||||
})
|
||||
|
||||
test("the limits a running link holds follow the config without a restart", () => {
|
||||
const a = dir()
|
||||
setTrust(a, "trusted")
|
||||
fresh()
|
||||
const L = limitsFromConfig()
|
||||
expect(refusalFor(L, a)).toContain("remote work is off")
|
||||
run("add", a)
|
||||
expect(L.enabled).toBe(true)
|
||||
expect(L.roots).toEqual([a])
|
||||
expect(refusalFor(L, a)).toBeUndefined()
|
||||
run("mode", "plan")
|
||||
expect(L.maxMode).toBe("plan")
|
||||
run("disable")
|
||||
expect(refusalFor(L, a)).toContain("remote work is off")
|
||||
// A file broken mid-edit keeps what was read last, rather than opening or closing anything.
|
||||
writeFileSync(file(), "remote: [unterminated\n")
|
||||
expect(L.maxMode).toBe("plan")
|
||||
// /remote's link while remote work is off reaches only what its terminal shares; once on, the roots.
|
||||
fresh(`remote:\n roots: [${a}]\n`)
|
||||
const shared = limitsFromConfig({ sharedOnly: true })
|
||||
expect(shared.sharedOnly).toBe(true)
|
||||
expect(shared.roots).toEqual([])
|
||||
run("enable")
|
||||
expect(shared.sharedOnly).toBe(false)
|
||||
expect(shared.roots).toEqual([a])
|
||||
})
|
||||
|
||||
test("the service links with remote work off, says no roots and refuses everything, and serves once a folder is added — no restart", async () => {
|
||||
const a = dir()
|
||||
setTrust(a, "trusted")
|
||||
fresh()
|
||||
const hellos: any[] = []
|
||||
/** Answers by request id, from the link that is up now. */
|
||||
let answers = new Map<number, any>()
|
||||
let current: any
|
||||
let next = 1
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, srv) {
|
||||
const u = new URL(req.url)
|
||||
const base = `http://${u.host}`
|
||||
if (u.pathname === "/.well-known/lembas.json")
|
||||
return Response.json({ service: "lembas", version: "2.2.0", protocol: 1, protocols: [1, 2], harness_spec: "2.1.0", base_url: base, api: { openai: `${base}/v1` }, login: { device: { code: `${base}/c`, token: `${base}/t`, verify: `${base}/d` } } })
|
||||
if (u.pathname === "/api/devices/link" && srv.upgrade(req)) return
|
||||
return new Response("no", { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
current = ws
|
||||
answers = new Map()
|
||||
},
|
||||
message(_ws, msg) {
|
||||
const m = JSON.parse(String(msg))
|
||||
if (m.method === "_lembas/hello") hellos.push(m.params)
|
||||
else if (m.id !== undefined && !m.method) answers.set(m.id, m)
|
||||
},
|
||||
},
|
||||
})
|
||||
stops.push(() => server.stop(true))
|
||||
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: `http://127.0.0.1:${server.port}`, connection: "fake", logged_in_at: "" } } }))
|
||||
mkdirSync(join(paths.config, "lembas"), { recursive: true })
|
||||
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
|
||||
|
||||
const stop = new AbortController()
|
||||
stops.push(() => stop.abort())
|
||||
const states: LinkStatus[] = []
|
||||
const until = async (ok: () => boolean, what: string) => {
|
||||
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(25)
|
||||
if (!ok()) throw new Error(`timed out waiting for ${what}: ${JSON.stringify(states.map((s) => [s.state, s.detail]))}`)
|
||||
}
|
||||
/** A request from the instance over the link that is up, and its answer. */
|
||||
const ask = async (method: string, params: unknown) => {
|
||||
const id = next++
|
||||
current.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
|
||||
await until(() => answers.has(id), method)
|
||||
return answers.get(id)
|
||||
}
|
||||
const done = runLink({ pollMs: 20, backoffMs: [10, 20], signal: stop.signal, onStatus: (s) => void states.push(s) })
|
||||
// Off: linked all the same, saying it allows no folder, and refusing whatever is asked.
|
||||
await until(() => hellos.length === 1, "the first hello")
|
||||
expect(hellos[0].limits.roots).toEqual([])
|
||||
const init = await ask("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
|
||||
expect(init.result._meta.lembas.limits.roots).toEqual([])
|
||||
expect((await ask("_lembas/directories", {})).result.places).toEqual([])
|
||||
expect((await ask("_lembas/directories", { path: a })).result?.accepted ?? false).toBe(false)
|
||||
const refused = await ask("session/new", { cwd: a, mcpServers: [] })
|
||||
expect(refused.error.message).toContain("remote work is off")
|
||||
// Turned on from outside, as Desktop does: it links again, saying the new roots, and serves.
|
||||
run("add", a)
|
||||
await until(() => hellos.length === 2, "a second hello")
|
||||
expect(hellos[1].limits).toEqual({ roots: [a], max_mode: "edit", require_trust: true })
|
||||
expect(states.some((s) => s.detail === "the remote settings changed; linking again")).toBe(true)
|
||||
await ask("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
|
||||
expect((await ask("_lembas/directories", {})).result.places.map((p: any) => [p.path, p.accepted])).toEqual([[a, true]])
|
||||
// A change the instance was told about, and turned off again: each time a new hello.
|
||||
run("mode", "plan")
|
||||
await until(() => hellos.length === 3, "a third hello")
|
||||
expect(hellos[2].limits.max_mode).toBe("plan")
|
||||
run("disable")
|
||||
await until(() => hellos.length === 4, "a fourth hello")
|
||||
expect(hellos[3].limits.roots).toEqual([])
|
||||
stop.abort()
|
||||
expect((await done).state).toBe("stopped")
|
||||
})
|
||||
Reference in new issue
Block a user