moshcode 0.89.0 → 0.91.0

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/prd/README.md CHANGED
@@ -27,4 +27,6 @@ Start one with `moshcode prd "<idea>"` (TUI: `/prd`).
27
27
  | [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Accepted |
28
28
  | [0010](0010-cloud-settings-sync.md) | Sync the pit's settings to your moshcode.sh account | Draft |
29
29
  | [0011](0011-herd-agent-protocol.md) | Teach the herd the agent protocol — hooks-first state, a task ledger, and an A2A surface for local and remote agents | Draft |
30
+ | [0012](0012-billing-baked-into-the-agent-cli.md) | Bake billing into the agent CLI — timer, clients, teams, rates, invoices, rails | Draft |
31
+ | [0013](0013-persistent-ssh-workspaces.md) | Add persistent SSH workspaces for humans and agents | Draft |
30
32
  <!-- PRD-INDEX:END -->
@@ -207,6 +207,36 @@ export const CORE_CLI_COMMANDS = [
207
207
  seeAlso: ["herd", "ps"],
208
208
  note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.",
209
209
  },
210
+ {
211
+ name: "ssh",
212
+ group: "runtime",
213
+ description: "persistent SSH workspaces — one connection, many clean commands",
214
+ synopsis: [
215
+ ["moshcode ssh", "targets, and whether each one is connected"],
216
+ ["moshcode ssh <name>", "an interactive shell over the shared connection"],
217
+ ["moshcode ssh exec <name> [flags] -- <command…>", "one command over it: stdout, stderr and exit status kept apart"],
218
+ ["moshcode ssh <verb> [args…]", "add, open, check, close, put, get, shell, bench"],
219
+ ],
220
+ verbs: "SSH_VERBS",
221
+ flags: [
222
+ ["--json", "machine-readable, on every verb that does not take the terminal", ""],
223
+ ["--persist <dur>", "how long the connection outlives its last client", "10m"],
224
+ ],
225
+ examples: [
226
+ ["moshcode ssh add dev deploy@example.com --cwd /srv/app", "a name for a box (or an alias from ~/.ssh/config)"],
227
+ ["moshcode ssh open dev", "authenticate once"],
228
+ ["moshcode ssh exec dev -- git status --short", "…then every command reuses it"],
229
+ ["moshcode ssh exec dev --json -- pnpm test", "exit code, stdout and stderr as one object"],
230
+ ["git diff | moshcode ssh exec dev --stdin -- git apply -", "a multi-file patch in one round trip"],
231
+ ["moshcode ssh dev", "a real shell on the same connection"],
232
+ ["moshcode ssh shell dev --name app", "a remote tmux shell that survives your laptop sleeping"],
233
+ ["moshcode ssh close dev", "hang up (or let --persist expire)"],
234
+ ],
235
+ seeAlso: ["herd", "shell"],
236
+ note: "nothing secret is stored: targets.json holds a host, a port and a directory, and OpenSSH keeps using your ~/.ssh, "
237
+ + "agent, known_hosts and ProxyJump exactly as it does at the prompt. `exec` gives every call a fresh command channel — "
238
+ + "no shell state carries between calls; `shell` is for the workflows that need it.",
239
+ },
210
240
  {
211
241
  name: "install",
212
242
  group: "engines",
@@ -1325,6 +1355,82 @@ export const HERD_VERBS = [
1325
1355
  // The business layer's verbs. Flatter than the herd's on purpose: these are
1326
1356
  // commands somebody types between other work, and a verb that needs a paragraph
1327
1357
  // to explain itself is a verb in the wrong place.
1358
+ export const SSH_VERBS = [
1359
+ { name: "list", description: "every target, and whether its connection is up",
1360
+ synopsis: [["moshcode ssh [list] [--json]", ""]],
1361
+ flags: [["--json", "machine-readable", ""]] },
1362
+ { name: "add", description: "name a box: user@host, or an alias from ~/.ssh/config",
1363
+ synopsis: [["moshcode ssh add <name> <user@host|alias> [--port N] [--cwd PATH] [--persist 10m]", ""]],
1364
+ flags: [
1365
+ ["--port <n>", "ssh port, when ~/.ssh/config does not say", "22"],
1366
+ ["--cwd <path>", "where exec and the interactive shell start", "the remote login directory"],
1367
+ ["--persist <dur>", "how long the connection outlives its last client", "10m"],
1368
+ ["--json", "machine-readable", ""],
1369
+ ] },
1370
+ { name: "remove", description: "forget a target (and close its connection)",
1371
+ synopsis: [["moshcode ssh remove <name>", ""]] },
1372
+ { name: "show", description: "one target in full, with its socket and state",
1373
+ synopsis: [["moshcode ssh show <name> [--json]", ""]],
1374
+ flags: [["--json", "machine-readable", ""]] },
1375
+ { name: "open", description: "authenticate once and keep the connection",
1376
+ synopsis: [["moshcode ssh open <name> [--persist 10m] [--batch] [--json]", ""]],
1377
+ flags: [
1378
+ ["--persist <dur>", "how long it outlives its last client", "10m"],
1379
+ ["--batch", "never prompt — fail instead (the default when stdin is not a terminal)", ""],
1380
+ ["--json", "machine-readable; alreadyOpen says whether it was up", ""],
1381
+ ] },
1382
+ { name: "check", description: "is the connection up? (exit 0 yes, 1 no)",
1383
+ synopsis: [["moshcode ssh check <name> [--json]", ""]],
1384
+ flags: [["--json", "machine-readable", ""]] },
1385
+ { name: "close", description: "hang up, with ssh's own -O exit",
1386
+ synopsis: [["moshcode ssh close <name> [--json]", ""]],
1387
+ flags: [["--json", "machine-readable", ""]] },
1388
+ { name: "exec", description: "one command over the connection — no PTY, no shared shell state",
1389
+ synopsis: [["moshcode ssh exec <name> [--cwd PATH] [--env K=V…] [--stdin] [--tty] [--timeout 2m] [--sh] [--json] -- <command…>", ""]],
1390
+ flags: [
1391
+ ["--cwd <path>", "run it here, this once", "the target's cwd"],
1392
+ ["--env <K=V>", "an environment variable for this command only (repeatable)", ""],
1393
+ ["--stdin", "forward this process's stdin, raw", ""],
1394
+ ["--tty", "allocate a terminal (sudo, editors, anything that insists)", ""],
1395
+ ["--timeout <dur>", "kill it after this long — exit 124", ""],
1396
+ ["--sh", "the one argument is a shell snippet, pipes and all", ""],
1397
+ ["--batch", "never prompt for auth — fail instead", ""],
1398
+ ["--json", "{ ok, transportOk, code, signal, stdout, stderr, durationMs }", ""],
1399
+ ],
1400
+ examples: [
1401
+ ["moshcode ssh exec dev -- git status --short", ""],
1402
+ ["moshcode ssh exec dev --json -- grep -rn TODO src", "exit 1 is grep's answer, not a transport failure"],
1403
+ ["moshcode ssh exec dev --sh 'git log --oneline | head -5'", "a pipeline, on purpose"],
1404
+ ] },
1405
+ { name: "put", description: "copy a file up over the connection, atomically",
1406
+ synopsis: [["moshcode ssh put <name> <local> <remote> [--json]", ""]],
1407
+ flags: [["--json", "machine-readable", ""]] },
1408
+ { name: "get", description: "copy a file down over the connection",
1409
+ synopsis: [["moshcode ssh get <name> <remote> <local> [--json]", ""]],
1410
+ flags: [["--json", "machine-readable", ""]] },
1411
+ { name: "shell", description: "a persistent remote shell in tmux, and the verbs to drive it",
1412
+ synopsis: [
1413
+ ["moshcode ssh shell <name> --name <session>", "create or attach — Ctrl-b d leaves it running"],
1414
+ ["moshcode ssh shell send <name>/<session> <text>", "type a line into it"],
1415
+ ["moshcode ssh shell read <name>/<session> [--lines N]", "its screen, as text"],
1416
+ ["moshcode ssh shell kill <name>/<session>", "end it"],
1417
+ ["moshcode ssh shell list <name>", "every moshcode shell on the box"],
1418
+ ],
1419
+ flags: [
1420
+ ["--name <session>", "which shell", "main"],
1421
+ ["--lines <n>", "how much screen to read", "60"],
1422
+ ["--json", "machine-readable", ""],
1423
+ ],
1424
+ examples: [
1425
+ ["moshcode ssh shell dev --name app", ""],
1426
+ ["moshcode ssh shell send dev/app \"pnpm dev\"", ""],
1427
+ ["moshcode ssh shell read dev/app --lines 40", ""],
1428
+ ] },
1429
+ { name: "bench", description: "measure fresh connections against the shared one, on this host",
1430
+ synopsis: [["moshcode ssh bench <name> [--n 20] [--json]", ""]],
1431
+ flags: [["--n <runs>", "how many of each", "20"], ["--json", "machine-readable", ""]] },
1432
+ ];
1433
+
1328
1434
  export const TIMER_VERBS = [
1329
1435
  { name: "on", description: "start the clock", synopsis: [["moshcode timer on [client] [--task …] [--agents N|auto]", ""]] },
1330
1436
  { name: "off", description: "stop it and write the entry", synopsis: [["moshcode timer off [--note …]", ""]] },
@@ -1377,6 +1483,7 @@ export const PAYMENT_VERBS = [
1377
1483
 
1378
1484
  export const VERB_TABLES = {
1379
1485
  HERD_VERBS,
1486
+ SSH_VERBS,
1380
1487
  TIMER_VERBS,
1381
1488
  CLIENT_VERBS,
1382
1489
  TEAM_VERBS,
@@ -1428,6 +1535,8 @@ export const PIT_COMMANDS = [
1428
1535
  description: "block until a session is blocked or done" },
1429
1536
  { name: "restore", args: "[--resume]", cli: "restore",
1430
1537
  description: "rebuild the herd's sessions after a reboot" },
1538
+ { name: "ssh", args: "[name|verb] [args…]", cli: "ssh",
1539
+ description: "persistent SSH workspaces — one connection, many clean commands" },
1431
1540
  { name: "tools", args: "[name] [args…]", cli: "tools",
1432
1541
  description: "list workflow tools, or run one" },
1433
1542
  { name: "trade", args: "<verb> [args…]", cli: "trade",
package/src/commands.mjs CHANGED
@@ -22,6 +22,27 @@ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
22
22
  import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
23
23
  import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
24
24
  import { shellInvocation } from "./shell.mjs";
25
+ import {
26
+ checkMaster as sshCheckMaster, closeMaster as sshCloseMaster, exec as sshRun, get as sshGetFile,
27
+ openMaster as sshOpenMaster, parseSessionRef as sshParseSessionRef, parseTimeout as parseSshTimeout,
28
+ put as sshPutFile, resolveTarget as sshResolve,
29
+ shellKill as sshShellKillRemote, shellRead as sshShellReadRemote, shellSend as sshShellSendRemote,
30
+ } from "./ssh.mjs";
31
+
32
+ /** The registry entry for a named ssh target, or a moshscript error naming the verb. */
33
+ function sshTarget(name, verb) {
34
+ if (!name) throw new Error(`moshscript: ${verb}(name) requires a target name`);
35
+ const found = sshResolve(String(name));
36
+ if (found.error) throw new Error(`moshscript: ${verb}: ${found.error.replace(/^ssh: /, "")}`);
37
+ return found.entry;
38
+ }
39
+
40
+ /** `dev/app` → the target entry and the session name, or a moshscript error. */
41
+ function sshShellRef(ref, verb) {
42
+ const parsed = sshParseSessionRef(ref);
43
+ if (parsed.error) throw new Error(`moshscript: ${verb}: ${parsed.error.replace(/^ssh: /, "")}`);
44
+ return { found: sshTarget(parsed.name, verb), session: parsed.session };
45
+ }
25
46
  import { captureSpec } from "./pty.mjs";
26
47
  import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
27
48
  import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
@@ -654,6 +675,148 @@ const COMMANDS = [
654
675
  },
655
676
  },
656
677
 
678
+ // SSH workspaces (PRD 0013 R49–R52). Value-returning for the herd's reason:
679
+ // a script that runs a command on a remote box wants its stdout back, and a
680
+ // cliVerb's { ok, code } cannot carry it. Each returns the same object the
681
+ // CLI prints under --json, so a script and a shell pipeline read one shape.
682
+ //
683
+ // sshOpen("dev");
684
+ // const r = sshExec("dev", ["git", "status", "--short"], { cwd: "/srv/app" });
685
+ // if (!r.ok) say(r.stderr);
686
+ // sshExec("dev", ["git", "apply", "-"], { stdin: patch });
687
+ // sshClose("dev");
688
+ {
689
+ name: "sshOpen",
690
+ summary: "authenticate to a named ssh target once and keep the connection",
691
+ usage: "sshOpen(name, { persist })",
692
+ detail: "returns { ok, connected, alreadyOpen }; a live connection is left alone",
693
+ run(ctx, name, opts = {}) {
694
+ const found = sshTarget(name, "sshOpen");
695
+ if (ctx.dryRun) { ctx.out(` 🔌 sshOpen(${name}) → would open a master to ${found.target}`); return { ok: true, target: found.name, connected: true, dryRun: true }; }
696
+ const r = sshOpenMaster(found, { persist: opts.persist, batch: opts.batch });
697
+ ctx.out(r.ok ? ` 🔌 sshOpen(${name}) → ${r.alreadyOpen ? "already connected" : "connected"}` : ` ✗ sshOpen(${name}) → ${r.error}`);
698
+ return r;
699
+ },
700
+ },
701
+ {
702
+ name: "sshCheck",
703
+ summary: "is the connection to a target up?",
704
+ usage: "sshCheck(name)",
705
+ detail: "returns { connected, stale, pid }; never connects",
706
+ run(ctx, name) {
707
+ const found = sshTarget(name, "sshCheck");
708
+ if (ctx.dryRun) return { target: found.name, connected: false, dryRun: true };
709
+ const s = sshCheckMaster(found);
710
+ return { target: found.name, connected: s.connected, stale: s.stale, pid: s.pid ?? null };
711
+ },
712
+ },
713
+ {
714
+ name: "sshExec",
715
+ summary: "run one command over the shared connection and RETURN its result",
716
+ usage: "sshExec(name, [cmd, ...args], { cwd, env, stdin, timeout, sh })",
717
+ detail: "returns { ok, transportOk, code, signal, stdout, stderr, durationMs }; opens the connection if it is down. ok is the command's verdict, transportOk is ssh's",
718
+ run(ctx, name, argv, opts = {}) {
719
+ const found = sshTarget(name, "sshExec");
720
+ const command = Array.isArray(argv) ? argv.map(String) : [String(argv ?? "")].filter(Boolean);
721
+ if (!command.length) throw new Error("moshscript: sshExec(name, [command, ...args]) needs a command");
722
+ if (ctx.dryRun) {
723
+ ctx.out(` ▶ sshExec(${name}) → would run on ${found.target}: ${command.join(" ")}`);
724
+ return { ok: true, transportOk: true, target: found.name, connected: true, code: 0, signal: null, stdout: "", stderr: "", durationMs: 0, dryRun: true };
725
+ }
726
+ ctx.out(` ▶ sshExec(${name}) → ${command.join(" ").slice(0, 60)}${command.join(" ").length > 60 ? "…" : ""}`);
727
+ const timeoutMs = opts.timeout === undefined ? undefined
728
+ : (typeof opts.timeout === "number" ? opts.timeout : parseSshTimeout(opts.timeout));
729
+ const r = sshRun(found, command, {
730
+ cwd: opts.cwd, remoteEnv: opts.env || {}, stdin: opts.stdin, sh: Boolean(opts.sh), timeoutMs, persist: opts.persist, batch: opts.batch ?? true,
731
+ });
732
+ if (!r.transportOk) ctx.out(` ✗ sshExec(${name}) → ${r.error}`);
733
+ else if (!r.ok) ctx.out(` ✗ sshExec(${name}) exited ${r.signal || r.code}`);
734
+ return r;
735
+ },
736
+ },
737
+ {
738
+ name: "sshClose",
739
+ summary: "hang up a target's connection",
740
+ usage: "sshClose(name)",
741
+ detail: "returns { ok, closed, wasOpen }",
742
+ run(ctx, name) {
743
+ const found = sshTarget(name, "sshClose");
744
+ if (ctx.dryRun) { ctx.out(` 🔌 sshClose(${name}) → would send -O exit`); return { ok: true, target: found.name, closed: true, dryRun: true }; }
745
+ const r = sshCloseMaster(found);
746
+ ctx.out(r.ok ? ` 🔌 sshClose(${name}) → ${r.wasOpen ? "closed" : "was not connected"}` : ` ✗ sshClose(${name}) → ${r.error}`);
747
+ return r;
748
+ },
749
+ },
750
+ {
751
+ name: "sshPut",
752
+ summary: "copy a local file to a target, atomically",
753
+ usage: "sshPut(name, local, remote)",
754
+ detail: "returns { ok, remote }; scp to a temp path over the shared connection, then rename",
755
+ run(ctx, name, local, remote) {
756
+ const found = sshTarget(name, "sshPut");
757
+ if (!local || !remote) throw new Error("moshscript: sshPut(name, local, remote) needs both paths");
758
+ if (ctx.dryRun) { ctx.out(` 📤 sshPut(${name}) → would copy ${local} to ${remote}`); return { ok: true, target: found.name, dryRun: true }; }
759
+ const r = sshPutFile(found, String(local), String(remote));
760
+ ctx.out(r.ok ? ` 📤 sshPut(${name}) → ${r.remote}` : ` ✗ sshPut(${name}) → ${r.error}`);
761
+ return r;
762
+ },
763
+ },
764
+ {
765
+ name: "sshGet",
766
+ summary: "copy a file down from a target",
767
+ usage: "sshGet(name, remote, local)",
768
+ detail: "returns { ok, local }",
769
+ run(ctx, name, remote, local) {
770
+ const found = sshTarget(name, "sshGet");
771
+ if (!local || !remote) throw new Error("moshscript: sshGet(name, remote, local) needs both paths");
772
+ if (ctx.dryRun) { ctx.out(` 📥 sshGet(${name}) → would copy ${remote} to ${local}`); return { ok: true, target: found.name, dryRun: true }; }
773
+ const r = sshGetFile(found, String(remote), String(local));
774
+ ctx.out(r.ok ? ` 📥 sshGet(${name}) → ${r.local}` : ` ✗ sshGet(${name}) → ${r.error}`);
775
+ return r;
776
+ },
777
+ },
778
+ {
779
+ name: "sshShellSend",
780
+ summary: "type a line into a persistent remote shell",
781
+ usage: 'sshShellSend("dev/app", text)',
782
+ detail: "returns { ok }; literal text then Enter, into the remote tmux session",
783
+ run(ctx, ref, ...words) {
784
+ const { found, session } = sshShellRef(ref, "sshShellSend");
785
+ const text = words.join(" ");
786
+ if (!text) throw new Error("moshscript: sshShellSend(ref, text) needs text");
787
+ if (ctx.dryRun) { ctx.out(` 💬 sshShellSend(${ref}) → would send: ${text}`); return { ok: true, dryRun: true }; }
788
+ const r = sshShellSendRemote(found, session, text);
789
+ if (!r.ok) ctx.out(` ✗ sshShellSend(${ref}) → ${r.error}`);
790
+ return r;
791
+ },
792
+ },
793
+ {
794
+ name: "sshShellRead",
795
+ summary: "the screen of a persistent remote shell, as a string",
796
+ usage: 'sshShellRead("dev/app", { lines })',
797
+ detail: "returns the text (empty on failure); the same capture-pane the CLI's read prints",
798
+ run(ctx, ref, opts = {}) {
799
+ const { found, session } = sshShellRef(ref, "sshShellRead");
800
+ if (ctx.dryRun) { ctx.out(` 📖 sshShellRead(${ref}) → would capture the pane`); return ""; }
801
+ const r = sshShellReadRemote(found, session, { lines: opts.lines });
802
+ if (!r.ok) { ctx.out(` ✗ sshShellRead(${ref}) → ${r.error}`); return ""; }
803
+ return r.screen;
804
+ },
805
+ },
806
+ {
807
+ name: "sshShellKill",
808
+ summary: "end a persistent remote shell",
809
+ usage: 'sshShellKill("dev/app")',
810
+ detail: "returns { ok }",
811
+ run(ctx, ref) {
812
+ const { found, session } = sshShellRef(ref, "sshShellKill");
813
+ if (ctx.dryRun) { ctx.out(` ☠ sshShellKill(${ref}) → would kill the session`); return { ok: true, dryRun: true }; }
814
+ const r = sshShellKillRemote(found, session);
815
+ if (!r.ok) ctx.out(` ✗ sshShellKill(${ref}) → ${r.error}`);
816
+ return r;
817
+ },
818
+ },
819
+
657
820
  // CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
658
821
  // scripting the CLI. Add a capability by adding a line here.
659
822
  //
@@ -695,6 +858,7 @@ const COMMANDS = [
695
858
  cliVerb("elevenlabs", "drive the ElevenLabs CLI (Eleven Agents, voices, TTS, dubbing)"),
696
859
  cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
697
860
  cliVerb("pwd", "print the current repo/location"),
861
+ cliVerb("ssh", "persistent SSH workspaces (moshcode ssh <verb>) — see sshExec/sshOpen for values"),
698
862
 
699
863
  // Research and feeds. The *Read() verbs above return the data; these are the
700
864
  // rendered CLI, for when a script wants the table on the operator's screen.
@@ -29,9 +29,8 @@
29
29
  // it, and the entry is the script this very command was invoked from. Nothing
30
30
  // is guessed and nothing depends on PATH.
31
31
  import { spawn } from "node:child_process";
32
- import { mkdir, rm, writeFile } from "node:fs/promises";
33
- import { existsSync } from "node:fs";
34
- import { homedir } from "node:os";
32
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
33
+ import { existsSync, statSync } from "node:fs";
35
34
  import { dirname, join } from "node:path";
36
35
  import { operatorHome } from "./trust.mjs";
37
36
 
@@ -48,10 +47,49 @@ export const UNIT_NAME = "moshcode-dns.service";
48
47
  * /run/user/<uid>/moshpit-dns.pid for the daemon and for the person asking
49
48
  * after it. Under a system unit those are two different paths.
50
49
  */
51
- export function servicePaths({ system = false, home = homedir() } = {}) {
50
+ export function servicePaths({ system = false, home = operatorHome(), env = process.env } = {}) {
52
51
  return system
53
52
  ? { path: join("/etc/systemd/system", UNIT_NAME), systemctl: ["systemctl"], scope: "system" }
54
- : { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: ["systemctl", "--user"], scope: "user" };
53
+ : { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: userSystemctl(env), scope: "user" };
54
+ }
55
+
56
+ /**
57
+ * How to reach the operator's own systemd from wherever this is running.
58
+ *
59
+ * `systemctl --user` talks to the session of whoever is running it. `dns enable`
60
+ * escalates, so from there it is root's session — which has no bridge in it, has
61
+ * never had one, and reports every query about one as "not loaded". Meanwhile
62
+ * the operator's bridge keeps running with whatever it started with.
63
+ *
64
+ * That is why enabling proxy mode could be detected, written, and still not take
65
+ * effect: the unit that had to change belongs to a session the escalated half of
66
+ * the command cannot see.
67
+ *
68
+ * So an escalated run drops back to the invoking user, and hands them the runtime
69
+ * directory their session bus lives in — deriving it rather than inheriting it,
70
+ * because sudo does not carry XDG_RUNTIME_DIR across and the default under sudo
71
+ * points at root's.
72
+ */
73
+ export function userSystemctl(env = process.env, { home = operatorHome({ env }), owner = ownerOf } = {}) {
74
+ const user = env.SUDO_USER || env.DOAS_USER;
75
+ // Not escalated, or escalated from root itself: the session in reach is the
76
+ // right one.
77
+ if (!user || user === "root") return ["systemctl", "--user"];
78
+ // sudo publishes the uid; doas publishes only the name. Falling back to the
79
+ // owner of the operator's home covers that, and covers an escalator that
80
+ // publishes neither — without it, a doas machine would quietly address root's
81
+ // session, which has no bridge in it and never will.
82
+ const uid = env.SUDO_UID || env.DOAS_UID || owner(home);
83
+ if (uid === null || uid === undefined) return ["systemctl", "--user"];
84
+ return ["sudo", "-u", user, "env", `XDG_RUNTIME_DIR=/run/user/${uid}`, "systemctl", "--user"];
85
+ }
86
+
87
+ function ownerOf(path) {
88
+ try {
89
+ return statSync(path).uid;
90
+ } catch {
91
+ return null;
92
+ }
55
93
  }
56
94
 
57
95
  /**
@@ -127,6 +165,8 @@ export function serviceUnit({
127
165
  return lines.join("\n");
128
166
  }
129
167
 
168
+ const defaultRead = async (path) => readFile(path, "utf8").catch(() => "");
169
+
130
170
  function run(command, args) {
131
171
  return new Promise((resolve) => {
132
172
  const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -140,8 +180,8 @@ function run(command, args) {
140
180
  }
141
181
 
142
182
  /** Write the unit and start it. Returns the steps taken, in order, for printing. */
143
- export async function installService(unit, { system = false, home = homedir(), exec = run } = {}) {
144
- const { path, systemctl, scope } = servicePaths({ system, home });
183
+ export async function installService(unit, { system = false, home = operatorHome(), exec = run, env = process.env } = {}) {
184
+ const { path, systemctl, scope } = servicePaths({ system, home, env });
145
185
  const steps = [];
146
186
  try {
147
187
  await mkdir(dirname(path), { recursive: true });
@@ -152,7 +192,14 @@ export async function installService(unit, { system = false, home = homedir(), e
152
192
  }
153
193
 
154
194
  const [cmd, ...flags] = systemctl;
155
- for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", UNIT_NAME]]) {
195
+ // Enable *and* restart. `enable --now` starts a stopped unit and does nothing
196
+ // to a running one, so rewriting the unit to add `--proxy` would leave the old
197
+ // bridge running without it — the change on disk, no change in behaviour.
198
+ for (const args of [
199
+ [...flags, "daemon-reload"],
200
+ [...flags, "enable", UNIT_NAME],
201
+ [...flags, "restart", UNIT_NAME],
202
+ ]) {
156
203
  const result = await exec(cmd, args);
157
204
  steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
158
205
  if (!result.ok) return { ok: false, path, scope, steps };
@@ -160,9 +207,119 @@ export async function installService(unit, { system = false, home = homedir(), e
160
207
  return { ok: true, path, scope, steps };
161
208
  }
162
209
 
210
+ /**
211
+ * The `--upstream` servers an installed unit already forwards to.
212
+ *
213
+ * Read back rather than recomputed. A supervised bridge needs upstreams to hand
214
+ * the clearnet to, and the machine may already be routing every lookup at that
215
+ * bridge — so asking the system resolver what its upstreams are can answer
216
+ * "this bridge", and a bridge whose upstream is itself resolves nothing at all.
217
+ * Whatever the unit was working with is the safe answer to keep.
218
+ */
219
+ export function unitUpstreams(text) {
220
+ const line = String(text ?? "").split("\n").find((l) => l.startsWith("ExecStart="));
221
+ if (!line) return [];
222
+ const parts = line.trim().split(/\s+/);
223
+ const found = [];
224
+ for (let i = 0; i < parts.length; i += 1) {
225
+ if (parts[i] !== "--upstream") continue;
226
+ const value = parts[i + 1];
227
+ if (!value || value.startsWith("--")) continue;
228
+ if (!found.includes(value)) found.push(value);
229
+ }
230
+ return found;
231
+ }
232
+
233
+ /**
234
+ * Re-describe the bridge unit to match the run happening now, and restart it so
235
+ * the description becomes the truth.
236
+ *
237
+ * This is the step that made proxy mode arrive one reboot late. A supervised
238
+ * bridge is not started by `enable`: it is already up under `Restart=always`,
239
+ * so `startDaemon` finds a live pidfile and reports "already running" — true,
240
+ * and useless, because what a resolver answers with is fixed when it spawns. A
241
+ * bridge that came up before the proxy existed goes on answering origins
242
+ * forever, and stopping it by hand does not help, since systemd brings the same
243
+ * ExecStart straight back.
244
+ *
245
+ * The only thing that changes a supervised bridge's mind is rewriting its unit
246
+ * and restarting it. That is all this is.
247
+ *
248
+ * With no unit installed it does nothing and says so. An unsupervised machine
249
+ * is `startDaemon`'s business, and writing a unit here would be `enable`
250
+ * quietly making the bridge outlive a reboot on a machine that never asked for
251
+ * that — a different decision, and one `dns service --write` exists to make.
252
+ */
253
+ export async function refreshService({
254
+ entry,
255
+ port,
256
+ registryBase = null,
257
+ proxy = null,
258
+ system = false,
259
+ home = operatorHome(),
260
+ env = process.env,
261
+ exec = run,
262
+ exists = existsSync,
263
+ read = defaultRead,
264
+ } = {}) {
265
+ const { path, scope } = servicePaths({ system, home, env });
266
+ if (!exists(path)) return { refreshed: false, reason: "no unit installed", path, scope, upstreams: [], steps: [] };
267
+
268
+ const current = await read(path);
269
+ const upstreams = unitUpstreams(current);
270
+ const unit = serviceUnit({ system, entry, port, registryBase, upstreams, proxy });
271
+
272
+ // Deliberately not short-circuited on `current === unit`. Matching text says
273
+ // the unit describes the right bridge, not that the bridge is running it: a
274
+ // unit can be installed and stopped, installed and never enabled, or running
275
+ // what it was spawned with before the file last changed. Since the whole
276
+ // point here is to make what is running match what is written, the enable and
277
+ // restart happen either way, and cost a moment of no resolver during a
278
+ // command that is already rewriting the machine's routing.
279
+
280
+ const result = await installService(unit, { system, home, env, exec });
281
+ return {
282
+ refreshed: result.ok,
283
+ reason: result.ok ? null : "systemctl refused the unit",
284
+ path,
285
+ scope,
286
+ upstreams,
287
+ steps: result.steps,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Stop the supervised bridge and leave the unit where it is.
293
+ *
294
+ * `stopDaemon` cannot do this. It reads the pidfile and signals that process,
295
+ * which is right for a bridge started by hand and useless for one systemd owns:
296
+ * the unit is `Restart=always`, so the pid dies and the same ExecStart is back
297
+ * within the second. `disable` printed "bridge stopped" and left a bridge
298
+ * running — on a machine whose routing had just been put back, so the bridge
299
+ * was still up, still answering, and no longer on anybody's path.
300
+ *
301
+ * The unit file stays. Removing it is a different decision than turning
302
+ * resolution off for an afternoon, and `enable` re-enables what it finds — so
303
+ * leaving it costs nothing and deleting it would quietly take away a unit the
304
+ * operator may have written themselves.
305
+ */
306
+ export async function stopService({ system = false, home = operatorHome(), env = process.env, exec = run, exists = existsSync } = {}) {
307
+ const { path, systemctl, scope } = servicePaths({ system, home, env });
308
+ if (!exists(path)) return { stopped: false, reason: "no unit installed", path, scope, steps: [] };
309
+ const [cmd, ...flags] = systemctl;
310
+ const result = await exec(cmd, [...flags, "disable", "--now", UNIT_NAME]);
311
+ return {
312
+ stopped: result.ok,
313
+ reason: result.ok ? null : (result.error || "systemctl refused"),
314
+ path,
315
+ scope,
316
+ steps: [{ step: `${cmd} ${flags.join(" ")} disable --now ${UNIT_NAME}`, ok: result.ok, error: result.error }],
317
+ };
318
+ }
319
+
163
320
  /** Stop it and take the unit away. Missing is not a failure — removal is idempotent. */
164
- export async function removeService({ system = false, home = homedir(), exec = run } = {}) {
165
- const { path, systemctl, scope } = servicePaths({ system, home });
321
+ export async function removeService({ system = false, home = operatorHome(), exec = run, env = process.env } = {}) {
322
+ const { path, systemctl, scope } = servicePaths({ system, home, env });
166
323
  const [cmd, ...flags] = systemctl;
167
324
  const steps = [];
168
325
  for (const args of [[...flags, "disable", "--now", UNIT_NAME]]) {