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/README.md +121 -0
- package/bin/moshcode.mjs +31 -0
- package/package.json +1 -1
- package/prd/0009-persistent-agent-runtime.md +362 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +166 -3
- package/src/commands.mjs +101 -0
- package/src/dns-system.mjs +5 -1
- package/src/dns.mjs +167 -1
- package/src/engines.mjs +46 -0
- package/src/herd-cli.mjs +665 -0
- package/src/herd-state.mjs +227 -0
- package/src/herd.mjs +746 -0
- package/src/pty.mjs +6 -2
- package/src/trust.mjs +60 -7
- package/src/tui.mjs +67 -4
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
|
-
|
|
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
|
-
//
|
|
503
|
-
// one name — so trusting it vouches
|
|
504
|
-
// is a far smaller grant than a CA,
|
|
505
|
-
// name
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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 },
|
|
700
|
+
await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, detached.args);
|
|
638
701
|
rl = mkrl();
|
|
639
702
|
continue;
|
|
640
703
|
}
|