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
+85
-17
@@ -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<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 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<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 +180,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 +204,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 +219,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 })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user