@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.
- package/README.md +15 -7
- package/docs/remote-deployment.md +101 -16
- package/package.json +1 -1
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/remote-client.ts +201 -21
- package/src/capabilities/rg.ts +147 -20
- package/src/capabilities/tls-identity.ts +3 -2
- package/src/cli.ts +7 -1
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/remote.txt +4 -2
- package/src/commands/guide-content/rg.txt +35 -0
- package/src/commands/guide.ts +11 -4
- package/src/commands/init.ts +27 -0
- package/src/commands/rg.ts +45 -14
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +101 -19
- package/src/server.ts +113 -65
- package/src/spawn-relay.ts +57 -0
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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:
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
*
|
|
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.
|
|
49
|
-
* (
|
|
50
|
-
*
|
|
51
|
-
* per-endpoint documents
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* nav
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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),
|
|
147
|
-
*
|
|
148
|
-
* "you opened a write face" line (ADR-REM-005 verdict C)
|
|
149
|
-
|
|
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
|
-
*
|
|
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):
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
|
|
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).
|
|
293
|
-
*
|
|
294
|
-
|
|
295
|
-
|
|
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 (
|
|
475
|
-
return { error: "'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
544
|
+
request: { query: files ? "" : query!, limit },
|
|
511
545
|
options: {
|
|
512
546
|
explicitEndpoints: [endpointName],
|
|
513
547
|
global: false,
|
|
514
|
-
...(
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|