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