@catheadowl/ukp 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/bin/ukp.js +31 -11
- package/docs/remote-deployment.md +7 -0
- package/package.json +1 -1
- package/src/capabilities/propose.ts +1 -1
- package/src/capabilities/read.ts +1 -1
- package/src/capabilities/remote-client.ts +361 -70
- package/src/capabilities/rg.ts +148 -21
- package/src/capabilities/search.ts +2 -2
- package/src/cli.ts +73 -4
- package/src/commands/guide-content/propose.txt +1 -1
- package/src/commands/guide-content/remote.txt +9 -7
- package/src/commands/guide-content/rg.txt +35 -0
- package/src/commands/guide-content/service-qmd.txt +1 -1
- package/src/commands/guide.ts +11 -4
- package/src/commands/inventory.ts +182 -36
- package/src/commands/propose.ts +1 -1
- package/src/commands/read.ts +6 -6
- package/src/commands/rg.ts +45 -14
- package/src/commands/serve-task.ts +17 -17
- package/src/commands/serve.ts +22 -22
- package/src/registry.ts +2 -2
- package/src/server.ts +41 -26
- package/src/spawn-relay.ts +57 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import { connect as tlsConnect } from "node:tls";
|
|
3
3
|
import { X509Certificate } from "node:crypto";
|
|
4
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
4
7
|
import {
|
|
5
8
|
DISCOVERY_PATH,
|
|
6
9
|
PROTOCOL_NAME,
|
|
@@ -16,11 +19,12 @@ import {
|
|
|
16
19
|
type RemoteUrlParts,
|
|
17
20
|
} from "../registry.ts";
|
|
18
21
|
import { spkiPinOf } from "./tls-identity.ts";
|
|
22
|
+
import { toolRelayScript } from "../spawn-relay.ts";
|
|
19
23
|
import type { SearchEndpointOutcome } from "./search.ts";
|
|
20
24
|
import type { NavEnvelope } from "./nav.ts";
|
|
21
25
|
import type { ProposeResult, ProposeStatus } from "./propose.ts";
|
|
22
26
|
import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
|
|
23
|
-
import type { RgEndpointOutcome, RgMatch, RgCountEntry } from "./rg.ts";
|
|
27
|
+
import type { RgEndpointOutcome, RgMatch, RgCountEntry, RgFileEntry } from "./rg.ts";
|
|
24
28
|
|
|
25
29
|
/** Remote transport for the client side (ukp-remote wire v1, ADR-REM-002/003;
|
|
26
30
|
* ukp_remote W2). All calls fetch fresh (no cross-invocation cache), carry the
|
|
@@ -54,6 +58,25 @@ function remoteTimeoutMs(): number {
|
|
|
54
58
|
return Number.isSafeInteger(value) && value >= 1000 ? value : 60_000;
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** Connection-establishment bound (ADR 0026 rule 2 / D-091): every ssh
|
|
62
|
+
* process UKP spawns carries ConnectTimeout, so an unreachable host fails
|
|
63
|
+
* in seconds instead of waiting out the OS TCP timeout — which then
|
|
64
|
+
* multiplied through the wake ladder's attempts and the serial origin
|
|
65
|
+
* chain. Default 10s (the Ansible baseline); `UKP_SSH_CONNECT_TIMEOUT_MS`
|
|
66
|
+
* overrides, mirroring UKP_REMOTE_TIMEOUT_MS. ssh takes whole seconds, so
|
|
67
|
+
* an integer ms value ≥1000 floors (1500 → 1s); anything else — non-numeric,
|
|
68
|
+
* fractional, below 1000 — keeps the default. */
|
|
69
|
+
function sshConnectTimeoutSeconds(): number {
|
|
70
|
+
const raw = process.env.UKP_SSH_CONNECT_TIMEOUT_MS;
|
|
71
|
+
if (raw === undefined || raw.trim() === "") return 10;
|
|
72
|
+
const value = Number(raw);
|
|
73
|
+
return Number.isSafeInteger(value) && value >= 1000 ? Math.floor(value / 1000) : 10;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function connectTimeoutOptions(): string[] {
|
|
77
|
+
return ["-o", `ConnectTimeout=${sshConnectTimeoutSeconds()}`];
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
/** TOFU mismatch policy (RQ-17): `warn` (default) appends a warning and
|
|
58
81
|
* continues; `block` refuses the endpoint until it is explicitly
|
|
59
82
|
* re-registered. */
|
|
@@ -64,6 +87,11 @@ function tofuMode(): "warn" | "block" {
|
|
|
64
87
|
export interface RemoteTransportHandle {
|
|
65
88
|
/** Wire base the client actually fetches (tunnel endpoint or the url itself). */
|
|
66
89
|
base: string;
|
|
90
|
+
/** The url the caller knows — the registered binding url. Error and remedy
|
|
91
|
+
* text quotes THIS, never the wire base: a door-endpoint binding's wire
|
|
92
|
+
* base carries the `/e/<name>` route prefix (and, over ssh, a loopback
|
|
93
|
+
* tunnel port) that no user ever typed or could act on. */
|
|
94
|
+
displayUrl: string;
|
|
67
95
|
/** Releases per-invocation resources (kills an ephemeral tunnel); noop for
|
|
68
96
|
* direct connections. */
|
|
69
97
|
close: () => void;
|
|
@@ -178,12 +206,13 @@ function tlsAnchorOf(binding: RegistryBinding, options: { registryPath?: string
|
|
|
178
206
|
}
|
|
179
207
|
|
|
180
208
|
function directTransportHandle(
|
|
181
|
-
binding: RegistryBinding,
|
|
209
|
+
binding: RegistryBinding & { url: string },
|
|
182
210
|
parts: RemoteUrlParts,
|
|
183
211
|
options: { registryPath?: string },
|
|
184
212
|
): RemoteTransportHandle {
|
|
185
213
|
return {
|
|
186
214
|
base: parts.origin + wirePrefix(parts.endpointName),
|
|
215
|
+
displayUrl: binding.url,
|
|
187
216
|
close: () => {},
|
|
188
217
|
...(tlsAnchorOf(binding, options) !== undefined ? { tls: tlsAnchorOf(binding, options)! } : {}),
|
|
189
218
|
};
|
|
@@ -276,6 +305,7 @@ function spawnMuxMaster(
|
|
|
276
305
|
...(sshCommand ?? ["ssh"]),
|
|
277
306
|
"-N",
|
|
278
307
|
"-o", "BatchMode=yes",
|
|
308
|
+
...connectTimeoutOptions(),
|
|
279
309
|
...muxCandidateOptions(),
|
|
280
310
|
target,
|
|
281
311
|
];
|
|
@@ -352,6 +382,170 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
|
|
|
352
382
|
return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
|
|
353
383
|
}
|
|
354
384
|
|
|
385
|
+
/** Wake failure classes from ssh's own diagnostics (ADR-REM-008 / D-093):
|
|
386
|
+
* OpenSSH's stderr wording is locale-stable (unlike cmd.exe's), so stable
|
|
387
|
+
* substrings classify the deterministic failures — verdicts a re-run against
|
|
388
|
+
* the same host reproduces, which is why none of them belongs in the retry
|
|
389
|
+
* ladder. One taxonomy, three matchers: these connect/auth signatures here,
|
|
390
|
+
* missing-ukp (127/9009 dictionary) and shell-mismatch keep their existing
|
|
391
|
+
* branches. Pure function by design — the signature table is unit-testable
|
|
392
|
+
* without spawning ssh. */
|
|
393
|
+
export type WakeFailureClass = "unreachable" | "refused" | "auth" | "hostkey";
|
|
394
|
+
|
|
395
|
+
export function classifyWakeFailure(diagnostics: string): WakeFailureClass | undefined {
|
|
396
|
+
if (/Connection timed out|No route to host|Could not resolve hostname/i.test(diagnostics)) return "unreachable";
|
|
397
|
+
if (/Connection refused/i.test(diagnostics)) return "refused";
|
|
398
|
+
if (/Host key verification failed/i.test(diagnostics)) return "hostkey";
|
|
399
|
+
// Auth failures always carry the method-list suffix ("Permission denied
|
|
400
|
+
// (publickey,password).") — ssh's own format. The bare phrase also appears
|
|
401
|
+
// in REMOTE-COMMAND exec errors (a found-but-not-executable ukp on a
|
|
402
|
+
// noexec mount), which reach these diagnostics under -tt and must keep
|
|
403
|
+
// falling through to the generic wording, not get the key-auth remedy
|
|
404
|
+
// (review P2).
|
|
405
|
+
if (/Permission denied \(/i.test(diagnostics)) return "auth";
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A probe-stage timeout verdict is not trustworthy on its own (ADR-REM-008
|
|
410
|
+
* §3, implementation-time refinement): the probe's 5s race window is shorter
|
|
411
|
+
* than the full ConnectTimeout budget a slow-but-alive host may legitimately
|
|
412
|
+
* need — its wake would succeed in the ladder's window — so black-hole
|
|
413
|
+
* signatures defer to the ladder, which runs the full budget before the same
|
|
414
|
+
* classifier issues the verdict. Instant verdicts (refused, auth, hostkey,
|
|
415
|
+
* resolve and no-route failures) settle within one roundtrip and are trusted
|
|
416
|
+
* wherever they appear; a probe-local short ConnectTimeout would have
|
|
417
|
+
* misjudged every host slower than it. */
|
|
418
|
+
function wakeFailureDefersToLadder(failureClass: WakeFailureClass, diagnostics: string): boolean {
|
|
419
|
+
return failureClass === "unreachable"
|
|
420
|
+
&& /Connection timed out/i.test(diagnostics)
|
|
421
|
+
&& !/No route to host|Could not resolve hostname/i.test(diagnostics);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Classified wake-failure text (ADR-REM-008 §4): names the ssh TARGET (an
|
|
425
|
+
* origin-level fact under fan-out — never an endpoint label), quotes the ssh
|
|
426
|
+
* diagnostics as evidence, and gives the class its one remedy. Printable
|
|
427
|
+
* ASCII only — the frozen vocabulary rule for runtime strings. */
|
|
428
|
+
export function wakeFailureMessage(failureClass: WakeFailureClass, target: string, diagnostics: string): string {
|
|
429
|
+
const evidence = diagnostics.length > 0 ? `: ${diagnostics}` : "";
|
|
430
|
+
switch (failureClass) {
|
|
431
|
+
case "unreachable":
|
|
432
|
+
return `waking the ukp door on '${target}' failed - the host is unreachable${evidence}. The machine appears off, firewalled, or its address no longer resolves; verify it is powered on and reachable`;
|
|
433
|
+
case "refused":
|
|
434
|
+
return `waking the ukp door on '${target}' failed - the host is reachable but nothing is listening for ssh${evidence}. Start the ssh server on the host (on Windows: the optional OpenSSH Server feature) or fix the port`;
|
|
435
|
+
case "auth":
|
|
436
|
+
return `waking the ukp door on '${target}' failed - ssh rejected the credentials (BatchMode, no password prompt)${evidence}. Fix key auth: add the public key to the host's authorized_keys, or check the ssh config's IdentityFile`;
|
|
437
|
+
case "hostkey":
|
|
438
|
+
return `waking the ukp door on '${target}' failed - the host key was not accepted${evidence}. If the host was reinstalled this is expected: re-verify it (ssh-keygen -R), then retry`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Not-ready wording (ADR-REM-008): the old text blamed the host alias and
|
|
443
|
+
* key auth — both, by the time this message is reached, have already
|
|
444
|
+
* succeeded (their failures classify earlier); what failed is the door
|
|
445
|
+
* behind a working connection. */
|
|
446
|
+
export function wakeNotReadyMessage(target: string, timeoutMs: number): string {
|
|
447
|
+
return `the ssh connection to '${target}' worked but the ukp door did not become ready within ${Math.round(timeoutMs / 1000)}s. Host prerequisite (W9): ukp on the remote PATH; a slow first start also lands here - retry once before investigating`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Cross-invocation shell-family cache (ukp_list W1 / L-1, ADR 0026's
|
|
451
|
+
* implementation-time note): a host's shell family is a static fact, but
|
|
452
|
+
* learning it costs a full ssh roundtrip per origin per invocation — on
|
|
453
|
+
* win32 (no ControlMaster) a full TCP+key handshake. Cached beside the
|
|
454
|
+
* registry (`<registry dir>/wake-shell.toml`, keyed by ssh target), the
|
|
455
|
+
* probe is skipped when an entry exists. The wake ladder stays the
|
|
456
|
+
* backstop: a stale entry (host reinstalled under a different shell) costs
|
|
457
|
+
* one wrong-shell retry, and the successful form is written back — the
|
|
458
|
+
* cache can only lose time, never correctness. Best-effort by design:
|
|
459
|
+
* missing/corrupt/unwritable ⇒ re-probe or run uncached. */
|
|
460
|
+
function shellFamilyCachePath(options: { registryPath?: string }): string | undefined {
|
|
461
|
+
return options.registryPath === undefined ? undefined : join(dirname(options.registryPath), "wake-shell.toml");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function readCachedShellFamily(cachePath: string | undefined, target: string): WakeShellForm | undefined {
|
|
465
|
+
if (cachePath === undefined) return undefined;
|
|
466
|
+
try {
|
|
467
|
+
const parsed = parseToml(readFileSync(cachePath, "utf8")) as { shells?: Record<string, unknown> };
|
|
468
|
+
const value = parsed.shells?.[target];
|
|
469
|
+
return value === "posix" || value === "cmd" ? value : undefined;
|
|
470
|
+
} catch {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function persistShellFamily(cachePath: string | undefined, target: string, form: WakeShellForm): void {
|
|
476
|
+
if (cachePath === undefined) return;
|
|
477
|
+
try {
|
|
478
|
+
let shells: Record<string, unknown> = {};
|
|
479
|
+
try {
|
|
480
|
+
const parsed = parseToml(readFileSync(cachePath, "utf8")) as { shells?: Record<string, unknown> };
|
|
481
|
+
if (parsed.shells !== undefined && typeof parsed.shells === "object") shells = parsed.shells;
|
|
482
|
+
} catch {
|
|
483
|
+
// corrupt or absent: a fresh map replaces whatever was there
|
|
484
|
+
}
|
|
485
|
+
// Unchanged value: no write — a per-origin-per-invocation rewrite would
|
|
486
|
+
// only amplify disk churn and widen the concurrent-writer window.
|
|
487
|
+
if (shells[target] === form) return;
|
|
488
|
+
shells[target] = form;
|
|
489
|
+
writeFileSync(cachePath, stringifyToml({ shells }), { mode: 0o600 });
|
|
490
|
+
} catch {
|
|
491
|
+
// unwritable (read-only dir, concurrent writer): run without cache
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Structural subset of Bun's Subprocess that the ssh paths consume (a Pick,
|
|
496
|
+
* not a hand-written interface — the ADR 0021 guard scans for CLI-shaped
|
|
497
|
+
* member declarations). Direct spawns return the Subprocess itself; the win32
|
|
498
|
+
* relay below returns a delegating wrapper because its kill semantics differ
|
|
499
|
+
* (tree-kill). */
|
|
500
|
+
type HostSshProcess = Pick<ReturnType<typeof Bun.spawn>, "pid" | "exitCode" | "stdout" | "stderr" | "kill">;
|
|
501
|
+
|
|
502
|
+
/** Win32 Defender tax (2026-09-22 finding, ukp_remote TODO
|
|
503
|
+
* 20260922-win32-ssh-spawn-tax): the DIRECT bun-to-ssh.exe child edge is held
|
|
504
|
+
* ~10s by behavioral inspection — measured across stdio modes, windowsHide,
|
|
505
|
+
* and absolute paths, while bash-to-ssh is 0.16s and ANY intermediary parent
|
|
506
|
+
* (cmd, node, powershell) escapes to 0.5-0.8s. Every ssh spawn in this file
|
|
507
|
+
* pays it once, so on win32 real-ssh spawns go through a powershell.exe
|
|
508
|
+
* relay. -EncodedCommand carries the script as one base64 token (no quoting
|
|
509
|
+
* stress from Bun), and the script splats a PS array so the pinned wake
|
|
510
|
+
* command's load-bearing quotes reach ssh byte-identical (verified E2E: door
|
|
511
|
+
* READY in ~1.4s). Test injection (explicit sshCommand) bypasses the relay —
|
|
512
|
+
* the fakes must not be wrapped. Killing the relay orphans the ssh child
|
|
513
|
+
* (tunnel + door outlive the killed handle — verified), so the wrapped kill
|
|
514
|
+
* reaps the tree via taskkill /T /F. */
|
|
515
|
+
function spawnHostSsh(options: { sshCommand?: readonly string[] }, argv: readonly string[]): HostSshProcess {
|
|
516
|
+
if (process.platform !== "win32" || options.sshCommand !== undefined) {
|
|
517
|
+
return Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
518
|
+
}
|
|
519
|
+
const script = toolRelayScript(argv);
|
|
520
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
521
|
+
const proc = Bun.spawn(["powershell", "-NoProfile", "-EncodedCommand", encoded], {
|
|
522
|
+
stdout: "pipe",
|
|
523
|
+
stderr: "pipe",
|
|
524
|
+
stdin: "ignore",
|
|
525
|
+
});
|
|
526
|
+
const { pid, stdout, stderr } = proc;
|
|
527
|
+
return {
|
|
528
|
+
pid,
|
|
529
|
+
stdout,
|
|
530
|
+
stderr,
|
|
531
|
+
get exitCode() {
|
|
532
|
+
return proc.exitCode;
|
|
533
|
+
},
|
|
534
|
+
kill() {
|
|
535
|
+
// taskkill also carries the Defender tax when spawned directly from
|
|
536
|
+
// bun (measured 10.1s direct vs 105ms through cmd — same finding as
|
|
537
|
+
// the ssh/rg edges); the PID is digits-only, so the cmd payload needs
|
|
538
|
+
// no quoting. /T /F takes the relay AND its ssh child down together
|
|
539
|
+
// (killing the relay alone orphans the tunnel).
|
|
540
|
+
Bun.spawnSync(["cmd", "/d", "/c", `taskkill /PID ${pid} /T /F`], {
|
|
541
|
+
stdout: "ignore",
|
|
542
|
+
stderr: "ignore",
|
|
543
|
+
stdin: "ignore",
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
355
549
|
/** One cheap, language-independent roundtrip to learn the host shell family
|
|
356
550
|
* BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
|
|
357
551
|
* expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
|
|
@@ -362,21 +556,32 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
|
|
|
362
556
|
* build LOSES a quoted remote command under a pty (an interactive cmd
|
|
363
557
|
* swallows it and waits on stdin forever — the no-pty path executes it
|
|
364
558
|
* correctly). Defaults to POSIX (the common server shape) when the probe
|
|
365
|
-
* errors or times out; the ladder remains as the backstop.
|
|
559
|
+
* errors or times out; the ladder remains as the backstop.
|
|
560
|
+
*
|
|
561
|
+
* The probe doubles as the first failure classifier (ADR-REM-008 §3): its
|
|
562
|
+
* merged output — the `%OS%` answer on stdout, ssh's own diagnostics on
|
|
563
|
+
* stderr — feeds `classifyWakeFailure`, so instant verdicts (refused / auth
|
|
564
|
+
* / hostkey / resolve failures) classify here and the ladder is never
|
|
565
|
+
* entered for them. */
|
|
366
566
|
async function probeHostShellFamily(
|
|
367
567
|
options: { sshCommand?: readonly string[] },
|
|
368
568
|
target: string,
|
|
369
|
-
): Promise<WakeShellForm> {
|
|
370
|
-
const proc =
|
|
371
|
-
|
|
372
|
-
|
|
569
|
+
): Promise<{ form: WakeShellForm; diagnostics: string }> {
|
|
570
|
+
const proc = spawnHostSsh(
|
|
571
|
+
options,
|
|
572
|
+
[...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", ...connectTimeoutOptions(), target, "echo %OS%"],
|
|
373
573
|
);
|
|
374
|
-
|
|
375
|
-
|
|
574
|
+
// One merged read of BOTH streams, raced against the 5s cap: resolves when
|
|
575
|
+
// the process dies (a healthy probe answers and exits), else returns empty
|
|
576
|
+
// at the cap and the default form stands — a slow host is never punished.
|
|
577
|
+
// A generous keep (review P3): the tail-trimmed merge must not push the
|
|
578
|
+
// stdout %OS% answer out the front even behind unusual stderr volume.
|
|
579
|
+
const text = await Promise.race([
|
|
580
|
+
drainStderrTail([proc.stdout, proc.stderr], 8192),
|
|
376
581
|
Bun.sleep(5_000).then(() => ""),
|
|
377
582
|
]);
|
|
378
583
|
proc.kill();
|
|
379
|
-
return /Windows_NT/i.test(
|
|
584
|
+
return { form: /Windows_NT/i.test(text) ? "cmd" : "posix", diagnostics: text };
|
|
380
585
|
}
|
|
381
586
|
|
|
382
587
|
/** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
|
|
@@ -389,7 +594,7 @@ async function probeHostShellFamily(
|
|
|
389
594
|
async function probeWakeReady(
|
|
390
595
|
base: string,
|
|
391
596
|
timeoutMs: number,
|
|
392
|
-
proc:
|
|
597
|
+
proc: HostSshProcess,
|
|
393
598
|
): Promise<boolean> {
|
|
394
599
|
const deadline = Date.now() + timeoutMs;
|
|
395
600
|
for (;;) {
|
|
@@ -425,12 +630,15 @@ async function probeWakeReady(
|
|
|
425
630
|
* so diagnostics can arrive on either stream — both are drained and
|
|
426
631
|
* concatenated. Resolves when the streams end (i.e. after the process is
|
|
427
632
|
* dead) — only await once the proc has been killed or has exited. */
|
|
428
|
-
function drainStderrTail(
|
|
633
|
+
function drainStderrTail(
|
|
634
|
+
streams: readonly (ReadableStream<Uint8Array> | number | undefined)[],
|
|
635
|
+
keep = 2000,
|
|
636
|
+
): Promise<string> {
|
|
429
637
|
return Promise.all(
|
|
430
|
-
streams.map((stream) => new Response(stream).text().catch(() => "")),
|
|
638
|
+
streams.map((stream) => (typeof stream === "object" && stream !== null ? new Response(stream).text().catch(() => "") : "")),
|
|
431
639
|
).then((texts) => {
|
|
432
640
|
const text = texts.join("").trim();
|
|
433
|
-
return text.length > keep ?
|
|
641
|
+
return text.length > keep ? `...${text.slice(-keep)}` : text;
|
|
434
642
|
});
|
|
435
643
|
}
|
|
436
644
|
|
|
@@ -441,12 +649,17 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
|
|
|
441
649
|
* abrupt-disconnect orphan). Encryption + host/user auth come from the user's
|
|
442
650
|
* SSH config/keys. `sshCommand` is a test injection point for the ssh binary.
|
|
443
651
|
* Bounded retries absorb a client-chosen remote port colliding with an
|
|
444
|
-
* in-use one (the door fails to bind and the command exits).
|
|
652
|
+
* in-use one (the door fails to bind and the command exits).
|
|
653
|
+
*
|
|
654
|
+
* Failure messages name the TARGET (an origin-level fact), never an endpoint
|
|
655
|
+
* label: under fan-out (ADR 0026 rule 1) every same-origin binding shares
|
|
656
|
+
* this one ladder and its one rejection — the per-endpoint identity belongs
|
|
657
|
+
* to the caller's row warning ("endpoint '<name>' capabilities unavailable:
|
|
658
|
+
* …"), which each row supplies itself. */
|
|
445
659
|
async function openSshTunnel(
|
|
446
660
|
parts: { user?: string; host: string },
|
|
447
|
-
options: { sshCommand?: readonly string[] },
|
|
448
|
-
|
|
449
|
-
): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
|
|
661
|
+
options: { sshCommand?: readonly string[]; registryPath?: string },
|
|
662
|
+
): Promise<{ proc: HostSshProcess; base: string }> {
|
|
450
663
|
const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
|
|
451
664
|
// Tier 1 first: a detached mux master per origin (no-op when one already
|
|
452
665
|
// holds the socket); the wake client below rides it when possible and
|
|
@@ -460,7 +673,25 @@ async function openSshTunnel(
|
|
|
460
673
|
// Shell form (2026-09-20): probed up front via `echo %OS%` (language-
|
|
461
674
|
// independent — see probeHostShellFamily), POSIX by default; the wrong-
|
|
462
675
|
// shell ladder below remains the backstop for a lying or timed-out probe.
|
|
463
|
-
|
|
676
|
+
// Cold cache: the probe doubles as the first failure classifier
|
|
677
|
+
// (ADR-REM-008 §3) — its instant verdicts (refused / auth / hostkey /
|
|
678
|
+
// resolve failures) reject the origin here, before any ladder attempt;
|
|
679
|
+
// black-hole timeouts defer to the ladder's full ConnectTimeout budget.
|
|
680
|
+
// Warm cache (the probe is skipped): the ladder's first attempt carries
|
|
681
|
+
// the same classifier, so the classified verdicts cost at most one
|
|
682
|
+
// ConnectTimeout either way.
|
|
683
|
+
let form: WakeShellForm;
|
|
684
|
+
const cachedForm = readCachedShellFamily(shellFamilyCachePath(options), target);
|
|
685
|
+
if (cachedForm !== undefined) {
|
|
686
|
+
form = cachedForm;
|
|
687
|
+
} else {
|
|
688
|
+
const probed = await probeHostShellFamily(options, target);
|
|
689
|
+
const probeClass = classifyWakeFailure(probed.diagnostics);
|
|
690
|
+
if (probeClass !== undefined && !wakeFailureDefersToLadder(probeClass, probed.diagnostics)) {
|
|
691
|
+
throw new RemoteTransportError(wakeFailureMessage(probeClass, target, probed.diagnostics));
|
|
692
|
+
}
|
|
693
|
+
form = probed.form;
|
|
694
|
+
}
|
|
464
695
|
const attempts = 3;
|
|
465
696
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
466
697
|
const localPort = await freePort();
|
|
@@ -478,14 +709,19 @@ async function openSshTunnel(
|
|
|
478
709
|
...(form === "posix" ? ["-tt"] : []),
|
|
479
710
|
"-o", "ExitOnForwardFailure=yes",
|
|
480
711
|
"-o", "BatchMode=yes",
|
|
712
|
+
...connectTimeoutOptions(),
|
|
481
713
|
...(muxDisabled ? [] : muxClientOptions()),
|
|
482
714
|
"-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
|
|
483
715
|
target,
|
|
484
716
|
wakeDoorCommand(remotePort, form),
|
|
485
717
|
];
|
|
486
|
-
const proc =
|
|
718
|
+
const proc = spawnHostSsh(options, argv);
|
|
487
719
|
const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
|
|
488
720
|
if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
|
|
721
|
+
// Ground truth for the shell-family cache: the form that actually woke
|
|
722
|
+
// the door — a probe's first guess, or the ladder's flip past a stale
|
|
723
|
+
// cache entry, both converge here.
|
|
724
|
+
persistShellFamily(shellFamilyCachePath(options), target, form);
|
|
489
725
|
return { proc, base: `http://127.0.0.1:${localPort}` };
|
|
490
726
|
}
|
|
491
727
|
proc.kill();
|
|
@@ -494,6 +730,15 @@ async function openSshTunnel(
|
|
|
494
730
|
// fast crash as a slow timeout.
|
|
495
731
|
const diagnostics = (await stderrTail).trim();
|
|
496
732
|
const exited = proc.exitCode !== null;
|
|
733
|
+
// Deterministic connect/auth verdicts never consume the ladder's
|
|
734
|
+
// remaining attempts (ADR-REM-008 §2): re-running against the same host
|
|
735
|
+
// reproduces them, so attempt 1's verdict IS the origin's verdict. The
|
|
736
|
+
// ladder stays for what it was built for — port-collision binds and
|
|
737
|
+
// not-ready timeouts.
|
|
738
|
+
const failureClass = exited ? classifyWakeFailure(diagnostics) : undefined;
|
|
739
|
+
if (failureClass !== undefined) {
|
|
740
|
+
throw new RemoteTransportError(wakeFailureMessage(failureClass, target, diagnostics));
|
|
741
|
+
}
|
|
497
742
|
if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
|
|
498
743
|
muxDisabled = true;
|
|
499
744
|
}
|
|
@@ -505,21 +750,21 @@ async function openSshTunnel(
|
|
|
505
750
|
// Both forms rejected: this is a shell mismatch, not a missing-ukp
|
|
506
751
|
// failure — say so instead of falling into the missing-ukp wording.
|
|
507
752
|
throw new RemoteTransportError(
|
|
508
|
-
`waking the ukp door on '${target}'
|
|
753
|
+
`waking the ukp door on '${target}' failed - the remote shell rejected both wake command forms (POSIX and cmd.exe): ${diagnostics}. Supported host shells: POSIX login shells, cmd.exe default, Git-Bash-style DefaultShell; a powershell DefaultShell is not supported`,
|
|
509
754
|
);
|
|
510
755
|
}
|
|
511
756
|
// POSIX login shells print "…: ukp: command not found" (exit 127); Windows
|
|
512
757
|
// OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
|
|
513
758
|
if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
|
|
514
759
|
throw new RemoteTransportError(
|
|
515
|
-
`waking the ukp door on '${target}'
|
|
760
|
+
`waking the ukp door on '${target}' failed - the remote shell cannot find 'ukp': ${diagnostics}. Host prerequisite (W9): ssh reachable AND ukp on the remote PATH`,
|
|
516
761
|
);
|
|
517
762
|
}
|
|
518
763
|
if (attempt === attempts) {
|
|
519
764
|
throw new RemoteTransportError(
|
|
520
765
|
exited
|
|
521
|
-
? `waking the ukp door on '${target}'
|
|
522
|
-
:
|
|
766
|
+
? `waking the ukp door on '${target}' failed: ${diagnostics}`
|
|
767
|
+
: wakeNotReadyMessage(target, WAKE_READY_TIMEOUT_MS),
|
|
523
768
|
);
|
|
524
769
|
}
|
|
525
770
|
}
|
|
@@ -550,11 +795,12 @@ export async function openRemoteTransport(
|
|
|
550
795
|
throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
|
|
551
796
|
}
|
|
552
797
|
if (parts.scheme !== "ssh") {
|
|
553
|
-
return directTransportHandle(binding, parts, options);
|
|
798
|
+
return directTransportHandle({ ...binding, url: binding.url }, parts, options);
|
|
554
799
|
}
|
|
555
|
-
const tunnel = await openSshTunnel(parts, options
|
|
800
|
+
const tunnel = await openSshTunnel(parts, options);
|
|
556
801
|
return {
|
|
557
802
|
base: tunnel.base + wirePrefix(parts.endpointName),
|
|
803
|
+
displayUrl: binding.url,
|
|
558
804
|
close: () => tunnel.proc.kill(),
|
|
559
805
|
};
|
|
560
806
|
}
|
|
@@ -576,7 +822,7 @@ export interface RemoteTransportPool {
|
|
|
576
822
|
export function createTransportPool(
|
|
577
823
|
options: { sshCommand?: readonly string[]; registryPath?: string } = {},
|
|
578
824
|
): RemoteTransportPool {
|
|
579
|
-
const tunnels = new Map<string, Promise<{ proc:
|
|
825
|
+
const tunnels = new Map<string, Promise<{ proc: HostSshProcess; base: string }>>();
|
|
580
826
|
return {
|
|
581
827
|
async acquire(binding) {
|
|
582
828
|
if (binding.kind !== "remote" || binding.url === undefined) {
|
|
@@ -588,13 +834,13 @@ export function createTransportPool(
|
|
|
588
834
|
throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
|
|
589
835
|
}
|
|
590
836
|
if (parts.scheme !== "ssh") {
|
|
591
|
-
return directTransportHandle(binding, parts, options);
|
|
837
|
+
return directTransportHandle({ ...binding, url: binding.url }, parts, options);
|
|
592
838
|
}
|
|
593
839
|
let tunnel = tunnels.get(parts.origin);
|
|
594
840
|
if (tunnel === undefined) {
|
|
595
841
|
// Drop the entry on failure so a retry inside the same invocation
|
|
596
842
|
// spawns fresh instead of re-throwing a stale rejected promise.
|
|
597
|
-
tunnel = openSshTunnel(parts, options
|
|
843
|
+
tunnel = openSshTunnel(parts, options).catch((error: unknown) => {
|
|
598
844
|
tunnels.delete(parts.origin);
|
|
599
845
|
throw error;
|
|
600
846
|
});
|
|
@@ -603,6 +849,7 @@ export function createTransportPool(
|
|
|
603
849
|
const acquired = await tunnel;
|
|
604
850
|
return {
|
|
605
851
|
base: acquired.base + wirePrefix(parts.endpointName),
|
|
852
|
+
displayUrl: binding.url,
|
|
606
853
|
close: () => {},
|
|
607
854
|
};
|
|
608
855
|
},
|
|
@@ -621,11 +868,11 @@ function isTimeoutError(error: unknown): boolean {
|
|
|
621
868
|
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
622
869
|
}
|
|
623
870
|
|
|
624
|
-
function unreachableError(
|
|
871
|
+
function unreachableError(displayUrl: string, error: unknown): RemoteTransportError {
|
|
625
872
|
const code = (error as { code?: unknown }).code;
|
|
626
873
|
const detail = error instanceof Error ? error.message : String(error);
|
|
627
874
|
return new RemoteTransportError(
|
|
628
|
-
`remote endpoint unreachable: ${
|
|
875
|
+
`remote endpoint unreachable: ${displayUrl} (${detail}${typeof code === "string" && code !== "" ? ` [${code}]` : ""})`,
|
|
629
876
|
);
|
|
630
877
|
}
|
|
631
878
|
|
|
@@ -640,7 +887,7 @@ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<
|
|
|
640
887
|
if (probe === undefined) return false;
|
|
641
888
|
if (anchor.pin !== undefined && probe.spkiPin !== anchor.pin) {
|
|
642
889
|
throw new RemoteTransportError(
|
|
643
|
-
`remote '${anchor.name ?? "endpoint"}' TLS identity changed
|
|
890
|
+
`remote '${anchor.name ?? "endpoint"}' TLS identity changed - pinned ${anchor.pin}, got ${probe.spkiPin}; if the server was reinstalled this is expected: refresh trust with 'ukp register --url <url> --token <token>'`,
|
|
644
891
|
);
|
|
645
892
|
}
|
|
646
893
|
anchor.ca = probe.certPem;
|
|
@@ -657,12 +904,16 @@ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<
|
|
|
657
904
|
|
|
658
905
|
/** Fetch + JSON-decode with one transparent renewal re-anchor: a pinned
|
|
659
906
|
* https anchor turns a certificate-verification fetch failure into a probe —
|
|
660
|
-
* same SPKI keeps going (anchor swapped in place), a different SPKI blocks.
|
|
907
|
+
* same SPKI keeps going (anchor swapped in place), a different SPKI blocks.
|
|
908
|
+
* `displayUrl` (the url the caller knows) replaces the wire url in error
|
|
909
|
+
* text; without one the wire url itself is the best available label. */
|
|
661
910
|
async function fetchJson(
|
|
662
911
|
url: string,
|
|
663
912
|
init: RequestInit,
|
|
664
913
|
anchor?: RemoteTlsAnchor,
|
|
914
|
+
displayUrl?: string,
|
|
665
915
|
): Promise<{ status: number; body: unknown }> {
|
|
916
|
+
const label = displayUrl ?? url;
|
|
666
917
|
const attempt = async (ca?: string, wrap = true): Promise<{ status: number; body: unknown }> => {
|
|
667
918
|
let response: Response;
|
|
668
919
|
try {
|
|
@@ -675,13 +926,13 @@ async function fetchJson(
|
|
|
675
926
|
// wrap=false keeps the raw error so the anchored caller can classify
|
|
676
927
|
// it (TLS verification failure → re-anchor probe) before wrapping.
|
|
677
928
|
if (!wrap) throw error;
|
|
678
|
-
throw unreachableError(
|
|
929
|
+
throw unreachableError(label, error);
|
|
679
930
|
}
|
|
680
931
|
let body: unknown;
|
|
681
932
|
try {
|
|
682
933
|
body = await response.json();
|
|
683
934
|
} catch {
|
|
684
|
-
throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${
|
|
935
|
+
throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${label}`);
|
|
685
936
|
}
|
|
686
937
|
return { status: response.status, body };
|
|
687
938
|
};
|
|
@@ -691,17 +942,17 @@ async function fetchJson(
|
|
|
691
942
|
return await attempt(anchor.ca, false);
|
|
692
943
|
} catch (error) {
|
|
693
944
|
if (error instanceof RemoteTransportError) throw error; // non-JSON body: not a TLS event
|
|
694
|
-
if (isTimeoutError(error)) throw unreachableError(
|
|
945
|
+
if (isTimeoutError(error)) throw unreachableError(label, error);
|
|
695
946
|
// Re-anchoring needs a pinned identity: only the SPKI pin distinguishes
|
|
696
947
|
// "renewed certificate, same key" from "different identity".
|
|
697
948
|
if (anchor.pin !== undefined && await reanchorFromProbe(url, anchor)) return await attempt(anchor.ca);
|
|
698
|
-
throw unreachableError(
|
|
949
|
+
throw unreachableError(label, error);
|
|
699
950
|
}
|
|
700
951
|
}
|
|
701
952
|
|
|
702
|
-
function asRecord(body: unknown,
|
|
953
|
+
function asRecord(body: unknown, displayUrl: string): Record<string, unknown> {
|
|
703
954
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
704
|
-
throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${
|
|
955
|
+
throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${displayUrl}`);
|
|
705
956
|
}
|
|
706
957
|
return body as Record<string, unknown>;
|
|
707
958
|
}
|
|
@@ -720,6 +971,7 @@ export async function fetchDiscoveryDocument(
|
|
|
720
971
|
token?: string,
|
|
721
972
|
): Promise<DiscoveryFetch> {
|
|
722
973
|
return fetchDiscoveryDocumentAt(transport.base, {
|
|
974
|
+
displayUrl: transport.displayUrl,
|
|
723
975
|
name: binding.name,
|
|
724
976
|
...(binding.instance_uid !== undefined ? { pinnedUid: binding.instance_uid } : {}),
|
|
725
977
|
...(token !== undefined ? { token } : {}),
|
|
@@ -734,16 +986,17 @@ export async function fetchDiscoveryDocument(
|
|
|
734
986
|
* (ADR-REM-004 / O-1). */
|
|
735
987
|
async function fetchWellKnownRecord(
|
|
736
988
|
base: string,
|
|
737
|
-
options: { token?: string; tlsAnchor?: RemoteTlsAnchor },
|
|
989
|
+
options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string },
|
|
738
990
|
): Promise<{ record: Record<string, unknown>; bearerRequired: boolean }> {
|
|
739
991
|
const url = `${base}${DISCOVERY_PATH}`;
|
|
740
|
-
const
|
|
741
|
-
const
|
|
992
|
+
const display = options.displayUrl ?? base;
|
|
993
|
+
const { body } = await fetchJson(url, { headers: authorizationHeaders(options.token) }, options.tlsAnchor, display);
|
|
994
|
+
const record = asRecord(body, display);
|
|
742
995
|
if (record.protocol !== PROTOCOL_NAME) {
|
|
743
|
-
throw new RemoteTransportError(`service at ${
|
|
996
|
+
throw new RemoteTransportError(`service at ${display} is not a ukp-remote service (protocol: ${String(record.protocol)})`);
|
|
744
997
|
}
|
|
745
998
|
if (record.protocol_version !== "1") {
|
|
746
|
-
throw new RemoteTransportError(`service at ${
|
|
999
|
+
throw new RemoteTransportError(`service at ${display} speaks ukp-remote protocol version ${String(record.protocol_version)}; this client supports 1`);
|
|
747
1000
|
}
|
|
748
1001
|
const schemes = (record.security as { schemes?: unknown } | undefined)?.schemes;
|
|
749
1002
|
return { record, bearerRequired: Array.isArray(schemes) && schemes.includes("bearer") };
|
|
@@ -756,14 +1009,22 @@ async function fetchWellKnownRecord(
|
|
|
756
1009
|
* documents; doors go through `fetchRegistrationDocument`. */
|
|
757
1010
|
export async function fetchDiscoveryDocumentAt(
|
|
758
1011
|
url: string,
|
|
759
|
-
options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
|
|
1012
|
+
options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
|
|
760
1013
|
): Promise<DiscoveryFetch> {
|
|
761
1014
|
const base = url.replace(/\/+$/, "");
|
|
762
|
-
const
|
|
763
|
-
const
|
|
1015
|
+
const display = options.displayUrl ?? base;
|
|
1016
|
+
const label = options.name ?? display;
|
|
1017
|
+
const { record, bearerRequired } = await fetchWellKnownRecord(base, {
|
|
1018
|
+
token: options.token,
|
|
1019
|
+
tlsAnchor: options.tlsAnchor,
|
|
1020
|
+
displayUrl: display,
|
|
1021
|
+
});
|
|
764
1022
|
if (record.scope === "host") {
|
|
1023
|
+
// The remedy registers the DOOR, not the endpoint path the wire base
|
|
1024
|
+
// carries: derive the origin from the url the caller knows.
|
|
1025
|
+
const doorOrigin = parseRemoteUrl(display)?.origin ?? display;
|
|
765
1026
|
throw new RemoteTransportError(
|
|
766
|
-
`service at ${
|
|
1027
|
+
`service at ${display} is a host door (scope:"host"); register it with 'ukp register --url ${doorOrigin}' to import its endpoints`,
|
|
767
1028
|
);
|
|
768
1029
|
}
|
|
769
1030
|
const warnings: string[] = [];
|
|
@@ -815,15 +1076,20 @@ export interface DoorFetch {
|
|
|
815
1076
|
* itself carries no identity. */
|
|
816
1077
|
export async function fetchDoorDocument(
|
|
817
1078
|
url: string,
|
|
818
|
-
options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
|
|
1079
|
+
options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
|
|
819
1080
|
): Promise<DoorFetch> {
|
|
820
1081
|
const base = url.replace(/\/+$/, "");
|
|
821
|
-
const
|
|
1082
|
+
const display = options.displayUrl ?? base;
|
|
1083
|
+
const { record, bearerRequired } = await fetchWellKnownRecord(base, {
|
|
1084
|
+
token: options.token,
|
|
1085
|
+
tlsAnchor: options.tlsAnchor,
|
|
1086
|
+
displayUrl: display,
|
|
1087
|
+
});
|
|
822
1088
|
if (record.scope !== "host") {
|
|
823
|
-
throw new RemoteTransportError(`service at ${
|
|
1089
|
+
throw new RemoteTransportError(`service at ${display} is not a host door (scope: ${String(record.scope)}); register it directly with 'ukp register --url ${display}'`);
|
|
824
1090
|
}
|
|
825
1091
|
return {
|
|
826
|
-
doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record,
|
|
1092
|
+
doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, display) },
|
|
827
1093
|
bearerRequired,
|
|
828
1094
|
};
|
|
829
1095
|
}
|
|
@@ -838,15 +1104,20 @@ export type RegistrationFetch =
|
|
|
838
1104
|
|
|
839
1105
|
export async function fetchRegistrationDocument(
|
|
840
1106
|
url: string,
|
|
841
|
-
options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
|
|
1107
|
+
options: { token?: string; tlsAnchor?: RemoteTlsAnchor; displayUrl?: string } = {},
|
|
842
1108
|
): Promise<RegistrationFetch> {
|
|
843
1109
|
const base = url.replace(/\/+$/, "");
|
|
844
|
-
const
|
|
1110
|
+
const display = options.displayUrl ?? base;
|
|
1111
|
+
const { record, bearerRequired } = await fetchWellKnownRecord(base, {
|
|
1112
|
+
token: options.token,
|
|
1113
|
+
tlsAnchor: options.tlsAnchor,
|
|
1114
|
+
displayUrl: display,
|
|
1115
|
+
});
|
|
845
1116
|
if (record.scope === "host") {
|
|
846
1117
|
return {
|
|
847
1118
|
kind: "door",
|
|
848
1119
|
door: {
|
|
849
|
-
doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record,
|
|
1120
|
+
doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, display) },
|
|
850
1121
|
bearerRequired,
|
|
851
1122
|
},
|
|
852
1123
|
};
|
|
@@ -892,8 +1163,8 @@ export async function remoteSearch(
|
|
|
892
1163
|
method: "POST",
|
|
893
1164
|
headers: { "content-type": "application/json", ...authorizationHeaders(token) },
|
|
894
1165
|
body: JSON.stringify({ query, limit }),
|
|
895
|
-
}, transport.tls);
|
|
896
|
-
const record = asRecord(body,
|
|
1166
|
+
}, transport.tls, transport.displayUrl);
|
|
1167
|
+
const record = asRecord(body, transport.displayUrl);
|
|
897
1168
|
const envelope = record.endpoints as Array<Record<string, unknown>> | undefined;
|
|
898
1169
|
const entry = Array.isArray(envelope) ? envelope[0] : undefined;
|
|
899
1170
|
|
|
@@ -930,7 +1201,7 @@ export async function remoteSearch(
|
|
|
930
1201
|
|
|
931
1202
|
function authMessage(token: string | undefined): string {
|
|
932
1203
|
return token === undefined
|
|
933
|
-
? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-'
|
|
1204
|
+
? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-' -> '_')"
|
|
934
1205
|
: "remote endpoint rejected the bearer token (401)";
|
|
935
1206
|
}
|
|
936
1207
|
|
|
@@ -969,8 +1240,8 @@ export async function remoteRead(
|
|
|
969
1240
|
if (params.pin !== undefined) search.set("pin", params.pin);
|
|
970
1241
|
const { status, body } = await fetchJson(`${base}/v1/read?${search.toString()}`, {
|
|
971
1242
|
headers: authorizationHeaders(token),
|
|
972
|
-
}, transport.tls);
|
|
973
|
-
const record = asRecord(body,
|
|
1243
|
+
}, transport.tls, transport.displayUrl);
|
|
1244
|
+
const record = asRecord(body, transport.displayUrl);
|
|
974
1245
|
const error = record.error as { class?: unknown; message?: unknown } | undefined;
|
|
975
1246
|
return {
|
|
976
1247
|
status,
|
|
@@ -1007,8 +1278,8 @@ export async function remoteNav(
|
|
|
1007
1278
|
const query = search.size > 0 ? `?${search.toString()}` : "";
|
|
1008
1279
|
const { status, body } = await fetchJson(`${base}/v1/nav${query}`, {
|
|
1009
1280
|
headers: authorizationHeaders(token),
|
|
1010
|
-
}, transport.tls);
|
|
1011
|
-
const record = asRecord(body,
|
|
1281
|
+
}, transport.tls, transport.displayUrl);
|
|
1282
|
+
const record = asRecord(body, transport.displayUrl);
|
|
1012
1283
|
const error = record.error as { class?: unknown; message?: unknown } | undefined;
|
|
1013
1284
|
// Shape gate (read precedent validates what it consumes): a body claiming
|
|
1014
1285
|
// the schema but missing the rendered fields would crash the shared
|
|
@@ -1057,10 +1328,23 @@ function remoteRgCounts(raw: unknown): RgCountEntry[] | undefined {
|
|
|
1057
1328
|
});
|
|
1058
1329
|
}
|
|
1059
1330
|
|
|
1331
|
+
function remoteRgFiles(raw: unknown): RgFileEntry[] | undefined {
|
|
1332
|
+
if (!Array.isArray(raw)) return undefined;
|
|
1333
|
+
return raw.flatMap((entry): RgFileEntry[] => {
|
|
1334
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
1335
|
+
const file = entry as Record<string, unknown>;
|
|
1336
|
+
if (typeof file.path !== "string") return [];
|
|
1337
|
+
return [{ path: file.path, ...(typeof file.ukp_uri === "string" ? { ukp_uri: file.ukp_uri } : {}) }];
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1060
1341
|
const RG_WIRE_STATUSES = new Set(["succeeded", "no_matches", "skipped", "failed", "interrupted", "cancelled"]);
|
|
1061
1342
|
|
|
1062
1343
|
/** GET /v1/rg — single-endpoint outcome in the local RgEndpointOutcome
|
|
1063
|
-
* vocabulary (provider is rg's external tier regardless of transport).
|
|
1344
|
+
* vocabulary (provider is rg's external tier regardless of transport).
|
|
1345
|
+
* Files mode (ADR-RG-005) sends files=1 and omits query — an older serve
|
|
1346
|
+
* without files support answers its query-required usage error, which
|
|
1347
|
+
* lands here as a failed outcome carrying the server's message. */
|
|
1064
1348
|
export async function remoteRg(
|
|
1065
1349
|
binding: RegistryBinding,
|
|
1066
1350
|
transport: RemoteTransportHandle,
|
|
@@ -1068,24 +1352,27 @@ export async function remoteRg(
|
|
|
1068
1352
|
params: {
|
|
1069
1353
|
query: string;
|
|
1070
1354
|
limit: number;
|
|
1071
|
-
|
|
1355
|
+
globs?: readonly string[];
|
|
1072
1356
|
type?: string;
|
|
1073
1357
|
ignoreCase?: boolean;
|
|
1074
1358
|
count?: boolean;
|
|
1359
|
+
files?: boolean;
|
|
1075
1360
|
passthrough: readonly string[];
|
|
1076
1361
|
},
|
|
1077
1362
|
): Promise<RemoteRgExecution> {
|
|
1078
1363
|
const base = transport.base;
|
|
1079
|
-
const search = new URLSearchParams({
|
|
1080
|
-
if (params.
|
|
1364
|
+
const search = new URLSearchParams({ limit: String(params.limit) });
|
|
1365
|
+
if (params.files === true) search.set("files", "1");
|
|
1366
|
+
else search.set("query", params.query);
|
|
1367
|
+
for (const glob of params.globs ?? []) search.append("glob", glob);
|
|
1081
1368
|
if (params.type !== undefined) search.set("type", params.type);
|
|
1082
1369
|
if (params.ignoreCase === true) search.set("i", "1");
|
|
1083
1370
|
if (params.count === true) search.set("count", "1");
|
|
1084
1371
|
for (const arg of params.passthrough) search.append("passthrough", arg);
|
|
1085
1372
|
const { status, body } = await fetchJson(`${base}/v1/rg?${search.toString()}`, {
|
|
1086
1373
|
headers: authorizationHeaders(token),
|
|
1087
|
-
}, transport.tls);
|
|
1088
|
-
const record = asRecord(body,
|
|
1374
|
+
}, transport.tls, transport.displayUrl);
|
|
1375
|
+
const record = asRecord(body, transport.displayUrl);
|
|
1089
1376
|
const error = record.error as { class?: unknown; message?: unknown } | undefined;
|
|
1090
1377
|
|
|
1091
1378
|
if (status === 401) {
|
|
@@ -1112,6 +1399,9 @@ export async function remoteRg(
|
|
|
1112
1399
|
match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
|
|
1113
1400
|
);
|
|
1114
1401
|
const counts = remoteRgCounts(entry.counts);
|
|
1402
|
+
const files = remoteRgFiles(entry.files)?.map((file) =>
|
|
1403
|
+
file.ukp_uri !== undefined ? { ...file, ukp_uri: reanchorRemoteUkpUri(file.ukp_uri, binding) } : file,
|
|
1404
|
+
);
|
|
1115
1405
|
const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
|
|
1116
1406
|
return {
|
|
1117
1407
|
outcome: {
|
|
@@ -1121,6 +1411,7 @@ export async function remoteRg(
|
|
|
1121
1411
|
...(typeof entry.message === "string" ? { message: entry.message } : {}),
|
|
1122
1412
|
...(matches !== undefined ? { matches } : {}),
|
|
1123
1413
|
...(counts !== undefined ? { counts } : {}),
|
|
1414
|
+
...(files !== undefined ? { files } : {}),
|
|
1124
1415
|
...(entry.truncated === true ? { truncated: true } : {}),
|
|
1125
1416
|
},
|
|
1126
1417
|
warnings,
|
|
@@ -1156,8 +1447,8 @@ export async function remotePropose(
|
|
|
1156
1447
|
method: "PUT",
|
|
1157
1448
|
headers: { "content-type": "text/plain; charset=utf-8", ...authorizationHeaders(token) },
|
|
1158
1449
|
body: content,
|
|
1159
|
-
}, transport.tls);
|
|
1160
|
-
const record = asRecord(body,
|
|
1450
|
+
}, transport.tls, transport.displayUrl);
|
|
1451
|
+
const record = asRecord(body, transport.displayUrl);
|
|
1161
1452
|
const error = record.error as { class?: unknown; message?: unknown } | undefined;
|
|
1162
1453
|
const ok = record.schema === "ukp.propose.v1"
|
|
1163
1454
|
&& typeof record.id === "string"
|