@catheadowl/ukp 0.2.3 → 0.2.5

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.
@@ -1,6 +1,9 @@
1
1
  import { isIP } from "node:net";
2
2
  import { connect as tlsConnect } from "node:tls";
3
3
  import { X509Certificate } from "node:crypto";
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
7
  import {
5
8
  DISCOVERY_PATH,
6
9
  PROTOCOL_NAME,
@@ -16,11 +19,12 @@ import {
16
19
  type RemoteUrlParts,
17
20
  } from "../registry.ts";
18
21
  import { spkiPinOf } from "./tls-identity.ts";
22
+ import { toolRelayScript } from "../spawn-relay.ts";
19
23
  import type { SearchEndpointOutcome } from "./search.ts";
20
24
  import type { NavEnvelope } from "./nav.ts";
21
25
  import type { ProposeResult, ProposeStatus } from "./propose.ts";
22
26
  import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
23
- import type { RgEndpointOutcome, RgMatch, RgCountEntry } from "./rg.ts";
27
+ import type { RgEndpointOutcome, RgMatch, RgCountEntry, RgFileEntry } from "./rg.ts";
24
28
 
25
29
  /** Remote transport for the client side (ukp-remote wire v1, ADR-REM-002/003;
26
30
  * ukp_remote W2). All calls fetch fresh (no cross-invocation cache), carry the
@@ -54,6 +58,25 @@ function remoteTimeoutMs(): number {
54
58
  return Number.isSafeInteger(value) && value >= 1000 ? value : 60_000;
55
59
  }
56
60
 
61
+ /** Connection-establishment bound (ADR 0026 rule 2 / D-091): every ssh
62
+ * process UKP spawns carries ConnectTimeout, so an unreachable host fails
63
+ * in seconds instead of waiting out the OS TCP timeout — which then
64
+ * multiplied through the wake ladder's attempts and the serial origin
65
+ * chain. Default 10s (the Ansible baseline); `UKP_SSH_CONNECT_TIMEOUT_MS`
66
+ * overrides, mirroring UKP_REMOTE_TIMEOUT_MS. ssh takes whole seconds, so
67
+ * an integer ms value ≥1000 floors (1500 → 1s); anything else — non-numeric,
68
+ * fractional, below 1000 — keeps the default. */
69
+ function sshConnectTimeoutSeconds(): number {
70
+ const raw = process.env.UKP_SSH_CONNECT_TIMEOUT_MS;
71
+ if (raw === undefined || raw.trim() === "") return 10;
72
+ const value = Number(raw);
73
+ return Number.isSafeInteger(value) && value >= 1000 ? Math.floor(value / 1000) : 10;
74
+ }
75
+
76
+ function connectTimeoutOptions(): string[] {
77
+ return ["-o", `ConnectTimeout=${sshConnectTimeoutSeconds()}`];
78
+ }
79
+
57
80
  /** TOFU mismatch policy (RQ-17): `warn` (default) appends a warning and
58
81
  * continues; `block` refuses the endpoint until it is explicitly
59
82
  * re-registered. */
@@ -64,6 +87,11 @@ function tofuMode(): "warn" | "block" {
64
87
  export interface RemoteTransportHandle {
65
88
  /** Wire base the client actually fetches (tunnel endpoint or the url itself). */
66
89
  base: string;
90
+ /** The url the caller knows — the registered binding url. Error and remedy
91
+ * text quotes THIS, never the wire base: a door-endpoint binding's wire
92
+ * base carries the `/e/<name>` route prefix (and, over ssh, a loopback
93
+ * tunnel port) that no user ever typed or could act on. */
94
+ displayUrl: string;
67
95
  /** Releases per-invocation resources (kills an ephemeral tunnel); noop for
68
96
  * direct connections. */
69
97
  close: () => void;
@@ -178,12 +206,13 @@ function tlsAnchorOf(binding: RegistryBinding, options: { registryPath?: string
178
206
  }
179
207
 
180
208
  function directTransportHandle(
181
- binding: RegistryBinding,
209
+ binding: RegistryBinding & { url: string },
182
210
  parts: RemoteUrlParts,
183
211
  options: { registryPath?: string },
184
212
  ): RemoteTransportHandle {
185
213
  return {
186
214
  base: parts.origin + wirePrefix(parts.endpointName),
215
+ displayUrl: binding.url,
187
216
  close: () => {},
188
217
  ...(tlsAnchorOf(binding, options) !== undefined ? { tls: tlsAnchorOf(binding, options)! } : {}),
189
218
  };
@@ -276,6 +305,7 @@ function spawnMuxMaster(
276
305
  ...(sshCommand ?? ["ssh"]),
277
306
  "-N",
278
307
  "-o", "BatchMode=yes",
308
+ ...connectTimeoutOptions(),
279
309
  ...muxCandidateOptions(),
280
310
  target,
281
311
  ];
@@ -352,6 +382,170 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
352
382
  return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
353
383
  }
354
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
+
450
+ /** Cross-invocation shell-family cache (ukp_list W1 / L-1, ADR 0026's
451
+ * implementation-time note): a host's shell family is a static fact, but
452
+ * learning it costs a full ssh roundtrip per origin per invocation — on
453
+ * win32 (no ControlMaster) a full TCP+key handshake. Cached beside the
454
+ * registry (`<registry dir>/wake-shell.toml`, keyed by ssh target), the
455
+ * probe is skipped when an entry exists. The wake ladder stays the
456
+ * backstop: a stale entry (host reinstalled under a different shell) costs
457
+ * one wrong-shell retry, and the successful form is written back — the
458
+ * cache can only lose time, never correctness. Best-effort by design:
459
+ * missing/corrupt/unwritable ⇒ re-probe or run uncached. */
460
+ function shellFamilyCachePath(options: { registryPath?: string }): string | undefined {
461
+ return options.registryPath === undefined ? undefined : join(dirname(options.registryPath), "wake-shell.toml");
462
+ }
463
+
464
+ function readCachedShellFamily(cachePath: string | undefined, target: string): WakeShellForm | undefined {
465
+ if (cachePath === undefined) return undefined;
466
+ try {
467
+ const parsed = parseToml(readFileSync(cachePath, "utf8")) as { shells?: Record<string, unknown> };
468
+ const value = parsed.shells?.[target];
469
+ return value === "posix" || value === "cmd" ? value : undefined;
470
+ } catch {
471
+ return undefined;
472
+ }
473
+ }
474
+
475
+ function persistShellFamily(cachePath: string | undefined, target: string, form: WakeShellForm): void {
476
+ if (cachePath === undefined) return;
477
+ try {
478
+ let shells: Record<string, unknown> = {};
479
+ try {
480
+ const parsed = parseToml(readFileSync(cachePath, "utf8")) as { shells?: Record<string, unknown> };
481
+ if (parsed.shells !== undefined && typeof parsed.shells === "object") shells = parsed.shells;
482
+ } catch {
483
+ // corrupt or absent: a fresh map replaces whatever was there
484
+ }
485
+ // Unchanged value: no write — a per-origin-per-invocation rewrite would
486
+ // only amplify disk churn and widen the concurrent-writer window.
487
+ if (shells[target] === form) return;
488
+ shells[target] = form;
489
+ writeFileSync(cachePath, stringifyToml({ shells }), { mode: 0o600 });
490
+ } catch {
491
+ // unwritable (read-only dir, concurrent writer): run without cache
492
+ }
493
+ }
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
+
355
549
  /** One cheap, language-independent roundtrip to learn the host shell family
356
550
  * BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
357
551
  * expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
@@ -362,21 +556,32 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
362
556
  * build LOSES a quoted remote command under a pty (an interactive cmd
363
557
  * swallows it and waits on stdin forever — the no-pty path executes it
364
558
  * correctly). Defaults to POSIX (the common server shape) when the probe
365
- * 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. */
366
566
  async function probeHostShellFamily(
367
567
  options: { sshCommand?: readonly string[] },
368
568
  target: string,
369
- ): Promise<WakeShellForm> {
370
- const proc = Bun.spawn(
371
- [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", target, "echo %OS%"],
372
- { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
569
+ ): Promise<{ form: WakeShellForm; diagnostics: string }> {
570
+ const proc = spawnHostSsh(
571
+ options,
572
+ [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", ...connectTimeoutOptions(), target, "echo %OS%"],
373
573
  );
374
- const output = await Promise.race([
375
- 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),
376
581
  Bun.sleep(5_000).then(() => ""),
377
582
  ]);
378
583
  proc.kill();
379
- return /Windows_NT/i.test(output) ? "cmd" : "posix";
584
+ return { form: /Windows_NT/i.test(text) ? "cmd" : "posix", diagnostics: text };
380
585
  }
381
586
 
382
587
  /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
@@ -389,7 +594,7 @@ async function probeHostShellFamily(
389
594
  async function probeWakeReady(
390
595
  base: string,
391
596
  timeoutMs: number,
392
- proc: ReturnType<typeof Bun.spawn>,
597
+ proc: HostSshProcess,
393
598
  ): Promise<boolean> {
394
599
  const deadline = Date.now() + timeoutMs;
395
600
  for (;;) {
@@ -425,12 +630,15 @@ async function probeWakeReady(
425
630
  * so diagnostics can arrive on either stream — both are drained and
426
631
  * concatenated. Resolves when the streams end (i.e. after the process is
427
632
  * dead) — only await once the proc has been killed or has exited. */
428
- 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> {
429
637
  return Promise.all(
430
- streams.map((stream) => new Response(stream).text().catch(() => "")),
638
+ streams.map((stream) => (typeof stream === "object" && stream !== null ? new Response(stream).text().catch(() => "") : "")),
431
639
  ).then((texts) => {
432
640
  const text = texts.join("").trim();
433
- return text.length > keep ? `…${text.slice(-keep)}` : text;
641
+ return text.length > keep ? `...${text.slice(-keep)}` : text;
434
642
  });
435
643
  }
436
644
 
@@ -441,12 +649,17 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
441
649
  * abrupt-disconnect orphan). Encryption + host/user auth come from the user's
442
650
  * SSH config/keys. `sshCommand` is a test injection point for the ssh binary.
443
651
  * Bounded retries absorb a client-chosen remote port colliding with an
444
- * in-use one (the door fails to bind and the command exits). */
652
+ * in-use one (the door fails to bind and the command exits).
653
+ *
654
+ * Failure messages name the TARGET (an origin-level fact), never an endpoint
655
+ * label: under fan-out (ADR 0026 rule 1) every same-origin binding shares
656
+ * this one ladder and its one rejection — the per-endpoint identity belongs
657
+ * to the caller's row warning ("endpoint '<name>' capabilities unavailable:
658
+ * …"), which each row supplies itself. */
445
659
  async function openSshTunnel(
446
660
  parts: { user?: string; host: string },
447
- options: { sshCommand?: readonly string[] },
448
- endpointLabel: string,
449
- ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
661
+ options: { sshCommand?: readonly string[]; registryPath?: string },
662
+ ): Promise<{ proc: HostSshProcess; base: string }> {
450
663
  const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
451
664
  // Tier 1 first: a detached mux master per origin (no-op when one already
452
665
  // holds the socket); the wake client below rides it when possible and
@@ -460,7 +673,25 @@ async function openSshTunnel(
460
673
  // Shell form (2026-09-20): probed up front via `echo %OS%` (language-
461
674
  // independent — see probeHostShellFamily), POSIX by default; the wrong-
462
675
  // shell ladder below remains the backstop for a lying or timed-out probe.
463
- let form: WakeShellForm = 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
+ }
464
695
  const attempts = 3;
465
696
  for (let attempt = 1; attempt <= attempts; attempt++) {
466
697
  const localPort = await freePort();
@@ -478,14 +709,19 @@ async function openSshTunnel(
478
709
  ...(form === "posix" ? ["-tt"] : []),
479
710
  "-o", "ExitOnForwardFailure=yes",
480
711
  "-o", "BatchMode=yes",
712
+ ...connectTimeoutOptions(),
481
713
  ...(muxDisabled ? [] : muxClientOptions()),
482
714
  "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
483
715
  target,
484
716
  wakeDoorCommand(remotePort, form),
485
717
  ];
486
- const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
718
+ const proc = spawnHostSsh(options, argv);
487
719
  const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
488
720
  if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
721
+ // Ground truth for the shell-family cache: the form that actually woke
722
+ // the door — a probe's first guess, or the ladder's flip past a stale
723
+ // cache entry, both converge here.
724
+ persistShellFamily(shellFamilyCachePath(options), target, form);
489
725
  return { proc, base: `http://127.0.0.1:${localPort}` };
490
726
  }
491
727
  proc.kill();
@@ -494,6 +730,15 @@ async function openSshTunnel(
494
730
  // fast crash as a slow timeout.
495
731
  const diagnostics = (await stderrTail).trim();
496
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
+ }
497
742
  if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
498
743
  muxDisabled = true;
499
744
  }
@@ -505,21 +750,21 @@ async function openSshTunnel(
505
750
  // Both forms rejected: this is a shell mismatch, not a missing-ukp
506
751
  // failure — say so instead of falling into the missing-ukp wording.
507
752
  throw new RemoteTransportError(
508
- `waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed — the remote shell rejected both wake command forms (POSIX and cmd.exe): ${diagnostics}. Supported host shells: POSIX login shells, cmd.exe default, Git-Bash-style DefaultShell; a powershell DefaultShell is not supported`,
753
+ `waking the ukp door on '${target}' failed - the remote shell rejected both wake command forms (POSIX and cmd.exe): ${diagnostics}. Supported host shells: POSIX login shells, cmd.exe default, Git-Bash-style DefaultShell; a powershell DefaultShell is not supported`,
509
754
  );
510
755
  }
511
756
  // POSIX login shells print "…: ukp: command not found" (exit 127); Windows
512
757
  // OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
513
758
  if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
514
759
  throw new RemoteTransportError(
515
- `waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed — the remote shell cannot find 'ukp': ${diagnostics}. Host prerequisite (W9): ssh reachable AND ukp on the remote PATH`,
760
+ `waking the ukp door on '${target}' failed - the remote shell cannot find 'ukp': ${diagnostics}. Host prerequisite (W9): ssh reachable AND ukp on the remote PATH`,
516
761
  );
517
762
  }
518
763
  if (attempt === attempts) {
519
764
  throw new RemoteTransportError(
520
765
  exited
521
- ? `waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed: ${diagnostics}`
522
- : `the ukp door on '${target}' (endpoint '${endpointLabel}') did not become ready within ${WAKE_READY_TIMEOUT_MS / 1000}s; check the host alias and key auth (BatchMode)`,
766
+ ? `waking the ukp door on '${target}' failed: ${diagnostics}`
767
+ : wakeNotReadyMessage(target, WAKE_READY_TIMEOUT_MS),
523
768
  );
524
769
  }
525
770
  }
@@ -550,11 +795,12 @@ export async function openRemoteTransport(
550
795
  throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
551
796
  }
552
797
  if (parts.scheme !== "ssh") {
553
- return directTransportHandle(binding, parts, options);
798
+ return directTransportHandle({ ...binding, url: binding.url }, parts, options);
554
799
  }
555
- const tunnel = await openSshTunnel(parts, options, binding.name);
800
+ const tunnel = await openSshTunnel(parts, options);
556
801
  return {
557
802
  base: tunnel.base + wirePrefix(parts.endpointName),
803
+ displayUrl: binding.url,
558
804
  close: () => tunnel.proc.kill(),
559
805
  };
560
806
  }
@@ -576,7 +822,7 @@ export interface RemoteTransportPool {
576
822
  export function createTransportPool(
577
823
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
578
824
  ): RemoteTransportPool {
579
- const tunnels = new Map<string, Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }>>();
825
+ const tunnels = new Map<string, Promise<{ proc: HostSshProcess; base: string }>>();
580
826
  return {
581
827
  async acquire(binding) {
582
828
  if (binding.kind !== "remote" || binding.url === undefined) {
@@ -588,13 +834,13 @@ export function createTransportPool(
588
834
  throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
589
835
  }
590
836
  if (parts.scheme !== "ssh") {
591
- return directTransportHandle(binding, parts, options);
837
+ return directTransportHandle({ ...binding, url: binding.url }, parts, options);
592
838
  }
593
839
  let tunnel = tunnels.get(parts.origin);
594
840
  if (tunnel === undefined) {
595
841
  // Drop the entry on failure so a retry inside the same invocation
596
842
  // spawns fresh instead of re-throwing a stale rejected promise.
597
- tunnel = openSshTunnel(parts, options, binding.name).catch((error: unknown) => {
843
+ tunnel = openSshTunnel(parts, options).catch((error: unknown) => {
598
844
  tunnels.delete(parts.origin);
599
845
  throw error;
600
846
  });
@@ -603,6 +849,7 @@ export function createTransportPool(
603
849
  const acquired = await tunnel;
604
850
  return {
605
851
  base: acquired.base + wirePrefix(parts.endpointName),
852
+ displayUrl: binding.url,
606
853
  close: () => {},
607
854
  };
608
855
  },
@@ -621,11 +868,11 @@ function isTimeoutError(error: unknown): boolean {
621
868
  return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
622
869
  }
623
870
 
624
- function unreachableError(url: string, error: unknown): RemoteTransportError {
871
+ function unreachableError(displayUrl: string, error: unknown): RemoteTransportError {
625
872
  const code = (error as { code?: unknown }).code;
626
873
  const detail = error instanceof Error ? error.message : String(error);
627
874
  return new RemoteTransportError(
628
- `remote endpoint unreachable: ${url} (${detail}${typeof code === "string" && code !== "" ? ` [${code}]` : ""})`,
875
+ `remote endpoint unreachable: ${displayUrl} (${detail}${typeof code === "string" && code !== "" ? ` [${code}]` : ""})`,
629
876
  );
630
877
  }
631
878
 
@@ -640,7 +887,7 @@ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<
640
887
  if (probe === undefined) return false;
641
888
  if (anchor.pin !== undefined && probe.spkiPin !== anchor.pin) {
642
889
  throw new RemoteTransportError(
643
- `remote '${anchor.name ?? "endpoint"}' TLS identity changed — pinned ${anchor.pin}, got ${probe.spkiPin}; if the server was reinstalled this is expected: refresh trust with 'ukp register --url <url> --token <token>'`,
890
+ `remote '${anchor.name ?? "endpoint"}' TLS identity changed - pinned ${anchor.pin}, got ${probe.spkiPin}; if the server was reinstalled this is expected: refresh trust with 'ukp register --url <url> --token <token>'`,
644
891
  );
645
892
  }
646
893
  anchor.ca = probe.certPem;
@@ -657,12 +904,16 @@ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<
657
904
 
658
905
  /** Fetch + JSON-decode with one transparent renewal re-anchor: a pinned
659
906
  * https anchor turns a certificate-verification fetch failure into a probe —
660
- * same SPKI keeps going (anchor swapped in place), a different SPKI blocks. */
907
+ * same SPKI keeps going (anchor swapped in place), a different SPKI blocks.
908
+ * `displayUrl` (the url the caller knows) replaces the wire url in error
909
+ * text; without one the wire url itself is the best available label. */
661
910
  async function fetchJson(
662
911
  url: string,
663
912
  init: RequestInit,
664
913
  anchor?: RemoteTlsAnchor,
914
+ displayUrl?: string,
665
915
  ): Promise<{ status: number; body: unknown }> {
916
+ const label = displayUrl ?? url;
666
917
  const attempt = async (ca?: string, wrap = true): Promise<{ status: number; body: unknown }> => {
667
918
  let response: Response;
668
919
  try {
@@ -675,13 +926,13 @@ async function fetchJson(
675
926
  // wrap=false keeps the raw error so the anchored caller can classify
676
927
  // it (TLS verification failure → re-anchor probe) before wrapping.
677
928
  if (!wrap) throw error;
678
- throw unreachableError(url, error);
929
+ throw unreachableError(label, error);
679
930
  }
680
931
  let body: unknown;
681
932
  try {
682
933
  body = await response.json();
683
934
  } catch {
684
- throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${url}`);
935
+ throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${label}`);
685
936
  }
686
937
  return { status: response.status, body };
687
938
  };
@@ -691,17 +942,17 @@ async function fetchJson(
691
942
  return await attempt(anchor.ca, false);
692
943
  } catch (error) {
693
944
  if (error instanceof RemoteTransportError) throw error; // non-JSON body: not a TLS event
694
- if (isTimeoutError(error)) throw unreachableError(url, error);
945
+ if (isTimeoutError(error)) throw unreachableError(label, error);
695
946
  // Re-anchoring needs a pinned identity: only the SPKI pin distinguishes
696
947
  // "renewed certificate, same key" from "different identity".
697
948
  if (anchor.pin !== undefined && await reanchorFromProbe(url, anchor)) return await attempt(anchor.ca);
698
- throw unreachableError(url, error);
949
+ throw unreachableError(label, error);
699
950
  }
700
951
  }
701
952
 
702
- function asRecord(body: unknown, url: string): Record<string, unknown> {
953
+ function asRecord(body: unknown, displayUrl: string): Record<string, unknown> {
703
954
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
704
- throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${url}`);
955
+ throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${displayUrl}`);
705
956
  }
706
957
  return body as Record<string, unknown>;
707
958
  }
@@ -720,6 +971,7 @@ export async function fetchDiscoveryDocument(
720
971
  token?: string,
721
972
  ): Promise<DiscoveryFetch> {
722
973
  return fetchDiscoveryDocumentAt(transport.base, {
974
+ displayUrl: transport.displayUrl,
723
975
  name: binding.name,
724
976
  ...(binding.instance_uid !== undefined ? { pinnedUid: binding.instance_uid } : {}),
725
977
  ...(token !== undefined ? { token } : {}),
@@ -734,16 +986,17 @@ export async function fetchDiscoveryDocument(
734
986
  * (ADR-REM-004 / O-1). */
735
987
  async function fetchWellKnownRecord(
736
988
  base: string,
737
- options: { token?: string; tlsAnchor?: RemoteTlsAnchor },
989
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string },
738
990
  ): Promise<{ record: Record<string, unknown>; bearerRequired: boolean }> {
739
991
  const url = `${base}${DISCOVERY_PATH}`;
740
- const { body } = await fetchJson(url, { headers: authorizationHeaders(options.token) }, options.tlsAnchor);
741
- const record = asRecord(body, url);
992
+ const display = options.displayUrl ?? base;
993
+ const { body } = await fetchJson(url, { headers: authorizationHeaders(options.token) }, options.tlsAnchor, display);
994
+ const record = asRecord(body, display);
742
995
  if (record.protocol !== PROTOCOL_NAME) {
743
- throw new RemoteTransportError(`service at ${base} is not a ukp-remote service (protocol: ${String(record.protocol)})`);
996
+ throw new RemoteTransportError(`service at ${display} is not a ukp-remote service (protocol: ${String(record.protocol)})`);
744
997
  }
745
998
  if (record.protocol_version !== "1") {
746
- throw new RemoteTransportError(`service at ${base} speaks ukp-remote protocol version ${String(record.protocol_version)}; this client supports 1`);
999
+ throw new RemoteTransportError(`service at ${display} speaks ukp-remote protocol version ${String(record.protocol_version)}; this client supports 1`);
747
1000
  }
748
1001
  const schemes = (record.security as { schemes?: unknown } | undefined)?.schemes;
749
1002
  return { record, bearerRequired: Array.isArray(schemes) && schemes.includes("bearer") };
@@ -756,14 +1009,22 @@ async function fetchWellKnownRecord(
756
1009
  * documents; doors go through `fetchRegistrationDocument`. */
757
1010
  export async function fetchDiscoveryDocumentAt(
758
1011
  url: string,
759
- options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
1012
+ options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
760
1013
  ): Promise<DiscoveryFetch> {
761
1014
  const base = url.replace(/\/+$/, "");
762
- const label = options.name ?? base;
763
- const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
1015
+ const display = options.displayUrl ?? base;
1016
+ const label = options.name ?? display;
1017
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, {
1018
+ token: options.token,
1019
+ tlsAnchor: options.tlsAnchor,
1020
+ displayUrl: display,
1021
+ });
764
1022
  if (record.scope === "host") {
1023
+ // The remedy registers the DOOR, not the endpoint path the wire base
1024
+ // carries: derive the origin from the url the caller knows.
1025
+ const doorOrigin = parseRemoteUrl(display)?.origin ?? display;
765
1026
  throw new RemoteTransportError(
766
- `service at ${base} is a host door (scope:"host"); register it with 'ukp register --url ${base}' to import its endpoints`,
1027
+ `service at ${display} is a host door (scope:"host"); register it with 'ukp register --url ${doorOrigin}' to import its endpoints`,
767
1028
  );
768
1029
  }
769
1030
  const warnings: string[] = [];
@@ -815,15 +1076,20 @@ export interface DoorFetch {
815
1076
  * itself carries no identity. */
816
1077
  export async function fetchDoorDocument(
817
1078
  url: string,
818
- options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
1079
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
819
1080
  ): Promise<DoorFetch> {
820
1081
  const base = url.replace(/\/+$/, "");
821
- const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
1082
+ const display = options.displayUrl ?? base;
1083
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, {
1084
+ token: options.token,
1085
+ tlsAnchor: options.tlsAnchor,
1086
+ displayUrl: display,
1087
+ });
822
1088
  if (record.scope !== "host") {
823
- throw new RemoteTransportError(`service at ${base} is not a host door (scope: ${String(record.scope)}); register it directly with 'ukp register --url ${base}'`);
1089
+ throw new RemoteTransportError(`service at ${display} is not a host door (scope: ${String(record.scope)}); register it directly with 'ukp register --url ${display}'`);
824
1090
  }
825
1091
  return {
826
- doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, base) },
1092
+ doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, display) },
827
1093
  bearerRequired,
828
1094
  };
829
1095
  }
@@ -838,15 +1104,20 @@ export type RegistrationFetch =
838
1104
 
839
1105
  export async function fetchRegistrationDocument(
840
1106
  url: string,
841
- options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
1107
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
842
1108
  ): Promise<RegistrationFetch> {
843
1109
  const base = url.replace(/\/+$/, "");
844
- const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
1110
+ const display = options.displayUrl ?? base;
1111
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, {
1112
+ token: options.token,
1113
+ tlsAnchor: options.tlsAnchor,
1114
+ displayUrl: display,
1115
+ });
845
1116
  if (record.scope === "host") {
846
1117
  return {
847
1118
  kind: "door",
848
1119
  door: {
849
- doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, base) },
1120
+ doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, display) },
850
1121
  bearerRequired,
851
1122
  },
852
1123
  };
@@ -892,8 +1163,8 @@ export async function remoteSearch(
892
1163
  method: "POST",
893
1164
  headers: { "content-type": "application/json", ...authorizationHeaders(token) },
894
1165
  body: JSON.stringify({ query, limit }),
895
- }, transport.tls);
896
- const record = asRecord(body, `${base}/v1/search`);
1166
+ }, transport.tls, transport.displayUrl);
1167
+ const record = asRecord(body, transport.displayUrl);
897
1168
  const envelope = record.endpoints as Array<Record<string, unknown>> | undefined;
898
1169
  const entry = Array.isArray(envelope) ? envelope[0] : undefined;
899
1170
 
@@ -930,7 +1201,7 @@ export async function remoteSearch(
930
1201
 
931
1202
  function authMessage(token: string | undefined): string {
932
1203
  return token === undefined
933
- ? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-' → '_')"
1204
+ ? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-' -> '_')"
934
1205
  : "remote endpoint rejected the bearer token (401)";
935
1206
  }
936
1207
 
@@ -969,8 +1240,8 @@ export async function remoteRead(
969
1240
  if (params.pin !== undefined) search.set("pin", params.pin);
970
1241
  const { status, body } = await fetchJson(`${base}/v1/read?${search.toString()}`, {
971
1242
  headers: authorizationHeaders(token),
972
- }, transport.tls);
973
- const record = asRecord(body, `${base}/v1/read`);
1243
+ }, transport.tls, transport.displayUrl);
1244
+ const record = asRecord(body, transport.displayUrl);
974
1245
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
975
1246
  return {
976
1247
  status,
@@ -1007,8 +1278,8 @@ export async function remoteNav(
1007
1278
  const query = search.size > 0 ? `?${search.toString()}` : "";
1008
1279
  const { status, body } = await fetchJson(`${base}/v1/nav${query}`, {
1009
1280
  headers: authorizationHeaders(token),
1010
- }, transport.tls);
1011
- const record = asRecord(body, `${base}/v1/nav`);
1281
+ }, transport.tls, transport.displayUrl);
1282
+ const record = asRecord(body, transport.displayUrl);
1012
1283
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
1013
1284
  // Shape gate (read precedent validates what it consumes): a body claiming
1014
1285
  // the schema but missing the rendered fields would crash the shared
@@ -1057,10 +1328,23 @@ function remoteRgCounts(raw: unknown): RgCountEntry[] | undefined {
1057
1328
  });
1058
1329
  }
1059
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
+
1060
1341
  const RG_WIRE_STATUSES = new Set(["succeeded", "no_matches", "skipped", "failed", "interrupted", "cancelled"]);
1061
1342
 
1062
1343
  /** GET /v1/rg — single-endpoint outcome in the local RgEndpointOutcome
1063
- * 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. */
1064
1348
  export async function remoteRg(
1065
1349
  binding: RegistryBinding,
1066
1350
  transport: RemoteTransportHandle,
@@ -1068,24 +1352,27 @@ export async function remoteRg(
1068
1352
  params: {
1069
1353
  query: string;
1070
1354
  limit: number;
1071
- glob?: string;
1355
+ globs?: readonly string[];
1072
1356
  type?: string;
1073
1357
  ignoreCase?: boolean;
1074
1358
  count?: boolean;
1359
+ files?: boolean;
1075
1360
  passthrough: readonly string[];
1076
1361
  },
1077
1362
  ): Promise<RemoteRgExecution> {
1078
1363
  const base = transport.base;
1079
- const search = new URLSearchParams({ query: params.query, limit: String(params.limit) });
1080
- 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);
1081
1368
  if (params.type !== undefined) search.set("type", params.type);
1082
1369
  if (params.ignoreCase === true) search.set("i", "1");
1083
1370
  if (params.count === true) search.set("count", "1");
1084
1371
  for (const arg of params.passthrough) search.append("passthrough", arg);
1085
1372
  const { status, body } = await fetchJson(`${base}/v1/rg?${search.toString()}`, {
1086
1373
  headers: authorizationHeaders(token),
1087
- }, transport.tls);
1088
- const record = asRecord(body, `${base}/v1/rg`);
1374
+ }, transport.tls, transport.displayUrl);
1375
+ const record = asRecord(body, transport.displayUrl);
1089
1376
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
1090
1377
 
1091
1378
  if (status === 401) {
@@ -1112,6 +1399,9 @@ export async function remoteRg(
1112
1399
  match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
1113
1400
  );
1114
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
+ );
1115
1405
  const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
1116
1406
  return {
1117
1407
  outcome: {
@@ -1121,6 +1411,7 @@ export async function remoteRg(
1121
1411
  ...(typeof entry.message === "string" ? { message: entry.message } : {}),
1122
1412
  ...(matches !== undefined ? { matches } : {}),
1123
1413
  ...(counts !== undefined ? { counts } : {}),
1414
+ ...(files !== undefined ? { files } : {}),
1124
1415
  ...(entry.truncated === true ? { truncated: true } : {}),
1125
1416
  },
1126
1417
  warnings,
@@ -1156,8 +1447,8 @@ export async function remotePropose(
1156
1447
  method: "PUT",
1157
1448
  headers: { "content-type": "text/plain; charset=utf-8", ...authorizationHeaders(token) },
1158
1449
  body: content,
1159
- }, transport.tls);
1160
- const record = asRecord(body, url);
1450
+ }, transport.tls, transport.displayUrl);
1451
+ const record = asRecord(body, transport.displayUrl);
1161
1452
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
1162
1453
  const ok = record.schema === "ukp.propose.v1"
1163
1454
  && typeof record.id === "string"