pi-straw-boss 0.33.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/control.ts CHANGED
@@ -35,7 +35,7 @@ export { ENDING };
35
35
 
36
36
  export interface Reply { status: number; body: unknown }
37
37
  /** `answers.target` reads the net afresh, so the binding and the dialog are checked again for every answer. */
38
- export interface ControlDeps { lookup(key: string): Found | undefined; call?: HerdrCall; csrf: string; canonical(file?: string): string | undefined; pair?(): { url: string; rows: string[] } | undefined; remoteOn?(): Promise<void>; remoteOff?(): Promise<void>; paired?: { on(): boolean; list(): unknown; unpair(id: string): boolean; unpairAll(): number }; spawn?(parts: string[], json: Record<string, unknown>): Promise<Reply>; close?(kind: string, json: Record<string, unknown>): Promise<Reply>; dismiss?(key: unknown): Promise<boolean>; dismissAll?(): Promise<number>; stop?(asked: Asked): void; mirrors?: Pick<ReturnType<typeof createMirrors>, "toggle" | "fresh">; secret?(file: string): Promise<boolean>; answers?: Pick<Hub, "replay" | "ended" | "send" | "probe" | "commands"> & { target(key: string): Promise<Target | undefined> } }
38
+ export interface ControlDeps { lookup(key: string): Found | undefined; call?: HerdrCall; csrf: string; canonical(file?: string): string | undefined; pair?(): { url: string; rows: string[] } | undefined; remoteOn?(): Promise<void>; remoteOff?(): Promise<void>; paired?: { on(): boolean; list(): unknown; unpair(id: string): boolean; unpairAll(): number; unsaved(): string | undefined }; saveFailed?(reason: string): void; spawn?(parts: string[], json: Record<string, unknown>): Promise<Reply>; close?(kind: string, json: Record<string, unknown>): Promise<Reply>; dismiss?(key: unknown): Promise<boolean>; dismissAll?(): Promise<number>; stop?(asked: Asked): void; mirrors?: Pick<ReturnType<typeof createMirrors>, "toggle" | "fresh">; secret?(file: string): Promise<boolean>; answers?: Pick<Hub, "replay" | "ended" | "send" | "probe" | "commands"> & { target(key: string): Promise<Target | undefined> } }
39
39
 
40
40
  const no = (status: number, error: string, reason: string): Reply => ({ status, body: { error, reason } });
41
41
  const same = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
@@ -76,8 +76,13 @@ export function checkText(text: unknown): string | Reply {
76
76
  return typed;
77
77
  }
78
78
 
79
- export function createControl({ lookup, call, csrf, canonical, pair, remoteOn, remoteOff, paired, spawn, close, dismiss, dismissAll, stop, mirrors, secret = async () => true, answers }: ControlDeps) {
79
+ export function createControl({ lookup, call, csrf, canonical, pair, remoteOn, remoteOff, paired, saveFailed, spawn, close, dismiss, dismissAll, stop, mirrors, secret = async () => true, answers }: ControlDeps) {
80
80
  const busy = new Set<string>();
81
+ // A change to the paired devices or the remote wish that could not be written to paired.json took effect in memory only: say so (500), to the page and the session.
82
+ const lost = (): Reply | undefined => {
83
+ const reason = paired?.unsaved();
84
+ return reason ? (saveFailed?.(reason), no(500, "save_failed", reason)) : undefined;
85
+ };
81
86
  const pad = createKeys({ fresh: (id, key, pane) => mirrors?.fresh(id, key, pane) ?? false, secret, answerable: async (key) => Boolean(await answers?.target(key)), probe: async (key) => answers?.probe(key) });
82
87
  /** The origin checks, then the session checks, then one Herdr call. `parts` is the path after api/: sessions/<key>/<action>, remote/pair, or server/stop. */
83
88
  return async function handle(req: IncomingMessage, origin: string, [scope, key = "", action = ""]: string[]): Promise<Reply> {
@@ -102,16 +107,18 @@ export function createControl({ lookup, call, csrf, canonical, pair, remoteOn, r
102
107
  if (scope === "remote" && key === "on" && !action) {
103
108
  if (!remoteOn) return no(503, "unavailable", "This server cannot turn remote access on.");
104
109
  const failed = await remoteOn().then(() => undefined, (error: Error) => error.message);
110
+ const unsaved = !failed && lost();
111
+ if (unsaved) return unsaved;
105
112
  const paired = !failed && pair?.();
106
113
  return paired ? { status: 200, body: paired } : no(502, "remote_failed", failed ?? "Remote access did not turn on.");
107
114
  }
108
115
  if (scope === "remote" && !action && ["devices", "unpair", "unpair-all", "off"].includes(key)) {
109
116
  if (!paired) return no(503, "unavailable", "This server keeps no paired devices.");
110
117
  if (key === "devices") return { status: 200, body: { on: paired.on(), devices: paired.list() } };
111
- if (key === "off") return remoteOff ? remoteOff().then(() => ({ status: 200, body: {} }), (error: Error) => no(502, "remote_failed", error.message)) : no(503, "unavailable", "This server cannot turn remote access off.");
112
- if (key === "unpair-all") return { status: 200, body: { unpaired: paired.unpairAll() } };
118
+ if (key === "off") return remoteOff ? remoteOff().then(() => lost() ?? { status: 200, body: {} }, (error: Error) => no(502, "remote_failed", error.message)) : no(503, "unavailable", "This server cannot turn remote access off.");
119
+ if (key === "unpair-all") return ((n) => lost() ?? { status: 200, body: { unpaired: n } })(paired.unpairAll());
113
120
  const { id } = read.json;
114
- return typeof id !== "string" || !id || id.length > 64 ? no(400, "bad_request", "Name the device to unpair.") : paired.unpair(id) ? { status: 200, body: {} } : no(404, "not_found", "No such paired device.");
121
+ return typeof id !== "string" || !id || id.length > 64 ? no(400, "bad_request", "Name the device to unpair.") : paired.unpair(id) ? (lost() ?? { status: 200, body: {} }) : no(404, "not_found", "No such paired device.");
115
122
  }
116
123
  if (scope === "remote") {
117
124
  const pairing = key === "pair" && !action ? pair?.() : undefined;
package/src/devices.ts CHANGED
@@ -27,11 +27,12 @@ export function createDevices(file?: string) {
27
27
  if (saved?.host && saved.login) identity = { host: String(saved.host), login: String(saved.login) };
28
28
  // An older file (0.32.1 and earlier) kept only `sessions` (hash to expiry) and was written only while remote was on, so one with devices and no wish says remote was on.
29
29
  const old = Object.entries(saved?.sessions ?? {}).map(([h, until]): [string, Record<string, unknown>] => [h, { until }]);
30
- wanted = typeof saved?.wanted === "boolean" ? saved.wanted : old.length > 0;
30
+ wanted = saved?.wanted === true;
31
31
  for (const [h, d] of [...old, ...Object.entries<Record<string, unknown>>(saved?.devices ?? {})]) {
32
32
  const until = num(d?.until, 0);
33
33
  if (/^[0-9a-f]{64}$/.test(h) && until > now) devices.set(h, { until, label: typeof d.label === "string" ? d.label.slice(0, 80) : "", paired: num(d.paired, 0), seen: num(d.seen, 0) });
34
34
  }
35
+ if (typeof saved?.wanted !== "boolean") wanted = devices.size > 0; // the older file: only a session still valid says remote was on
35
36
  } catch {} // none saved
36
37
  // A file that cannot be written (a full disk, a directory that went away) must not throw out of the request that changed the state: the
37
38
  // state stays in memory for this server's life, and `error` says what failed until a save works again.
package/src/gui.ts CHANGED
@@ -44,11 +44,11 @@ export interface Gui { url: string; port: number; remotePort: number; token: str
44
44
  /** `net` is the registry behind the net reading (the allow list of session keys); without it no session can be watched. `call` reaches Herdr for input. */
45
45
  /** `hub` carries answers to the dialogs bridges relay (dialogs.ts); it runs exactly as long as the server. */
46
46
  /** `onRemote` and `onRemoteOff` turn remote access on and off from the page, in the session that runs the server. `onStop` takes another session's stop request (requestStop), so the owner also takes its remote entry down; without it the server stops itself. */
47
- export interface GuiOptions { read(): Promise<Net>; net?: { lookup(key: string): Found | undefined; workspace(id: string): Ref[]; dismiss?(key: string): boolean; dismissAll?(): number }; call?: HerdrCall; hub?: Hub; dir?: string; paired?: string; port?: number; remotePort?: number; token?: string; csrf?: string; pollMs?: number; pingMs?: number; onStop?(asked: Asked): void; onRemote?(): Promise<void>; onRemoteOff?(): Promise<void>; spawn?: ControlDeps["spawn"]; close?: ControlDeps["close"] }
47
+ export interface GuiOptions { read(): Promise<Net>; net?: { lookup(key: string): Found | undefined; workspace(id: string): Ref[]; dismiss?(key: string): boolean; dismissAll?(): number }; call?: HerdrCall; hub?: Hub; dir?: string; paired?: string; port?: number; remotePort?: number; token?: string; csrf?: string; pollMs?: number; pingMs?: number; onStop?(asked: Asked): void; onRemote?(): Promise<void>; onRemoteOff?(): Promise<void>; onSaveFailed?(reason: string): void; spawn?: ControlDeps["spawn"]; close?: ControlDeps["close"] }
48
48
  export interface Asked { by: string; take: boolean }
49
49
 
50
50
  /** Starts the server. A preferred `port` and `remotePort` (with the `token` and `csrf`) are reused when free, so a restarted server keeps the URL and the page an open tab already has. */
51
- export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, port = 0, remotePort = 0, token = randomBytes(16).toString("hex"), csrf = randomBytes(16).toString("hex"), pollMs = 2000, pingMs = 15_000, onStop, onRemote, onRemoteOff, spawn, close }: GuiOptions): Promise<Gui> {
51
+ export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, port = 0, remotePort = 0, token = randomBytes(16).toString("hex"), csrf = randomBytes(16).toString("hex"), pollMs = 2000, pingMs = 15_000, onStop, onRemote, onRemoteOff, onSaveFailed, spawn, close }: GuiOptions): Promise<Gui> {
52
52
  try {
53
53
  if (!statSync(join(dir, "index.html")).isFile()) throw new Error();
54
54
  } catch {
@@ -70,7 +70,7 @@ export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, por
70
70
  if (n) await tick();
71
71
  return n;
72
72
  };
73
- const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, remoteOn: onRemote, remoteOff: onRemoteOff, paired: { on: () => Boolean(access.remote), ...access.devices }, spawn, close: close && (async (kind, json) => (await tick(), close(kind, json))), dismiss: async (key) => typeof key === "string" && Boolean(net?.dismiss?.(key)) && (await tick(), true), dismissAll, mirrors, stop, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, commands: hub.commands, target: async (key) => (await tick(), hub.target(key)) } });
73
+ const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, remoteOn: onRemote, remoteOff: onRemoteOff, paired: { on: () => Boolean(access.remote), ...access.devices, unsaved: access.unsaved }, saveFailed: onSaveFailed, spawn, close: close && (async (kind, json) => (await tick(), close(kind, json))), dismiss: async (key) => typeof key === "string" && Boolean(net?.dismiss?.(key)) && (await tick(), true), dismissAll, mirrors, stop, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, commands: hub.commands, target: async (key) => (await tick(), hub.target(key)) } });
74
74
  let last: string | undefined, problem: string | undefined, inflight: Promise<void> | undefined;
75
75
  let poll: ReturnType<typeof setInterval> | undefined, beat: ReturnType<typeof setInterval> | undefined;
76
76
  const send = (to: ServerResponse, data: string, event?: string) => to.writableEnded || to.write(`${event ? `event: ${event}\n` : ""}data: ${data}\n\n`); // a reading in flight at stop ends after the streams
package/src/host.ts CHANGED
@@ -26,6 +26,7 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
26
26
  }
27
27
  return (s.c ?? s.viewer!).listSessions();
28
28
  };
29
+ const unsaved = (gui: Gui) => ((reason) => reason && tell(reason, "warning"))(gui.access.unsaved()); // a paired.json that could not be saved: what was just changed is lost at a restart
29
30
  const label = () => `${me() || "unknown"} (pane ${cfg.env.HERDR_PANE_ID})`;
30
31
  // This session's server stops, its remote entry first: /boss gui stop here, another session's stop or takeover (`asked`), or the session's end.
31
32
  const stopOwn = async (asked?: { by: string; take: boolean }) => {
@@ -61,7 +62,7 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
61
62
  const share = (take = false, manual = false, was = false) =>
62
63
  (opening ??= (async () => {
63
64
  if (s.gui) return { url: s.gui.url, owner: label(), mine: s.gui };
64
- const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, hub: (s.hub = s.hub?.me === me() ? s.hub : createHub(pi, me(), bridge)) })) => startGui({ read: net.read, net, call, hub: s.hub, dir: bundle, paired: join(guiDir, "paired.json"), ...prefer, onStop: (asked) => void stopOwn(asked), onRemote: async () => tell((await switchRemote(s.gui!, true, guiDir, ts)).text), onRemoteOff: async () => tell((await switchRemote(s.gui!, false, guiDir, ts)).text), spawn: createSpawn({ run, sleep, call, live: async (id) => (await sessions()).some((x) => x.id === id), sessionAt: async (pane) => (await sessions()).find((x) => x.herdrLocation?.paneId === pane)?.id }), close: createCloser({ run, sleep, me, sessions, ask: async (key, keepPane) => s.hub?.close(key, keepPane) }) });
65
+ const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, hub: (s.hub = s.hub?.me === me() ? s.hub : createHub(pi, me(), bridge)) })) => startGui({ read: net.read, net, call, hub: s.hub, dir: bundle, paired: join(guiDir, "paired.json"), ...prefer, onStop: (asked) => void stopOwn(asked), onSaveFailed: (reason) => tell(reason, "warning"), onRemote: async () => tell((await switchRemote(s.gui!, true, guiDir, ts)).text), onRemoteOff: async () => tell((await switchRemote(s.gui!, false, guiDir, ts)).text), spawn: createSpawn({ run, sleep, call, live: async (id) => (await sessions()).some((x) => x.id === id), sessionAt: async (pane) => (await sessions()).find((x) => x.herdrLocation?.paneId === pane)?.id }), close: createCloser({ run, sleep, me, sessions, ask: async (key, keepPane) => s.hub?.close(key, keepPane) }) });
65
66
  const shared = await sharedGui(guiDir, [label(), me()], start, sleep, { version, take });
66
67
  if ((s.gui = shared.mine) && !manual) await resume(s.gui, was);
67
68
  return shared;
@@ -85,10 +86,14 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
85
86
  const { url, owner, stale } = await share(mode.startsWith("remote"), mode.startsWith("remote"));
86
87
  if (mode.startsWith("remote") && !s.gui) return tell(older(owner));
87
88
  if (stale) tell(older(owner), "warning");
88
- if (mode === "remote off") return tell((await switchRemote(s.gui!, false, guiDir, ts)).text);
89
+ if (mode === "remote off") {
90
+ tell((await switchRemote(s.gui!, false, guiDir, ts)).text);
91
+ return void unsaved(s.gui!);
92
+ }
89
93
  if (mode === "remote") {
90
94
  const { text, lines, link } = await switchRemote(s.gui!, true, guiDir, ts), block = (active: boolean) => pi.events.emit("herdr:blocked", { active, label: "Showing the remote pairing QR code" });
91
95
  tell(text);
96
+ unsaved(s.gui!);
92
97
  return void (lines && link && s.ui && (await showLines(s.ui, lines, { block, keys: pairingKeys(link, copy, openUrl) })));
93
98
  }
94
99
  tell(`Straw net GUI: ${url}`);
package/src/remote.ts CHANGED
@@ -57,7 +57,9 @@ async function served(run: Run, host: string) {
57
57
  const s = parse<Serve>(await run(["serve", "status", "--json"]), "serve status"), held = s.TCP?.[PORT] !== undefined;
58
58
  const handlers = Object.entries(s.Web?.[host]?.Handlers ?? {}), target = held && s.TCP?.[PORT]?.HTTPS && handlers.length === 1 && handlers[0]![0] === "/" ? handlers[0]![1].Proxy : undefined;
59
59
  const elsewhere = Object.keys(s.Web ?? {}).some((h) => h !== host && h.endsWith(`:${PORT}`)) || Object.values(s.Foreground ?? {}).some((f) => f.TCP?.[PORT] !== undefined);
60
- return { target, foreign: elsewhere || (held && !target), funnel: Boolean(s.AllowFunnel?.[host]) };
60
+ // Any host's "/" handler on 8443, to tell an entry that is gone from one under another host (an identity change).
61
+ const anywhere = Object.entries(s.Web ?? {}).filter(([h]) => h.endsWith(`:${PORT}`)).flatMap(([, w]) => Object.entries(w.Handlers ?? {}).filter(([p]) => p === "/").map(([, x]) => x.Proxy));
62
+ return { target, anywhere, foreign: elsewhere || (held && !target), funnel: Boolean(s.AllowFunnel?.[host]) };
61
63
  }
62
64
 
63
65
  /** Points 8443 at `target`, when 8443 is free or ours (`target` itself or one of `stale`); otherwise, or when Funnel is on there, it refuses and touches nothing. */
@@ -74,8 +76,9 @@ export async function serveOn(run: Run, host: string, target: string, stale: (st
74
76
  }
75
77
 
76
78
  /** Removes the 8443 entry only while it is still ours (only "/", proxying to one of `targets`). */
77
- export async function serveOff(run: Run, host: string, targets: (string | undefined)[]) {
79
+ export async function serveOff(run: Run, host: string, targets: (string | undefined)[], elsewhere?: { remains: boolean }) {
78
80
  const now = await served(run, host);
81
+ if (elsewhere) elsewhere.remains = now.anywhere.some((t) => t !== undefined && targets.includes(t));
79
82
  if (now.foreign || !now.target || !targets.includes(now.target)) return false;
80
83
  await run(["serve", `--https=${PORT}`, "off"]);
81
84
  return true;
@@ -96,6 +99,8 @@ export function createAccess(token: string, ended: (device?: string) => void = (
96
99
  get remembered() { return remote ?? devices.identity; },
97
100
  /** Why paired.json could not be written last time, or undefined; the devices are then kept in memory only. */
98
101
  get saveError() { return devices.error; },
102
+ /** What a failed save of paired.json means (the change holds until the server stops), or undefined while the file is current. */
103
+ unsaved(): string | undefined { return devices.error && `paired.json could not be saved (${devices.error}); the change holds until the server stops, and a restart loses it`; },
99
104
  devices: {
100
105
  list: devices.list,
101
106
  unpair(id: string) {
@@ -205,8 +210,8 @@ export async function switchRemote(gui: { port: number; remotePort: number; toke
205
210
  gui.access.disable(stop);
206
211
  const id = was ?? (stale ? await identity(run).catch(() => undefined) : undefined);
207
212
  if (!id) return { text: "Remote access is off", changed: false };
208
- const removed = await serveOff(run, id.host, [was && target, stale, old]);
209
- rmSync(file, { force: true });
213
+ const left = { remains: false }, removed = await serveOff(run, id.host, [was && target, stale, old], left);
214
+ if (removed || !left.remains) rmSync(file, { force: true }); // an entry of ours under another host (the identity changed) is still to remove: its record stays
210
215
  return { text: removed ? `Remote access is off; the Tailscale serve entry on ${PORT} is removed` : `Remote access is off; the ${PORT} entry no longer pointed here, so it was left alone`, changed: true };
211
216
  }
212
217
  const id = await identity(run);