@catheadowl/ukp 0.2.4 → 0.2.6

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.
@@ -19,11 +19,12 @@ import {
19
19
  type RemoteUrlParts,
20
20
  } from "../registry.ts";
21
21
  import { spkiPinOf } from "./tls-identity.ts";
22
+ import { toolRelayScript } from "../spawn-relay.ts";
22
23
  import type { SearchEndpointOutcome } from "./search.ts";
23
24
  import type { NavEnvelope } from "./nav.ts";
24
25
  import type { ProposeResult, ProposeStatus } from "./propose.ts";
25
26
  import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
26
- import type { RgEndpointOutcome, RgMatch, RgCountEntry } from "./rg.ts";
27
+ import type { RgEndpointOutcome, RgMatch, RgCountEntry, RgFileEntry } from "./rg.ts";
27
28
 
28
29
  /** Remote transport for the client side (ukp-remote wire v1, ADR-REM-002/003;
29
30
  * ukp_remote W2). All calls fetch fresh (no cross-invocation cache), carry the
@@ -381,6 +382,71 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
381
382
  return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
382
383
  }
383
384
 
385
+ /** Wake failure classes from ssh's own diagnostics (ADR-REM-008 / D-093):
386
+ * OpenSSH's stderr wording is locale-stable (unlike cmd.exe's), so stable
387
+ * substrings classify the deterministic failures — verdicts a re-run against
388
+ * the same host reproduces, which is why none of them belongs in the retry
389
+ * ladder. One taxonomy, three matchers: these connect/auth signatures here,
390
+ * missing-ukp (127/9009 dictionary) and shell-mismatch keep their existing
391
+ * branches. Pure function by design — the signature table is unit-testable
392
+ * without spawning ssh. */
393
+ export type WakeFailureClass = "unreachable" | "refused" | "auth" | "hostkey";
394
+
395
+ export function classifyWakeFailure(diagnostics: string): WakeFailureClass | undefined {
396
+ if (/Connection timed out|No route to host|Could not resolve hostname/i.test(diagnostics)) return "unreachable";
397
+ if (/Connection refused/i.test(diagnostics)) return "refused";
398
+ if (/Host key verification failed/i.test(diagnostics)) return "hostkey";
399
+ // Auth failures always carry the method-list suffix ("Permission denied
400
+ // (publickey,password).") — ssh's own format. The bare phrase also appears
401
+ // in REMOTE-COMMAND exec errors (a found-but-not-executable ukp on a
402
+ // noexec mount), which reach these diagnostics under -tt and must keep
403
+ // falling through to the generic wording, not get the key-auth remedy
404
+ // (review P2).
405
+ if (/Permission denied \(/i.test(diagnostics)) return "auth";
406
+ return undefined;
407
+ }
408
+
409
+ /** A probe-stage timeout verdict is not trustworthy on its own (ADR-REM-008
410
+ * §3, implementation-time refinement): the probe's 5s race window is shorter
411
+ * than the full ConnectTimeout budget a slow-but-alive host may legitimately
412
+ * need — its wake would succeed in the ladder's window — so black-hole
413
+ * signatures defer to the ladder, which runs the full budget before the same
414
+ * classifier issues the verdict. Instant verdicts (refused, auth, hostkey,
415
+ * resolve and no-route failures) settle within one roundtrip and are trusted
416
+ * wherever they appear; a probe-local short ConnectTimeout would have
417
+ * misjudged every host slower than it. */
418
+ function wakeFailureDefersToLadder(failureClass: WakeFailureClass, diagnostics: string): boolean {
419
+ return failureClass === "unreachable"
420
+ && /Connection timed out/i.test(diagnostics)
421
+ && !/No route to host|Could not resolve hostname/i.test(diagnostics);
422
+ }
423
+
424
+ /** Classified wake-failure text (ADR-REM-008 §4): names the ssh TARGET (an
425
+ * origin-level fact under fan-out — never an endpoint label), quotes the ssh
426
+ * diagnostics as evidence, and gives the class its one remedy. Printable
427
+ * ASCII only — the frozen vocabulary rule for runtime strings. */
428
+ export function wakeFailureMessage(failureClass: WakeFailureClass, target: string, diagnostics: string): string {
429
+ const evidence = diagnostics.length > 0 ? `: ${diagnostics}` : "";
430
+ switch (failureClass) {
431
+ case "unreachable":
432
+ return `waking the ukp door on '${target}' failed - the host is unreachable${evidence}. The machine appears off, firewalled, or its address no longer resolves; verify it is powered on and reachable`;
433
+ case "refused":
434
+ return `waking the ukp door on '${target}' failed - the host is reachable but nothing is listening for ssh${evidence}. Start the ssh server on the host (on Windows: the optional OpenSSH Server feature) or fix the port`;
435
+ case "auth":
436
+ return `waking the ukp door on '${target}' failed - ssh rejected the credentials (BatchMode, no password prompt)${evidence}. Fix key auth: add the public key to the host's authorized_keys, or check the ssh config's IdentityFile`;
437
+ case "hostkey":
438
+ return `waking the ukp door on '${target}' failed - the host key was not accepted${evidence}. If the host was reinstalled this is expected: re-verify it (ssh-keygen -R), then retry`;
439
+ }
440
+ }
441
+
442
+ /** Not-ready wording (ADR-REM-008): the old text blamed the host alias and
443
+ * key auth — both, by the time this message is reached, have already
444
+ * succeeded (their failures classify earlier); what failed is the door
445
+ * behind a working connection. */
446
+ export function wakeNotReadyMessage(target: string, timeoutMs: number): string {
447
+ return `the ssh connection to '${target}' worked but the ukp door did not become ready within ${Math.round(timeoutMs / 1000)}s. Host prerequisite (W9): ukp on the remote PATH; a slow first start also lands here - retry once before investigating`;
448
+ }
449
+
384
450
  /** Cross-invocation shell-family cache (ukp_list W1 / L-1, ADR 0026's
385
451
  * implementation-time note): a host's shell family is a static fact, but
386
452
  * learning it costs a full ssh roundtrip per origin per invocation — on
@@ -426,6 +492,60 @@ function persistShellFamily(cachePath: string | undefined, target: string, form:
426
492
  }
427
493
  }
428
494
 
495
+ /** Structural subset of Bun's Subprocess that the ssh paths consume (a Pick,
496
+ * not a hand-written interface — the ADR 0021 guard scans for CLI-shaped
497
+ * member declarations). Direct spawns return the Subprocess itself; the win32
498
+ * relay below returns a delegating wrapper because its kill semantics differ
499
+ * (tree-kill). */
500
+ type HostSshProcess = Pick<ReturnType<typeof Bun.spawn>, "pid" | "exitCode" | "stdout" | "stderr" | "kill">;
501
+
502
+ /** Win32 Defender tax (2026-09-22 finding, ukp_remote TODO
503
+ * 20260922-win32-ssh-spawn-tax): the DIRECT bun-to-ssh.exe child edge is held
504
+ * ~10s by behavioral inspection — measured across stdio modes, windowsHide,
505
+ * and absolute paths, while bash-to-ssh is 0.16s and ANY intermediary parent
506
+ * (cmd, node, powershell) escapes to 0.5-0.8s. Every ssh spawn in this file
507
+ * pays it once, so on win32 real-ssh spawns go through a powershell.exe
508
+ * relay. -EncodedCommand carries the script as one base64 token (no quoting
509
+ * stress from Bun), and the script splats a PS array so the pinned wake
510
+ * command's load-bearing quotes reach ssh byte-identical (verified E2E: door
511
+ * READY in ~1.4s). Test injection (explicit sshCommand) bypasses the relay —
512
+ * the fakes must not be wrapped. Killing the relay orphans the ssh child
513
+ * (tunnel + door outlive the killed handle — verified), so the wrapped kill
514
+ * reaps the tree via taskkill /T /F. */
515
+ function spawnHostSsh(options: { sshCommand?: readonly string[] }, argv: readonly string[]): HostSshProcess {
516
+ if (process.platform !== "win32" || options.sshCommand !== undefined) {
517
+ return Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
518
+ }
519
+ const script = toolRelayScript(argv);
520
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
521
+ const proc = Bun.spawn(["powershell", "-NoProfile", "-EncodedCommand", encoded], {
522
+ stdout: "pipe",
523
+ stderr: "pipe",
524
+ stdin: "ignore",
525
+ });
526
+ const { pid, stdout, stderr } = proc;
527
+ return {
528
+ pid,
529
+ stdout,
530
+ stderr,
531
+ get exitCode() {
532
+ return proc.exitCode;
533
+ },
534
+ kill() {
535
+ // taskkill also carries the Defender tax when spawned directly from
536
+ // bun (measured 10.1s direct vs 105ms through cmd — same finding as
537
+ // the ssh/rg edges); the PID is digits-only, so the cmd payload needs
538
+ // no quoting. /T /F takes the relay AND its ssh child down together
539
+ // (killing the relay alone orphans the tunnel).
540
+ Bun.spawnSync(["cmd", "/d", "/c", `taskkill /PID ${pid} /T /F`], {
541
+ stdout: "ignore",
542
+ stderr: "ignore",
543
+ stdin: "ignore",
544
+ });
545
+ },
546
+ };
547
+ }
548
+
429
549
  /** One cheap, language-independent roundtrip to learn the host shell family
430
550
  * BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
431
551
  * expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
@@ -436,21 +556,32 @@ function persistShellFamily(cachePath: string | undefined, target: string, form:
436
556
  * build LOSES a quoted remote command under a pty (an interactive cmd
437
557
  * swallows it and waits on stdin forever — the no-pty path executes it
438
558
  * correctly). Defaults to POSIX (the common server shape) when the probe
439
- * errors or times out; the ladder remains as the backstop. */
559
+ * errors or times out; the ladder remains as the backstop.
560
+ *
561
+ * The probe doubles as the first failure classifier (ADR-REM-008 §3): its
562
+ * merged output — the `%OS%` answer on stdout, ssh's own diagnostics on
563
+ * stderr — feeds `classifyWakeFailure`, so instant verdicts (refused / auth
564
+ * / hostkey / resolve failures) classify here and the ladder is never
565
+ * entered for them. */
440
566
  async function probeHostShellFamily(
441
567
  options: { sshCommand?: readonly string[] },
442
568
  target: string,
443
- ): Promise<WakeShellForm> {
444
- const proc = Bun.spawn(
569
+ ): Promise<{ form: WakeShellForm; diagnostics: string }> {
570
+ const proc = spawnHostSsh(
571
+ options,
445
572
  [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", ...connectTimeoutOptions(), target, "echo %OS%"],
446
- { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
447
573
  );
448
- const output = await Promise.race([
449
- new Response(proc.stdout).text().catch(() => ""),
574
+ // One merged read of BOTH streams, raced against the 5s cap: resolves when
575
+ // the process dies (a healthy probe answers and exits), else returns empty
576
+ // at the cap and the default form stands — a slow host is never punished.
577
+ // A generous keep (review P3): the tail-trimmed merge must not push the
578
+ // stdout %OS% answer out the front even behind unusual stderr volume.
579
+ const text = await Promise.race([
580
+ drainStderrTail([proc.stdout, proc.stderr], 8192),
450
581
  Bun.sleep(5_000).then(() => ""),
451
582
  ]);
452
583
  proc.kill();
453
- return /Windows_NT/i.test(output) ? "cmd" : "posix";
584
+ return { form: /Windows_NT/i.test(text) ? "cmd" : "posix", diagnostics: text };
454
585
  }
455
586
 
456
587
  /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
@@ -463,7 +594,7 @@ async function probeHostShellFamily(
463
594
  async function probeWakeReady(
464
595
  base: string,
465
596
  timeoutMs: number,
466
- proc: ReturnType<typeof Bun.spawn>,
597
+ proc: HostSshProcess,
467
598
  ): Promise<boolean> {
468
599
  const deadline = Date.now() + timeoutMs;
469
600
  for (;;) {
@@ -499,9 +630,12 @@ async function probeWakeReady(
499
630
  * so diagnostics can arrive on either stream — both are drained and
500
631
  * concatenated. Resolves when the streams end (i.e. after the process is
501
632
  * dead) — only await once the proc has been killed or has exited. */
502
- function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep = 2000): Promise<string> {
633
+ function drainStderrTail(
634
+ streams: readonly (ReadableStream<Uint8Array> | number | undefined)[],
635
+ keep = 2000,
636
+ ): Promise<string> {
503
637
  return Promise.all(
504
- streams.map((stream) => new Response(stream).text().catch(() => "")),
638
+ streams.map((stream) => (typeof stream === "object" && stream !== null ? new Response(stream).text().catch(() => "") : "")),
505
639
  ).then((texts) => {
506
640
  const text = texts.join("").trim();
507
641
  return text.length > keep ? `...${text.slice(-keep)}` : text;
@@ -525,7 +659,7 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
525
659
  async function openSshTunnel(
526
660
  parts: { user?: string; host: string },
527
661
  options: { sshCommand?: readonly string[]; registryPath?: string },
528
- ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
662
+ ): Promise<{ proc: HostSshProcess; base: string }> {
529
663
  const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
530
664
  // Tier 1 first: a detached mux master per origin (no-op when one already
531
665
  // holds the socket); the wake client below rides it when possible and
@@ -539,8 +673,25 @@ async function openSshTunnel(
539
673
  // Shell form (2026-09-20): probed up front via `echo %OS%` (language-
540
674
  // independent — see probeHostShellFamily), POSIX by default; the wrong-
541
675
  // shell ladder below remains the backstop for a lying or timed-out probe.
542
- let form: WakeShellForm = readCachedShellFamily(shellFamilyCachePath(options), target)
543
- ?? await probeHostShellFamily(options, target);
676
+ // Cold cache: the probe doubles as the first failure classifier
677
+ // (ADR-REM-008 §3) — its instant verdicts (refused / auth / hostkey /
678
+ // resolve failures) reject the origin here, before any ladder attempt;
679
+ // black-hole timeouts defer to the ladder's full ConnectTimeout budget.
680
+ // Warm cache (the probe is skipped): the ladder's first attempt carries
681
+ // the same classifier, so the classified verdicts cost at most one
682
+ // ConnectTimeout either way.
683
+ let form: WakeShellForm;
684
+ const cachedForm = readCachedShellFamily(shellFamilyCachePath(options), target);
685
+ if (cachedForm !== undefined) {
686
+ form = cachedForm;
687
+ } else {
688
+ const probed = await probeHostShellFamily(options, target);
689
+ const probeClass = classifyWakeFailure(probed.diagnostics);
690
+ if (probeClass !== undefined && !wakeFailureDefersToLadder(probeClass, probed.diagnostics)) {
691
+ throw new RemoteTransportError(wakeFailureMessage(probeClass, target, probed.diagnostics));
692
+ }
693
+ form = probed.form;
694
+ }
544
695
  const attempts = 3;
545
696
  for (let attempt = 1; attempt <= attempts; attempt++) {
546
697
  const localPort = await freePort();
@@ -564,7 +715,7 @@ async function openSshTunnel(
564
715
  target,
565
716
  wakeDoorCommand(remotePort, form),
566
717
  ];
567
- const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
718
+ const proc = spawnHostSsh(options, argv);
568
719
  const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
569
720
  if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
570
721
  // Ground truth for the shell-family cache: the form that actually woke
@@ -579,6 +730,15 @@ async function openSshTunnel(
579
730
  // fast crash as a slow timeout.
580
731
  const diagnostics = (await stderrTail).trim();
581
732
  const exited = proc.exitCode !== null;
733
+ // Deterministic connect/auth verdicts never consume the ladder's
734
+ // remaining attempts (ADR-REM-008 §2): re-running against the same host
735
+ // reproduces them, so attempt 1's verdict IS the origin's verdict. The
736
+ // ladder stays for what it was built for — port-collision binds and
737
+ // not-ready timeouts.
738
+ const failureClass = exited ? classifyWakeFailure(diagnostics) : undefined;
739
+ if (failureClass !== undefined) {
740
+ throw new RemoteTransportError(wakeFailureMessage(failureClass, target, diagnostics));
741
+ }
582
742
  if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
583
743
  muxDisabled = true;
584
744
  }
@@ -604,7 +764,7 @@ async function openSshTunnel(
604
764
  throw new RemoteTransportError(
605
765
  exited
606
766
  ? `waking the ukp door on '${target}' failed: ${diagnostics}`
607
- : `the ukp door on '${target}' did not become ready within ${WAKE_READY_TIMEOUT_MS / 1000}s; check the host alias and key auth (BatchMode)`,
767
+ : wakeNotReadyMessage(target, WAKE_READY_TIMEOUT_MS),
608
768
  );
609
769
  }
610
770
  }
@@ -662,7 +822,7 @@ export interface RemoteTransportPool {
662
822
  export function createTransportPool(
663
823
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
664
824
  ): RemoteTransportPool {
665
- const tunnels = new Map<string, Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }>>();
825
+ const tunnels = new Map<string, Promise<{ proc: HostSshProcess; base: string }>>();
666
826
  return {
667
827
  async acquire(binding) {
668
828
  if (binding.kind !== "remote" || binding.url === undefined) {
@@ -1168,10 +1328,23 @@ function remoteRgCounts(raw: unknown): RgCountEntry[] | undefined {
1168
1328
  });
1169
1329
  }
1170
1330
 
1331
+ function remoteRgFiles(raw: unknown): RgFileEntry[] | undefined {
1332
+ if (!Array.isArray(raw)) return undefined;
1333
+ return raw.flatMap((entry): RgFileEntry[] => {
1334
+ if (typeof entry !== "object" || entry === null) return [];
1335
+ const file = entry as Record<string, unknown>;
1336
+ if (typeof file.path !== "string") return [];
1337
+ return [{ path: file.path, ...(typeof file.ukp_uri === "string" ? { ukp_uri: file.ukp_uri } : {}) }];
1338
+ });
1339
+ }
1340
+
1171
1341
  const RG_WIRE_STATUSES = new Set(["succeeded", "no_matches", "skipped", "failed", "interrupted", "cancelled"]);
1172
1342
 
1173
1343
  /** GET /v1/rg — single-endpoint outcome in the local RgEndpointOutcome
1174
- * vocabulary (provider is rg's external tier regardless of transport). */
1344
+ * vocabulary (provider is rg's external tier regardless of transport).
1345
+ * Files mode (ADR-RG-005) sends files=1 and omits query — an older serve
1346
+ * without files support answers its query-required usage error, which
1347
+ * lands here as a failed outcome carrying the server's message. */
1175
1348
  export async function remoteRg(
1176
1349
  binding: RegistryBinding,
1177
1350
  transport: RemoteTransportHandle,
@@ -1179,16 +1352,19 @@ export async function remoteRg(
1179
1352
  params: {
1180
1353
  query: string;
1181
1354
  limit: number;
1182
- glob?: string;
1355
+ globs?: readonly string[];
1183
1356
  type?: string;
1184
1357
  ignoreCase?: boolean;
1185
1358
  count?: boolean;
1359
+ files?: boolean;
1186
1360
  passthrough: readonly string[];
1187
1361
  },
1188
1362
  ): Promise<RemoteRgExecution> {
1189
1363
  const base = transport.base;
1190
- const search = new URLSearchParams({ query: params.query, limit: String(params.limit) });
1191
- if (params.glob !== undefined) search.set("glob", params.glob);
1364
+ const search = new URLSearchParams({ limit: String(params.limit) });
1365
+ if (params.files === true) search.set("files", "1");
1366
+ else search.set("query", params.query);
1367
+ for (const glob of params.globs ?? []) search.append("glob", glob);
1192
1368
  if (params.type !== undefined) search.set("type", params.type);
1193
1369
  if (params.ignoreCase === true) search.set("i", "1");
1194
1370
  if (params.count === true) search.set("count", "1");
@@ -1223,6 +1399,9 @@ export async function remoteRg(
1223
1399
  match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
1224
1400
  );
1225
1401
  const counts = remoteRgCounts(entry.counts);
1402
+ const files = remoteRgFiles(entry.files)?.map((file) =>
1403
+ file.ukp_uri !== undefined ? { ...file, ukp_uri: reanchorRemoteUkpUri(file.ukp_uri, binding) } : file,
1404
+ );
1226
1405
  const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
1227
1406
  return {
1228
1407
  outcome: {
@@ -1232,6 +1411,7 @@ export async function remoteRg(
1232
1411
  ...(typeof entry.message === "string" ? { message: entry.message } : {}),
1233
1412
  ...(matches !== undefined ? { matches } : {}),
1234
1413
  ...(counts !== undefined ? { counts } : {}),
1414
+ ...(files !== undefined ? { files } : {}),
1235
1415
  ...(entry.truncated === true ? { truncated: true } : {}),
1236
1416
  },
1237
1417
  warnings,