moshcode 0.32.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/src/pty.mjs CHANGED
@@ -92,9 +92,13 @@ export function ptySpec(cmd, args = [], transcript, flavor) {
92
92
  * decoding each slice independently turns them into U+FFFD in the mirror. The
93
93
  * decoder holds the incomplete tail back until the rest of it arrives.
94
94
  */
95
- export function followFile(file, onChunk, { intervalMs = 100 } = {}) {
95
+ export function followFile(file, onChunk, { intervalMs = 100, startOffset = 0 } = {}) {
96
96
  let fd = null;
97
- let offset = 0;
97
+ // Callers that already have the earlier bytes — the herd's `attach` has just
98
+ // printed the tail of the transcript for context — pass the offset they got
99
+ // to, so following a session that has been running for hours costs the new
100
+ // bytes rather than a full replay of everything it ever printed.
101
+ let offset = Number(startOffset) || 0;
98
102
  let stopped = false;
99
103
  let decoder = new StringDecoder("utf8");
100
104
 
package/src/trust.mjs CHANGED
@@ -482,16 +482,53 @@ export function leafPath(name, { platform = process.platform } = {}) {
482
482
  : `/usr/local/share/ca-certificates/moshpit-${safe}.crt`;
483
483
  }
484
484
 
485
+ /**
486
+ * Is this certificate marked as a certificate authority?
487
+ *
488
+ * Read with node's X509 parser rather than by grepping openssl's text, because
489
+ * the answer decides whether a key gets authority over the whole clearnet and
490
+ * "CA:FALSE" is a substring of nothing but is adjacent to plenty.
491
+ *
492
+ * A certificate carrying no basicConstraints at all answers false: absent is
493
+ * not the same as asserted, and RFC 5280 §4.2.1.9 treats such a certificate as
494
+ * an end entity.
495
+ */
496
+ export async function isCertificateAuthority(pem) {
497
+ const crypto = await import("node:crypto");
498
+ return new crypto.X509Certificate(pem).ca === true;
499
+ }
500
+
485
501
  /**
486
502
  * What `trust <name>` should do, given what the socket served and what the
487
503
  * registry says about it.
488
504
  *
489
505
  * Pure, so the refusal path is testable without a network or a trust store.
490
506
  */
491
- export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) {
507
+ export function leafTrustPlan({ name, pin, published, platform = process.platform, ca = false } = {}) {
492
508
  const accepted = pinAccepted(pin, published);
493
509
  if (!accepted.ok) return { ok: false, refused: true, why: accepted.why };
494
510
 
511
+ // A certificate installed here is installed as a *trust anchor*, and an
512
+ // anchor marked CA:TRUE may issue for any name in the world. The SAN says
513
+ // what the certificate speaks for; it says nothing about what a key trusted
514
+ // as an authority may go on to sign — so `subjectAltName=DNS:seo.rank` on a
515
+ // CA:TRUE certificate is not the bound it looks like, and trusting one would
516
+ // hand its holder google.com along with their own name.
517
+ //
518
+ // This is the same hole `requireNameConstraints` exists to close on the root
519
+ // path, arriving by the other door. It went unnoticed because openssl's
520
+ // `req -x509` defaults to CA:TRUE, so every origin set up before that default
521
+ // was overridden serves exactly the shape that must be refused — and it looks
522
+ // identical to a correct one until someone trusts it.
523
+ if (ca) {
524
+ return {
525
+ ok: false,
526
+ refused: true,
527
+ kind: "ca",
528
+ why: `${name} serves a certificate marked CA:TRUE — trusted directly, its key could vouch for any name`,
529
+ };
530
+ }
531
+
495
532
  const file = leafPath(name, { platform });
496
533
  if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` };
497
534
 
@@ -499,10 +536,11 @@ export function leafTrustPlan({ name, pin, published, platform = process.platfor
499
536
  ok: true,
500
537
  why: accepted.why,
501
538
  file,
502
- // A self-signed leaf is its own trust anchor, and its SAN limits it to this
503
- // one name — so trusting it vouches for `seo.rank` and nothing else. That
504
- // is a far smaller grant than a CA, which is why this path needs no
505
- // name constraints argument to be defensible.
539
+ // With CA:FALSE established above, a self-signed leaf is its own trust
540
+ // anchor and its SAN limits it to this one name — so trusting it vouches
541
+ // for `seo.rank` and nothing else. That is a far smaller grant than a CA,
542
+ // which is why this path needs no name-constraints argument to be
543
+ // defensible. It is only true because of the check above.
506
544
  refresh: platform === "darwin"
507
545
  ? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] }
508
546
  : { command: "update-ca-certificates", args: [] },
@@ -571,10 +609,25 @@ export async function trustName(name, out, deps = {}) {
571
609
  return 1;
572
610
  }
573
611
 
574
- const plan = leafTrustPlan({ name, pin, published, platform });
612
+ // Read off the certificate rather than assumed: an origin set up before
613
+ // `setup-origin.sh` overrode openssl's default serves CA:TRUE, and that is
614
+ // the one shape this must not install.
615
+ const ca = await isCertificateAuthority(served.stdout).catch(() => true);
616
+
617
+ const plan = leafTrustPlan({ name, pin, published, platform, ca });
575
618
  if (!plan.ok) {
576
619
  out(`REFUSED — ${plan.why}`);
577
- if (plan.refused) {
620
+ if (plan.kind === "ca") {
621
+ // A refusal with no way forward is a refusal people route around, and
622
+ // this one has a cheap way forward that costs nothing anywhere else: the
623
+ // pin is over the key, so re-issuing the certificate from the same key
624
+ // leaves the published pin untouched. Nothing has to be republished and
625
+ // no client holding the old pin breaks.
626
+ out(" its SAN says what it speaks for, not what it may sign — an anchor");
627
+ out(" marked CA:TRUE is not limited to the name printed on it.");
628
+ out(" re-issue it as CA:FALSE; the key is reused, so the pin does not move:");
629
+ out(` sudo sh scripts/setup-origin.sh ${name} # from moshpit-proxy`);
630
+ } else if (plan.refused) {
578
631
  out(` served ${pin}`);
579
632
  out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)");
580
633
  out(" moshcode will not trust a certificate the registry does not vouch for.");
package/src/tui.mjs CHANGED
@@ -27,6 +27,8 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion
27
27
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
28
28
  import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
29
29
  import { openNewTab } from "./tabs.mjs";
30
+ import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs";
31
+ import { detectSubstrate, substrateNote } from "./herd.mjs";
30
32
 
31
33
  const PROMPT = () => acid("mosh ") + dim("▸ ");
32
34
 
@@ -148,6 +150,44 @@ function printEngines(json = false) {
148
150
  }
149
151
  }
150
152
 
153
+ /**
154
+ * The herd, on the pit's front door.
155
+ *
156
+ * Printed before the prompt because "what is already running, and does any of
157
+ * it want me?" is the first question on opening the pit, and until now the only
158
+ * way to answer it was to remember. Silent when the herd is empty — a heading
159
+ * over nothing is noise on every cold start.
160
+ */
161
+ function printHerd() {
162
+ const rows = roster();
163
+ if (!rows.length) return;
164
+ const blocked = rows.filter((r) => r.state === "blocked").length;
165
+ console.log(bone(" herd") + ash(` — ${rows.length} session${rows.length === 1 ? "" : "s"} · attach with `) + acid("/attach <name>"));
166
+ console.log(renderRoster(rows, { indent: " " }));
167
+ if (blocked) console.log(" " + warn(`${blocked} waiting on you`));
168
+ }
169
+
170
+ /**
171
+ * `-d` / `--name` on `/agents` and `/start`: run it in the herd instead of
172
+ * handing over the terminal.
173
+ *
174
+ * Returns { taken, args }. `taken` means the herd has it and the caller should
175
+ * skip its passthrough path; `args` is always the engine's own arguments with
176
+ * the herd flags removed, so a box with no substrate falls back to a normal
177
+ * foreground launch instead of passing `-d` on to an engine that has never
178
+ * heard of it.
179
+ */
180
+ function detachedLaunch(key, args, { agentMode = false } = {}) {
181
+ const { detach, name, rest } = splitDetachArgs(args);
182
+ if (!detach) return { taken: false, args: rest };
183
+ if (!detectSubstrate()) {
184
+ console.log(warn(substrateNote(null)));
185
+ return { taken: false, args: rest };
186
+ }
187
+ herdStart([key, ...(name ? ["--name", name] : []), ...(agentMode ? ["--agent"] : []), ...rest]);
188
+ return { taken: true, args: rest };
189
+ }
190
+
151
191
  function printTools() {
152
192
  // Named generically rather than listing every tool: the roster grows, and a
153
193
  // hardcoded list here silently goes stale the moment TOOLS gains an entry.
@@ -476,7 +516,9 @@ export async function tui() {
476
516
  printEngines();
477
517
  console.log();
478
518
  printTools();
479
- console.log("\n" + ash(" /help for commands · /new for a tab · /quit to leave") + "\n");
519
+ console.log();
520
+ printHerd();
521
+ console.log("\n" + ash(" /help for commands · /ps for the herd · /new for a tab · /quit to leave") + "\n");
480
522
 
481
523
  const ad = await motd;
482
524
  if (ad) console.log(dim(ad) + "\n");
@@ -610,6 +652,23 @@ export async function tui() {
610
652
  rl = mkrl();
611
653
  continue;
612
654
  }
655
+ // The herd (PRD 0009). These never close the readline interface, because
656
+ // that is the entire point of them: the pit keeps its prompt while the
657
+ // sessions run somewhere that outlives it.
658
+ if (cmd === "herd") { await herdCommand(rest); continue; }
659
+ if (cmd === "ps") { await herdCommand(["ps", ...rest]); continue; }
660
+ if (cmd === "kill") { await herdCommand(["kill", ...rest]); continue; }
661
+ if (cmd === "wait") { await herdCommand(["wait", ...rest]); continue; }
662
+ if (cmd === "restore") { await herdCommand(["restore", ...rest]); continue; }
663
+ // `/attach` is the exception — it hands over the terminal like an engine
664
+ // session does, so readline has to let go of stdin first or the two fight
665
+ // over every keystroke.
666
+ if (cmd === "attach") {
667
+ rl.close();
668
+ await herdCommand(["attach", ...rest]);
669
+ rl = mkrl();
670
+ continue;
671
+ }
613
672
  if (cmd === "agents" || cmd === "agent" || cmd === "engines") {
614
673
  if (!rest[0] || (rest.length === 1 && rest[0] === "--json")) {
615
674
  printEngines(rest[0] === "--json");
@@ -618,23 +677,27 @@ export async function tui() {
618
677
  const resolved = resolveEngine(rest[0]);
619
678
  if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
620
679
  const [key, engine] = resolved;
680
+ const detached = detachedLaunch(key, rest.slice(1), { agentMode: true });
681
+ if (detached.taken) continue;
621
682
  rl.close();
622
683
  await openEngine(
623
684
  key,
624
685
  { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed },
625
- rest.slice(1),
686
+ detached.args,
626
687
  { agentMode: true },
627
688
  );
628
689
  rl = mkrl();
629
690
  continue;
630
691
  }
631
692
  if (cmd === "start") {
632
- if (!rest[0]) { console.log(err("usage: /start <engine> [args…]")); continue; }
693
+ if (!rest[0]) { console.log(err("usage: /start <engine> [args…] [-d]")); continue; }
633
694
  const resolved = resolveEngine(rest[0]);
634
695
  if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
635
696
  const [key, engine] = resolved;
697
+ const detached = detachedLaunch(key, rest.slice(1));
698
+ if (detached.taken) continue;
636
699
  rl.close();
637
- await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest.slice(1));
700
+ await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, detached.args);
638
701
  rl = mkrl();
639
702
  continue;
640
703
  }