@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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Labeled credential file for a self-hosted bearer gate (ukp_remote W13 /
|
|
6
|
+
* RQ-16c). This module is the first resident of the `src/plumbing/` subtree:
|
|
7
|
+
* a purity-tested, zero-UKP-import library (only `node:` builtins and
|
|
8
|
+
* subtree-relative imports are legal — `test/plumbing-purity.test.ts`
|
|
9
|
+
* enforces it mechanically). Every UKP semantic (where the file lives, when
|
|
10
|
+
* the gate consults it, how rosters filter) stays with the callers; what
|
|
11
|
+
* lives here is the credential-file mechanics the mainstream already settled:
|
|
12
|
+
*
|
|
13
|
+
* - Key shape `ukp_<label>_<id>_<secret>` (GitHub-PAT-style self-describing
|
|
14
|
+
* prefix; the id lets verification hash exactly one row).
|
|
15
|
+
* - Storage keeps a SHA-256 of the secret only, never the raw key
|
|
16
|
+
* (high-entropy secrets make a fast hash the right trade — no argon2).
|
|
17
|
+
* - One line per key; revocation is deleting (or commenting out) the line.
|
|
18
|
+
* - Scopes are required per line, Docker-registry-grammar
|
|
19
|
+
* `name:access,name:access` with `*` as the explicit full-power name.
|
|
20
|
+
*
|
|
21
|
+
* Evidence SSOT: the ukp_remote line's api-key-file-patterns research note. */
|
|
22
|
+
|
|
23
|
+
/** Column grammar: `<label> <id> sha256$<hex> <scopes>`, whitespace-run
|
|
24
|
+
* separated. `#`-prefixed lines and blanks are comments/spacers (revocation
|
|
25
|
+
* by commenting a line out stays a legal hand edit; the managed rewrites
|
|
26
|
+
* preserve them). The `sha256$` algorithm prefix follows the htpasswd/
|
|
27
|
+
* Authelia line-format lineage so a future algorithm is a new prefix, never
|
|
28
|
+
* an autodetect guess. */
|
|
29
|
+
export const TOKEN_HASH_ALGORITHM = "sha256";
|
|
30
|
+
|
|
31
|
+
/** Label and scope-name grammar — endpoint-name-shaped
|
|
32
|
+
* (`^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$`). Duplicated from the UKP
|
|
33
|
+
* config layer on purpose: this subtree may not import UKP code, and the
|
|
34
|
+
* key's own segments (label) must be `_`/`:`-free so the bearer string
|
|
35
|
+
* splits unambiguously. */
|
|
36
|
+
const NAME_GRAMMAR = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
37
|
+
|
|
38
|
+
/** Scope wildcard: `*:rw` is the explicit full-power row (scope column is
|
|
39
|
+
* required — "no scopes listed" never means full power). */
|
|
40
|
+
export const SCOPE_WILDCARD = "*";
|
|
41
|
+
|
|
42
|
+
/** The bearer secret minted per key: 32 random bytes, base64url-unpadded
|
|
43
|
+
* (43 chars). High entropy is the premise of the fast-hash storage and of
|
|
44
|
+
* skipping online-bruteforce hardening. */
|
|
45
|
+
const SECRET_BYTES = 32;
|
|
46
|
+
|
|
47
|
+
/** Key id minted per key: 8 random bytes as lowercase hex (16 chars) —
|
|
48
|
+
* enough to make id collisions a non-event at self-hosted scale while the
|
|
49
|
+
* id stays the audit/display handle. */
|
|
50
|
+
const KEY_ID_BYTES = 8;
|
|
51
|
+
|
|
52
|
+
export type ScopeAccess = "ro" | "rw";
|
|
53
|
+
|
|
54
|
+
export interface TokenFileEntry {
|
|
55
|
+
label: string;
|
|
56
|
+
keyId: string;
|
|
57
|
+
/** Lowercase hex SHA-256 of the secret — the only secret-derived material
|
|
58
|
+
* the file ever holds. */
|
|
59
|
+
secretSha256: string;
|
|
60
|
+
/** Insertion-ordered scope map: endpoint name (or `*`) → access. */
|
|
61
|
+
scopes: Readonly<Record<string, ScopeAccess>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MintedTokenKey {
|
|
65
|
+
/** `ukp_<label>_<id>_<secret>` — the raw credential, returned exactly
|
|
66
|
+
* once at mint time; the server stores only `line`'s hash column. */
|
|
67
|
+
token: string;
|
|
68
|
+
label: string;
|
|
69
|
+
keyId: string;
|
|
70
|
+
/** Ready-to-append file line for `storeTokenFileText`. */
|
|
71
|
+
line: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Usage-level failures of the pure helpers (bad label, bad scopes spec,
|
|
75
|
+
* bad file text). IO failures surface as thrown node errors from the file
|
|
76
|
+
* helpers below; classification stops at the grammar boundary. */
|
|
77
|
+
export class TokenFileUsageError extends Error {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "TokenFileUsageError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse a scopes column: comma-separated `<name>:(ro|rw)`, at least one
|
|
85
|
+
* entry, no duplicates (a repeated name is a declaration conflict, not a
|
|
86
|
+
* merge — the config-layer strictness stance). `*` composes with specific
|
|
87
|
+
* names; the specific name wins (`*:ro,cad:rw` = read-everything plus write
|
|
88
|
+
* on cad). */
|
|
89
|
+
export function parseTokenScopes(spec: string): Record<string, ScopeAccess> {
|
|
90
|
+
const scopes: Record<string, ScopeAccess> = {};
|
|
91
|
+
const trimmed = spec.trim();
|
|
92
|
+
if (trimmed === "") {
|
|
93
|
+
throw new TokenFileUsageError("scopes must not be empty (full access is the explicit '*:rw')");
|
|
94
|
+
}
|
|
95
|
+
for (const part of trimmed.split(",")) {
|
|
96
|
+
const match = /^(.+):(ro|rw)$/.exec(part.trim());
|
|
97
|
+
if (match === null) {
|
|
98
|
+
throw new TokenFileUsageError(
|
|
99
|
+
`invalid scope '${part.trim()}': expected <endpoint>:(ro|rw) entries, comma-separated (e.g. notes:ro,cad:rw; full access is '*:rw')`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const name = match[1]!;
|
|
103
|
+
if (name !== SCOPE_WILDCARD && !NAME_GRAMMAR.test(name)) {
|
|
104
|
+
throw new TokenFileUsageError(
|
|
105
|
+
`invalid scope name '${name}': lowercase letters, digits, single hyphens, 1-63 chars (or '*')`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (Object.prototype.hasOwnProperty.call(scopes, name)) {
|
|
109
|
+
throw new TokenFileUsageError(`duplicate scope name '${name}' in '${spec}'`);
|
|
110
|
+
}
|
|
111
|
+
scopes[name] = match[2] as ScopeAccess;
|
|
112
|
+
}
|
|
113
|
+
return scopes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Scope judgment: the specific row wins over `*`, and `rw` satisfies `ro`
|
|
117
|
+
* demands. Used per request by the gate's authorization step. */
|
|
118
|
+
export function scopeAllows(
|
|
119
|
+
scopes: Readonly<Record<string, ScopeAccess>>,
|
|
120
|
+
endpoint: string,
|
|
121
|
+
access: ScopeAccess,
|
|
122
|
+
): boolean {
|
|
123
|
+
const specific = scopes[endpoint] ?? scopes[SCOPE_WILDCARD];
|
|
124
|
+
if (specific === undefined) return false;
|
|
125
|
+
return access === "ro" || specific === "rw";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Validate a label (client handle burned into the key string and the file
|
|
129
|
+
* line; revoke removes every line carrying it, so multiple active keys per
|
|
130
|
+
* label are the zero-downtime rotation shape). */
|
|
131
|
+
export function validateTokenLabel(label: string): void {
|
|
132
|
+
if (!NAME_GRAMMAR.test(label)) {
|
|
133
|
+
throw new TokenFileUsageError(
|
|
134
|
+
`invalid label '${label}': lowercase letters, digits, single hyphens, 1-63 chars`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mint a key: fresh id + secret, hash the secret, format the file line.
|
|
140
|
+
* Deterministic except through `randomBytes` (injected for tests). */
|
|
141
|
+
export function mintTokenFileKey(
|
|
142
|
+
label: string,
|
|
143
|
+
scopesSpec: string,
|
|
144
|
+
): MintedTokenKey {
|
|
145
|
+
validateTokenLabel(label);
|
|
146
|
+
const scopes = parseTokenScopes(scopesSpec);
|
|
147
|
+
const keyId = randomBytes(KEY_ID_BYTES).toString("hex");
|
|
148
|
+
const secret = randomBytes(SECRET_BYTES).toString("base64url");
|
|
149
|
+
const token = `ukp_${label}_${keyId}_${secret}`;
|
|
150
|
+
return {
|
|
151
|
+
token,
|
|
152
|
+
label,
|
|
153
|
+
keyId,
|
|
154
|
+
line: formatTokenFileLine({ label, keyId, secretSha256: sha256Hex(secret), scopes }),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sha256Hex(value: string): string {
|
|
159
|
+
return createHash(TOKEN_HASH_ALGORITHM).update(value, "utf8").digest("hex");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Serialize one entry as the canonical single-spaced line. */
|
|
163
|
+
export function formatTokenFileLine(entry: Omit<TokenFileEntry, "secretSha256"> & { secretSha256: string }): string {
|
|
164
|
+
const scopes = Object.entries(entry.scopes).map(([name, access]) => `${name}:${access}`).join(",");
|
|
165
|
+
return `${entry.label} ${entry.keyId} ${TOKEN_HASH_ALGORITHM}$${entry.secretSha256} ${scopes}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface TokenFileParseError {
|
|
169
|
+
/** 1-based line number in the file text. */
|
|
170
|
+
line: number;
|
|
171
|
+
message: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type TokenFileParseResult =
|
|
175
|
+
| { entries: TokenFileEntry[] }
|
|
176
|
+
| { error: TokenFileParseError };
|
|
177
|
+
|
|
178
|
+
/** Parse the whole file text. Comment (`#`-prefixed) and blank lines are
|
|
179
|
+
* structurally legal and carry no entry; every other line must be a valid
|
|
180
|
+
* four-column row. Duplicate key ids are a format error (verification looks
|
|
181
|
+
* up by id — an ambiguous row must not half-authorize). Duplicate labels
|
|
182
|
+
* are legal (rotation keeps several active keys per client). */
|
|
183
|
+
export function parseTokenFileText(text: string): TokenFileParseResult {
|
|
184
|
+
const entries: TokenFileEntry[] = [];
|
|
185
|
+
const seenKeyIds = new Set<string>();
|
|
186
|
+
const lines = text.split("\n");
|
|
187
|
+
for (let index = 0; index < lines.length; index++) {
|
|
188
|
+
const raw = lines[index]!;
|
|
189
|
+
const line = raw.trim();
|
|
190
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
191
|
+
const columns = line.split(/\s+/);
|
|
192
|
+
if (columns.length !== 4) {
|
|
193
|
+
return {
|
|
194
|
+
error: {
|
|
195
|
+
line: index + 1,
|
|
196
|
+
message: `expected 4 columns '<label> <id> ${TOKEN_HASH_ALGORITHM}$<hash> <scopes>', got ${columns.length}: '${line}'`,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const [label, keyId, hashColumn, scopesColumn] = columns as [string, string, string, string];
|
|
201
|
+
if (!NAME_GRAMMAR.test(label)) {
|
|
202
|
+
return { error: { line: index + 1, message: `invalid label '${label}'` } };
|
|
203
|
+
}
|
|
204
|
+
if (!/^[0-9a-f]{16}$/.test(keyId)) {
|
|
205
|
+
return { error: { line: index + 1, message: `invalid key id '${keyId}' (expected 16 lowercase hex chars)` } };
|
|
206
|
+
}
|
|
207
|
+
const hashMatch = new RegExp(`^${TOKEN_HASH_ALGORITHM}\\$([0-9a-f]{64})$`).exec(hashColumn);
|
|
208
|
+
if (hashMatch === null) {
|
|
209
|
+
return {
|
|
210
|
+
error: {
|
|
211
|
+
line: index + 1,
|
|
212
|
+
message: `invalid hash column '${hashColumn}' (expected ${TOKEN_HASH_ALGORITHM}$<64 lowercase hex chars>)`,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (seenKeyIds.has(keyId)) {
|
|
217
|
+
return { error: { line: index + 1, message: `duplicate key id '${keyId}'` } };
|
|
218
|
+
}
|
|
219
|
+
let scopes: Record<string, ScopeAccess>;
|
|
220
|
+
try {
|
|
221
|
+
scopes = parseTokenScopes(scopesColumn);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error instanceof TokenFileUsageError) {
|
|
224
|
+
return { error: { line: index + 1, message: error.message } };
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
seenKeyIds.add(keyId);
|
|
229
|
+
entries.push({ label, keyId, secretSha256: hashMatch[1]!, scopes });
|
|
230
|
+
}
|
|
231
|
+
return { entries };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Parse the bearer key shape `ukp_<label>_<id>_<secret>`: label and id are
|
|
235
|
+
* `_`-free by grammar, so the secret is everything after the third `_`
|
|
236
|
+
* (base64url may itself contain `_`). Returns undefined on any shape
|
|
237
|
+
* mismatch — authentication failures are data, not exceptions. */
|
|
238
|
+
export function parseTokenFileKey(token: string): { label: string; keyId: string; secret: string } | undefined {
|
|
239
|
+
const parts = token.split("_");
|
|
240
|
+
if (parts.length < 4 || parts[0] !== "ukp") return undefined;
|
|
241
|
+
const label = parts[1]!;
|
|
242
|
+
const keyId = parts[2]!;
|
|
243
|
+
const secret = parts.slice(3).join("_");
|
|
244
|
+
if (!NAME_GRAMMAR.test(label)) return undefined;
|
|
245
|
+
if (!/^[0-9a-f]{16}$/.test(keyId)) return undefined;
|
|
246
|
+
if (secret === "") return undefined;
|
|
247
|
+
return { label, keyId, secret };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Verify a presented bearer key against the parsed entries: look the row
|
|
251
|
+
* up by (label, keyId), then compare SHA-256 digests with
|
|
252
|
+
* `timingSafeEqual`. Digest lengths are fixed by the parse-time hash-column
|
|
253
|
+
* validation, so the comparison cannot throw on crafted input. Returns the
|
|
254
|
+
* matched entry (authorization data rides on it) or undefined. */
|
|
255
|
+
export function authenticateBearerKey(
|
|
256
|
+
presented: string,
|
|
257
|
+
entries: readonly TokenFileEntry[],
|
|
258
|
+
): TokenFileEntry | undefined {
|
|
259
|
+
const parsed = parseTokenFileKey(presented);
|
|
260
|
+
if (parsed === undefined) return undefined;
|
|
261
|
+
const presentedDigest = createHash(TOKEN_HASH_ALGORITHM).update(parsed.secret, "utf8").digest();
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (entry.keyId !== parsed.keyId || entry.label !== parsed.label) continue;
|
|
264
|
+
const storedDigest = Buffer.from(entry.secretSha256, "hex");
|
|
265
|
+
// Same-length by construction; timing-safe on the secret material only.
|
|
266
|
+
if (timingSafeEqual(storedDigest, presentedDigest)) return entry;
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Permission enforcement for the credential file, OpenSSH-style: on POSIX,
|
|
272
|
+
* group/other read or write bits make the file unusable (machine refusal,
|
|
273
|
+
* not advice). Windows carries no group/other bits on `stat().mode` (the
|
|
274
|
+
* ACLs under %USERPROFILE% are the mechanism there), so the check is
|
|
275
|
+
* skipped rather than guaranteed-false. Returns the refusal message or
|
|
276
|
+
* undefined when the mode is acceptable. */
|
|
277
|
+
export function tokenFileModeProblem(mode: number, platform: string): string | undefined {
|
|
278
|
+
if (platform === "win32") return undefined;
|
|
279
|
+
const openBits = mode & 0o077;
|
|
280
|
+
if (openBits !== 0) {
|
|
281
|
+
return `permissions ${(mode & 0o777).toString(8).padStart(3, "0")} are too open: group/other must not read or write the token file (chmod 600)`;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- file IO (node:fs is in-bounds for this subtree) ----------------------
|
|
287
|
+
|
|
288
|
+
export type TokenFileLoad =
|
|
289
|
+
| { text: string }
|
|
290
|
+
| { missing: true }
|
|
291
|
+
| { error: string };
|
|
292
|
+
|
|
293
|
+
/** Read the token file for verification or management. A missing file is
|
|
294
|
+
* the "no keys issued" state, not an error; a permission problem is the
|
|
295
|
+
* OpenSSH-shaped refusal; anything else is a classified read error. */
|
|
296
|
+
export function loadTokenFile(path: string): TokenFileLoad {
|
|
297
|
+
let text: string;
|
|
298
|
+
try {
|
|
299
|
+
text = readFileSync(path, "utf8");
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
302
|
+
if (code === "ENOENT") return { missing: true };
|
|
303
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
304
|
+
return { error: `cannot read token file ${path}: permission denied` };
|
|
305
|
+
}
|
|
306
|
+
return { error: `cannot read token file ${path}: ${code ?? String(error)}` };
|
|
307
|
+
}
|
|
308
|
+
let mode: number;
|
|
309
|
+
try {
|
|
310
|
+
mode = statSync(path).mode;
|
|
311
|
+
} catch {
|
|
312
|
+
return { text };
|
|
313
|
+
}
|
|
314
|
+
const refusal = tokenFileModeProblem(mode, process.platform);
|
|
315
|
+
if (refusal !== undefined) return { error: `${path}: ${refusal}` };
|
|
316
|
+
return { text };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Persist the managed text: parent directories created on demand, written
|
|
320
|
+
* 0600 (best-effort on Windows), atomically replaced via temp+rename so a
|
|
321
|
+
* concurrently fresh-reading gate never observes a partial file. Comments
|
|
322
|
+
* and blank lines survive because callers pass the full managed text. */
|
|
323
|
+
export function storeTokenFile(path: string, text: string): void {
|
|
324
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
325
|
+
const tempPath = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
326
|
+
writeFileSync(tempPath, text, { encoding: "utf8", mode: 0o600 });
|
|
327
|
+
try {
|
|
328
|
+
renameSync(tempPath, path);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (existsSync(tempPath)) unlinkQuietly(tempPath);
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function unlinkQuietly(path: string): void {
|
|
336
|
+
try {
|
|
337
|
+
unlinkSync(path);
|
|
338
|
+
} catch {
|
|
339
|
+
// best-effort cleanup of our own temp file
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Append one entry line to the managed text (newline discipline: text
|
|
344
|
+
* either is empty or already ends with exactly one newline). */
|
|
345
|
+
export function appendTokenLine(text: string, line: string): string {
|
|
346
|
+
const base = text === "" ? "" : (text.endsWith("\n") ? text : `${text}\n`);
|
|
347
|
+
return `${base}${line}\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Remove every entry line whose label matches (revoke = the client, all
|
|
351
|
+
* its active keys). Comment/blank lines are preserved verbatim. Returns
|
|
352
|
+
* the rewritten text and how many lines were removed. */
|
|
353
|
+
export function removeTokenLines(text: string, label: string): { text: string; removed: number } {
|
|
354
|
+
let removed = 0;
|
|
355
|
+
const kept: string[] = [];
|
|
356
|
+
for (const raw of text.split("\n")) {
|
|
357
|
+
const trimmed = raw.trim();
|
|
358
|
+
if (trimmed !== "" && !trimmed.startsWith("#") && trimmed.split(/\s+/)[0] === label) {
|
|
359
|
+
removed++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
kept.push(raw);
|
|
363
|
+
}
|
|
364
|
+
// Drop one trailing separator run left by the removal so the file never
|
|
365
|
+
// accumulates blank tails across revoke cycles.
|
|
366
|
+
while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop();
|
|
367
|
+
return { text: kept.length === 0 ? "" : `${kept.join("\n")}\n`, removed };
|
|
368
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -45,8 +45,24 @@ const bindingSchema = z.object({
|
|
|
45
45
|
|
|
46
46
|
const registrySchema = z.object({
|
|
47
47
|
endpoints: z.array(bindingSchema),
|
|
48
|
+
/** Agent substrates for session hosting (ukp_session / ADR-SESSION-001
|
|
49
|
+
* spec §2): declaration-free v0 — entries are hand-edited; tier is
|
|
50
|
+
* sdk-only (app-server is a reserved future value and fails the schema). */
|
|
51
|
+
agents: z.array(z.object({
|
|
52
|
+
handle: z.string().min(1),
|
|
53
|
+
tier: z.literal("sdk"),
|
|
54
|
+
command: z.array(z.string().min(1)).min(1),
|
|
55
|
+
}).strict()).optional(),
|
|
48
56
|
}).strict();
|
|
49
57
|
|
|
58
|
+
/** Host Registry agent entry (ukp_session): `command` is the substrate argv
|
|
59
|
+
* prefix — the session adapter appends its own mode arguments after it. */
|
|
60
|
+
export interface RegistryAgent {
|
|
61
|
+
handle: string;
|
|
62
|
+
tier: "sdk";
|
|
63
|
+
command: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/** Binding union over the `kind` axis (D-077). Local bindings keep the
|
|
51
67
|
* historical `{name, path}` shape (no injected `kind` field), so parsed
|
|
52
68
|
* locals stay byte-identical to the pre-remote era; remote bindings carry
|
|
@@ -315,6 +331,31 @@ export function readRegistry(registryPath: string): RegistryBinding[] {
|
|
|
315
331
|
}
|
|
316
332
|
}
|
|
317
333
|
|
|
334
|
+
/** Agent substrates declared in the Host Registry (ukp_session spec §2).
|
|
335
|
+
* Separate read so the bindings pipeline stays untouched; same strict
|
|
336
|
+
* schema, same classified error surface. Absent file or absent `agents`
|
|
337
|
+
* table both mean "no substrate on this host". */
|
|
338
|
+
export function readRegistryAgents(registryPath: string): RegistryAgent[] {
|
|
339
|
+
if (!existsSync(registryPath)) return [];
|
|
340
|
+
let raw: unknown;
|
|
341
|
+
try {
|
|
342
|
+
raw = parse(readFileSync(registryPath, "utf8"));
|
|
343
|
+
assertRestrictedToml(raw);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error instanceof ManifestError) throw new RegistryError(error.message, { cause: error });
|
|
346
|
+
throw new RegistryError(`Registry is not readable: ${registryPath}`, { cause: error });
|
|
347
|
+
}
|
|
348
|
+
const result = registrySchema.safeParse(raw);
|
|
349
|
+
if (!result.success) throw new RegistryError(`Registry schema is invalid: ${result.error.message}`);
|
|
350
|
+
const agents = result.data.agents ?? [];
|
|
351
|
+
const seen = new Set<string>();
|
|
352
|
+
for (const agent of agents) {
|
|
353
|
+
if (seen.has(agent.handle)) throw new RegistryError(`duplicate agent handle '${agent.handle}'`);
|
|
354
|
+
seen.add(agent.handle);
|
|
355
|
+
}
|
|
356
|
+
return [...agents].sort((left, right) => left.handle < right.handle ? -1 : left.handle > right.handle ? 1 : 0);
|
|
357
|
+
}
|
|
358
|
+
|
|
318
359
|
/** Canonical serialization (configuration-contracts canonical form): local rows
|
|
319
360
|
* `{name, path}`, remote rows `{name, kind, url, declared_name?, instance_uid?}`
|
|
320
361
|
* (plus credentials/pins when present) — key order is insertion order, so
|
|
@@ -350,8 +391,13 @@ export function registerBinding(endpoints: readonly RegistryBinding[], binding:
|
|
|
350
391
|
const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
|
|
351
392
|
if (sameName) {
|
|
352
393
|
if (sameName.kind !== "remote" && sameName.path === canonical.path) return validateBindings(endpoints);
|
|
394
|
+
// Refusals carry their remedy (spec L140 contract): a local binding whose
|
|
395
|
+
// folder moved points at the one-step --rebind exit, everything else at
|
|
396
|
+
// the two-step unregister path (registry-commands rebind-one-step, 2026-10-24).
|
|
353
397
|
throw new RegistryError(
|
|
354
|
-
|
|
398
|
+
sameName.kind === "remote"
|
|
399
|
+
? `endpoint name '${canonical.name}' is already bound to ${sameName.url}; unregister '${canonical.name}' first`
|
|
400
|
+
: `endpoint name '${canonical.name}' is already bound to ${sameName.path}; if the Service folder moved, re-run 'ukp register --rebind' from its new location, or unregister '${canonical.name}' first`,
|
|
355
401
|
);
|
|
356
402
|
}
|
|
357
403
|
const samePath = endpoints.find((endpoint) => endpoint.kind !== "remote" && endpoint.path === canonical.path);
|
|
@@ -361,6 +407,31 @@ export function registerBinding(endpoints: readonly RegistryBinding[], binding:
|
|
|
361
407
|
return validateBindings([...endpoints, canonical]);
|
|
362
408
|
}
|
|
363
409
|
|
|
410
|
+
/** One-step same-name relocation (registry-commands domain, rebind-one-step
|
|
411
|
+
* ruling 2026-10-24, superseding the two-step remedy for this exit only):
|
|
412
|
+
* swap a LOCAL binding's location in one atomic state transition. Narrow by
|
|
413
|
+
* design — no rename, no alias, no remote handles (an unbound name, a
|
|
414
|
+
* remote-held name, or a location owned by another name all refuse with
|
|
415
|
+
* their own remedy); a same-path rebind is the idempotent no-op. */
|
|
416
|
+
export function rebindBinding(endpoints: readonly RegistryBinding[], binding: RegistryBinding): RegistryBinding[] {
|
|
417
|
+
const canonical: RegistryBinding = { name: binding.name, path: realpathSync(binding.path!) };
|
|
418
|
+
const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
|
|
419
|
+
if (sameName === undefined) {
|
|
420
|
+
throw new RegistryError(`endpoint name '${canonical.name}' is not bound; nothing to rebind - run 'ukp register' to register it`);
|
|
421
|
+
}
|
|
422
|
+
if (sameName.kind === "remote") {
|
|
423
|
+
throw new RegistryError(
|
|
424
|
+
`endpoint name '${canonical.name}' is bound to the remote url ${sameName.url}; --rebind relocates local bindings - unregister '${canonical.name}' first`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (sameName.path === canonical.path) return validateBindings(endpoints);
|
|
428
|
+
const samePath = endpoints.find((endpoint) => endpoint.kind !== "remote" && endpoint.path === canonical.path);
|
|
429
|
+
if (samePath) {
|
|
430
|
+
throw new RegistryError(`Service location is already bound to '${samePath.name}'`);
|
|
431
|
+
}
|
|
432
|
+
return validateBindings([...endpoints.filter((endpoint) => endpoint.name !== canonical.name), canonical]);
|
|
433
|
+
}
|
|
434
|
+
|
|
364
435
|
/** Remote registration (D-077; naming residence ADR-REM-007 / D-086): the
|
|
365
436
|
* binding `name` is the consumer-chosen handle (defaults to the declared
|
|
366
437
|
* name), `declared_name` is the registration-time provenance snapshot.
|
|
@@ -488,6 +559,12 @@ export function registerAt(registryPath: string, name: string, servicePath: stri
|
|
|
488
559
|
return mutateRegistry(registryPath, (current) => registerBinding(current, { name, path: canonicalPath }));
|
|
489
560
|
}
|
|
490
561
|
|
|
562
|
+
export function rebindAt(registryPath: string, name: string, servicePath: string): RegistryBinding[] {
|
|
563
|
+
const canonicalPath = realpathSync(servicePath);
|
|
564
|
+
if (!statSync(canonicalPath).isDirectory()) throw new RegistryError(`Service location is not a directory: ${servicePath}`);
|
|
565
|
+
return mutateRegistry(registryPath, (current) => rebindBinding(current, { name, path: canonicalPath }));
|
|
566
|
+
}
|
|
567
|
+
|
|
491
568
|
export function registerRemoteAt(
|
|
492
569
|
registryPath: string,
|
|
493
570
|
binding: {
|