@catheadowl/ukp 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -21
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +175 -19
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -0
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/nav.ts +209 -444
- package/src/capabilities/qmd.ts +19 -0
- package/src/capabilities/read.ts +18 -2
- package/src/capabilities/remote-client.ts +4 -2
- package/src/capabilities/search.ts +23 -7
- package/src/capabilities/session.ts +455 -0
- package/src/capabilities/tls-identity.ts +55 -3
- package/src/cli.ts +38 -64
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +22 -1
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +243 -1
- package/src/commands/inventory.ts +277 -17
- package/src/commands/nav.ts +17 -12
- package/src/commands/read.ts +1 -1
- package/src/commands/rg.ts +2 -2
- package/src/commands/search.ts +19 -3
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +410 -44
- package/src/commands/session.ts +541 -0
- package/src/commands/use.ts +178 -0
- package/src/config/agent-backed.ts +136 -0
- package/src/config/manifest.ts +39 -3
- package/src/manifest.yml +15 -0
- package/src/path-safety.ts +9 -4
- package/src/plumbing/token-file.ts +368 -0
- package/src/registry.ts +78 -1
- package/src/server.ts +291 -75
- package/src/spawn-relay.ts +21 -3
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { readFileSync, writeFileSync, fstatSync, constants as fsConstants } from "node:fs";
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { createServer as netCreateServer, connect as netConnect, type Server as NetServer } from "node:net";
|
|
4
5
|
import { ENDPOINT_NAME, loadManifest, type LoadedManifest } from "./config/manifest.ts";
|
|
5
6
|
import { localPathOf, readRegistry } from "./registry.ts";
|
|
@@ -12,7 +13,8 @@ import {
|
|
|
12
13
|
proposeEnvelope,
|
|
13
14
|
proposeUpsert,
|
|
14
15
|
} from "./capabilities/propose.ts";
|
|
15
|
-
import { certSanOf, ensureSelfSignedTlsFiles, spkiPinOf } from "./capabilities/tls-identity.ts";
|
|
16
|
+
import { certSanOf, ensureSelfSignedTlsFiles, migrateLegacyServiceTlsDir, spkiPinOf } from "./capabilities/tls-identity.ts";
|
|
17
|
+
import { retireTlsIgnoreLine } from "./commands/init.ts";
|
|
16
18
|
import {
|
|
17
19
|
buildInlineReferences,
|
|
18
20
|
projectSearchEnvelope,
|
|
@@ -40,22 +42,39 @@ import {
|
|
|
40
42
|
type ParsedRg,
|
|
41
43
|
} from "./capabilities/rg.ts";
|
|
42
44
|
import { KitUsageError } from "./commands/kit.ts";
|
|
45
|
+
import {
|
|
46
|
+
authenticateBearerKey,
|
|
47
|
+
loadTokenFile,
|
|
48
|
+
parseTokenFileText,
|
|
49
|
+
scopeAllows,
|
|
50
|
+
type ScopeAccess,
|
|
51
|
+
type TokenFileEntry,
|
|
52
|
+
} from "./plumbing/token-file.ts";
|
|
43
53
|
|
|
44
54
|
/** ukp-remote wire v1 server core (ADR-REM-001/002/003, ukp_remote W1/W7):
|
|
45
55
|
* two serving shapes over one wire. Single-endpoint mode exposes ONE
|
|
46
56
|
* registered endpoint; door mode (`endpointName` absent, ADR-REM-004) serves
|
|
47
|
-
*
|
|
57
|
+
* local bindings in the registry, routed by name at `/e/<name>/…` with
|
|
48
58
|
* a `scope:"host"` door document — the registry is read fresh per request so
|
|
49
|
-
* the door grows without restart.
|
|
50
|
-
* (
|
|
51
|
-
*
|
|
52
|
-
* per-endpoint documents
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* nav
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
+
* the door grows without restart. Door mode comes in two widths
|
|
60
|
+
* (ADR-REM-010): every local binding, or the `select` whitelist subset. To
|
|
61
|
+
* anyone the subset excludes, the door pretends those endpoints do not
|
|
62
|
+
* exist — roster, routes, per-endpoint documents, and 404 rosters all see
|
|
63
|
+
* the same effective set (select ∩ current registry). Both shapes expose a
|
|
64
|
+
* discovery document (manifest projection + protocol version + instance
|
|
65
|
+
* identity) plus the read-side capability routes (search/read since W1,
|
|
66
|
+
* nav/rg since W6); the per-endpoint documents are byte-identical across
|
|
67
|
+
* modes, so client-side TOFU and name-assertion paths are shared. The
|
|
68
|
+
* capability layer runs unchanged behind the routes (provider/transport
|
|
69
|
+
* axes stay orthogonal): search via `runSearch` with a single-endpoint
|
|
70
|
+
* scope, read via `runRead`, nav via `runNav`, rg via `runRg`. The bearer
|
|
71
|
+
* gate has two sources (W13 / RQ-16c): env tokens — the full-power
|
|
72
|
+
* shortcut, original RQ-16 semantics — or the labeled token file, read
|
|
73
|
+
* fresh per request so deleting a line revokes without a restart; a file
|
|
74
|
+
* key's scopes narrow the door's rosters exactly like `--select` does
|
|
75
|
+
* (ADR-REM-010 mechanism reuse, no 403 class). There is no
|
|
76
|
+
* session, no streaming, and no server-side artifact: responses inline the
|
|
77
|
+
* reference data (RQ-07) and generate a serve-scoped run id. */
|
|
59
78
|
|
|
60
79
|
export const PROTOCOL_NAME = "ukp-remote";
|
|
61
80
|
export const PROTOCOL_VERSION = "1";
|
|
@@ -66,11 +85,20 @@ export const DISCOVERY_PATH = "/.well-known/ukp.json";
|
|
|
66
85
|
export const PROPOSE_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
67
86
|
|
|
68
87
|
export interface ServeConfig {
|
|
69
|
-
/** Absent = host door mode (ADR-REM-004 / O-2): serve
|
|
70
|
-
*
|
|
71
|
-
*
|
|
88
|
+
/** Absent = host door mode (ADR-REM-004 / O-2): serve local bindings in
|
|
89
|
+
* the registry over one listener, routed by name at `/e/<name>/…`, with a
|
|
90
|
+
* `scope:"host"` door document at the well-known path. Present = the
|
|
72
91
|
* original single-endpoint mode (N=1 retreat stays legal long-term). */
|
|
73
92
|
endpointName?: string;
|
|
93
|
+
/** Subset door (ADR-REM-010 / O-020 ruling D): a declarative name
|
|
94
|
+
* whitelist next to an ABSENT endpointName — the door serves only the
|
|
95
|
+
* selected local bindings, and to anyone the list excludes the door
|
|
96
|
+
* pretends those endpoints do not exist (roster, routes, per-endpoint
|
|
97
|
+
* documents, 404 rosters all carry the same effective set). The server
|
|
98
|
+
* layer does NOT validate the names at startup — the command layer's
|
|
99
|
+
* eager check (usage error with the roster) is the admission; here the
|
|
100
|
+
* set just intersects with the per-request registry read. */
|
|
101
|
+
select?: readonly string[];
|
|
74
102
|
currentDirectory: string;
|
|
75
103
|
registryPath: string;
|
|
76
104
|
qmdCommand?: readonly string[];
|
|
@@ -83,9 +111,24 @@ export interface ServeConfig {
|
|
|
83
111
|
* readable without it). In door mode the same gate covers all
|
|
84
112
|
* `/e/<name>/v1/*` routes (door-level auth, O-2). */
|
|
85
113
|
tokens?: readonly string[];
|
|
114
|
+
/** W13 / RQ-16c: the labeled token file (`~/.ukp/tokens`). When `tokens`
|
|
115
|
+
* is absent, the gate arms from this file instead: it is read FRESH on
|
|
116
|
+
* every request (revocation = deleting the line, no restart), a presented
|
|
117
|
+
* `ukp_<label>_<id>_<secret>` key is verified against the row's SHA-256,
|
|
118
|
+
* and the matched row's scopes decide which endpoints the key may touch
|
|
119
|
+
* (`name:ro|rw`, Docker-registry grammar, required per row). When
|
|
120
|
+
* `tokens` IS set, env wins outright and the file is not consulted —
|
|
121
|
+
* env is the full-power shortcut form of the same gate (AWS/gh/kubectl
|
|
122
|
+
* chain order). Scope enforcement reuses the ADR-REM-010 roster
|
|
123
|
+
* mechanism: an out-of-scope name shares the roster 404 with an unknown
|
|
124
|
+
* one, byte-identical, and the roster a scoped 404 carries is the key's
|
|
125
|
+
* own effective set — no new 403 class exists. */
|
|
126
|
+
tokenFilePath?: string;
|
|
86
127
|
/** TLS transport (W5' / D-079): `self-signed` generates and persists an
|
|
87
|
-
* identity
|
|
88
|
-
*
|
|
128
|
+
* identity in the machine home beside the registry — `~/.ukp/tls/<name>/`
|
|
129
|
+
* in endpoint mode, the flat `~/.ukp/tls/` in door mode (a door has no
|
|
130
|
+
* single Service folder; endpoint mode converged on the same home
|
|
131
|
+
* 2026-11-01 because the identity is machine-scoped material);
|
|
89
132
|
* `certificates` serves operator-provided PEM files (Let's Encrypt IP
|
|
90
133
|
* certs, mkcert, private CA). `sanEntries` (the `--tls-san` flag, already
|
|
91
134
|
* normalized `IP:x`/`DNS:y`) merges extra coverage into the self-signed
|
|
@@ -144,21 +187,29 @@ export interface ServeInfo {
|
|
|
144
187
|
folder?: string;
|
|
145
188
|
instanceUid?: string;
|
|
146
189
|
/** Door mode fields (present iff mode === "door"): the startup snapshot of
|
|
147
|
-
* servable endpoint names (the door itself grows without restart),
|
|
148
|
-
*
|
|
149
|
-
* "you opened a write face" line (ADR-REM-005 verdict C)
|
|
150
|
-
|
|
190
|
+
* servable endpoint names (the door itself grows without restart), the
|
|
191
|
+
* snapshot of write-capable (propose-declaring) names — the banner's
|
|
192
|
+
* "you opened a write face" line (ADR-REM-005 verdict C) — and, on a
|
|
193
|
+
* subset door (ADR-REM-010), the declared select whitelist itself (the
|
|
194
|
+
* banner's `select:` line: what was asked for, next to what is being
|
|
195
|
+
* served). */
|
|
196
|
+
door?: { endpoints: string[]; write: string[]; select?: string[] };
|
|
151
197
|
url: string;
|
|
152
198
|
host: string;
|
|
153
199
|
port: number;
|
|
154
200
|
authRequired: boolean;
|
|
201
|
+
/** Present when the gate armed from the token file instead of env
|
|
202
|
+
* (W13 / RQ-16c) — feeds the banner's operator line. `keys` is a startup
|
|
203
|
+
* snapshot for visibility only; the live truth is the per-request fresh
|
|
204
|
+
* read (delete a line, next request 401s). */
|
|
205
|
+
tokenFile?: { keys: number };
|
|
155
206
|
/** ripgrep availability probed once at startup IN THE DOOR'S OWN PROCESS
|
|
156
207
|
* (2026-09-20, liku feedback): /v1/rg serves every endpoint and a missing
|
|
157
208
|
* binary degrades per-endpoint on the wire by design — but that truth is
|
|
158
209
|
* consumer-channel only. The banner line is the operator channel, and the
|
|
159
210
|
* probe environment is by construction the one that matters (ukp diagnose
|
|
160
211
|
* measures the invoking shell instead, which can differ from the door's
|
|
161
|
-
*
|
|
212
|
+
* Task Scheduler / wake environment). */
|
|
162
213
|
rg: "ok" | "missing";
|
|
163
214
|
/** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
|
|
164
215
|
* what remote clients TOFU-pin at registration. `source` "re-signed" marks
|
|
@@ -279,21 +330,32 @@ export function buildDiscoveryDocument(
|
|
|
279
330
|
};
|
|
280
331
|
}
|
|
281
332
|
|
|
282
|
-
/** Servable endpoints of a door (O-2):
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
|
|
333
|
+
/** Servable endpoints of a door (O-2): the LOCAL bindings in the registry
|
|
334
|
+
* the door's width admits — every one of them, or the `select` whitelist's
|
|
335
|
+
* members (ADR-REM-010) — read fresh by the caller per request (new
|
|
336
|
+
* endpoints appear without a restart). Remote bindings are not servable and
|
|
337
|
+
* never make the roster. W13 adds the per-key narrowing axis: `admit` is
|
|
338
|
+
* the matched file key's scope predicate for the route's access level, so
|
|
339
|
+
* the roster a scoped caller can ever see is scope ∩ select ∩ registry. */
|
|
340
|
+
function listDoorEndpoints(
|
|
341
|
+
registryPath: string,
|
|
342
|
+
select?: readonly string[],
|
|
343
|
+
admit?: (name: string) => boolean,
|
|
344
|
+
): Array<{ name: string; folder: string }> {
|
|
286
345
|
return readRegistry(registryPath)
|
|
287
346
|
.filter((binding) => binding.kind !== "remote")
|
|
347
|
+
.filter((binding) => select === undefined || select.includes(binding.name))
|
|
348
|
+
.filter((binding) => admit === undefined || admit(binding.name))
|
|
288
349
|
.map((binding) => ({ name: binding.name, folder: binding.path! }));
|
|
289
350
|
}
|
|
290
351
|
|
|
291
352
|
/** Startup snapshot of write-capable (propose-declaring) door endpoints —
|
|
292
353
|
* the banner line that makes the opened write face visible (ADR-REM-005
|
|
293
|
-
* verdict C).
|
|
294
|
-
*
|
|
295
|
-
|
|
296
|
-
|
|
354
|
+
* verdict C). Subset doors narrow it to the select set (ADR-REM-010).
|
|
355
|
+
* Unreadable/name-drifted Services drop out silently here; they are already
|
|
356
|
+
* reported by the roster/discovery paths. */
|
|
357
|
+
function listWriteEndpoints(registryPath: string, select?: readonly string[]): string[] {
|
|
358
|
+
return listDoorEndpoints(registryPath, select).flatMap((endpoint) => {
|
|
297
359
|
try {
|
|
298
360
|
const loaded = loadManifest(endpoint.folder);
|
|
299
361
|
return loaded.effectiveName === endpoint.name
|
|
@@ -693,8 +755,10 @@ function startServeListener(
|
|
|
693
755
|
|
|
694
756
|
/** TLS material resolution (W5' / D-079), shared by both serve modes: a
|
|
695
757
|
* self-signed identity is generated/re-signed/persisted under `identityDir`
|
|
696
|
-
* (the
|
|
697
|
-
* in door mode — a door has no single Service
|
|
758
|
+
* (the machine home beside the registry: `~/.ukp/tls/<name>/` in endpoint
|
|
759
|
+
* mode, the home's flat root in door mode — a door has no single Service
|
|
760
|
+
* folder, and endpoint mode converged on the same home 2026-11-01 because
|
|
761
|
+
* the identity is machine-scoped material), explicit certificates
|
|
698
762
|
* are read from the operator's paths. Bun.serve takes PEM contents
|
|
699
763
|
* (path-string handling is platform-dependent). Every failure — unreadable
|
|
700
764
|
* operator files, an unusable persisted identity, openssl unable to sign —
|
|
@@ -739,29 +803,113 @@ function resolveTlsMaterial(
|
|
|
739
803
|
}
|
|
740
804
|
}
|
|
741
805
|
|
|
806
|
+
/** CHK-01 ruling (D-100 / ADR 0027): the bearer check hashes both sides
|
|
807
|
+
* with SHA-256 and compares the fixed 32-byte digests via
|
|
808
|
+
* `crypto.timingSafeEqual`. Hashing first makes the comparison
|
|
809
|
+
* length-uniform — operator-chosen tokens (`UKP_SERVE_TOKEN`, arbitrary
|
|
810
|
+
* length — not fixed-length hex) and attacker-chosen header values can
|
|
811
|
+
* never reach the raw API's equal-length requirement, and no early exit
|
|
812
|
+
* (length mismatch, first differing byte) shapes the failure timing.
|
|
813
|
+
* Every failure class — missing header, non-Bearer scheme, wrong value —
|
|
814
|
+
* runs this one path and then the single 401 below, so responses stay
|
|
815
|
+
* byte-identical across classes (CHK-02, CWE-204 family: the gate is
|
|
816
|
+
* not a credential oracle). */
|
|
817
|
+
export function timingSafeBearerMatch(
|
|
818
|
+
authorization: string | null,
|
|
819
|
+
tokens: readonly string[],
|
|
820
|
+
): boolean {
|
|
821
|
+
const presented = createHash("sha256").update(authorization ?? "").digest();
|
|
822
|
+
return tokens.some((token) =>
|
|
823
|
+
timingSafeEqual(createHash("sha256").update(`Bearer ${token}`).digest(), presented)
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** What the gate established about the caller (W13 / RQ-16c): an env token
|
|
828
|
+
* is the full-power shortcut form (its scope is the whole door, original
|
|
829
|
+
* RQ-16 semantics); a file key carries its row's scopes into authorization.
|
|
830
|
+
* `undefined` = no gate armed (loopback anonymous serving) — not a failure.
|
|
831
|
+
* The two never mix: env present means the file is not consulted at all. */
|
|
832
|
+
type GateCredential =
|
|
833
|
+
| { kind: "env" }
|
|
834
|
+
| { kind: "file"; entry: TokenFileEntry };
|
|
835
|
+
|
|
836
|
+
/** The single 401 every gate failure class emits (CHK-02 / CWE-204 family:
|
|
837
|
+
* the gate is not a credential oracle) — missing header, wrong scheme,
|
|
838
|
+
* unknown key, wrong secret, out-of-scope key on a single-endpoint gate. */
|
|
839
|
+
function authFailureResponse(): Response {
|
|
840
|
+
return jsonResponse(
|
|
841
|
+
errorBody("auth-failure", "missing or invalid bearer token (Authorization: Bearer <token>)"),
|
|
842
|
+
401,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** W13 / RQ-16c: whether ANY auth source is configured — env tokens or the
|
|
847
|
+
* token file. Drives `authRequired` (banner, discovery security.schemes)
|
|
848
|
+
* and the gate itself; with neither, serving is anonymous (the command
|
|
849
|
+
* layer's deny-by-default admission, RQ-18, is what prevents reaching here
|
|
850
|
+
* unarmed on non-loopback binds). */
|
|
851
|
+
function gateArmed(config: ServeConfig): boolean {
|
|
852
|
+
return (config.tokens?.length ?? 0) > 0 || config.tokenFilePath !== undefined;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** The door gate, both sources (W13): env tokens compare through the
|
|
856
|
+
* CHK-01 digest path unchanged; a token file is fresh-read per request
|
|
857
|
+
* (missing/unreadable/unparseable file ⇒ no key can pass — fail closed)
|
|
858
|
+
* and the presented key is verified against exactly its (label, keyId) row
|
|
859
|
+
* with a timing-safe digest compare inside the plumbing module. */
|
|
742
860
|
function bearerGate(
|
|
743
861
|
request: Request,
|
|
744
|
-
|
|
745
|
-
):
|
|
746
|
-
if ((
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
401,
|
|
752
|
-
);
|
|
862
|
+
config: ServeConfig,
|
|
863
|
+
): { credential: GateCredential | undefined } | { response: Response } {
|
|
864
|
+
if (!gateArmed(config)) return { credential: undefined };
|
|
865
|
+
if ((config.tokens?.length ?? 0) > 0) {
|
|
866
|
+
return timingSafeBearerMatch(request.headers.get("authorization"), config.tokens!)
|
|
867
|
+
? { credential: { kind: "env" } }
|
|
868
|
+
: { response: authFailureResponse() };
|
|
753
869
|
}
|
|
754
|
-
|
|
870
|
+
const loaded = loadTokenFile(config.tokenFilePath!);
|
|
871
|
+
if (!("text" in loaded)) return { response: authFailureResponse() };
|
|
872
|
+
const parsed = parseTokenFileText(loaded.text);
|
|
873
|
+
if ("error" in parsed) return { response: authFailureResponse() };
|
|
874
|
+
const authorization = request.headers.get("authorization");
|
|
875
|
+
// Scheme shape is part of the uniform failure path: a non-Bearer header
|
|
876
|
+
// fails exactly like a missing one (single 401, no early distinctions).
|
|
877
|
+
const match = /^Bearer (.+)$/.exec(authorization ?? "");
|
|
878
|
+
if (match === null) return { response: authFailureResponse() };
|
|
879
|
+
const entry = authenticateBearerKey(match[1]!, parsed.entries);
|
|
880
|
+
return entry !== undefined ? { credential: { kind: "file", entry } } : { response: authFailureResponse() };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Per-request roster admission from the matched key's scopes (rehearsal
|
|
884
|
+
* point E): read routes demand `ro`, the propose route demands `rw`. A
|
|
885
|
+
* `ro`-scoped endpoint is therefore absent from the write face's effective
|
|
886
|
+
* set the same way an unselected name is absent from a subset door — same
|
|
887
|
+
* roster-404 shape, no new refusal class. Env credentials and anonymous
|
|
888
|
+
* serving return undefined (the door's own width applies). */
|
|
889
|
+
function scopeAdmit(
|
|
890
|
+
credential: GateCredential | undefined,
|
|
891
|
+
access: ScopeAccess,
|
|
892
|
+
): ((name: string) => boolean) | undefined {
|
|
893
|
+
if (credential?.kind !== "file") return undefined;
|
|
894
|
+
return (name: string) => scopeAllows(credential.entry.scopes, name, access);
|
|
755
895
|
}
|
|
756
896
|
|
|
757
897
|
/** Per-request endpoint resolution for door routing (O-2): the registry is
|
|
758
898
|
* read fresh, the name segment has already passed ENDPOINT_NAME (injection
|
|
759
899
|
* unreachable), unknown and non-servable names get the roster 404, and the
|
|
760
900
|
* RQ-14 binding↔manifest identity check runs per request like the
|
|
761
|
-
* single-mode discovery fetch does.
|
|
901
|
+
* single-mode discovery fetch does. A subset door (ADR-REM-010) routes an
|
|
902
|
+
* excluded name into the SAME roster-404 branch as an unknown one — the
|
|
903
|
+
* response is byte-identical either way, so the roster is the only name
|
|
904
|
+
* list any response ever carries. W13 scopes ride the same branch: an
|
|
905
|
+
* out-of-scope name for the matched key is indistinguishable from an
|
|
906
|
+
* unknown one, and the roster it sees is the key's own effective set
|
|
907
|
+
* (`admit` = scope ∩ select ∩ registry), never the wider door. */
|
|
762
908
|
function resolveDoorRoute(
|
|
763
909
|
registryPath: string,
|
|
764
910
|
name: string,
|
|
911
|
+
select?: readonly string[],
|
|
912
|
+
admit?: (name: string) => boolean,
|
|
765
913
|
): { folder: string } | { response: Response } {
|
|
766
914
|
let binding;
|
|
767
915
|
try {
|
|
@@ -770,8 +918,13 @@ function resolveDoorRoute(
|
|
|
770
918
|
const reason = error instanceof Error ? error.message : String(error);
|
|
771
919
|
return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
|
|
772
920
|
}
|
|
773
|
-
if (
|
|
774
|
-
|
|
921
|
+
if (
|
|
922
|
+
binding === undefined
|
|
923
|
+
|| binding.kind === "remote"
|
|
924
|
+
|| (select !== undefined && !select.includes(name))
|
|
925
|
+
|| (admit !== undefined && !admit(name))
|
|
926
|
+
) {
|
|
927
|
+
const roster = listDoorEndpoints(registryPath, select, admit).map((endpoint) => endpoint.name).join(", ");
|
|
775
928
|
return {
|
|
776
929
|
response: jsonResponse(
|
|
777
930
|
errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
|
|
@@ -808,10 +961,19 @@ function resolveDoorRoute(
|
|
|
808
961
|
|
|
809
962
|
/** Door-mode routing table (ADR-REM-004 / O-2). The door document and the
|
|
810
963
|
* per-endpoint documents stay public (ADR-REM-003 declaration/capability
|
|
811
|
-
* divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
|
|
812
|
-
|
|
964
|
+
* divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
|
|
965
|
+
* The roster a 404 carries is the door's effective set — on a subset door
|
|
966
|
+
* (ADR-REM-010) that is the select ∩ registry intersection, never the full
|
|
967
|
+
* registry; on an authenticated scoped route (W13) it further intersects
|
|
968
|
+
* with the matched key's scopes via `admit`. */
|
|
969
|
+
function doorNotFound(
|
|
970
|
+
name: string | undefined,
|
|
971
|
+
registryPath: string,
|
|
972
|
+
select?: readonly string[],
|
|
973
|
+
admit?: (name: string) => boolean,
|
|
974
|
+
): Response {
|
|
813
975
|
if (name !== undefined) {
|
|
814
|
-
const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
|
|
976
|
+
const roster = listDoorEndpoints(registryPath, select, admit).map((endpoint) => endpoint.name).join(", ");
|
|
815
977
|
return jsonResponse(
|
|
816
978
|
errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
|
|
817
979
|
404,
|
|
@@ -827,6 +989,11 @@ function doorNotFound(name: string | undefined, registryPath: string): Response
|
|
|
827
989
|
}
|
|
828
990
|
|
|
829
991
|
function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnType<typeof resolveTlsMaterial>): StartedServe {
|
|
992
|
+
// The door's width (ADR-REM-010): undefined = the whole registry, a name
|
|
993
|
+
// list = the subset whitelist. One variable feeds every surface below —
|
|
994
|
+
// door document, per-request routes, 404 rosters — so the subset cannot
|
|
995
|
+
// drift between them.
|
|
996
|
+
const select = config.select;
|
|
830
997
|
const handler = async (request: Request): Promise<Response> => {
|
|
831
998
|
const url = new URL(request.url);
|
|
832
999
|
|
|
@@ -835,7 +1002,9 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
835
1002
|
return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
|
|
836
1003
|
}
|
|
837
1004
|
try {
|
|
838
|
-
|
|
1005
|
+
// Public door document (ADR-REM-003): no key presented, no scope
|
|
1006
|
+
// narrowing — the door's own width is what the roster declares.
|
|
1007
|
+
return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath, select), gateArmed(config)));
|
|
839
1008
|
} catch (error) {
|
|
840
1009
|
const reason = error instanceof Error ? error.message : String(error);
|
|
841
1010
|
return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
|
|
@@ -849,18 +1018,18 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
849
1018
|
const name = routeMatch[1]!;
|
|
850
1019
|
const rest = routeMatch[2] ?? "/";
|
|
851
1020
|
if (!ENDPOINT_NAME.test(name)) {
|
|
852
|
-
return doorNotFound(name, config.registryPath);
|
|
1021
|
+
return doorNotFound(name, config.registryPath, select);
|
|
853
1022
|
}
|
|
854
1023
|
if (rest === DISCOVERY_PATH) {
|
|
855
1024
|
if (request.method !== "GET") {
|
|
856
1025
|
return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
|
|
857
1026
|
}
|
|
858
|
-
const resolved = resolveDoorRoute(config.registryPath, name);
|
|
1027
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select);
|
|
859
1028
|
if ("response" in resolved) return resolved.response;
|
|
860
1029
|
try {
|
|
861
1030
|
const loaded = loadManifest(resolved.folder);
|
|
862
1031
|
return jsonResponse(
|
|
863
|
-
buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), (config
|
|
1032
|
+
buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), gateArmed(config)),
|
|
864
1033
|
);
|
|
865
1034
|
} catch (error) {
|
|
866
1035
|
const reason = error instanceof Error ? error.message : String(error);
|
|
@@ -868,18 +1037,22 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
868
1037
|
}
|
|
869
1038
|
}
|
|
870
1039
|
if (rest.startsWith("/v1/propose/")) {
|
|
871
|
-
const
|
|
872
|
-
if (
|
|
1040
|
+
const gate = bearerGate(request, config);
|
|
1041
|
+
if ("response" in gate) return gate.response;
|
|
1042
|
+
// The write face demands the rw bit: a ro-scoped endpoint is not in
|
|
1043
|
+
// this key's effective write roster (rehearsal point E).
|
|
1044
|
+
const admit = scopeAdmit(gate.credential, "rw");
|
|
873
1045
|
const id = rest.slice("/v1/propose/".length);
|
|
874
|
-
if (id.includes("/")) return doorNotFound(undefined, config.registryPath);
|
|
875
|
-
const resolved = resolveDoorRoute(config.registryPath, name);
|
|
1046
|
+
if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select, admit);
|
|
1047
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select, admit);
|
|
876
1048
|
if ("response" in resolved) return resolved.response;
|
|
877
1049
|
return await handlePropose(request, config, name, resolved.folder, id);
|
|
878
1050
|
}
|
|
879
1051
|
if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
|
|
880
|
-
const
|
|
881
|
-
if (
|
|
882
|
-
const
|
|
1052
|
+
const gate = bearerGate(request, config);
|
|
1053
|
+
if ("response" in gate) return gate.response;
|
|
1054
|
+
const admit = scopeAdmit(gate.credential, "ro");
|
|
1055
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select, admit);
|
|
883
1056
|
if ("response" in resolved) return resolved.response;
|
|
884
1057
|
if (rest === "/v1/search") {
|
|
885
1058
|
return await handleSearch(request, config, name, resolved.folder);
|
|
@@ -888,14 +1061,14 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
888
1061
|
return handleRead(url, request, config, name);
|
|
889
1062
|
}
|
|
890
1063
|
if (rest === "/v1/nav") {
|
|
891
|
-
return handleNav(url, request, config, name);
|
|
1064
|
+
return await handleNav(url, request, config, name);
|
|
892
1065
|
}
|
|
893
1066
|
return handleRg(url, request, config, name);
|
|
894
1067
|
}
|
|
895
|
-
return doorNotFound(undefined, config.registryPath);
|
|
1068
|
+
return doorNotFound(undefined, config.registryPath, select);
|
|
896
1069
|
}
|
|
897
1070
|
|
|
898
|
-
return doorNotFound(undefined, config.registryPath);
|
|
1071
|
+
return doorNotFound(undefined, config.registryPath, select);
|
|
899
1072
|
};
|
|
900
1073
|
|
|
901
1074
|
const listener = startServeListener(handler, {
|
|
@@ -906,17 +1079,20 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
906
1079
|
...(config.systemdSocket === true ? { systemdSocket: true } : {}),
|
|
907
1080
|
});
|
|
908
1081
|
const { server } = listener;
|
|
1082
|
+
const tokenFile = tokenFileSnapshot(config);
|
|
909
1083
|
|
|
910
1084
|
const info: ServeInfo = {
|
|
911
1085
|
mode: "door",
|
|
912
1086
|
door: {
|
|
913
|
-
endpoints: listDoorEndpoints(config.registryPath).map((endpoint) => endpoint.name),
|
|
914
|
-
write: listWriteEndpoints(config.registryPath),
|
|
1087
|
+
endpoints: listDoorEndpoints(config.registryPath, select).map((endpoint) => endpoint.name),
|
|
1088
|
+
write: listWriteEndpoints(config.registryPath, select),
|
|
1089
|
+
...(select !== undefined ? { select: [...select] } : {}),
|
|
915
1090
|
},
|
|
916
1091
|
url: listener.publicUrl,
|
|
917
1092
|
host,
|
|
918
1093
|
port: listener.port,
|
|
919
|
-
authRequired: (config
|
|
1094
|
+
authRequired: gateArmed(config),
|
|
1095
|
+
...(tokenFile !== undefined ? { tokenFile } : {}),
|
|
920
1096
|
rg: rgExecutableAvailable() ? "ok" : "missing",
|
|
921
1097
|
...(tlsMaterial !== undefined
|
|
922
1098
|
? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
|
|
@@ -926,6 +1102,19 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
926
1102
|
return { server, info, stopAll: listener.stopAll };
|
|
927
1103
|
}
|
|
928
1104
|
|
|
1105
|
+
/** Startup snapshot of the token file for the banner's operator line
|
|
1106
|
+
* (W13): the live gate re-reads per request; this count is visibility, not
|
|
1107
|
+
* truth. A parse failure here is cosmetic-zero — the command layer's
|
|
1108
|
+
* admission already refused a malformed file, and the gate itself fails
|
|
1109
|
+
* closed on any read/parse problem. */
|
|
1110
|
+
function tokenFileSnapshot(config: ServeConfig): { keys: number } | undefined {
|
|
1111
|
+
if (config.tokenFilePath === undefined || (config.tokens?.length ?? 0) > 0) return undefined;
|
|
1112
|
+
const loaded = loadTokenFile(config.tokenFilePath);
|
|
1113
|
+
if (!("text" in loaded)) return { keys: 0 };
|
|
1114
|
+
const parsed = parseTokenFileText(loaded.text);
|
|
1115
|
+
return "error" in parsed ? { keys: 0 } : { keys: parsed.entries.length };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
929
1118
|
export function startUkpServer(config: ServeConfig): StartedServe {
|
|
930
1119
|
const host = config.host ?? "127.0.0.1";
|
|
931
1120
|
|
|
@@ -955,7 +1144,21 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
955
1144
|
);
|
|
956
1145
|
}
|
|
957
1146
|
|
|
958
|
-
|
|
1147
|
+
// The self-signed identity is machine-scoped material (its SAN coverage
|
|
1148
|
+
// names THIS host's interfaces) and lives in the machine home beside the
|
|
1149
|
+
// registry — `~/.ukp/tls/<endpoint>/` here, the same home's flat root in
|
|
1150
|
+
// door mode (adjudication 2026-11-01, inbox service-dir-layering item 2;
|
|
1151
|
+
// endpoint mode converged on the home door mode already used). Keyed by
|
|
1152
|
+
// the manifest's effective name: the registry namespace holds one
|
|
1153
|
+
// endpoint per name, so a re-cloned folder re-attaches to its pinned
|
|
1154
|
+
// identity instead of orphaning every client (INV-04). A pre-2026-11-01
|
|
1155
|
+
// `.ukp/tls/` moves in once, keypair untouched; the retired `/tls/`
|
|
1156
|
+
// ignore rule swaps out in the same gesture.
|
|
1157
|
+
const identityDir = join(dirname(config.registryPath), "tls", startupManifest.effectiveName);
|
|
1158
|
+
if (migrateLegacyServiceTlsDir(serviceFolder, identityDir) === "moved") {
|
|
1159
|
+
retireTlsIgnoreLine(join(serviceFolder, ".ukp"));
|
|
1160
|
+
}
|
|
1161
|
+
const tlsMaterial = resolveTlsMaterial(config, identityDir);
|
|
959
1162
|
|
|
960
1163
|
const handler = async (request: Request): Promise<Response> => {
|
|
961
1164
|
const url = new URL(request.url);
|
|
@@ -978,7 +1181,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
978
1181
|
);
|
|
979
1182
|
}
|
|
980
1183
|
return jsonResponse(
|
|
981
|
-
buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), (config
|
|
1184
|
+
buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), gateArmed(config)),
|
|
982
1185
|
);
|
|
983
1186
|
} catch (error) {
|
|
984
1187
|
return jsonResponse(
|
|
@@ -989,8 +1192,15 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
989
1192
|
}
|
|
990
1193
|
|
|
991
1194
|
if (url.pathname.startsWith("/v1/propose/")) {
|
|
992
|
-
const
|
|
993
|
-
if (
|
|
1195
|
+
const gate = bearerGate(request, config);
|
|
1196
|
+
if ("response" in gate) return gate.response;
|
|
1197
|
+
// Single-endpoint gates have no roster to hide behind: a key that
|
|
1198
|
+
// does not carry this endpoint at rw is simply not valid for this
|
|
1199
|
+
// gate's write face — the same uniform 401, no new refusal class.
|
|
1200
|
+
if (gate.credential?.kind === "file"
|
|
1201
|
+
&& !scopeAllows(gate.credential.entry.scopes, binding.name, "rw")) {
|
|
1202
|
+
return authFailureResponse();
|
|
1203
|
+
}
|
|
994
1204
|
const id = url.pathname.slice("/v1/propose/".length);
|
|
995
1205
|
if (id.includes("/")) {
|
|
996
1206
|
return jsonResponse(
|
|
@@ -1010,8 +1220,12 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1010
1220
|
|| url.pathname === "/v1/nav"
|
|
1011
1221
|
|| url.pathname === "/v1/rg"
|
|
1012
1222
|
) {
|
|
1013
|
-
const
|
|
1014
|
-
if (
|
|
1223
|
+
const gate = bearerGate(request, config);
|
|
1224
|
+
if ("response" in gate) return gate.response;
|
|
1225
|
+
if (gate.credential?.kind === "file"
|
|
1226
|
+
&& !scopeAllows(gate.credential.entry.scopes, binding.name, "ro")) {
|
|
1227
|
+
return authFailureResponse();
|
|
1228
|
+
}
|
|
1015
1229
|
if (url.pathname === "/v1/search") {
|
|
1016
1230
|
return await handleSearch(request, config, binding.name, serviceFolder);
|
|
1017
1231
|
}
|
|
@@ -1019,7 +1233,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1019
1233
|
return handleRead(url, request, config, binding.name);
|
|
1020
1234
|
}
|
|
1021
1235
|
if (url.pathname === "/v1/nav") {
|
|
1022
|
-
return handleNav(url, request, config, binding.name);
|
|
1236
|
+
return await handleNav(url, request, config, binding.name);
|
|
1023
1237
|
}
|
|
1024
1238
|
return handleRg(url, request, config, binding.name);
|
|
1025
1239
|
}
|
|
@@ -1041,6 +1255,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1041
1255
|
...(config.systemdSocket === true ? { systemdSocket: true } : {}),
|
|
1042
1256
|
});
|
|
1043
1257
|
const { server } = listener;
|
|
1258
|
+
const tokenFile = tokenFileSnapshot(config);
|
|
1044
1259
|
|
|
1045
1260
|
const info: ServeInfo = {
|
|
1046
1261
|
mode: "endpoint",
|
|
@@ -1050,7 +1265,8 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1050
1265
|
url: listener.publicUrl,
|
|
1051
1266
|
host,
|
|
1052
1267
|
port: listener.port,
|
|
1053
|
-
authRequired: (config
|
|
1268
|
+
authRequired: gateArmed(config),
|
|
1269
|
+
...(tokenFile !== undefined ? { tokenFile } : {}),
|
|
1054
1270
|
rg: rgExecutableAvailable() ? "ok" : "missing",
|
|
1055
1271
|
...(tlsMaterial !== undefined
|
|
1056
1272
|
? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
|
|
@@ -1149,7 +1365,7 @@ function handleRead(url: URL, request: Request, config: ServeConfig, endpointNam
|
|
|
1149
1365
|
/** GET /v1/nav?path=&depth= → the nav envelope IS the response (W6): the
|
|
1150
1366
|
* client renders it through the same renderNavHuman/renderNavJson path as a
|
|
1151
1367
|
* local run. Failures use the transport error shape with nav's classes. */
|
|
1152
|
-
function handleNav(url: URL, request: Request, config: ServeConfig, endpointName: string): Response {
|
|
1368
|
+
async function handleNav(url: URL, request: Request, config: ServeConfig, endpointName: string): Promise<Response> {
|
|
1153
1369
|
if (request.method !== "GET") {
|
|
1154
1370
|
return jsonResponse(errorBody("method-not-allowed", "/v1/nav is a GET route"), 405);
|
|
1155
1371
|
}
|
|
@@ -1157,7 +1373,7 @@ function handleNav(url: URL, request: Request, config: ServeConfig, endpointName
|
|
|
1157
1373
|
if ("error" in parsed) {
|
|
1158
1374
|
return jsonResponse(errorBody("usage-error", parsed.error), 400);
|
|
1159
1375
|
}
|
|
1160
|
-
const outcome = runNav(parsed.request, {
|
|
1376
|
+
const outcome = await runNav(parsed.request, {
|
|
1161
1377
|
currentDirectory: config.currentDirectory,
|
|
1162
1378
|
registryPath: config.registryPath,
|
|
1163
1379
|
});
|
package/src/spawn-relay.ts
CHANGED
|
@@ -36,12 +36,30 @@ function psSingleQuoted(arg: string): string {
|
|
|
36
36
|
|
|
37
37
|
/** The relay script. `notFoundGuard` adds a Get-Command pre-check that exits
|
|
38
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.
|
|
39
|
+
* distinguish a missing tool from a tool's own failure exit.
|
|
40
|
+
*
|
|
41
|
+
* The leading $ProgressPreference suppression is load-bearing for the stderr
|
|
42
|
+
* contract (2026-10-09 finding, TODO rg-clixml-pattern-failure-windows): the
|
|
43
|
+
* guard's Get-Command is the script's first cmdlet, which makes the engine
|
|
44
|
+
* emit its one-time "Preparing modules for first use" progress record, and
|
|
45
|
+
* with a redirected stderr powershell.exe serializes that record as a
|
|
46
|
+
* `#< CLIXML` stream ahead of the tool's own stderr — the consumer then
|
|
47
|
+
* surfaces `status: failed: #< CLIXML` instead of the real tool error.
|
|
48
|
+
* Suppressing progress keeps the channel tool-stderr-only; native stderr
|
|
49
|
+
* itself passes through byte-clean, exit code included (verified live:
|
|
50
|
+
* rg's regex parse error arrives readable; UTF-8 match text unaffected). */
|
|
40
51
|
export function toolRelayScript(argv: readonly string[], options: { notFoundGuard?: boolean } = {}): string {
|
|
52
|
+
// The guard resolves via -Name, never -LiteralName: PS 5.1's Get-Command
|
|
53
|
+
// has no -LiteralName parameter, and an unknown parameter on it is not a
|
|
54
|
+
// binding error — the argument is silently swallowed and the call returns
|
|
55
|
+
// EVERY command, so a -LiteralName guard is vacuous (a missing tool then
|
|
56
|
+
// falls through to `&` and its CommandNotFoundException exits 0 with a
|
|
57
|
+
// CLIXML leak; live-verified 2026-10-09 review). -Name globs, but tool
|
|
58
|
+
// names ("rg", "ssh") contain no glob characters.
|
|
41
59
|
const guard = options.notFoundGuard === true
|
|
42
|
-
? " if (-not (Get-Command -
|
|
60
|
+
? " if (-not (Get-Command -Name $a[0] -ErrorAction SilentlyContinue)) { exit 127 };"
|
|
43
61
|
: "";
|
|
44
|
-
return `$a = @(${argv.map(psSingleQuoted).join(",")});${guard} & $a[0] $a[1..($a.Count-1)]; exit $LASTEXITCODE`;
|
|
62
|
+
return `$ProgressPreference = 'SilentlyContinue'; $a = @(${argv.map(psSingleQuoted).join(",")});${guard} & $a[0] $a[1..($a.Count-1)]; exit $LASTEXITCODE`;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
/** The powershell carrier argv for a tool invocation: spawn THIS instead of
|