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