@catheadowl/ukp 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { connect as netConnect, isIP } from "node:net";
1
+ import { isIP } from "node:net";
2
2
  import { connect as tlsConnect } from "node:tls";
3
3
  import { X509Certificate } from "node:crypto";
4
4
  import {
@@ -87,24 +87,6 @@ function freePort(): Promise<number> {
87
87
  });
88
88
  }
89
89
 
90
- function portAccepts(hostname: string, port: number, timeoutMs: number): Promise<boolean> {
91
- const deadline = Date.now() + timeoutMs;
92
- return new Promise((resolve) => {
93
- const attempt = () => {
94
- const socket = netConnect({ host: hostname, port }, () => {
95
- socket.destroy();
96
- resolve(true);
97
- });
98
- socket.on("error", () => {
99
- socket.destroy();
100
- if (Date.now() > deadline) resolve(false);
101
- else setTimeout(attempt, 120);
102
- });
103
- };
104
- attempt();
105
- });
106
- }
107
-
108
90
  /** TLS trust state for one invocation (W5' / D-079): `ca` is the pinned
109
91
  * certificate PEM handed to fetch as the trust anchor; `pin` is the RFC 7469
110
92
  * SPKI pin used to distinguish "server renewed its certificate, same key"
@@ -207,43 +189,323 @@ function directTransportHandle(
207
189
  };
208
190
  }
209
191
 
210
- /** One ephemeral ssh local forward (D-078): free local port → the remote
211
- * host's loopback on `port`; encryption + host auth come from the user's
212
- * SSH config/keys. Shared by the one-shot transport and the per-origin
213
- * pool; `sshCommand` is a test injection point for the ssh binary. */
214
- async function openSshTunnel(
215
- parts: { user?: string; host: string; port: number },
216
- options: { sshCommand?: readonly string[] },
217
- endpointLabel: string,
218
- ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
219
- const localPort = await freePort();
220
- const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
192
+ /** W9 / ADR-REM-006: the woken door self-reaps after this idle window. The
193
+ * ali E2E falsified the "clean disconnect reaps via SIGHUP" assumption for
194
+ * no-TTY sessions — without a controlling terminal a remote command NEVER
195
+ * receives SIGHUP, normal disconnect included — so `-tt` (below) is what
196
+ * makes the door session-bound, and this window is the pure backstop for
197
+ * paths where even the pty session lingers (network death awaiting TCP
198
+ * keepalive). NOT a tuning knob. */
199
+ const WAKE_DOOR_MAX_IDLE_SECONDS = 60;
200
+ /** Probe budget for a woken door: ssh handshake + auth + remote bun startup. */
201
+ const WAKE_READY_TIMEOUT_MS = 20_000;
202
+ /** Remote door ports are client-chosen from this unprivileged band — the url
203
+ * port no longer selects anything under wake (the resident-door assumption is
204
+ * gone; per-binding door urls keep their identity role). The band sits BELOW
205
+ * the Linux (32768+) and Windows (49152+) ephemeral ranges, so OS-assigned
206
+ * short-lived listeners never collide with it; only an explicitly bound
207
+ * service in the band can (rare — absorbed by the bounded retry). */
208
+ const WAKE_REMOTE_PORT_MIN = 20000;
209
+ const WAKE_REMOTE_PORT_SPAN = 12768;
210
+
211
+ function randomRemotePort(): number {
212
+ return WAKE_REMOTE_PORT_MIN + Math.floor(Math.random() * WAKE_REMOTE_PORT_SPAN);
213
+ }
214
+
215
+ /** Tier 1 (ADR-REM-006 §4): substrate-native handshake amortization. A
216
+ * dedicated `-N` mux master is spawned per origin before the wake client;
217
+ * with ControlPersist it daemonizes immediately and self-exits WINDOW
218
+ * seconds after the last session — ukp never reaps it. The wake client then
219
+ * multiplexes over the warm connection (no TCP+key handshake); only the
220
+ * remote bun startup stays per-call. The door STILL spawns per call as the
221
+ * client's own session and dies with it — this is NOT the deferred Tier-2
222
+ * persistent door. UKP-owned ControlPath namespace (keyed by %r/%h/%p = the
223
+ * SSH endpoint, not the forwarded ports) avoids colliding with the user's
224
+ * own multiplexing config. Native Windows OpenSSH has no ControlMaster
225
+ * support ("unix listener too long") — Tier 0 there, per-call handshake. */
226
+ const WAKE_MUX_PERSIST_SECONDS = 120;
227
+ const WAKE_MUX_CONTROL_PATH = "~/.ssh/ukp-cm-%r@%h-%p";
228
+ /** A successful candidate daemonizes (its foreground exits within seconds of
229
+ * the handshake); one still alive after this window is by definition the
230
+ * degraded direct `-N` connection — reaped here. */
231
+ const WAKE_MASTER_GRACE_MS = 10_000;
232
+
233
+ /** The candidate's options: it OWNS master creation (ControlMaster=auto +
234
+ * ControlPersist — with them a becoming-master daemonizes immediately and
235
+ * self-exits after the persist window). */
236
+ function muxCandidateOptions(): readonly string[] {
237
+ if (process.platform === "win32") return [];
238
+ return [
239
+ "-o", "ControlMaster=auto",
240
+ "-o", `ControlPersist=${WAKE_MUX_PERSIST_SECONDS}`,
241
+ "-o", `ControlPath=${WAKE_MUX_CONTROL_PATH}`,
242
+ ];
243
+ }
244
+
245
+ /** The wake client's options: attach-only (ControlMaster=no + ControlPath).
246
+ * OpenSSH attempts the control socket whenever ControlPath is set, so the
247
+ * client rides an existing master without any handshake; with no master (or
248
+ * a stale socket) it just connects directly. Crucially the client NEVER
249
+ * creates masters: a client-side auto racing the candidate resolves via
250
+ * temp-bind+link() where the loser disables mux and continues as a FOREGROUND
251
+ * connection — for the candidate that means an idle `-N` leaking forever
252
+ * (openssh mux.c muxserver_listen). */
253
+ function muxClientOptions(): readonly string[] {
254
+ if (process.platform === "win32") return [];
255
+ return [
256
+ "-o", "ControlMaster=no",
257
+ "-o", `ControlPath=${WAKE_MUX_CONTROL_PATH}`,
258
+ ];
259
+ }
260
+
261
+ /** The detached mux master (Tier 1): no session, no forwards — with
262
+ * ControlPersist it backgrounds itself and holds just the authenticated
263
+ * connection for the persist window. If a master already exists (socket
264
+ * alive), this exits immediately and harmlessly. Only FAILED candidates are
265
+ * reaped, after the grace window: a daemonized master's foreground has
266
+ * already exited, while a candidate that lost the creation race or hit a
267
+ * stale socket lives on as a plain direct `-N` connection that would idle
268
+ * forever — one per invocation until the socket is cleaned by hand. Never
269
+ * spawned on win32: native OpenSSH has no ControlMaster support. */
270
+ function spawnMuxMaster(
271
+ sshCommand: readonly string[] | undefined,
272
+ target: string,
273
+ ): void {
274
+ if (process.platform === "win32") return;
221
275
  const argv = [
222
- ...(options.sshCommand ?? ["ssh"]),
276
+ ...(sshCommand ?? ["ssh"]),
223
277
  "-N",
224
- "-o", "ExitOnForwardFailure=yes",
225
278
  "-o", "BatchMode=yes",
226
- "-L", `127.0.0.1:${localPort}:127.0.0.1:${parts.port}`,
279
+ ...muxCandidateOptions(),
227
280
  target,
228
281
  ];
229
- const proc = Bun.spawn(argv, { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
230
- const ready = await portAccepts("127.0.0.1", localPort, 15_000);
231
- if (!ready) {
282
+ try {
283
+ const proc = Bun.spawn(argv, { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
284
+ const grace = setTimeout(() => {
285
+ if (proc.exitCode === null) proc.kill();
286
+ }, WAKE_MASTER_GRACE_MS);
287
+ (grace as { unref?: () => void }).unref?.();
288
+ } catch {
289
+ // best-effort: without a master the wake client just handshakes itself
290
+ }
291
+ }
292
+
293
+ /** The pinned wake command (W9 / ADR-REM-006 §4 safety narrowing), in two
294
+ * host-shell forms: only the client-chosen integer port and idle window are
295
+ * interpolated — the exact string an operator can allowlist with git-shell /
296
+ * authorized_keys `command=`. Loopback-only, anonymous: SSH carries
297
+ * encryption and auth. The POSIX form is unchanged since W9; the cmd form
298
+ * (2026-09-20, liku feedback) serves Windows hosts whose OpenSSH default
299
+ * shell is cmd.exe and cannot parse `sh -c` at all — it is the pinned
300
+ * equivalent with a Windows-shaped PATH prefix (bun official, scoop, npm
301
+ * user prefix; the launcher resolves bun itself once ukp is found, the
302
+ * prefix guarantees ukp resolves). The `set "PATH=…"` quoting is load-
303
+ * bearing: an unquoted `set PATH=…%PATH%&&…` breaks when the expanded PATH
304
+ * contains cmd metacharacters (`&` in a directory name — verified), while
305
+ * the quoted form survives both the sshd double-cmd wrapper and cmd's own
306
+ * expansion (verified against Win32-OpenSSH source and live). The wake
307
+ * ladder sends POSIX first and switches on the wrong-shell failure
308
+ * signature; host shells covered: POSIX login shells, cmd.exe default, and
309
+ * Git-Bash-style DefaultShell hosts — a powershell DefaultShell is NOT
310
+ * supported (its -c quoting drops the form).
311
+ *
312
+ * The remote shell ssh runs commands in is NON-INTERACTIVE (bare system
313
+ * PATH), while every standard install mode lands in user-local,
314
+ * interactive-only dirs (bun's official script writes ~/.bashrc BEHIND its
315
+ * interactivity guard; `bun add -g` links into ~/.bun/bin; a user npm prefix
316
+ * like ~/.npm-global never leaves the user dir; brew lives outside the
317
+ * default ssh PATH). So the command prepends the standard install locations
318
+ * to PATH before exec — ukp AND its shebang/launcher both resolve for any
319
+ * usual install. nvm-style versioned layouts have no fixed path shape and
320
+ * need a system symlink (documented exception). */
321
+ // The Linuxbrew segment is joined, not written as one literal: its standard
322
+ // install location is a functional constant (part of the byte-pinned remote
323
+ // command), while the release "absolute user path" leak scan rightly flags
324
+ // any literal home-directory path in shipped sources. Constructing it keeps
325
+ // the gate green for real leaks without weakening it for this deliberate
326
+ // constant.
327
+ const WAKE_REMOTE_PATH_PREFIX = [
328
+ "$HOME/.bun/bin",
329
+ "$HOME/.npm-global/bin",
330
+ "/opt/homebrew/bin",
331
+ ["/home", "linuxbrew", ".linuxbrew", "bin"].join("/"),
332
+ ].join(":");
333
+
334
+ export type WakeShellForm = "posix" | "cmd";
335
+
336
+ function wakeDoorCommand(remotePort: number, form: WakeShellForm = "posix"): string {
337
+ if (form === "cmd") {
338
+ return `cmd /d /c "set "PATH=%USERPROFILE%\\.bun\\bin;%USERPROFILE%\\scoop\\shims;%APPDATA%\\npm;%PATH%"&&ukp serve --allow-anonymous --host 127.0.0.1 --port ${remotePort} --max-idle ${WAKE_DOOR_MAX_IDLE_SECONDS}"`;
339
+ }
340
+ return `sh -c 'PATH="${WAKE_REMOTE_PATH_PREFIX}:$PATH" exec ukp serve --allow-anonymous --host 127.0.0.1 --port ${remotePort} --max-idle ${WAKE_DOOR_MAX_IDLE_SECONDS}'`;
341
+ }
342
+
343
+ /** Wrong-shell failure signature for the wake ladder: the remote shell could
344
+ * not even parse the sent form, so the OTHER form is the retry — as opposed
345
+ * to a missing-ukp failure, where the shell ran fine and named 'ukp' as the
346
+ * thing it cannot find. cmd.exe errors quote the missing token ('sh' is not
347
+ * recognized, exit 9009); POSIX shells print "<token>: command not found". */
348
+ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean {
349
+ if (form === "posix") return /'sh' is not recognized|sh: command not found/i.test(diagnostics);
350
+ return /'cmd' is not recognized|cmd: command not found/i.test(diagnostics);
351
+ }
352
+
353
+ /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
354
+ * local forward proves nothing — ssh accepts locally and connects remotely
355
+ * lazily — so we poll the door's public discovery document until it answers
356
+ * AND declares the ukp-remote protocol (a foreign listener that happens to
357
+ * occupy the remote port and answer 200 must not read as "ready"). An ssh
358
+ * that already exited aborts the wait (no point polling a dead forward; the
359
+ * caller classifies from the captured stderr). */
360
+ async function probeWakeReady(
361
+ base: string,
362
+ timeoutMs: number,
363
+ proc: ReturnType<typeof Bun.spawn>,
364
+ ): Promise<boolean> {
365
+ const deadline = Date.now() + timeoutMs;
366
+ for (;;) {
367
+ try {
368
+ const response = await fetch(`${base}${DISCOVERY_PATH}`, { signal: AbortSignal.timeout(1000) });
369
+ if (response.ok) {
370
+ try {
371
+ const body: unknown = await response.json();
372
+ if (
373
+ typeof body === "object" && body !== null
374
+ && (body as Record<string, unknown>).protocol === PROTOCOL_NAME
375
+ ) {
376
+ return true;
377
+ }
378
+ } catch {
379
+ // not our door (non-JSON) — keep waiting; the real door may still
380
+ // be booting behind the forward
381
+ }
382
+ } else {
383
+ await response.body?.cancel().catch(() => {});
384
+ }
385
+ } catch {
386
+ // not ready yet — the door is still booting behind the forward
387
+ }
388
+ if (proc.exitCode !== null) return false;
389
+ if (Date.now() > deadline) return false;
390
+ await Bun.sleep(150);
391
+ }
392
+ }
393
+
394
+ /** Keep the tail of a spawn's output for failure diagnostics. The wake
395
+ * client runs under `-tt` (a pty merges the remote stderr into its stdout),
396
+ * so diagnostics can arrive on either stream — both are drained and
397
+ * concatenated. Resolves when the streams end (i.e. after the process is
398
+ * dead) — only await once the proc has been killed or has exited. */
399
+ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep = 2000): Promise<string> {
400
+ return Promise.all(
401
+ streams.map((stream) => new Response(stream).text().catch(() => "")),
402
+ ).then((texts) => {
403
+ const text = texts.join("").trim();
404
+ return text.length > keep ? `…${text.slice(-keep)}` : text;
405
+ });
406
+ }
407
+
408
+ /** On-demand wake (W9 / ADR-REM-006, replaces the W4 `-N -L` resident-door
409
+ * assumption): one ssh process both opens the local forward AND runs the
410
+ * pinned `ukp serve` loopback door as its remote command — the door lives and
411
+ * dies with the session (SIGHUP on clean close; `--max-idle` bounds the
412
+ * abrupt-disconnect orphan). Encryption + host/user auth come from the user's
413
+ * SSH config/keys. `sshCommand` is a test injection point for the ssh binary.
414
+ * Bounded retries absorb a client-chosen remote port colliding with an
415
+ * in-use one (the door fails to bind and the command exits). */
416
+ async function openSshTunnel(
417
+ parts: { user?: string; host: string },
418
+ options: { sshCommand?: readonly string[] },
419
+ endpointLabel: string,
420
+ ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
421
+ const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
422
+ // Tier 1 first: a detached mux master per origin (no-op when one already
423
+ // holds the socket); the wake client below rides it when possible and
424
+ // degrades to a full handshake when not (first call of a burst, win32).
425
+ spawnMuxMaster(options.sshCommand, target);
426
+ // Fatal mux conditions on the wake client (ControlPath expansion too long,
427
+ // unwritable socket dir) are fatal per attempt — once seen, drop the mux
428
+ // options for the remaining attempts so the endpoint degrades to Tier 0
429
+ // instead of hard-failing what worked without mux.
430
+ let muxDisabled = false;
431
+ // Shell-form ladder (2026-09-20): POSIX first (the common server shell),
432
+ // cmd second — a Windows host whose OpenSSH default shell is cmd.exe fails
433
+ // the POSIX form with an 'sh'-not-recognized signature (not a missing-ukp
434
+ // failure: the shell never got as far as looking for ukp).
435
+ let form: WakeShellForm = "posix";
436
+ const attempts = 3;
437
+ for (let attempt = 1; attempt <= attempts; attempt++) {
438
+ const localPort = await freePort();
439
+ const remotePort = randomRemotePort();
440
+ const argv = [
441
+ ...(options.sshCommand ?? ["ssh"]),
442
+ // Session-bound door (ali E2E): a no-TTY remote command NEVER receives
443
+ // SIGHUP on disconnect — normal disconnect included, the door only ever
444
+ // died to --max-idle. Forcing a pty gives the session a controlling
445
+ // terminal, so killing the client reaps the door in seconds (verified:
446
+ // kill -9 of the local ssh → door gone within 3s).
447
+ "-tt",
448
+ "-o", "ExitOnForwardFailure=yes",
449
+ "-o", "BatchMode=yes",
450
+ ...(muxDisabled ? [] : muxClientOptions()),
451
+ "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
452
+ target,
453
+ wakeDoorCommand(remotePort, form),
454
+ ];
455
+ const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
456
+ const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
457
+ if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
458
+ return { proc, base: `http://127.0.0.1:${localPort}` };
459
+ }
232
460
  proc.kill();
233
- throw new RemoteTransportError(
234
- `ssh tunnel to '${target}:${parts.port}' (endpoint '${endpointLabel}') did not become ready; check the host alias and key auth (BatchMode)`,
235
- );
461
+ // Read `exited` only after the stderr stream ended (its end implies the
462
+ // process is dead) — reading before the probe's last tick could label a
463
+ // fast crash as a slow timeout.
464
+ const diagnostics = (await stderrTail).trim();
465
+ const exited = proc.exitCode !== null;
466
+ if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
467
+ muxDisabled = true;
468
+ }
469
+ if (exited && wrongShellSignature(form, diagnostics)) {
470
+ if (attempt < attempts) {
471
+ form = form === "posix" ? "cmd" : "posix";
472
+ continue;
473
+ }
474
+ // Both forms rejected: this is a shell mismatch, not a missing-ukp
475
+ // failure — say so instead of falling into the missing-ukp wording.
476
+ throw new RemoteTransportError(
477
+ `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`,
478
+ );
479
+ }
480
+ // POSIX login shells print "…: ukp: command not found" (exit 127); Windows
481
+ // OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
482
+ if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
483
+ throw new RemoteTransportError(
484
+ `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`,
485
+ );
486
+ }
487
+ if (attempt === attempts) {
488
+ throw new RemoteTransportError(
489
+ exited
490
+ ? `waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed: ${diagnostics}`
491
+ : `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)`,
492
+ );
493
+ }
236
494
  }
237
- return { proc, base: `http://127.0.0.1:${localPort}` };
495
+ // Unreachable: the loop throws on its final attempt (attempts >= 1).
496
+ throw new RemoteTransportError(`waking the ukp door on '${target}' failed unexpectedly`);
238
497
  }
239
498
 
240
- /** Ensure a usable wire base for one invocation (D-078 transparent ssh):
241
- * http/https urls are used directly; `ssh://[user@]host[:port][/endpoint]`
242
- * opens an ephemeral local forward over SSH and returns the tunnel endpoint
243
- * (plus the `/e/<name>` prefix when the url selects a door endpoint). https
244
- * bindings with a pinned certificate carry their TLS anchor on the handle.
245
- * `sshCommand` is a test injection point for the ssh binary invocation;
246
- * `registryPath` lets the renewal re-anchor persist. */
499
+ /** Ensure a usable wire base for one invocation (D-078 transparent ssh, W9
500
+ * on-demand wake): http/https urls are used directly; `ssh://[user@]host
501
+ * [:port][/endpoint]` spawns ONE ssh process that opens the local forward
502
+ * AND wakes the pinned loopback door as its remote command, then returns the
503
+ * tunnel endpoint (plus the `/e/<name>` prefix when the url selects a door
504
+ * endpoint). Under wake the url's `[:port]` selects nothing — the remote
505
+ * door port is client-chosen. https bindings with a pinned certificate carry
506
+ * their TLS anchor on the handle. `sshCommand` is a test injection point for
507
+ * the ssh binary invocation; `registryPath` lets the renewal re-anchor
508
+ * persist. */
247
509
  export async function openRemoteTransport(
248
510
  binding: RegistryBinding,
249
511
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
@@ -266,12 +528,15 @@ export async function openRemoteTransport(
266
528
  };
267
529
  }
268
530
 
269
- /** Per-invocation transport pool (W7 / O-5): same-origin ssh urls share ONE
270
- * tunnel for the pool's lifetime — a 3-endpoint door's `ukp list` is one
271
- * tunnel, not four (per-row + door fetch). Pure client-internal: handles it
272
- * hands out have a noop `close`; the caller closes the pool when the
273
- * invocation ends. Direct (https / loopback http) urls need no pooling and
274
- * get the same one-shot handles as `openRemoteTransport`. */
531
+ /** Per-invocation transport pool (W7 / O-5, W9 wake): same-origin ssh urls
532
+ * share ONE woken ssh+door pair for the pool's lifetime — a 3-endpoint door's
533
+ * `ukp list` is one tunnel, not four (per-row + door fetch). The map stores
534
+ * the OPEN PROMISE, so overlapping acquires (a future fan-out) converge on
535
+ * one spawn instead of racing a second and orphaning the first door. Pure
536
+ * client-internal: handed-out handles have a noop `close`; the caller closes
537
+ * the pool when the invocation ends. Direct (https / loopback http) urls
538
+ * need no pooling and get the same one-shot handles as
539
+ * `openRemoteTransport`. */
275
540
  export interface RemoteTransportPool {
276
541
  acquire(binding: RegistryBinding): Promise<RemoteTransportHandle>;
277
542
  close(): void;
@@ -280,7 +545,7 @@ export interface RemoteTransportPool {
280
545
  export function createTransportPool(
281
546
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
282
547
  ): RemoteTransportPool {
283
- const tunnels = new Map<string, { proc: ReturnType<typeof Bun.spawn>; base: string }>();
548
+ const tunnels = new Map<string, Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }>>();
284
549
  return {
285
550
  async acquire(binding) {
286
551
  if (binding.kind !== "remote" || binding.url === undefined) {
@@ -296,16 +561,22 @@ export function createTransportPool(
296
561
  }
297
562
  let tunnel = tunnels.get(parts.origin);
298
563
  if (tunnel === undefined) {
299
- tunnel = await openSshTunnel(parts, options, binding.name);
564
+ // Drop the entry on failure so a retry inside the same invocation
565
+ // spawns fresh instead of re-throwing a stale rejected promise.
566
+ tunnel = openSshTunnel(parts, options, binding.name).catch((error: unknown) => {
567
+ tunnels.delete(parts.origin);
568
+ throw error;
569
+ });
300
570
  tunnels.set(parts.origin, tunnel);
301
571
  }
572
+ const acquired = await tunnel;
302
573
  return {
303
- base: tunnel.base + wirePrefix(parts.endpointName),
574
+ base: acquired.base + wirePrefix(parts.endpointName),
304
575
  close: () => {},
305
576
  };
306
577
  },
307
578
  close() {
308
- for (const { proc } of tunnels.values()) proc.kill();
579
+ for (const tunnel of tunnels.values()) void tunnel.then(({ proc }) => proc.kill(), () => {});
309
580
  tunnels.clear();
310
581
  },
311
582
  };
@@ -558,10 +829,24 @@ export interface RemoteSearchExecution {
558
829
  * human result units, replacing local providerOutput's role. */
559
830
  results: unknown[];
560
831
  /** Inline references (wire `references`, RQ-07): per-result handoff keys
561
- * with server-declared `ukp_uri`. */
832
+ * with server-declared `ukp_uri`, re-anchored to the local handle below. */
562
833
  references: Array<{ ukp_uri?: string }> | undefined;
563
834
  }
564
835
 
836
+ /** Re-anchor a server-declared `ukp://` authority to the local handle
837
+ * (ADR-REM-007 / D-086): the server emits its own declared name, but this
838
+ * registry keys the endpoint under the consumer-chosen handle — a hand-off
839
+ * key must resolve where it is handed off. No-op when the two agree or when
840
+ * the binding predates declared_name (handle == declared under RQ-14). The
841
+ * request direction needs no inverse rewrite: URI-addressed reads travel as
842
+ * endpoint-relative `ref`s, and authority resolution is client-side. */
843
+ export function reanchorRemoteUkpUri(uri: string, binding: RegistryBinding): string {
844
+ const declared = binding.declared_name;
845
+ if (declared === undefined || declared === binding.name) return uri;
846
+ const prefix = `ukp://${declared}/`;
847
+ return uri.startsWith(prefix) ? `ukp://${binding.name}/${uri.slice(prefix.length)}` : uri;
848
+ }
849
+
565
850
  /** POST /v1/search → endpoint outcome in the local SearchEndpointOutcome
566
851
  * vocabulary (status/message/envelope-compatible fields). */
567
852
  export async function remoteSearch(
@@ -594,7 +879,11 @@ export async function remoteSearch(
594
879
  }
595
880
  const referencesRecord = record.references as { results?: unknown } | undefined;
596
881
  const references = referencesRecord !== undefined && Array.isArray(referencesRecord.results)
597
- ? referencesRecord.results as Array<{ ukp_uri?: string }>
882
+ ? (referencesRecord.results as Array<{ ukp_uri?: string }>).map((reference) =>
883
+ reference.ukp_uri !== undefined
884
+ ? { ...reference, ukp_uri: reanchorRemoteUkpUri(reference.ukp_uri, binding) }
885
+ : reference,
886
+ )
598
887
  : undefined;
599
888
  return {
600
889
  outcome: {
@@ -788,7 +1077,9 @@ export async function remoteRg(
788
1077
  };
789
1078
  }
790
1079
  const wireStatus = typeof entry.status === "string" && RG_WIRE_STATUSES.has(entry.status) ? entry.status : "failed";
791
- const matches = remoteRgMatches(entry.matches);
1080
+ const matches = remoteRgMatches(entry.matches)?.map((match) =>
1081
+ match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
1082
+ );
792
1083
  const counts = remoteRgCounts(entry.counts);
793
1084
  const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
794
1085
  return {
@@ -43,6 +43,8 @@ export interface RgContext {
43
43
  currentDirectory: string;
44
44
  registryPath: string;
45
45
  rgCommand?: readonly string[];
46
+ /** Test injection point for the ssh binary the wake transport spawns. */
47
+ sshCommand?: readonly string[];
46
48
  }
47
49
 
48
50
  /** ADR 0021 aggregate classification — the adapter maps it onto exit codes;
@@ -55,6 +55,8 @@ export interface SearchContext {
55
55
  currentDirectory: string;
56
56
  registryPath: string;
57
57
  qmdCommand?: readonly string[];
58
+ /** Test injection point for the ssh binary the wake transport spawns. */
59
+ sshCommand?: readonly string[];
58
60
  artifactRoot?: string;
59
61
  artifactRunId?: string;
60
62
  now?: Date;
@@ -371,6 +373,25 @@ function renderTraversalProvenance(traversal: TraversalProvenance | undefined):
371
373
  return lines;
372
374
  }
373
375
 
376
+ /** Zero hits by parsed content, not stdout size: qmd's JSON mode prints a
377
+ * non-empty `[]` for zero results. `null` marks non-array / unparseable
378
+ * provider-native output, which keeps the fallback render path `succeeded`. */
379
+ function parseJsonArrayHitCount(providerOutput: string): number | null {
380
+ try {
381
+ const value: unknown = JSON.parse(providerOutput);
382
+ return Array.isArray(value) ? value.length : null;
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+
388
+ /** An empty provider result is indistinguishable from an unconfigured provider
389
+ * to a first-run caller — both classification sites surface the setup path
390
+ * (search's own contract stays: no matches is a result, not a failure, exit 0). */
391
+ function noMatchesSetupHint(endpointName: string): string {
392
+ return `endpoint '${endpointName}' returned no matches; if the QMD provider has not been set up for this Service yet, see 'ukp guide service qmd'`;
393
+ }
394
+
374
395
  function runHumanMode(
375
396
  parsed: ParsedSearch,
376
397
  plan: readonly PlannedEndpoint[],
@@ -458,12 +479,10 @@ function runHumanMode(
458
479
  outcome.providerErrorDetail = providerError;
459
480
  continue;
460
481
  }
461
- outcome.status = providerOutput ? "succeeded" : "no_matches";
482
+ const hitCount = parseJsonArrayHitCount(providerOutput);
483
+ outcome.status = hitCount === 0 || !providerOutput ? "no_matches" : "succeeded";
462
484
  if (outcome.status === "no_matches") {
463
- // An empty provider result is indistinguishable from an unconfigured
464
- // provider to a first-run caller — surface the setup path as guidance
465
- // (search's own contract stays: no matches is a result, not a failure).
466
- warnings.push(`endpoint '${endpoint.name}' returned no matches; if the QMD provider has not been set up for this Service yet, see 'ukp guide service qmd'`);
485
+ warnings.push(noMatchesSetupHint(endpoint.name));
467
486
  }
468
487
  }
469
488
  return {
@@ -1132,6 +1151,7 @@ function runJsonMode(
1132
1151
 
1133
1152
  succeeded = true;
1134
1153
  outcome.status = status;
1154
+ if (status === "no_matches") warnings.push(noMatchesSetupHint(endpoint.name));
1135
1155
  let hasReferenceSidecar = false;
1136
1156
  try {
1137
1157
  writeQmdReferenceSidecar(endpoint.name, endpoint.folder!, artifact, referencesArtifact);