@catheadowl/ukp 0.1.1 → 0.2.0

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,278 @@ 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): a fixed shape where
294
+ * only the client-chosen integer port and idle window are interpolated — the
295
+ * exact string an operator can allowlist with git-shell / authorized_keys
296
+ * `command=`. Loopback-only, anonymous: SSH carries encryption and auth.
297
+ *
298
+ * The remote shell ssh runs commands in is NON-INTERACTIVE (bare system
299
+ * PATH), while every standard install mode lands in user-local,
300
+ * interactive-only dirs (bun's official script writes ~/.bashrc BEHIND its
301
+ * interactivity guard; `bun add -g` links into ~/.bun/bin; a user npm prefix
302
+ * like ~/.npm-global never leaves the user dir; brew lives outside the
303
+ * default ssh PATH). So the command prepends the standard install locations
304
+ * to PATH before exec — ukp AND its `#!/usr/bin/env bun` shebang both
305
+ * resolve for any usual install. nvm-style versioned layouts have no fixed
306
+ * path shape and need a system symlink (documented exception). */
307
+ // The Linuxbrew segment is joined, not written as one literal: its standard
308
+ // install location is a functional constant (part of the byte-pinned remote
309
+ // command), while the release "absolute user path" leak scan rightly flags
310
+ // any literal home-directory path in shipped sources. Constructing it keeps
311
+ // the gate green for real leaks without weakening it for this deliberate
312
+ // constant.
313
+ const WAKE_REMOTE_PATH_PREFIX = [
314
+ "$HOME/.bun/bin",
315
+ "$HOME/.npm-global/bin",
316
+ "/opt/homebrew/bin",
317
+ ["/home", "linuxbrew", ".linuxbrew", "bin"].join("/"),
318
+ ].join(":");
319
+
320
+ function wakeDoorCommand(remotePort: number): string {
321
+ 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}'`;
322
+ }
323
+
324
+ /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
325
+ * local forward proves nothing — ssh accepts locally and connects remotely
326
+ * lazily — so we poll the door's public discovery document until it answers
327
+ * AND declares the ukp-remote protocol (a foreign listener that happens to
328
+ * occupy the remote port and answer 200 must not read as "ready"). An ssh
329
+ * that already exited aborts the wait (no point polling a dead forward; the
330
+ * caller classifies from the captured stderr). */
331
+ async function probeWakeReady(
332
+ base: string,
333
+ timeoutMs: number,
334
+ proc: ReturnType<typeof Bun.spawn>,
335
+ ): Promise<boolean> {
336
+ const deadline = Date.now() + timeoutMs;
337
+ for (;;) {
338
+ try {
339
+ const response = await fetch(`${base}${DISCOVERY_PATH}`, { signal: AbortSignal.timeout(1000) });
340
+ if (response.ok) {
341
+ try {
342
+ const body: unknown = await response.json();
343
+ if (
344
+ typeof body === "object" && body !== null
345
+ && (body as Record<string, unknown>).protocol === PROTOCOL_NAME
346
+ ) {
347
+ return true;
348
+ }
349
+ } catch {
350
+ // not our door (non-JSON) — keep waiting; the real door may still
351
+ // be booting behind the forward
352
+ }
353
+ } else {
354
+ await response.body?.cancel().catch(() => {});
355
+ }
356
+ } catch {
357
+ // not ready yet — the door is still booting behind the forward
358
+ }
359
+ if (proc.exitCode !== null) return false;
360
+ if (Date.now() > deadline) return false;
361
+ await Bun.sleep(150);
362
+ }
363
+ }
364
+
365
+ /** Keep the tail of a spawn's output for failure diagnostics. The wake
366
+ * client runs under `-tt` (a pty merges the remote stderr into its stdout),
367
+ * so diagnostics can arrive on either stream — both are drained and
368
+ * concatenated. Resolves when the streams end (i.e. after the process is
369
+ * dead) — only await once the proc has been killed or has exited. */
370
+ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep = 2000): Promise<string> {
371
+ return Promise.all(
372
+ streams.map((stream) => new Response(stream).text().catch(() => "")),
373
+ ).then((texts) => {
374
+ const text = texts.join("").trim();
375
+ return text.length > keep ? `…${text.slice(-keep)}` : text;
376
+ });
377
+ }
378
+
379
+ /** On-demand wake (W9 / ADR-REM-006, replaces the W4 `-N -L` resident-door
380
+ * assumption): one ssh process both opens the local forward AND runs the
381
+ * pinned `ukp serve` loopback door as its remote command — the door lives and
382
+ * dies with the session (SIGHUP on clean close; `--max-idle` bounds the
383
+ * abrupt-disconnect orphan). Encryption + host/user auth come from the user's
384
+ * SSH config/keys. `sshCommand` is a test injection point for the ssh binary.
385
+ * Bounded retries absorb a client-chosen remote port colliding with an
386
+ * in-use one (the door fails to bind and the command exits). */
387
+ async function openSshTunnel(
388
+ parts: { user?: string; host: string },
389
+ options: { sshCommand?: readonly string[] },
390
+ endpointLabel: string,
391
+ ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
392
+ const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
393
+ // Tier 1 first: a detached mux master per origin (no-op when one already
394
+ // holds the socket); the wake client below rides it when possible and
395
+ // degrades to a full handshake when not (first call of a burst, win32).
396
+ spawnMuxMaster(options.sshCommand, target);
397
+ // Fatal mux conditions on the wake client (ControlPath expansion too long,
398
+ // unwritable socket dir) are fatal per attempt — once seen, drop the mux
399
+ // options for the remaining attempts so the endpoint degrades to Tier 0
400
+ // instead of hard-failing what worked without mux.
401
+ let muxDisabled = false;
402
+ const attempts = 3;
403
+ for (let attempt = 1; attempt <= attempts; attempt++) {
404
+ const localPort = await freePort();
405
+ const remotePort = randomRemotePort();
406
+ const argv = [
407
+ ...(options.sshCommand ?? ["ssh"]),
408
+ // Session-bound door (ali E2E): a no-TTY remote command NEVER receives
409
+ // SIGHUP on disconnect — normal disconnect included, the door only ever
410
+ // died to --max-idle. Forcing a pty gives the session a controlling
411
+ // terminal, so killing the client reaps the door in seconds (verified:
412
+ // kill -9 of the local ssh → door gone within 3s).
413
+ "-tt",
414
+ "-o", "ExitOnForwardFailure=yes",
415
+ "-o", "BatchMode=yes",
416
+ ...(muxDisabled ? [] : muxClientOptions()),
417
+ "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
418
+ target,
419
+ wakeDoorCommand(remotePort),
420
+ ];
421
+ const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
422
+ const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
423
+ if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
424
+ return { proc, base: `http://127.0.0.1:${localPort}` };
425
+ }
232
426
  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
- );
427
+ // Read `exited` only after the stderr stream ended (its end implies the
428
+ // process is dead) — reading before the probe's last tick could label a
429
+ // fast crash as a slow timeout.
430
+ const diagnostics = (await stderrTail).trim();
431
+ const exited = proc.exitCode !== null;
432
+ if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
433
+ muxDisabled = true;
434
+ }
435
+ // POSIX login shells print "…: ukp: command not found" (exit 127); Windows
436
+ // OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
437
+ if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
438
+ throw new RemoteTransportError(
439
+ `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`,
440
+ );
441
+ }
442
+ if (attempt === attempts) {
443
+ throw new RemoteTransportError(
444
+ exited
445
+ ? `waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed: ${diagnostics}`
446
+ : `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)`,
447
+ );
448
+ }
236
449
  }
237
- return { proc, base: `http://127.0.0.1:${localPort}` };
450
+ // Unreachable: the loop throws on its final attempt (attempts >= 1).
451
+ throw new RemoteTransportError(`waking the ukp door on '${target}' failed unexpectedly`);
238
452
  }
239
453
 
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. */
454
+ /** Ensure a usable wire base for one invocation (D-078 transparent ssh, W9
455
+ * on-demand wake): http/https urls are used directly; `ssh://[user@]host
456
+ * [:port][/endpoint]` spawns ONE ssh process that opens the local forward
457
+ * AND wakes the pinned loopback door as its remote command, then returns the
458
+ * tunnel endpoint (plus the `/e/<name>` prefix when the url selects a door
459
+ * endpoint). Under wake the url's `[:port]` selects nothing — the remote
460
+ * door port is client-chosen. https bindings with a pinned certificate carry
461
+ * their TLS anchor on the handle. `sshCommand` is a test injection point for
462
+ * the ssh binary invocation; `registryPath` lets the renewal re-anchor
463
+ * persist. */
247
464
  export async function openRemoteTransport(
248
465
  binding: RegistryBinding,
249
466
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
@@ -266,12 +483,15 @@ export async function openRemoteTransport(
266
483
  };
267
484
  }
268
485
 
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`. */
486
+ /** Per-invocation transport pool (W7 / O-5, W9 wake): same-origin ssh urls
487
+ * share ONE woken ssh+door pair for the pool's lifetime — a 3-endpoint door's
488
+ * `ukp list` is one tunnel, not four (per-row + door fetch). The map stores
489
+ * the OPEN PROMISE, so overlapping acquires (a future fan-out) converge on
490
+ * one spawn instead of racing a second and orphaning the first door. Pure
491
+ * client-internal: handed-out handles have a noop `close`; the caller closes
492
+ * the pool when the invocation ends. Direct (https / loopback http) urls
493
+ * need no pooling and get the same one-shot handles as
494
+ * `openRemoteTransport`. */
275
495
  export interface RemoteTransportPool {
276
496
  acquire(binding: RegistryBinding): Promise<RemoteTransportHandle>;
277
497
  close(): void;
@@ -280,7 +500,7 @@ export interface RemoteTransportPool {
280
500
  export function createTransportPool(
281
501
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
282
502
  ): RemoteTransportPool {
283
- const tunnels = new Map<string, { proc: ReturnType<typeof Bun.spawn>; base: string }>();
503
+ const tunnels = new Map<string, Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }>>();
284
504
  return {
285
505
  async acquire(binding) {
286
506
  if (binding.kind !== "remote" || binding.url === undefined) {
@@ -296,16 +516,22 @@ export function createTransportPool(
296
516
  }
297
517
  let tunnel = tunnels.get(parts.origin);
298
518
  if (tunnel === undefined) {
299
- tunnel = await openSshTunnel(parts, options, binding.name);
519
+ // Drop the entry on failure so a retry inside the same invocation
520
+ // spawns fresh instead of re-throwing a stale rejected promise.
521
+ tunnel = openSshTunnel(parts, options, binding.name).catch((error: unknown) => {
522
+ tunnels.delete(parts.origin);
523
+ throw error;
524
+ });
300
525
  tunnels.set(parts.origin, tunnel);
301
526
  }
527
+ const acquired = await tunnel;
302
528
  return {
303
- base: tunnel.base + wirePrefix(parts.endpointName),
529
+ base: acquired.base + wirePrefix(parts.endpointName),
304
530
  close: () => {},
305
531
  };
306
532
  },
307
533
  close() {
308
- for (const { proc } of tunnels.values()) proc.kill();
534
+ for (const tunnel of tunnels.values()) void tunnel.then(({ proc }) => proc.kill(), () => {});
309
535
  tunnels.clear();
310
536
  },
311
537
  };
@@ -558,10 +784,24 @@ export interface RemoteSearchExecution {
558
784
  * human result units, replacing local providerOutput's role. */
559
785
  results: unknown[];
560
786
  /** Inline references (wire `references`, RQ-07): per-result handoff keys
561
- * with server-declared `ukp_uri`. */
787
+ * with server-declared `ukp_uri`, re-anchored to the local handle below. */
562
788
  references: Array<{ ukp_uri?: string }> | undefined;
563
789
  }
564
790
 
791
+ /** Re-anchor a server-declared `ukp://` authority to the local handle
792
+ * (ADR-REM-007 / D-086): the server emits its own declared name, but this
793
+ * registry keys the endpoint under the consumer-chosen handle — a hand-off
794
+ * key must resolve where it is handed off. No-op when the two agree or when
795
+ * the binding predates declared_name (handle == declared under RQ-14). The
796
+ * request direction needs no inverse rewrite: URI-addressed reads travel as
797
+ * endpoint-relative `ref`s, and authority resolution is client-side. */
798
+ export function reanchorRemoteUkpUri(uri: string, binding: RegistryBinding): string {
799
+ const declared = binding.declared_name;
800
+ if (declared === undefined || declared === binding.name) return uri;
801
+ const prefix = `ukp://${declared}/`;
802
+ return uri.startsWith(prefix) ? `ukp://${binding.name}/${uri.slice(prefix.length)}` : uri;
803
+ }
804
+
565
805
  /** POST /v1/search → endpoint outcome in the local SearchEndpointOutcome
566
806
  * vocabulary (status/message/envelope-compatible fields). */
567
807
  export async function remoteSearch(
@@ -594,7 +834,11 @@ export async function remoteSearch(
594
834
  }
595
835
  const referencesRecord = record.references as { results?: unknown } | undefined;
596
836
  const references = referencesRecord !== undefined && Array.isArray(referencesRecord.results)
597
- ? referencesRecord.results as Array<{ ukp_uri?: string }>
837
+ ? (referencesRecord.results as Array<{ ukp_uri?: string }>).map((reference) =>
838
+ reference.ukp_uri !== undefined
839
+ ? { ...reference, ukp_uri: reanchorRemoteUkpUri(reference.ukp_uri, binding) }
840
+ : reference,
841
+ )
598
842
  : undefined;
599
843
  return {
600
844
  outcome: {
@@ -788,7 +1032,9 @@ export async function remoteRg(
788
1032
  };
789
1033
  }
790
1034
  const wireStatus = typeof entry.status === "string" && RG_WIRE_STATUSES.has(entry.status) ? entry.status : "failed";
791
- const matches = remoteRgMatches(entry.matches);
1035
+ const matches = remoteRgMatches(entry.matches)?.map((match) =>
1036
+ match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
1037
+ );
792
1038
  const counts = remoteRgCounts(entry.counts);
793
1039
  const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
794
1040
  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);