@catheadowl/ukp 0.2.6 → 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 +83 -16
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +86 -8
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -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 +54 -3
- package/src/cli.ts +32 -64
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +21 -0
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +224 -9
- 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 +5 -5
- package/src/commands/serve.ts +313 -29
- 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 +228 -45
- 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,6 +42,14 @@ 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
|
|
@@ -57,7 +67,12 @@ import { KitUsageError } from "./commands/kit.ts";
|
|
|
57
67
|
* modes, so client-side TOFU and name-assertion paths are shared. The
|
|
58
68
|
* capability layer runs unchanged behind the routes (provider/transport
|
|
59
69
|
* axes stay orthogonal): search via `runSearch` with a single-endpoint
|
|
60
|
-
* scope, read via `runRead`, nav via `runNav`, rg via `runRg`.
|
|
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
|
|
61
76
|
* session, no streaming, and no server-side artifact: responses inline the
|
|
62
77
|
* reference data (RQ-07) and generate a serve-scoped run id. */
|
|
63
78
|
|
|
@@ -96,9 +111,24 @@ export interface ServeConfig {
|
|
|
96
111
|
* readable without it). In door mode the same gate covers all
|
|
97
112
|
* `/e/<name>/v1/*` routes (door-level auth, O-2). */
|
|
98
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;
|
|
99
127
|
/** TLS transport (W5' / D-079): `self-signed` generates and persists an
|
|
100
|
-
* identity
|
|
101
|
-
*
|
|
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);
|
|
102
132
|
* `certificates` serves operator-provided PEM files (Let's Encrypt IP
|
|
103
133
|
* certs, mkcert, private CA). `sanEntries` (the `--tls-san` flag, already
|
|
104
134
|
* normalized `IP:x`/`DNS:y`) merges extra coverage into the self-signed
|
|
@@ -168,6 +198,11 @@ export interface ServeInfo {
|
|
|
168
198
|
host: string;
|
|
169
199
|
port: number;
|
|
170
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 };
|
|
171
206
|
/** ripgrep availability probed once at startup IN THE DOOR'S OWN PROCESS
|
|
172
207
|
* (2026-09-20, liku feedback): /v1/rg serves every endpoint and a missing
|
|
173
208
|
* binary degrades per-endpoint on the wire by design — but that truth is
|
|
@@ -299,11 +334,18 @@ export function buildDiscoveryDocument(
|
|
|
299
334
|
* the door's width admits — every one of them, or the `select` whitelist's
|
|
300
335
|
* members (ADR-REM-010) — read fresh by the caller per request (new
|
|
301
336
|
* endpoints appear without a restart). Remote bindings are not servable and
|
|
302
|
-
* never make the roster.
|
|
303
|
-
|
|
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 }> {
|
|
304
345
|
return readRegistry(registryPath)
|
|
305
346
|
.filter((binding) => binding.kind !== "remote")
|
|
306
347
|
.filter((binding) => select === undefined || select.includes(binding.name))
|
|
348
|
+
.filter((binding) => admit === undefined || admit(binding.name))
|
|
307
349
|
.map((binding) => ({ name: binding.name, folder: binding.path! }));
|
|
308
350
|
}
|
|
309
351
|
|
|
@@ -713,8 +755,10 @@ function startServeListener(
|
|
|
713
755
|
|
|
714
756
|
/** TLS material resolution (W5' / D-079), shared by both serve modes: a
|
|
715
757
|
* self-signed identity is generated/re-signed/persisted under `identityDir`
|
|
716
|
-
* (the
|
|
717
|
-
* 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
|
|
718
762
|
* are read from the operator's paths. Bun.serve takes PEM contents
|
|
719
763
|
* (path-string handling is platform-dependent). Every failure — unreadable
|
|
720
764
|
* operator files, an unusable persisted identity, openssl unable to sign —
|
|
@@ -759,19 +803,95 @@ function resolveTlsMaterial(
|
|
|
759
803
|
}
|
|
760
804
|
}
|
|
761
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. */
|
|
762
860
|
function bearerGate(
|
|
763
861
|
request: Request,
|
|
764
|
-
|
|
765
|
-
):
|
|
766
|
-
if ((
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
401,
|
|
772
|
-
);
|
|
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() };
|
|
773
869
|
}
|
|
774
|
-
|
|
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);
|
|
775
895
|
}
|
|
776
896
|
|
|
777
897
|
/** Per-request endpoint resolution for door routing (O-2): the registry is
|
|
@@ -781,11 +901,15 @@ function bearerGate(
|
|
|
781
901
|
* single-mode discovery fetch does. A subset door (ADR-REM-010) routes an
|
|
782
902
|
* excluded name into the SAME roster-404 branch as an unknown one — the
|
|
783
903
|
* response is byte-identical either way, so the roster is the only name
|
|
784
|
-
* list any response ever carries.
|
|
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. */
|
|
785
908
|
function resolveDoorRoute(
|
|
786
909
|
registryPath: string,
|
|
787
910
|
name: string,
|
|
788
911
|
select?: readonly string[],
|
|
912
|
+
admit?: (name: string) => boolean,
|
|
789
913
|
): { folder: string } | { response: Response } {
|
|
790
914
|
let binding;
|
|
791
915
|
try {
|
|
@@ -794,8 +918,13 @@ function resolveDoorRoute(
|
|
|
794
918
|
const reason = error instanceof Error ? error.message : String(error);
|
|
795
919
|
return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
|
|
796
920
|
}
|
|
797
|
-
if (
|
|
798
|
-
|
|
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(", ");
|
|
799
928
|
return {
|
|
800
929
|
response: jsonResponse(
|
|
801
930
|
errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
|
|
@@ -835,10 +964,16 @@ function resolveDoorRoute(
|
|
|
835
964
|
* divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
|
|
836
965
|
* The roster a 404 carries is the door's effective set — on a subset door
|
|
837
966
|
* (ADR-REM-010) that is the select ∩ registry intersection, never the full
|
|
838
|
-
* registry
|
|
839
|
-
|
|
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 {
|
|
840
975
|
if (name !== undefined) {
|
|
841
|
-
const roster = listDoorEndpoints(registryPath, select).map((endpoint) => endpoint.name).join(", ");
|
|
976
|
+
const roster = listDoorEndpoints(registryPath, select, admit).map((endpoint) => endpoint.name).join(", ");
|
|
842
977
|
return jsonResponse(
|
|
843
978
|
errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
|
|
844
979
|
404,
|
|
@@ -867,7 +1002,9 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
867
1002
|
return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
|
|
868
1003
|
}
|
|
869
1004
|
try {
|
|
870
|
-
|
|
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)));
|
|
871
1008
|
} catch (error) {
|
|
872
1009
|
const reason = error instanceof Error ? error.message : String(error);
|
|
873
1010
|
return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
|
|
@@ -892,7 +1029,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
892
1029
|
try {
|
|
893
1030
|
const loaded = loadManifest(resolved.folder);
|
|
894
1031
|
return jsonResponse(
|
|
895
|
-
buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), (config
|
|
1032
|
+
buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), gateArmed(config)),
|
|
896
1033
|
);
|
|
897
1034
|
} catch (error) {
|
|
898
1035
|
const reason = error instanceof Error ? error.message : String(error);
|
|
@@ -900,18 +1037,22 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
900
1037
|
}
|
|
901
1038
|
}
|
|
902
1039
|
if (rest.startsWith("/v1/propose/")) {
|
|
903
|
-
const
|
|
904
|
-
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");
|
|
905
1045
|
const id = rest.slice("/v1/propose/".length);
|
|
906
|
-
if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select);
|
|
907
|
-
const resolved = resolveDoorRoute(config.registryPath, name, select);
|
|
1046
|
+
if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select, admit);
|
|
1047
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select, admit);
|
|
908
1048
|
if ("response" in resolved) return resolved.response;
|
|
909
1049
|
return await handlePropose(request, config, name, resolved.folder, id);
|
|
910
1050
|
}
|
|
911
1051
|
if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
|
|
912
|
-
const
|
|
913
|
-
if (
|
|
914
|
-
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);
|
|
915
1056
|
if ("response" in resolved) return resolved.response;
|
|
916
1057
|
if (rest === "/v1/search") {
|
|
917
1058
|
return await handleSearch(request, config, name, resolved.folder);
|
|
@@ -920,7 +1061,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
920
1061
|
return handleRead(url, request, config, name);
|
|
921
1062
|
}
|
|
922
1063
|
if (rest === "/v1/nav") {
|
|
923
|
-
return handleNav(url, request, config, name);
|
|
1064
|
+
return await handleNav(url, request, config, name);
|
|
924
1065
|
}
|
|
925
1066
|
return handleRg(url, request, config, name);
|
|
926
1067
|
}
|
|
@@ -938,6 +1079,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
938
1079
|
...(config.systemdSocket === true ? { systemdSocket: true } : {}),
|
|
939
1080
|
});
|
|
940
1081
|
const { server } = listener;
|
|
1082
|
+
const tokenFile = tokenFileSnapshot(config);
|
|
941
1083
|
|
|
942
1084
|
const info: ServeInfo = {
|
|
943
1085
|
mode: "door",
|
|
@@ -949,7 +1091,8 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
949
1091
|
url: listener.publicUrl,
|
|
950
1092
|
host,
|
|
951
1093
|
port: listener.port,
|
|
952
|
-
authRequired: (config
|
|
1094
|
+
authRequired: gateArmed(config),
|
|
1095
|
+
...(tokenFile !== undefined ? { tokenFile } : {}),
|
|
953
1096
|
rg: rgExecutableAvailable() ? "ok" : "missing",
|
|
954
1097
|
...(tlsMaterial !== undefined
|
|
955
1098
|
? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
|
|
@@ -959,6 +1102,19 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
959
1102
|
return { server, info, stopAll: listener.stopAll };
|
|
960
1103
|
}
|
|
961
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
|
+
|
|
962
1118
|
export function startUkpServer(config: ServeConfig): StartedServe {
|
|
963
1119
|
const host = config.host ?? "127.0.0.1";
|
|
964
1120
|
|
|
@@ -988,7 +1144,21 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
988
1144
|
);
|
|
989
1145
|
}
|
|
990
1146
|
|
|
991
|
-
|
|
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);
|
|
992
1162
|
|
|
993
1163
|
const handler = async (request: Request): Promise<Response> => {
|
|
994
1164
|
const url = new URL(request.url);
|
|
@@ -1011,7 +1181,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1011
1181
|
);
|
|
1012
1182
|
}
|
|
1013
1183
|
return jsonResponse(
|
|
1014
|
-
buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), (config
|
|
1184
|
+
buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), gateArmed(config)),
|
|
1015
1185
|
);
|
|
1016
1186
|
} catch (error) {
|
|
1017
1187
|
return jsonResponse(
|
|
@@ -1022,8 +1192,15 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1022
1192
|
}
|
|
1023
1193
|
|
|
1024
1194
|
if (url.pathname.startsWith("/v1/propose/")) {
|
|
1025
|
-
const
|
|
1026
|
-
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
|
+
}
|
|
1027
1204
|
const id = url.pathname.slice("/v1/propose/".length);
|
|
1028
1205
|
if (id.includes("/")) {
|
|
1029
1206
|
return jsonResponse(
|
|
@@ -1043,8 +1220,12 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1043
1220
|
|| url.pathname === "/v1/nav"
|
|
1044
1221
|
|| url.pathname === "/v1/rg"
|
|
1045
1222
|
) {
|
|
1046
|
-
const
|
|
1047
|
-
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
|
+
}
|
|
1048
1229
|
if (url.pathname === "/v1/search") {
|
|
1049
1230
|
return await handleSearch(request, config, binding.name, serviceFolder);
|
|
1050
1231
|
}
|
|
@@ -1052,7 +1233,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1052
1233
|
return handleRead(url, request, config, binding.name);
|
|
1053
1234
|
}
|
|
1054
1235
|
if (url.pathname === "/v1/nav") {
|
|
1055
|
-
return handleNav(url, request, config, binding.name);
|
|
1236
|
+
return await handleNav(url, request, config, binding.name);
|
|
1056
1237
|
}
|
|
1057
1238
|
return handleRg(url, request, config, binding.name);
|
|
1058
1239
|
}
|
|
@@ -1074,6 +1255,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1074
1255
|
...(config.systemdSocket === true ? { systemdSocket: true } : {}),
|
|
1075
1256
|
});
|
|
1076
1257
|
const { server } = listener;
|
|
1258
|
+
const tokenFile = tokenFileSnapshot(config);
|
|
1077
1259
|
|
|
1078
1260
|
const info: ServeInfo = {
|
|
1079
1261
|
mode: "endpoint",
|
|
@@ -1083,7 +1265,8 @@ export function startUkpServer(config: ServeConfig): StartedServe {
|
|
|
1083
1265
|
url: listener.publicUrl,
|
|
1084
1266
|
host,
|
|
1085
1267
|
port: listener.port,
|
|
1086
|
-
authRequired: (config
|
|
1268
|
+
authRequired: gateArmed(config),
|
|
1269
|
+
...(tokenFile !== undefined ? { tokenFile } : {}),
|
|
1087
1270
|
rg: rgExecutableAvailable() ? "ok" : "missing",
|
|
1088
1271
|
...(tlsMaterial !== undefined
|
|
1089
1272
|
? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
|
|
@@ -1182,7 +1365,7 @@ function handleRead(url: URL, request: Request, config: ServeConfig, endpointNam
|
|
|
1182
1365
|
/** GET /v1/nav?path=&depth= → the nav envelope IS the response (W6): the
|
|
1183
1366
|
* client renders it through the same renderNavHuman/renderNavJson path as a
|
|
1184
1367
|
* local run. Failures use the transport error shape with nav's classes. */
|
|
1185
|
-
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> {
|
|
1186
1369
|
if (request.method !== "GET") {
|
|
1187
1370
|
return jsonResponse(errorBody("method-not-allowed", "/v1/nav is a GET route"), 405);
|
|
1188
1371
|
}
|
|
@@ -1190,7 +1373,7 @@ function handleNav(url: URL, request: Request, config: ServeConfig, endpointName
|
|
|
1190
1373
|
if ("error" in parsed) {
|
|
1191
1374
|
return jsonResponse(errorBody("usage-error", parsed.error), 400);
|
|
1192
1375
|
}
|
|
1193
|
-
const outcome = runNav(parsed.request, {
|
|
1376
|
+
const outcome = await runNav(parsed.request, {
|
|
1194
1377
|
currentDirectory: config.currentDirectory,
|
|
1195
1378
|
registryPath: config.registryPath,
|
|
1196
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
|