lembas remote: manage remote: without YAML; a running service follows it
`lembas remote list | add | remove | enable | disable | mode | terminal`, with
`--json` printing {enabled, roots, max_mode, terminal}, for LLeMbas Desktop.
Writes go through the global config writer and keep comments.
The device's limits are now live getters over the remote: block, re-read when
config.yaml's stat changes. `lembas service` links even with remote work off
or no roots: it says roots: [] and refuses every session, directory and
terminal, so the instance can tell "no folder allowed yet" from "not running".
When what the instance was told changes, the link re-dials once nothing is
working, so `lembas remote add` or `enable` takes effect without a restart.
Harness spec 1.0.1 documents both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
13 files changed
+636
-32
No files matched your search
@@ -6,6 +6,27 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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
|
## [1.1.0] — 2026-10-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-p
|
|||||||
protected path's `scope`.
|
protected path's `scope`.
|
||||||
- A tool's other names, per project, are listed in its `aliases` and work as the tool.
|
- 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
|
## 1.0.0
|
||||||
|
|
||||||
The spec as described here: the shared tools with their scopes, risk classes, call blocks and
|
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`:
|
Sent once when the link opens, before `initialize`:
|
||||||
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }`.
|
`{ 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
|
### `_lembas/event` — agent → client
|
||||||
|
|
||||||
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export interface Limits {
|
|||||||
/** Its shell marks prompts, commands and the directory (`remote.terminal_integration`).
|
/** Its shell marks prompts, commands and the directory (`remote.terminal_integration`).
|
||||||
* Absent: yes. */
|
* Absent: yes. */
|
||||||
terminalIntegration?: boolean
|
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 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. */
|
* the sessions shared from that terminal can be reached; the web UI opens none of its own. */
|
||||||
sharedOnly?: boolean
|
sharedOnly?: boolean
|
||||||
@@ -67,6 +70,8 @@ export interface Limits {
|
|||||||
export function refusalFor(L: Limits | undefined, dir: string): string | undefined {
|
export function refusalFor(L: Limits | undefined, dir: string): string | undefined {
|
||||||
if (!L) return 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.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 (!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)))
|
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)`
|
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 {
|
export function deletableFor(L: Limits | undefined, root: string, shown: boolean): string | undefined {
|
||||||
if (!L || shown) return 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.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)`
|
if (!underRoots(root, L.roots)) return `${root} is outside the directories this device accepts remote work in (remote.roots)`
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-17
@@ -8,7 +8,7 @@
|
|||||||
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
|
// 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 { existsSync, readFileSync } from "node:fs"
|
||||||
import { hostname } from "node:os"
|
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 type { Mode } from "../config/schema.ts"
|
||||||
import { HARNESS_VERSION } from "../harness.ts"
|
import { HARNESS_VERSION } from "../harness.ts"
|
||||||
import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.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 class LinkError extends Error {}
|
||||||
|
|
||||||
export function limitsFromConfig(): Limits & { enabled: boolean } {
|
/** The device's limits, read from the global `remote:` block each time one is looked at — so a
|
||||||
const r = loadConfig().config.remote ?? {}
|
* 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 {
|
return {
|
||||||
enabled: r.enabled === true,
|
get enabled() {
|
||||||
roots: r.roots ?? [],
|
return r().enabled === true
|
||||||
maxMode: (r.max_mode ?? "edit") as Mode,
|
},
|
||||||
approvalTimeoutMs: (r.approval_timeout ?? 600) * 1000,
|
get sharedOnly() {
|
||||||
requireTrust: r.require_trust !== false,
|
return o.sharedOnly === true && r().enabled !== true
|
||||||
terminal: r.terminal === true,
|
},
|
||||||
terminalIntegration: r.terminal_integration !== false,
|
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 {
|
export function pickInstance(name?: string): Instance {
|
||||||
const all = instances()
|
const all = instances()
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -82,14 +114,18 @@ export interface LinkOptions {
|
|||||||
/** A terminal's link for /remote: it runs whatever `remote.enabled` says; with remote
|
/** 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. */
|
* work off, only the sessions the terminal shares can be reached. */
|
||||||
sharedOnly?: boolean
|
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> {
|
export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
||||||
const configured = limitsFromConfig()
|
const limits = limitsFromConfig({ sharedOnly: o.sharedOnly })
|
||||||
const limits = o.sharedOnly && !configured.enabled ? { ...configured, roots: [], sharedOnly: true } : configured
|
const poll = o.pollMs ?? 2000
|
||||||
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 instance = pickInstance(o.instance)
|
const instance = pickInstance(o.instance)
|
||||||
const token = readFileSync(keyFile(instance.connection), "utf8").trim()
|
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
|
// 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<LinkStatus> {
|
|||||||
rememberProtocols(instance.connection, protocols)
|
rememberProtocols(instance.connection, protocols)
|
||||||
} catch {}
|
} catch {}
|
||||||
const protocol = downgraded ? 1 : linkProtocol(protocols)
|
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 opened = false
|
||||||
|
let changed = false
|
||||||
|
let watch: ReturnType<typeof setInterval> | undefined
|
||||||
const ws = new WebSocket(linkUrl(instance), {
|
const ws = new WebSocket(linkUrl(instance), {
|
||||||
headers: { authorization: `Bearer ${token}`, "user-agent": `lembas-cli/${VERSION}` },
|
headers: { authorization: `Bearer ${token}`, "user-agent": `lembas-cli/${VERSION}` },
|
||||||
...(ca ? { tls: { ca } } : {}),
|
...(ca ? { tls: { ca } } : {}),
|
||||||
@@ -142,9 +180,21 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust },
|
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust },
|
||||||
})
|
})
|
||||||
status("linked")
|
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) => {
|
ws.addEventListener("close", (e) => {
|
||||||
o.signal?.removeEventListener("abort", stop)
|
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.
|
// 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" })
|
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.
|
// 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<LinkStatus> {
|
|||||||
ws.addEventListener("error", () => {})
|
ws.addEventListener("error", () => {})
|
||||||
})
|
})
|
||||||
if (o.signal?.aborted) break
|
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) {
|
if (outcome.final && protocol === 2 && !downgraded) {
|
||||||
downgraded = true
|
downgraded = true
|
||||||
status("waiting", "the instance does not take link protocol 2; trying again with 1")
|
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<LinkStatus> {
|
|||||||
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
|
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`)
|
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)
|
wait = Math.min(ceiling, wait * 2)
|
||||||
}
|
}
|
||||||
clearInterval(freshen)
|
clearInterval(freshen)
|
||||||
return status("stopped", "stopped")
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -40,6 +40,12 @@ Usage:
|
|||||||
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
||||||
lembas service install | uninstall | status | logs [-f] | run
|
lembas service install | uninstall | status | logs [-f] | run
|
||||||
work from a LLeMbas instance as a background service (remote:)
|
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 [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 auth <server> sign in to a remote MCP server (OAuth)
|
||||||
lembas mcp logout <server> forget a server's stored sign-in
|
lembas mcp logout <server> 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 === "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
|
// 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
|
// loads the configuration to use it. A few seconds at most; an instance that does not answer
|
||||||
// leaves what it said last.
|
// leaves what it said last.
|
||||||
|
|||||||
@@ -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
|
// Settings the TUI remembers (/personality, /theme) and `lembas remote` go into the global
|
||||||
// comments and layout of whatever the user wrote there.
|
// config.yaml, keeping the comments and layout of whatever the user wrote there.
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { parseDocument } from "yaml"
|
import { parseDocument } from "yaml"
|
||||||
import { paths } from "./paths.ts"
|
import { paths } from "./paths.ts"
|
||||||
|
|
||||||
export function setGlobalConfig(path: string[], value: unknown) {
|
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")
|
const file = join(paths.config, "config.yaml")
|
||||||
mkdirSync(paths.config, { recursive: true })
|
mkdirSync(paths.config, { recursive: true })
|
||||||
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
|
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
|
||||||
if (!doc.contents) doc.contents = doc.createNode({}) as never
|
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())
|
writeFileSync(file, doc.toString())
|
||||||
}
|
}
|
||||||
+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
|
// 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,
|
// 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.
|
// 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() })
|
const hub = new Hub({ service: true, limits: limitsFromConfig() })
|
||||||
let said = false
|
let said = false
|
||||||
while (!stop.signal.aborted && !(await hub.listen())) {
|
while (!stop.signal.aborted && !(await hub.listen())) {
|
||||||
@@ -198,6 +199,8 @@ export async function run(instance?: string): Promise<number> {
|
|||||||
}, 30_000)
|
}, 30_000)
|
||||||
watch?.unref?.()
|
watch?.unref?.()
|
||||||
try {
|
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({
|
const end = await runLink({
|
||||||
instance,
|
instance,
|
||||||
hub,
|
hub,
|
||||||
@@ -212,8 +215,8 @@ export async function run(instance?: string): Promise<number> {
|
|||||||
return updated ? RESTART_FOR_UPDATE : 0
|
return updated ? RESTART_FOR_UPDATE : 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`lembas service: ${(e as Error).message}`)
|
console.error(`lembas service: ${(e as Error).message}`)
|
||||||
// A configuration that cannot work is not fixed by restarting: exit 0 so Restart=on-failure
|
// A configuration that cannot work (not logged in) is not fixed by restarting: exit 0 so
|
||||||
// leaves it, and the journal says why.
|
// Restart=on-failure leaves it, and the journal says why.
|
||||||
return e instanceof LinkError ? 0 : 1
|
return e instanceof LinkError ? 0 : 1
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(watch)
|
clearInterval(watch)
|
||||||
|
|||||||
+1
-9
@@ -6,7 +6,7 @@ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
|
|||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { AcpAgent, clampMode, underRoots, type Limits } from "../src/acp/agent.ts"
|
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 { Peer, type Transport } from "../src/acp/rpc.ts"
|
||||||
import { paths } from "../src/config/paths.ts"
|
import { paths } from "../src/config/paths.ts"
|
||||||
import { setTrust } from "../src/project/root.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")
|
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 () => {
|
test("the link dials out with the token, says hello, is driven, and stops when refused", async () => {
|
||||||
fake = fakeProvider([{ chunks: [delta({ content: "linked reply" })] }])
|
fake = fakeProvider([{ chunks: [delta({ content: "linked reply" })] }])
|
||||||
const root = project()
|
const root = project()
|
||||||
|
|||||||
@@ -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