@catheadowl/ukp 0.2.4 → 0.2.6

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.
@@ -7,9 +7,11 @@ import {
7
7
  type KitParsed,
8
8
  type UkpCommandSpec,
9
9
  } from "./kit.ts";
10
+ import { readRegistry } from "../registry.ts";
11
+ import { homedir } from "node:os";
10
12
  import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
11
13
  import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
12
- import { printTaskPreflightError, renderServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
14
+ import { printTaskPreflightError, renderServeTaskArtifacts, writeServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
13
15
 
14
16
  export interface ServeCommandContext {
15
17
  currentDirectory: string;
@@ -17,6 +19,10 @@ export interface ServeCommandContext {
17
19
  qmdCommand?: readonly string[];
18
20
  /** Overrides `process.env.UKP_SERVE_TOKEN` for tests. */
19
21
  tokens?: readonly string[];
22
+ /** Overrides the home directory `--print-task --write` writes into
23
+ * (tests point this at a scratch home; production resolves the real
24
+ * user home). */
25
+ homeDir?: string;
20
26
  }
21
27
 
22
28
  /** `UKP_SERVE_TOKEN=a,b,c` (RQ-16): comma-separated, trimmed, empty entries
@@ -30,17 +36,19 @@ function parseServeTokens(raw: string | undefined): string[] {
30
36
  * Bun.serve listener keeps the process alive after runCli returns its exit
31
37
  * code; SIGINT/SIGTERM stop the listener so the process exits with it.
32
38
  * `--endpoint` is optional (W7 / ADR-REM-004): present = the original
33
- * single-endpoint mode; absent = host door mode serving the whole registry
34
- * over `/e/<name>/` routing. */
39
+ * single-endpoint mode; absent = host door mode serving the registry over
40
+ * `/e/<name>/` routing — every local binding, or the `--select` whitelist
41
+ * subset (ADR-REM-010). */
35
42
  export const SERVE_SPEC: UkpCommandSpec = {
36
43
  name: "serve",
37
- summary: "serve one endpoint - or the whole registry as a host door, one HTTP server for every endpoint - for remote UKP clients",
44
+ summary: "serve one endpoint, a selected subset, or the whole registry as a host door - one HTTP server, one port - for remote UKP clients",
38
45
  group: "operations",
39
46
  description:
40
- "Expose a registered endpoint over HTTP using the ukp-remote wire, or - without --endpoint - serve every local endpoint as one host door routed by name.",
41
- usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]... | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task]",
47
+ "Expose a registered endpoint over HTTP using the ukp-remote wire, or - without --endpoint - serve local endpoints as one host door routed by name: all of them, or just the ones --select names.",
48
+ usage: "[--endpoint <name>] [--select <names>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]... | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task [--write]]",
42
49
  options: [
43
- { flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
50
+ { flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve a host door (/e/<name>/ routing, one port) instead" },
51
+ { flags: "--select <names>", help: "host door serving ONLY the named endpoints (comma-separated): one port, /e/<name>/ routing, and a door document that declares just this subset - unselected local endpoints are not served, routed, or declared; mutually exclusive with --endpoint" },
44
52
  { flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
45
53
  { flags: "--port <n>", help: "listen port (default 8570)" },
46
54
  { flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under .ukp/tls/, SAN covers this host's addresses; clients pin it at registration)" },
@@ -51,6 +59,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
51
59
  { flags: "--max-idle <seconds>", help: "exit after <seconds> without requests (self-reap; the orphan backstop for on-demand-woken doors - fractional values accepted for tests)" },
52
60
  { flags: "--systemd-socket", help: "serve on the systemd socket-activation listener (LISTEN_FDS fd 3) instead of binding a port - the port belongs to your .socket unit; Linux-only" },
53
61
  { flags: "--print-task", help: "Windows: print this door's process-manager artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) instead of serving - review and apply them yourself; nothing is installed or started for you, and the token is yours to paste (never printed)" },
62
+ { flags: "--write", help: "with --print-task: write the two artifact files (%USERPROFILE%\\.ukp\\start-door.cmd with the token interpolated from UKP_SERVE_TOKEN, plus the hidden launcher) instead of printing them - existing files are never overwritten, the token is never displayed, and the task/firewall steps stay on stdout for you to apply" },
54
63
  ],
55
64
  helpSuffix: [
56
65
  "",
@@ -63,7 +72,8 @@ export const SERVE_SPEC: UkpCommandSpec = {
63
72
  " GET /v1/rg?query=... lexical search (ukp.rg.v1, ukp_uri handoff)",
64
73
  "",
65
74
  "Wire (ukp-remote v1), host door (no --endpoint):",
66
- ` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster)`,
75
+ ` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster -`,
76
+ " the --select subset when --select is given)",
67
77
  " GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
68
78
  " as single-endpoint mode; TOFU pins anchor here)",
69
79
  " POST /e/<name>/v1/search capabilities routed by endpoint name",
@@ -103,6 +113,12 @@ export const SERVE_SPEC: UkpCommandSpec = {
103
113
  export interface ParsedServe {
104
114
  /** Present = single-endpoint mode; absent = host door mode (W7). */
105
115
  endpoint?: string;
116
+ /** `--select` names (ADR-REM-010): present next to an absent endpoint =
117
+ * subset door — the door serves, routes, and declares only these
118
+ * endpoints. Parsed here exactly the way register --select parses its
119
+ * list (comma-separated, trimmed, empty entries dropped); the execute
120
+ * layer's eager check (usage error with the roster) is the admission. */
121
+ select?: string[];
106
122
  host: string;
107
123
  port: number;
108
124
  /** Present = the --max-idle self-reap timer is armed (W9). */
@@ -112,6 +128,10 @@ export interface ParsedServe {
112
128
  /** Present = render the Windows process-manager artifacts instead of
113
129
  * serving (W12; Windows-only, print-only, never starts a listener). */
114
130
  printTask?: boolean;
131
+ /** Present = the print-task artifacts' file pair lands on disk with the
132
+ * env token interpolated (O-020 ruling C); only valid next to
133
+ * --print-task, and the print surface stays secret-free. */
134
+ write?: boolean;
115
135
  /** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
116
136
  * valid only next to `--tls` — the execute layer enforces the pairing.
117
137
  * Absent when the flag is not passed (parse output stays byte-identical
@@ -120,11 +140,24 @@ export interface ParsedServe {
120
140
  }
121
141
 
122
142
  function toParsedServe(parsed: KitParsed): ParsedServe {
123
- const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; tlsSan?: string[] };
143
+ const options = parsed.options as { host?: string; port?: string; endpoint?: string; select?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; write?: boolean; tlsSan?: string[] };
124
144
  const endpoint = options.endpoint;
125
145
  if (endpoint !== undefined && endpoint.length === 0) {
126
146
  throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
127
147
  }
148
+ // --select parses with register --select's exact list semantics (the same
149
+ // operation's other side): comma-separated, trimmed, empties dropped. A
150
+ // flag that parses to nothing is a usage error, not an empty whitelist.
151
+ let select: string[] | undefined;
152
+ if (options.select !== undefined) {
153
+ select = options.select.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
154
+ if (select.length === 0) {
155
+ throw new KitUsageError("--select requires at least one endpoint name");
156
+ }
157
+ }
158
+ if (endpoint !== undefined && select !== undefined) {
159
+ throw new KitUsageError("--endpoint and --select cannot be used together (--endpoint is the N=1 form; --select narrows a host door)");
160
+ }
128
161
  const host = options.host ?? "127.0.0.1";
129
162
  const port = Number(options.port ?? "8570");
130
163
  if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
@@ -138,6 +171,10 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
138
171
  if (printTask && systemdSocket) {
139
172
  throw new KitUsageError("--print-task is the Windows resident form; --systemd-socket is the Linux one (systemd holds the port - see the deployment handbook)");
140
173
  }
174
+ const write = options.write === true;
175
+ if (write && !printTask) {
176
+ throw new KitUsageError("--write modifies --print-task: run ukp serve --print-task --write (without --print-task, serve just serves)");
177
+ }
141
178
  let maxIdleSeconds: number | undefined;
142
179
  if (options.maxIdle !== undefined) {
143
180
  maxIdleSeconds = Number(options.maxIdle);
@@ -160,11 +197,13 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
160
197
  }
161
198
  return {
162
199
  ...(endpoint !== undefined ? { endpoint } : {}),
200
+ ...(select !== undefined ? { select } : {}),
163
201
  host,
164
202
  port,
165
203
  ...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
166
204
  ...(systemdSocket ? { systemdSocket } : {}),
167
205
  ...(printTask ? { printTask } : {}),
206
+ ...(write ? { write } : {}),
168
207
  ...(tlsSan.length > 0 ? { tlsSan } : {}),
169
208
  };
170
209
  }
@@ -184,6 +223,9 @@ export function renderServeBanner(info: ServeInfo): string {
184
223
  ` listening: ${info.url}`,
185
224
  ` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url} (host door)`,
186
225
  ` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none - register endpoints on this host)"}`,
226
+ ...(info.door!.select !== undefined
227
+ ? [` select: ${info.door!.select.join(", ")} (subset door - unselected local endpoints are not served, routed, or declared)`]
228
+ : []),
187
229
  ` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
188
230
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
189
231
  ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning - install ripgrep on this door's PATH)"}`,
@@ -241,7 +283,7 @@ export function executeServeCommand(
241
283
  context: ServeCommandContext,
242
284
  ): KitCommandResult {
243
285
  return executeKitCommand(SERVE_SPEC, args, (parsed) => {
244
- const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
286
+ const { endpoint, select, host, port, maxIdleSeconds, systemdSocket, write, tlsSan = [] } = toParsedServe(parsed);
245
287
  const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
246
288
  const printTask = (parsed.options as { printTask?: boolean }).printTask === true;
247
289
  const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
@@ -258,10 +300,37 @@ export function executeServeCommand(
258
300
  if (tlsSan.length > 0 && options.tls !== true) {
259
301
  throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
260
302
  }
261
- // W12 print-only branch: render the Windows artifacts and stop. No
262
- // listener, no admission token check (the token is pasted into the
263
- // generated script later), and the env token is deliberately not read
264
- // — the output is secret-free by construction.
303
+ // Subset-door admission (ADR-REM-010): a name not among the local
304
+ // bindings is a usage error with the roster — the eager check mirrors
305
+ // register --select's door-import behavior and the ingress admission
306
+ // precedent (a typo silently narrowing the door is a zero-signal
307
+ // misconfiguration). An unreadable registry skips the check: that
308
+ // failure surface is the door's own, shown per request like today.
309
+ if (select !== undefined && endpoint === undefined) {
310
+ let localNames: string[] | undefined;
311
+ try {
312
+ localNames = readRegistry(context.registryPath)
313
+ .filter((binding) => binding.kind !== "remote")
314
+ .map((binding) => binding.name);
315
+ } catch {
316
+ localNames = undefined;
317
+ }
318
+ if (localNames !== undefined) {
319
+ const unknown = select.filter((name) => !localNames!.includes(name));
320
+ if (unknown.length > 0) {
321
+ throw new KitUsageError(
322
+ `--select names not registered on this host: ${unknown.join(", ")} (available: ${localNames.join(", ")})`,
323
+ );
324
+ }
325
+ }
326
+ }
327
+ // W12 print branch: render the Windows artifacts and stop. No listener,
328
+ // no admission token check. Print mode never reads the env token - the
329
+ // output is secret-free by construction. Write mode (O-020 ruling C:
330
+ // the D-090 secret-free clause scopes to the print surface) reads
331
+ // UKP_SERVE_TOKEN to interpolate it into the files it writes - the
332
+ // token exists in exactly one place, the cmd file's bytes; stdout stays
333
+ // secret-free in both modes.
265
334
  if (printTask) {
266
335
  const preflight = printTaskPreflightError({
267
336
  platform: process.platform,
@@ -278,13 +347,25 @@ export function executeServeCommand(
278
347
  : options.tlsCert !== undefined && options.tlsKey !== undefined
279
348
  ? { mode: "certificates", certPath: options.tlsCert, keyPath: options.tlsKey }
280
349
  : undefined;
350
+ const input = {
351
+ host,
352
+ port,
353
+ ...(endpoint !== undefined ? { endpoint } : {}),
354
+ ...(select !== undefined ? { select } : {}),
355
+ ...(tls !== undefined ? { tls } : {}),
356
+ };
357
+ if (!write) {
358
+ return {
359
+ exitCode: 0,
360
+ stdout: renderServeTaskArtifacts(input),
361
+ stderr: "",
362
+ };
363
+ }
281
364
  return {
282
365
  exitCode: 0,
283
- stdout: renderServeTaskArtifacts({
284
- host,
285
- port,
286
- ...(endpoint !== undefined ? { endpoint } : {}),
287
- ...(tls !== undefined ? { tls } : {}),
366
+ stdout: writeServeTaskArtifacts(input, {
367
+ token: process.env.UKP_SERVE_TOKEN,
368
+ homeDir: context.homeDir ?? homedir(),
288
369
  }),
289
370
  stderr: "",
290
371
  };
@@ -310,6 +391,7 @@ export function executeServeCommand(
310
391
  host,
311
392
  port,
312
393
  ...(endpoint !== undefined ? { endpointName: endpoint } : {}),
394
+ ...(select !== undefined ? { select } : {}),
313
395
  ...(tokens.length > 0 ? { tokens } : {}),
314
396
  ...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
315
397
  ...(systemdSocket !== undefined && systemdSocket ? { systemdSocket } : {}),
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,
@@ -43,18 +44,22 @@ import { KitUsageError } from "./commands/kit.ts";
43
44
  /** ukp-remote wire v1 server core (ADR-REM-001/002/003, ukp_remote W1/W7):
44
45
  * two serving shapes over one wire. Single-endpoint mode exposes ONE
45
46
  * registered endpoint; door mode (`endpointName` absent, ADR-REM-004) serves
46
- * every local binding in the registry, routed by name at `/e/<name>/…` with
47
+ * local bindings in the registry, routed by name at `/e/<name>/…` with
47
48
  * a `scope:"host"` door document — the registry is read fresh per request so
48
- * the door grows without restart. Both shapes expose a discovery document
49
- * (manifest projection + protocol version + instance identity) plus the
50
- * read-side capability routes (search/read since W1, nav/rg since W6); the
51
- * per-endpoint documents are byte-identical across modes, so client-side
52
- * TOFU and name-assertion paths are shared. The capability layer runs
53
- * unchanged behind the routes (provider/transport axes stay orthogonal):
54
- * search via `runSearch` with a single-endpoint scope, read via `runRead`,
55
- * nav via `runNav`, rg via `runRg`. There is no session, no streaming, and
56
- * no server-side artifact: responses inline the reference data (RQ-07) and
57
- * generate a serve-scoped run id. */
49
+ * the door grows without restart. Door mode comes in two widths
50
+ * (ADR-REM-010): every local binding, or the `select` whitelist subset. To
51
+ * anyone the subset excludes, the door pretends those endpoints do not
52
+ * exist — roster, routes, per-endpoint documents, and 404 rosters all see
53
+ * the same effective set (select ∩ current registry). Both shapes expose a
54
+ * discovery document (manifest projection + protocol version + instance
55
+ * identity) plus the read-side capability routes (search/read since W1,
56
+ * nav/rg since W6); the per-endpoint documents are byte-identical across
57
+ * modes, so client-side TOFU and name-assertion paths are shared. The
58
+ * capability layer runs unchanged behind the routes (provider/transport
59
+ * axes stay orthogonal): search via `runSearch` with a single-endpoint
60
+ * scope, read via `runRead`, nav via `runNav`, rg via `runRg`. There is no
61
+ * session, no streaming, and no server-side artifact: responses inline the
62
+ * reference data (RQ-07) and generate a serve-scoped run id. */
58
63
 
59
64
  export const PROTOCOL_NAME = "ukp-remote";
60
65
  export const PROTOCOL_VERSION = "1";
@@ -65,11 +70,20 @@ export const DISCOVERY_PATH = "/.well-known/ukp.json";
65
70
  export const PROPOSE_BODY_LIMIT_BYTES = 1024 * 1024;
66
71
 
67
72
  export interface ServeConfig {
68
- /** Absent = host door mode (ADR-REM-004 / O-2): serve EVERY local binding
69
- * in the registry over one listener, routed by name at `/e/<name>/…`, with
70
- * a `scope:"host"` door document at the well-known path. Present = the
73
+ /** Absent = host door mode (ADR-REM-004 / O-2): serve local bindings in
74
+ * the registry over one listener, routed by name at `/e/<name>/…`, with a
75
+ * `scope:"host"` door document at the well-known path. Present = the
71
76
  * original single-endpoint mode (N=1 retreat stays legal long-term). */
72
77
  endpointName?: string;
78
+ /** Subset door (ADR-REM-010 / O-020 ruling D): a declarative name
79
+ * whitelist next to an ABSENT endpointName — the door serves only the
80
+ * selected local bindings, and to anyone the list excludes the door
81
+ * pretends those endpoints do not exist (roster, routes, per-endpoint
82
+ * documents, 404 rosters all carry the same effective set). The server
83
+ * layer does NOT validate the names at startup — the command layer's
84
+ * eager check (usage error with the roster) is the admission; here the
85
+ * set just intersects with the per-request registry read. */
86
+ select?: readonly string[];
73
87
  currentDirectory: string;
74
88
  registryPath: string;
75
89
  qmdCommand?: readonly string[];
@@ -143,10 +157,13 @@ export interface ServeInfo {
143
157
  folder?: string;
144
158
  instanceUid?: string;
145
159
  /** Door mode fields (present iff mode === "door"): the startup snapshot of
146
- * servable endpoint names (the door itself grows without restart), plus
147
- * the snapshot of write-capable (propose-declaring) names — the banner's
148
- * "you opened a write face" line (ADR-REM-005 verdict C). */
149
- door?: { endpoints: string[]; write: string[] };
160
+ * servable endpoint names (the door itself grows without restart), the
161
+ * snapshot of write-capable (propose-declaring) names — the banner's
162
+ * "you opened a write face" line (ADR-REM-005 verdict C) — and, on a
163
+ * subset door (ADR-REM-010), the declared select whitelist itself (the
164
+ * banner's `select:` line: what was asked for, next to what is being
165
+ * served). */
166
+ door?: { endpoints: string[]; write: string[]; select?: string[] };
150
167
  url: string;
151
168
  host: string;
152
169
  port: number;
@@ -157,7 +174,7 @@ export interface ServeInfo {
157
174
  * consumer-channel only. The banner line is the operator channel, and the
158
175
  * probe environment is by construction the one that matters (ukp diagnose
159
176
  * measures the invoking shell instead, which can differ from the door's
160
- * schtasks / wake environment). */
177
+ * Task Scheduler / wake environment). */
161
178
  rg: "ok" | "missing";
162
179
  /** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
163
180
  * what remote clients TOFU-pin at registration. `source` "re-signed" marks
@@ -278,21 +295,25 @@ export function buildDiscoveryDocument(
278
295
  };
279
296
  }
280
297
 
281
- /** Servable endpoints of a door (O-2): every LOCAL binding in the registry,
282
- * read fresh by the caller per request (new endpoints appear without a
283
- * restart). Remote bindings are not servable and never make the roster. */
284
- function listDoorEndpoints(registryPath: string): Array<{ name: string; folder: string }> {
298
+ /** Servable endpoints of a door (O-2): the LOCAL bindings in the registry
299
+ * the door's width admits — every one of them, or the `select` whitelist's
300
+ * members (ADR-REM-010) — read fresh by the caller per request (new
301
+ * endpoints appear without a restart). Remote bindings are not servable and
302
+ * never make the roster. */
303
+ function listDoorEndpoints(registryPath: string, select?: readonly string[]): Array<{ name: string; folder: string }> {
285
304
  return readRegistry(registryPath)
286
305
  .filter((binding) => binding.kind !== "remote")
306
+ .filter((binding) => select === undefined || select.includes(binding.name))
287
307
  .map((binding) => ({ name: binding.name, folder: binding.path! }));
288
308
  }
289
309
 
290
310
  /** Startup snapshot of write-capable (propose-declaring) door endpoints —
291
311
  * the banner line that makes the opened write face visible (ADR-REM-005
292
- * verdict C). Unreadable/name-drifted Services drop out silently here;
293
- * they are already reported by the roster/discovery paths. */
294
- function listWriteEndpoints(registryPath: string): string[] {
295
- return listDoorEndpoints(registryPath).flatMap((endpoint) => {
312
+ * verdict C). Subset doors narrow it to the select set (ADR-REM-010).
313
+ * Unreadable/name-drifted Services drop out silently here; they are already
314
+ * reported by the roster/discovery paths. */
315
+ function listWriteEndpoints(registryPath: string, select?: readonly string[]): string[] {
316
+ return listDoorEndpoints(registryPath, select).flatMap((endpoint) => {
296
317
  try {
297
318
  const loaded = loadManifest(endpoint.folder);
298
319
  return loaded.effectiveName === endpoint.name
@@ -463,18 +484,44 @@ function parseNavParams(
463
484
  }
464
485
 
465
486
  /** Wire rg params → ParsedRg with serve-fixed scope. Params are flat (no
466
- * argv reconstruction needed): query/limit/glob/type/i/count map onto
487
+ * argv reconstruction needed): query/limit/glob/type/i/count/files map onto
467
488
  * ParsedRg directly and passthrough re-validates against the same
468
- * allowlist (ADR-RG-002) before any endpoint work starts. */
489
+ * allowlist (ADR-RG-002) before any endpoint work starts. Files mode
490
+ * (ADR-RG-005) mirrors the CLI parse layer's mutual exclusions so the wire
491
+ * keeps the same strictness as query/limit. */
469
492
  function parseRgParams(
470
493
  endpointName: string,
471
494
  url: URL,
472
495
  ): { parsed: ParsedRg } | { error: string } {
496
+ // Boolean params are strictly "1"/"0" — same strictness as query/limit, so
497
+ // a hand-crafted `i=true` is a visible usage error, not a silent mode miss.
498
+ const booleanOf = (name: string): boolean | { error: string } => {
499
+ const raw = url.searchParams.get(name);
500
+ if (raw === null) return false;
501
+ if (raw === "1") return true;
502
+ if (raw === "0") return false;
503
+ return { error: `'${name}' must be 1 or 0` };
504
+ };
505
+ const files = booleanOf("files");
506
+ if (typeof files === "object") return files;
507
+ const ignoreCase = booleanOf("i");
508
+ if (typeof ignoreCase === "object") return ignoreCase;
509
+ const count = booleanOf("count");
510
+ if (typeof count === "object") return count;
473
511
  const query = url.searchParams.get("query");
474
- if (query === null || query.length === 0) {
475
- return { error: "'query' is required and must be a non-empty pattern" };
512
+ if (files && count) {
513
+ return { error: "'files' and 'count' cannot be used together (pick one output mode)" };
514
+ }
515
+ if (files && ignoreCase) {
516
+ return { error: "'files' cannot be combined with 'i': pass an --iglob passthrough arg for a case-insensitive glob filter" };
517
+ }
518
+ if (files && query !== null) {
519
+ return { error: "'files' takes no 'query' - use 'glob' to filter by name" };
476
520
  }
477
- let limit = RG_DEFAULT_LIMIT;
521
+ if (!files && (query === null || query.length === 0)) {
522
+ return { error: "'query' is required and must be a non-empty pattern (or pass files=1 for enumeration mode)" };
523
+ }
524
+ let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
478
525
  const rawLimit = url.searchParams.get("limit");
479
526
  if (rawLimit !== null) {
480
527
  if (!/^[0-9]+$/.test(rawLimit)) return { error: "'limit' must be a decimal integer" };
@@ -490,31 +537,19 @@ function parseRgParams(
490
537
  if (error instanceof RgUsageError) return { error: error.message };
491
538
  throw error;
492
539
  }
493
- // Boolean params are strictly "1"/"0" — same strictness as query/limit, so
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");
540
+ const globs = url.searchParams.getAll("glob");
507
541
  const type = url.searchParams.get("type");
508
542
  return {
509
543
  parsed: {
510
- request: { query, limit },
544
+ request: { query: files ? "" : query!, limit },
511
545
  options: {
512
546
  explicitEndpoints: [endpointName],
513
547
  global: false,
514
- ...(glob !== null ? { glob } : {}),
548
+ ...(globs.length > 0 ? { globs } : {}),
515
549
  ...(type !== null ? { type } : {}),
516
550
  ...(ignoreCase ? { ignoreCase: true } : {}),
517
551
  ...(count ? { count: true } : {}),
552
+ ...(files ? { files: true } : {}),
518
553
  passthrough,
519
554
  },
520
555
  warnings: [],
@@ -743,10 +778,14 @@ function bearerGate(
743
778
  * read fresh, the name segment has already passed ENDPOINT_NAME (injection
744
779
  * unreachable), unknown and non-servable names get the roster 404, and the
745
780
  * RQ-14 binding↔manifest identity check runs per request like the
746
- * single-mode discovery fetch does. */
781
+ * single-mode discovery fetch does. A subset door (ADR-REM-010) routes an
782
+ * excluded name into the SAME roster-404 branch as an unknown one — the
783
+ * response is byte-identical either way, so the roster is the only name
784
+ * list any response ever carries. */
747
785
  function resolveDoorRoute(
748
786
  registryPath: string,
749
787
  name: string,
788
+ select?: readonly string[],
750
789
  ): { folder: string } | { response: Response } {
751
790
  let binding;
752
791
  try {
@@ -755,8 +794,8 @@ function resolveDoorRoute(
755
794
  const reason = error instanceof Error ? error.message : String(error);
756
795
  return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
757
796
  }
758
- if (binding === undefined || binding.kind === "remote") {
759
- const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
797
+ if (binding === undefined || binding.kind === "remote" || (select !== undefined && !select.includes(name))) {
798
+ const roster = listDoorEndpoints(registryPath, select).map((endpoint) => endpoint.name).join(", ");
760
799
  return {
761
800
  response: jsonResponse(
762
801
  errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
@@ -793,10 +832,13 @@ function resolveDoorRoute(
793
832
 
794
833
  /** Door-mode routing table (ADR-REM-004 / O-2). The door document and the
795
834
  * per-endpoint documents stay public (ADR-REM-003 declaration/capability
796
- * divide); every `/e/<name>/v1/*` route passes the door-level bearer gate. */
797
- function doorNotFound(name: string | undefined, registryPath: string): Response {
835
+ * divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
836
+ * The roster a 404 carries is the door's effective set — on a subset door
837
+ * (ADR-REM-010) that is the select ∩ registry intersection, never the full
838
+ * registry. */
839
+ function doorNotFound(name: string | undefined, registryPath: string, select?: readonly string[]): Response {
798
840
  if (name !== undefined) {
799
- const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
841
+ const roster = listDoorEndpoints(registryPath, select).map((endpoint) => endpoint.name).join(", ");
800
842
  return jsonResponse(
801
843
  errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
802
844
  404,
@@ -812,6 +854,11 @@ function doorNotFound(name: string | undefined, registryPath: string): Response
812
854
  }
813
855
 
814
856
  function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnType<typeof resolveTlsMaterial>): StartedServe {
857
+ // The door's width (ADR-REM-010): undefined = the whole registry, a name
858
+ // list = the subset whitelist. One variable feeds every surface below —
859
+ // door document, per-request routes, 404 rosters — so the subset cannot
860
+ // drift between them.
861
+ const select = config.select;
815
862
  const handler = async (request: Request): Promise<Response> => {
816
863
  const url = new URL(request.url);
817
864
 
@@ -820,7 +867,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
820
867
  return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
821
868
  }
822
869
  try {
823
- return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath), (config.tokens?.length ?? 0) > 0));
870
+ return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath, select), (config.tokens?.length ?? 0) > 0));
824
871
  } catch (error) {
825
872
  const reason = error instanceof Error ? error.message : String(error);
826
873
  return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
@@ -834,13 +881,13 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
834
881
  const name = routeMatch[1]!;
835
882
  const rest = routeMatch[2] ?? "/";
836
883
  if (!ENDPOINT_NAME.test(name)) {
837
- return doorNotFound(name, config.registryPath);
884
+ return doorNotFound(name, config.registryPath, select);
838
885
  }
839
886
  if (rest === DISCOVERY_PATH) {
840
887
  if (request.method !== "GET") {
841
888
  return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
842
889
  }
843
- const resolved = resolveDoorRoute(config.registryPath, name);
890
+ const resolved = resolveDoorRoute(config.registryPath, name, select);
844
891
  if ("response" in resolved) return resolved.response;
845
892
  try {
846
893
  const loaded = loadManifest(resolved.folder);
@@ -856,15 +903,15 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
856
903
  const denied = bearerGate(request, config.tokens);
857
904
  if (denied !== undefined) return denied;
858
905
  const id = rest.slice("/v1/propose/".length);
859
- if (id.includes("/")) return doorNotFound(undefined, config.registryPath);
860
- const resolved = resolveDoorRoute(config.registryPath, name);
906
+ if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select);
907
+ const resolved = resolveDoorRoute(config.registryPath, name, select);
861
908
  if ("response" in resolved) return resolved.response;
862
909
  return await handlePropose(request, config, name, resolved.folder, id);
863
910
  }
864
911
  if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
865
912
  const denied = bearerGate(request, config.tokens);
866
913
  if (denied !== undefined) return denied;
867
- const resolved = resolveDoorRoute(config.registryPath, name);
914
+ const resolved = resolveDoorRoute(config.registryPath, name, select);
868
915
  if ("response" in resolved) return resolved.response;
869
916
  if (rest === "/v1/search") {
870
917
  return await handleSearch(request, config, name, resolved.folder);
@@ -877,10 +924,10 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
877
924
  }
878
925
  return handleRg(url, request, config, name);
879
926
  }
880
- return doorNotFound(undefined, config.registryPath);
927
+ return doorNotFound(undefined, config.registryPath, select);
881
928
  }
882
929
 
883
- return doorNotFound(undefined, config.registryPath);
930
+ return doorNotFound(undefined, config.registryPath, select);
884
931
  };
885
932
 
886
933
  const listener = startServeListener(handler, {
@@ -895,8 +942,9 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
895
942
  const info: ServeInfo = {
896
943
  mode: "door",
897
944
  door: {
898
- endpoints: listDoorEndpoints(config.registryPath).map((endpoint) => endpoint.name),
899
- write: listWriteEndpoints(config.registryPath),
945
+ endpoints: listDoorEndpoints(config.registryPath, select).map((endpoint) => endpoint.name),
946
+ write: listWriteEndpoints(config.registryPath, select),
947
+ ...(select !== undefined ? { select: [...select] } : {}),
900
948
  },
901
949
  url: listener.publicUrl,
902
950
  host,
@@ -1170,7 +1218,7 @@ function handleRg(url: URL, request: Request, config: ServeConfig, endpointName:
1170
1218
  currentDirectory: config.currentDirectory,
1171
1219
  registryPath: config.registryPath,
1172
1220
  });
1173
- return jsonResponse(projectRgEnvelope(result, parsed.parsed.options.count === true));
1221
+ return jsonResponse(projectRgEnvelope(result));
1174
1222
  }
1175
1223
 
1176
1224
  /** 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
+ }