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