@catheadowl/ukp 0.2.3 → 0.2.4

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/bin/ukp.js CHANGED
@@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, join } from "node:path";
15
- import { fileURLToPath } from "node:url";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
 
17
17
  const here = dirname(fileURLToPath(import.meta.url));
18
18
  const cli = join(here, "..", "src", "cli.ts");
@@ -68,15 +68,35 @@ function fail(detail) {
68
68
 
69
69
  const bunPath = bunCandidates().find((candidate) => existsSync(candidate));
70
70
  if (bunPath === undefined) {
71
- fail("looked for UKP_BUN, the standard install locations, and PATH");
71
+ fail("looked at UKP_BUN, the standard install locations, and PATH");
72
72
  }
73
- const result = spawnSync(bunPath, [cli, ...process.argv.slice(2)], {
74
- stdio: "inherit",
75
- env: process.env,
76
- });
77
- if (result.error) {
78
- // bunPath existed on disk a moment ago; a spawn failure here is exotic,
79
- // but it must still land in the remedy, never a stack trace.
80
- fail(`spawning '${bunPath}' failed: ${String(result.error.message ?? result.error)}`);
73
+
74
+ // In-process handoff when this launcher already runs under bun (bun's own
75
+ // bin links and `bunx`): importing the CLI keeps it in THIS process, where
76
+ // stdout/stderr keep their real TTY identity (columns, isTTY) — a spawnSync
77
+ // grandchild re-inherits duplicated handles, and on some hosts that layer
78
+ // degraded TTY detection (the third dogfood round: progress/wrap silently
79
+ // skipped while a direct run on the same console had both). One process
80
+ // also starts faster. The node-interpreted path (npm shims) keeps spawning.
81
+ if (runningUnderBun) {
82
+ const { runCli } = await import(pathToFileURL(cli).href);
83
+ const exit = runCli(process.argv.slice(2));
84
+ if (exit instanceof Promise) {
85
+ exit.then((code) => {
86
+ process.exitCode = code;
87
+ });
88
+ } else {
89
+ process.exitCode = exit;
90
+ }
91
+ } else {
92
+ const result = spawnSync(bunPath, [cli, ...process.argv.slice(2)], {
93
+ stdio: "inherit",
94
+ env: process.env,
95
+ });
96
+ if (result.error) {
97
+ // bunPath existed on disk a moment ago; a spawn failure here is exotic,
98
+ // but it must still land in the remedy, never a stack trace.
99
+ fail(`spawning '${bunPath}' failed: ${String(result.error.message ?? result.error)}`);
100
+ }
101
+ process.exit(result.status === null ? (result.signal !== null ? 130 : 1) : result.status);
81
102
  }
82
- process.exit(result.status === null ? (result.signal !== null ? 130 : 1) : result.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catheadowl/ukp",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.14"
@@ -442,7 +442,7 @@ export function runPropose(request: ProposeRequest, context: ProposeContext): Pr
442
442
  failure: {
443
443
  errorClass: "capability-undeclared",
444
444
  message: `endpoint '${binding.name}' does not declare the propose capability`
445
- + ` — declare [capabilities.propose] in the Service Manifest (.ukp/service.toml); 'ukp guide propose' walks through it`,
445
+ + ` - declare [capabilities.propose] in the Service Manifest (.ukp/service.toml); 'ukp guide propose' walks through it`,
446
446
  },
447
447
  };
448
448
  }
@@ -870,7 +870,7 @@ function executeResolvedRead(
870
870
  return readFailure(
871
871
  "resource-missing",
872
872
  `resource-missing '${request.path}' in endpoint '${binding.name}'`
873
- + ` (absolute paths address files exactly; the mapped route does not exist — the target may live one level deeper, e.g. under src/)\n`
873
+ + ` (absolute paths address files exactly; the mapped route does not exist - the target may live one level deeper, e.g. under src/)\n`
874
874
  + formatRecoveryCandidates(recovered.recovery.candidates).trimEnd(),
875
875
  recovered.recovery,
876
876
  );
@@ -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,
@@ -54,6 +57,25 @@ function remoteTimeoutMs(): number {
54
57
  return Number.isSafeInteger(value) && value >= 1000 ? value : 60_000;
55
58
  }
56
59
 
60
+ /** Connection-establishment bound (ADR 0026 rule 2 / D-091): every ssh
61
+ * process UKP spawns carries ConnectTimeout, so an unreachable host fails
62
+ * in seconds instead of waiting out the OS TCP timeout — which then
63
+ * multiplied through the wake ladder's attempts and the serial origin
64
+ * chain. Default 10s (the Ansible baseline); `UKP_SSH_CONNECT_TIMEOUT_MS`
65
+ * overrides, mirroring UKP_REMOTE_TIMEOUT_MS. ssh takes whole seconds, so
66
+ * an integer ms value ≥1000 floors (1500 → 1s); anything else — non-numeric,
67
+ * fractional, below 1000 — keeps the default. */
68
+ function sshConnectTimeoutSeconds(): number {
69
+ const raw = process.env.UKP_SSH_CONNECT_TIMEOUT_MS;
70
+ if (raw === undefined || raw.trim() === "") return 10;
71
+ const value = Number(raw);
72
+ return Number.isSafeInteger(value) && value >= 1000 ? Math.floor(value / 1000) : 10;
73
+ }
74
+
75
+ function connectTimeoutOptions(): string[] {
76
+ return ["-o", `ConnectTimeout=${sshConnectTimeoutSeconds()}`];
77
+ }
78
+
57
79
  /** TOFU mismatch policy (RQ-17): `warn` (default) appends a warning and
58
80
  * continues; `block` refuses the endpoint until it is explicitly
59
81
  * re-registered. */
@@ -64,6 +86,11 @@ function tofuMode(): "warn" | "block" {
64
86
  export interface RemoteTransportHandle {
65
87
  /** Wire base the client actually fetches (tunnel endpoint or the url itself). */
66
88
  base: string;
89
+ /** The url the caller knows — the registered binding url. Error and remedy
90
+ * text quotes THIS, never the wire base: a door-endpoint binding's wire
91
+ * base carries the `/e/<name>` route prefix (and, over ssh, a loopback
92
+ * tunnel port) that no user ever typed or could act on. */
93
+ displayUrl: string;
67
94
  /** Releases per-invocation resources (kills an ephemeral tunnel); noop for
68
95
  * direct connections. */
69
96
  close: () => void;
@@ -178,12 +205,13 @@ function tlsAnchorOf(binding: RegistryBinding, options: { registryPath?: string
178
205
  }
179
206
 
180
207
  function directTransportHandle(
181
- binding: RegistryBinding,
208
+ binding: RegistryBinding & { url: string },
182
209
  parts: RemoteUrlParts,
183
210
  options: { registryPath?: string },
184
211
  ): RemoteTransportHandle {
185
212
  return {
186
213
  base: parts.origin + wirePrefix(parts.endpointName),
214
+ displayUrl: binding.url,
187
215
  close: () => {},
188
216
  ...(tlsAnchorOf(binding, options) !== undefined ? { tls: tlsAnchorOf(binding, options)! } : {}),
189
217
  };
@@ -276,6 +304,7 @@ function spawnMuxMaster(
276
304
  ...(sshCommand ?? ["ssh"]),
277
305
  "-N",
278
306
  "-o", "BatchMode=yes",
307
+ ...connectTimeoutOptions(),
279
308
  ...muxCandidateOptions(),
280
309
  target,
281
310
  ];
@@ -352,6 +381,51 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
352
381
  return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
353
382
  }
354
383
 
384
+ /** Cross-invocation shell-family cache (ukp_list W1 / L-1, ADR 0026's
385
+ * implementation-time note): a host's shell family is a static fact, but
386
+ * learning it costs a full ssh roundtrip per origin per invocation — on
387
+ * win32 (no ControlMaster) a full TCP+key handshake. Cached beside the
388
+ * registry (`<registry dir>/wake-shell.toml`, keyed by ssh target), the
389
+ * probe is skipped when an entry exists. The wake ladder stays the
390
+ * backstop: a stale entry (host reinstalled under a different shell) costs
391
+ * one wrong-shell retry, and the successful form is written back — the
392
+ * cache can only lose time, never correctness. Best-effort by design:
393
+ * missing/corrupt/unwritable ⇒ re-probe or run uncached. */
394
+ function shellFamilyCachePath(options: { registryPath?: string }): string | undefined {
395
+ return options.registryPath === undefined ? undefined : join(dirname(options.registryPath), "wake-shell.toml");
396
+ }
397
+
398
+ function readCachedShellFamily(cachePath: string | undefined, target: string): WakeShellForm | undefined {
399
+ if (cachePath === undefined) return undefined;
400
+ try {
401
+ const parsed = parseToml(readFileSync(cachePath, "utf8")) as { shells?: Record<string, unknown> };
402
+ const value = parsed.shells?.[target];
403
+ return value === "posix" || value === "cmd" ? value : undefined;
404
+ } catch {
405
+ return undefined;
406
+ }
407
+ }
408
+
409
+ function persistShellFamily(cachePath: string | undefined, target: string, form: WakeShellForm): void {
410
+ if (cachePath === undefined) return;
411
+ try {
412
+ let shells: Record<string, unknown> = {};
413
+ try {
414
+ const parsed = parseToml(readFileSync(cachePath, "utf8")) as { shells?: Record<string, unknown> };
415
+ if (parsed.shells !== undefined && typeof parsed.shells === "object") shells = parsed.shells;
416
+ } catch {
417
+ // corrupt or absent: a fresh map replaces whatever was there
418
+ }
419
+ // Unchanged value: no write — a per-origin-per-invocation rewrite would
420
+ // only amplify disk churn and widen the concurrent-writer window.
421
+ if (shells[target] === form) return;
422
+ shells[target] = form;
423
+ writeFileSync(cachePath, stringifyToml({ shells }), { mode: 0o600 });
424
+ } catch {
425
+ // unwritable (read-only dir, concurrent writer): run without cache
426
+ }
427
+ }
428
+
355
429
  /** One cheap, language-independent roundtrip to learn the host shell family
356
430
  * BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
357
431
  * expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
@@ -368,7 +442,7 @@ async function probeHostShellFamily(
368
442
  target: string,
369
443
  ): Promise<WakeShellForm> {
370
444
  const proc = Bun.spawn(
371
- [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", target, "echo %OS%"],
445
+ [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", ...connectTimeoutOptions(), target, "echo %OS%"],
372
446
  { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
373
447
  );
374
448
  const output = await Promise.race([
@@ -430,7 +504,7 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
430
504
  streams.map((stream) => new Response(stream).text().catch(() => "")),
431
505
  ).then((texts) => {
432
506
  const text = texts.join("").trim();
433
- return text.length > keep ? `…${text.slice(-keep)}` : text;
507
+ return text.length > keep ? `...${text.slice(-keep)}` : text;
434
508
  });
435
509
  }
436
510
 
@@ -441,11 +515,16 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
441
515
  * abrupt-disconnect orphan). Encryption + host/user auth come from the user's
442
516
  * SSH config/keys. `sshCommand` is a test injection point for the ssh binary.
443
517
  * Bounded retries absorb a client-chosen remote port colliding with an
444
- * in-use one (the door fails to bind and the command exits). */
518
+ * in-use one (the door fails to bind and the command exits).
519
+ *
520
+ * Failure messages name the TARGET (an origin-level fact), never an endpoint
521
+ * label: under fan-out (ADR 0026 rule 1) every same-origin binding shares
522
+ * this one ladder and its one rejection — the per-endpoint identity belongs
523
+ * to the caller's row warning ("endpoint '<name>' capabilities unavailable:
524
+ * …"), which each row supplies itself. */
445
525
  async function openSshTunnel(
446
526
  parts: { user?: string; host: string },
447
- options: { sshCommand?: readonly string[] },
448
- endpointLabel: string,
527
+ options: { sshCommand?: readonly string[]; registryPath?: string },
449
528
  ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
450
529
  const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
451
530
  // Tier 1 first: a detached mux master per origin (no-op when one already
@@ -460,7 +539,8 @@ async function openSshTunnel(
460
539
  // Shell form (2026-09-20): probed up front via `echo %OS%` (language-
461
540
  // independent — see probeHostShellFamily), POSIX by default; the wrong-
462
541
  // shell ladder below remains the backstop for a lying or timed-out probe.
463
- let form: WakeShellForm = await probeHostShellFamily(options, target);
542
+ let form: WakeShellForm = readCachedShellFamily(shellFamilyCachePath(options), target)
543
+ ?? await probeHostShellFamily(options, target);
464
544
  const attempts = 3;
465
545
  for (let attempt = 1; attempt <= attempts; attempt++) {
466
546
  const localPort = await freePort();
@@ -478,6 +558,7 @@ async function openSshTunnel(
478
558
  ...(form === "posix" ? ["-tt"] : []),
479
559
  "-o", "ExitOnForwardFailure=yes",
480
560
  "-o", "BatchMode=yes",
561
+ ...connectTimeoutOptions(),
481
562
  ...(muxDisabled ? [] : muxClientOptions()),
482
563
  "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
483
564
  target,
@@ -486,6 +567,10 @@ async function openSshTunnel(
486
567
  const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
487
568
  const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
488
569
  if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
570
+ // Ground truth for the shell-family cache: the form that actually woke
571
+ // the door — a probe's first guess, or the ladder's flip past a stale
572
+ // cache entry, both converge here.
573
+ persistShellFamily(shellFamilyCachePath(options), target, form);
489
574
  return { proc, base: `http://127.0.0.1:${localPort}` };
490
575
  }
491
576
  proc.kill();
@@ -505,21 +590,21 @@ async function openSshTunnel(
505
590
  // Both forms rejected: this is a shell mismatch, not a missing-ukp
506
591
  // failure — say so instead of falling into the missing-ukp wording.
507
592
  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`,
593
+ `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
594
  );
510
595
  }
511
596
  // POSIX login shells print "…: ukp: command not found" (exit 127); Windows
512
597
  // OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
513
598
  if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
514
599
  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`,
600
+ `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
601
  );
517
602
  }
518
603
  if (attempt === attempts) {
519
604
  throw new RemoteTransportError(
520
605
  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)`,
606
+ ? `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)`,
523
608
  );
524
609
  }
525
610
  }
@@ -550,11 +635,12 @@ export async function openRemoteTransport(
550
635
  throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
551
636
  }
552
637
  if (parts.scheme !== "ssh") {
553
- return directTransportHandle(binding, parts, options);
638
+ return directTransportHandle({ ...binding, url: binding.url }, parts, options);
554
639
  }
555
- const tunnel = await openSshTunnel(parts, options, binding.name);
640
+ const tunnel = await openSshTunnel(parts, options);
556
641
  return {
557
642
  base: tunnel.base + wirePrefix(parts.endpointName),
643
+ displayUrl: binding.url,
558
644
  close: () => tunnel.proc.kill(),
559
645
  };
560
646
  }
@@ -588,13 +674,13 @@ export function createTransportPool(
588
674
  throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
589
675
  }
590
676
  if (parts.scheme !== "ssh") {
591
- return directTransportHandle(binding, parts, options);
677
+ return directTransportHandle({ ...binding, url: binding.url }, parts, options);
592
678
  }
593
679
  let tunnel = tunnels.get(parts.origin);
594
680
  if (tunnel === undefined) {
595
681
  // Drop the entry on failure so a retry inside the same invocation
596
682
  // spawns fresh instead of re-throwing a stale rejected promise.
597
- tunnel = openSshTunnel(parts, options, binding.name).catch((error: unknown) => {
683
+ tunnel = openSshTunnel(parts, options).catch((error: unknown) => {
598
684
  tunnels.delete(parts.origin);
599
685
  throw error;
600
686
  });
@@ -603,6 +689,7 @@ export function createTransportPool(
603
689
  const acquired = await tunnel;
604
690
  return {
605
691
  base: acquired.base + wirePrefix(parts.endpointName),
692
+ displayUrl: binding.url,
606
693
  close: () => {},
607
694
  };
608
695
  },
@@ -621,11 +708,11 @@ function isTimeoutError(error: unknown): boolean {
621
708
  return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
622
709
  }
623
710
 
624
- function unreachableError(url: string, error: unknown): RemoteTransportError {
711
+ function unreachableError(displayUrl: string, error: unknown): RemoteTransportError {
625
712
  const code = (error as { code?: unknown }).code;
626
713
  const detail = error instanceof Error ? error.message : String(error);
627
714
  return new RemoteTransportError(
628
- `remote endpoint unreachable: ${url} (${detail}${typeof code === "string" && code !== "" ? ` [${code}]` : ""})`,
715
+ `remote endpoint unreachable: ${displayUrl} (${detail}${typeof code === "string" && code !== "" ? ` [${code}]` : ""})`,
629
716
  );
630
717
  }
631
718
 
@@ -640,7 +727,7 @@ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<
640
727
  if (probe === undefined) return false;
641
728
  if (anchor.pin !== undefined && probe.spkiPin !== anchor.pin) {
642
729
  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>'`,
730
+ `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
731
  );
645
732
  }
646
733
  anchor.ca = probe.certPem;
@@ -657,12 +744,16 @@ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<
657
744
 
658
745
  /** Fetch + JSON-decode with one transparent renewal re-anchor: a pinned
659
746
  * https anchor turns a certificate-verification fetch failure into a probe —
660
- * same SPKI keeps going (anchor swapped in place), a different SPKI blocks. */
747
+ * same SPKI keeps going (anchor swapped in place), a different SPKI blocks.
748
+ * `displayUrl` (the url the caller knows) replaces the wire url in error
749
+ * text; without one the wire url itself is the best available label. */
661
750
  async function fetchJson(
662
751
  url: string,
663
752
  init: RequestInit,
664
753
  anchor?: RemoteTlsAnchor,
754
+ displayUrl?: string,
665
755
  ): Promise<{ status: number; body: unknown }> {
756
+ const label = displayUrl ?? url;
666
757
  const attempt = async (ca?: string, wrap = true): Promise<{ status: number; body: unknown }> => {
667
758
  let response: Response;
668
759
  try {
@@ -675,13 +766,13 @@ async function fetchJson(
675
766
  // wrap=false keeps the raw error so the anchored caller can classify
676
767
  // it (TLS verification failure → re-anchor probe) before wrapping.
677
768
  if (!wrap) throw error;
678
- throw unreachableError(url, error);
769
+ throw unreachableError(label, error);
679
770
  }
680
771
  let body: unknown;
681
772
  try {
682
773
  body = await response.json();
683
774
  } catch {
684
- throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${url}`);
775
+ throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${label}`);
685
776
  }
686
777
  return { status: response.status, body };
687
778
  };
@@ -691,17 +782,17 @@ async function fetchJson(
691
782
  return await attempt(anchor.ca, false);
692
783
  } catch (error) {
693
784
  if (error instanceof RemoteTransportError) throw error; // non-JSON body: not a TLS event
694
- if (isTimeoutError(error)) throw unreachableError(url, error);
785
+ if (isTimeoutError(error)) throw unreachableError(label, error);
695
786
  // Re-anchoring needs a pinned identity: only the SPKI pin distinguishes
696
787
  // "renewed certificate, same key" from "different identity".
697
788
  if (anchor.pin !== undefined && await reanchorFromProbe(url, anchor)) return await attempt(anchor.ca);
698
- throw unreachableError(url, error);
789
+ throw unreachableError(label, error);
699
790
  }
700
791
  }
701
792
 
702
- function asRecord(body: unknown, url: string): Record<string, unknown> {
793
+ function asRecord(body: unknown, displayUrl: string): Record<string, unknown> {
703
794
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
704
- throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${url}`);
795
+ throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${displayUrl}`);
705
796
  }
706
797
  return body as Record<string, unknown>;
707
798
  }
@@ -720,6 +811,7 @@ export async function fetchDiscoveryDocument(
720
811
  token?: string,
721
812
  ): Promise<DiscoveryFetch> {
722
813
  return fetchDiscoveryDocumentAt(transport.base, {
814
+ displayUrl: transport.displayUrl,
723
815
  name: binding.name,
724
816
  ...(binding.instance_uid !== undefined ? { pinnedUid: binding.instance_uid } : {}),
725
817
  ...(token !== undefined ? { token } : {}),
@@ -734,16 +826,17 @@ export async function fetchDiscoveryDocument(
734
826
  * (ADR-REM-004 / O-1). */
735
827
  async function fetchWellKnownRecord(
736
828
  base: string,
737
- options: { token?: string; tlsAnchor?: RemoteTlsAnchor },
829
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string },
738
830
  ): Promise<{ record: Record<string, unknown>; bearerRequired: boolean }> {
739
831
  const url = `${base}${DISCOVERY_PATH}`;
740
- const { body } = await fetchJson(url, { headers: authorizationHeaders(options.token) }, options.tlsAnchor);
741
- const record = asRecord(body, url);
832
+ const display = options.displayUrl ?? base;
833
+ const { body } = await fetchJson(url, { headers: authorizationHeaders(options.token) }, options.tlsAnchor, display);
834
+ const record = asRecord(body, display);
742
835
  if (record.protocol !== PROTOCOL_NAME) {
743
- throw new RemoteTransportError(`service at ${base} is not a ukp-remote service (protocol: ${String(record.protocol)})`);
836
+ throw new RemoteTransportError(`service at ${display} is not a ukp-remote service (protocol: ${String(record.protocol)})`);
744
837
  }
745
838
  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`);
839
+ throw new RemoteTransportError(`service at ${display} speaks ukp-remote protocol version ${String(record.protocol_version)}; this client supports 1`);
747
840
  }
748
841
  const schemes = (record.security as { schemes?: unknown } | undefined)?.schemes;
749
842
  return { record, bearerRequired: Array.isArray(schemes) && schemes.includes("bearer") };
@@ -756,14 +849,22 @@ async function fetchWellKnownRecord(
756
849
  * documents; doors go through `fetchRegistrationDocument`. */
757
850
  export async function fetchDiscoveryDocumentAt(
758
851
  url: string,
759
- options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
852
+ options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
760
853
  ): Promise<DiscoveryFetch> {
761
854
  const base = url.replace(/\/+$/, "");
762
- const label = options.name ?? base;
763
- const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
855
+ const display = options.displayUrl ?? base;
856
+ const label = options.name ?? display;
857
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, {
858
+ token: options.token,
859
+ tlsAnchor: options.tlsAnchor,
860
+ displayUrl: display,
861
+ });
764
862
  if (record.scope === "host") {
863
+ // The remedy registers the DOOR, not the endpoint path the wire base
864
+ // carries: derive the origin from the url the caller knows.
865
+ const doorOrigin = parseRemoteUrl(display)?.origin ?? display;
765
866
  throw new RemoteTransportError(
766
- `service at ${base} is a host door (scope:"host"); register it with 'ukp register --url ${base}' to import its endpoints`,
867
+ `service at ${display} is a host door (scope:"host"); register it with 'ukp register --url ${doorOrigin}' to import its endpoints`,
767
868
  );
768
869
  }
769
870
  const warnings: string[] = [];
@@ -815,15 +916,20 @@ export interface DoorFetch {
815
916
  * itself carries no identity. */
816
917
  export async function fetchDoorDocument(
817
918
  url: string,
818
- options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
919
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
819
920
  ): Promise<DoorFetch> {
820
921
  const base = url.replace(/\/+$/, "");
821
- const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
922
+ const display = options.displayUrl ?? base;
923
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, {
924
+ token: options.token,
925
+ tlsAnchor: options.tlsAnchor,
926
+ displayUrl: display,
927
+ });
822
928
  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}'`);
929
+ throw new RemoteTransportError(`service at ${display} is not a host door (scope: ${String(record.scope)}); register it directly with 'ukp register --url ${display}'`);
824
930
  }
825
931
  return {
826
- doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, base) },
932
+ doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, display) },
827
933
  bearerRequired,
828
934
  };
829
935
  }
@@ -838,15 +944,20 @@ export type RegistrationFetch =
838
944
 
839
945
  export async function fetchRegistrationDocument(
840
946
  url: string,
841
- options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
947
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
842
948
  ): Promise<RegistrationFetch> {
843
949
  const base = url.replace(/\/+$/, "");
844
- const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
950
+ const display = options.displayUrl ?? base;
951
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, {
952
+ token: options.token,
953
+ tlsAnchor: options.tlsAnchor,
954
+ displayUrl: display,
955
+ });
845
956
  if (record.scope === "host") {
846
957
  return {
847
958
  kind: "door",
848
959
  door: {
849
- doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, base) },
960
+ doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, display) },
850
961
  bearerRequired,
851
962
  },
852
963
  };
@@ -892,8 +1003,8 @@ export async function remoteSearch(
892
1003
  method: "POST",
893
1004
  headers: { "content-type": "application/json", ...authorizationHeaders(token) },
894
1005
  body: JSON.stringify({ query, limit }),
895
- }, transport.tls);
896
- const record = asRecord(body, `${base}/v1/search`);
1006
+ }, transport.tls, transport.displayUrl);
1007
+ const record = asRecord(body, transport.displayUrl);
897
1008
  const envelope = record.endpoints as Array<Record<string, unknown>> | undefined;
898
1009
  const entry = Array.isArray(envelope) ? envelope[0] : undefined;
899
1010
 
@@ -930,7 +1041,7 @@ export async function remoteSearch(
930
1041
 
931
1042
  function authMessage(token: string | undefined): string {
932
1043
  return token === undefined
933
- ? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-' → '_')"
1044
+ ? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-' -> '_')"
934
1045
  : "remote endpoint rejected the bearer token (401)";
935
1046
  }
936
1047
 
@@ -969,8 +1080,8 @@ export async function remoteRead(
969
1080
  if (params.pin !== undefined) search.set("pin", params.pin);
970
1081
  const { status, body } = await fetchJson(`${base}/v1/read?${search.toString()}`, {
971
1082
  headers: authorizationHeaders(token),
972
- }, transport.tls);
973
- const record = asRecord(body, `${base}/v1/read`);
1083
+ }, transport.tls, transport.displayUrl);
1084
+ const record = asRecord(body, transport.displayUrl);
974
1085
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
975
1086
  return {
976
1087
  status,
@@ -1007,8 +1118,8 @@ export async function remoteNav(
1007
1118
  const query = search.size > 0 ? `?${search.toString()}` : "";
1008
1119
  const { status, body } = await fetchJson(`${base}/v1/nav${query}`, {
1009
1120
  headers: authorizationHeaders(token),
1010
- }, transport.tls);
1011
- const record = asRecord(body, `${base}/v1/nav`);
1121
+ }, transport.tls, transport.displayUrl);
1122
+ const record = asRecord(body, transport.displayUrl);
1012
1123
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
1013
1124
  // Shape gate (read precedent validates what it consumes): a body claiming
1014
1125
  // the schema but missing the rendered fields would crash the shared
@@ -1084,8 +1195,8 @@ export async function remoteRg(
1084
1195
  for (const arg of params.passthrough) search.append("passthrough", arg);
1085
1196
  const { status, body } = await fetchJson(`${base}/v1/rg?${search.toString()}`, {
1086
1197
  headers: authorizationHeaders(token),
1087
- }, transport.tls);
1088
- const record = asRecord(body, `${base}/v1/rg`);
1198
+ }, transport.tls, transport.displayUrl);
1199
+ const record = asRecord(body, transport.displayUrl);
1089
1200
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
1090
1201
 
1091
1202
  if (status === 401) {
@@ -1156,8 +1267,8 @@ export async function remotePropose(
1156
1267
  method: "PUT",
1157
1268
  headers: { "content-type": "text/plain; charset=utf-8", ...authorizationHeaders(token) },
1158
1269
  body: content,
1159
- }, transport.tls);
1160
- const record = asRecord(body, url);
1270
+ }, transport.tls, transport.displayUrl);
1271
+ const record = asRecord(body, transport.displayUrl);
1161
1272
  const error = record.error as { class?: unknown; message?: unknown } | undefined;
1162
1273
  const ok = record.schema === "ukp.propose.v1"
1163
1274
  && typeof record.id === "string"
@@ -155,7 +155,7 @@ export function validateRgPassthrough(args: readonly string[]): readonly string[
155
155
  const token = args[index]!;
156
156
  if (!token.startsWith("-")) {
157
157
  throw new RgUsageError(
158
- `passthrough argument '${token}' is not allowed: rg targets the Service folder — no path operands (use the endpoint selector for scope)`,
158
+ `passthrough argument '${token}' is not allowed: rg targets the Service folder - no path operands (use the endpoint selector for scope)`,
159
159
  );
160
160
  }
161
161
  if (PASSTHROUGH_BOOL_FLAGS.has(token)) continue;
@@ -872,7 +872,7 @@ function renderResultUnit(
872
872
  const base = basenameOf(providerLocation);
873
873
  const location = base ? (line ? `${base}:${line}` : base) : "";
874
874
  const identity = title
875
- ? (location ? `${title} — ${location}` : title)
875
+ ? (location ? `${title} - ${location}` : title)
876
876
  : (location || "");
877
877
 
878
878
  const snippet = snippetOf(result);
@@ -890,7 +890,7 @@ function renderResultUnit(
890
890
  const key = line ? `${docid}:${line}` : docid;
891
891
  lines.push(` read: ukp read --endpoint ${endpointName} ${key}`);
892
892
  } else {
893
- lines.push(` (no direct read — provider-managed result)`);
893
+ lines.push(` (no direct read - provider-managed result)`);
894
894
  }
895
895
  if (ukpUri && remoteUri === undefined) lines.push(` uri: ${ukpUri}`);
896
896
  return lines.join("\n");