diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e5e832..24fcd8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes to LLeMbas CLI. The format follows ## [Unreleased] +### Added + +- `lembas remote list | add … | remove … | enable | disable | mode | terminal ` + 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. + +### 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 diff --git a/harness/README.md b/harness/README.md index e0f937e..f8f9e83 100644 --- a/harness/README.md +++ b/harness/README.md @@ -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 diff --git a/harness/VERSION b/harness/VERSION index 3eefcb9..7dea76e 100644 --- a/harness/VERSION +++ b/harness/VERSION @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/harness/acp.md b/harness/acp.md index 4d20225..233be18 100644 --- a/harness/acp.md +++ b/harness/acp.md @@ -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`. diff --git a/src/acp/agent.ts b/src/acp/agent.ts index 476373b..3ea8f3f 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -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 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 } diff --git a/src/acp/link.ts b/src/acp/link.ts index e58f701..ab247fb 100644 --- a/src/acp/link.ts +++ b/src/acp/link.ts @@ -8,7 +8,7 @@ // 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 type { Mode } from "../config/schema.ts" import { HARNESS_VERSION } from "../harness.ts" import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts" @@ -20,19 +20,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 +114,18 @@ 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 { - 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 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 +157,10 @@ export async function runLink(o: LinkOptions = {}): Promise { 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 | undefined const ws = new WebSocket(linkUrl(instance), { headers: { authorization: `Bearer ${token}`, "user-agent": `lembas-cli/${VERSION}` }, ...(ca ? { tls: { ca } } : {}), @@ -142,9 +180,21 @@ export async function runLink(o: LinkOptions = {}): Promise { 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 +204,11 @@ export async function runLink(o: LinkOptions = {}): Promise { 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 +219,22 @@ export async function runLink(o: LinkOptions = {}): Promise { 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 { + return new Promise((r) => { + const t = setTimeout(done, ms) + function done() { + clearTimeout(t) + signal?.removeEventListener("abort", done) + r() + } + signal?.addEventListener("abort", done, { once: true }) + }) +} diff --git a/src/cli.ts b/src/cli.ts index 54b366a..cf90d67 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,6 +40,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 … | remove … | enable | disable + | mode | terminal [--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 sign in to a remote MCP server (OAuth) lembas mcp logout forget a server's stored sign-in @@ -168,6 +174,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. diff --git a/src/config/remote.ts b/src/config/remote.ts new file mode 100644 index 0000000..d218352 --- /dev/null +++ b/src/config/remote.ts @@ -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 + +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) +} diff --git a/src/config/write.ts b/src/config/write.ts index 7c77f23..962cd78 100644 --- a/src/config/write.ts +++ b/src/config/write.ts @@ -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()) } diff --git a/src/remote.ts b/src/remote.ts new file mode 100644 index 0000000..a437548 --- /dev/null +++ b/src/remote.ts @@ -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 … [--json] allow remote sessions there (turns remote work on) +// lembas remote remove … [--json] stop allowing them there +// lembas remote enable | disable [--json] remote.enabled +// lembas remote mode remote.max_mode +// lembas remote terminal 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 … | remove … | enable | disable | mode <${MODES.join("|")}> | terminal [--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 …"), 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 …"), 2 + const roots = block.roots ?? [] + const gone = new Set() + 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 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 "), 2 + write([[["terminal"], v === "on"]]) + return show() + } + + io.err(REMOTE_USAGE) + return 2 +} diff --git a/src/service.ts b/src/service.ts index b8aa051..b4dbeef 100644 --- a/src/service.ts +++ b/src/service.ts @@ -170,6 +170,7 @@ export async function run(instance?: string): Promise { // 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 { }, 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 ` or `enable` makes it serve, no restart. const end = await runLink({ instance, hub, @@ -212,8 +215,8 @@ export async function run(instance?: string): Promise { 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) diff --git a/tests/acp.test.ts b/tests/acp.test.ts index a3b233c..7f4f283 100644 --- a/tests/acp.test.ts +++ b/tests/acp.test.ts @@ -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() diff --git a/tests/remote.test.ts b/tests/remote.test.ts new file mode 100644 index 0000000..325d439 --- /dev/null +++ b/tests/remote.test.ts @@ -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() + 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") +})