@catheadowl/ukp 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/docs/remote-deployment.md +7 -0
- package/package.json +1 -1
- package/src/capabilities/remote-client.ts +201 -21
- package/src/capabilities/rg.ts +147 -20
- package/src/cli.ts +1 -1
- package/src/commands/guide-content/remote.txt +3 -1
- package/src/commands/guide-content/rg.txt +35 -0
- package/src/commands/guide.ts +11 -4
- package/src/commands/rg.ts +45 -14
- package/src/server.ts +37 -22
- package/src/spawn-relay.ts +57 -0
package/README.md
CHANGED
|
@@ -61,7 +61,9 @@ predictable scope, and a single command surface. Use UKP when:
|
|
|
61
61
|
Requirements). `read` and `nav` are derived defaults of every local
|
|
62
62
|
Service.
|
|
63
63
|
- Run base lexical search with `rg` across endpoint files — no search
|
|
64
|
-
provider needed, on by default, shaped into `read`-ready references
|
|
64
|
+
provider needed, on by default, shaped into `read`-ready references —
|
|
65
|
+
or enumerate them with `rg --files` (a tree walk: empty and binary files
|
|
66
|
+
included, hidden files behind `-- --hidden`).
|
|
65
67
|
- Search indexed content, QMD-backed: one endpoint, the workspace default
|
|
66
68
|
scope, or the whole local Registry. `--recursive` additionally searches
|
|
67
69
|
the endpoints a Service declares in its manifest `dependencies` —
|
|
@@ -143,7 +145,7 @@ Endpoint commands:
|
|
|
143
145
|
| `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands the manifest's declared `dependencies`. Results hand off via docid session keys and verified `ukp://` references. |
|
|
144
146
|
| `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or a `docid` handed off by search results. |
|
|
145
147
|
| `ukp nav` | Markdown outline of an endpoint (`--depth`, respects `.gitignore`); on by default, configurable via `[capabilities.nav]`. |
|
|
146
|
-
| `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default;
|
|
148
|
+
| `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; `--files` enumerates them instead of searching. Results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning. |
|
|
147
149
|
| `ukp propose` | Submits an idempotent change proposal (suggestion box — the owner decides what happens next). Created/unchanged/updated; the revision bumps only on `updated`. |
|
|
148
150
|
|
|
149
151
|
Registry commands:
|
|
@@ -171,6 +173,7 @@ Help commands:
|
|
|
171
173
|
| `ukp guide service` | Provider-agnostic Service setup path. |
|
|
172
174
|
| `ukp guide service qmd` | Provider-owned setup for the QMD provider. |
|
|
173
175
|
| `ukp guide client` | How a workspace uses registered Services by default. |
|
|
176
|
+
| `ukp guide rg` | The rg quickstart: modes, glob semantics, visibility tiers. |
|
|
174
177
|
| `ukp guide remote` | How to serve and consume endpoints across machines. |
|
|
175
178
|
| `ukp guide propose` | The propose quickstart. |
|
|
176
179
|
|
|
@@ -65,6 +65,13 @@ Notes:
|
|
|
65
65
|
wake — the client chooses the door port per invocation, which a fixed
|
|
66
66
|
forced command cannot express.
|
|
67
67
|
|
|
68
|
+
Windows note: some antivirus setups (Windows Defender behavioral inspection
|
|
69
|
+
included) delay a process spawned directly by the runtime by several seconds
|
|
70
|
+
per call — 5-10s per ssh/rg spawn was measured before ukp routed its
|
|
71
|
+
Windows tool spawns through a one-level relay. ukp does this automatically;
|
|
72
|
+
if you also add your own exclusion for your package manager's runtimes the
|
|
73
|
+
residual per-call cost is just the real ssh handshake plus the door spawn.
|
|
74
|
+
|
|
68
75
|
Windows hosts work out of the box: when the host's OpenSSH default shell
|
|
69
76
|
(cmd.exe) rejects the POSIX wake form, the client detects the signature and
|
|
70
77
|
resends the pinned cmd.exe form automatically — nothing to configure beyond
|
package/package.json
CHANGED
|
@@ -19,11 +19,12 @@ import {
|
|
|
19
19
|
type RemoteUrlParts,
|
|
20
20
|
} from "../registry.ts";
|
|
21
21
|
import { spkiPinOf } from "./tls-identity.ts";
|
|
22
|
+
import { toolRelayScript } from "../spawn-relay.ts";
|
|
22
23
|
import type { SearchEndpointOutcome } from "./search.ts";
|
|
23
24
|
import type { NavEnvelope } from "./nav.ts";
|
|
24
25
|
import type { ProposeResult, ProposeStatus } from "./propose.ts";
|
|
25
26
|
import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
|
|
26
|
-
import type { RgEndpointOutcome, RgMatch, RgCountEntry } from "./rg.ts";
|
|
27
|
+
import type { RgEndpointOutcome, RgMatch, RgCountEntry, RgFileEntry } from "./rg.ts";
|
|
27
28
|
|
|
28
29
|
/** Remote transport for the client side (ukp-remote wire v1, ADR-REM-002/003;
|
|
29
30
|
* ukp_remote W2). All calls fetch fresh (no cross-invocation cache), carry the
|
|
@@ -381,6 +382,71 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
|
|
|
381
382
|
return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
|
|
382
383
|
}
|
|
383
384
|
|
|
385
|
+
/** Wake failure classes from ssh's own diagnostics (ADR-REM-008 / D-093):
|
|
386
|
+
* OpenSSH's stderr wording is locale-stable (unlike cmd.exe's), so stable
|
|
387
|
+
* substrings classify the deterministic failures — verdicts a re-run against
|
|
388
|
+
* the same host reproduces, which is why none of them belongs in the retry
|
|
389
|
+
* ladder. One taxonomy, three matchers: these connect/auth signatures here,
|
|
390
|
+
* missing-ukp (127/9009 dictionary) and shell-mismatch keep their existing
|
|
391
|
+
* branches. Pure function by design — the signature table is unit-testable
|
|
392
|
+
* without spawning ssh. */
|
|
393
|
+
export type WakeFailureClass = "unreachable" | "refused" | "auth" | "hostkey";
|
|
394
|
+
|
|
395
|
+
export function classifyWakeFailure(diagnostics: string): WakeFailureClass | undefined {
|
|
396
|
+
if (/Connection timed out|No route to host|Could not resolve hostname/i.test(diagnostics)) return "unreachable";
|
|
397
|
+
if (/Connection refused/i.test(diagnostics)) return "refused";
|
|
398
|
+
if (/Host key verification failed/i.test(diagnostics)) return "hostkey";
|
|
399
|
+
// Auth failures always carry the method-list suffix ("Permission denied
|
|
400
|
+
// (publickey,password).") — ssh's own format. The bare phrase also appears
|
|
401
|
+
// in REMOTE-COMMAND exec errors (a found-but-not-executable ukp on a
|
|
402
|
+
// noexec mount), which reach these diagnostics under -tt and must keep
|
|
403
|
+
// falling through to the generic wording, not get the key-auth remedy
|
|
404
|
+
// (review P2).
|
|
405
|
+
if (/Permission denied \(/i.test(diagnostics)) return "auth";
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A probe-stage timeout verdict is not trustworthy on its own (ADR-REM-008
|
|
410
|
+
* §3, implementation-time refinement): the probe's 5s race window is shorter
|
|
411
|
+
* than the full ConnectTimeout budget a slow-but-alive host may legitimately
|
|
412
|
+
* need — its wake would succeed in the ladder's window — so black-hole
|
|
413
|
+
* signatures defer to the ladder, which runs the full budget before the same
|
|
414
|
+
* classifier issues the verdict. Instant verdicts (refused, auth, hostkey,
|
|
415
|
+
* resolve and no-route failures) settle within one roundtrip and are trusted
|
|
416
|
+
* wherever they appear; a probe-local short ConnectTimeout would have
|
|
417
|
+
* misjudged every host slower than it. */
|
|
418
|
+
function wakeFailureDefersToLadder(failureClass: WakeFailureClass, diagnostics: string): boolean {
|
|
419
|
+
return failureClass === "unreachable"
|
|
420
|
+
&& /Connection timed out/i.test(diagnostics)
|
|
421
|
+
&& !/No route to host|Could not resolve hostname/i.test(diagnostics);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Classified wake-failure text (ADR-REM-008 §4): names the ssh TARGET (an
|
|
425
|
+
* origin-level fact under fan-out — never an endpoint label), quotes the ssh
|
|
426
|
+
* diagnostics as evidence, and gives the class its one remedy. Printable
|
|
427
|
+
* ASCII only — the frozen vocabulary rule for runtime strings. */
|
|
428
|
+
export function wakeFailureMessage(failureClass: WakeFailureClass, target: string, diagnostics: string): string {
|
|
429
|
+
const evidence = diagnostics.length > 0 ? `: ${diagnostics}` : "";
|
|
430
|
+
switch (failureClass) {
|
|
431
|
+
case "unreachable":
|
|
432
|
+
return `waking the ukp door on '${target}' failed - the host is unreachable${evidence}. The machine appears off, firewalled, or its address no longer resolves; verify it is powered on and reachable`;
|
|
433
|
+
case "refused":
|
|
434
|
+
return `waking the ukp door on '${target}' failed - the host is reachable but nothing is listening for ssh${evidence}. Start the ssh server on the host (on Windows: the optional OpenSSH Server feature) or fix the port`;
|
|
435
|
+
case "auth":
|
|
436
|
+
return `waking the ukp door on '${target}' failed - ssh rejected the credentials (BatchMode, no password prompt)${evidence}. Fix key auth: add the public key to the host's authorized_keys, or check the ssh config's IdentityFile`;
|
|
437
|
+
case "hostkey":
|
|
438
|
+
return `waking the ukp door on '${target}' failed - the host key was not accepted${evidence}. If the host was reinstalled this is expected: re-verify it (ssh-keygen -R), then retry`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Not-ready wording (ADR-REM-008): the old text blamed the host alias and
|
|
443
|
+
* key auth — both, by the time this message is reached, have already
|
|
444
|
+
* succeeded (their failures classify earlier); what failed is the door
|
|
445
|
+
* behind a working connection. */
|
|
446
|
+
export function wakeNotReadyMessage(target: string, timeoutMs: number): string {
|
|
447
|
+
return `the ssh connection to '${target}' worked but the ukp door did not become ready within ${Math.round(timeoutMs / 1000)}s. Host prerequisite (W9): ukp on the remote PATH; a slow first start also lands here - retry once before investigating`;
|
|
448
|
+
}
|
|
449
|
+
|
|
384
450
|
/** Cross-invocation shell-family cache (ukp_list W1 / L-1, ADR 0026's
|
|
385
451
|
* implementation-time note): a host's shell family is a static fact, but
|
|
386
452
|
* learning it costs a full ssh roundtrip per origin per invocation — on
|
|
@@ -426,6 +492,60 @@ function persistShellFamily(cachePath: string | undefined, target: string, form:
|
|
|
426
492
|
}
|
|
427
493
|
}
|
|
428
494
|
|
|
495
|
+
/** Structural subset of Bun's Subprocess that the ssh paths consume (a Pick,
|
|
496
|
+
* not a hand-written interface — the ADR 0021 guard scans for CLI-shaped
|
|
497
|
+
* member declarations). Direct spawns return the Subprocess itself; the win32
|
|
498
|
+
* relay below returns a delegating wrapper because its kill semantics differ
|
|
499
|
+
* (tree-kill). */
|
|
500
|
+
type HostSshProcess = Pick<ReturnType<typeof Bun.spawn>, "pid" | "exitCode" | "stdout" | "stderr" | "kill">;
|
|
501
|
+
|
|
502
|
+
/** Win32 Defender tax (2026-09-22 finding, ukp_remote TODO
|
|
503
|
+
* 20260922-win32-ssh-spawn-tax): the DIRECT bun-to-ssh.exe child edge is held
|
|
504
|
+
* ~10s by behavioral inspection — measured across stdio modes, windowsHide,
|
|
505
|
+
* and absolute paths, while bash-to-ssh is 0.16s and ANY intermediary parent
|
|
506
|
+
* (cmd, node, powershell) escapes to 0.5-0.8s. Every ssh spawn in this file
|
|
507
|
+
* pays it once, so on win32 real-ssh spawns go through a powershell.exe
|
|
508
|
+
* relay. -EncodedCommand carries the script as one base64 token (no quoting
|
|
509
|
+
* stress from Bun), and the script splats a PS array so the pinned wake
|
|
510
|
+
* command's load-bearing quotes reach ssh byte-identical (verified E2E: door
|
|
511
|
+
* READY in ~1.4s). Test injection (explicit sshCommand) bypasses the relay —
|
|
512
|
+
* the fakes must not be wrapped. Killing the relay orphans the ssh child
|
|
513
|
+
* (tunnel + door outlive the killed handle — verified), so the wrapped kill
|
|
514
|
+
* reaps the tree via taskkill /T /F. */
|
|
515
|
+
function spawnHostSsh(options: { sshCommand?: readonly string[] }, argv: readonly string[]): HostSshProcess {
|
|
516
|
+
if (process.platform !== "win32" || options.sshCommand !== undefined) {
|
|
517
|
+
return Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
518
|
+
}
|
|
519
|
+
const script = toolRelayScript(argv);
|
|
520
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
521
|
+
const proc = Bun.spawn(["powershell", "-NoProfile", "-EncodedCommand", encoded], {
|
|
522
|
+
stdout: "pipe",
|
|
523
|
+
stderr: "pipe",
|
|
524
|
+
stdin: "ignore",
|
|
525
|
+
});
|
|
526
|
+
const { pid, stdout, stderr } = proc;
|
|
527
|
+
return {
|
|
528
|
+
pid,
|
|
529
|
+
stdout,
|
|
530
|
+
stderr,
|
|
531
|
+
get exitCode() {
|
|
532
|
+
return proc.exitCode;
|
|
533
|
+
},
|
|
534
|
+
kill() {
|
|
535
|
+
// taskkill also carries the Defender tax when spawned directly from
|
|
536
|
+
// bun (measured 10.1s direct vs 105ms through cmd — same finding as
|
|
537
|
+
// the ssh/rg edges); the PID is digits-only, so the cmd payload needs
|
|
538
|
+
// no quoting. /T /F takes the relay AND its ssh child down together
|
|
539
|
+
// (killing the relay alone orphans the tunnel).
|
|
540
|
+
Bun.spawnSync(["cmd", "/d", "/c", `taskkill /PID ${pid} /T /F`], {
|
|
541
|
+
stdout: "ignore",
|
|
542
|
+
stderr: "ignore",
|
|
543
|
+
stdin: "ignore",
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
429
549
|
/** One cheap, language-independent roundtrip to learn the host shell family
|
|
430
550
|
* BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
|
|
431
551
|
* expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
|
|
@@ -436,21 +556,32 @@ function persistShellFamily(cachePath: string | undefined, target: string, form:
|
|
|
436
556
|
* build LOSES a quoted remote command under a pty (an interactive cmd
|
|
437
557
|
* swallows it and waits on stdin forever — the no-pty path executes it
|
|
438
558
|
* correctly). Defaults to POSIX (the common server shape) when the probe
|
|
439
|
-
* errors or times out; the ladder remains as the backstop.
|
|
559
|
+
* errors or times out; the ladder remains as the backstop.
|
|
560
|
+
*
|
|
561
|
+
* The probe doubles as the first failure classifier (ADR-REM-008 §3): its
|
|
562
|
+
* merged output — the `%OS%` answer on stdout, ssh's own diagnostics on
|
|
563
|
+
* stderr — feeds `classifyWakeFailure`, so instant verdicts (refused / auth
|
|
564
|
+
* / hostkey / resolve failures) classify here and the ladder is never
|
|
565
|
+
* entered for them. */
|
|
440
566
|
async function probeHostShellFamily(
|
|
441
567
|
options: { sshCommand?: readonly string[] },
|
|
442
568
|
target: string,
|
|
443
|
-
): Promise<WakeShellForm> {
|
|
444
|
-
const proc =
|
|
569
|
+
): Promise<{ form: WakeShellForm; diagnostics: string }> {
|
|
570
|
+
const proc = spawnHostSsh(
|
|
571
|
+
options,
|
|
445
572
|
[...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", ...connectTimeoutOptions(), target, "echo %OS%"],
|
|
446
|
-
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
|
|
447
573
|
);
|
|
448
|
-
|
|
449
|
-
|
|
574
|
+
// One merged read of BOTH streams, raced against the 5s cap: resolves when
|
|
575
|
+
// the process dies (a healthy probe answers and exits), else returns empty
|
|
576
|
+
// at the cap and the default form stands — a slow host is never punished.
|
|
577
|
+
// A generous keep (review P3): the tail-trimmed merge must not push the
|
|
578
|
+
// stdout %OS% answer out the front even behind unusual stderr volume.
|
|
579
|
+
const text = await Promise.race([
|
|
580
|
+
drainStderrTail([proc.stdout, proc.stderr], 8192),
|
|
450
581
|
Bun.sleep(5_000).then(() => ""),
|
|
451
582
|
]);
|
|
452
583
|
proc.kill();
|
|
453
|
-
return /Windows_NT/i.test(
|
|
584
|
+
return { form: /Windows_NT/i.test(text) ? "cmd" : "posix", diagnostics: text };
|
|
454
585
|
}
|
|
455
586
|
|
|
456
587
|
/** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
|
|
@@ -463,7 +594,7 @@ async function probeHostShellFamily(
|
|
|
463
594
|
async function probeWakeReady(
|
|
464
595
|
base: string,
|
|
465
596
|
timeoutMs: number,
|
|
466
|
-
proc:
|
|
597
|
+
proc: HostSshProcess,
|
|
467
598
|
): Promise<boolean> {
|
|
468
599
|
const deadline = Date.now() + timeoutMs;
|
|
469
600
|
for (;;) {
|
|
@@ -499,9 +630,12 @@ async function probeWakeReady(
|
|
|
499
630
|
* so diagnostics can arrive on either stream — both are drained and
|
|
500
631
|
* concatenated. Resolves when the streams end (i.e. after the process is
|
|
501
632
|
* dead) — only await once the proc has been killed or has exited. */
|
|
502
|
-
function drainStderrTail(
|
|
633
|
+
function drainStderrTail(
|
|
634
|
+
streams: readonly (ReadableStream<Uint8Array> | number | undefined)[],
|
|
635
|
+
keep = 2000,
|
|
636
|
+
): Promise<string> {
|
|
503
637
|
return Promise.all(
|
|
504
|
-
streams.map((stream) => new Response(stream).text().catch(() => "")),
|
|
638
|
+
streams.map((stream) => (typeof stream === "object" && stream !== null ? new Response(stream).text().catch(() => "") : "")),
|
|
505
639
|
).then((texts) => {
|
|
506
640
|
const text = texts.join("").trim();
|
|
507
641
|
return text.length > keep ? `...${text.slice(-keep)}` : text;
|
|
@@ -525,7 +659,7 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
|
|
|
525
659
|
async function openSshTunnel(
|
|
526
660
|
parts: { user?: string; host: string },
|
|
527
661
|
options: { sshCommand?: readonly string[]; registryPath?: string },
|
|
528
|
-
): Promise<{ proc:
|
|
662
|
+
): Promise<{ proc: HostSshProcess; base: string }> {
|
|
529
663
|
const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
|
|
530
664
|
// Tier 1 first: a detached mux master per origin (no-op when one already
|
|
531
665
|
// holds the socket); the wake client below rides it when possible and
|
|
@@ -539,8 +673,25 @@ async function openSshTunnel(
|
|
|
539
673
|
// Shell form (2026-09-20): probed up front via `echo %OS%` (language-
|
|
540
674
|
// independent — see probeHostShellFamily), POSIX by default; the wrong-
|
|
541
675
|
// shell ladder below remains the backstop for a lying or timed-out probe.
|
|
542
|
-
|
|
543
|
-
|
|
676
|
+
// Cold cache: the probe doubles as the first failure classifier
|
|
677
|
+
// (ADR-REM-008 §3) — its instant verdicts (refused / auth / hostkey /
|
|
678
|
+
// resolve failures) reject the origin here, before any ladder attempt;
|
|
679
|
+
// black-hole timeouts defer to the ladder's full ConnectTimeout budget.
|
|
680
|
+
// Warm cache (the probe is skipped): the ladder's first attempt carries
|
|
681
|
+
// the same classifier, so the classified verdicts cost at most one
|
|
682
|
+
// ConnectTimeout either way.
|
|
683
|
+
let form: WakeShellForm;
|
|
684
|
+
const cachedForm = readCachedShellFamily(shellFamilyCachePath(options), target);
|
|
685
|
+
if (cachedForm !== undefined) {
|
|
686
|
+
form = cachedForm;
|
|
687
|
+
} else {
|
|
688
|
+
const probed = await probeHostShellFamily(options, target);
|
|
689
|
+
const probeClass = classifyWakeFailure(probed.diagnostics);
|
|
690
|
+
if (probeClass !== undefined && !wakeFailureDefersToLadder(probeClass, probed.diagnostics)) {
|
|
691
|
+
throw new RemoteTransportError(wakeFailureMessage(probeClass, target, probed.diagnostics));
|
|
692
|
+
}
|
|
693
|
+
form = probed.form;
|
|
694
|
+
}
|
|
544
695
|
const attempts = 3;
|
|
545
696
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
546
697
|
const localPort = await freePort();
|
|
@@ -564,7 +715,7 @@ async function openSshTunnel(
|
|
|
564
715
|
target,
|
|
565
716
|
wakeDoorCommand(remotePort, form),
|
|
566
717
|
];
|
|
567
|
-
const proc =
|
|
718
|
+
const proc = spawnHostSsh(options, argv);
|
|
568
719
|
const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
|
|
569
720
|
if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
|
|
570
721
|
// Ground truth for the shell-family cache: the form that actually woke
|
|
@@ -579,6 +730,15 @@ async function openSshTunnel(
|
|
|
579
730
|
// fast crash as a slow timeout.
|
|
580
731
|
const diagnostics = (await stderrTail).trim();
|
|
581
732
|
const exited = proc.exitCode !== null;
|
|
733
|
+
// Deterministic connect/auth verdicts never consume the ladder's
|
|
734
|
+
// remaining attempts (ADR-REM-008 §2): re-running against the same host
|
|
735
|
+
// reproduces them, so attempt 1's verdict IS the origin's verdict. The
|
|
736
|
+
// ladder stays for what it was built for — port-collision binds and
|
|
737
|
+
// not-ready timeouts.
|
|
738
|
+
const failureClass = exited ? classifyWakeFailure(diagnostics) : undefined;
|
|
739
|
+
if (failureClass !== undefined) {
|
|
740
|
+
throw new RemoteTransportError(wakeFailureMessage(failureClass, target, diagnostics));
|
|
741
|
+
}
|
|
582
742
|
if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
|
|
583
743
|
muxDisabled = true;
|
|
584
744
|
}
|
|
@@ -604,7 +764,7 @@ async function openSshTunnel(
|
|
|
604
764
|
throw new RemoteTransportError(
|
|
605
765
|
exited
|
|
606
766
|
? `waking the ukp door on '${target}' failed: ${diagnostics}`
|
|
607
|
-
:
|
|
767
|
+
: wakeNotReadyMessage(target, WAKE_READY_TIMEOUT_MS),
|
|
608
768
|
);
|
|
609
769
|
}
|
|
610
770
|
}
|
|
@@ -662,7 +822,7 @@ export interface RemoteTransportPool {
|
|
|
662
822
|
export function createTransportPool(
|
|
663
823
|
options: { sshCommand?: readonly string[]; registryPath?: string } = {},
|
|
664
824
|
): RemoteTransportPool {
|
|
665
|
-
const tunnels = new Map<string, Promise<{ proc:
|
|
825
|
+
const tunnels = new Map<string, Promise<{ proc: HostSshProcess; base: string }>>();
|
|
666
826
|
return {
|
|
667
827
|
async acquire(binding) {
|
|
668
828
|
if (binding.kind !== "remote" || binding.url === undefined) {
|
|
@@ -1168,10 +1328,23 @@ function remoteRgCounts(raw: unknown): RgCountEntry[] | undefined {
|
|
|
1168
1328
|
});
|
|
1169
1329
|
}
|
|
1170
1330
|
|
|
1331
|
+
function remoteRgFiles(raw: unknown): RgFileEntry[] | undefined {
|
|
1332
|
+
if (!Array.isArray(raw)) return undefined;
|
|
1333
|
+
return raw.flatMap((entry): RgFileEntry[] => {
|
|
1334
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
1335
|
+
const file = entry as Record<string, unknown>;
|
|
1336
|
+
if (typeof file.path !== "string") return [];
|
|
1337
|
+
return [{ path: file.path, ...(typeof file.ukp_uri === "string" ? { ukp_uri: file.ukp_uri } : {}) }];
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1171
1341
|
const RG_WIRE_STATUSES = new Set(["succeeded", "no_matches", "skipped", "failed", "interrupted", "cancelled"]);
|
|
1172
1342
|
|
|
1173
1343
|
/** GET /v1/rg — single-endpoint outcome in the local RgEndpointOutcome
|
|
1174
|
-
* vocabulary (provider is rg's external tier regardless of transport).
|
|
1344
|
+
* vocabulary (provider is rg's external tier regardless of transport).
|
|
1345
|
+
* Files mode (ADR-RG-005) sends files=1 and omits query — an older serve
|
|
1346
|
+
* without files support answers its query-required usage error, which
|
|
1347
|
+
* lands here as a failed outcome carrying the server's message. */
|
|
1175
1348
|
export async function remoteRg(
|
|
1176
1349
|
binding: RegistryBinding,
|
|
1177
1350
|
transport: RemoteTransportHandle,
|
|
@@ -1179,16 +1352,19 @@ export async function remoteRg(
|
|
|
1179
1352
|
params: {
|
|
1180
1353
|
query: string;
|
|
1181
1354
|
limit: number;
|
|
1182
|
-
|
|
1355
|
+
globs?: readonly string[];
|
|
1183
1356
|
type?: string;
|
|
1184
1357
|
ignoreCase?: boolean;
|
|
1185
1358
|
count?: boolean;
|
|
1359
|
+
files?: boolean;
|
|
1186
1360
|
passthrough: readonly string[];
|
|
1187
1361
|
},
|
|
1188
1362
|
): Promise<RemoteRgExecution> {
|
|
1189
1363
|
const base = transport.base;
|
|
1190
|
-
const search = new URLSearchParams({
|
|
1191
|
-
if (params.
|
|
1364
|
+
const search = new URLSearchParams({ limit: String(params.limit) });
|
|
1365
|
+
if (params.files === true) search.set("files", "1");
|
|
1366
|
+
else search.set("query", params.query);
|
|
1367
|
+
for (const glob of params.globs ?? []) search.append("glob", glob);
|
|
1192
1368
|
if (params.type !== undefined) search.set("type", params.type);
|
|
1193
1369
|
if (params.ignoreCase === true) search.set("i", "1");
|
|
1194
1370
|
if (params.count === true) search.set("count", "1");
|
|
@@ -1223,6 +1399,9 @@ export async function remoteRg(
|
|
|
1223
1399
|
match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
|
|
1224
1400
|
);
|
|
1225
1401
|
const counts = remoteRgCounts(entry.counts);
|
|
1402
|
+
const files = remoteRgFiles(entry.files)?.map((file) =>
|
|
1403
|
+
file.ukp_uri !== undefined ? { ...file, ukp_uri: reanchorRemoteUkpUri(file.ukp_uri, binding) } : file,
|
|
1404
|
+
);
|
|
1226
1405
|
const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
|
|
1227
1406
|
return {
|
|
1228
1407
|
outcome: {
|
|
@@ -1232,6 +1411,7 @@ export async function remoteRg(
|
|
|
1232
1411
|
...(typeof entry.message === "string" ? { message: entry.message } : {}),
|
|
1233
1412
|
...(matches !== undefined ? { matches } : {}),
|
|
1234
1413
|
...(counts !== undefined ? { counts } : {}),
|
|
1414
|
+
...(files !== undefined ? { files } : {}),
|
|
1235
1415
|
...(entry.truncated === true ? { truncated: true } : {}),
|
|
1236
1416
|
},
|
|
1237
1417
|
warnings,
|
package/src/capabilities/rg.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveExternalToolCapability, EXTERNAL_PROVIDER } from "../config/exte
|
|
|
6
6
|
import { localPathOf, readRegistry } from "../registry.ts";
|
|
7
7
|
import { resolveScope } from "../scope.ts";
|
|
8
8
|
import { isInsideRealRoot } from "../path-safety.ts";
|
|
9
|
+
import { toolRelayArgv, TOOL_NOT_FOUND_EXIT } from "../spawn-relay.ts";
|
|
9
10
|
|
|
10
11
|
// rg capability (ADR-RG-001..004): an independent atomic
|
|
11
12
|
// capability parallel to `search` — base lexical search over the endpoint's
|
|
@@ -15,6 +16,8 @@ import { isInsideRealRoot } from "../path-safety.ts";
|
|
|
15
16
|
// Structured outcome per ADR 0021 — never rendered text, never exit codes.
|
|
16
17
|
|
|
17
18
|
export interface RgRequest {
|
|
19
|
+
/** Search pattern (match/count modes). Empty only in files mode, where
|
|
20
|
+
* the parse layer guarantees it was omitted (ADR-RG-005 mutual exclusion). */
|
|
18
21
|
query: string;
|
|
19
22
|
limit: number;
|
|
20
23
|
}
|
|
@@ -22,10 +25,15 @@ export interface RgRequest {
|
|
|
22
25
|
export interface RgOptions {
|
|
23
26
|
explicitEndpoints?: string[];
|
|
24
27
|
global: boolean;
|
|
25
|
-
glob
|
|
28
|
+
/** Ordered glob filters (repeatable top-level flag, ADR-RG-005: rg glob
|
|
29
|
+
* grammar lets a later glob negate with `!` — include+exclude combos). */
|
|
30
|
+
globs?: readonly string[];
|
|
26
31
|
type?: string;
|
|
27
32
|
ignoreCase?: boolean;
|
|
28
33
|
count?: boolean;
|
|
34
|
+
/** Files enumeration mode (ADR-RG-005): true tree walk via `rg --files` —
|
|
35
|
+
* no content matching, so empty and binary files are visible. */
|
|
36
|
+
files?: boolean;
|
|
29
37
|
/** Validated passthrough args (allowlist + scope protection, ADR-RG-002). */
|
|
30
38
|
passthrough: readonly string[];
|
|
31
39
|
/** Surface concern (propose precedent): the capability never consumes it —
|
|
@@ -72,6 +80,14 @@ export interface RgCountEntry {
|
|
|
72
80
|
count: number;
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
/** One enumerated file (files mode, ADR-RG-005): endpoint-relative path plus
|
|
84
|
+
* the durable slot key under the same containment-gated emission rule as
|
|
85
|
+
* matches (ADR 0019 stance). */
|
|
86
|
+
export interface RgFileEntry {
|
|
87
|
+
path: string;
|
|
88
|
+
ukp_uri?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
export interface RgEndpointOutcome {
|
|
76
92
|
name: string;
|
|
77
93
|
provider: typeof EXTERNAL_PROVIDER;
|
|
@@ -91,13 +107,20 @@ export interface RgEndpointOutcome {
|
|
|
91
107
|
matches?: RgMatch[];
|
|
92
108
|
/** Count mode results (`--count`). */
|
|
93
109
|
counts?: RgCountEntry[];
|
|
110
|
+
/** Files mode results (`--files`, sorted then capped). */
|
|
111
|
+
files?: RgFileEntry[];
|
|
94
112
|
/** True when matches were truncated at the request limit. */
|
|
95
113
|
truncated?: boolean;
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
/** The run's output mode — drives tool argv, intake, and both presentation
|
|
117
|
+
* stages (envelope count_mode/files_mode and the human render branches). */
|
|
118
|
+
export type RgMode = "match" | "count" | "files";
|
|
119
|
+
|
|
98
120
|
export interface RgResult {
|
|
99
121
|
query: string;
|
|
100
122
|
limit: number;
|
|
123
|
+
mode: RgMode;
|
|
101
124
|
endpoints: RgEndpointOutcome[];
|
|
102
125
|
warnings: string[];
|
|
103
126
|
aggregate: RgAggregateStatus;
|
|
@@ -118,6 +141,10 @@ export class RgPlanningError extends Error {
|
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
export const RG_DEFAULT_LIMIT = 50;
|
|
144
|
+
/** Files-mode request limit default (ADR-RG-005): enumeration units are a
|
|
145
|
+
* single path line vs the 3-4 line match unit, so the same consumer-comfort
|
|
146
|
+
* calibration that set 50 for matches lands on 500 here. */
|
|
147
|
+
export const RG_FILES_DEFAULT_LIMIT = 500;
|
|
121
148
|
export const RG_MAX_LIMIT = 1000;
|
|
122
149
|
|
|
123
150
|
export function defaultRgCommand(): readonly string[] {
|
|
@@ -125,10 +152,23 @@ export function defaultRgCommand(): readonly string[] {
|
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
/** Base-tool availability probe (diagnose / provider resolver): true when a
|
|
128
|
-
* ripgrep executable starts.
|
|
155
|
+
* ripgrep executable starts. Memoized per process — PATH does not change
|
|
156
|
+
* mid-process, and server startup (the door's ServeInfo carries this field)
|
|
157
|
+
* must not pay the probe again per start; the win32 relay costs ~0.8s where
|
|
158
|
+
* the direct spawn was Defender-taxed ~5s (2026-09-22 finding, see
|
|
159
|
+
* spawn-relay.ts). The relay's not-found guard exits TOOL_NOT_FOUND_EXIT,
|
|
160
|
+
* which no real ripgrep invocation produces (rg exits 0/1/2 only). */
|
|
161
|
+
let rgAvailabilityMemo: boolean | undefined;
|
|
162
|
+
|
|
129
163
|
export function rgExecutableAvailable(): boolean {
|
|
130
|
-
|
|
131
|
-
|
|
164
|
+
if (rgAvailabilityMemo === undefined) {
|
|
165
|
+
const probeArgv = process.platform === "win32"
|
|
166
|
+
? toolRelayArgv(["rg", "--version"], { notFoundGuard: true })
|
|
167
|
+
: ["rg", "--version"];
|
|
168
|
+
const result = spawnSync(probeArgv[0]!, probeArgv.slice(1), { encoding: "utf8", windowsHide: true, timeout: 5_000 });
|
|
169
|
+
rgAvailabilityMemo = !result.error && result.status !== TOOL_NOT_FOUND_EXIT;
|
|
170
|
+
}
|
|
171
|
+
return rgAvailabilityMemo;
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
export function rgTimeoutMs(): number {
|
|
@@ -142,8 +182,9 @@ export function rgTimeoutMs(): number {
|
|
|
142
182
|
// Passthrough validation (ADR-RG-002): allowlist + scope protection.
|
|
143
183
|
// ---------------------------------------------------------------------------
|
|
144
184
|
|
|
145
|
-
/** Value flags consume the next token (or an attached value: `-C2`).
|
|
146
|
-
|
|
185
|
+
/** Value flags consume the next token (or an attached value: `-C2`). `--iglob`
|
|
186
|
+
* joined with ADR-RG-005 (case-insensitive glob variant, probe friction 2/8). */
|
|
187
|
+
const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "--iglob", "-t", "--type", "--max-filesize"]);
|
|
147
188
|
const PASSTHROUGH_BOOL_FLAGS = new Set(["-i", "--ignore-case", "-S", "--smart-case", "-s", "--case-sensitive", "-w", "--word-regexp", "-F", "--fixed-strings", "-v", "--invert-match", "-U", "--multiline", "--no-ignore", "--hidden", "--no-messages", "--column", "--no-heading"]);
|
|
148
189
|
|
|
149
190
|
/** Validates passthrough args: allowlist membership + scope protection.
|
|
@@ -234,13 +275,15 @@ function toUkpUri(endpointName: string, path: string): string {
|
|
|
234
275
|
* provider-location mapping. A miss silently drops the `ukp_uri` field
|
|
235
276
|
* (the match and its `path` stay): rg output is tool output, not user
|
|
236
277
|
* error. Output-side filtering is the second line of defense; the
|
|
237
|
-
* passthrough allowlist (no path operands) is the first.
|
|
238
|
-
|
|
278
|
+
* passthrough allowlist (no path operands) is the first. The folder's
|
|
279
|
+
* realpath is passed in pre-resolved (per endpoint, not per entry — review
|
|
280
|
+
* 2026-09-22: per-file resolution paid 2 realpaths/file on exactly the
|
|
281
|
+
* win32 Defender-taxed path); an unresolvable folder emits nothing. */
|
|
282
|
+
function ukpUriIfInside(folderReal: string | undefined, endpointFolder: string, endpointName: string, path: string): string | undefined {
|
|
283
|
+
if (folderReal === undefined) return undefined;
|
|
239
284
|
const absolute = resolve(endpointFolder, ...path.replace(/\\/g, "/").split("/"));
|
|
240
|
-
let folderReal: string;
|
|
241
285
|
let targetReal: string;
|
|
242
286
|
try {
|
|
243
|
-
folderReal = realpathSync(endpointFolder);
|
|
244
287
|
targetReal = realpathSync(absolute);
|
|
245
288
|
} catch {
|
|
246
289
|
return undefined;
|
|
@@ -259,6 +302,16 @@ interface RgJsonEvent {
|
|
|
259
302
|
}
|
|
260
303
|
|
|
261
304
|
function rgToolArgs(parsed: ParsedRg): string[] {
|
|
305
|
+
if (parsed.options.files) {
|
|
306
|
+
// Files mode (ADR-RG-005): true tree walk — rg lists paths without
|
|
307
|
+
// reading content, so empty and binary files are visible. No `-e`:
|
|
308
|
+
// the parse layer guarantees no pattern rides along.
|
|
309
|
+
const args: string[] = ["--files", "--no-messages"];
|
|
310
|
+
for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
|
|
311
|
+
if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
|
|
312
|
+
args.push(...parsed.options.passthrough);
|
|
313
|
+
return args;
|
|
314
|
+
}
|
|
262
315
|
const args: string[] = ["--no-heading", "--no-messages"];
|
|
263
316
|
if (parsed.options.count) {
|
|
264
317
|
args.push("--count");
|
|
@@ -266,7 +319,7 @@ function rgToolArgs(parsed: ParsedRg): string[] {
|
|
|
266
319
|
args.push("--json");
|
|
267
320
|
}
|
|
268
321
|
if (parsed.options.ignoreCase) args.push("-i");
|
|
269
|
-
|
|
322
|
+
for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
|
|
270
323
|
if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
|
|
271
324
|
args.push(...parsed.options.passthrough);
|
|
272
325
|
args.push("-e", parsed.request.query);
|
|
@@ -280,6 +333,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
280
333
|
validateRgPassthrough(parsed.options.passthrough);
|
|
281
334
|
const { plan, warnings } = planRg(parsed, context);
|
|
282
335
|
const command = context.rgCommand ?? defaultRgCommand();
|
|
336
|
+
const mode: RgMode = parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match";
|
|
283
337
|
const endpoints: RgEndpointOutcome[] = [];
|
|
284
338
|
let succeeded = false;
|
|
285
339
|
let failed = false;
|
|
@@ -293,13 +347,36 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
293
347
|
continue;
|
|
294
348
|
}
|
|
295
349
|
|
|
350
|
+
// Lazy once-per-endpoint folder realpath for the uri emission rule: the
|
|
351
|
+
// folder cannot change mid-run, and a skip/fail path never pays for it.
|
|
352
|
+
// `null` marks "not attempted yet" vs `undefined` = "attempted, failed"
|
|
353
|
+
// (a failed resolution emits no uris without re-attempting per entry).
|
|
354
|
+
let endpointFolderReal: string | undefined | null = null;
|
|
355
|
+
const folderRealpath = (): string | undefined => {
|
|
356
|
+
if (endpointFolderReal === null) {
|
|
357
|
+
try {
|
|
358
|
+
endpointFolderReal = realpathSync(endpoint.folder);
|
|
359
|
+
} catch {
|
|
360
|
+
endpointFolderReal = undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return endpointFolderReal;
|
|
364
|
+
};
|
|
365
|
+
|
|
296
366
|
// Explicit search root: with no path operand rg searches stdin instead
|
|
297
367
|
// of the cwd whenever stdin is not a tty (ISSUE-013 root cause,
|
|
298
368
|
// confirmed on the ali repro host: spawned rg read an empty pipe and
|
|
299
369
|
// exited 1 with zero traversal; tty and /dev/null stdin walk the cwd).
|
|
300
370
|
// "." keeps rg's rendered paths endpoint-relative; the "./" prefix is
|
|
301
|
-
// stripped at both intakes below.
|
|
302
|
-
|
|
371
|
+
// stripped at both intakes below. Win32: the real-tool invocation routes
|
|
372
|
+
// through the spawn relay (Defender taxes the direct bun-to-rg.exe edge
|
|
373
|
+
// ~5s — 2026-09-22 finding, see spawn-relay.ts); the injected test
|
|
374
|
+
// command is never wrapped, and the relay's not-found guard maps a
|
|
375
|
+
// missing rg onto the same skip path the spawn error always took.
|
|
376
|
+
const runArgv = process.platform === "win32" && context.rgCommand === undefined
|
|
377
|
+
? toolRelayArgv([...command, ...rgToolArgs(parsed), "."], { notFoundGuard: true })
|
|
378
|
+
: [command[0]!, ...command.slice(1), ...rgToolArgs(parsed), "."];
|
|
379
|
+
const result = spawnSync(runArgv[0]!, runArgv.slice(1), {
|
|
303
380
|
cwd: endpoint.folder,
|
|
304
381
|
encoding: "utf8",
|
|
305
382
|
windowsHide: true,
|
|
@@ -307,7 +384,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
307
384
|
timeout: rgTimeoutMs(),
|
|
308
385
|
});
|
|
309
386
|
|
|
310
|
-
if (result.error) {
|
|
387
|
+
if (result.error || result.status === TOOL_NOT_FOUND_EXIT) {
|
|
311
388
|
// ADR-RG-003/R-7: a missing base tool is a degradation, not a fault —
|
|
312
389
|
// skipped + warning, the run continues with other endpoints.
|
|
313
390
|
warnings.push(`endpoint '${endpoint.name}' rg unavailable: rg executable is not available`);
|
|
@@ -340,6 +417,42 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
340
417
|
continue;
|
|
341
418
|
}
|
|
342
419
|
|
|
420
|
+
if (mode === "files") {
|
|
421
|
+
// Plain path-per-line intake. The full list is sorted BEFORE the
|
|
422
|
+
// client-side cap so truncation yields a deterministic window (the
|
|
423
|
+
// first N paths in lexicographic order, ADR-RG-005) rather than an
|
|
424
|
+
// arbitrary subset of rg's parallel-walk order.
|
|
425
|
+
const paths = (result.stdout ?? "")
|
|
426
|
+
.split(/\r?\n/)
|
|
427
|
+
.filter((line) => line.length > 0)
|
|
428
|
+
.map((line) => line.replace(/\\/g, "/").replace(/^\.\//, ""))
|
|
429
|
+
.sort();
|
|
430
|
+
const files: RgFileEntry[] = [];
|
|
431
|
+
let truncated = false;
|
|
432
|
+
for (const path of paths) {
|
|
433
|
+
if (files.length >= parsed.request.limit) {
|
|
434
|
+
truncated = true;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
const entry: RgFileEntry = { path };
|
|
438
|
+
const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, path);
|
|
439
|
+
if (uri !== undefined) entry.ukp_uri = uri;
|
|
440
|
+
files.push(entry);
|
|
441
|
+
}
|
|
442
|
+
if (files.length === 0 && !truncated) {
|
|
443
|
+
// An empty enumeration (empty folder or everything filtered out) is
|
|
444
|
+
// a successful result — same D-036 stance as a no-match search.
|
|
445
|
+
succeeded = true;
|
|
446
|
+
outcome.status = "no_matches";
|
|
447
|
+
} else {
|
|
448
|
+
succeeded = true;
|
|
449
|
+
outcome.status = "succeeded";
|
|
450
|
+
outcome.files = files;
|
|
451
|
+
if (truncated) outcome.truncated = true;
|
|
452
|
+
}
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
343
456
|
if (parsed.options.count) {
|
|
344
457
|
const counts: RgCountEntry[] = [];
|
|
345
458
|
for (const line of (result.stdout ?? "").split(/\r?\n/)) {
|
|
@@ -386,7 +499,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
386
499
|
const firstLine = text.split(/\r?\n/)[0] ?? "";
|
|
387
500
|
if (firstLine.length > 0) entry.text = firstLine.length > 240 ? `${firstLine.slice(0, 240)}...` : firstLine;
|
|
388
501
|
}
|
|
389
|
-
const uri = ukpUriIfInside(endpoint.folder, endpoint.name, entry.path);
|
|
502
|
+
const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, entry.path);
|
|
390
503
|
if (uri !== undefined) entry.ukp_uri = uri;
|
|
391
504
|
matches.push(entry);
|
|
392
505
|
}
|
|
@@ -406,6 +519,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
406
519
|
return {
|
|
407
520
|
query: parsed.request.query,
|
|
408
521
|
limit: parsed.request.limit,
|
|
522
|
+
mode,
|
|
409
523
|
endpoints,
|
|
410
524
|
warnings,
|
|
411
525
|
aggregate: interrupted
|
|
@@ -426,35 +540,41 @@ export interface RgEnvelope {
|
|
|
426
540
|
schema: "ukp.rg.v1";
|
|
427
541
|
command: "rg";
|
|
428
542
|
capability: "rg";
|
|
429
|
-
|
|
543
|
+
/** Omitted in files mode (ADR-RG-005): there is no pattern to echo. */
|
|
544
|
+
query?: string;
|
|
430
545
|
limit: number;
|
|
431
546
|
count_mode: boolean;
|
|
547
|
+
files_mode: boolean;
|
|
432
548
|
endpoints: Array<{
|
|
433
549
|
name: string;
|
|
434
550
|
provider: string;
|
|
435
551
|
status: string;
|
|
436
552
|
match_count?: number;
|
|
553
|
+
file_count?: number;
|
|
437
554
|
truncated?: boolean;
|
|
438
555
|
message?: string;
|
|
439
556
|
matches?: RgMatch[];
|
|
440
557
|
counts?: RgCountEntry[];
|
|
558
|
+
files?: RgFileEntry[];
|
|
441
559
|
}>;
|
|
442
560
|
warnings: string[];
|
|
443
561
|
}
|
|
444
562
|
|
|
445
|
-
export function projectRgEnvelope(result: RgResult
|
|
563
|
+
export function projectRgEnvelope(result: RgResult): RgEnvelope {
|
|
446
564
|
return {
|
|
447
565
|
schema: "ukp.rg.v1",
|
|
448
566
|
command: "rg",
|
|
449
567
|
capability: "rg",
|
|
450
|
-
query: result.query,
|
|
568
|
+
...(result.mode === "files" ? {} : { query: result.query }),
|
|
451
569
|
limit: result.limit,
|
|
452
|
-
count_mode:
|
|
570
|
+
count_mode: result.mode === "count",
|
|
571
|
+
files_mode: result.mode === "files",
|
|
453
572
|
endpoints: result.endpoints.map((endpoint) => ({
|
|
454
573
|
name: endpoint.name,
|
|
455
574
|
provider: endpoint.provider,
|
|
456
575
|
status: endpoint.status ?? "failed",
|
|
457
576
|
...(endpoint.matches !== undefined ? { match_count: endpoint.matches.length, matches: endpoint.matches } : {}),
|
|
577
|
+
...(endpoint.files !== undefined ? { file_count: endpoint.files.length, files: endpoint.files } : {}),
|
|
458
578
|
...(endpoint.counts !== undefined ? { counts: endpoint.counts } : {}),
|
|
459
579
|
...(endpoint.truncated !== undefined ? { truncated: endpoint.truncated } : {}),
|
|
460
580
|
...(endpoint.message !== undefined ? { message: endpoint.message } : {}),
|
|
@@ -480,7 +600,14 @@ export function renderRgHuman(result: RgResult): RgHumanView {
|
|
|
480
600
|
continue;
|
|
481
601
|
}
|
|
482
602
|
if (endpoint.status === "no_matches") {
|
|
483
|
-
lines.push("(no matches)");
|
|
603
|
+
lines.push(result.mode === "files" ? "(no files)" : "(no matches)");
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (endpoint.files !== undefined) {
|
|
607
|
+
// Exhibit form (probe 20260922, zero misreads): one line per file,
|
|
608
|
+
// the durable uri when containment allows it, the raw path otherwise.
|
|
609
|
+
for (const file of endpoint.files) lines.push(file.ukp_uri ?? file.path);
|
|
610
|
+
if (endpoint.truncated) lines.push(`(truncated at the result limit ${result.limit}; raise --limit)`);
|
|
484
611
|
continue;
|
|
485
612
|
}
|
|
486
613
|
if (endpoint.counts !== undefined) {
|
package/src/cli.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { executeSearchCommand, SEARCH_SPEC } from "./commands/search.ts";
|
|
|
24
24
|
export { renderSearchHelp } from "./commands/search.ts";
|
|
25
25
|
export { renderDiagnoseHelp } from "./commands/diagnose.ts";
|
|
26
26
|
export { renderReadHelp } from "./commands/read.ts";
|
|
27
|
-
export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
|
|
27
|
+
export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRgGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
|
|
28
28
|
export { renderInitHelp, renderInitServiceHelp } from "./commands/init.ts";
|
|
29
29
|
export { renderInspectHelp } from "./commands/inspect.ts";
|
|
30
30
|
export { renderUpdateHelp } from "./commands/update.ts";
|
|
@@ -12,6 +12,8 @@ Current model:
|
|
|
12
12
|
Owner path over SSH (zero ceremony - nothing to start on the host):
|
|
13
13
|
1. Prerequisites (once): the host accepts your SSH key and has ukp installed the usual way
|
|
14
14
|
ssh <host> ukp --help
|
|
15
|
+
Peek at the door before importing: ssh <host> ukp list shows the endpoint
|
|
16
|
+
names and capabilities the host declares
|
|
15
17
|
|
|
16
18
|
2. On the consuming machine, import the door (the first call wakes it)
|
|
17
19
|
ukp register --url ssh://<host>
|
|
@@ -45,7 +47,7 @@ Use this when you deliberately want to expose only one endpoint instead of the w
|
|
|
45
47
|
ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
|
|
46
48
|
|
|
47
49
|
Name and identity:
|
|
48
|
-
- register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken).
|
|
50
|
+
- register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken). That document is public - curl it before registering to preview the endpoint names and capabilities the door declares (self-signed doors: curl -k).
|
|
49
51
|
- --endpoint <name> on register asserts the expected name; if discovery declares a different name, registration fails.
|
|
50
52
|
- A host door can import everything, --select a comma-separated subset, or --endpoint exactly one endpoint.
|
|
51
53
|
- Self-signed HTTPS certificates are pinned on first registration; compare the pinned fingerprint with the serve banner on untrusted networks.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
UKP rg quickstart
|
|
2
|
+
|
|
3
|
+
Goal: search or enumerate a Service's own files with ripgrep - no index, no declaration, real-time.
|
|
4
|
+
|
|
5
|
+
Model:
|
|
6
|
+
- rg is the lexical baseline: it scans files at call time, so it works on every registered endpoint. 'ukp search' is the indexed path and needs the Service to declare a provider.
|
|
7
|
+
- Output is ukp:// references that 'ukp read' consumes directly.
|
|
8
|
+
- Three modes: match (default), --count (per-file totals), --files (enumeration).
|
|
9
|
+
|
|
10
|
+
1. Search
|
|
11
|
+
ukp rg --endpoint <name> "pattern"
|
|
12
|
+
-i is case-insensitive; --glob narrows by name; --limit raises the cap (default 50 matches).
|
|
13
|
+
|
|
14
|
+
2. Enumerate files (no pattern)
|
|
15
|
+
ukp rg --endpoint <name> --files
|
|
16
|
+
A true tree walk, not a content match: empty and binary files are listed too.
|
|
17
|
+
Narrow by name with --glob, by kind with --type. Output is sorted; the
|
|
18
|
+
cap defaults to 500 (--limit to change).
|
|
19
|
+
|
|
20
|
+
3. Glob semantics
|
|
21
|
+
--glob follows ripgrep/gitignore rules: a glob without "/" matches the
|
|
22
|
+
file name at any depth ("*.md"); a glob containing "/" anchors to the
|
|
23
|
+
endpoint-relative path ("docs/*"). Matching is case-sensitive; pass
|
|
24
|
+
-- --iglob "<glob>" after '--' for a case-insensitive variant.
|
|
25
|
+
--glob repeats: a leading "!" excludes, so "*.md" plus "!docs/*" is
|
|
26
|
+
markdown outside docs/.
|
|
27
|
+
|
|
28
|
+
4. Visibility (every rg mode)
|
|
29
|
+
Hidden files (dot-prefixed, including the .ukp/ wiring) are skipped by
|
|
30
|
+
default. Add -- --hidden after '--' to include them; -uu also lifts
|
|
31
|
+
.gitignore rules. 'ukp read' can address hidden files directly either way.
|
|
32
|
+
|
|
33
|
+
Remember:
|
|
34
|
+
- Exact flags and the passthrough allowlist live in 'ukp rg --help'.
|
|
35
|
+
- Remote endpoints behave the same: one command drives local and door-registered endpoints alike.
|
package/src/commands/guide.ts
CHANGED
|
@@ -20,10 +20,11 @@ const GUIDE_CONTENT_URL = new URL("./guide-content/", import.meta.url);
|
|
|
20
20
|
* `ukp guide --help` render the same summaries (blank-agent sweep 2026-09-11:
|
|
21
21
|
* the inversion of root help describing guides better than guide's own help
|
|
22
22
|
* was a confirmed doc-gap). */
|
|
23
|
-
export const GUIDE_TOPICS: ReadonlyArray<readonly [
|
|
23
|
+
export const GUIDE_TOPICS: ReadonlyArray<readonly [string, string]> = [
|
|
24
24
|
["service", "first Service setup with provider-free nav/read/rg baseline"],
|
|
25
25
|
["service qmd", "provider setup for the default QMD provider"],
|
|
26
26
|
["client", "use registered Services by default from a workspace; set your default scope here"],
|
|
27
|
+
["rg", "lexical search and file enumeration: modes, glob semantics, visibility tiers"],
|
|
27
28
|
["remote", "serve and consume remote endpoints with a host door, ssh, or TLS"],
|
|
28
29
|
["propose", "submit idempotent change proposals to a Service"],
|
|
29
30
|
];
|
|
@@ -38,7 +39,7 @@ export const GUIDE_SPEC: UkpCommandSpec = {
|
|
|
38
39
|
description: "Show short operational guides.",
|
|
39
40
|
usage: "<topic> [subtopic]",
|
|
40
41
|
arguments: [
|
|
41
|
-
{ name: "topic", required: true, help: "guide topic: service | service qmd | client | remote | propose" },
|
|
42
|
+
{ name: "topic", required: true, help: "guide topic: service | service qmd | client | rg | remote | propose" },
|
|
42
43
|
{ name: "subtopic", help: "provider subtopic for a topic, e.g. 'qmd' as in 'ukp guide service qmd'" },
|
|
43
44
|
],
|
|
44
45
|
strictArguments: true,
|
|
@@ -72,17 +73,19 @@ export function executeGuideCommand(args: readonly string[]): GuideCommandResult
|
|
|
72
73
|
return { exitCode: 0, stdout: renderServiceGuide(), stderr: "" };
|
|
73
74
|
}
|
|
74
75
|
if (subtopic) {
|
|
75
|
-
throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, remote, propose`);
|
|
76
|
+
throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, rg, remote, propose`);
|
|
76
77
|
}
|
|
77
78
|
switch (topic) {
|
|
78
79
|
case "client":
|
|
79
80
|
return { exitCode: 0, stdout: renderClientGuide(), stderr: "" };
|
|
81
|
+
case "rg":
|
|
82
|
+
return { exitCode: 0, stdout: renderRgGuide(), stderr: "" };
|
|
80
83
|
case "remote":
|
|
81
84
|
return { exitCode: 0, stdout: renderRemoteGuide(), stderr: "" };
|
|
82
85
|
case "propose":
|
|
83
86
|
return { exitCode: 0, stdout: renderProposeGuide(), stderr: "" };
|
|
84
87
|
default:
|
|
85
|
-
throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, remote, propose`);
|
|
88
|
+
throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, rg, remote, propose`);
|
|
86
89
|
}
|
|
87
90
|
} catch (error) {
|
|
88
91
|
if (error instanceof HelpRequestError) {
|
|
@@ -115,6 +118,10 @@ export function renderClientGuide(): string {
|
|
|
115
118
|
return readGuideContent("client.txt");
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
export function renderRgGuide(): string {
|
|
122
|
+
return readGuideContent("rg.txt");
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
export function renderRemoteGuide(): string {
|
|
119
126
|
return readGuideContent("remote.txt");
|
|
120
127
|
}
|
package/src/commands/rg.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
projectRgEnvelope,
|
|
4
4
|
renderRgHuman,
|
|
5
5
|
RG_DEFAULT_LIMIT,
|
|
6
|
+
RG_FILES_DEFAULT_LIMIT,
|
|
6
7
|
RG_MAX_LIMIT,
|
|
7
8
|
RgPlanningError,
|
|
8
9
|
RgUsageError,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
type RgAggregateStatus,
|
|
12
13
|
type RgContext,
|
|
13
14
|
type RgEndpointOutcome,
|
|
15
|
+
type RgMode,
|
|
14
16
|
type RgResult,
|
|
15
17
|
} from "../capabilities/rg.ts";
|
|
16
18
|
import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
|
|
@@ -50,17 +52,19 @@ export const RG_SPEC: UkpCommandSpec = {
|
|
|
50
52
|
summary: "grep raw endpoint files with ripgrep (no index or declaration needed; results as ukp:// references)",
|
|
51
53
|
group: "endpoint",
|
|
52
54
|
description:
|
|
53
|
-
"Run base lexical search (ripgrep) across one or more Service endpoints
|
|
55
|
+
"Run base lexical search (ripgrep) across one or more Service endpoints, "
|
|
56
|
+
+ "or enumerate their files with --files. "
|
|
54
57
|
+ "Available on every registered endpoint by default; results are shaped into ukp:// references that 'ukp read' consumes directly. "
|
|
55
58
|
+ "For indexed/semantic search use 'ukp search'.",
|
|
56
|
-
usage: "[--endpoint <name> ... | -g] <pattern> [--limit <1-1000>] [--count] [--glob <glob>] [--type <type>] [-i] [-- <rg flags>]",
|
|
57
|
-
arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell" }],
|
|
59
|
+
usage: "[--endpoint <name> ... | -g] (<pattern> | --files) [--limit <1-1000>] [--count] [--glob <glob> ...] [--type <type>] [-i] [-- <rg flags>]",
|
|
60
|
+
arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell (omit with --files)" }],
|
|
58
61
|
options: [
|
|
59
62
|
{ flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
|
|
60
63
|
{ flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
|
|
61
|
-
{ flags: "--limit <1-1000>", help: `maximum
|
|
64
|
+
{ flags: "--limit <1-1000>", help: `maximum results per run (default: ${RG_DEFAULT_LIMIT}; ${RG_FILES_DEFAULT_LIMIT} with --files)` },
|
|
62
65
|
{ flags: "--count", help: "count mode: per-file match counts instead of matches" },
|
|
63
|
-
{ flags: "--
|
|
66
|
+
{ flags: "--files", help: "files mode: enumerate files by tree walk with no pattern (empty and binary files included; combine with --glob/--type)" },
|
|
67
|
+
{ flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\"); repeat to combine - a later glob may negate with !", multi: true },
|
|
64
68
|
{ flags: "--type <type>", help: "file type filter passed to rg (e.g. md, py)" },
|
|
65
69
|
{ flags: "-i", help: "case-insensitive search" },
|
|
66
70
|
{ flags: "--json", help: "emit the structured response envelope" },
|
|
@@ -69,7 +73,8 @@ export const RG_SPEC: UkpCommandSpec = {
|
|
|
69
73
|
"",
|
|
70
74
|
"Passthrough:",
|
|
71
75
|
" After '--', rg native flags are passed through on an allowlist",
|
|
72
|
-
" (-A/-B/-C/-m/--glob/--type/--max-filesize and common boolean
|
|
76
|
+
" (-A/-B/-C/-m/--glob/--iglob/--type/--max-filesize and common boolean",
|
|
77
|
+
" flags).",
|
|
73
78
|
" Path operands and output-changing flags (--json, -r, --pre, --config)",
|
|
74
79
|
" are rejected: the endpoint selector owns scope, UKP owns the output.",
|
|
75
80
|
"",
|
|
@@ -84,6 +89,12 @@ export const RG_SPEC: UkpCommandSpec = {
|
|
|
84
89
|
" 'ukp search' covers indexed search where the Service declares a search",
|
|
85
90
|
" capability provider.",
|
|
86
91
|
"",
|
|
92
|
+
"Visibility:",
|
|
93
|
+
" Every rg mode skips hidden files (dot-prefixed, including the .ukp/",
|
|
94
|
+
" wiring) by default; pass -- --hidden after '--' to include them",
|
|
95
|
+
" (-uu additionally lifts .gitignore rules). --files lists every",
|
|
96
|
+
" non-hidden file, including empty and binary ones.",
|
|
97
|
+
"",
|
|
87
98
|
].join("\n"),
|
|
88
99
|
};
|
|
89
100
|
|
|
@@ -92,7 +103,8 @@ interface RgCommandOptions extends Record<string, unknown> {
|
|
|
92
103
|
g?: boolean;
|
|
93
104
|
limit?: string;
|
|
94
105
|
count?: boolean;
|
|
95
|
-
|
|
106
|
+
files?: boolean;
|
|
107
|
+
glob?: string[];
|
|
96
108
|
type?: string;
|
|
97
109
|
i?: boolean;
|
|
98
110
|
json?: boolean;
|
|
@@ -102,10 +114,13 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
|
|
|
102
114
|
const parsed = parseKitArgs<RgCommandOptions>(RG_SPEC, args);
|
|
103
115
|
const explicit: string[] = [];
|
|
104
116
|
const warnings: string[] = [];
|
|
117
|
+
const files = parsed.options.files === true;
|
|
105
118
|
|
|
106
119
|
// Command-side semantic checks, in the pre-kit order: limit validation,
|
|
107
|
-
// duplicate warnings, excess positional,
|
|
108
|
-
|
|
120
|
+
// duplicate warnings, excess positional, mode conflicts, pattern rule,
|
|
121
|
+
// scope conflict. Files mode (ADR-RG-005) relaxes the pattern requirement
|
|
122
|
+
// and rejects combinations that would silently no-op.
|
|
123
|
+
let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
|
|
109
124
|
if (parsed.options.limit !== undefined) {
|
|
110
125
|
if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
|
|
111
126
|
limit = Number(parsed.options.limit);
|
|
@@ -125,20 +140,34 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
|
|
|
125
140
|
`unexpected argument '${unexpected}'; rg accepts exactly one pattern. Use '--endpoint <name>' to select an endpoint; '-g' takes no value.`,
|
|
126
141
|
);
|
|
127
142
|
}
|
|
128
|
-
if (
|
|
143
|
+
if (files && parsed.options.count === true) {
|
|
144
|
+
throw new KitUsageError("--files and --count cannot be used together (pick one output mode)");
|
|
145
|
+
}
|
|
146
|
+
if (files && parsed.options.i === true) {
|
|
147
|
+
throw new KitUsageError("--files cannot be combined with -i: there is no pattern to match; pass '-- --iglob <glob>' for a case-insensitive glob filter");
|
|
148
|
+
}
|
|
149
|
+
if (files) {
|
|
150
|
+
if (pattern !== undefined) {
|
|
151
|
+
throw new KitUsageError(`unexpected argument '${pattern}'; --files takes no pattern - use --glob to filter by name`);
|
|
152
|
+
}
|
|
153
|
+
} else if (pattern === undefined || pattern.length === 0) {
|
|
154
|
+
throw new KitUsageError("rg pattern must be non-empty (or pass --files to enumerate files without a pattern)");
|
|
155
|
+
}
|
|
129
156
|
if (parsed.options.g && explicit.length > 0) {
|
|
130
157
|
throw new KitUsageError("--endpoint and -g cannot be used together");
|
|
131
158
|
}
|
|
132
159
|
|
|
160
|
+
const globs = parsed.options.glob ?? [];
|
|
133
161
|
return {
|
|
134
|
-
request: { query: pattern
|
|
162
|
+
request: { query: files ? "" : pattern!, limit },
|
|
135
163
|
options: {
|
|
136
164
|
explicitEndpoints: explicit.length > 0 ? explicit : undefined,
|
|
137
165
|
global: parsed.options.g ?? false,
|
|
138
|
-
...(
|
|
166
|
+
...(globs.length > 0 ? { globs } : {}),
|
|
139
167
|
...(parsed.options.type === undefined ? {} : { type: parsed.options.type }),
|
|
140
168
|
...(parsed.options.i === true ? { ignoreCase: true } : {}),
|
|
141
169
|
...(parsed.options.count === true ? { count: true } : {}),
|
|
170
|
+
...(files ? { files: true } : {}),
|
|
142
171
|
...(parsed.options.json === true ? { json: true } : {}),
|
|
143
172
|
passthrough: [],
|
|
144
173
|
},
|
|
@@ -238,7 +267,7 @@ function renderRgCommandResult(parsed: ParsedRg, result: RgResult): RgCommandRes
|
|
|
238
267
|
if (parsed.options.json === true) {
|
|
239
268
|
return {
|
|
240
269
|
exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
|
|
241
|
-
stdout: `${JSON.stringify(projectRgEnvelope(result
|
|
270
|
+
stdout: `${JSON.stringify(projectRgEnvelope(result), null, 2)}\n`,
|
|
242
271
|
stderr: view.diagnostics,
|
|
243
272
|
};
|
|
244
273
|
}
|
|
@@ -305,10 +334,11 @@ async function executeMixedRg(
|
|
|
305
334
|
const execution = await remoteRg(binding, transport, token, {
|
|
306
335
|
query: parsed.request.query,
|
|
307
336
|
limit: parsed.request.limit,
|
|
308
|
-
...(parsed.options.
|
|
337
|
+
...(parsed.options.globs !== undefined ? { globs: parsed.options.globs } : {}),
|
|
309
338
|
...(parsed.options.type !== undefined ? { type: parsed.options.type } : {}),
|
|
310
339
|
...(parsed.options.ignoreCase === true ? { ignoreCase: true } : {}),
|
|
311
340
|
...(parsed.options.count === true ? { count: true } : {}),
|
|
341
|
+
...(parsed.options.files === true ? { files: true } : {}),
|
|
312
342
|
passthrough: parsed.options.passthrough,
|
|
313
343
|
});
|
|
314
344
|
remoteOutcomes.set(binding.name, execution.outcome);
|
|
@@ -343,6 +373,7 @@ async function executeMixedRg(
|
|
|
343
373
|
return renderRgCommandResult(parsed, {
|
|
344
374
|
query: parsed.request.query,
|
|
345
375
|
limit: parsed.request.limit,
|
|
376
|
+
mode: (parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match") as RgMode,
|
|
346
377
|
endpoints,
|
|
347
378
|
warnings,
|
|
348
379
|
aggregate,
|
package/src/server.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
runRg,
|
|
34
34
|
projectRgEnvelope,
|
|
35
35
|
RG_DEFAULT_LIMIT,
|
|
36
|
+
RG_FILES_DEFAULT_LIMIT,
|
|
36
37
|
RG_MAX_LIMIT,
|
|
37
38
|
validateRgPassthrough,
|
|
38
39
|
RgUsageError,
|
|
@@ -463,18 +464,44 @@ function parseNavParams(
|
|
|
463
464
|
}
|
|
464
465
|
|
|
465
466
|
/** Wire rg params → ParsedRg with serve-fixed scope. Params are flat (no
|
|
466
|
-
* argv reconstruction needed): query/limit/glob/type/i/count map onto
|
|
467
|
+
* argv reconstruction needed): query/limit/glob/type/i/count/files map onto
|
|
467
468
|
* ParsedRg directly and passthrough re-validates against the same
|
|
468
|
-
* allowlist (ADR-RG-002) before any endpoint work starts.
|
|
469
|
+
* allowlist (ADR-RG-002) before any endpoint work starts. Files mode
|
|
470
|
+
* (ADR-RG-005) mirrors the CLI parse layer's mutual exclusions so the wire
|
|
471
|
+
* keeps the same strictness as query/limit. */
|
|
469
472
|
function parseRgParams(
|
|
470
473
|
endpointName: string,
|
|
471
474
|
url: URL,
|
|
472
475
|
): { parsed: ParsedRg } | { error: string } {
|
|
476
|
+
// Boolean params are strictly "1"/"0" — same strictness as query/limit, so
|
|
477
|
+
// a hand-crafted `i=true` is a visible usage error, not a silent mode miss.
|
|
478
|
+
const booleanOf = (name: string): boolean | { error: string } => {
|
|
479
|
+
const raw = url.searchParams.get(name);
|
|
480
|
+
if (raw === null) return false;
|
|
481
|
+
if (raw === "1") return true;
|
|
482
|
+
if (raw === "0") return false;
|
|
483
|
+
return { error: `'${name}' must be 1 or 0` };
|
|
484
|
+
};
|
|
485
|
+
const files = booleanOf("files");
|
|
486
|
+
if (typeof files === "object") return files;
|
|
487
|
+
const ignoreCase = booleanOf("i");
|
|
488
|
+
if (typeof ignoreCase === "object") return ignoreCase;
|
|
489
|
+
const count = booleanOf("count");
|
|
490
|
+
if (typeof count === "object") return count;
|
|
473
491
|
const query = url.searchParams.get("query");
|
|
474
|
-
if (
|
|
475
|
-
return { error: "'
|
|
492
|
+
if (files && count) {
|
|
493
|
+
return { error: "'files' and 'count' cannot be used together (pick one output mode)" };
|
|
494
|
+
}
|
|
495
|
+
if (files && ignoreCase) {
|
|
496
|
+
return { error: "'files' cannot be combined with 'i': pass an --iglob passthrough arg for a case-insensitive glob filter" };
|
|
497
|
+
}
|
|
498
|
+
if (files && query !== null) {
|
|
499
|
+
return { error: "'files' takes no 'query' - use 'glob' to filter by name" };
|
|
476
500
|
}
|
|
477
|
-
|
|
501
|
+
if (!files && (query === null || query.length === 0)) {
|
|
502
|
+
return { error: "'query' is required and must be a non-empty pattern (or pass files=1 for enumeration mode)" };
|
|
503
|
+
}
|
|
504
|
+
let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
|
|
478
505
|
const rawLimit = url.searchParams.get("limit");
|
|
479
506
|
if (rawLimit !== null) {
|
|
480
507
|
if (!/^[0-9]+$/.test(rawLimit)) return { error: "'limit' must be a decimal integer" };
|
|
@@ -490,31 +517,19 @@ function parseRgParams(
|
|
|
490
517
|
if (error instanceof RgUsageError) return { error: error.message };
|
|
491
518
|
throw error;
|
|
492
519
|
}
|
|
493
|
-
|
|
494
|
-
// a hand-crafted `i=true` is a visible usage error, not a silent mode miss.
|
|
495
|
-
const booleanOf = (name: string): boolean | { error: string } => {
|
|
496
|
-
const raw = url.searchParams.get(name);
|
|
497
|
-
if (raw === null) return false;
|
|
498
|
-
if (raw === "1") return true;
|
|
499
|
-
if (raw === "0") return false;
|
|
500
|
-
return { error: `'${name}' must be 1 or 0` };
|
|
501
|
-
};
|
|
502
|
-
const ignoreCase = booleanOf("i");
|
|
503
|
-
if (typeof ignoreCase === "object") return ignoreCase;
|
|
504
|
-
const count = booleanOf("count");
|
|
505
|
-
if (typeof count === "object") return count;
|
|
506
|
-
const glob = url.searchParams.get("glob");
|
|
520
|
+
const globs = url.searchParams.getAll("glob");
|
|
507
521
|
const type = url.searchParams.get("type");
|
|
508
522
|
return {
|
|
509
523
|
parsed: {
|
|
510
|
-
request: { query
|
|
524
|
+
request: { query: files ? "" : query!, limit },
|
|
511
525
|
options: {
|
|
512
526
|
explicitEndpoints: [endpointName],
|
|
513
527
|
global: false,
|
|
514
|
-
...(
|
|
528
|
+
...(globs.length > 0 ? { globs } : {}),
|
|
515
529
|
...(type !== null ? { type } : {}),
|
|
516
530
|
...(ignoreCase ? { ignoreCase: true } : {}),
|
|
517
531
|
...(count ? { count: true } : {}),
|
|
532
|
+
...(files ? { files: true } : {}),
|
|
518
533
|
passthrough,
|
|
519
534
|
},
|
|
520
535
|
warnings: [],
|
|
@@ -1170,7 +1185,7 @@ function handleRg(url: URL, request: Request, config: ServeConfig, endpointName:
|
|
|
1170
1185
|
currentDirectory: config.currentDirectory,
|
|
1171
1186
|
registryPath: config.registryPath,
|
|
1172
1187
|
});
|
|
1173
|
-
return jsonResponse(projectRgEnvelope(result
|
|
1188
|
+
return jsonResponse(projectRgEnvelope(result));
|
|
1174
1189
|
}
|
|
1175
1190
|
|
|
1176
1191
|
/** PUT /v1/propose/{id} (W8 / ADR-REM-005): the write face. Body = the
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Win32 tool-spawn relay (2026-09-22 finding, ukp_remote TODO
|
|
3
|
+
* 20260922-win32-ssh-spawn-tax): Windows Defender holds the DIRECT
|
|
4
|
+
* bun-to-<tool>.exe child edge for seconds per spawn — measured 9.7-10.7s for
|
|
5
|
+
* ssh.exe and ~5.4s for rg.exe across stdio modes, windowsHide, and absolute
|
|
6
|
+
* paths, while the same binaries start in 0.16-0.26s from bash and in
|
|
7
|
+
* 0.55-0.8s from bun through ANY intermediary parent (cmd, node, powershell).
|
|
8
|
+
* The inspection keys on the parent-child edge, so routing the spawn through
|
|
9
|
+
* a one-level intermediary escapes it.
|
|
10
|
+
*
|
|
11
|
+
* The intermediary is powershell.exe driven by -EncodedCommand: the script
|
|
12
|
+
* rides as one base64 token (no quoting stress from the spawning side), and
|
|
13
|
+
* the script splats a single-quoted array so argv reaches the tool
|
|
14
|
+
* byte-identical — the only escape in a PS single-quoted literal is doubling
|
|
15
|
+
* inner single quotes; double quotes, backslashes, and cmd metacharacters
|
|
16
|
+
* are all literal (verified E2E: the pinned wake command's load-bearing
|
|
17
|
+
* quotes survive, door READY ~1.4s). powershell.exe ships with every
|
|
18
|
+
* Windows; no extra prerequisite is imposed.
|
|
19
|
+
*
|
|
20
|
+
* Exit-code contract: the script exits with the tool's own exit status. With
|
|
21
|
+
* the not-found guard requested, a tool that does not resolve on PATH exits
|
|
22
|
+
* TOOL_NOT_FOUND_EXIT before any invocation — distinct from every real tool
|
|
23
|
+
* exit, so callers can keep treating "tool missing" as a degradation instead
|
|
24
|
+
* of a fault.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Distinct not-found sentinel: no real tool invocation ran when this is the
|
|
28
|
+
* exit status (ripgrep itself only ever exits 0/1/2; ssh diagnostics arrive
|
|
29
|
+
* via stderr instead). */
|
|
30
|
+
export const TOOL_NOT_FOUND_EXIT = 127;
|
|
31
|
+
|
|
32
|
+
/** PS single-quote literal: the only escape is doubling inner single quotes. */
|
|
33
|
+
function psSingleQuoted(arg: string): string {
|
|
34
|
+
return `'${arg.replaceAll("'", "''")}'`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The relay script. `notFoundGuard` adds a Get-Command pre-check that exits
|
|
38
|
+
* TOOL_NOT_FOUND_EXIT when argv[0] does not resolve — for callers that must
|
|
39
|
+
* distinguish a missing tool from a tool's own failure exit. */
|
|
40
|
+
export function toolRelayScript(argv: readonly string[], options: { notFoundGuard?: boolean } = {}): string {
|
|
41
|
+
const guard = options.notFoundGuard === true
|
|
42
|
+
? " if (-not (Get-Command -LiteralName $a[0] -ErrorAction SilentlyContinue)) { exit 127 };"
|
|
43
|
+
: "";
|
|
44
|
+
return `$a = @(${argv.map(psSingleQuoted).join(",")});${guard} & $a[0] $a[1..($a.Count-1)]; exit $LASTEXITCODE`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The powershell carrier argv for a tool invocation: spawn THIS instead of
|
|
48
|
+
* the tool when the direct bun-to-tool edge is taxed (win32 real-tool
|
|
49
|
+
* spawns). Test-injected commands must not be wrapped. */
|
|
50
|
+
export function toolRelayArgv(argv: readonly string[], options: { notFoundGuard?: boolean } = {}): string[] {
|
|
51
|
+
return [
|
|
52
|
+
"powershell",
|
|
53
|
+
"-NoProfile",
|
|
54
|
+
"-EncodedCommand",
|
|
55
|
+
Buffer.from(toolRelayScript(argv, options), "utf16le").toString("base64"),
|
|
56
|
+
];
|
|
57
|
+
}
|