@npmmo/roster 0.0.1
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/LICENSE +21 -0
- package/README.md +74 -0
- package/bundle/bin.js +5882 -0
- package/bundle/index.js +4778 -0
- package/package.json +66 -0
package/bundle/index.js
ADDED
|
@@ -0,0 +1,4778 @@
|
|
|
1
|
+
// src/clients.ts
|
|
2
|
+
import fs2 from "node:fs";
|
|
3
|
+
import path2 from "node:path";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
|
|
7
|
+
// src/jsonc.ts
|
|
8
|
+
function parseJsonc(input) {
|
|
9
|
+
const cleaned = input.charCodeAt(0) === 65279 ? input.slice(1) : input;
|
|
10
|
+
return JSON.parse(stripTrailingCommas(stripComments(cleaned)));
|
|
11
|
+
}
|
|
12
|
+
function stripComments(input) {
|
|
13
|
+
let out = "";
|
|
14
|
+
let inString = false;
|
|
15
|
+
let inLine = false;
|
|
16
|
+
let inBlock = false;
|
|
17
|
+
for (let i = 0; i < input.length; i++) {
|
|
18
|
+
const ch = input[i];
|
|
19
|
+
const next = input[i + 1];
|
|
20
|
+
if (inLine) {
|
|
21
|
+
if (ch === "\n") {
|
|
22
|
+
inLine = false;
|
|
23
|
+
out += ch;
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (inBlock) {
|
|
28
|
+
if (ch === "*" && next === "/") {
|
|
29
|
+
inBlock = false;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (inString) {
|
|
35
|
+
out += ch;
|
|
36
|
+
if (ch === "\\") {
|
|
37
|
+
if (next !== void 0) {
|
|
38
|
+
out += next;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
} else if (ch === '"') {
|
|
42
|
+
inString = false;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '"') {
|
|
47
|
+
inString = true;
|
|
48
|
+
out += ch;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === "/" && next === "/") {
|
|
52
|
+
inLine = true;
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (ch === "/" && next === "*") {
|
|
57
|
+
inBlock = true;
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
out += ch;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function stripTrailingCommas(input) {
|
|
66
|
+
let out = "";
|
|
67
|
+
let inString = false;
|
|
68
|
+
for (let i = 0; i < input.length; i++) {
|
|
69
|
+
const ch = input[i];
|
|
70
|
+
if (inString) {
|
|
71
|
+
out += ch;
|
|
72
|
+
if (ch === "\\") {
|
|
73
|
+
const next = input[i + 1];
|
|
74
|
+
if (next !== void 0) {
|
|
75
|
+
out += next;
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
} else if (ch === '"') {
|
|
79
|
+
inString = false;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === '"') {
|
|
84
|
+
inString = true;
|
|
85
|
+
out += ch;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === ",") {
|
|
89
|
+
let j = i + 1;
|
|
90
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
91
|
+
if (input[j] === "}" || input[j] === "]") continue;
|
|
92
|
+
}
|
|
93
|
+
out += ch;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/paths.ts
|
|
99
|
+
import fs from "node:fs";
|
|
100
|
+
import os from "node:os";
|
|
101
|
+
import path from "node:path";
|
|
102
|
+
var PRIVATE_FILE = 384;
|
|
103
|
+
var PRIVATE_DIR = 448;
|
|
104
|
+
function homeDir() {
|
|
105
|
+
return process.env.ROSTER_TEST_HOME ?? os.homedir();
|
|
106
|
+
}
|
|
107
|
+
function rosterHome() {
|
|
108
|
+
return process.env.ROSTER_HOME ?? path.join(homeDir(), ".roster");
|
|
109
|
+
}
|
|
110
|
+
function ensurePrivateDir(dir) {
|
|
111
|
+
fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR });
|
|
112
|
+
try {
|
|
113
|
+
const current = fs.statSync(dir).mode & 511;
|
|
114
|
+
if ((current & 63) !== 0) fs.chmodSync(dir, current & 448);
|
|
115
|
+
} catch {
|
|
116
|
+
}
|
|
117
|
+
return dir;
|
|
118
|
+
}
|
|
119
|
+
function ensureRosterHome() {
|
|
120
|
+
return ensurePrivateDir(rosterHome());
|
|
121
|
+
}
|
|
122
|
+
function rosterConfigPath() {
|
|
123
|
+
return path.join(rosterHome(), "roster.json");
|
|
124
|
+
}
|
|
125
|
+
function receiptPath() {
|
|
126
|
+
return path.join(rosterHome(), "receipt.json");
|
|
127
|
+
}
|
|
128
|
+
function coachDbPath() {
|
|
129
|
+
return path.join(rosterHome(), "coach.db");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/clients.ts
|
|
133
|
+
function isRecord(value) {
|
|
134
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
135
|
+
}
|
|
136
|
+
function fromMcpServersObject(obj, client, sourcePath, opts = {}) {
|
|
137
|
+
if (obj === void 0) return [];
|
|
138
|
+
if (!isRecord(obj)) throw new Error("MCP servers must be an object");
|
|
139
|
+
const out = [];
|
|
140
|
+
const urlKeys = opts.urlKeys ?? ["url", "httpUrl", "serverUrl"];
|
|
141
|
+
const supported = /* @__PURE__ */ new Set(["command", "args", "env", "type", ...urlKeys]);
|
|
142
|
+
for (const [name, raw] of Object.entries(obj)) {
|
|
143
|
+
if (!isRecord(raw)) throw new Error(`MCP server "${name}" must be an object`);
|
|
144
|
+
const unsupported = Object.keys(raw).filter((key) => !supported.has(key));
|
|
145
|
+
if (unsupported.length > 0 || raw.type !== void 0 && raw.type !== "stdio") {
|
|
146
|
+
throw new Error(`Unsupported MCP server settings for "${name}": ${unsupported.join(", ") || "type"}; configuration left unchanged`);
|
|
147
|
+
}
|
|
148
|
+
for (const key of ["command", ...urlKeys]) {
|
|
149
|
+
if (raw[key] !== void 0 && (typeof raw[key] !== "string" || raw[key].trim() === "")) {
|
|
150
|
+
throw new Error(`MCP server "${name}" ${key} must be a non-empty string`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (raw.args !== void 0 && (!Array.isArray(raw.args) || raw.args.some((arg) => typeof arg !== "string"))) {
|
|
154
|
+
throw new Error(`MCP server "${name}" args must be an array of strings`);
|
|
155
|
+
}
|
|
156
|
+
if (raw.env !== void 0 && (!isRecord(raw.env) || Object.values(raw.env).some((value) => typeof value !== "string"))) {
|
|
157
|
+
throw new Error(`MCP server "${name}" env must be an object of strings`);
|
|
158
|
+
}
|
|
159
|
+
const url = urlKeys.map((k) => raw[k]).find((v) => typeof v === "string");
|
|
160
|
+
const command = typeof raw.command === "string" ? raw.command : void 0;
|
|
161
|
+
if (!url && !command) throw new Error(`MCP server "${name}" must define command or url`);
|
|
162
|
+
if (command && url) throw new Error(`Unsupported mixed MCP transports for "${name}"`);
|
|
163
|
+
out.push({
|
|
164
|
+
name,
|
|
165
|
+
command,
|
|
166
|
+
args: Array.isArray(raw.args) ? raw.args.map(String) : void 0,
|
|
167
|
+
env: raw.env && typeof raw.env === "object" ? Object.fromEntries(
|
|
168
|
+
Object.entries(raw.env).map(([k, v]) => [k, String(v)])
|
|
169
|
+
) : void 0,
|
|
170
|
+
url,
|
|
171
|
+
client,
|
|
172
|
+
sourcePath
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
var home = homeDir;
|
|
178
|
+
function appDataDir() {
|
|
179
|
+
if (process.env.ROSTER_TEST_HOME) return path2.join(home(), "AppData", "Roaming");
|
|
180
|
+
return process.env.APPDATA ?? path2.join(home(), "AppData", "Roaming");
|
|
181
|
+
}
|
|
182
|
+
var CLIENTS = [
|
|
183
|
+
{
|
|
184
|
+
id: "claude-code",
|
|
185
|
+
displayName: "Claude Code",
|
|
186
|
+
nativeToolSearch: true,
|
|
187
|
+
stateFileBasename: ".claude.json",
|
|
188
|
+
// Claude Code's live state file (cwd .mcp.json stays byte-restore)
|
|
189
|
+
// Verified 2026-07-04 on a live machine: user-scope MCP servers live in
|
|
190
|
+
// ~/.claude.json (NOT ~/.claude/settings.json — handoff §8 amended).
|
|
191
|
+
configPaths: () => [
|
|
192
|
+
path2.join(home(), ".claude.json"),
|
|
193
|
+
path2.join(process.cwd(), ".mcp.json")
|
|
194
|
+
],
|
|
195
|
+
parse: (content, sourcePath) => {
|
|
196
|
+
const data = parseJsonc(content);
|
|
197
|
+
return fromMcpServersObject(data.mcpServers, "claude-code", sourcePath);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "claude-desktop",
|
|
202
|
+
displayName: "Claude Desktop",
|
|
203
|
+
nativeToolSearch: false,
|
|
204
|
+
configPaths: () => {
|
|
205
|
+
if (process.platform === "win32") {
|
|
206
|
+
return [path2.join(appDataDir(), "Claude", "claude_desktop_config.json")];
|
|
207
|
+
}
|
|
208
|
+
return [path2.join(home(), "Library", "Application Support", "Claude", "claude_desktop_config.json")];
|
|
209
|
+
},
|
|
210
|
+
parse: (content, sourcePath) => {
|
|
211
|
+
const data = parseJsonc(content);
|
|
212
|
+
return fromMcpServersObject(data.mcpServers, "claude-desktop", sourcePath);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "cursor",
|
|
217
|
+
displayName: "Cursor",
|
|
218
|
+
nativeToolSearch: false,
|
|
219
|
+
configPaths: () => [
|
|
220
|
+
path2.join(home(), ".cursor", "mcp.json"),
|
|
221
|
+
path2.join(process.cwd(), ".cursor", "mcp.json")
|
|
222
|
+
],
|
|
223
|
+
parse: (content, sourcePath) => {
|
|
224
|
+
const data = parseJsonc(content);
|
|
225
|
+
return fromMcpServersObject(data.mcpServers, "cursor", sourcePath);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "codex",
|
|
230
|
+
displayName: "Codex",
|
|
231
|
+
nativeToolSearch: false,
|
|
232
|
+
configPaths: () => [path2.join(home(), ".codex", "config.toml")],
|
|
233
|
+
parse: (content, sourcePath) => {
|
|
234
|
+
const data = parseToml(content);
|
|
235
|
+
return fromMcpServersObject(data.mcp_servers, "codex", sourcePath);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "gemini-cli",
|
|
240
|
+
displayName: "Gemini CLI",
|
|
241
|
+
nativeToolSearch: false,
|
|
242
|
+
configPaths: () => [path2.join(home(), ".gemini", "settings.json")],
|
|
243
|
+
parse: (content, sourcePath) => {
|
|
244
|
+
const data = parseJsonc(content);
|
|
245
|
+
return fromMcpServersObject(data.mcpServers, "gemini-cli", sourcePath);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "hermes",
|
|
250
|
+
displayName: "Hermes",
|
|
251
|
+
nativeToolSearch: false,
|
|
252
|
+
configPaths: () => [path2.join(home(), ".hermes", "config.yaml")],
|
|
253
|
+
parse: (content, sourcePath) => {
|
|
254
|
+
const data = parseYaml(content);
|
|
255
|
+
return fromMcpServersObject(data?.mcp_servers, "hermes", sourcePath);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: "openclaw",
|
|
260
|
+
displayName: "OpenClaw",
|
|
261
|
+
nativeToolSearch: false,
|
|
262
|
+
stateFileBasename: "openclaw.json",
|
|
263
|
+
// holds broader client config beyond mcpServers
|
|
264
|
+
configPaths: () => [
|
|
265
|
+
path2.join(home(), ".openclaw", "openclaw.json"),
|
|
266
|
+
path2.join(home(), "openclaw.json")
|
|
267
|
+
],
|
|
268
|
+
parse: (content, sourcePath) => {
|
|
269
|
+
const data = parseJsonc(content);
|
|
270
|
+
return fromMcpServersObject(data.mcpServers, "openclaw", sourcePath);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: "vscode",
|
|
275
|
+
displayName: "VS Code",
|
|
276
|
+
nativeToolSearch: false,
|
|
277
|
+
configPaths: () => {
|
|
278
|
+
const base = process.platform === "win32" ? path2.join(appDataDir(), "Code", "User") : process.platform === "darwin" ? path2.join(home(), "Library", "Application Support", "Code", "User") : path2.join(home(), ".config", "Code", "User");
|
|
279
|
+
return [path2.join(base, "mcp.json"), path2.join(process.cwd(), ".vscode", "mcp.json")];
|
|
280
|
+
},
|
|
281
|
+
parse: (content, sourcePath) => {
|
|
282
|
+
const data = parseJsonc(content);
|
|
283
|
+
return fromMcpServersObject(data.servers ?? data.mcpServers, "vscode", sourcePath);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "windsurf",
|
|
288
|
+
displayName: "Windsurf",
|
|
289
|
+
nativeToolSearch: false,
|
|
290
|
+
configPaths: () => [path2.join(home(), ".codeium", "windsurf", "mcp_config.json")],
|
|
291
|
+
parse: (content, sourcePath) => {
|
|
292
|
+
const data = parseJsonc(content);
|
|
293
|
+
return fromMcpServersObject(data.mcpServers, "windsurf", sourcePath);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "zed",
|
|
298
|
+
displayName: "Zed",
|
|
299
|
+
nativeToolSearch: false,
|
|
300
|
+
configPaths: () => [path2.join(home(), ".config", "zed", "settings.json")],
|
|
301
|
+
parse: (content, sourcePath) => {
|
|
302
|
+
const data = parseJsonc(content);
|
|
303
|
+
return fromMcpServersObject(data.context_servers, "zed", sourcePath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
];
|
|
307
|
+
function discoverClients() {
|
|
308
|
+
const discoveries = [];
|
|
309
|
+
for (const client of CLIENTS) {
|
|
310
|
+
for (const configPath of client.configPaths()) {
|
|
311
|
+
if (!fs2.existsSync(configPath)) continue;
|
|
312
|
+
try {
|
|
313
|
+
const content = fs2.readFileSync(configPath, "utf8");
|
|
314
|
+
discoveries.push({ client, configPath, servers: client.parse(content, configPath) });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
discoveries.push({
|
|
317
|
+
client,
|
|
318
|
+
configPath,
|
|
319
|
+
servers: [],
|
|
320
|
+
parseError: err instanceof Error ? err.message : String(err)
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return discoveries;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/eject.ts
|
|
329
|
+
import fs10 from "node:fs";
|
|
330
|
+
import path10 from "node:path";
|
|
331
|
+
|
|
332
|
+
// ../coach/dist/db.js
|
|
333
|
+
import fs3 from "node:fs";
|
|
334
|
+
import Database from "better-sqlite3";
|
|
335
|
+
var SCHEMA_VERSION = "1";
|
|
336
|
+
function ensureOwnerOnlyDbFile(file) {
|
|
337
|
+
if (file === ":memory:" || file === "")
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
fs3.closeSync(fs3.openSync(file, "a", 384));
|
|
341
|
+
const current = fs3.statSync(file).mode & 511;
|
|
342
|
+
if ((current & 63) !== 0)
|
|
343
|
+
fs3.chmodSync(file, current & 448);
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function openCoachDb(path15) {
|
|
348
|
+
ensureOwnerOnlyDbFile(path15);
|
|
349
|
+
const db = new Database(path15);
|
|
350
|
+
db.pragma("journal_mode = WAL");
|
|
351
|
+
db.pragma("foreign_keys = ON");
|
|
352
|
+
db.pragma("busy_timeout = 5000");
|
|
353
|
+
migrate(db);
|
|
354
|
+
return db;
|
|
355
|
+
}
|
|
356
|
+
function migrate(db) {
|
|
357
|
+
db.exec(`
|
|
358
|
+
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
359
|
+
|
|
360
|
+
CREATE TABLE IF NOT EXISTS capability(
|
|
361
|
+
id TEXT PRIMARY KEY,
|
|
362
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool','skill')),
|
|
363
|
+
source TEXT NOT NULL,
|
|
364
|
+
name TEXT NOT NULL,
|
|
365
|
+
description TEXT NOT NULL DEFAULT '',
|
|
366
|
+
input_schema TEXT,
|
|
367
|
+
output_schema TEXT,
|
|
368
|
+
body TEXT,
|
|
369
|
+
path TEXT,
|
|
370
|
+
def_hash TEXT NOT NULL,
|
|
371
|
+
quarantined INTEGER NOT NULL DEFAULT 0,
|
|
372
|
+
first_seen INTEGER NOT NULL,
|
|
373
|
+
last_seen INTEGER NOT NULL
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
-- The "name" column carries "<source> <tool>" so a user searching "memory"
|
|
377
|
+
-- or a server's own name finds its tools even when descriptions never say
|
|
378
|
+
-- the word (empty-draft bug in default lexical mode).
|
|
379
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS capability_fts USING fts5(id UNINDEXED, name, description, body);
|
|
380
|
+
|
|
381
|
+
CREATE TABLE IF NOT EXISTS suggestion(
|
|
382
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
383
|
+
ts INTEGER NOT NULL,
|
|
384
|
+
session TEXT NOT NULL,
|
|
385
|
+
failed_capability TEXT NOT NULL,
|
|
386
|
+
suggested_capability TEXT NOT NULL,
|
|
387
|
+
taken INTEGER NOT NULL DEFAULT 0
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
CREATE TABLE IF NOT EXISTS outcome(
|
|
391
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
392
|
+
ts INTEGER NOT NULL,
|
|
393
|
+
session TEXT NOT NULL,
|
|
394
|
+
source TEXT NOT NULL,
|
|
395
|
+
capability TEXT NOT NULL,
|
|
396
|
+
need_hash TEXT,
|
|
397
|
+
args_hash TEXT,
|
|
398
|
+
intent_cat TEXT,
|
|
399
|
+
class TEXT NOT NULL,
|
|
400
|
+
latency_ms INTEGER NOT NULL,
|
|
401
|
+
soft_fail INTEGER NOT NULL DEFAULT 0,
|
|
402
|
+
substituted INTEGER NOT NULL DEFAULT 0,
|
|
403
|
+
explored INTEGER NOT NULL DEFAULT 0,
|
|
404
|
+
spec_ver TEXT
|
|
405
|
+
);
|
|
406
|
+
CREATE INDEX IF NOT EXISTS idx_outcome_session ON outcome(session, id);
|
|
407
|
+
CREATE INDEX IF NOT EXISTS idx_outcome_capability ON outcome(capability, ts);
|
|
408
|
+
|
|
409
|
+
CREATE TABLE IF NOT EXISTS rating(
|
|
410
|
+
capability TEXT NOT NULL,
|
|
411
|
+
category TEXT NOT NULL,
|
|
412
|
+
n INTEGER NOT NULL,
|
|
413
|
+
successes INTEGER NOT NULL,
|
|
414
|
+
wilson_lb REAL NOT NULL,
|
|
415
|
+
p50_ms INTEGER,
|
|
416
|
+
p95_ms INTEGER,
|
|
417
|
+
updated_at INTEGER NOT NULL,
|
|
418
|
+
PRIMARY KEY(capability, category)
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
CREATE TABLE IF NOT EXISTS drift_event(
|
|
422
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
423
|
+
ts INTEGER NOT NULL,
|
|
424
|
+
capability TEXT NOT NULL,
|
|
425
|
+
old_hash TEXT NOT NULL,
|
|
426
|
+
new_hash TEXT NOT NULL
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
-- Tombstone for pruned capabilities: carries the last-seen definition hash
|
|
430
|
+
-- (and quarantine state) forward so a tool that is REMOVED and later
|
|
431
|
+
-- RE-ADDED with a changed definition still raises a drift event instead of
|
|
432
|
+
-- slipping back in as "new" (drift-evasion via remove/re-add).
|
|
433
|
+
CREATE TABLE IF NOT EXISTS removed_capability(
|
|
434
|
+
id TEXT PRIMARY KEY,
|
|
435
|
+
def_hash TEXT NOT NULL,
|
|
436
|
+
quarantined INTEGER NOT NULL DEFAULT 0,
|
|
437
|
+
last_drift_ts INTEGER,
|
|
438
|
+
removed_at INTEGER NOT NULL
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
CREATE TABLE IF NOT EXISTS vec(
|
|
442
|
+
capability TEXT PRIMARY KEY,
|
|
443
|
+
dims INTEGER NOT NULL,
|
|
444
|
+
base BLOB NOT NULL,
|
|
445
|
+
adj BLOB,
|
|
446
|
+
updated_at INTEGER NOT NULL
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
CREATE TABLE IF NOT EXISTS need_vec(
|
|
450
|
+
need_hash TEXT PRIMARY KEY,
|
|
451
|
+
dims INTEGER NOT NULL,
|
|
452
|
+
vec BLOB NOT NULL,
|
|
453
|
+
ts INTEGER NOT NULL
|
|
454
|
+
);
|
|
455
|
+
`);
|
|
456
|
+
db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?)").run(SCHEMA_VERSION);
|
|
457
|
+
addColumnIfMissing(db, "capability", "title", "TEXT");
|
|
458
|
+
addColumnIfMissing(db, "capability", "annotations", "TEXT");
|
|
459
|
+
addColumnIfMissing(db, "capability", "execution", "TEXT");
|
|
460
|
+
}
|
|
461
|
+
function addColumnIfMissing(db, table, column, decl) {
|
|
462
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
463
|
+
if (cols.some((c) => c.name === column))
|
|
464
|
+
return;
|
|
465
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ../coach/dist/classifier.js
|
|
469
|
+
function classifyOutcome(e) {
|
|
470
|
+
if (e.transportError)
|
|
471
|
+
return "hard_fail:transport";
|
|
472
|
+
if (e.protocolError)
|
|
473
|
+
return "hard_fail:protocol";
|
|
474
|
+
if (e.timedOut)
|
|
475
|
+
return "tool_fail:timeout";
|
|
476
|
+
if (e.inputValidationError)
|
|
477
|
+
return "tool_fail:schema";
|
|
478
|
+
if (e.isError)
|
|
479
|
+
return `tool_fail:${classifyToolFailKind(e.errorText ?? "")}`;
|
|
480
|
+
if (e.outputSchemaViolation)
|
|
481
|
+
return "schema_drift_suspect";
|
|
482
|
+
return "success";
|
|
483
|
+
}
|
|
484
|
+
function classifyToolFailKind(errorText) {
|
|
485
|
+
const normalized = errorText.toLowerCase();
|
|
486
|
+
const redacted = normalized.replace(/'[^']*'|"[^"]*"/g, " ");
|
|
487
|
+
const wholeMessage = normalized.trim();
|
|
488
|
+
const quote = wholeMessage.at(0);
|
|
489
|
+
const isQuote = quote === "'" || quote === '"';
|
|
490
|
+
const interior = isQuote ? wholeMessage.slice(1, -1) : "";
|
|
491
|
+
const unwrappedWholeMessage = wholeMessage.length >= 2 && isQuote && wholeMessage.at(-1) === quote && !hasUnescapedQuote(interior, quote) ? interior : "";
|
|
492
|
+
const fallback = isBoundedFilenameLiteral(unwrappedWholeMessage) && !hasHighConfidenceFilenamePrefix(unwrappedWholeMessage) ? "" : stripPathLiteralTokens(unwrappedWholeMessage);
|
|
493
|
+
const t = redacted.trim() || fallback;
|
|
494
|
+
if (/time.?out|timed out|deadline|etimedout/.test(t))
|
|
495
|
+
return "timeout";
|
|
496
|
+
if (/quota|rate.?limit|too many requests|\b429\b|tokens?\s+per\b|per\s+(minute|min|second|sec|hour|day)\b/.test(t)) {
|
|
497
|
+
return "quota";
|
|
498
|
+
}
|
|
499
|
+
if (/internal (server )?error|\b50[023]\b|panic|crashed|segfault/.test(t))
|
|
500
|
+
return "internal";
|
|
501
|
+
if (/schema|invalid (argument|param|input|request)|validation|required (field|property|parameter)|must be of type|invalid\b[\w\s'"()-]{0,40}\b(format|argument|parameter|value|type|field|property)\b/.test(t)) {
|
|
502
|
+
return "schema";
|
|
503
|
+
}
|
|
504
|
+
if (/unauthori[sz]ed|forbidden|permission denied|access denied|credential|api.?key|signature|(?:^|[^a-z])auth|\b40[13]\b|(?:invalid|expired|revoked|missing|bad)[^.;]{0,40}\btoken\b|\btoken\b[^.;]{0,40}(?:invalid|expired|revoked)/.test(t)) {
|
|
505
|
+
return "auth";
|
|
506
|
+
}
|
|
507
|
+
return "other";
|
|
508
|
+
}
|
|
509
|
+
function hasUnescapedQuote(text, quote) {
|
|
510
|
+
let precedingBackslashes = 0;
|
|
511
|
+
for (const character of text) {
|
|
512
|
+
if (character === "\\") {
|
|
513
|
+
precedingBackslashes += 1;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (character === quote && precedingBackslashes % 2 === 0)
|
|
517
|
+
return true;
|
|
518
|
+
precedingBackslashes = 0;
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
function stripPathLiteralTokens(text) {
|
|
523
|
+
return text.split(/\s+/).filter((token) => !isPathUriOrFilenameToken(token)).join(" ");
|
|
524
|
+
}
|
|
525
|
+
function isPathUriOrFilenameToken(token) {
|
|
526
|
+
return /[\\/]/.test(token) || /^[a-z][a-z0-9+.-]{0,31}:(?:\/\/)?[^\s]{1,512}$/.test(token) || isBoundedFilenameLiteral(token);
|
|
527
|
+
}
|
|
528
|
+
function isBoundedFilenameLiteral(text) {
|
|
529
|
+
return !/[\\/]/.test(text) && /^[a-z0-9][a-z0-9 ._-]{0,127}\.[a-z0-9_-]{1,16}$/.test(text);
|
|
530
|
+
}
|
|
531
|
+
function hasHighConfidenceFilenamePrefix(text) {
|
|
532
|
+
const finalSpace = text.lastIndexOf(" ");
|
|
533
|
+
if (finalSpace <= 0)
|
|
534
|
+
return false;
|
|
535
|
+
const prefix = text.slice(0, finalSpace).trim();
|
|
536
|
+
return /time.?out|timed out|deadline|etimedout|quota|rate.?limit|too many requests|\b429\b|tokens?\s+per\b|per\s+(minute|min|second|sec|hour|day)\b|internal (server )?error|\b50[023]\b|panic|crashed|segfault|schema|invalid (argument|param|input|request)|validation|required (field|property|parameter)|must be of type|invalid\b[\w\s'"()-]{0,40}\b(format|argument|parameter|value|type|field|property)\b|\b40[13]\b|unauthori[sz]ed|forbidden|permission denied|access denied|(?:invalid|expired)\b[^.;]{0,40}\btoken\b|\btoken\b[^.;]{0,40}(?:invalid|expired)|authentication failure/.test(prefix);
|
|
537
|
+
}
|
|
538
|
+
function isAttributable(cls) {
|
|
539
|
+
if (cls === "tool_fail:schema")
|
|
540
|
+
return false;
|
|
541
|
+
return cls === "success" || cls.startsWith("hard_fail:") || cls.startsWith("tool_fail:") || cls === "schema_drift_suspect";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ../coach/dist/oats.js
|
|
545
|
+
function normalize(v) {
|
|
546
|
+
let norm = 0;
|
|
547
|
+
for (const x of v)
|
|
548
|
+
norm += x * x;
|
|
549
|
+
norm = Math.sqrt(norm);
|
|
550
|
+
if (norm === 0)
|
|
551
|
+
return new Float32Array(v.length);
|
|
552
|
+
const out = new Float32Array(v.length);
|
|
553
|
+
for (let i = 0; i < v.length; i++)
|
|
554
|
+
out[i] = v[i] / norm;
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
function cosine(a, b) {
|
|
558
|
+
if (a.length !== b.length)
|
|
559
|
+
throw new RangeError("dimension mismatch");
|
|
560
|
+
let dot = 0;
|
|
561
|
+
let na = 0;
|
|
562
|
+
let nb = 0;
|
|
563
|
+
for (let i = 0; i < a.length; i++) {
|
|
564
|
+
const x = a[i];
|
|
565
|
+
const y = b[i];
|
|
566
|
+
dot += x * y;
|
|
567
|
+
na += x * x;
|
|
568
|
+
nb += y * y;
|
|
569
|
+
}
|
|
570
|
+
if (na === 0 || nb === 0)
|
|
571
|
+
return 0;
|
|
572
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
573
|
+
}
|
|
574
|
+
function meanVec(vs) {
|
|
575
|
+
if (vs.length === 0)
|
|
576
|
+
throw new RangeError("meanVec of empty set");
|
|
577
|
+
const dims = vs[0].length;
|
|
578
|
+
const out = new Float32Array(dims);
|
|
579
|
+
for (const v of vs) {
|
|
580
|
+
if (v.length !== dims)
|
|
581
|
+
throw new RangeError("dimension mismatch");
|
|
582
|
+
for (let i = 0; i < dims; i++)
|
|
583
|
+
out[i] = out[i] + v[i];
|
|
584
|
+
}
|
|
585
|
+
for (let i = 0; i < dims; i++)
|
|
586
|
+
out[i] = out[i] / vs.length;
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
function oatsAdjust(base, positives, negatives, opts = {}) {
|
|
590
|
+
const { alpha = 0.3, beta = 0.1, iterations = 3, minPositives = 4 } = opts;
|
|
591
|
+
if (positives.length < minPositives) {
|
|
592
|
+
return { vec: normalize(base), applied: false };
|
|
593
|
+
}
|
|
594
|
+
const posCentroid = meanVec(positives);
|
|
595
|
+
const negCentroid = negatives.length > 0 ? meanVec(negatives) : null;
|
|
596
|
+
let e = normalize(base);
|
|
597
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
598
|
+
const next = new Float32Array(e.length);
|
|
599
|
+
for (let i = 0; i < e.length; i++) {
|
|
600
|
+
let x = (1 - alpha) * e[i] + alpha * posCentroid[i];
|
|
601
|
+
if (negCentroid)
|
|
602
|
+
x -= beta * negCentroid[i];
|
|
603
|
+
next[i] = x;
|
|
604
|
+
}
|
|
605
|
+
e = normalize(next);
|
|
606
|
+
}
|
|
607
|
+
return { vec: e, applied: true };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ../shared/dist/namespacing.js
|
|
611
|
+
import { createHash } from "node:crypto";
|
|
612
|
+
var INVALID = /[^a-zA-Z0-9_-]+/g;
|
|
613
|
+
var GENERATED_SUFFIX = /-[a-f0-9]{10}$/;
|
|
614
|
+
function sanitizeSegment(raw) {
|
|
615
|
+
const cleaned = raw.replace(INVALID, "-").replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
|
|
616
|
+
return cleaned.length > 0 ? cleaned : "x";
|
|
617
|
+
}
|
|
618
|
+
function sanitizeSource(raw) {
|
|
619
|
+
const s = sanitizeSegment(raw).replace(/_{2,}/g, "_").replace(/^_+|_+$/g, "");
|
|
620
|
+
return s.length > 0 ? s : "x";
|
|
621
|
+
}
|
|
622
|
+
function sha256PublicName(raw) {
|
|
623
|
+
return createHash("sha256").update(raw, "utf8").digest("hex");
|
|
624
|
+
}
|
|
625
|
+
function stableSegment(raw) {
|
|
626
|
+
const safe = sanitizeSegment(raw);
|
|
627
|
+
return raw === safe && !GENERATED_SUFFIX.test(raw) ? safe : `${safe}-${sha256PublicName(raw).slice(0, 10)}`;
|
|
628
|
+
}
|
|
629
|
+
function stableBackendName(raw) {
|
|
630
|
+
const segment = stableSegment(raw);
|
|
631
|
+
const source = sanitizeSource(segment);
|
|
632
|
+
const name = segment === source && source !== "skill-server" ? segment : `${source}-${sha256PublicName(raw).slice(0, 10)}`;
|
|
633
|
+
return name === "skill" ? "skill-server" : name;
|
|
634
|
+
}
|
|
635
|
+
var NAMESPACE_SEP = "__";
|
|
636
|
+
function stableNamespacedId(source, rawName) {
|
|
637
|
+
return `${sanitizeSource(source)}${NAMESPACE_SEP}${stableSegment(rawName)}`;
|
|
638
|
+
}
|
|
639
|
+
function parseNamespacedId(id) {
|
|
640
|
+
const idx = id.indexOf(NAMESPACE_SEP);
|
|
641
|
+
if (idx <= 0 || idx >= id.length - NAMESPACE_SEP.length)
|
|
642
|
+
return null;
|
|
643
|
+
return { source: id.slice(0, idx), name: id.slice(idx + NAMESPACE_SEP.length) };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ../shared/dist/stats.js
|
|
647
|
+
function wilsonLowerBound(successes, n, z = 1.96) {
|
|
648
|
+
if (n <= 0)
|
|
649
|
+
return 0;
|
|
650
|
+
if (successes < 0 || successes > n)
|
|
651
|
+
throw new RangeError("successes must be within [0, n]");
|
|
652
|
+
const phat = successes / n;
|
|
653
|
+
const z2 = z * z;
|
|
654
|
+
const denom = 1 + z2 / n;
|
|
655
|
+
const centre = phat + z2 / (2 * n);
|
|
656
|
+
const spread = z * Math.sqrt(phat * (1 - phat) / n + z2 / (4 * n * n));
|
|
657
|
+
return Math.max(0, (centre - spread) / denom);
|
|
658
|
+
}
|
|
659
|
+
function percentile(sortedAscending, p) {
|
|
660
|
+
if (sortedAscending.length === 0)
|
|
661
|
+
return null;
|
|
662
|
+
const idx = Math.min(sortedAscending.length - 1, Math.max(0, Math.ceil(p / 100 * sortedAscending.length) - 1));
|
|
663
|
+
return sortedAscending[idx] ?? null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ../shared/dist/tokens.js
|
|
667
|
+
function estimateTokensFromChars(chars) {
|
|
668
|
+
return chars <= 0 ? 0 : Math.ceil(chars / 4);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ../coach/dist/util.js
|
|
672
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
673
|
+
function sha256Hex(input) {
|
|
674
|
+
const hash = createHash2("sha256");
|
|
675
|
+
if (typeof input === "string")
|
|
676
|
+
hash.update(input, "utf8");
|
|
677
|
+
else
|
|
678
|
+
hash.update(input);
|
|
679
|
+
return hash.digest("hex");
|
|
680
|
+
}
|
|
681
|
+
function stableStringify(value) {
|
|
682
|
+
return JSON.stringify(sortValue(value));
|
|
683
|
+
}
|
|
684
|
+
function sortValue(value) {
|
|
685
|
+
if (Array.isArray(value))
|
|
686
|
+
return value.map(sortValue);
|
|
687
|
+
if (value !== null && typeof value === "object") {
|
|
688
|
+
const out = {};
|
|
689
|
+
for (const key of Object.keys(value).sort()) {
|
|
690
|
+
out[key] = sortValue(value[key]);
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
return value;
|
|
695
|
+
}
|
|
696
|
+
function hashArgs(args) {
|
|
697
|
+
return sha256Hex(stableStringify(args ?? null));
|
|
698
|
+
}
|
|
699
|
+
function hashNeed(need) {
|
|
700
|
+
return sha256Hex(need.trim().toLowerCase());
|
|
701
|
+
}
|
|
702
|
+
function vecToBlob(vec) {
|
|
703
|
+
return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
|
|
704
|
+
}
|
|
705
|
+
function blobToVec(blob, dims) {
|
|
706
|
+
if (!Number.isInteger(dims) || dims <= 0) {
|
|
707
|
+
throw new RangeError(`vector dimensions must be a positive integer, got ${dims}`);
|
|
708
|
+
}
|
|
709
|
+
if (blob.byteLength !== dims * 4) {
|
|
710
|
+
throw new RangeError(`vector blob is ${blob.byteLength}B but dims=${dims} expects ${dims * 4}B`);
|
|
711
|
+
}
|
|
712
|
+
const copy = Buffer.from(blob);
|
|
713
|
+
const vec = new Float32Array(copy.buffer, copy.byteOffset, dims);
|
|
714
|
+
for (const value of vec) {
|
|
715
|
+
if (!Number.isFinite(value)) {
|
|
716
|
+
throw new RangeError("vector contains a non-finite value");
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return vec;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ../coach/dist/store.js
|
|
723
|
+
var SOFT_FAIL_LOOKBACK = 3;
|
|
724
|
+
var QUARANTINE_DWELL_MS = 24 * 3600 * 1e3;
|
|
725
|
+
var HYBRID_LEX_WEIGHT = 0.15;
|
|
726
|
+
var HYBRID_COS_WEIGHT = 0.85;
|
|
727
|
+
var MIN_INFORMATIVE_COS_SPAN = 0.15;
|
|
728
|
+
var LEX_SCORE_FLOOR = 0.05;
|
|
729
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
730
|
+
"the",
|
|
731
|
+
"a",
|
|
732
|
+
"an",
|
|
733
|
+
"and",
|
|
734
|
+
"or",
|
|
735
|
+
"of",
|
|
736
|
+
"to",
|
|
737
|
+
"in",
|
|
738
|
+
"on",
|
|
739
|
+
"for",
|
|
740
|
+
"is",
|
|
741
|
+
"are",
|
|
742
|
+
"be",
|
|
743
|
+
"my",
|
|
744
|
+
"that",
|
|
745
|
+
"this",
|
|
746
|
+
"it",
|
|
747
|
+
"its",
|
|
748
|
+
"with",
|
|
749
|
+
"as",
|
|
750
|
+
"at",
|
|
751
|
+
"by",
|
|
752
|
+
"from",
|
|
753
|
+
"into",
|
|
754
|
+
"do",
|
|
755
|
+
"does",
|
|
756
|
+
"me",
|
|
757
|
+
"we",
|
|
758
|
+
"us",
|
|
759
|
+
"your",
|
|
760
|
+
"you",
|
|
761
|
+
"i",
|
|
762
|
+
"so",
|
|
763
|
+
"if",
|
|
764
|
+
"then"
|
|
765
|
+
]);
|
|
766
|
+
function lexTokens(text) {
|
|
767
|
+
const spaced = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Za-z])([0-9])/g, "$1 $2");
|
|
768
|
+
return spaced.toLowerCase().match(/[a-z0-9]{2,}/g) ?? [];
|
|
769
|
+
}
|
|
770
|
+
function ftsNameText(source, name) {
|
|
771
|
+
return `${source} ${name} ${lexTokens(name).join(" ")}`;
|
|
772
|
+
}
|
|
773
|
+
function canonicalJson(value) {
|
|
774
|
+
if (value === void 0)
|
|
775
|
+
return "null";
|
|
776
|
+
if (value === null || typeof value !== "object")
|
|
777
|
+
return JSON.stringify(value) ?? "null";
|
|
778
|
+
if (Array.isArray(value))
|
|
779
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
780
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
781
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
782
|
+
}
|
|
783
|
+
var DEF_HASH_VERSION = "2";
|
|
784
|
+
var DEF_HASH_VERSION_KEY = "def_hash_version";
|
|
785
|
+
function defHash(entry) {
|
|
786
|
+
return sha256Hex(canonicalJson({
|
|
787
|
+
v: DEF_HASH_VERSION,
|
|
788
|
+
name: entry.name,
|
|
789
|
+
description: entry.description,
|
|
790
|
+
title: entry.title ?? null,
|
|
791
|
+
annotations: entry.annotations ?? null,
|
|
792
|
+
inputSchema: entry.inputSchema ?? null,
|
|
793
|
+
outputSchema: entry.outputSchema ?? null,
|
|
794
|
+
execution: entry.execution ?? null,
|
|
795
|
+
body: entry.body ?? null
|
|
796
|
+
}));
|
|
797
|
+
}
|
|
798
|
+
var CoachStore = class {
|
|
799
|
+
db;
|
|
800
|
+
// Initialized in the constructor BODY: with ES2022 class fields, field
|
|
801
|
+
// initializers run before parameter-property assignment — `this.db` would
|
|
802
|
+
// still be undefined here.
|
|
803
|
+
activeCapabilityStmt;
|
|
804
|
+
constructor(db) {
|
|
805
|
+
this.db = db;
|
|
806
|
+
this.activeCapabilityStmt = this.db.prepare(`SELECT id, kind, source, name, description, title, annotations, execution, input_schema, output_schema, body, path, quarantined
|
|
807
|
+
FROM capability WHERE id = ? AND quarantined = 0`);
|
|
808
|
+
}
|
|
809
|
+
/** Close the underlying database handle. Idempotent — a second call is a
|
|
810
|
+
* no-op — so it is safe to call from a shutdown path that may fire more than
|
|
811
|
+
* once (stdin EOF racing SIGTERM). */
|
|
812
|
+
close() {
|
|
813
|
+
if (this.db.open)
|
|
814
|
+
this.db.close();
|
|
815
|
+
}
|
|
816
|
+
// ── maintenance (the nightly job) ─────────────────────────────────────────
|
|
817
|
+
getMeta(key) {
|
|
818
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
819
|
+
return row?.value ?? null;
|
|
820
|
+
}
|
|
821
|
+
setMeta(key, value) {
|
|
822
|
+
this.db.prepare("INSERT INTO meta(key, value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* The nightly job, run opportunistically at serve boot: recompute ratings
|
|
826
|
+
* from logged outcomes and refine tool vectors (OATS). Debounced by
|
|
827
|
+
* `intervalMs` so frequent client restarts don't thrash. Returns what ran.
|
|
828
|
+
* This is what makes the README's "learns from outcomes" true at runtime.
|
|
829
|
+
*/
|
|
830
|
+
runMaintenanceIfDue(intervalMs = 20 * 3600 * 1e3, now = Date.now()) {
|
|
831
|
+
const last = Number(this.getMeta("last_maintenance") ?? 0);
|
|
832
|
+
if (now - last < intervalMs)
|
|
833
|
+
return { ran: false };
|
|
834
|
+
this.recomputeRatings("all", now);
|
|
835
|
+
const oats = this.runOats(now);
|
|
836
|
+
this.setMeta("last_maintenance", String(now));
|
|
837
|
+
return { ran: true, oats };
|
|
838
|
+
}
|
|
839
|
+
// ── capabilities ────────────────────────────────────────────────────────
|
|
840
|
+
upsertCapabilities(entries, now = Date.now()) {
|
|
841
|
+
const result = { added: [], changed: [], driftEvents: 0 };
|
|
842
|
+
const getExisting = this.db.prepare("SELECT id, def_hash FROM capability WHERE id = ?");
|
|
843
|
+
const insert = this.db.prepare(`
|
|
844
|
+
INSERT INTO capability(id, kind, source, name, description, title, annotations, execution,
|
|
845
|
+
input_schema, output_schema, body, path, def_hash, quarantined, first_seen, last_seen)
|
|
846
|
+
VALUES(@id, @kind, @source, @name, @description, @title, @annotations, @execution,
|
|
847
|
+
@input_schema, @output_schema, @body, @path, @def_hash, 0, @now, @now)
|
|
848
|
+
`);
|
|
849
|
+
const update = this.db.prepare(`
|
|
850
|
+
UPDATE capability SET kind=@kind, source=@source, name=@name, description=@description,
|
|
851
|
+
title=@title, annotations=@annotations, execution=@execution,
|
|
852
|
+
input_schema=@input_schema, output_schema=@output_schema, body=@body, path=@path,
|
|
853
|
+
def_hash=@def_hash, quarantined=@quarantined, last_seen=@now
|
|
854
|
+
WHERE id=@id
|
|
855
|
+
`);
|
|
856
|
+
const touch = this.db.prepare("UPDATE capability SET last_seen=? WHERE id=?");
|
|
857
|
+
const rebaseline = this.db.prepare(`
|
|
858
|
+
UPDATE capability SET kind=@kind, source=@source, name=@name, description=@description,
|
|
859
|
+
title=@title, annotations=@annotations, execution=@execution,
|
|
860
|
+
input_schema=@input_schema, output_schema=@output_schema, body=@body, path=@path,
|
|
861
|
+
def_hash=@def_hash, last_seen=@now
|
|
862
|
+
WHERE id=@id
|
|
863
|
+
`);
|
|
864
|
+
const drift = this.db.prepare("INSERT INTO drift_event(ts, capability, old_hash, new_hash) VALUES(?,?,?,?)");
|
|
865
|
+
const ftsDelete = this.db.prepare("DELETE FROM capability_fts WHERE id = ?");
|
|
866
|
+
const ftsInsert = this.db.prepare("INSERT INTO capability_fts(id, name, description, body) VALUES(?,?,?,?)");
|
|
867
|
+
const vecDelete = this.db.prepare("DELETE FROM vec WHERE capability = ?");
|
|
868
|
+
const getTombstone = this.db.prepare("SELECT def_hash, quarantined, last_drift_ts FROM removed_capability WHERE id = ?");
|
|
869
|
+
const deleteTombstone = this.db.prepare("DELETE FROM removed_capability WHERE id = ?");
|
|
870
|
+
const setQuarantined = this.db.prepare("UPDATE capability SET quarantined = 1 WHERE id = ?");
|
|
871
|
+
const run = this.db.transaction(() => {
|
|
872
|
+
if (this.getMeta(DEF_HASH_VERSION_KEY) !== DEF_HASH_VERSION) {
|
|
873
|
+
this.db.prepare("UPDATE capability SET def_hash = ''").run();
|
|
874
|
+
this.db.prepare("UPDATE removed_capability SET def_hash = ''").run();
|
|
875
|
+
this.setMeta(DEF_HASH_VERSION_KEY, DEF_HASH_VERSION);
|
|
876
|
+
}
|
|
877
|
+
for (const entry of entries) {
|
|
878
|
+
const hash = defHash(entry);
|
|
879
|
+
const row = getExisting.get(entry.id);
|
|
880
|
+
if (row && row.def_hash === "") {
|
|
881
|
+
rebaseline.run(buildParams(entry, hash, now));
|
|
882
|
+
ftsDelete.run(entry.id);
|
|
883
|
+
ftsInsert.run(entry.id, ftsNameText(entry.source, entry.name), entry.description, entry.body ?? "");
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const params = buildParams(entry, hash, now);
|
|
887
|
+
if (!row) {
|
|
888
|
+
insert.run(params);
|
|
889
|
+
ftsInsert.run(entry.id, ftsNameText(entry.source, entry.name), entry.description, entry.body ?? "");
|
|
890
|
+
const tomb = getTombstone.get(entry.id);
|
|
891
|
+
if (tomb) {
|
|
892
|
+
deleteTombstone.run(entry.id);
|
|
893
|
+
if (tomb.def_hash !== "" && tomb.def_hash !== hash) {
|
|
894
|
+
drift.run(now, entry.id, tomb.def_hash, hash);
|
|
895
|
+
setQuarantined.run(entry.id);
|
|
896
|
+
result.changed.push(entry.id);
|
|
897
|
+
result.driftEvents += 1;
|
|
898
|
+
} else {
|
|
899
|
+
if (tomb.quarantined === 1 && tomb.last_drift_ts !== null && now - tomb.last_drift_ts < QUARANTINE_DWELL_MS) {
|
|
900
|
+
setQuarantined.run(entry.id);
|
|
901
|
+
}
|
|
902
|
+
result.added.push(entry.id);
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
result.added.push(entry.id);
|
|
906
|
+
}
|
|
907
|
+
} else if (row.def_hash !== hash) {
|
|
908
|
+
drift.run(now, entry.id, row.def_hash, hash);
|
|
909
|
+
update.run({ ...params, quarantined: 1 });
|
|
910
|
+
ftsDelete.run(entry.id);
|
|
911
|
+
ftsInsert.run(entry.id, ftsNameText(entry.source, entry.name), entry.description, entry.body ?? "");
|
|
912
|
+
vecDelete.run(entry.id);
|
|
913
|
+
result.changed.push(entry.id);
|
|
914
|
+
result.driftEvents += 1;
|
|
915
|
+
} else {
|
|
916
|
+
const lastDrift = this.db.prepare("SELECT ts FROM drift_event WHERE capability = ? ORDER BY id DESC LIMIT 1").get(entry.id);
|
|
917
|
+
const dwellOver = !lastDrift || now - lastDrift.ts >= QUARANTINE_DWELL_MS;
|
|
918
|
+
if (dwellOver) {
|
|
919
|
+
this.db.prepare("UPDATE capability SET quarantined = 0, last_seen = ? WHERE id = ?").run(now, entry.id);
|
|
920
|
+
} else {
|
|
921
|
+
touch.run(now, entry.id);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
run.immediate();
|
|
927
|
+
return result;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Model-switch guard: OATS-adjusted vectors and cached need vectors are only
|
|
931
|
+
* meaningful in the embedding space they were computed in. When the active
|
|
932
|
+
* model changes (RAM boundary crossed, DB moved between machines), stale
|
|
933
|
+
* `adj` blobs would otherwise be read at the new dims — silently poisoning
|
|
934
|
+
* exactly the best-learned tools. Call before any backfill.
|
|
935
|
+
*/
|
|
936
|
+
ensureEmbeddingModel(modelId) {
|
|
937
|
+
const prev = this.getMeta("embedding_model");
|
|
938
|
+
if (prev === modelId)
|
|
939
|
+
return { switched: false };
|
|
940
|
+
const run = this.db.transaction(() => {
|
|
941
|
+
if (prev !== null) {
|
|
942
|
+
this.db.prepare("DELETE FROM vec").run();
|
|
943
|
+
this.db.prepare("DELETE FROM need_vec").run();
|
|
944
|
+
}
|
|
945
|
+
this.setMeta("embedding_model", modelId);
|
|
946
|
+
});
|
|
947
|
+
run.immediate();
|
|
948
|
+
return { switched: prev !== null };
|
|
949
|
+
}
|
|
950
|
+
listCapabilities(opts = {}) {
|
|
951
|
+
const rows = this.db.prepare(`SELECT id, kind, source, name, description, title, annotations, execution, input_schema, output_schema, body, path, quarantined
|
|
952
|
+
FROM capability
|
|
953
|
+
WHERE (@includeQuarantined = 1 OR quarantined = 0)
|
|
954
|
+
AND (@kind IS NULL OR kind = @kind)
|
|
955
|
+
ORDER BY id`).all({
|
|
956
|
+
includeQuarantined: opts.includeQuarantined ? 1 : 0,
|
|
957
|
+
kind: opts.kind ?? null
|
|
958
|
+
});
|
|
959
|
+
return rows.map(rowToEntry);
|
|
960
|
+
}
|
|
961
|
+
getCapability(id) {
|
|
962
|
+
const row = this.db.prepare(`SELECT id, kind, source, name, description, title, annotations, execution, input_schema, output_schema, body, path, quarantined
|
|
963
|
+
FROM capability WHERE id = ?`).get(id);
|
|
964
|
+
return row ? rowToEntry(row) : null;
|
|
965
|
+
}
|
|
966
|
+
/** Draft-path lookup: quarantined capabilities never enter a roster. */
|
|
967
|
+
activeCapability(id) {
|
|
968
|
+
const row = this.activeCapabilityStmt.get(id);
|
|
969
|
+
return row ? rowToEntry(row) : null;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Remove capabilities that no longer exist upstream (server removed, skill
|
|
973
|
+
* deleted). Vectors and FTS rows go with them; outcome history is kept.
|
|
974
|
+
*/
|
|
975
|
+
pruneMissing(presentIds, protectedSources = /* @__PURE__ */ new Set(), opts = {}) {
|
|
976
|
+
const now = opts.now ?? Date.now();
|
|
977
|
+
const selectAll = this.db.prepare("SELECT id, source, last_seen, def_hash, quarantined FROM capability");
|
|
978
|
+
let gone = [];
|
|
979
|
+
const keepSince = opts.keepSeenSince ?? Number.POSITIVE_INFINITY;
|
|
980
|
+
const deSuffix = (source) => source.replace(/-\d+$/, "");
|
|
981
|
+
const run = this.db.transaction(() => {
|
|
982
|
+
const all = selectAll.all();
|
|
983
|
+
gone = all.filter((r) => !presentIds.has(r.id) && !protectedSources.has(r.source) && !protectedSources.has(deSuffix(r.source)) && r.last_seen < keepSince);
|
|
984
|
+
const delCap = this.db.prepare("DELETE FROM capability WHERE id = ?");
|
|
985
|
+
const delFts = this.db.prepare("DELETE FROM capability_fts WHERE id = ?");
|
|
986
|
+
const delVec = this.db.prepare("DELETE FROM vec WHERE capability = ?");
|
|
987
|
+
const lastDriftStmt = this.db.prepare("SELECT ts FROM drift_event WHERE capability = ? ORDER BY id DESC LIMIT 1");
|
|
988
|
+
const tombstone = this.db.prepare(`INSERT OR REPLACE INTO removed_capability(id, def_hash, quarantined, last_drift_ts, removed_at)
|
|
989
|
+
VALUES(?,?,?,?,?)`);
|
|
990
|
+
for (const r of gone) {
|
|
991
|
+
const ld = lastDriftStmt.get(r.id);
|
|
992
|
+
tombstone.run(r.id, r.def_hash, r.quarantined, ld?.ts ?? null, now);
|
|
993
|
+
delCap.run(r.id);
|
|
994
|
+
delFts.run(r.id);
|
|
995
|
+
delVec.run(r.id);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
run.immediate();
|
|
999
|
+
return gone.map((r) => r.id);
|
|
1000
|
+
}
|
|
1001
|
+
/** Sixth Man field data: every suggestion is logged; `taken` flips when the agent follows it. */
|
|
1002
|
+
recordSuggestion(session, failed, suggested, now = Date.now()) {
|
|
1003
|
+
this.db.prepare("INSERT INTO suggestion(ts, session, failed_capability, suggested_capability) VALUES(?,?,?,?)").run(now, session, failed, suggested);
|
|
1004
|
+
}
|
|
1005
|
+
markSuggestionTaken(session, capability) {
|
|
1006
|
+
this.db.prepare(`UPDATE suggestion SET taken = 1 WHERE id = (
|
|
1007
|
+
SELECT id FROM suggestion WHERE session = ? AND suggested_capability = ? AND taken = 0
|
|
1008
|
+
ORDER BY id DESC LIMIT 1)`).run(session, capability);
|
|
1009
|
+
}
|
|
1010
|
+
clearQuarantine(id) {
|
|
1011
|
+
this.db.prepare("UPDATE capability SET quarantined = 0 WHERE id = ?").run(id);
|
|
1012
|
+
}
|
|
1013
|
+
driftEvents() {
|
|
1014
|
+
return this.db.prepare("SELECT ts, capability, old_hash, new_hash FROM drift_event ORDER BY id DESC").all().map((r) => ({ ts: r.ts, capability: r.capability, oldHash: r.old_hash, newHash: r.new_hash }));
|
|
1015
|
+
}
|
|
1016
|
+
// ── outcomes ────────────────────────────────────────────────────────────
|
|
1017
|
+
recordOutcome(input) {
|
|
1018
|
+
const ts = input.ts ?? Date.now();
|
|
1019
|
+
const run = this.db.transaction(() => {
|
|
1020
|
+
const insert = this.db.prepare(`
|
|
1021
|
+
INSERT INTO outcome(ts, session, source, capability, need_hash, args_hash, intent_cat,
|
|
1022
|
+
class, latency_ms, soft_fail, substituted, explored, spec_ver)
|
|
1023
|
+
VALUES(@ts, @session, @source, @capability, @need_hash, @args_hash, @intent_cat,
|
|
1024
|
+
@class, @latency_ms, 0, @substituted, @explored, @spec_ver)
|
|
1025
|
+
`);
|
|
1026
|
+
const info = insert.run({
|
|
1027
|
+
ts,
|
|
1028
|
+
session: input.session,
|
|
1029
|
+
source: input.source,
|
|
1030
|
+
capability: input.capability,
|
|
1031
|
+
need_hash: input.needHash ?? null,
|
|
1032
|
+
args_hash: input.argsHash ?? null,
|
|
1033
|
+
intent_cat: input.intentCategory ?? null,
|
|
1034
|
+
class: input.outcomeClass,
|
|
1035
|
+
latency_ms: Math.max(0, Math.round(input.latencyMs)),
|
|
1036
|
+
substituted: input.substituted ? 1 : 0,
|
|
1037
|
+
explored: input.explored ? 1 : 0,
|
|
1038
|
+
spec_ver: input.specVersion ?? null
|
|
1039
|
+
});
|
|
1040
|
+
const id = Number(info.lastInsertRowid);
|
|
1041
|
+
this.markSoftFailIfRetry(id, input);
|
|
1042
|
+
this.markSuggestionTaken(input.session, input.capability);
|
|
1043
|
+
return id;
|
|
1044
|
+
});
|
|
1045
|
+
return run.immediate();
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Handoff §6.2 rule 4 (amended 2026-07-07 after the deep-review audit): a
|
|
1049
|
+
* re-call of the same capability with *different* args marks the PRIOR attempt
|
|
1050
|
+
* soft_fail — BUT only when that prior attempt did NOT succeed. The original
|
|
1051
|
+
* rule marked any prior call, which conflated the "retried because the result
|
|
1052
|
+
* was unusable" signal with the DOMINANT agent pattern of iterating one tool
|
|
1053
|
+
* over different inputs (read 5 files, list 5 dirs). Empirically that discarded
|
|
1054
|
+
* ~4 of 5 legitimate successes and starved OATS's positive corpus. A genuine
|
|
1055
|
+
* success is never retroactively downgraded; a prior FAILURE followed by an
|
|
1056
|
+
* adjusted-args retry is still excluded (MCP-Atlas fairness: don't blame the
|
|
1057
|
+
* tool for what may be the caller's first bad args). Distinguishing iteration
|
|
1058
|
+
* from dissatisfaction on two successes needs an end-of-task signal we don't
|
|
1059
|
+
* yet have (§6.2 item 5); until then, a success counts as a success.
|
|
1060
|
+
*/
|
|
1061
|
+
markSoftFailIfRetry(currentId, input) {
|
|
1062
|
+
if (!input.argsHash)
|
|
1063
|
+
return;
|
|
1064
|
+
const recent = this.db.prepare(`SELECT id, capability, args_hash, class FROM outcome
|
|
1065
|
+
WHERE session = ? AND id < ? ORDER BY id DESC LIMIT ?`).all(input.session, currentId, SOFT_FAIL_LOOKBACK);
|
|
1066
|
+
const prior = recent.find((r) => r.capability === input.capability && r.args_hash !== null && r.args_hash !== input.argsHash && r.class !== "success");
|
|
1067
|
+
if (prior) {
|
|
1068
|
+
this.db.prepare("UPDATE outcome SET soft_fail = 1 WHERE id = ?").run(prior.id);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// ── ratings ─────────────────────────────────────────────────────────────
|
|
1072
|
+
/**
|
|
1073
|
+
* Ratings use only attributable, non-explored, non-soft-fail rows (§6.2).
|
|
1074
|
+
* Percentile latencies come from successful calls: they describe how the
|
|
1075
|
+
* capability performs when it works.
|
|
1076
|
+
*/
|
|
1077
|
+
recomputeRatings(category = "all", now = Date.now()) {
|
|
1078
|
+
const rows = category === "all" ? this.db.prepare(`SELECT capability, class, latency_ms FROM outcome
|
|
1079
|
+
WHERE explored = 0 AND soft_fail = 0`).all() : this.db.prepare(`SELECT capability, class, latency_ms FROM outcome
|
|
1080
|
+
WHERE explored = 0 AND soft_fail = 0 AND intent_cat = ?`).all(category);
|
|
1081
|
+
const byCap = /* @__PURE__ */ new Map();
|
|
1082
|
+
for (const row of rows) {
|
|
1083
|
+
if (!isAttributable(row.class))
|
|
1084
|
+
continue;
|
|
1085
|
+
let agg = byCap.get(row.capability);
|
|
1086
|
+
if (!agg) {
|
|
1087
|
+
agg = { n: 0, successes: 0, latencies: [] };
|
|
1088
|
+
byCap.set(row.capability, agg);
|
|
1089
|
+
}
|
|
1090
|
+
agg.n += 1;
|
|
1091
|
+
if (row.class === "success") {
|
|
1092
|
+
agg.successes += 1;
|
|
1093
|
+
agg.latencies.push(row.latency_ms);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const upsert = this.db.prepare(`
|
|
1097
|
+
INSERT INTO rating(capability, category, n, successes, wilson_lb, p50_ms, p95_ms, updated_at)
|
|
1098
|
+
VALUES(@capability, @category, @n, @successes, @wilson_lb, @p50, @p95, @now)
|
|
1099
|
+
ON CONFLICT(capability, category) DO UPDATE SET
|
|
1100
|
+
n=@n, successes=@successes, wilson_lb=@wilson_lb, p50_ms=@p50, p95_ms=@p95, updated_at=@now
|
|
1101
|
+
`);
|
|
1102
|
+
const existing = this.db.prepare("SELECT capability FROM rating WHERE category = ?").all(category);
|
|
1103
|
+
const deleteRating = this.db.prepare("DELETE FROM rating WHERE capability = ? AND category = ?");
|
|
1104
|
+
const run = this.db.transaction(() => {
|
|
1105
|
+
for (const { capability } of existing) {
|
|
1106
|
+
if (!byCap.has(capability))
|
|
1107
|
+
deleteRating.run(capability, category);
|
|
1108
|
+
}
|
|
1109
|
+
for (const [capability, agg] of byCap) {
|
|
1110
|
+
const sorted = [...agg.latencies].sort((a, b) => a - b);
|
|
1111
|
+
upsert.run({
|
|
1112
|
+
capability,
|
|
1113
|
+
category,
|
|
1114
|
+
n: agg.n,
|
|
1115
|
+
successes: agg.successes,
|
|
1116
|
+
wilson_lb: wilsonLowerBound(agg.successes, agg.n),
|
|
1117
|
+
p50: percentile(sorted, 50),
|
|
1118
|
+
p95: percentile(sorted, 95),
|
|
1119
|
+
now
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
run();
|
|
1124
|
+
}
|
|
1125
|
+
getRating(capability, category = "all") {
|
|
1126
|
+
const row = this.db.prepare("SELECT n, successes, wilson_lb, p50_ms, p95_ms FROM rating WHERE capability=? AND category=?").get(capability, category);
|
|
1127
|
+
if (!row)
|
|
1128
|
+
return null;
|
|
1129
|
+
return {
|
|
1130
|
+
n: row.n,
|
|
1131
|
+
successes: row.successes,
|
|
1132
|
+
wilsonLb: row.wilson_lb,
|
|
1133
|
+
p50Ms: row.p50_ms,
|
|
1134
|
+
p95Ms: row.p95_ms
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
// ── retrieval ladder ────────────────────────────────────────────────────
|
|
1138
|
+
/** Rung 1: FTS5/BM25 — instant, zero-download. */
|
|
1139
|
+
lexicalSearch(need, k = 30, eligibleIds) {
|
|
1140
|
+
const all = [...new Set(lexTokens(need))];
|
|
1141
|
+
const content = all.filter((t) => !STOPWORDS.has(t));
|
|
1142
|
+
const tokens = content.length > 0 ? content : all;
|
|
1143
|
+
if (tokens.length === 0)
|
|
1144
|
+
return [];
|
|
1145
|
+
const match = tokens.map((t) => `"${t}"`).join(" OR ");
|
|
1146
|
+
try {
|
|
1147
|
+
const rows = this.db.prepare(`SELECT id, bm25(capability_fts) AS rank FROM capability_fts
|
|
1148
|
+
WHERE capability_fts MATCH @match
|
|
1149
|
+
AND (@eligible IS NULL OR id IN (SELECT value FROM json_each(@eligible)))
|
|
1150
|
+
ORDER BY rank LIMIT @limit`).all({ match, limit: k, eligible: eligibleIds ? JSON.stringify([...eligibleIds]) : null });
|
|
1151
|
+
if (rows.length === 0)
|
|
1152
|
+
return [];
|
|
1153
|
+
const ranks = rows.map((r) => r.rank);
|
|
1154
|
+
const best = Math.min(...ranks);
|
|
1155
|
+
const worst = Math.max(...ranks);
|
|
1156
|
+
const span = worst - best;
|
|
1157
|
+
return rows.map((r) => ({
|
|
1158
|
+
id: r.id,
|
|
1159
|
+
lexScore: span === 0 ? 1 : LEX_SCORE_FLOOR + (1 - LEX_SCORE_FLOOR) * ((worst - r.rank) / span)
|
|
1160
|
+
}));
|
|
1161
|
+
} catch {
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Rung 2 fusion: 0.15·lexical + 0.85·cosine when a need vector is available.
|
|
1167
|
+
* Quarantined capabilities never enter a roster.
|
|
1168
|
+
*/
|
|
1169
|
+
draftCandidates(need, k, needVec, eligibleIds) {
|
|
1170
|
+
const lexical = this.lexicalSearch(need, Math.max(30, k * 6), eligibleIds);
|
|
1171
|
+
const lexById = new Map(lexical.map((l) => [l.id, l.lexScore]));
|
|
1172
|
+
const vecs = needVec ? this.loadVecs() : /* @__PURE__ */ new Map();
|
|
1173
|
+
const candidateIds = new Set(lexById.keys());
|
|
1174
|
+
if (needVec)
|
|
1175
|
+
for (const id of vecs.keys())
|
|
1176
|
+
candidateIds.add(id);
|
|
1177
|
+
const gathered = [];
|
|
1178
|
+
for (const id of candidateIds) {
|
|
1179
|
+
if (eligibleIds && !eligibleIds.has(id))
|
|
1180
|
+
continue;
|
|
1181
|
+
const entry = this.activeCapability(id);
|
|
1182
|
+
if (!entry)
|
|
1183
|
+
continue;
|
|
1184
|
+
const lexScore = lexById.get(id) ?? null;
|
|
1185
|
+
let cosScore = null;
|
|
1186
|
+
if (needVec) {
|
|
1187
|
+
const v = vecs.get(id);
|
|
1188
|
+
if (v && v.length === needVec.length)
|
|
1189
|
+
cosScore = cosine(needVec, v);
|
|
1190
|
+
}
|
|
1191
|
+
gathered.push({ entry, lexScore, cosScore });
|
|
1192
|
+
}
|
|
1193
|
+
const cosVals = gathered.map((g) => g.cosScore).filter((c) => c !== null);
|
|
1194
|
+
const cosMin = cosVals.length > 0 ? Math.min(...cosVals) : 0;
|
|
1195
|
+
const cosSpan = cosVals.length > 0 ? Math.max(...cosVals) - cosMin : 0;
|
|
1196
|
+
const denseInformative = cosVals.length > 1 && cosSpan >= MIN_INFORMATIVE_COS_SPAN;
|
|
1197
|
+
const out = [];
|
|
1198
|
+
for (const g of gathered) {
|
|
1199
|
+
let score;
|
|
1200
|
+
if (needVec && denseInformative && g.cosScore !== null) {
|
|
1201
|
+
const cosNorm = (g.cosScore - cosMin) / cosSpan;
|
|
1202
|
+
score = HYBRID_LEX_WEIGHT * (g.lexScore ?? 0) + HYBRID_COS_WEIGHT * cosNorm;
|
|
1203
|
+
} else {
|
|
1204
|
+
score = g.lexScore ?? 0;
|
|
1205
|
+
}
|
|
1206
|
+
if (score > 0)
|
|
1207
|
+
out.push({ entry: g.entry, score, lexScore: g.lexScore, cosScore: g.cosScore });
|
|
1208
|
+
}
|
|
1209
|
+
out.sort((a, b) => b.score - a.score);
|
|
1210
|
+
if (out.length >= k)
|
|
1211
|
+
return out.slice(0, k);
|
|
1212
|
+
const have = new Set(out.map((c) => c.entry.id));
|
|
1213
|
+
for (const entry of this.ratedFallback(k - out.length, have, eligibleIds)) {
|
|
1214
|
+
out.push({ entry, score: 0, lexScore: null, cosScore: null });
|
|
1215
|
+
}
|
|
1216
|
+
return out.slice(0, k);
|
|
1217
|
+
}
|
|
1218
|
+
ratedFallback(limit, exclude, eligibleIds) {
|
|
1219
|
+
const rows = this.db.prepare(`SELECT c.id FROM capability c
|
|
1220
|
+
LEFT JOIN rating r ON r.capability = c.id AND r.category = 'all'
|
|
1221
|
+
WHERE c.quarantined = 0
|
|
1222
|
+
AND (@eligible IS NULL OR c.id IN (SELECT value FROM json_each(@eligible)))
|
|
1223
|
+
ORDER BY COALESCE(r.wilson_lb, 0) DESC, c.last_seen DESC
|
|
1224
|
+
LIMIT @limit`).all({ limit: Math.max(limit + exclude.size, limit), eligible: eligibleIds ? JSON.stringify([...eligibleIds]) : null });
|
|
1225
|
+
const out = [];
|
|
1226
|
+
for (const row of rows) {
|
|
1227
|
+
if (exclude.has(row.id))
|
|
1228
|
+
continue;
|
|
1229
|
+
const entry = this.activeCapability(row.id);
|
|
1230
|
+
if (entry)
|
|
1231
|
+
out.push(entry);
|
|
1232
|
+
if (out.length >= limit)
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
return out;
|
|
1236
|
+
}
|
|
1237
|
+
// ── vectors & OATS ──────────────────────────────────────────────────────
|
|
1238
|
+
storeBaseVec(capability, vec, now = Date.now(), expected) {
|
|
1239
|
+
if (vec.length === 0 || vec.some((value) => !Number.isFinite(value)))
|
|
1240
|
+
return false;
|
|
1241
|
+
const normalized = normalize(vec);
|
|
1242
|
+
const result = this.db.prepare(`INSERT INTO vec(capability, dims, base, adj, updated_at)
|
|
1243
|
+
SELECT @capability, @dims, @base, NULL, @updatedAt
|
|
1244
|
+
WHERE @guarded = 0 OR (
|
|
1245
|
+
EXISTS (
|
|
1246
|
+
SELECT 1 FROM capability
|
|
1247
|
+
WHERE id = @capability AND def_hash = @expectedDefHash
|
|
1248
|
+
)
|
|
1249
|
+
AND EXISTS (
|
|
1250
|
+
SELECT 1 FROM meta
|
|
1251
|
+
WHERE key = 'embedding_model' AND value = @expectedModelId
|
|
1252
|
+
)
|
|
1253
|
+
)
|
|
1254
|
+
ON CONFLICT(capability) DO UPDATE SET
|
|
1255
|
+
-- a dims change means a different embedding space: the old adj is
|
|
1256
|
+
-- meaningless there and must not survive the base rewrite
|
|
1257
|
+
adj = CASE WHEN vec.dims != excluded.dims THEN NULL ELSE vec.adj END,
|
|
1258
|
+
dims = excluded.dims, base = excluded.base, updated_at = excluded.updated_at`).run({
|
|
1259
|
+
capability,
|
|
1260
|
+
dims: normalized.length,
|
|
1261
|
+
base: vecToBlob(normalized),
|
|
1262
|
+
updatedAt: now,
|
|
1263
|
+
guarded: expected ? 1 : 0,
|
|
1264
|
+
expectedDefHash: expected?.defHash ?? null,
|
|
1265
|
+
expectedModelId: expected?.modelId ?? null
|
|
1266
|
+
});
|
|
1267
|
+
return result.changes === 1;
|
|
1268
|
+
}
|
|
1269
|
+
storeNeedVec(needHash, vec, now = Date.now()) {
|
|
1270
|
+
if (vec.length === 0 || vec.some((value) => !Number.isFinite(value)))
|
|
1271
|
+
return;
|
|
1272
|
+
const normalized = normalize(vec);
|
|
1273
|
+
this.db.prepare(`INSERT INTO need_vec(need_hash, dims, vec, ts) VALUES(?,?,?,?)
|
|
1274
|
+
ON CONFLICT(need_hash) DO UPDATE SET dims=excluded.dims, vec=excluded.vec, ts=excluded.ts`).run(needHash, normalized.length, vecToBlob(normalized), now);
|
|
1275
|
+
}
|
|
1276
|
+
/** Ids that already have a stored vector (same model, post model-switch guard) —
|
|
1277
|
+
* lets warm boots skip re-embedding what's already there instead of re-doing
|
|
1278
|
+
* the whole roster every serve process (audit D4). */
|
|
1279
|
+
vecCapabilityIds() {
|
|
1280
|
+
const rows = this.validVecRows();
|
|
1281
|
+
return new Set(rows.map((r) => r.capability));
|
|
1282
|
+
}
|
|
1283
|
+
/** adj if present, else base — the vector drafts actually use. */
|
|
1284
|
+
loadVecs() {
|
|
1285
|
+
const rows = this.validVecRows();
|
|
1286
|
+
const map = /* @__PURE__ */ new Map();
|
|
1287
|
+
for (const row of rows) {
|
|
1288
|
+
map.set(row.capability, blobToVec(row.adj ?? row.base, row.dims));
|
|
1289
|
+
}
|
|
1290
|
+
return map;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Validate and repair stored base/adjustment vectors under one writer lock.
|
|
1294
|
+
* A corrupt base makes the row backfill-eligible; a corrupt derived
|
|
1295
|
+
* adjustment is losslessly cleared so routing falls back to the valid base.
|
|
1296
|
+
*/
|
|
1297
|
+
validVecRows() {
|
|
1298
|
+
const read = this.db.prepare("SELECT capability, dims, base, adj, updated_at FROM vec");
|
|
1299
|
+
const deleteBase = this.db.prepare("DELETE FROM vec WHERE capability = ?");
|
|
1300
|
+
const clearAdj = this.db.prepare("UPDATE vec SET adj = NULL WHERE capability = ?");
|
|
1301
|
+
const repair = this.db.transaction(() => {
|
|
1302
|
+
const rows = read.all();
|
|
1303
|
+
const valid = [];
|
|
1304
|
+
for (const row of rows) {
|
|
1305
|
+
try {
|
|
1306
|
+
blobToVec(row.base, row.dims);
|
|
1307
|
+
} catch {
|
|
1308
|
+
deleteBase.run(row.capability);
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
if (row.adj !== null) {
|
|
1312
|
+
try {
|
|
1313
|
+
blobToVec(row.adj, row.dims);
|
|
1314
|
+
} catch {
|
|
1315
|
+
clearAdj.run(row.capability);
|
|
1316
|
+
row.adj = null;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
valid.push(row);
|
|
1320
|
+
}
|
|
1321
|
+
return valid;
|
|
1322
|
+
});
|
|
1323
|
+
return repair.immediate();
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Nightly OATS (§6.2). Positives: need vectors where the capability succeeded.
|
|
1327
|
+
* Negatives: need vectors where it was called and failed attributably — a
|
|
1328
|
+
* conservative superset of the paper's "ranked #1 but failed" (we know these
|
|
1329
|
+
* needs actually reached the tool). Window 90 days, cap 500 per side.
|
|
1330
|
+
*/
|
|
1331
|
+
runOats(now = Date.now()) {
|
|
1332
|
+
const since = now - 90 * 24 * 3600 * 1e3;
|
|
1333
|
+
const caps = this.validVecRows();
|
|
1334
|
+
const needVecStmt = this.db.prepare("SELECT dims, vec FROM need_vec WHERE need_hash = ?");
|
|
1335
|
+
const deleteNeedVecStmt = this.db.prepare("DELETE FROM need_vec WHERE need_hash = ? AND dims = ? AND vec = ?");
|
|
1336
|
+
const positivesStmt = this.db.prepare(`SELECT need_hash, class FROM outcome
|
|
1337
|
+
WHERE capability = ? AND ts >= ? AND need_hash IS NOT NULL
|
|
1338
|
+
AND explored = 0 AND soft_fail = 0 AND class = 'success'
|
|
1339
|
+
ORDER BY ts DESC LIMIT 500`);
|
|
1340
|
+
const negativesStmt = this.db.prepare(`SELECT need_hash, class FROM outcome
|
|
1341
|
+
WHERE capability = ? AND ts >= ? AND need_hash IS NOT NULL
|
|
1342
|
+
AND explored = 0 AND soft_fail = 0 AND class != 'success'
|
|
1343
|
+
ORDER BY ts DESC LIMIT 500`);
|
|
1344
|
+
let adjusted = 0;
|
|
1345
|
+
let skipped = 0;
|
|
1346
|
+
const writeAdj = this.db.prepare("UPDATE vec SET adj = ?, updated_at = ? WHERE capability = ? AND dims = ? AND base = ? AND adj IS ? AND updated_at = ?");
|
|
1347
|
+
for (const cap of caps) {
|
|
1348
|
+
const rows = [
|
|
1349
|
+
...positivesStmt.all(cap.capability, since),
|
|
1350
|
+
...negativesStmt.all(cap.capability, since)
|
|
1351
|
+
];
|
|
1352
|
+
const positives = [];
|
|
1353
|
+
const negatives = [];
|
|
1354
|
+
for (const row of rows) {
|
|
1355
|
+
const nv = needVecStmt.get(row.need_hash);
|
|
1356
|
+
if (!nv)
|
|
1357
|
+
continue;
|
|
1358
|
+
let vec;
|
|
1359
|
+
try {
|
|
1360
|
+
vec = blobToVec(nv.vec, nv.dims);
|
|
1361
|
+
} catch {
|
|
1362
|
+
deleteNeedVecStmt.run(row.need_hash, nv.dims, nv.vec);
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (nv.dims !== cap.dims)
|
|
1366
|
+
continue;
|
|
1367
|
+
if (row.class === "success")
|
|
1368
|
+
positives.push(vec);
|
|
1369
|
+
else if (isAttributable(row.class))
|
|
1370
|
+
negatives.push(vec);
|
|
1371
|
+
}
|
|
1372
|
+
const base = blobToVec(cap.base, cap.dims);
|
|
1373
|
+
const result = oatsAdjust(base, positives, negatives);
|
|
1374
|
+
const written = writeAdj.run(result.applied ? vecToBlob(result.vec) : null, now, cap.capability, cap.dims, cap.base, cap.adj, cap.updated_at);
|
|
1375
|
+
if (result.applied && written.changes === 1)
|
|
1376
|
+
adjusted += 1;
|
|
1377
|
+
else
|
|
1378
|
+
skipped += 1;
|
|
1379
|
+
}
|
|
1380
|
+
return { adjusted, skipped };
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
function buildParams(entry, hash, now) {
|
|
1384
|
+
return {
|
|
1385
|
+
id: entry.id,
|
|
1386
|
+
kind: entry.kind,
|
|
1387
|
+
source: entry.source,
|
|
1388
|
+
name: entry.name,
|
|
1389
|
+
description: entry.description,
|
|
1390
|
+
title: entry.title ?? null,
|
|
1391
|
+
annotations: entry.annotations ? JSON.stringify(entry.annotations) : null,
|
|
1392
|
+
execution: entry.execution ? JSON.stringify(entry.execution) : null,
|
|
1393
|
+
input_schema: entry.inputSchema ? JSON.stringify(entry.inputSchema) : null,
|
|
1394
|
+
output_schema: entry.outputSchema ? JSON.stringify(entry.outputSchema) : null,
|
|
1395
|
+
body: entry.body ?? null,
|
|
1396
|
+
path: entry.path ?? null,
|
|
1397
|
+
def_hash: hash,
|
|
1398
|
+
now
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
function parseJsonColumn(value) {
|
|
1402
|
+
if (value === null)
|
|
1403
|
+
return void 0;
|
|
1404
|
+
try {
|
|
1405
|
+
const parsed = JSON.parse(value);
|
|
1406
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
1407
|
+
} catch {
|
|
1408
|
+
return void 0;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function rowToEntry(row) {
|
|
1412
|
+
return {
|
|
1413
|
+
id: row.id,
|
|
1414
|
+
kind: row.kind,
|
|
1415
|
+
source: row.source,
|
|
1416
|
+
name: row.name,
|
|
1417
|
+
description: row.description,
|
|
1418
|
+
title: row.title ?? void 0,
|
|
1419
|
+
annotations: parseJsonColumn(row.annotations),
|
|
1420
|
+
execution: parseJsonColumn(row.execution),
|
|
1421
|
+
inputSchema: parseJsonColumn(row.input_schema),
|
|
1422
|
+
outputSchema: parseJsonColumn(row.output_schema),
|
|
1423
|
+
body: row.body ?? void 0,
|
|
1424
|
+
path: row.path ?? void 0
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ../coach/dist/embeddings.js
|
|
1429
|
+
import { createRequire } from "node:module";
|
|
1430
|
+
import os2 from "node:os";
|
|
1431
|
+
import path3 from "node:path";
|
|
1432
|
+
import { pathToFileURL } from "node:url";
|
|
1433
|
+
var denseRuntimeDir = null;
|
|
1434
|
+
function setDenseRuntimeDir(dir) {
|
|
1435
|
+
denseRuntimeDir = dir;
|
|
1436
|
+
}
|
|
1437
|
+
async function loadTransformers() {
|
|
1438
|
+
try {
|
|
1439
|
+
return await import("@huggingface/transformers");
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
if (denseRuntimeDir === null)
|
|
1442
|
+
throw error;
|
|
1443
|
+
const require_ = createRequire(path3.join(denseRuntimeDir, "resolve-from.js"));
|
|
1444
|
+
const entry = require_.resolve("@huggingface/transformers");
|
|
1445
|
+
return await import(pathToFileURL(entry).href);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
var GEMMA_MODEL = "onnx-community/embeddinggemma-300m-ONNX";
|
|
1449
|
+
var MINILM_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
1450
|
+
var MATRYOSHKA_DIMS = 256;
|
|
1451
|
+
var MINILM_NATIVE_DIMS = 384;
|
|
1452
|
+
var EIGHT_GIB = 8 * 1024 * 1024 * 1024;
|
|
1453
|
+
var IDLE_UNLOAD_MS = 10 * 60 * 1e3;
|
|
1454
|
+
function selectModelId(totalMemBytes = os2.totalmem()) {
|
|
1455
|
+
return totalMemBytes >= EIGHT_GIB ? GEMMA_MODEL : MINILM_MODEL;
|
|
1456
|
+
}
|
|
1457
|
+
function truncateAndNormalize(vec, dims = MATRYOSHKA_DIMS) {
|
|
1458
|
+
const sliced = vec.length > dims ? vec.slice(0, dims) : vec;
|
|
1459
|
+
let norm = 0;
|
|
1460
|
+
for (const x of sliced)
|
|
1461
|
+
norm += x * x;
|
|
1462
|
+
norm = Math.sqrt(norm);
|
|
1463
|
+
if (norm === 0)
|
|
1464
|
+
return new Float32Array(sliced.length);
|
|
1465
|
+
const out = new Float32Array(sliced.length);
|
|
1466
|
+
for (let i = 0; i < sliced.length; i++)
|
|
1467
|
+
out[i] = sliced[i] / norm;
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
function gemmaPrefix(kind, text) {
|
|
1471
|
+
return kind === "query" ? `task: search result | query: ${text}` : `title: none | text: ${text}`;
|
|
1472
|
+
}
|
|
1473
|
+
var TransformersEmbeddings = class {
|
|
1474
|
+
modelId;
|
|
1475
|
+
/** Matryoshka truncation applies ONLY to models trained for it (Gemma). */
|
|
1476
|
+
dims;
|
|
1477
|
+
pipe = null;
|
|
1478
|
+
queue = Promise.resolve();
|
|
1479
|
+
idleTimer = null;
|
|
1480
|
+
disposed = false;
|
|
1481
|
+
constructor(modelId = selectModelId()) {
|
|
1482
|
+
this.modelId = modelId;
|
|
1483
|
+
this.dims = modelId === GEMMA_MODEL ? MATRYOSHKA_DIMS : MINILM_NATIVE_DIMS;
|
|
1484
|
+
}
|
|
1485
|
+
static async isAvailable() {
|
|
1486
|
+
try {
|
|
1487
|
+
await loadTransformers();
|
|
1488
|
+
return true;
|
|
1489
|
+
} catch {
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
async embed(texts, kind = "document") {
|
|
1494
|
+
if (this.disposed)
|
|
1495
|
+
throw new Error("embeddings provider disposed");
|
|
1496
|
+
const isGemma = this.modelId === GEMMA_MODEL;
|
|
1497
|
+
const prepared = isGemma ? texts.map((t) => gemmaPrefix(kind, t)) : [...texts];
|
|
1498
|
+
const run = this.queue.then(async () => {
|
|
1499
|
+
const pipe = await this.loadPipeline();
|
|
1500
|
+
const output = await pipe(prepared, { pooling: "mean", normalize: true });
|
|
1501
|
+
this.touchIdleTimer();
|
|
1502
|
+
return output.tolist().map((row) => isGemma ? truncateAndNormalize(new Float32Array(row)) : new Float32Array(row));
|
|
1503
|
+
});
|
|
1504
|
+
this.queue = run.catch(() => void 0);
|
|
1505
|
+
return run;
|
|
1506
|
+
}
|
|
1507
|
+
async dispose() {
|
|
1508
|
+
this.disposed = true;
|
|
1509
|
+
if (this.idleTimer)
|
|
1510
|
+
clearTimeout(this.idleTimer);
|
|
1511
|
+
this.idleTimer = null;
|
|
1512
|
+
await this.unloadThroughQueue();
|
|
1513
|
+
}
|
|
1514
|
+
async loadPipeline() {
|
|
1515
|
+
if (this.pipe)
|
|
1516
|
+
return this.pipe;
|
|
1517
|
+
if (this.disposed)
|
|
1518
|
+
throw new Error("embeddings provider disposed");
|
|
1519
|
+
const { pipeline } = await loadTransformers();
|
|
1520
|
+
this.pipe = await pipeline("feature-extraction", this.modelId, {
|
|
1521
|
+
dtype: "q8"
|
|
1522
|
+
});
|
|
1523
|
+
this.touchIdleTimer();
|
|
1524
|
+
return this.pipe;
|
|
1525
|
+
}
|
|
1526
|
+
touchIdleTimer() {
|
|
1527
|
+
if (this.idleTimer)
|
|
1528
|
+
clearTimeout(this.idleTimer);
|
|
1529
|
+
this.idleTimer = setTimeout(() => {
|
|
1530
|
+
void this.unloadThroughQueue();
|
|
1531
|
+
}, IDLE_UNLOAD_MS);
|
|
1532
|
+
this.idleTimer.unref?.();
|
|
1533
|
+
}
|
|
1534
|
+
/** Unload serialized behind in-flight embeds so a session is never freed mid-call. */
|
|
1535
|
+
async unloadThroughQueue() {
|
|
1536
|
+
const run = this.queue.then(async () => {
|
|
1537
|
+
const pipe = this.pipe;
|
|
1538
|
+
this.pipe = null;
|
|
1539
|
+
if (pipe?.dispose) {
|
|
1540
|
+
await pipe.dispose().catch(() => void 0);
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
this.queue = run.catch(() => void 0);
|
|
1544
|
+
await run;
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
// src/entry.ts
|
|
1549
|
+
import fs4 from "node:fs";
|
|
1550
|
+
import path4 from "node:path";
|
|
1551
|
+
import { fileURLToPath } from "node:url";
|
|
1552
|
+
function firstPathEntryMatches(binPath) {
|
|
1553
|
+
const names = process.platform === "win32" ? ["roster.cmd", "roster.exe", "roster"] : ["roster"];
|
|
1554
|
+
let expected;
|
|
1555
|
+
try {
|
|
1556
|
+
expected = fs4.realpathSync(binPath);
|
|
1557
|
+
} catch {
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
|
|
1561
|
+
if (dir === "") continue;
|
|
1562
|
+
for (const n of names) {
|
|
1563
|
+
const p = path4.join(dir, n);
|
|
1564
|
+
try {
|
|
1565
|
+
if (!fs4.statSync(p).isFile()) continue;
|
|
1566
|
+
fs4.accessSync(p, fs4.constants.X_OK);
|
|
1567
|
+
return fs4.realpathSync(p) === expected;
|
|
1568
|
+
} catch {
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
function ourBinPath() {
|
|
1575
|
+
return path4.join(path4.dirname(fileURLToPath(import.meta.url)), "bin.js");
|
|
1576
|
+
}
|
|
1577
|
+
function verifiedRosterAliases() {
|
|
1578
|
+
const dir = path4.dirname(fileURLToPath(import.meta.url));
|
|
1579
|
+
const binaries = [ourBinPath(), path4.resolve(dir, "../bundle/bin.js"), path4.resolve(dir, "../dist/bin.js")];
|
|
1580
|
+
return binaries.some(firstPathEntryMatches) ? [{ command: "roster", args: ["serve"] }] : [];
|
|
1581
|
+
}
|
|
1582
|
+
var PACKAGE_NAME = "@npmmo/roster";
|
|
1583
|
+
function runningFromNpxCache(binPath = ourBinPath()) {
|
|
1584
|
+
return binPath.split(path4.sep).includes("_npx");
|
|
1585
|
+
}
|
|
1586
|
+
function rosterEntry(binPath = ourBinPath()) {
|
|
1587
|
+
if (runningFromNpxCache(binPath)) {
|
|
1588
|
+
const args = ["-y", PACKAGE_NAME, "serve"];
|
|
1589
|
+
return process.platform === "win32" ? { command: path4.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe"), args: ["/d", "/s", "/c", "npx", ...args] } : { command: "npx", args };
|
|
1590
|
+
}
|
|
1591
|
+
return { command: process.execPath, args: [path4.resolve(binPath), "serve"] };
|
|
1592
|
+
}
|
|
1593
|
+
var INERT_CLIENT_KEYS = {
|
|
1594
|
+
type: (value) => value === "stdio"
|
|
1595
|
+
// a non-stdio transport is a CONFLICTING entry, not ours
|
|
1596
|
+
};
|
|
1597
|
+
var normalizeSpawnEntry = (v) => {
|
|
1598
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return null;
|
|
1599
|
+
const e = v;
|
|
1600
|
+
if (typeof e.command !== "string") return null;
|
|
1601
|
+
if (e.args !== void 0 && (!Array.isArray(e.args) || e.args.some((arg) => typeof arg !== "string"))) {
|
|
1602
|
+
return null;
|
|
1603
|
+
}
|
|
1604
|
+
for (const key of Object.keys(e)) {
|
|
1605
|
+
if (key === "command" || key === "args") continue;
|
|
1606
|
+
const inert = INERT_CLIENT_KEYS[key];
|
|
1607
|
+
if (!inert?.(e[key])) return null;
|
|
1608
|
+
}
|
|
1609
|
+
return { command: e.command, args: e.args === void 0 ? [] : [...e.args] };
|
|
1610
|
+
};
|
|
1611
|
+
function sameEntry(candidate, injected) {
|
|
1612
|
+
const e = normalizeSpawnEntry(candidate);
|
|
1613
|
+
if (!e || !injected) return false;
|
|
1614
|
+
return e.command === injected.command && e.args.length === injected.args.length && e.args.every((a, i) => a === injected.args[i]);
|
|
1615
|
+
}
|
|
1616
|
+
function isOwnedRosterEntry(candidate, ownedEntries) {
|
|
1617
|
+
return ownedEntries.some((owned) => sameEntry(candidate, owned));
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// src/ejectJournal.ts
|
|
1621
|
+
import crypto3 from "node:crypto";
|
|
1622
|
+
import fs8 from "node:fs";
|
|
1623
|
+
import path8 from "node:path";
|
|
1624
|
+
|
|
1625
|
+
// src/rosterfile.ts
|
|
1626
|
+
import crypto2 from "node:crypto";
|
|
1627
|
+
import fs6 from "node:fs";
|
|
1628
|
+
import path6 from "node:path";
|
|
1629
|
+
|
|
1630
|
+
// src/lock.ts
|
|
1631
|
+
import crypto from "node:crypto";
|
|
1632
|
+
import fs5 from "node:fs";
|
|
1633
|
+
import path5 from "node:path";
|
|
1634
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
1635
|
+
var LOCK_POLL_MS = 20;
|
|
1636
|
+
var CLAIM_BASENAME = ".reclaim";
|
|
1637
|
+
var CLAIM_GRACE_MS = 1e3;
|
|
1638
|
+
var OWNER_REMOVE_RETRIES = 10;
|
|
1639
|
+
var TRANSIENT_REMOVE_CODES = /* @__PURE__ */ new Set(["EBUSY", "EACCES", "EPERM", "EMFILE", "ENFILE"]);
|
|
1640
|
+
var RM_OPTS = { recursive: true, force: true, maxRetries: 10, retryDelay: 50 };
|
|
1641
|
+
function sleepSync(ms) {
|
|
1642
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1643
|
+
}
|
|
1644
|
+
function removeOwnerFile(file, allowMissing) {
|
|
1645
|
+
for (let attempt = 0; ; attempt++) {
|
|
1646
|
+
try {
|
|
1647
|
+
fs5.unlinkSync(file);
|
|
1648
|
+
return true;
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
const code = error.code;
|
|
1651
|
+
if (code === "ENOENT" && allowMissing) return false;
|
|
1652
|
+
if (!TRANSIENT_REMOVE_CODES.has(code ?? "") || attempt >= OWNER_REMOVE_RETRIES) {
|
|
1653
|
+
throw error;
|
|
1654
|
+
}
|
|
1655
|
+
sleepSync(50);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
function lockPath(key) {
|
|
1660
|
+
const digest = crypto.createHash("sha256").update(key).digest("hex");
|
|
1661
|
+
return path5.join(rosterHome(), "locks", `${digest}.lock`);
|
|
1662
|
+
}
|
|
1663
|
+
function ownerPath(dir) {
|
|
1664
|
+
return path5.join(dir, "owner.json");
|
|
1665
|
+
}
|
|
1666
|
+
function claimPath(dir) {
|
|
1667
|
+
return path5.join(dir, CLAIM_BASENAME);
|
|
1668
|
+
}
|
|
1669
|
+
function sameOwner(a, b) {
|
|
1670
|
+
return a !== null && a.pid === b.pid && a.token === b.token;
|
|
1671
|
+
}
|
|
1672
|
+
function mkdirWasContended(error, dir) {
|
|
1673
|
+
const code = error.code;
|
|
1674
|
+
if (code === "EEXIST") return true;
|
|
1675
|
+
if (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") return false;
|
|
1676
|
+
try {
|
|
1677
|
+
fs5.lstatSync(dir);
|
|
1678
|
+
return true;
|
|
1679
|
+
} catch {
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
function readOwnerState(dir) {
|
|
1684
|
+
let raw;
|
|
1685
|
+
try {
|
|
1686
|
+
raw = fs5.readFileSync(ownerPath(dir), "utf8");
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
return error.code === "ENOENT" ? { kind: "absent" } : { kind: "invalid" };
|
|
1689
|
+
}
|
|
1690
|
+
try {
|
|
1691
|
+
const parsed = JSON.parse(raw);
|
|
1692
|
+
if (typeof parsed.pid !== "number" || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.token !== "string" || parsed.token === "") {
|
|
1693
|
+
return { kind: "invalid" };
|
|
1694
|
+
}
|
|
1695
|
+
return { kind: "owned", owner: { pid: parsed.pid, token: parsed.token } };
|
|
1696
|
+
} catch {
|
|
1697
|
+
return { kind: "invalid" };
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
function readOwner(dir) {
|
|
1701
|
+
const state = readOwnerState(dir);
|
|
1702
|
+
return state.kind === "owned" ? state.owner : null;
|
|
1703
|
+
}
|
|
1704
|
+
function processIsAlive(pid) {
|
|
1705
|
+
try {
|
|
1706
|
+
process.kill(pid, 0);
|
|
1707
|
+
return true;
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
return error.code !== "ESRCH";
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function ensureLockSlot(dir) {
|
|
1713
|
+
try {
|
|
1714
|
+
fs5.mkdirSync(dir, { mode: PRIVATE_DIR });
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
if (!mkdirWasContended(error, dir)) throw error;
|
|
1717
|
+
}
|
|
1718
|
+
try {
|
|
1719
|
+
const stat = fs5.lstatSync(dir);
|
|
1720
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
|
|
1721
|
+
try {
|
|
1722
|
+
fs5.chmodSync(dir, PRIVATE_DIR);
|
|
1723
|
+
} catch {
|
|
1724
|
+
}
|
|
1725
|
+
return true;
|
|
1726
|
+
} catch {
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
function claimOwned(dir, claim) {
|
|
1731
|
+
return sameOwner(readOwner(claimPath(dir)), claim);
|
|
1732
|
+
}
|
|
1733
|
+
function retireClaim(dir, observed, observedCtime) {
|
|
1734
|
+
const claimDir = claimPath(dir);
|
|
1735
|
+
let currentStat;
|
|
1736
|
+
try {
|
|
1737
|
+
currentStat = fs5.lstatSync(claimDir);
|
|
1738
|
+
if (currentStat.isSymbolicLink() || !currentStat.isDirectory()) return false;
|
|
1739
|
+
} catch {
|
|
1740
|
+
return true;
|
|
1741
|
+
}
|
|
1742
|
+
const current = readOwnerState(claimDir);
|
|
1743
|
+
if (observed.kind === "owned") {
|
|
1744
|
+
if (current.kind !== "owned" || !sameOwner(current.owner, observed.owner)) return false;
|
|
1745
|
+
if (processIsAlive(current.owner.pid)) return false;
|
|
1746
|
+
} else {
|
|
1747
|
+
if (current.kind !== observed.kind || currentStat.ctimeMs !== observedCtime) return false;
|
|
1748
|
+
if (Math.max(0, Date.now() - currentStat.ctimeMs) < CLAIM_GRACE_MS) return false;
|
|
1749
|
+
}
|
|
1750
|
+
const retired = path5.join(
|
|
1751
|
+
dir,
|
|
1752
|
+
`${CLAIM_BASENAME}.stale-${process.pid}-${crypto.randomBytes(8).toString("hex")}`
|
|
1753
|
+
);
|
|
1754
|
+
try {
|
|
1755
|
+
fs5.renameSync(claimDir, retired);
|
|
1756
|
+
} catch {
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1759
|
+
const moved = readOwnerState(retired);
|
|
1760
|
+
const safeToRemove = observed.kind === "owned" ? moved.kind === "owned" && sameOwner(moved.owner, observed.owner) && !processIsAlive(moved.owner.pid) : moved.kind === observed.kind;
|
|
1761
|
+
if (!safeToRemove) {
|
|
1762
|
+
try {
|
|
1763
|
+
fs5.renameSync(retired, claimDir);
|
|
1764
|
+
} catch {
|
|
1765
|
+
}
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
fs5.rmSync(retired, RM_OPTS);
|
|
1769
|
+
return true;
|
|
1770
|
+
}
|
|
1771
|
+
function claimBlocks(dir) {
|
|
1772
|
+
const claimDir = claimPath(dir);
|
|
1773
|
+
let stat;
|
|
1774
|
+
try {
|
|
1775
|
+
stat = fs5.lstatSync(claimDir);
|
|
1776
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return true;
|
|
1777
|
+
} catch {
|
|
1778
|
+
return false;
|
|
1779
|
+
}
|
|
1780
|
+
const state = readOwnerState(claimDir);
|
|
1781
|
+
if (state.kind === "owned" && processIsAlive(state.owner.pid)) return true;
|
|
1782
|
+
retireClaim(dir, state, stat.ctimeMs);
|
|
1783
|
+
try {
|
|
1784
|
+
fs5.lstatSync(claimDir);
|
|
1785
|
+
return true;
|
|
1786
|
+
} catch {
|
|
1787
|
+
return false;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
function tryAcquireClaim(dir) {
|
|
1791
|
+
const claimDir = claimPath(dir);
|
|
1792
|
+
try {
|
|
1793
|
+
fs5.mkdirSync(claimDir, { mode: PRIVATE_DIR });
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
if (mkdirWasContended(error, claimDir)) {
|
|
1796
|
+
claimBlocks(dir);
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
if (dirVanished(error)) return null;
|
|
1800
|
+
throw error;
|
|
1801
|
+
}
|
|
1802
|
+
const claim = { pid: process.pid, token: crypto.randomBytes(16).toString("hex") };
|
|
1803
|
+
try {
|
|
1804
|
+
fs5.writeFileSync(ownerPath(claimDir), `${JSON.stringify(claim)}
|
|
1805
|
+
`, {
|
|
1806
|
+
mode: PRIVATE_FILE,
|
|
1807
|
+
flag: "wx"
|
|
1808
|
+
});
|
|
1809
|
+
} catch (error) {
|
|
1810
|
+
const contended = dirVanished(error) || mkdirWasContended(error, claimDir);
|
|
1811
|
+
try {
|
|
1812
|
+
fs5.rmSync(claimDir, RM_OPTS);
|
|
1813
|
+
} catch {
|
|
1814
|
+
}
|
|
1815
|
+
if (contended) return null;
|
|
1816
|
+
throw error;
|
|
1817
|
+
}
|
|
1818
|
+
return claimOwned(dir, claim) ? claim : null;
|
|
1819
|
+
}
|
|
1820
|
+
function releaseClaim(dir, claim) {
|
|
1821
|
+
if (!claimOwned(dir, claim)) return;
|
|
1822
|
+
try {
|
|
1823
|
+
fs5.rmSync(claimPath(dir), RM_OPTS);
|
|
1824
|
+
} catch {
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
function reclaimStaleLock(dir, observed) {
|
|
1828
|
+
if (!ensureLockSlot(dir) || claimBlocks(dir)) return "occupied";
|
|
1829
|
+
const stateBeforeClaim = readOwnerState(dir);
|
|
1830
|
+
if (stateBeforeClaim.kind === "absent") return "reclaimed";
|
|
1831
|
+
if (stateBeforeClaim.kind !== "owned" || !sameOwner(stateBeforeClaim.owner, observed)) {
|
|
1832
|
+
return "occupied";
|
|
1833
|
+
}
|
|
1834
|
+
const claim = tryAcquireClaim(dir);
|
|
1835
|
+
if (!claim) return "occupied";
|
|
1836
|
+
try {
|
|
1837
|
+
if (!claimOwned(dir, claim)) return "occupied";
|
|
1838
|
+
const current = readOwner(dir);
|
|
1839
|
+
if (!sameOwner(current, observed) || processIsAlive(observed.pid)) return "occupied";
|
|
1840
|
+
if (!claimOwned(dir, claim) || !sameOwner(readOwner(dir), observed)) return "occupied";
|
|
1841
|
+
try {
|
|
1842
|
+
removeOwnerFile(ownerPath(dir), true);
|
|
1843
|
+
} catch {
|
|
1844
|
+
return "occupied";
|
|
1845
|
+
}
|
|
1846
|
+
return "reclaimed";
|
|
1847
|
+
} finally {
|
|
1848
|
+
releaseClaim(dir, claim);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
function dirVanished(error) {
|
|
1852
|
+
const code = error.code;
|
|
1853
|
+
return code === "ENOENT" || code === "EINVAL" || code === "ENOTDIR";
|
|
1854
|
+
}
|
|
1855
|
+
function createOwner(dir) {
|
|
1856
|
+
const owner = { pid: process.pid, token: crypto.randomBytes(16).toString("hex") };
|
|
1857
|
+
try {
|
|
1858
|
+
fs5.writeFileSync(ownerPath(dir), `${JSON.stringify(owner)}
|
|
1859
|
+
`, {
|
|
1860
|
+
mode: PRIVATE_FILE,
|
|
1861
|
+
flag: "wx"
|
|
1862
|
+
});
|
|
1863
|
+
return owner;
|
|
1864
|
+
} catch (error) {
|
|
1865
|
+
if (error.code === "EEXIST" || dirVanished(error) || mkdirWasContended(error, dir)) {
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
throw error;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
function acquire(key) {
|
|
1872
|
+
ensureRosterHome();
|
|
1873
|
+
ensurePrivateDir(path5.join(rosterHome(), "locks"));
|
|
1874
|
+
const dir = lockPath(key);
|
|
1875
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
1876
|
+
while (true) {
|
|
1877
|
+
if (ensureLockSlot(dir) && !claimBlocks(dir)) {
|
|
1878
|
+
const owner = createOwner(dir);
|
|
1879
|
+
if (owner) return { dir, token: owner.token };
|
|
1880
|
+
const state = readOwnerState(dir);
|
|
1881
|
+
if (state.kind === "owned" && !processIsAlive(state.owner.pid)) {
|
|
1882
|
+
if (reclaimStaleLock(dir, state.owner) === "reclaimed") continue;
|
|
1883
|
+
} else if (state.kind === "absent") {
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
if (Date.now() >= deadline) {
|
|
1888
|
+
const state = readOwnerState(dir);
|
|
1889
|
+
throw new Error(
|
|
1890
|
+
`timed out waiting for Roster lock "${key}"${state.kind === "owned" ? ` held by pid ${state.owner.pid}` : " with unreadable ownership"}`
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
sleepSync(LOCK_POLL_MS);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
function releaseMovedLock(dir, token) {
|
|
1897
|
+
const root = path5.dirname(dir);
|
|
1898
|
+
const prefix = `${path5.basename(dir)}.`;
|
|
1899
|
+
let entries;
|
|
1900
|
+
try {
|
|
1901
|
+
entries = fs5.readdirSync(root);
|
|
1902
|
+
} catch {
|
|
1903
|
+
return false;
|
|
1904
|
+
}
|
|
1905
|
+
for (const entry of entries) {
|
|
1906
|
+
if (!entry.startsWith(prefix)) continue;
|
|
1907
|
+
const candidate = path5.join(root, entry);
|
|
1908
|
+
const owner = readOwner(candidate);
|
|
1909
|
+
if (owner?.pid === process.pid && owner.token === token) {
|
|
1910
|
+
fs5.rmSync(candidate, RM_OPTS);
|
|
1911
|
+
return true;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return false;
|
|
1915
|
+
}
|
|
1916
|
+
function release(dir, token) {
|
|
1917
|
+
const deadline = Date.now() + 250;
|
|
1918
|
+
for (; ; ) {
|
|
1919
|
+
const owner = readOwner(dir);
|
|
1920
|
+
if (owner && owner.pid === process.pid && owner.token === token) {
|
|
1921
|
+
try {
|
|
1922
|
+
removeOwnerFile(ownerPath(dir), false);
|
|
1923
|
+
return;
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
if (!dirVanished(error)) throw error;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
if (releaseMovedLock(dir, token)) return;
|
|
1929
|
+
if (Date.now() >= deadline) {
|
|
1930
|
+
throw new Error("Roster lock ownership changed before release");
|
|
1931
|
+
}
|
|
1932
|
+
sleepSync(LOCK_POLL_MS);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
function withFileLockSync(key, fn) {
|
|
1936
|
+
const held = acquire(key);
|
|
1937
|
+
let result;
|
|
1938
|
+
let failure;
|
|
1939
|
+
let didThrow = false;
|
|
1940
|
+
try {
|
|
1941
|
+
result = fn();
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
didThrow = true;
|
|
1944
|
+
failure = error;
|
|
1945
|
+
}
|
|
1946
|
+
try {
|
|
1947
|
+
release(held.dir, held.token);
|
|
1948
|
+
} catch (releaseError) {
|
|
1949
|
+
if (!didThrow) throw releaseError;
|
|
1950
|
+
}
|
|
1951
|
+
if (didThrow) throw failure;
|
|
1952
|
+
return result;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// src/rosterfile.ts
|
|
1956
|
+
function resolveWriteTopology(sourcePath) {
|
|
1957
|
+
const source = path6.resolve(sourcePath);
|
|
1958
|
+
const stat = fs6.lstatSync(source);
|
|
1959
|
+
const writePath = fs6.realpathSync(source);
|
|
1960
|
+
if (stat.isSymbolicLink()) {
|
|
1961
|
+
const symlinkTarget = fs6.readlinkSync(source);
|
|
1962
|
+
if (!fs6.lstatSync(writePath).isFile()) {
|
|
1963
|
+
throw new Error(`client config symlink target is not a regular file: ${sourcePath}`);
|
|
1964
|
+
}
|
|
1965
|
+
return { sourcePath: source, writePath, symlinkTarget };
|
|
1966
|
+
}
|
|
1967
|
+
if (!stat.isFile()) {
|
|
1968
|
+
throw new Error(`client config is not a regular file: ${sourcePath}`);
|
|
1969
|
+
}
|
|
1970
|
+
return { sourcePath: source, writePath };
|
|
1971
|
+
}
|
|
1972
|
+
function projectedRealPath(target) {
|
|
1973
|
+
const suffix = [];
|
|
1974
|
+
let cursor = target;
|
|
1975
|
+
for (; ; ) {
|
|
1976
|
+
try {
|
|
1977
|
+
return path6.join(fs6.realpathSync(cursor), ...suffix.reverse());
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
if (error.code !== "ENOENT") throw error;
|
|
1980
|
+
const parent = path6.dirname(cursor);
|
|
1981
|
+
if (parent === cursor) throw error;
|
|
1982
|
+
suffix.push(path6.basename(cursor));
|
|
1983
|
+
cursor = parent;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
function validateWriteTopology(sourcePath, recordedWritePath, recordedSymlinkTarget) {
|
|
1988
|
+
const source = path6.resolve(sourcePath);
|
|
1989
|
+
if (recordedWritePath === void 0 && recordedSymlinkTarget === void 0) return source;
|
|
1990
|
+
if (recordedWritePath === void 0) {
|
|
1991
|
+
throw new Error("recorded symlink topology is missing its write path");
|
|
1992
|
+
}
|
|
1993
|
+
const writePath = path6.resolve(recordedWritePath);
|
|
1994
|
+
if (recordedSymlinkTarget !== void 0) {
|
|
1995
|
+
let sourceStat;
|
|
1996
|
+
try {
|
|
1997
|
+
sourceStat = fs6.lstatSync(source);
|
|
1998
|
+
} catch {
|
|
1999
|
+
throw new Error("recorded config symlink is missing");
|
|
2000
|
+
}
|
|
2001
|
+
if (!sourceStat.isSymbolicLink()) {
|
|
2002
|
+
throw new Error("recorded config symlink was replaced");
|
|
2003
|
+
}
|
|
2004
|
+
if (fs6.readlinkSync(source) !== recordedSymlinkTarget) {
|
|
2005
|
+
throw new Error("recorded config symlink target changed");
|
|
2006
|
+
}
|
|
2007
|
+
if (fs6.realpathSync(source) !== writePath) {
|
|
2008
|
+
throw new Error("recorded config symlink now resolves to a different target");
|
|
2009
|
+
}
|
|
2010
|
+
if (!fs6.lstatSync(writePath).isFile()) {
|
|
2011
|
+
throw new Error("recorded config write target is not a regular file");
|
|
2012
|
+
}
|
|
2013
|
+
return writePath;
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
const stat = fs6.lstatSync(source);
|
|
2017
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
2018
|
+
throw new Error("recorded regular config changed filesystem type");
|
|
2019
|
+
}
|
|
2020
|
+
if (fs6.realpathSync(source) !== writePath) {
|
|
2021
|
+
throw new Error("recorded config parent symlink now resolves to a different target");
|
|
2022
|
+
}
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
if (error.code !== "ENOENT") throw error;
|
|
2025
|
+
if (projectedRealPath(source) !== writePath) {
|
|
2026
|
+
throw new Error("recorded config parent symlink now resolves to a different target");
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return writePath;
|
|
2030
|
+
}
|
|
2031
|
+
function existingMode(target) {
|
|
2032
|
+
try {
|
|
2033
|
+
return fs6.statSync(target).mode & 511;
|
|
2034
|
+
} catch {
|
|
2035
|
+
return void 0;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
function atomicWriteFileSync(target, data, mode, beforeReplace) {
|
|
2039
|
+
const tmp = `${target}.${process.pid}.${crypto2.randomBytes(6).toString("hex")}.tmp`;
|
|
2040
|
+
const finalMode = mode ?? existingMode(target) ?? PRIVATE_FILE;
|
|
2041
|
+
let fd;
|
|
2042
|
+
try {
|
|
2043
|
+
fd = fs6.openSync(tmp, "wx", PRIVATE_FILE);
|
|
2044
|
+
fs6.writeFileSync(fd, data);
|
|
2045
|
+
fs6.fchmodSync(fd, finalMode);
|
|
2046
|
+
fs6.fsyncSync(fd);
|
|
2047
|
+
fs6.closeSync(fd);
|
|
2048
|
+
fd = void 0;
|
|
2049
|
+
beforeReplace?.();
|
|
2050
|
+
fs6.renameSync(tmp, target);
|
|
2051
|
+
try {
|
|
2052
|
+
const parent = fs6.openSync(path6.dirname(target), "r");
|
|
2053
|
+
try {
|
|
2054
|
+
fs6.fsyncSync(parent);
|
|
2055
|
+
} finally {
|
|
2056
|
+
fs6.closeSync(parent);
|
|
2057
|
+
}
|
|
2058
|
+
} catch {
|
|
2059
|
+
}
|
|
2060
|
+
} catch (err) {
|
|
2061
|
+
if (fd !== void 0) {
|
|
2062
|
+
try {
|
|
2063
|
+
fs6.closeSync(fd);
|
|
2064
|
+
} catch {
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
try {
|
|
2068
|
+
fs6.rmSync(tmp, { force: true });
|
|
2069
|
+
} catch {
|
|
2070
|
+
}
|
|
2071
|
+
throw err;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
function defaultConfig() {
|
|
2075
|
+
return {
|
|
2076
|
+
version: 1,
|
|
2077
|
+
mode: "transparent",
|
|
2078
|
+
servers: /* @__PURE__ */ Object.create(null),
|
|
2079
|
+
skillSources: [],
|
|
2080
|
+
telemetry: { enabled: false },
|
|
2081
|
+
embeddings: "auto"
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
function loadConfig() {
|
|
2085
|
+
const p = rosterConfigPath();
|
|
2086
|
+
if (!fs6.existsSync(p)) return defaultConfig();
|
|
2087
|
+
let parsed;
|
|
2088
|
+
try {
|
|
2089
|
+
parsed = JSON.parse(fs6.readFileSync(p, "utf8"));
|
|
2090
|
+
} catch (err) {
|
|
2091
|
+
throw new Error(`~/.roster/roster.json is malformed JSON: ${err instanceof Error ? err.message : err}`);
|
|
2092
|
+
}
|
|
2093
|
+
return normalizeConfig(parsed);
|
|
2094
|
+
}
|
|
2095
|
+
function saveConfig(config) {
|
|
2096
|
+
ensureRosterHome();
|
|
2097
|
+
const normalized = normalizeConfig(config);
|
|
2098
|
+
atomicWriteFileSync(
|
|
2099
|
+
rosterConfigPath(),
|
|
2100
|
+
`${JSON.stringify(normalized, null, 2)}
|
|
2101
|
+
`,
|
|
2102
|
+
PRIVATE_FILE
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
function updateConfig(mutator) {
|
|
2106
|
+
return withFileLockSync("config", () => {
|
|
2107
|
+
const config = loadConfig();
|
|
2108
|
+
const result = mutator(config);
|
|
2109
|
+
saveConfig(config);
|
|
2110
|
+
return result;
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
function isRecord2(value) {
|
|
2114
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2115
|
+
}
|
|
2116
|
+
function stringArray(value, field, fallback = []) {
|
|
2117
|
+
if (value === void 0) return [...fallback];
|
|
2118
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
2119
|
+
throw new Error(`~/.roster/roster.json ${field} must be an array of strings`);
|
|
2120
|
+
}
|
|
2121
|
+
return [...value];
|
|
2122
|
+
}
|
|
2123
|
+
function normalizeServer(value, field) {
|
|
2124
|
+
if (!isRecord2(value)) {
|
|
2125
|
+
throw new Error(`~/.roster/roster.json ${field} must be an object`);
|
|
2126
|
+
}
|
|
2127
|
+
const unsupported = Object.keys(value).filter((key) => !["command", "args", "env", "url", "importedFrom"].includes(key));
|
|
2128
|
+
if (unsupported.length > 0) {
|
|
2129
|
+
throw new Error(`~/.roster/roster.json ${field} has unsupported settings: ${unsupported.join(", ")}`);
|
|
2130
|
+
}
|
|
2131
|
+
const command = value.command;
|
|
2132
|
+
const url = value.url;
|
|
2133
|
+
if (command !== void 0 && typeof command !== "string") {
|
|
2134
|
+
throw new Error(`~/.roster/roster.json ${field}.command must be a string`);
|
|
2135
|
+
}
|
|
2136
|
+
if (url !== void 0 && typeof url !== "string") {
|
|
2137
|
+
throw new Error(`~/.roster/roster.json ${field}.url must be a string`);
|
|
2138
|
+
}
|
|
2139
|
+
if (command === void 0 && url === void 0) {
|
|
2140
|
+
throw new Error(`~/.roster/roster.json ${field} must define command or url`);
|
|
2141
|
+
}
|
|
2142
|
+
let env;
|
|
2143
|
+
if (value.env !== void 0) {
|
|
2144
|
+
if (!isRecord2(value.env) || Object.values(value.env).some((item) => typeof item !== "string")) {
|
|
2145
|
+
throw new Error(`~/.roster/roster.json ${field}.env must be an object of strings`);
|
|
2146
|
+
}
|
|
2147
|
+
env = Object.fromEntries(Object.entries(value.env));
|
|
2148
|
+
}
|
|
2149
|
+
return {
|
|
2150
|
+
...command !== void 0 ? { command } : {},
|
|
2151
|
+
...value.args !== void 0 ? { args: stringArray(value.args, `${field}.args`) } : {},
|
|
2152
|
+
...env !== void 0 ? { env } : {},
|
|
2153
|
+
...url !== void 0 ? { url } : {},
|
|
2154
|
+
importedFrom: stringArray(value.importedFrom, `${field}.importedFrom`)
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
function normalizeConfig(value) {
|
|
2158
|
+
if (!isRecord2(value)) {
|
|
2159
|
+
throw new Error("~/.roster/roster.json must be a JSON object");
|
|
2160
|
+
}
|
|
2161
|
+
if (value.version !== void 0 && value.version !== 1) {
|
|
2162
|
+
throw new Error("~/.roster/roster.json version must be 1");
|
|
2163
|
+
}
|
|
2164
|
+
if (value.mode !== void 0 && value.mode !== "transparent" && value.mode !== "five") {
|
|
2165
|
+
throw new Error('~/.roster/roster.json mode must be "transparent" or "five"');
|
|
2166
|
+
}
|
|
2167
|
+
if (value.embeddings !== void 0 && value.embeddings !== "auto" && value.embeddings !== "off") {
|
|
2168
|
+
throw new Error('~/.roster/roster.json embeddings must be "auto" or "off"');
|
|
2169
|
+
}
|
|
2170
|
+
if (value.servers !== void 0 && !isRecord2(value.servers)) {
|
|
2171
|
+
throw new Error("~/.roster/roster.json servers must be an object");
|
|
2172
|
+
}
|
|
2173
|
+
const servers = /* @__PURE__ */ Object.create(null);
|
|
2174
|
+
for (const [name, server] of Object.entries(value.servers ?? {})) {
|
|
2175
|
+
servers[name] = normalizeServer(server, `servers.${name}`);
|
|
2176
|
+
}
|
|
2177
|
+
let telemetryEnabled = false;
|
|
2178
|
+
if (value.telemetry !== void 0) {
|
|
2179
|
+
if (!isRecord2(value.telemetry)) {
|
|
2180
|
+
throw new Error("~/.roster/roster.json telemetry must be an object");
|
|
2181
|
+
}
|
|
2182
|
+
if (value.telemetry.enabled !== void 0 && typeof value.telemetry.enabled !== "boolean") {
|
|
2183
|
+
throw new Error("~/.roster/roster.json telemetry.enabled must be a boolean");
|
|
2184
|
+
}
|
|
2185
|
+
telemetryEnabled = value.telemetry.enabled === true;
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
version: 1,
|
|
2189
|
+
mode: value.mode === "five" ? "five" : "transparent",
|
|
2190
|
+
servers,
|
|
2191
|
+
skillSources: stringArray(value.skillSources, "skillSources"),
|
|
2192
|
+
telemetry: { enabled: telemetryEnabled },
|
|
2193
|
+
embeddings: value.embeddings === "off" ? "off" : "auto"
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
function serverIdentity(server) {
|
|
2197
|
+
return sha256Hex(
|
|
2198
|
+
JSON.stringify({
|
|
2199
|
+
command: server.command ?? null,
|
|
2200
|
+
args: server.args ?? [],
|
|
2201
|
+
url: server.url ?? null,
|
|
2202
|
+
env: Object.fromEntries(Object.entries(server.env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
|
2203
|
+
})
|
|
2204
|
+
);
|
|
2205
|
+
}
|
|
2206
|
+
function mergeServers(config, imported, ownedEntries = [rosterEntry(), ...verifiedRosterAliases()]) {
|
|
2207
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
2208
|
+
for (const [name, entry] of Object.entries(config.servers)) {
|
|
2209
|
+
byIdentity.set(serverIdentity(entry), name);
|
|
2210
|
+
}
|
|
2211
|
+
const added = [];
|
|
2212
|
+
const merged = [];
|
|
2213
|
+
let changed = false;
|
|
2214
|
+
for (const server of imported) {
|
|
2215
|
+
const candidate = {
|
|
2216
|
+
command: server.command,
|
|
2217
|
+
args: server.args ?? [],
|
|
2218
|
+
...server.env !== void 0 ? { env: server.env } : {},
|
|
2219
|
+
...server.url !== void 0 ? { url: server.url } : {}
|
|
2220
|
+
};
|
|
2221
|
+
if (isOwnedRosterEntry(candidate, ownedEntries)) continue;
|
|
2222
|
+
const identity = serverIdentity(server);
|
|
2223
|
+
const existingName = byIdentity.get(identity);
|
|
2224
|
+
if (existingName) {
|
|
2225
|
+
const entry = config.servers[existingName];
|
|
2226
|
+
if (!entry.importedFrom.includes(server.client)) {
|
|
2227
|
+
entry.importedFrom.push(server.client);
|
|
2228
|
+
changed = true;
|
|
2229
|
+
}
|
|
2230
|
+
merged.push(existingName);
|
|
2231
|
+
continue;
|
|
2232
|
+
}
|
|
2233
|
+
let name = server.name;
|
|
2234
|
+
let suffix = 2;
|
|
2235
|
+
while (Object.hasOwn(config.servers, name)) name = `${server.name}-${suffix++}`;
|
|
2236
|
+
config.servers[name] = {
|
|
2237
|
+
command: server.command,
|
|
2238
|
+
args: server.args,
|
|
2239
|
+
env: server.env,
|
|
2240
|
+
url: server.url,
|
|
2241
|
+
importedFrom: [server.client]
|
|
2242
|
+
};
|
|
2243
|
+
byIdentity.set(identity, name);
|
|
2244
|
+
added.push(name);
|
|
2245
|
+
changed = true;
|
|
2246
|
+
}
|
|
2247
|
+
return { config, added, merged, changed };
|
|
2248
|
+
}
|
|
2249
|
+
function backupDirFor(clientId, timestamp) {
|
|
2250
|
+
return path6.join(rosterHome(), "backups", clientId, timestamp);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// src/safeFile.ts
|
|
2254
|
+
import fs7 from "node:fs";
|
|
2255
|
+
import path7 from "node:path";
|
|
2256
|
+
var NO_FOLLOW = fs7.constants.O_NOFOLLOW ?? 0;
|
|
2257
|
+
function readRegularFileNoFollow(target, options = {}) {
|
|
2258
|
+
const attempts = options.attempts ?? 1;
|
|
2259
|
+
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 16) {
|
|
2260
|
+
throw new Error("integrity read attempts must be an integer from 1 through 16");
|
|
2261
|
+
}
|
|
2262
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
2263
|
+
try {
|
|
2264
|
+
return readRegularFileOnce(target);
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
if (error.code !== "ROSTER_FILE_CHANGED" || attempt === attempts) {
|
|
2267
|
+
throw error;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
throw new Error("integrity read exhausted without a result");
|
|
2272
|
+
}
|
|
2273
|
+
function readRegularFileOnce(target) {
|
|
2274
|
+
const parent = path7.dirname(target);
|
|
2275
|
+
const parentBefore = fs7.lstatSync(parent, { bigint: true });
|
|
2276
|
+
if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink()) {
|
|
2277
|
+
throw new Error("integrity file parent is not a regular directory");
|
|
2278
|
+
}
|
|
2279
|
+
const fd = fs7.openSync(target, fs7.constants.O_RDONLY | NO_FOLLOW);
|
|
2280
|
+
try {
|
|
2281
|
+
const openedBefore = fs7.fstatSync(fd, { bigint: true });
|
|
2282
|
+
const namedAfterOpen = fs7.lstatSync(target, { bigint: true });
|
|
2283
|
+
const parentAfterOpen = fs7.lstatSync(parent, { bigint: true });
|
|
2284
|
+
if (!openedBefore.isFile() || !namedAfterOpen.isFile() || namedAfterOpen.isSymbolicLink() || !sameIdentity(openedBefore, namedAfterOpen) || !sameDirectory(parentBefore, parentAfterOpen)) {
|
|
2285
|
+
throw fileChanged("integrity file changed while being opened");
|
|
2286
|
+
}
|
|
2287
|
+
const bytes = fs7.readFileSync(fd);
|
|
2288
|
+
const openedAfter = fs7.fstatSync(fd, { bigint: true });
|
|
2289
|
+
const parentAfterRead = fs7.lstatSync(parent, { bigint: true });
|
|
2290
|
+
if (!sameStableFile(openedBefore, openedAfter) || !sameDirectory(parentBefore, parentAfterRead)) {
|
|
2291
|
+
throw fileChanged("integrity file changed while being read");
|
|
2292
|
+
}
|
|
2293
|
+
return bytes;
|
|
2294
|
+
} finally {
|
|
2295
|
+
fs7.closeSync(fd);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
function fileChanged(message) {
|
|
2299
|
+
return Object.assign(new Error(message), { code: "ROSTER_FILE_CHANGED" });
|
|
2300
|
+
}
|
|
2301
|
+
function sameIdentity(left, right) {
|
|
2302
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
2303
|
+
}
|
|
2304
|
+
function sameDirectory(left, right) {
|
|
2305
|
+
return right.isDirectory() && !right.isSymbolicLink() && sameIdentity(left, right);
|
|
2306
|
+
}
|
|
2307
|
+
function sameStableFile(left, right) {
|
|
2308
|
+
return right.isFile() && sameIdentity(left, right) && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// src/ejectJournal.ts
|
|
2312
|
+
function journalRoot() {
|
|
2313
|
+
return path8.join(rosterHome(), "eject-journals");
|
|
2314
|
+
}
|
|
2315
|
+
function journalDir(clientId) {
|
|
2316
|
+
return path8.join(journalRoot(), clientId);
|
|
2317
|
+
}
|
|
2318
|
+
function isHash(value) {
|
|
2319
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
2320
|
+
}
|
|
2321
|
+
function isSpawnEntryArray(value) {
|
|
2322
|
+
return Array.isArray(value) && value.every(
|
|
2323
|
+
(e) => e !== null && typeof e === "object" && typeof e.command === "string" && Array.isArray(e.args) && e.args.every((a) => typeof a === "string")
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
function parsePlan(value, clientId) {
|
|
2327
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
2328
|
+
throw new Error("pending eject plan is not an object");
|
|
2329
|
+
}
|
|
2330
|
+
const raw = value;
|
|
2331
|
+
if (raw.version !== 1 || raw.client !== clientId || typeof raw.boundary !== "string" || raw.boundary === "") {
|
|
2332
|
+
throw new Error("pending eject plan identity is invalid");
|
|
2333
|
+
}
|
|
2334
|
+
if (!Array.isArray(raw.targets) || raw.targets.length === 0) {
|
|
2335
|
+
throw new Error("pending eject plan has no targets");
|
|
2336
|
+
}
|
|
2337
|
+
const seenSources = /* @__PURE__ */ new Set();
|
|
2338
|
+
const seenFiles = /* @__PURE__ */ new Set();
|
|
2339
|
+
const targets = raw.targets.map((value2, index) => {
|
|
2340
|
+
if (value2 === null || typeof value2 !== "object" || Array.isArray(value2)) {
|
|
2341
|
+
throw new Error(`pending eject target ${index} is invalid`);
|
|
2342
|
+
}
|
|
2343
|
+
const target = value2;
|
|
2344
|
+
if (typeof target.sourcePath !== "string" || !path8.isAbsolute(target.sourcePath) || target.writePath !== void 0 && (typeof target.writePath !== "string" || !path8.isAbsolute(target.writePath)) || target.symlinkTarget !== void 0 && typeof target.symlinkTarget !== "string" || target.beforeSha256 !== null && !isHash(target.beforeSha256) || !isHash(target.desiredSha256) || typeof target.desiredFile !== "string" || !/^target-[0-9]+\.bin$/.test(target.desiredFile)) {
|
|
2345
|
+
throw new Error(`pending eject target ${index} has invalid fields`);
|
|
2346
|
+
}
|
|
2347
|
+
const keyLevel = target.keyLevel === true;
|
|
2348
|
+
if (keyLevel) {
|
|
2349
|
+
if (typeof target.originalFile !== "string" || !/^original-[0-9]+\.bin$/.test(target.originalFile) || !isHash(target.originalSha256) || !isSpawnEntryArray(target.injectedEntries)) {
|
|
2350
|
+
throw new Error(`pending eject target ${index} has invalid key-level recovery fields`);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (seenSources.has(target.sourcePath) || seenFiles.has(target.desiredFile)) {
|
|
2354
|
+
throw new Error("pending eject plan contains duplicate targets");
|
|
2355
|
+
}
|
|
2356
|
+
seenSources.add(target.sourcePath);
|
|
2357
|
+
seenFiles.add(target.desiredFile);
|
|
2358
|
+
return {
|
|
2359
|
+
sourcePath: target.sourcePath,
|
|
2360
|
+
...target.writePath !== void 0 ? { writePath: target.writePath } : {},
|
|
2361
|
+
...target.symlinkTarget !== void 0 ? { symlinkTarget: target.symlinkTarget } : {},
|
|
2362
|
+
beforeSha256: target.beforeSha256,
|
|
2363
|
+
desiredSha256: target.desiredSha256,
|
|
2364
|
+
desiredFile: target.desiredFile,
|
|
2365
|
+
...keyLevel ? {
|
|
2366
|
+
keyLevel: true,
|
|
2367
|
+
originalFile: target.originalFile,
|
|
2368
|
+
originalSha256: target.originalSha256,
|
|
2369
|
+
injectedEntries: target.injectedEntries.map((e) => ({
|
|
2370
|
+
command: e.command,
|
|
2371
|
+
args: [...e.args]
|
|
2372
|
+
}))
|
|
2373
|
+
} : {}
|
|
2374
|
+
};
|
|
2375
|
+
});
|
|
2376
|
+
return { version: 1, client: clientId, boundary: raw.boundary, targets };
|
|
2377
|
+
}
|
|
2378
|
+
function ensurePrivateDirectory(dir) {
|
|
2379
|
+
fs8.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR });
|
|
2380
|
+
const stat = fs8.lstatSync(dir);
|
|
2381
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
2382
|
+
throw new Error(`eject journal path is not a regular directory: ${dir}`);
|
|
2383
|
+
}
|
|
2384
|
+
fs8.chmodSync(dir, PRIVATE_DIR);
|
|
2385
|
+
}
|
|
2386
|
+
function fsyncDirectory(dir) {
|
|
2387
|
+
try {
|
|
2388
|
+
const fd = fs8.openSync(dir, "r");
|
|
2389
|
+
try {
|
|
2390
|
+
fs8.fsyncSync(fd);
|
|
2391
|
+
} finally {
|
|
2392
|
+
fs8.closeSync(fd);
|
|
2393
|
+
}
|
|
2394
|
+
} catch {
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
function hasEjectJournal(clientId) {
|
|
2398
|
+
return fs8.existsSync(journalDir(clientId));
|
|
2399
|
+
}
|
|
2400
|
+
function loadEjectJournal(clientId) {
|
|
2401
|
+
const dir = journalDir(clientId);
|
|
2402
|
+
if (!fs8.existsSync(dir)) return null;
|
|
2403
|
+
const stat = fs8.lstatSync(dir);
|
|
2404
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
2405
|
+
throw new Error("pending eject journal is not a regular directory");
|
|
2406
|
+
}
|
|
2407
|
+
let parsed;
|
|
2408
|
+
try {
|
|
2409
|
+
parsed = JSON.parse(
|
|
2410
|
+
readRegularFileNoFollow(path8.join(dir, "plan.json")).toString("utf8")
|
|
2411
|
+
);
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
throw new Error(
|
|
2414
|
+
`pending eject plan is missing or corrupt: ${error instanceof Error ? error.message : String(error)}`
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
return { dir, plan: parsePlan(parsed, clientId) };
|
|
2418
|
+
}
|
|
2419
|
+
function createEjectJournal(clientId, boundary, targets) {
|
|
2420
|
+
if (targets.length === 0) throw new Error("cannot journal an eject with no targets");
|
|
2421
|
+
const root = journalRoot();
|
|
2422
|
+
ensurePrivateDirectory(root);
|
|
2423
|
+
const destination = journalDir(clientId);
|
|
2424
|
+
if (fs8.existsSync(destination)) {
|
|
2425
|
+
throw new Error("a pending eject journal already exists");
|
|
2426
|
+
}
|
|
2427
|
+
const staging = path8.join(
|
|
2428
|
+
root,
|
|
2429
|
+
`.staging-${clientId}-${process.pid}-${crypto3.randomBytes(6).toString("hex")}`
|
|
2430
|
+
);
|
|
2431
|
+
fs8.mkdirSync(staging, { mode: PRIVATE_DIR });
|
|
2432
|
+
try {
|
|
2433
|
+
const journalTargets = targets.map((target, index) => {
|
|
2434
|
+
const desiredFile = `target-${index}.bin`;
|
|
2435
|
+
const desiredSha256 = sha256Hex(target.desiredBytes);
|
|
2436
|
+
atomicWriteFileSync(
|
|
2437
|
+
path8.join(staging, desiredFile),
|
|
2438
|
+
target.desiredBytes,
|
|
2439
|
+
PRIVATE_FILE
|
|
2440
|
+
);
|
|
2441
|
+
const keyLevel = target.keyLevel === true && target.originalBytes !== void 0;
|
|
2442
|
+
let originalFields = {};
|
|
2443
|
+
if (keyLevel) {
|
|
2444
|
+
const originalFile = `original-${index}.bin`;
|
|
2445
|
+
atomicWriteFileSync(path8.join(staging, originalFile), target.originalBytes, PRIVATE_FILE);
|
|
2446
|
+
originalFields = {
|
|
2447
|
+
keyLevel: true,
|
|
2448
|
+
originalFile,
|
|
2449
|
+
originalSha256: sha256Hex(target.originalBytes),
|
|
2450
|
+
injectedEntries: (target.injectedEntries ?? []).map((e) => ({
|
|
2451
|
+
command: e.command,
|
|
2452
|
+
args: [...e.args]
|
|
2453
|
+
}))
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
return {
|
|
2457
|
+
sourcePath: target.sourcePath,
|
|
2458
|
+
...target.writePath !== void 0 ? { writePath: target.writePath } : {},
|
|
2459
|
+
...target.symlinkTarget !== void 0 ? { symlinkTarget: target.symlinkTarget } : {},
|
|
2460
|
+
beforeSha256: target.beforeSha256,
|
|
2461
|
+
desiredSha256,
|
|
2462
|
+
desiredFile,
|
|
2463
|
+
...originalFields
|
|
2464
|
+
};
|
|
2465
|
+
});
|
|
2466
|
+
const plan = {
|
|
2467
|
+
version: 1,
|
|
2468
|
+
client: clientId,
|
|
2469
|
+
boundary,
|
|
2470
|
+
targets: journalTargets
|
|
2471
|
+
};
|
|
2472
|
+
atomicWriteFileSync(
|
|
2473
|
+
path8.join(staging, "plan.json"),
|
|
2474
|
+
`${JSON.stringify(plan, null, 2)}
|
|
2475
|
+
`,
|
|
2476
|
+
PRIVATE_FILE
|
|
2477
|
+
);
|
|
2478
|
+
fsyncDirectory(staging);
|
|
2479
|
+
fs8.renameSync(staging, destination);
|
|
2480
|
+
fsyncDirectory(root);
|
|
2481
|
+
return { dir: destination, plan };
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
fs8.rmSync(staging, { recursive: true, force: true });
|
|
2484
|
+
throw error;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
function rebaseEjectJournal(journal, beforeHashes) {
|
|
2488
|
+
const targets = journal.plan.targets.map((target) => {
|
|
2489
|
+
const desired = readDesiredBytes(journal, target);
|
|
2490
|
+
const bytes = target.keyLevel ? readOriginalBytes(journal, target) : desired;
|
|
2491
|
+
const beforeSha256 = beforeHashes.get(target.sourcePath);
|
|
2492
|
+
if (beforeSha256 === void 0) throw new Error("missing forced-eject preflight snapshot");
|
|
2493
|
+
const nonce = BigInt(`0x${crypto3.randomBytes(16).toString("hex")}`);
|
|
2494
|
+
const desiredFile = `target-${nonce}.bin`;
|
|
2495
|
+
atomicWriteFileSync(path8.join(journal.dir, desiredFile), bytes, PRIVATE_FILE);
|
|
2496
|
+
return {
|
|
2497
|
+
sourcePath: target.sourcePath,
|
|
2498
|
+
...target.writePath !== void 0 ? { writePath: target.writePath } : {},
|
|
2499
|
+
...target.symlinkTarget !== void 0 ? { symlinkTarget: target.symlinkTarget } : {},
|
|
2500
|
+
beforeSha256,
|
|
2501
|
+
desiredSha256: sha256Hex(bytes),
|
|
2502
|
+
desiredFile
|
|
2503
|
+
};
|
|
2504
|
+
});
|
|
2505
|
+
const plan = { ...journal.plan, targets };
|
|
2506
|
+
atomicWriteFileSync(path8.join(journal.dir, "plan.json"), `${JSON.stringify(plan, null, 2)}
|
|
2507
|
+
`, PRIVATE_FILE);
|
|
2508
|
+
return { dir: journal.dir, plan };
|
|
2509
|
+
}
|
|
2510
|
+
function readDesiredBytes(journal, target) {
|
|
2511
|
+
return readJournalFile(journal, target.desiredFile, target.desiredSha256);
|
|
2512
|
+
}
|
|
2513
|
+
function readOriginalBytes(journal, target) {
|
|
2514
|
+
if (!target.originalFile || !target.originalSha256) {
|
|
2515
|
+
throw new Error("pending eject target has no original bytes to re-derive from");
|
|
2516
|
+
}
|
|
2517
|
+
return readJournalFile(journal, target.originalFile, target.originalSha256);
|
|
2518
|
+
}
|
|
2519
|
+
function readJournalFile(journal, file, expectedSha) {
|
|
2520
|
+
const resolved = path8.resolve(path8.join(journal.dir, file));
|
|
2521
|
+
if (path8.dirname(resolved) !== path8.resolve(journal.dir)) {
|
|
2522
|
+
throw new Error("pending eject journal file path escapes its journal");
|
|
2523
|
+
}
|
|
2524
|
+
const bytes = readRegularFileNoFollow(resolved);
|
|
2525
|
+
if (sha256Hex(bytes) !== expectedSha) {
|
|
2526
|
+
throw new Error("pending eject journal bytes do not match their recorded hash");
|
|
2527
|
+
}
|
|
2528
|
+
return bytes;
|
|
2529
|
+
}
|
|
2530
|
+
function clearEjectJournal(journal) {
|
|
2531
|
+
fs8.rmSync(journal.dir, { recursive: true });
|
|
2532
|
+
fsyncDirectory(path8.dirname(journal.dir));
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// src/sync.ts
|
|
2536
|
+
import crypto4 from "node:crypto";
|
|
2537
|
+
import fs9 from "node:fs";
|
|
2538
|
+
import path9 from "node:path";
|
|
2539
|
+
import { parse as parseToml2, stringify as stringifyToml } from "smol-toml";
|
|
2540
|
+
var WRITE_CLIENTS = ["claude-code", "cursor", "codex", "openclaw"];
|
|
2541
|
+
function syncClient(clientId, now = /* @__PURE__ */ new Date()) {
|
|
2542
|
+
return withFileLockSync(`client:${clientId}`, () => syncClientUnlocked(clientId, now));
|
|
2543
|
+
}
|
|
2544
|
+
function syncClientUnlocked(clientId, now) {
|
|
2545
|
+
const spec = CLIENTS.find((c) => c.id === clientId);
|
|
2546
|
+
if (!spec) throw new Error(`unknown client: ${clientId}`);
|
|
2547
|
+
if (hasEjectJournal(clientId)) {
|
|
2548
|
+
throw new Error(
|
|
2549
|
+
`a previous ${clientId} eject is pending recovery; run \`roster eject --client ${clientId}\` before syncing again`
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
const configPath = spec.configPaths().find((p) => fs9.existsSync(p));
|
|
2553
|
+
if (!configPath) return { client: clientId, configPath: "", action: "not-found" };
|
|
2554
|
+
const topology = resolveWriteTopology(configPath);
|
|
2555
|
+
const originalBytes = readRegularFileNoFollow(topology.writePath, {
|
|
2556
|
+
attempts: 4
|
|
2557
|
+
});
|
|
2558
|
+
let imported = 0;
|
|
2559
|
+
const servers = spec.parse(originalBytes.toString("utf8"), configPath);
|
|
2560
|
+
if (servers.some((server) => server.url && !server.command)) {
|
|
2561
|
+
throw new Error(
|
|
2562
|
+
"URL-only MCP servers cannot be synced yet: Roster routing is stdio-only; the client was left untouched"
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
const injectedEntry = rosterEntry();
|
|
2566
|
+
const ownedEntries = ownedRosterEntries(clientId, injectedEntry);
|
|
2567
|
+
if (servers.length > 0) {
|
|
2568
|
+
const { added } = updateConfig((config) => mergeServers(config, servers, ownedEntries));
|
|
2569
|
+
imported = added.length;
|
|
2570
|
+
}
|
|
2571
|
+
sweepOrphanStaging(clientId);
|
|
2572
|
+
const rewritten = rewriteConfig(
|
|
2573
|
+
clientId,
|
|
2574
|
+
originalBytes.toString("utf8"),
|
|
2575
|
+
injectedEntry
|
|
2576
|
+
);
|
|
2577
|
+
if (rewritten === null) {
|
|
2578
|
+
return { client: clientId, configPath, action: "already-synced", imported };
|
|
2579
|
+
}
|
|
2580
|
+
const timestamp = now.toISOString().replace(/[:.]/g, "-");
|
|
2581
|
+
const backupDir = backupDirFor(clientId, timestamp);
|
|
2582
|
+
const latestPath = path9.join(path9.dirname(backupDir), "latest");
|
|
2583
|
+
const previousLatest = fs9.existsSync(latestPath) ? readRegularFileNoFollow(latestPath) : null;
|
|
2584
|
+
const stagingDir = `${backupDir}.staging-${crypto4.randomBytes(4).toString("hex")}`;
|
|
2585
|
+
fs9.mkdirSync(stagingDir, { recursive: true, mode: PRIVATE_DIR });
|
|
2586
|
+
fs9.writeFileSync(path9.join(stagingDir, "original"), originalBytes, { mode: PRIVATE_FILE });
|
|
2587
|
+
const manifest = {
|
|
2588
|
+
client: clientId,
|
|
2589
|
+
sourcePath: configPath,
|
|
2590
|
+
originalSha256: sha256Hex(originalBytes),
|
|
2591
|
+
writtenSha256: sha256Hex(rewritten),
|
|
2592
|
+
timestamp,
|
|
2593
|
+
injectedEntry,
|
|
2594
|
+
// exact identity for eject — never the key name (R5-01)
|
|
2595
|
+
writePath: topology.writePath,
|
|
2596
|
+
...topology.symlinkTarget !== void 0 ? { symlinkTarget: topology.symlinkTarget } : {}
|
|
2597
|
+
};
|
|
2598
|
+
fs9.writeFileSync(path9.join(stagingDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
2599
|
+
`, {
|
|
2600
|
+
mode: PRIVATE_FILE
|
|
2601
|
+
});
|
|
2602
|
+
fs9.renameSync(stagingDir, backupDir);
|
|
2603
|
+
atomicWriteFileSync(latestPath, timestamp, PRIVATE_FILE);
|
|
2604
|
+
let replaceApproved = false;
|
|
2605
|
+
try {
|
|
2606
|
+
const writePath = validateWriteTopology(configPath, manifest.writePath, manifest.symlinkTarget);
|
|
2607
|
+
atomicWriteFileSync(writePath, rewritten, void 0, () => {
|
|
2608
|
+
validateWriteTopology(configPath, manifest.writePath, manifest.symlinkTarget);
|
|
2609
|
+
const current = readRegularFileNoFollow(writePath, { attempts: 4 });
|
|
2610
|
+
if (sha256Hex(current) !== manifest.originalSha256) {
|
|
2611
|
+
throw new Error("client config changed during sync; left untouched \u2014 retry when the client is idle");
|
|
2612
|
+
}
|
|
2613
|
+
replaceApproved = true;
|
|
2614
|
+
});
|
|
2615
|
+
} catch (error) {
|
|
2616
|
+
if (!replaceApproved) {
|
|
2617
|
+
fs9.renameSync(backupDir, stagingDir);
|
|
2618
|
+
if (previousLatest === null) fs9.rmSync(latestPath, { force: true });
|
|
2619
|
+
else atomicWriteFileSync(latestPath, previousLatest, PRIVATE_FILE);
|
|
2620
|
+
}
|
|
2621
|
+
throw error;
|
|
2622
|
+
}
|
|
2623
|
+
return { client: clientId, configPath, action: "synced", backupDir, imported };
|
|
2624
|
+
}
|
|
2625
|
+
function rewriteConfig(clientId, content, entry) {
|
|
2626
|
+
if (clientId === "codex") {
|
|
2627
|
+
const data2 = parseToml2(content);
|
|
2628
|
+
if (isAlreadySynced(data2.mcp_servers, [entry])) return null;
|
|
2629
|
+
data2.mcp_servers = { roster: entry };
|
|
2630
|
+
return `${stringifyToml(data2)}
|
|
2631
|
+
`;
|
|
2632
|
+
}
|
|
2633
|
+
const data = parseJsonc(content);
|
|
2634
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
2635
|
+
throw new Error(`config is not a JSON object (got ${Array.isArray(data) ? "array" : typeof data})`);
|
|
2636
|
+
}
|
|
2637
|
+
const obj = data;
|
|
2638
|
+
if (isAlreadySynced(obj.mcpServers, [entry])) return null;
|
|
2639
|
+
obj.mcpServers = { roster: entry };
|
|
2640
|
+
return `${JSON.stringify(obj, null, 2)}
|
|
2641
|
+
`;
|
|
2642
|
+
}
|
|
2643
|
+
function isAlreadySynced(servers, ownedEntries) {
|
|
2644
|
+
if (servers === null || typeof servers !== "object" || Array.isArray(servers)) return false;
|
|
2645
|
+
const entries = Object.entries(servers);
|
|
2646
|
+
return entries.length === 1 && isOwnedRosterEntry(entries[0][1], ownedEntries);
|
|
2647
|
+
}
|
|
2648
|
+
function ownedRosterEntries(clientId, current = rosterEntry()) {
|
|
2649
|
+
const entries = [current, ...verifiedRosterAliases()];
|
|
2650
|
+
const clients = clientId ? [clientId] : WRITE_CLIENTS;
|
|
2651
|
+
for (const id of clients) {
|
|
2652
|
+
for (const backup of rawBackups(id)) {
|
|
2653
|
+
const injected = normalizeSpawnEntry(backup.manifest?.injectedEntry);
|
|
2654
|
+
if (!injected || !backup.manifest) continue;
|
|
2655
|
+
try {
|
|
2656
|
+
const original = readRegularFileNoFollow(
|
|
2657
|
+
path9.join(backup.dir, "original")
|
|
2658
|
+
);
|
|
2659
|
+
if (sha256Hex(original) !== backup.manifest.originalSha256) continue;
|
|
2660
|
+
} catch {
|
|
2661
|
+
continue;
|
|
2662
|
+
}
|
|
2663
|
+
if (!entries.some((entry) => isOwnedRosterEntry(injected, [entry]))) {
|
|
2664
|
+
entries.push(injected);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
return entries;
|
|
2669
|
+
}
|
|
2670
|
+
function clientBackupDir(clientId) {
|
|
2671
|
+
return path9.dirname(backupDirFor(clientId, "x"));
|
|
2672
|
+
}
|
|
2673
|
+
var ORPHAN_STAGING_NAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.staging-[0-9a-f]{8}$/;
|
|
2674
|
+
function sweepOrphanStaging(clientId) {
|
|
2675
|
+
const dir = clientBackupDir(clientId);
|
|
2676
|
+
let entries;
|
|
2677
|
+
try {
|
|
2678
|
+
entries = fs9.readdirSync(dir);
|
|
2679
|
+
} catch {
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
for (const name of entries) {
|
|
2683
|
+
if (!ORPHAN_STAGING_NAME.test(name)) continue;
|
|
2684
|
+
try {
|
|
2685
|
+
fs9.rmSync(path9.join(dir, name), { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
2686
|
+
} catch {
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
function closedThroughPath(clientId) {
|
|
2691
|
+
return path9.join(clientBackupDir(clientId), ".closed-through");
|
|
2692
|
+
}
|
|
2693
|
+
function readClosedThrough(clientId) {
|
|
2694
|
+
try {
|
|
2695
|
+
const value = readRegularFileNoFollow(closedThroughPath(clientId)).toString("utf8").trim();
|
|
2696
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(value)) {
|
|
2697
|
+
throw new Error(`backup era marker for ${clientId} is corrupt`);
|
|
2698
|
+
}
|
|
2699
|
+
return value;
|
|
2700
|
+
} catch (error) {
|
|
2701
|
+
if (error.code === "ENOENT") return null;
|
|
2702
|
+
throw error;
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
function closeEraThrough(clientId, backupName) {
|
|
2706
|
+
const current = readClosedThrough(clientId);
|
|
2707
|
+
if (current !== null && current >= backupName) return true;
|
|
2708
|
+
try {
|
|
2709
|
+
atomicWriteFileSync(closedThroughPath(clientId), `${backupName}
|
|
2710
|
+
`);
|
|
2711
|
+
return true;
|
|
2712
|
+
} catch {
|
|
2713
|
+
return false;
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
function rawBackups(clientId) {
|
|
2717
|
+
const clientDir = clientBackupDir(clientId);
|
|
2718
|
+
if (!fs9.existsSync(clientDir)) return [];
|
|
2719
|
+
const closedThrough = readClosedThrough(clientId);
|
|
2720
|
+
const out = [];
|
|
2721
|
+
for (const name of fs9.readdirSync(clientDir).sort()) {
|
|
2722
|
+
if (name.includes(".staging-")) continue;
|
|
2723
|
+
if (closedThrough !== null && name <= closedThrough) continue;
|
|
2724
|
+
const dir = path9.join(clientDir, name);
|
|
2725
|
+
try {
|
|
2726
|
+
const stat = fs9.lstatSync(dir);
|
|
2727
|
+
if (stat.isSymbolicLink()) {
|
|
2728
|
+
out.push({ dir, name, manifest: null });
|
|
2729
|
+
continue;
|
|
2730
|
+
}
|
|
2731
|
+
if (!stat.isDirectory()) continue;
|
|
2732
|
+
} catch {
|
|
2733
|
+
continue;
|
|
2734
|
+
}
|
|
2735
|
+
let manifest = null;
|
|
2736
|
+
try {
|
|
2737
|
+
manifest = JSON.parse(
|
|
2738
|
+
readRegularFileNoFollow(path9.join(dir, "manifest.json")).toString(
|
|
2739
|
+
"utf8"
|
|
2740
|
+
)
|
|
2741
|
+
);
|
|
2742
|
+
} catch {
|
|
2743
|
+
manifest = null;
|
|
2744
|
+
}
|
|
2745
|
+
out.push({ dir, name, manifest });
|
|
2746
|
+
}
|
|
2747
|
+
return out;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// src/eject.ts
|
|
2751
|
+
function ejectClient(clientId, opts = {}) {
|
|
2752
|
+
return withFileLockSync(
|
|
2753
|
+
`client:${clientId}`,
|
|
2754
|
+
() => ejectClientUnlocked(clientId, opts)
|
|
2755
|
+
);
|
|
2756
|
+
}
|
|
2757
|
+
function ejectClientUnlocked(clientId, opts) {
|
|
2758
|
+
let pending;
|
|
2759
|
+
try {
|
|
2760
|
+
pending = loadEjectJournal(clientId);
|
|
2761
|
+
} catch (error) {
|
|
2762
|
+
return integrityFailure(
|
|
2763
|
+
clientId,
|
|
2764
|
+
`pending eject recovery data is corrupt \u2014 refusing: ${errorMessage(error)}`
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
if (pending) return applyJournal(clientId, pending, opts.force === true);
|
|
2768
|
+
let slots;
|
|
2769
|
+
try {
|
|
2770
|
+
slots = rawBackups(clientId);
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
return integrityFailure(
|
|
2773
|
+
clientId,
|
|
2774
|
+
`the backup era boundary is unreadable or corrupt: ${errorMessage(error)}`
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2777
|
+
if (slots.length === 0) return { client: clientId, action: "no-backup" };
|
|
2778
|
+
if (slots.some((slot) => slot.manifest === null)) {
|
|
2779
|
+
return integrityFailure(
|
|
2780
|
+
clientId,
|
|
2781
|
+
"the active backup set has a missing, corrupt, or non-directory slot \u2014 refusing every restore"
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2784
|
+
const backups = slots;
|
|
2785
|
+
try {
|
|
2786
|
+
for (const backup of backups) validateManifest(clientId, backup);
|
|
2787
|
+
} catch (error) {
|
|
2788
|
+
return integrityFailure(clientId, errorMessage(error));
|
|
2789
|
+
}
|
|
2790
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2791
|
+
for (const backup of backups) {
|
|
2792
|
+
const group = groups.get(backup.manifest.sourcePath) ?? [];
|
|
2793
|
+
group.push(backup);
|
|
2794
|
+
groups.set(backup.manifest.sourcePath, group);
|
|
2795
|
+
}
|
|
2796
|
+
const planned = [];
|
|
2797
|
+
for (const [sourcePath, group] of [...groups.entries()].sort(
|
|
2798
|
+
([a], [b]) => a.localeCompare(b)
|
|
2799
|
+
)) {
|
|
2800
|
+
const result = planRestore(clientId, sourcePath, group, opts.force === true);
|
|
2801
|
+
if ("action" in result) return result;
|
|
2802
|
+
planned.push(result);
|
|
2803
|
+
}
|
|
2804
|
+
const stable = stabilizeStateTargets(clientId, planned);
|
|
2805
|
+
if (stable) return stable;
|
|
2806
|
+
const boundary = backups.at(-1).name;
|
|
2807
|
+
let journal;
|
|
2808
|
+
try {
|
|
2809
|
+
journal = createEjectJournal(clientId, boundary, planned);
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
return integrityFailure(
|
|
2812
|
+
clientId,
|
|
2813
|
+
`could not persist the eject recovery journal before writing configs: ${errorMessage(error)}`
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
return applyJournal(clientId, journal);
|
|
2817
|
+
}
|
|
2818
|
+
function planRestore(clientId, sourcePath, backups, force) {
|
|
2819
|
+
const pristine = backups[0];
|
|
2820
|
+
const latest = backups.at(-1);
|
|
2821
|
+
let topology;
|
|
2822
|
+
try {
|
|
2823
|
+
topology = newestTopology(backups);
|
|
2824
|
+
} catch (error) {
|
|
2825
|
+
return integrityFailure(clientId, `${sourcePath}: ${errorMessage(error)}`, sourcePath);
|
|
2826
|
+
}
|
|
2827
|
+
let writePath;
|
|
2828
|
+
try {
|
|
2829
|
+
writePath = validateWriteTopology(
|
|
2830
|
+
sourcePath,
|
|
2831
|
+
topology.writePath,
|
|
2832
|
+
topology.symlinkTarget
|
|
2833
|
+
);
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
return {
|
|
2836
|
+
client: clientId,
|
|
2837
|
+
action: "refused-modified",
|
|
2838
|
+
configPath: sourcePath,
|
|
2839
|
+
detail: `config symlink/topology changed after sync \u2014 refusing restore: ${errorMessage(error)}`
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
let originalBytes;
|
|
2843
|
+
try {
|
|
2844
|
+
const originalPath = path10.join(pristine.dir, "original");
|
|
2845
|
+
originalBytes = readRegularFileNoFollow(originalPath);
|
|
2846
|
+
if (sha256Hex(originalBytes) !== pristine.manifest.originalSha256) {
|
|
2847
|
+
throw new Error("stored pristine bytes do not match their recorded hash");
|
|
2848
|
+
}
|
|
2849
|
+
} catch (error) {
|
|
2850
|
+
return integrityFailure(
|
|
2851
|
+
clientId,
|
|
2852
|
+
`${sourcePath}: ${errorMessage(error)} \u2014 not restoring`,
|
|
2853
|
+
sourcePath
|
|
2854
|
+
);
|
|
2855
|
+
}
|
|
2856
|
+
const currentBytes = readRegularFileIfPresent(writePath, 4);
|
|
2857
|
+
const stateFile = CLIENTS.find((client) => client.id === clientId)?.stateFileBasename === path10.basename(sourcePath);
|
|
2858
|
+
const injectedEntries = backups.map((backup) => normalizeSpawnEntry(backup.manifest.injectedEntry)).filter((entry) => entry !== null);
|
|
2859
|
+
if (stateFile && currentBytes && !force) {
|
|
2860
|
+
if (!normalizeSpawnEntry(latest.manifest.injectedEntry)) {
|
|
2861
|
+
return {
|
|
2862
|
+
client: clientId,
|
|
2863
|
+
action: "refused-modified",
|
|
2864
|
+
configPath: sourcePath,
|
|
2865
|
+
detail: "legacy backup has no exact injected-entry identity; refusing key-level deletion \u2014 use --force for an explicit pristine byte restore"
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
try {
|
|
2869
|
+
const desired = restoreServersKeyLevel(
|
|
2870
|
+
currentBytes.toString("utf8"),
|
|
2871
|
+
originalBytes.toString("utf8"),
|
|
2872
|
+
injectedEntries
|
|
2873
|
+
);
|
|
2874
|
+
if (ownedProxyRemains(desired, injectedEntries)) {
|
|
2875
|
+
return {
|
|
2876
|
+
client: clientId,
|
|
2877
|
+
action: "refused-modified",
|
|
2878
|
+
configPath: sourcePath,
|
|
2879
|
+
detail: "an owned Roster proxy entry is still present after key-level restore; refusing to close the era so `roster eject` can be re-run (recovery retained)"
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
return {
|
|
2883
|
+
sourcePath,
|
|
2884
|
+
...topology.writePath !== void 0 ? { writePath: topology.writePath } : {},
|
|
2885
|
+
...topology.symlinkTarget !== void 0 ? { symlinkTarget: topology.symlinkTarget } : {},
|
|
2886
|
+
beforeSha256: sha256Hex(currentBytes),
|
|
2887
|
+
desiredBytes: Buffer.from(desired),
|
|
2888
|
+
// Persisted so a resume can re-derive this merge from a third-state file
|
|
2889
|
+
// instead of deadlocking eject and sync (NEW-3).
|
|
2890
|
+
keyLevel: true,
|
|
2891
|
+
originalBytes,
|
|
2892
|
+
injectedEntries
|
|
2893
|
+
};
|
|
2894
|
+
} catch {
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
if (!currentBytes && !force) {
|
|
2898
|
+
return {
|
|
2899
|
+
client: clientId,
|
|
2900
|
+
action: "missing-file",
|
|
2901
|
+
configPath: sourcePath,
|
|
2902
|
+
detail: "config file no longer exists; use --force to recreate it from backup"
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
if (currentBytes && sha256Hex(currentBytes) !== latest.manifest.writtenSha256 && !force) {
|
|
2906
|
+
return {
|
|
2907
|
+
client: clientId,
|
|
2908
|
+
action: "refused-modified",
|
|
2909
|
+
configPath: sourcePath,
|
|
2910
|
+
detail: "config was modified after sync \u2014 refusing to overwrite those edits; re-run with --force to restore the pristine backup anyway"
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
return {
|
|
2914
|
+
sourcePath,
|
|
2915
|
+
...topology.writePath !== void 0 ? { writePath: topology.writePath } : {},
|
|
2916
|
+
...topology.symlinkTarget !== void 0 ? { symlinkTarget: topology.symlinkTarget } : {},
|
|
2917
|
+
beforeSha256: currentBytes ? sha256Hex(currentBytes) : null,
|
|
2918
|
+
desiredBytes: originalBytes
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
function validateManifest(clientId, backup) {
|
|
2922
|
+
const { manifest } = backup;
|
|
2923
|
+
if (manifest.client !== clientId || typeof manifest.sourcePath !== "string" || !path10.isAbsolute(manifest.sourcePath) || manifest.timestamp !== backup.name || !/^[a-f0-9]{64}$/.test(manifest.originalSha256) || !/^[a-f0-9]{64}$/.test(manifest.writtenSha256) || manifest.writePath !== void 0 && (typeof manifest.writePath !== "string" || !path10.isAbsolute(manifest.writePath)) || manifest.symlinkTarget !== void 0 && typeof manifest.symlinkTarget !== "string" || manifest.symlinkTarget !== void 0 && manifest.writePath === void 0) {
|
|
2924
|
+
throw new Error(
|
|
2925
|
+
`backup ${backup.name} has invalid or mismatched manifest fields`
|
|
2926
|
+
);
|
|
2927
|
+
}
|
|
2928
|
+
const originalPath = path10.join(backup.dir, "original");
|
|
2929
|
+
if (sha256Hex(readRegularFileNoFollow(originalPath)) !== manifest.originalSha256) {
|
|
2930
|
+
throw new Error(`backup ${backup.name} original bytes failed their hash check`);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
function newestTopology(backups) {
|
|
2934
|
+
const recorded = backups.filter(
|
|
2935
|
+
(backup) => backup.manifest.writePath !== void 0 || backup.manifest.symlinkTarget !== void 0
|
|
2936
|
+
);
|
|
2937
|
+
if (recorded.length === 0) return {};
|
|
2938
|
+
const newest = recorded.at(-1).manifest;
|
|
2939
|
+
for (const backup of recorded) {
|
|
2940
|
+
if (backup.manifest.writePath !== newest.writePath || backup.manifest.symlinkTarget !== newest.symlinkTarget) {
|
|
2941
|
+
throw new Error(
|
|
2942
|
+
"write topology changed between backups; refusing to guess which target owns the pristine bytes"
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
return {
|
|
2947
|
+
...newest.writePath !== void 0 ? { writePath: newest.writePath } : {},
|
|
2948
|
+
...newest.symlinkTarget !== void 0 ? { symlinkTarget: newest.symlinkTarget } : {}
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
function applyJournal(clientId, journal, force = false) {
|
|
2952
|
+
const restoredPaths = journal.plan.targets.map((target) => target.sourcePath);
|
|
2953
|
+
let alreadyClosed;
|
|
2954
|
+
try {
|
|
2955
|
+
alreadyClosed = readClosedThrough(clientId);
|
|
2956
|
+
} catch (error) {
|
|
2957
|
+
return integrityFailure(
|
|
2958
|
+
clientId,
|
|
2959
|
+
`the backup era boundary is unreadable or corrupt: ${errorMessage(error)}`
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
if (alreadyClosed !== null && alreadyClosed >= journal.plan.boundary) {
|
|
2963
|
+
try {
|
|
2964
|
+
clearEjectJournal(journal);
|
|
2965
|
+
} catch (error) {
|
|
2966
|
+
return integrityFailure(
|
|
2967
|
+
clientId,
|
|
2968
|
+
`the eject era is closed but its recovery journal could not be cleared: ${errorMessage(error)}`
|
|
2969
|
+
);
|
|
2970
|
+
}
|
|
2971
|
+
archiveEraThrough(clientId, journal.plan.boundary);
|
|
2972
|
+
return restoredResult(clientId, restoredPaths, "completed interrupted eject cleanup");
|
|
2973
|
+
}
|
|
2974
|
+
if (force) {
|
|
2975
|
+
try {
|
|
2976
|
+
const beforeHashes = /* @__PURE__ */ new Map();
|
|
2977
|
+
for (const target of journal.plan.targets) {
|
|
2978
|
+
const writePath = validateWriteTopology(target.sourcePath, target.writePath, target.symlinkTarget);
|
|
2979
|
+
beforeHashes.set(target.sourcePath, hashIfPresent(writePath));
|
|
2980
|
+
}
|
|
2981
|
+
journal = rebaseEjectJournal(journal, beforeHashes);
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
return integrityFailure(clientId, `forced eject recovery failed validation: ${errorMessage(error)}`);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
const prepared = [];
|
|
2987
|
+
for (const target of journal.plan.targets) {
|
|
2988
|
+
let desiredBytes;
|
|
2989
|
+
let writePath;
|
|
2990
|
+
try {
|
|
2991
|
+
desiredBytes = readDesiredBytes(journal, target);
|
|
2992
|
+
writePath = validateWriteTopology(
|
|
2993
|
+
target.sourcePath,
|
|
2994
|
+
target.writePath,
|
|
2995
|
+
target.symlinkTarget
|
|
2996
|
+
);
|
|
2997
|
+
} catch (error) {
|
|
2998
|
+
return integrityFailure(
|
|
2999
|
+
clientId,
|
|
3000
|
+
`${target.sourcePath}: pending eject recovery failed validation: ${errorMessage(error)}`,
|
|
3001
|
+
target.sourcePath
|
|
3002
|
+
);
|
|
3003
|
+
}
|
|
3004
|
+
const currentSha256 = hashIfPresent(writePath);
|
|
3005
|
+
if (currentSha256 === target.desiredSha256) {
|
|
3006
|
+
prepared.push({
|
|
3007
|
+
target,
|
|
3008
|
+
desiredBytes,
|
|
3009
|
+
writePath,
|
|
3010
|
+
pending: false,
|
|
3011
|
+
desiredSha256: target.desiredSha256,
|
|
3012
|
+
beforeSha256: target.beforeSha256
|
|
3013
|
+
});
|
|
3014
|
+
continue;
|
|
3015
|
+
}
|
|
3016
|
+
if (currentSha256 !== target.beforeSha256) {
|
|
3017
|
+
const rederived = rederiveKeyLevel(journal, target, writePath);
|
|
3018
|
+
if (rederived) {
|
|
3019
|
+
if (ownedProxyRemains(rederived.toString("utf8"), target.injectedEntries ?? [])) {
|
|
3020
|
+
return integrityFailure(
|
|
3021
|
+
clientId,
|
|
3022
|
+
`${target.sourcePath}: an owned proxy would remain after key-level recovery; refusing (recovery retained)`,
|
|
3023
|
+
target.sourcePath
|
|
3024
|
+
);
|
|
3025
|
+
}
|
|
3026
|
+
prepared.push({
|
|
3027
|
+
target,
|
|
3028
|
+
desiredBytes: rederived,
|
|
3029
|
+
writePath,
|
|
3030
|
+
pending: true,
|
|
3031
|
+
desiredSha256: sha256Hex(rederived),
|
|
3032
|
+
beforeSha256: currentSha256
|
|
3033
|
+
});
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
return integrityFailure(
|
|
3037
|
+
clientId,
|
|
3038
|
+
`${target.sourcePath}: config changed to a third state during interrupted eject; refusing to overwrite it \u2014 re-run \`roster eject --force\` to restore the pristine backup`,
|
|
3039
|
+
target.sourcePath
|
|
3040
|
+
);
|
|
3041
|
+
}
|
|
3042
|
+
prepared.push({
|
|
3043
|
+
target,
|
|
3044
|
+
desiredBytes,
|
|
3045
|
+
writePath,
|
|
3046
|
+
pending: true,
|
|
3047
|
+
desiredSha256: target.desiredSha256,
|
|
3048
|
+
beforeSha256: target.beforeSha256
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
for (const item of prepared) {
|
|
3052
|
+
if (!item.pending) continue;
|
|
3053
|
+
try {
|
|
3054
|
+
const checkedWritePath = validateWriteTopology(
|
|
3055
|
+
item.target.sourcePath,
|
|
3056
|
+
item.target.writePath,
|
|
3057
|
+
item.target.symlinkTarget
|
|
3058
|
+
);
|
|
3059
|
+
const currentSha256 = hashIfPresent(checkedWritePath);
|
|
3060
|
+
if (currentSha256 === item.desiredSha256) continue;
|
|
3061
|
+
if (currentSha256 !== item.beforeSha256) {
|
|
3062
|
+
throw new Error("config changed after eject preflight");
|
|
3063
|
+
}
|
|
3064
|
+
fs10.mkdirSync(path10.dirname(item.target.sourcePath), {
|
|
3065
|
+
recursive: true,
|
|
3066
|
+
mode: PRIVATE_DIR
|
|
3067
|
+
});
|
|
3068
|
+
const finalWritePath = validateWriteTopology(
|
|
3069
|
+
item.target.sourcePath,
|
|
3070
|
+
item.target.writePath,
|
|
3071
|
+
item.target.symlinkTarget
|
|
3072
|
+
);
|
|
3073
|
+
atomicWriteFileSync(finalWritePath, item.desiredBytes);
|
|
3074
|
+
if (hashIfPresent(finalWritePath) !== item.desiredSha256) {
|
|
3075
|
+
throw new Error("restored bytes failed their post-write hash check");
|
|
3076
|
+
}
|
|
3077
|
+
} catch (error) {
|
|
3078
|
+
return integrityFailure(
|
|
3079
|
+
clientId,
|
|
3080
|
+
`${item.target.sourcePath}: eject write stopped safely and remains recoverable: ${errorMessage(error)}`,
|
|
3081
|
+
item.target.sourcePath
|
|
3082
|
+
);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
let marked;
|
|
3086
|
+
try {
|
|
3087
|
+
marked = closeEraThrough(clientId, journal.plan.boundary);
|
|
3088
|
+
} catch (error) {
|
|
3089
|
+
return integrityFailure(
|
|
3090
|
+
clientId,
|
|
3091
|
+
`the backup era boundary is unreadable or corrupt: ${errorMessage(error)}`
|
|
3092
|
+
);
|
|
3093
|
+
}
|
|
3094
|
+
if (!marked && !archiveEraThrough(clientId, journal.plan.boundary)) {
|
|
3095
|
+
return integrityFailure(
|
|
3096
|
+
clientId,
|
|
3097
|
+
"configs were restored, but the exact backup era could not be closed; recovery data was retained",
|
|
3098
|
+
restoredPaths[0]
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
try {
|
|
3102
|
+
clearEjectJournal(journal);
|
|
3103
|
+
} catch (error) {
|
|
3104
|
+
return integrityFailure(
|
|
3105
|
+
clientId,
|
|
3106
|
+
`configs were restored and the era is closed, but recovery cleanup failed: ${errorMessage(error)}`,
|
|
3107
|
+
restoredPaths[0]
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
if (marked) archiveEraThrough(clientId, journal.plan.boundary);
|
|
3111
|
+
return restoredResult(clientId, restoredPaths);
|
|
3112
|
+
}
|
|
3113
|
+
function restoredResult(clientId, restoredPaths, detail) {
|
|
3114
|
+
return {
|
|
3115
|
+
client: clientId,
|
|
3116
|
+
action: "restored",
|
|
3117
|
+
configPath: restoredPaths[0],
|
|
3118
|
+
restoredPaths,
|
|
3119
|
+
...detail ? { detail } : {}
|
|
3120
|
+
};
|
|
3121
|
+
}
|
|
3122
|
+
function hashIfPresent(target) {
|
|
3123
|
+
const bytes = readRegularFileIfPresent(target, 4);
|
|
3124
|
+
return bytes === null ? null : sha256Hex(bytes);
|
|
3125
|
+
}
|
|
3126
|
+
function readRegularFileIfPresent(target, attempts = 1) {
|
|
3127
|
+
try {
|
|
3128
|
+
return readRegularFileNoFollow(target, { attempts });
|
|
3129
|
+
} catch (error) {
|
|
3130
|
+
if (error.code === "ENOENT") return null;
|
|
3131
|
+
throw error;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
function integrityFailure(clientId, detail, configPath) {
|
|
3135
|
+
return {
|
|
3136
|
+
client: clientId,
|
|
3137
|
+
action: "integrity-error",
|
|
3138
|
+
...configPath ? { configPath } : {},
|
|
3139
|
+
detail: `BACKUP INTEGRITY FAILURE: ${detail}`
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
function errorMessage(error) {
|
|
3143
|
+
return error instanceof Error ? error.message : String(error);
|
|
3144
|
+
}
|
|
3145
|
+
function stabilizeStateTargets(clientId, targets) {
|
|
3146
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
3147
|
+
let changed = false;
|
|
3148
|
+
for (const target of targets) {
|
|
3149
|
+
if (!target.keyLevel || !target.originalBytes || !target.injectedEntries) continue;
|
|
3150
|
+
let writePath;
|
|
3151
|
+
try {
|
|
3152
|
+
writePath = validateWriteTopology(
|
|
3153
|
+
target.sourcePath,
|
|
3154
|
+
target.writePath,
|
|
3155
|
+
target.symlinkTarget
|
|
3156
|
+
);
|
|
3157
|
+
} catch (error) {
|
|
3158
|
+
return {
|
|
3159
|
+
client: clientId,
|
|
3160
|
+
action: "refused-modified",
|
|
3161
|
+
configPath: target.sourcePath,
|
|
3162
|
+
detail: `config topology changed while planning eject: ${errorMessage(error)}`
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
const currentBytes = readRegularFileIfPresent(writePath, 4);
|
|
3166
|
+
if (currentBytes === null) {
|
|
3167
|
+
return {
|
|
3168
|
+
client: clientId,
|
|
3169
|
+
action: "refused-modified",
|
|
3170
|
+
configPath: target.sourcePath,
|
|
3171
|
+
detail: "state file disappeared while planning eject"
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
const currentSha256 = sha256Hex(currentBytes);
|
|
3175
|
+
if (currentSha256 === target.beforeSha256) continue;
|
|
3176
|
+
try {
|
|
3177
|
+
target.desiredBytes = Buffer.from(
|
|
3178
|
+
restoreServersKeyLevel(
|
|
3179
|
+
currentBytes.toString("utf8"),
|
|
3180
|
+
target.originalBytes.toString("utf8"),
|
|
3181
|
+
target.injectedEntries
|
|
3182
|
+
)
|
|
3183
|
+
);
|
|
3184
|
+
} catch {
|
|
3185
|
+
return {
|
|
3186
|
+
client: clientId,
|
|
3187
|
+
action: "refused-modified",
|
|
3188
|
+
configPath: target.sourcePath,
|
|
3189
|
+
detail: "state file became unparseable while planning eject"
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
target.beforeSha256 = currentSha256;
|
|
3193
|
+
changed = true;
|
|
3194
|
+
}
|
|
3195
|
+
if (!changed) return null;
|
|
3196
|
+
}
|
|
3197
|
+
return {
|
|
3198
|
+
client: clientId,
|
|
3199
|
+
action: "refused-modified",
|
|
3200
|
+
detail: "state files kept changing while eject was planning; retry when the client is idle"
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
function rederiveKeyLevel(journal, target, writePath) {
|
|
3204
|
+
if (!target.keyLevel || !target.injectedEntries) return null;
|
|
3205
|
+
try {
|
|
3206
|
+
const currentBytes = readRegularFileIfPresent(writePath, 4);
|
|
3207
|
+
if (currentBytes === null) return null;
|
|
3208
|
+
const originalBytes = readOriginalBytes(journal, target);
|
|
3209
|
+
return Buffer.from(
|
|
3210
|
+
restoreServersKeyLevel(
|
|
3211
|
+
currentBytes.toString("utf8"),
|
|
3212
|
+
originalBytes.toString("utf8"),
|
|
3213
|
+
target.injectedEntries
|
|
3214
|
+
)
|
|
3215
|
+
);
|
|
3216
|
+
} catch {
|
|
3217
|
+
return null;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
function ownedProxyRemains(content, injectedEntries) {
|
|
3221
|
+
const parsed = parseJsonc(content);
|
|
3222
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
3223
|
+
const servers = parsed.mcpServers;
|
|
3224
|
+
if (servers === null || typeof servers !== "object" || Array.isArray(servers)) return false;
|
|
3225
|
+
return Object.values(servers).some(
|
|
3226
|
+
(entry) => injectedEntries.some((injected) => sameEntry(entry, injected))
|
|
3227
|
+
);
|
|
3228
|
+
}
|
|
3229
|
+
function restoreServersKeyLevel(currentContent, originalContent, injectedEntries) {
|
|
3230
|
+
const current = parseJsonc(currentContent);
|
|
3231
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
3232
|
+
throw new Error("current config is not a JSON object");
|
|
3233
|
+
}
|
|
3234
|
+
const cur = current;
|
|
3235
|
+
const original = parseJsonc(originalContent);
|
|
3236
|
+
const origServers = original && typeof original === "object" && !Array.isArray(original) ? original.mcpServers : void 0;
|
|
3237
|
+
const currentServers = cur.mcpServers && typeof cur.mcpServers === "object" && !Array.isArray(cur.mcpServers) ? { ...cur.mcpServers } : {};
|
|
3238
|
+
for (const [name, entry] of Object.entries(currentServers)) {
|
|
3239
|
+
if (injectedEntries.some((injected) => sameEntry(entry, injected))) {
|
|
3240
|
+
delete currentServers[name];
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
const merged = { ...origServers ?? {}, ...currentServers };
|
|
3244
|
+
if (Object.keys(merged).length === 0 && origServers === void 0) {
|
|
3245
|
+
delete cur.mcpServers;
|
|
3246
|
+
} else {
|
|
3247
|
+
cur.mcpServers = merged;
|
|
3248
|
+
}
|
|
3249
|
+
return `${JSON.stringify(cur, null, 2)}
|
|
3250
|
+
`;
|
|
3251
|
+
}
|
|
3252
|
+
function archiveEraThrough(clientId, boundary) {
|
|
3253
|
+
const clientDir = path10.dirname(backupDirFor(clientId, "x"));
|
|
3254
|
+
if (!fs10.existsSync(clientDir)) return true;
|
|
3255
|
+
const archived = `${clientDir}-ejected-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
3256
|
+
try {
|
|
3257
|
+
const backupNames = fs10.readdirSync(clientDir).filter((name) => !name.startsWith(".") && name !== "latest").sort();
|
|
3258
|
+
if (backupNames.every((name) => name <= boundary)) {
|
|
3259
|
+
fs10.renameSync(clientDir, archived);
|
|
3260
|
+
return true;
|
|
3261
|
+
}
|
|
3262
|
+
fs10.mkdirSync(archived, { mode: PRIVATE_DIR });
|
|
3263
|
+
for (const name of backupNames.filter((name2) => name2 <= boundary)) {
|
|
3264
|
+
fs10.renameSync(path10.join(clientDir, name), path10.join(archived, name));
|
|
3265
|
+
}
|
|
3266
|
+
return true;
|
|
3267
|
+
} catch {
|
|
3268
|
+
return false;
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
// ../playbook/dist/skill.js
|
|
3273
|
+
import { parse as parseYaml2 } from "yaml";
|
|
3274
|
+
var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
3275
|
+
var SCRIPT_EXT = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl)$/i;
|
|
3276
|
+
function isScriptPath(rel) {
|
|
3277
|
+
return SCRIPT_EXT.test(rel);
|
|
3278
|
+
}
|
|
3279
|
+
function parseSkillMd(content, slug, dir) {
|
|
3280
|
+
if (content.charCodeAt(0) === 65279)
|
|
3281
|
+
content = content.slice(1);
|
|
3282
|
+
const match = FRONTMATTER.exec(content);
|
|
3283
|
+
let frontmatter = {};
|
|
3284
|
+
let body = content;
|
|
3285
|
+
if (match) {
|
|
3286
|
+
body = content.slice(match[0].length);
|
|
3287
|
+
try {
|
|
3288
|
+
const parsed = parseYaml2(match[1] ?? "");
|
|
3289
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
3290
|
+
frontmatter = parsed;
|
|
3291
|
+
}
|
|
3292
|
+
} catch {
|
|
3293
|
+
return null;
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
const name = typeof frontmatter.name === "string" && frontmatter.name.trim() !== "" ? frontmatter.name.trim() : slug;
|
|
3297
|
+
const description = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
|
|
3298
|
+
return { slug, name, description, body: body.trim(), dir, frontmatter };
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
// ../playbook/dist/scan.js
|
|
3302
|
+
import fs12 from "node:fs";
|
|
3303
|
+
import path12 from "node:path";
|
|
3304
|
+
import os3 from "node:os";
|
|
3305
|
+
|
|
3306
|
+
// ../playbook/dist/boundedRead.js
|
|
3307
|
+
import fs11 from "node:fs";
|
|
3308
|
+
import path11 from "node:path";
|
|
3309
|
+
var NON_BLOCK = fs11.constants.O_NONBLOCK ?? 0;
|
|
3310
|
+
var NO_FOLLOW2 = fs11.constants.O_NOFOLLOW ?? 0;
|
|
3311
|
+
function readFileHead(file, maxBytes, options = {}) {
|
|
3312
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
3313
|
+
throw new Error("bounded read limit must be a positive safe integer");
|
|
3314
|
+
}
|
|
3315
|
+
const before = snapshot(file);
|
|
3316
|
+
if (before.symlinked && !options.allowSymlink) {
|
|
3317
|
+
throw new Error("refusing to follow a symlink");
|
|
3318
|
+
}
|
|
3319
|
+
const noFollow = before.symlinked ? 0 : NO_FOLLOW2;
|
|
3320
|
+
const fd = fs11.openSync(file, fs11.constants.O_RDONLY | NON_BLOCK | noFollow);
|
|
3321
|
+
try {
|
|
3322
|
+
const openedBefore = fs11.fstatSync(fd, { bigint: true });
|
|
3323
|
+
const afterOpen = snapshot(file);
|
|
3324
|
+
if (!openedBefore.isFile() || !sameSnapshot(before, afterOpen) || !sameIdentity2(openedBefore, afterOpen.target)) {
|
|
3325
|
+
throw new Error("bounded-read path changed while being opened");
|
|
3326
|
+
}
|
|
3327
|
+
const buf = Buffer.allocUnsafe(maxBytes + 1);
|
|
3328
|
+
let n = 0;
|
|
3329
|
+
while (n < buf.length) {
|
|
3330
|
+
const read = fs11.readSync(fd, buf, n, buf.length - n, n);
|
|
3331
|
+
if (read === 0)
|
|
3332
|
+
break;
|
|
3333
|
+
n += read;
|
|
3334
|
+
}
|
|
3335
|
+
const openedAfter = fs11.fstatSync(fd, { bigint: true });
|
|
3336
|
+
const afterRead = snapshot(file);
|
|
3337
|
+
if (!sameStableFile2(openedBefore, openedAfter) || !sameSnapshot(before, afterRead) || !sameIdentity2(openedAfter, afterRead.target)) {
|
|
3338
|
+
throw new Error("bounded-read path changed while being read");
|
|
3339
|
+
}
|
|
3340
|
+
const truncated = n > maxBytes;
|
|
3341
|
+
return {
|
|
3342
|
+
text: buf.subarray(0, truncated ? maxBytes : n).toString("utf8"),
|
|
3343
|
+
truncated,
|
|
3344
|
+
symlinked: before.symlinked
|
|
3345
|
+
};
|
|
3346
|
+
} finally {
|
|
3347
|
+
fs11.closeSync(fd);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
function snapshot(file) {
|
|
3351
|
+
const parent = fs11.lstatSync(path11.dirname(file), { bigint: true });
|
|
3352
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) {
|
|
3353
|
+
throw new Error("bounded-read parent is not a regular directory");
|
|
3354
|
+
}
|
|
3355
|
+
const named = fs11.lstatSync(file, { bigint: true });
|
|
3356
|
+
const symlinked = named.isSymbolicLink();
|
|
3357
|
+
const target = symlinked ? fs11.statSync(file, { bigint: true }) : named;
|
|
3358
|
+
if (!target.isFile()) {
|
|
3359
|
+
throw new Error("refusing to read a non-regular file");
|
|
3360
|
+
}
|
|
3361
|
+
return { parent, named, target, symlinked };
|
|
3362
|
+
}
|
|
3363
|
+
function sameSnapshot(left, right) {
|
|
3364
|
+
return left.symlinked === right.symlinked && sameIdentity2(left.parent, right.parent) && sameIdentity2(left.named, right.named) && sameIdentity2(left.target, right.target);
|
|
3365
|
+
}
|
|
3366
|
+
function sameIdentity2(left, right) {
|
|
3367
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
3368
|
+
}
|
|
3369
|
+
function sameStableFile2(left, right) {
|
|
3370
|
+
return right.isFile() && sameIdentity2(left, right) && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
// ../playbook/dist/scan.js
|
|
3374
|
+
function defaultSkillSources(opts = {}) {
|
|
3375
|
+
const home2 = opts.home ?? process.env.ROSTER_TEST_HOME ?? os3.homedir();
|
|
3376
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
3377
|
+
return [
|
|
3378
|
+
path12.join(home2, ".claude", "skills"),
|
|
3379
|
+
path12.join(home2, ".agents", "skills"),
|
|
3380
|
+
path12.join(home2, ".openclaw", "skills"),
|
|
3381
|
+
path12.join(cwd, ".claude", "skills")
|
|
3382
|
+
];
|
|
3383
|
+
}
|
|
3384
|
+
var MAX_RESOURCES_LISTED = 200;
|
|
3385
|
+
var MAX_SKILL_MD_BYTES = 1024 * 1024;
|
|
3386
|
+
function scanSkillLibrary(libraryDir) {
|
|
3387
|
+
if (!fs12.existsSync(libraryDir))
|
|
3388
|
+
return [];
|
|
3389
|
+
let entries;
|
|
3390
|
+
try {
|
|
3391
|
+
entries = fs12.readdirSync(libraryDir, { withFileTypes: true });
|
|
3392
|
+
} catch {
|
|
3393
|
+
return [];
|
|
3394
|
+
}
|
|
3395
|
+
const skills = [];
|
|
3396
|
+
for (const entry of entries) {
|
|
3397
|
+
if (!entry.isDirectory())
|
|
3398
|
+
continue;
|
|
3399
|
+
const dir = path12.join(libraryDir, entry.name);
|
|
3400
|
+
const skillMd = path12.join(dir, "SKILL.md");
|
|
3401
|
+
if (!fs12.existsSync(skillMd))
|
|
3402
|
+
continue;
|
|
3403
|
+
let head;
|
|
3404
|
+
try {
|
|
3405
|
+
head = readFileHead(skillMd, MAX_SKILL_MD_BYTES, { allowSymlink: true });
|
|
3406
|
+
} catch {
|
|
3407
|
+
continue;
|
|
3408
|
+
}
|
|
3409
|
+
const parsed = parseSkillMd(head.text, entry.name, dir);
|
|
3410
|
+
if (!parsed)
|
|
3411
|
+
continue;
|
|
3412
|
+
const resources = listResources(dir);
|
|
3413
|
+
const securityWalk = listScripts(dir);
|
|
3414
|
+
const scanWarnings = [
|
|
3415
|
+
.../* @__PURE__ */ new Set([
|
|
3416
|
+
...securityWalk.warnings,
|
|
3417
|
+
...head.symlinked ? ["symlink:SKILL.md"] : [],
|
|
3418
|
+
...head.truncated ? [`skill-md-truncated:${MAX_SKILL_MD_BYTES}`] : []
|
|
3419
|
+
])
|
|
3420
|
+
].sort();
|
|
3421
|
+
skills.push({
|
|
3422
|
+
...parsed,
|
|
3423
|
+
resources,
|
|
3424
|
+
// Scripts are a SECURITY input, not a display list, so they get their own
|
|
3425
|
+
// COMPLETE bounded walk — never `resources.filter(isScriptPath)`. Deriving
|
|
3426
|
+
// them from the 200-capped display list let a skill hide a malicious script
|
|
3427
|
+
// behind 200 benign files: it was neither listed nor scanned, so the skill
|
|
3428
|
+
// scanned "ok" and (with the R5-09 gate) was served (R5-15).
|
|
3429
|
+
scripts: securityWalk.scripts,
|
|
3430
|
+
scanWarnings
|
|
3431
|
+
});
|
|
3432
|
+
}
|
|
3433
|
+
return skills;
|
|
3434
|
+
}
|
|
3435
|
+
function scanSkillSources(sources) {
|
|
3436
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3437
|
+
for (const source of sources) {
|
|
3438
|
+
for (const skill of scanSkillLibrary(source)) {
|
|
3439
|
+
if (!seen.has(skill.slug))
|
|
3440
|
+
seen.set(skill.slug, skill);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
return [...seen.values()];
|
|
3444
|
+
}
|
|
3445
|
+
function listResources(dir) {
|
|
3446
|
+
const out = [];
|
|
3447
|
+
const walk = (rel) => {
|
|
3448
|
+
if (out.length >= MAX_RESOURCES_LISTED)
|
|
3449
|
+
return;
|
|
3450
|
+
const abs = path12.join(dir, rel);
|
|
3451
|
+
let entries;
|
|
3452
|
+
try {
|
|
3453
|
+
entries = fs12.readdirSync(abs, { withFileTypes: true });
|
|
3454
|
+
} catch {
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
for (const entry of entries) {
|
|
3458
|
+
if (out.length >= MAX_RESOURCES_LISTED)
|
|
3459
|
+
return;
|
|
3460
|
+
if (entry.name.startsWith("."))
|
|
3461
|
+
continue;
|
|
3462
|
+
const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
3463
|
+
if (entry.isDirectory())
|
|
3464
|
+
walk(childRel);
|
|
3465
|
+
else if (childRel !== "SKILL.md")
|
|
3466
|
+
out.push(childRel);
|
|
3467
|
+
}
|
|
3468
|
+
};
|
|
3469
|
+
walk("");
|
|
3470
|
+
return out.sort();
|
|
3471
|
+
}
|
|
3472
|
+
var MAX_SCRIPTS_SCANNED = 5e3;
|
|
3473
|
+
function listScripts(dir) {
|
|
3474
|
+
const out = [];
|
|
3475
|
+
const warnings = /* @__PURE__ */ new Set();
|
|
3476
|
+
const walk = (rel) => {
|
|
3477
|
+
if (out.length >= MAX_SCRIPTS_SCANNED) {
|
|
3478
|
+
warnings.add(`script-scan-cap:${MAX_SCRIPTS_SCANNED}`);
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
let entries;
|
|
3482
|
+
try {
|
|
3483
|
+
entries = fs12.readdirSync(path12.join(dir, rel), { withFileTypes: true });
|
|
3484
|
+
} catch {
|
|
3485
|
+
warnings.add(`unreadable-directory:${rel || "."}`);
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
for (const entry of entries) {
|
|
3489
|
+
if (out.length >= MAX_SCRIPTS_SCANNED) {
|
|
3490
|
+
warnings.add(`script-scan-cap:${MAX_SCRIPTS_SCANNED}`);
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
3494
|
+
const childAbs = path12.join(dir, childRel);
|
|
3495
|
+
let stat;
|
|
3496
|
+
try {
|
|
3497
|
+
stat = fs12.lstatSync(childAbs);
|
|
3498
|
+
} catch {
|
|
3499
|
+
warnings.add(`unreadable-entry:${childRel}`);
|
|
3500
|
+
continue;
|
|
3501
|
+
}
|
|
3502
|
+
if (stat.isSymbolicLink()) {
|
|
3503
|
+
warnings.add(`symlink:${childRel}`);
|
|
3504
|
+
continue;
|
|
3505
|
+
}
|
|
3506
|
+
if (stat.isDirectory()) {
|
|
3507
|
+
walk(childRel);
|
|
3508
|
+
continue;
|
|
3509
|
+
}
|
|
3510
|
+
if (!stat.isFile()) {
|
|
3511
|
+
warnings.add(`unsupported-entry:${childRel}`);
|
|
3512
|
+
continue;
|
|
3513
|
+
}
|
|
3514
|
+
if (childRel === "SKILL.md")
|
|
3515
|
+
continue;
|
|
3516
|
+
const executable = (stat.mode & 73) !== 0;
|
|
3517
|
+
if (!isScriptPath(childRel) && !executable)
|
|
3518
|
+
continue;
|
|
3519
|
+
out.push(childRel);
|
|
3520
|
+
try {
|
|
3521
|
+
fs12.accessSync(childAbs, fs12.constants.R_OK);
|
|
3522
|
+
} catch {
|
|
3523
|
+
warnings.add(`unreadable-file:${childRel}`);
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
};
|
|
3527
|
+
walk("");
|
|
3528
|
+
if (out.length >= MAX_SCRIPTS_SCANNED) {
|
|
3529
|
+
warnings.add(`script-scan-cap:${MAX_SCRIPTS_SCANNED}`);
|
|
3530
|
+
}
|
|
3531
|
+
return { scripts: out.sort(), warnings: [...warnings].sort() };
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
// ../playbook/dist/trust.js
|
|
3535
|
+
import path13 from "node:path";
|
|
3536
|
+
var RM_SEP = String.raw`[ \t]+(?:\\\r?\n[ \t]+)*`;
|
|
3537
|
+
var RM_FLAG = "--?[A-Za-z0-9][-A-Za-z0-9=]*";
|
|
3538
|
+
var RM_OPT = `(?:${RM_SEP}${RM_FLAG})`;
|
|
3539
|
+
var RM_RECURSIVE = String.raw`-(?:[a-z]*r|-(?:recursive|recursiv|recursi|recurs|recur|recu|rec|re|r)(?![-\w]))`;
|
|
3540
|
+
var RM_FORCE = String.raw`-(?:[a-z]*f|-(?:force|forc|for|fo|f)(?![-\w]))`;
|
|
3541
|
+
var DESTRUCTIVE_RM = new RegExp(String.raw`(?<![-\w=])rm` + `(?=${RM_OPT}*${RM_SEP}${RM_RECURSIVE})(?=${RM_OPT}*${RM_SEP}${RM_FORCE})(?=(${RM_OPT}+))` + String.raw`\1` + `(?:${RM_SEP}--)?${RM_SEP}[~/]`, "i");
|
|
3542
|
+
var TRUST_RULES = [
|
|
3543
|
+
{
|
|
3544
|
+
id: "injection-override",
|
|
3545
|
+
pattern: /ignore (all |any )?(previous|prior|above) (instructions|rules|guidance)/i,
|
|
3546
|
+
detail: "instruction-override phrasing in skill body"
|
|
3547
|
+
},
|
|
3548
|
+
{
|
|
3549
|
+
id: "concealment",
|
|
3550
|
+
pattern: /do(n't| not) (tell|inform|mention|reveal|show)( this)? (to )?(the )?user/i,
|
|
3551
|
+
detail: "asks the agent to hide behavior from the user"
|
|
3552
|
+
},
|
|
3553
|
+
{
|
|
3554
|
+
id: "exfil-language",
|
|
3555
|
+
pattern: /\b(exfiltrate|send (all |the )?(credentials|secrets|tokens|keys))\b/i,
|
|
3556
|
+
detail: "credential-exfiltration language"
|
|
3557
|
+
},
|
|
3558
|
+
{
|
|
3559
|
+
id: "curl-pipe-shell",
|
|
3560
|
+
pattern: /\b(curl|wget)\b[^\n]{0,200}\|\s*(ba)?sh\b/i,
|
|
3561
|
+
detail: "pipe-to-shell install pattern"
|
|
3562
|
+
},
|
|
3563
|
+
{
|
|
3564
|
+
id: "destructive-command",
|
|
3565
|
+
// Built above from named fragments: the flat literal is 433 chars. The
|
|
3566
|
+
// predecessor `/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+[~/]/i` was linear but
|
|
3567
|
+
// required ONE cluster to carry both letters, so it missed every split and
|
|
3568
|
+
// long form — `rm -r -f /`, `rm --recursive --force /`, `rm --r --f /` — i.e.
|
|
3569
|
+
// 0/25 of the split-form corpus and recall 0.0967 on a 500,000-case fuzz
|
|
3570
|
+
// against a getopt oracle (this form: recall 1.0000, precision 0.966).
|
|
3571
|
+
pattern: DESTRUCTIVE_RM,
|
|
3572
|
+
detail: "recursive force-delete against home or root paths"
|
|
3573
|
+
},
|
|
3574
|
+
{
|
|
3575
|
+
id: "base64-blob",
|
|
3576
|
+
pattern: /[A-Za-z0-9+/]{400,}={0,2}/,
|
|
3577
|
+
detail: "large base64 blob embedded in instructions"
|
|
3578
|
+
},
|
|
3579
|
+
{
|
|
3580
|
+
id: "env-harvest",
|
|
3581
|
+
pattern: /\b(printenv|process\.env|os\.environ)\b[^\n]{0,120}\b(curl|wget|fetch|post|http)/i,
|
|
3582
|
+
detail: "environment variables flowing toward network calls"
|
|
3583
|
+
}
|
|
3584
|
+
];
|
|
3585
|
+
var MAX_SCRIPT_BYTES = 256 * 1024;
|
|
3586
|
+
function trustScan(skill) {
|
|
3587
|
+
const findings = [];
|
|
3588
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3589
|
+
const scan = (text, where, rules) => {
|
|
3590
|
+
for (const rule of rules) {
|
|
3591
|
+
if (!rule.pattern.test(text))
|
|
3592
|
+
continue;
|
|
3593
|
+
const key = `${rule.id}:${where}`;
|
|
3594
|
+
if (seen.has(key))
|
|
3595
|
+
continue;
|
|
3596
|
+
seen.add(key);
|
|
3597
|
+
findings.push({ rule: rule.id, detail: `${rule.detail} (${where})` });
|
|
3598
|
+
}
|
|
3599
|
+
};
|
|
3600
|
+
scan(`${skill.name ?? ""}
|
|
3601
|
+
${skill.description ?? ""}`, "metadata", TRUST_RULES);
|
|
3602
|
+
scan(skill.body, "body", TRUST_RULES);
|
|
3603
|
+
const scriptRules = TRUST_RULES.filter((r) => r.id !== "base64-blob");
|
|
3604
|
+
if (skill.dir) {
|
|
3605
|
+
for (const rel of skill.scripts) {
|
|
3606
|
+
try {
|
|
3607
|
+
scan(readFileHead(path13.join(skill.dir, rel), MAX_SCRIPT_BYTES).text, `script:${rel}`, scriptRules);
|
|
3608
|
+
} catch {
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
if (skill.scripts.length > 0) {
|
|
3613
|
+
findings.push({
|
|
3614
|
+
rule: "bundled-scripts",
|
|
3615
|
+
detail: `bundles ${skill.scripts.length} executable script(s) \u2014 review before allowing execution`
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
for (const warning of skill.scanWarnings ?? []) {
|
|
3619
|
+
const rule = warning.startsWith("symlink:") ? "symlink" : "scan-incomplete";
|
|
3620
|
+
const key = `${rule}:${warning}`;
|
|
3621
|
+
if (seen.has(key))
|
|
3622
|
+
continue;
|
|
3623
|
+
seen.add(key);
|
|
3624
|
+
findings.push({
|
|
3625
|
+
rule,
|
|
3626
|
+
detail: `${warning} (filesystem discovery did not establish a fully trusted skill tree)`
|
|
3627
|
+
});
|
|
3628
|
+
}
|
|
3629
|
+
return { status: findings.length > 0 ? "review" : "ok", findings };
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
// ../playbook/dist/openclaw.js
|
|
3633
|
+
var BASE_OVERHEAD_CHARS = 195;
|
|
3634
|
+
var PER_SKILL_OVERHEAD_CHARS = 97;
|
|
3635
|
+
function openclawInjectionChars(skills) {
|
|
3636
|
+
if (skills.length === 0)
|
|
3637
|
+
return 0;
|
|
3638
|
+
let total = BASE_OVERHEAD_CHARS;
|
|
3639
|
+
for (const skill of skills) {
|
|
3640
|
+
const filepath = `${skill.dir}/SKILL.md`;
|
|
3641
|
+
total += PER_SKILL_OVERHEAD_CHARS + skill.name.length + skill.description.length + filepath.length;
|
|
3642
|
+
}
|
|
3643
|
+
return total;
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
// ../playbook/dist/entry.js
|
|
3647
|
+
var SKILL_SOURCE = "skill";
|
|
3648
|
+
function skillToCapabilityEntry(skill, id) {
|
|
3649
|
+
return {
|
|
3650
|
+
id: id ?? stableNamespacedId(SKILL_SOURCE, skill.slug),
|
|
3651
|
+
kind: "skill",
|
|
3652
|
+
source: SKILL_SOURCE,
|
|
3653
|
+
name: skill.name,
|
|
3654
|
+
description: skill.description || `Skill: ${skill.name}`,
|
|
3655
|
+
body: skill.body,
|
|
3656
|
+
path: skill.dir
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
function skillInvocationResult(skill) {
|
|
3660
|
+
return {
|
|
3661
|
+
name: skill.name,
|
|
3662
|
+
description: skill.description,
|
|
3663
|
+
instructions: skill.body,
|
|
3664
|
+
resources: skill.resources.map((rel) => `${skill.dir}/${rel}`),
|
|
3665
|
+
scriptsNote: skill.scripts.length > 0 ? `This skill bundles ${skill.scripts.length} script(s); run them via your own execution tool if appropriate: ${skill.scripts.join(", ")}` : null
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// src/receipt.ts
|
|
3670
|
+
function routedByClient(routed) {
|
|
3671
|
+
const byClient = /* @__PURE__ */ new Map();
|
|
3672
|
+
for (const [name, server] of Object.entries(routed ?? {})) {
|
|
3673
|
+
for (const client of server.importedFrom) {
|
|
3674
|
+
const set = byClient.get(client) ?? /* @__PURE__ */ new Set();
|
|
3675
|
+
set.add(name);
|
|
3676
|
+
byClient.set(client, set);
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
return byClient;
|
|
3680
|
+
}
|
|
3681
|
+
function buildReceipt(discoveries, skills, trustReview, routed, ownedEntries = []) {
|
|
3682
|
+
const identities = /* @__PURE__ */ new Set();
|
|
3683
|
+
const byClient = routedByClient(routed);
|
|
3684
|
+
const clients = discoveries.map((d) => {
|
|
3685
|
+
const theirs = d.servers.filter((s) => {
|
|
3686
|
+
const candidate = { command: s.command, args: s.args };
|
|
3687
|
+
if (s.env !== void 0) candidate.env = s.env;
|
|
3688
|
+
return !isOwnedRosterEntry(candidate, ownedEntries);
|
|
3689
|
+
});
|
|
3690
|
+
const synced = theirs.length < d.servers.length;
|
|
3691
|
+
const routedHere = byClient.get(d.client.id) ?? /* @__PURE__ */ new Set();
|
|
3692
|
+
const clientIdentities = new Set(theirs.map(serverIdentity));
|
|
3693
|
+
if (synced) {
|
|
3694
|
+
for (const name of routedHere) {
|
|
3695
|
+
const server = routed?.[name];
|
|
3696
|
+
clientIdentities.add(server && (server.command || server.url) ? serverIdentity(server) : `routed:${name}`);
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
for (const identity of clientIdentities) identities.add(identity);
|
|
3700
|
+
return {
|
|
3701
|
+
id: d.client.id,
|
|
3702
|
+
displayName: d.client.displayName,
|
|
3703
|
+
configPath: d.configPath,
|
|
3704
|
+
serverCount: synced ? clientIdentities.size : theirs.length,
|
|
3705
|
+
note: d.parseError ? `could not parse (${d.parseError.slice(0, 80)})` : synced ? "routed through Roster \u2014 originals backed up; `roster eject` restores them" : d.client.nativeToolSearch ? "schemas natively deferred, not loaded \u2014 Roster adds learning, failover suggestions, and cross-client sync" : "schema weight measured at first serve"
|
|
3706
|
+
};
|
|
3707
|
+
});
|
|
3708
|
+
const hasOpenclaw = discoveries.some((d) => d.client.id === "openclaw");
|
|
3709
|
+
const chars = hasOpenclaw ? openclawInjectionChars(skills) : 0;
|
|
3710
|
+
return {
|
|
3711
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3712
|
+
clients,
|
|
3713
|
+
uniqueServers: identities.size,
|
|
3714
|
+
skills: {
|
|
3715
|
+
count: skills.length,
|
|
3716
|
+
trustReview,
|
|
3717
|
+
openclaw: hasOpenclaw ? { chars, estTokens: estimateTokensFromChars(chars) } : null
|
|
3718
|
+
},
|
|
3719
|
+
methodology: "Counts are read from your configs. Token figures are estimates (~4 chars/token); our own measurement puts the error at \u221237%\u2026+27% depending on tokenizer family and payload type (docs/lab/notes-token-economics.md), so read them as ballpark, never exact. OpenClaw skill-injection chars follow its deterministic <available_skills> formula."
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
function saveReceipt(receipt) {
|
|
3723
|
+
ensureRosterHome();
|
|
3724
|
+
atomicWriteFileSync(receiptPath(), `${JSON.stringify(receipt, null, 2)}
|
|
3725
|
+
`, PRIVATE_FILE);
|
|
3726
|
+
}
|
|
3727
|
+
function renderReceipt(receipt) {
|
|
3728
|
+
const lines = [];
|
|
3729
|
+
lines.push("\u2500".repeat(64));
|
|
3730
|
+
lines.push(" ROSTER \xB7 Day-0 receipt");
|
|
3731
|
+
lines.push("\u2500".repeat(64));
|
|
3732
|
+
if (receipt.clients.length === 0) {
|
|
3733
|
+
lines.push(" No MCP client configs found. Roster still works standalone \u2014");
|
|
3734
|
+
lines.push(" add servers to ~/.roster/roster.json and point any client at `roster serve`.");
|
|
3735
|
+
}
|
|
3736
|
+
for (const client of receipt.clients) {
|
|
3737
|
+
lines.push(` ${client.displayName.padEnd(14)} ${String(client.serverCount).padStart(3)} server(s) ${client.configPath}`);
|
|
3738
|
+
lines.push(` ${"".padEnd(14)} ${client.note}`);
|
|
3739
|
+
}
|
|
3740
|
+
lines.push("");
|
|
3741
|
+
lines.push(` Unique servers across clients: ${receipt.uniqueServers}`);
|
|
3742
|
+
lines.push(` Skills discovered: ${receipt.skills.count}${receipt.skills.trustReview > 0 ? ` (${receipt.skills.trustReview} flagged for review)` : ""}`);
|
|
3743
|
+
if (receipt.skills.openclaw) {
|
|
3744
|
+
lines.push(
|
|
3745
|
+
` OpenClaw skill injection: ${receipt.skills.openclaw.chars.toLocaleString()} chars into EVERY prompt (\u2248${receipt.skills.openclaw.estTokens.toLocaleString()} tokens, estimate)`
|
|
3746
|
+
);
|
|
3747
|
+
}
|
|
3748
|
+
lines.push("");
|
|
3749
|
+
lines.push(` ${receipt.methodology}`);
|
|
3750
|
+
lines.push("\u2500".repeat(64));
|
|
3751
|
+
return lines.join("\n");
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
// src/init.ts
|
|
3755
|
+
function init() {
|
|
3756
|
+
const discoveries = discoverClients();
|
|
3757
|
+
const imported = discoveries.flatMap((d) => d.servers);
|
|
3758
|
+
const { config, added, merged } = updateConfig((config2) => {
|
|
3759
|
+
const result = mergeServers(config2, imported, ownedRosterEntries());
|
|
3760
|
+
config2.skillSources = [.../* @__PURE__ */ new Set([...config2.skillSources, ...defaultSkillSources()])];
|
|
3761
|
+
return { config: config2, added: result.added, merged: result.merged };
|
|
3762
|
+
});
|
|
3763
|
+
const skillSources = config.skillSources;
|
|
3764
|
+
const skills = scanSkillSources(skillSources);
|
|
3765
|
+
const trustReview = skills.filter((s) => trustScan(s).status === "review").length;
|
|
3766
|
+
const receipt = buildReceipt(discoveries, skills, trustReview, config.servers, ownedRosterEntries());
|
|
3767
|
+
saveReceipt(receipt);
|
|
3768
|
+
process.stdout.write(`${renderReceipt(receipt)}
|
|
3769
|
+
|
|
3770
|
+
`);
|
|
3771
|
+
process.stdout.write(
|
|
3772
|
+
`Imported ${added.length} new server(s)${merged.length > 0 ? `, merged ${merged.length} duplicate definition(s)` : ""} into ~/.roster/roster.json
|
|
3773
|
+
`
|
|
3774
|
+
);
|
|
3775
|
+
const syncable = discoveries.map((d) => d.client.id).filter((id) => WRITE_CLIENTS.includes(id));
|
|
3776
|
+
if (syncable.length > 0) {
|
|
3777
|
+
process.stdout.write(
|
|
3778
|
+
`
|
|
3779
|
+
Next: \`roster sync\` swaps ${[...new Set(syncable)].join(", ")} to a single Roster entry (originals backed up; \`roster eject\` puts everything back exactly as found).
|
|
3780
|
+
`
|
|
3781
|
+
);
|
|
3782
|
+
}
|
|
3783
|
+
process.stdout.write(`Then point any agent at: roster serve
|
|
3784
|
+
`);
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
// src/serve.ts
|
|
3788
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3789
|
+
|
|
3790
|
+
// ../router/dist/backends.js
|
|
3791
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3792
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3793
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
3794
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
3795
|
+
|
|
3796
|
+
// ../router/dist/processGroupTransport.js
|
|
3797
|
+
import { spawn } from "node:child_process";
|
|
3798
|
+
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3799
|
+
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
|
|
3800
|
+
async function waitWithin(promise, ms) {
|
|
3801
|
+
let timer;
|
|
3802
|
+
try {
|
|
3803
|
+
await Promise.race([promise, new Promise((resolve) => {
|
|
3804
|
+
timer = setTimeout(resolve, ms);
|
|
3805
|
+
})]);
|
|
3806
|
+
} finally {
|
|
3807
|
+
if (timer)
|
|
3808
|
+
clearTimeout(timer);
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
var ProcessGroupTransport = class {
|
|
3812
|
+
parameters;
|
|
3813
|
+
onclose;
|
|
3814
|
+
onerror;
|
|
3815
|
+
onmessage;
|
|
3816
|
+
buffer = new ReadBuffer();
|
|
3817
|
+
child;
|
|
3818
|
+
exited;
|
|
3819
|
+
closed;
|
|
3820
|
+
closing;
|
|
3821
|
+
started = false;
|
|
3822
|
+
constructor(parameters) {
|
|
3823
|
+
this.parameters = parameters;
|
|
3824
|
+
}
|
|
3825
|
+
start() {
|
|
3826
|
+
if (process.platform === "win32")
|
|
3827
|
+
return Promise.reject(new Error("process groups require POSIX"));
|
|
3828
|
+
if (this.started || this.closing)
|
|
3829
|
+
return Promise.reject(new Error("transport already started or closed"));
|
|
3830
|
+
this.started = true;
|
|
3831
|
+
return new Promise((resolve, reject) => {
|
|
3832
|
+
const child = spawn(this.parameters.command, this.parameters.args ?? [], {
|
|
3833
|
+
env: { ...getDefaultEnvironment(), ...this.parameters.env },
|
|
3834
|
+
stdio: ["pipe", "pipe", this.parameters.stderr],
|
|
3835
|
+
detached: true,
|
|
3836
|
+
shell: false
|
|
3837
|
+
});
|
|
3838
|
+
this.child = child;
|
|
3839
|
+
this.exited = new Promise((done) => {
|
|
3840
|
+
child.once("exit", done);
|
|
3841
|
+
child.once("error", done);
|
|
3842
|
+
});
|
|
3843
|
+
this.closed = new Promise((done) => {
|
|
3844
|
+
child.once("close", done);
|
|
3845
|
+
});
|
|
3846
|
+
child.once("spawn", resolve);
|
|
3847
|
+
child.on("error", (error) => {
|
|
3848
|
+
reject(error);
|
|
3849
|
+
this.report(error);
|
|
3850
|
+
void this.close().catch((failure) => this.report(failure));
|
|
3851
|
+
});
|
|
3852
|
+
child.once("exit", () => {
|
|
3853
|
+
void this.close().catch((error) => this.report(error));
|
|
3854
|
+
});
|
|
3855
|
+
child.stdin?.on("error", (error) => this.report(error));
|
|
3856
|
+
child.stdout?.on("error", (error) => this.report(error));
|
|
3857
|
+
child.stdout?.on("data", (chunk) => this.receive(chunk));
|
|
3858
|
+
});
|
|
3859
|
+
}
|
|
3860
|
+
send(message) {
|
|
3861
|
+
return new Promise((resolve, reject) => {
|
|
3862
|
+
const stdin = this.child?.stdin;
|
|
3863
|
+
if (!stdin || this.closing) {
|
|
3864
|
+
reject(new Error("Not connected"));
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
stdin.write(serializeMessage(message), (error) => {
|
|
3868
|
+
if (error)
|
|
3869
|
+
reject(error);
|
|
3870
|
+
else
|
|
3871
|
+
resolve();
|
|
3872
|
+
});
|
|
3873
|
+
});
|
|
3874
|
+
}
|
|
3875
|
+
close() {
|
|
3876
|
+
this.closing ??= Promise.resolve().then(async () => {
|
|
3877
|
+
const child = this.child;
|
|
3878
|
+
try {
|
|
3879
|
+
if (child) {
|
|
3880
|
+
if (!child.stdin?.destroyed)
|
|
3881
|
+
child.stdin?.end();
|
|
3882
|
+
if (this.exited)
|
|
3883
|
+
await waitWithin(this.exited, 2e3);
|
|
3884
|
+
if (this.signalGroup("SIGTERM")) {
|
|
3885
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
3886
|
+
this.signalGroup("SIGKILL");
|
|
3887
|
+
}
|
|
3888
|
+
if (this.closed)
|
|
3889
|
+
await waitWithin(this.closed, 1e3);
|
|
3890
|
+
}
|
|
3891
|
+
} finally {
|
|
3892
|
+
child?.stdin?.destroy();
|
|
3893
|
+
child?.stdout?.destroy();
|
|
3894
|
+
this.child = void 0;
|
|
3895
|
+
this.buffer.clear();
|
|
3896
|
+
this.onclose?.();
|
|
3897
|
+
}
|
|
3898
|
+
});
|
|
3899
|
+
return this.closing;
|
|
3900
|
+
}
|
|
3901
|
+
signalGroup(signal) {
|
|
3902
|
+
const pid = this.child?.pid;
|
|
3903
|
+
if (!pid || pid <= 0)
|
|
3904
|
+
return false;
|
|
3905
|
+
try {
|
|
3906
|
+
process.kill(-pid, signal);
|
|
3907
|
+
return true;
|
|
3908
|
+
} catch (error) {
|
|
3909
|
+
if (error.code === "ESRCH")
|
|
3910
|
+
return false;
|
|
3911
|
+
throw error;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
receive(chunk) {
|
|
3915
|
+
try {
|
|
3916
|
+
this.buffer.append(chunk);
|
|
3917
|
+
} catch (error) {
|
|
3918
|
+
this.child?.stdout?.pause();
|
|
3919
|
+
this.report(error);
|
|
3920
|
+
void this.close().catch((failure) => this.report(failure));
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3923
|
+
while (true) {
|
|
3924
|
+
try {
|
|
3925
|
+
const message = this.buffer.readMessage();
|
|
3926
|
+
if (message === null)
|
|
3927
|
+
return;
|
|
3928
|
+
this.onmessage?.(message);
|
|
3929
|
+
} catch (error) {
|
|
3930
|
+
this.report(error);
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
report(error) {
|
|
3935
|
+
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
|
3936
|
+
}
|
|
3937
|
+
};
|
|
3938
|
+
|
|
3939
|
+
// ../router/dist/backends.js
|
|
3940
|
+
var IsolatingSchemaValidator = class {
|
|
3941
|
+
inner = new AjvJsonSchemaValidator();
|
|
3942
|
+
validators = /* @__PURE__ */ new WeakMap();
|
|
3943
|
+
getValidator(schema) {
|
|
3944
|
+
const cached = this.validators.get(schema);
|
|
3945
|
+
if (cached)
|
|
3946
|
+
return cached;
|
|
3947
|
+
let validator;
|
|
3948
|
+
try {
|
|
3949
|
+
validator = this.inner.getValidator(schema);
|
|
3950
|
+
} catch {
|
|
3951
|
+
validator = () => ({
|
|
3952
|
+
valid: false,
|
|
3953
|
+
data: void 0,
|
|
3954
|
+
errorMessage: "the tool declares an invalid output schema"
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
3957
|
+
this.validators.set(schema, validator);
|
|
3958
|
+
return validator;
|
|
3959
|
+
}
|
|
3960
|
+
};
|
|
3961
|
+
var DEFAULT_CALL_TIMEOUT_MS = 3e4;
|
|
3962
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 15e3;
|
|
3963
|
+
var DEFAULT_MAX_TOOLS = 1e4;
|
|
3964
|
+
var DEFAULT_CLOSE_TIMEOUT_MS = 2e3;
|
|
3965
|
+
async function withTimeout(promise, ms, label) {
|
|
3966
|
+
let timer;
|
|
3967
|
+
try {
|
|
3968
|
+
return await Promise.race([
|
|
3969
|
+
promise,
|
|
3970
|
+
new Promise((_, reject) => {
|
|
3971
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
3972
|
+
timer.unref?.();
|
|
3973
|
+
})
|
|
3974
|
+
]);
|
|
3975
|
+
} finally {
|
|
3976
|
+
if (timer)
|
|
3977
|
+
clearTimeout(timer);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
var BackendManager = class {
|
|
3981
|
+
callTimeoutMs;
|
|
3982
|
+
connectTimeoutMs;
|
|
3983
|
+
backends = /* @__PURE__ */ new Map();
|
|
3984
|
+
connecting = /* @__PURE__ */ new Set();
|
|
3985
|
+
connections = /* @__PURE__ */ new Set();
|
|
3986
|
+
closed = false;
|
|
3987
|
+
closing;
|
|
3988
|
+
maxTools;
|
|
3989
|
+
closeTimeoutMs;
|
|
3990
|
+
constructor(callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, options = {}) {
|
|
3991
|
+
this.callTimeoutMs = callTimeoutMs;
|
|
3992
|
+
this.connectTimeoutMs = connectTimeoutMs;
|
|
3993
|
+
this.maxTools = options.maxTools ?? DEFAULT_MAX_TOOLS;
|
|
3994
|
+
this.closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
|
|
3995
|
+
}
|
|
3996
|
+
async connect(config) {
|
|
3997
|
+
const name = stableBackendName(config.name);
|
|
3998
|
+
if (this.closed)
|
|
3999
|
+
throw new Error("backend manager is closed");
|
|
4000
|
+
if (this.backends.has(name) || this.connecting.has(name)) {
|
|
4001
|
+
throw new Error(`duplicate backend identity: ${name}`);
|
|
4002
|
+
}
|
|
4003
|
+
this.connecting.add(name);
|
|
4004
|
+
let connection;
|
|
4005
|
+
try {
|
|
4006
|
+
const validator = new IsolatingSchemaValidator();
|
|
4007
|
+
const client = new Client({ name: "roster-router", version: "0.0.1" }, { jsonSchemaValidator: validator });
|
|
4008
|
+
const transport = "transport" in config ? config.transport : new (process.platform === "win32" ? StdioClientTransport : ProcessGroupTransport)({
|
|
4009
|
+
command: config.command,
|
|
4010
|
+
args: config.args ?? [],
|
|
4011
|
+
// Only explicitly-configured env vars flow through; nothing is persisted or logged.
|
|
4012
|
+
env: config.env,
|
|
4013
|
+
stderr: "ignore"
|
|
4014
|
+
});
|
|
4015
|
+
if (transport instanceof StdioClientTransport) {
|
|
4016
|
+
const closeTransport = transport.close.bind(transport);
|
|
4017
|
+
let transportClosing;
|
|
4018
|
+
transport.close = () => {
|
|
4019
|
+
transportClosing ??= Promise.resolve().then(closeTransport);
|
|
4020
|
+
return transportClosing;
|
|
4021
|
+
};
|
|
4022
|
+
}
|
|
4023
|
+
connection = { client, transport, abort: new AbortController() };
|
|
4024
|
+
this.connections.add(connection);
|
|
4025
|
+
await withTimeout(client.connect(transport, { signal: connection.abort.signal }), this.connectTimeoutMs, "connect timeout");
|
|
4026
|
+
const tools = await withTimeout(this.fetchTools(name, client, connection.abort.signal), this.connectTimeoutMs, "listTools timeout");
|
|
4027
|
+
if (this.closed)
|
|
4028
|
+
throw new Error("backend manager is closed");
|
|
4029
|
+
this.backends.set(name, { name, client, tools, validator });
|
|
4030
|
+
return tools;
|
|
4031
|
+
} catch (err) {
|
|
4032
|
+
if (connection)
|
|
4033
|
+
await this.closeConnection(connection).catch(() => void 0);
|
|
4034
|
+
throw err;
|
|
4035
|
+
} finally {
|
|
4036
|
+
this.connecting.delete(name);
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
async fetchTools(source, client, signal) {
|
|
4040
|
+
const entries = [];
|
|
4041
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
4042
|
+
let cursor;
|
|
4043
|
+
do {
|
|
4044
|
+
const page = await client.listTools({ cursor }, { signal });
|
|
4045
|
+
for (const tool of page.tools) {
|
|
4046
|
+
const id = stableNamespacedId(source, tool.name);
|
|
4047
|
+
entries.push({
|
|
4048
|
+
id,
|
|
4049
|
+
kind: "tool",
|
|
4050
|
+
source,
|
|
4051
|
+
name: tool.name,
|
|
4052
|
+
description: tool.description ?? "",
|
|
4053
|
+
// Preserve title + annotations (incl. readOnlyHint/destructiveHint):
|
|
4054
|
+
// transparent mode must be a faithful passthrough, and clients that
|
|
4055
|
+
// gate confirmations on destructiveHint need it (audit D1).
|
|
4056
|
+
title: typeof tool.title === "string" ? tool.title : void 0,
|
|
4057
|
+
annotations: tool.annotations ?? void 0,
|
|
4058
|
+
inputSchema: tool.inputSchema ?? {
|
|
4059
|
+
type: "object"
|
|
4060
|
+
},
|
|
4061
|
+
outputSchema: tool.outputSchema,
|
|
4062
|
+
// `execution` (task-support hints) is part of the tool's declared
|
|
4063
|
+
// contract; a client that reads it to decide sync-vs-async must see it
|
|
4064
|
+
// through the proxy exactly as it would direct (R5-08).
|
|
4065
|
+
execution: tool.execution ?? void 0
|
|
4066
|
+
});
|
|
4067
|
+
if (entries.length > this.maxTools) {
|
|
4068
|
+
throw new Error(`backend exposes more than ${this.maxTools} tools`);
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
cursor = page.nextCursor;
|
|
4072
|
+
if (cursor) {
|
|
4073
|
+
if (seenCursors.has(cursor))
|
|
4074
|
+
throw new Error("tools pagination cursor repeated");
|
|
4075
|
+
seenCursors.add(cursor);
|
|
4076
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
4077
|
+
}
|
|
4078
|
+
} while (cursor);
|
|
4079
|
+
return entries;
|
|
4080
|
+
}
|
|
4081
|
+
/** Static snapshot of all backend tools, namespaced (client-compat rule: list never changes mid-session). */
|
|
4082
|
+
allTools() {
|
|
4083
|
+
return [...this.backends.values()].flatMap((b) => b.tools);
|
|
4084
|
+
}
|
|
4085
|
+
lookup(namespaced) {
|
|
4086
|
+
for (const backend of this.backends.values()) {
|
|
4087
|
+
const entry = backend.tools.find((t) => t.id === namespaced);
|
|
4088
|
+
if (entry)
|
|
4089
|
+
return { backend: backend.name, toolName: entry.name, entry };
|
|
4090
|
+
}
|
|
4091
|
+
return null;
|
|
4092
|
+
}
|
|
4093
|
+
async call(backendName, toolName, args, outputSchema) {
|
|
4094
|
+
const backend = this.backends.get(backendName);
|
|
4095
|
+
const started = Date.now();
|
|
4096
|
+
if (!backend) {
|
|
4097
|
+
return {
|
|
4098
|
+
result: null,
|
|
4099
|
+
evidence: { transportError: true, errorText: "unknown backend" },
|
|
4100
|
+
latencyMs: 0
|
|
4101
|
+
};
|
|
4102
|
+
}
|
|
4103
|
+
try {
|
|
4104
|
+
const result = await backend.client.callTool({ name: toolName, arguments: args ?? {} }, void 0, { timeout: this.callTimeoutMs });
|
|
4105
|
+
const schema = backend.tools.find((tool) => tool.name === toolName)?.outputSchema ?? outputSchema;
|
|
4106
|
+
const isError = result.isError === true;
|
|
4107
|
+
if (schema && !result.structuredContent && !isError) {
|
|
4108
|
+
throw new McpError(ErrorCode.InvalidRequest, `Tool ${toolName} has an output schema but did not return structured content`);
|
|
4109
|
+
}
|
|
4110
|
+
if (schema && result.structuredContent) {
|
|
4111
|
+
const validation = backend.validator.getValidator(schema)(result.structuredContent);
|
|
4112
|
+
if (!validation.valid) {
|
|
4113
|
+
throw new McpError(ErrorCode.InvalidParams, `Structured content does not match the tool's output schema: ${validation.errorMessage}`);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
const latencyMs = Date.now() - started;
|
|
4117
|
+
const evidence = isError ? { isError: true, errorText: extractErrorText(result) } : { outputSchemaViolation: violatesOutputSchema(result, schema) };
|
|
4118
|
+
return { result, evidence, latencyMs };
|
|
4119
|
+
} catch (err) {
|
|
4120
|
+
let error;
|
|
4121
|
+
if (err instanceof McpError) {
|
|
4122
|
+
const prefix = `MCP error ${err.code}: `;
|
|
4123
|
+
error = {
|
|
4124
|
+
code: err.code,
|
|
4125
|
+
message: err.message.startsWith(prefix) ? err.message.slice(prefix.length) : err.message,
|
|
4126
|
+
data: err.data
|
|
4127
|
+
};
|
|
4128
|
+
}
|
|
4129
|
+
return { result: null, evidence: errorToEvidence(err), latencyMs: Date.now() - started, error };
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
closeConnection(connection) {
|
|
4133
|
+
connection.closing ??= (async () => {
|
|
4134
|
+
connection.abort.abort();
|
|
4135
|
+
try {
|
|
4136
|
+
const closing = connection.client.close();
|
|
4137
|
+
if (connection.transport instanceof StdioClientTransport || connection.transport instanceof ProcessGroupTransport)
|
|
4138
|
+
await closing;
|
|
4139
|
+
else
|
|
4140
|
+
await withTimeout(closing, this.closeTimeoutMs, "close timeout");
|
|
4141
|
+
} finally {
|
|
4142
|
+
this.connections.delete(connection);
|
|
4143
|
+
}
|
|
4144
|
+
})();
|
|
4145
|
+
return connection.closing;
|
|
4146
|
+
}
|
|
4147
|
+
close() {
|
|
4148
|
+
this.closed = true;
|
|
4149
|
+
this.closing ??= Promise.allSettled([...this.connections].map((connection) => this.closeConnection(connection))).then(() => {
|
|
4150
|
+
this.backends.clear();
|
|
4151
|
+
});
|
|
4152
|
+
return this.closing;
|
|
4153
|
+
}
|
|
4154
|
+
};
|
|
4155
|
+
function errorToEvidence(err) {
|
|
4156
|
+
if (err instanceof McpError) {
|
|
4157
|
+
if (isSdkOutputValidationError(err)) {
|
|
4158
|
+
return {
|
|
4159
|
+
outputSchemaViolation: true,
|
|
4160
|
+
errorText: err.message,
|
|
4161
|
+
errorCode: err.code
|
|
4162
|
+
};
|
|
4163
|
+
}
|
|
4164
|
+
if (err.code === ErrorCode.RequestTimeout)
|
|
4165
|
+
return { timedOut: true, errorCode: err.code };
|
|
4166
|
+
if (err.code === ErrorCode.ConnectionClosed) {
|
|
4167
|
+
return { transportError: true, errorText: err.message, errorCode: err.code };
|
|
4168
|
+
}
|
|
4169
|
+
if (err.code === ErrorCode.InvalidParams) {
|
|
4170
|
+
return { inputValidationError: true, errorText: err.message, errorCode: err.code };
|
|
4171
|
+
}
|
|
4172
|
+
return { protocolError: true, errorText: err.message, errorCode: err.code };
|
|
4173
|
+
}
|
|
4174
|
+
return { transportError: true, errorText: err instanceof Error ? err.message : "" };
|
|
4175
|
+
}
|
|
4176
|
+
function isSdkOutputValidationError(err) {
|
|
4177
|
+
const detail = err.message.replace(/^MCP error -?\d+: /, "");
|
|
4178
|
+
return detail.startsWith("Structured content does not match the tool's output schema") || detail.startsWith("Failed to validate structured content") || detail.includes(" has an output schema but did not return structured content");
|
|
4179
|
+
}
|
|
4180
|
+
function extractErrorText(result) {
|
|
4181
|
+
const content = result.content;
|
|
4182
|
+
if (!Array.isArray(content))
|
|
4183
|
+
return "";
|
|
4184
|
+
return content.map((c) => c && typeof c === "object" && "text" in c ? String(c.text) : "").join(" ").slice(0, 500);
|
|
4185
|
+
}
|
|
4186
|
+
function violatesOutputSchema(result, outputSchema) {
|
|
4187
|
+
if (!outputSchema)
|
|
4188
|
+
return false;
|
|
4189
|
+
const required = outputSchema.required;
|
|
4190
|
+
if (!Array.isArray(required) || required.length === 0)
|
|
4191
|
+
return false;
|
|
4192
|
+
const structured = result.structuredContent;
|
|
4193
|
+
if (structured === void 0 || structured === null || typeof structured !== "object")
|
|
4194
|
+
return true;
|
|
4195
|
+
return required.some((key) => !(String(key) in structured));
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
// ../router/dist/cards.js
|
|
4199
|
+
var DESCRIPTION_LIMIT = 240;
|
|
4200
|
+
var MAX_ENUM_VALUES = 16;
|
|
4201
|
+
var MAX_PROPS = 50;
|
|
4202
|
+
function toCard(entry) {
|
|
4203
|
+
const card = {
|
|
4204
|
+
id: entry.id,
|
|
4205
|
+
kind: entry.kind,
|
|
4206
|
+
description: truncate(entry.description, DESCRIPTION_LIMIT)
|
|
4207
|
+
};
|
|
4208
|
+
if (entry.kind === "tool" && entry.inputSchema) {
|
|
4209
|
+
card.input = trimSchema(entry.inputSchema);
|
|
4210
|
+
}
|
|
4211
|
+
if (entry.kind === "skill") {
|
|
4212
|
+
card.note = "skill \u2014 call it to receive its full instructions and resources";
|
|
4213
|
+
}
|
|
4214
|
+
return card;
|
|
4215
|
+
}
|
|
4216
|
+
function trimSchema(schema) {
|
|
4217
|
+
const out = { type: schema.type ?? "object" };
|
|
4218
|
+
const props = schema.properties;
|
|
4219
|
+
if (props && typeof props === "object") {
|
|
4220
|
+
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
|
|
4221
|
+
const entries = Object.entries(props);
|
|
4222
|
+
const ordered = [
|
|
4223
|
+
...entries.filter(([k]) => required.includes(k)),
|
|
4224
|
+
...entries.filter(([k]) => !required.includes(k))
|
|
4225
|
+
];
|
|
4226
|
+
const trimmed = {};
|
|
4227
|
+
for (const [key, value] of ordered.slice(0, MAX_PROPS)) {
|
|
4228
|
+
if (value && typeof value === "object") {
|
|
4229
|
+
const v = value;
|
|
4230
|
+
trimmed[key] = { type: v.type ?? "any", ...trimEnum(v.enum) };
|
|
4231
|
+
} else {
|
|
4232
|
+
trimmed[key] = { type: "any" };
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
out.properties = trimmed;
|
|
4236
|
+
if (ordered.length > MAX_PROPS)
|
|
4237
|
+
out["x-trimmed-properties"] = ordered.length - MAX_PROPS;
|
|
4238
|
+
}
|
|
4239
|
+
if (Array.isArray(schema.required) && schema.required.length > 0) {
|
|
4240
|
+
out.required = schema.required;
|
|
4241
|
+
}
|
|
4242
|
+
return out;
|
|
4243
|
+
}
|
|
4244
|
+
function trimEnum(enumValue) {
|
|
4245
|
+
if (!Array.isArray(enumValue))
|
|
4246
|
+
return {};
|
|
4247
|
+
if (enumValue.length <= MAX_ENUM_VALUES)
|
|
4248
|
+
return { enum: enumValue };
|
|
4249
|
+
return { enum: enumValue.slice(0, MAX_ENUM_VALUES), "x-enum-truncated": enumValue.length - MAX_ENUM_VALUES };
|
|
4250
|
+
}
|
|
4251
|
+
function truncate(text, limit) {
|
|
4252
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}\u2026`;
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
// ../router/dist/rosterServer.js
|
|
4256
|
+
import { randomUUID } from "node:crypto";
|
|
4257
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4258
|
+
import { CallToolRequestSchema, ErrorCode as ErrorCode2, ListToolsRequestSchema, McpError as McpError2 } from "@modelcontextprotocol/sdk/types.js";
|
|
4259
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4260
|
+
var SUGGESTION_CLASSES = /* @__PURE__ */ new Set([
|
|
4261
|
+
"hard_fail:transport",
|
|
4262
|
+
"tool_fail:timeout",
|
|
4263
|
+
"tool_fail:internal"
|
|
4264
|
+
]);
|
|
4265
|
+
var DRAFT_TOOL = {
|
|
4266
|
+
name: "draft",
|
|
4267
|
+
description: "Describe the next thing you need to do. Returns the best \u2264K capabilities (the starting five) for it \u2014 tools and skills. Call again whenever your need changes.",
|
|
4268
|
+
inputSchema: {
|
|
4269
|
+
type: "object",
|
|
4270
|
+
properties: {
|
|
4271
|
+
need: { type: "string", description: "plain-language description of the immediate task" },
|
|
4272
|
+
k: { type: "integer", minimum: 1, maximum: 10, default: 5 }
|
|
4273
|
+
},
|
|
4274
|
+
required: ["need"]
|
|
4275
|
+
}
|
|
4276
|
+
};
|
|
4277
|
+
var CALL_TOOL = {
|
|
4278
|
+
name: "call",
|
|
4279
|
+
description: "Invoke a drafted capability by its full id. Tools execute; skills return their instructions.",
|
|
4280
|
+
inputSchema: {
|
|
4281
|
+
type: "object",
|
|
4282
|
+
properties: {
|
|
4283
|
+
tool: { type: "string", description: "namespaced id, e.g. github__create_issue" },
|
|
4284
|
+
args: { type: "object" },
|
|
4285
|
+
draft_id: { type: "string", description: "the draft this call belongs to (from draft's response)" }
|
|
4286
|
+
},
|
|
4287
|
+
required: ["tool"]
|
|
4288
|
+
}
|
|
4289
|
+
};
|
|
4290
|
+
var RosterServer = class {
|
|
4291
|
+
server;
|
|
4292
|
+
mode;
|
|
4293
|
+
manager;
|
|
4294
|
+
store;
|
|
4295
|
+
skills;
|
|
4296
|
+
embedNeed;
|
|
4297
|
+
defaultK;
|
|
4298
|
+
sessionId;
|
|
4299
|
+
/** Recent drafts by id — parallel draft/call pairs must not cross-attribute. */
|
|
4300
|
+
drafts = /* @__PURE__ */ new Map();
|
|
4301
|
+
draftCounter = 0;
|
|
4302
|
+
constructor(opts) {
|
|
4303
|
+
this.mode = opts.mode;
|
|
4304
|
+
this.manager = opts.manager;
|
|
4305
|
+
this.store = opts.store;
|
|
4306
|
+
this.embedNeed = opts.embedNeed;
|
|
4307
|
+
this.defaultK = opts.defaultK ?? 5;
|
|
4308
|
+
this.sessionId = opts.sessionId ?? randomUUID();
|
|
4309
|
+
const servableSkills = (opts.skills ?? []).filter((s) => opts.allowReviewSkills || trustScan(s).status === "ok");
|
|
4310
|
+
this.skills = new Map(servableSkills.map((s) => [skillToCapabilityEntry(s).id, s]));
|
|
4311
|
+
this.server = new Server({ name: "roster", version: "0.0.1" }, { capabilities: { tools: {} } });
|
|
4312
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
4313
|
+
await opts.ready;
|
|
4314
|
+
return { tools: this.listTools() };
|
|
4315
|
+
});
|
|
4316
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4317
|
+
await opts.ready;
|
|
4318
|
+
if (this.mode === "transparent") {
|
|
4319
|
+
return this.handleTransparentCall(request.params.name, request.params.arguments);
|
|
4320
|
+
}
|
|
4321
|
+
if (request.params.name === "draft") {
|
|
4322
|
+
return this.handleDraft(request.params.arguments);
|
|
4323
|
+
}
|
|
4324
|
+
if (request.params.name === "call") {
|
|
4325
|
+
return this.handleFiveCall(request.params.arguments);
|
|
4326
|
+
}
|
|
4327
|
+
throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4330
|
+
/**
|
|
4331
|
+
* Index everything the router fronts (drift detection) and prune ghosts.
|
|
4332
|
+
* Pass the sources that are configured but unreachable this boot — their
|
|
4333
|
+
* capabilities are preserved, not pruned (transient outage ≠ removal).
|
|
4334
|
+
*/
|
|
4335
|
+
syncCapabilities(unavailableSources = /* @__PURE__ */ new Set(), keepSeenSince) {
|
|
4336
|
+
const entries = [...this.sessionCapabilities().values()];
|
|
4337
|
+
this.store.upsertCapabilities(entries);
|
|
4338
|
+
this.store.pruneMissing(new Set(entries.map((e) => e.id)), unavailableSources, {
|
|
4339
|
+
keepSeenSince
|
|
4340
|
+
});
|
|
4341
|
+
}
|
|
4342
|
+
/** Skills that survived trust filtering and identity de-duplication. */
|
|
4343
|
+
servedSkillCount() {
|
|
4344
|
+
return this.skills.size;
|
|
4345
|
+
}
|
|
4346
|
+
sessionCapabilities() {
|
|
4347
|
+
return new Map([
|
|
4348
|
+
...this.manager.allTools(),
|
|
4349
|
+
...[...this.skills.entries()].map(([id, skill]) => skillToCapabilityEntry(skill, id))
|
|
4350
|
+
].map((entry) => [entry.id, entry]));
|
|
4351
|
+
}
|
|
4352
|
+
listTools() {
|
|
4353
|
+
if (this.mode === "five") {
|
|
4354
|
+
return [DRAFT_TOOL, CALL_TOOL];
|
|
4355
|
+
}
|
|
4356
|
+
return this.manager.allTools().map((entry) => ({
|
|
4357
|
+
name: entry.id,
|
|
4358
|
+
description: entry.description,
|
|
4359
|
+
// Faithful passthrough: title + annotations (incl. destructiveHint, D1) and
|
|
4360
|
+
// execution hints (R5-08) all survive — a proxied tool must be
|
|
4361
|
+
// indistinguishable from the direct one.
|
|
4362
|
+
...entry.title ? { title: entry.title } : {},
|
|
4363
|
+
...entry.annotations ? { annotations: entry.annotations } : {},
|
|
4364
|
+
inputSchema: entry.inputSchema ?? { type: "object" },
|
|
4365
|
+
...entry.outputSchema ? { outputSchema: entry.outputSchema } : {},
|
|
4366
|
+
...entry.execution ? { execution: entry.execution } : {}
|
|
4367
|
+
}));
|
|
4368
|
+
}
|
|
4369
|
+
// ── transparent mode ─────────────────────────────────────────────────────
|
|
4370
|
+
async handleTransparentCall(namespacedName, args) {
|
|
4371
|
+
const target = this.manager.lookup(namespacedName);
|
|
4372
|
+
if (!target) {
|
|
4373
|
+
throw new McpError2(ErrorCode2.InvalidParams, `Unknown tool: ${namespacedName}`);
|
|
4374
|
+
}
|
|
4375
|
+
const outcome = await this.manager.call(target.backend, target.toolName, args, target.entry.outputSchema);
|
|
4376
|
+
this.record(namespacedName, target.backend, outcome.evidence, outcome.latencyMs, args, null);
|
|
4377
|
+
if (outcome.result)
|
|
4378
|
+
return outcome.result;
|
|
4379
|
+
if (outcome.error) {
|
|
4380
|
+
const { code, message, data } = outcome.error;
|
|
4381
|
+
const error = new McpError2(code, message, data);
|
|
4382
|
+
error.message = message;
|
|
4383
|
+
throw error;
|
|
4384
|
+
}
|
|
4385
|
+
if (outcome.evidence.inputValidationError) {
|
|
4386
|
+
throw new McpError2(ErrorCode2.InvalidParams, outcome.evidence.errorText ?? "invalid params");
|
|
4387
|
+
}
|
|
4388
|
+
if (outcome.evidence.protocolError) {
|
|
4389
|
+
throw new McpError2(outcome.evidence.errorCode ?? ErrorCode2.InternalError, outcome.evidence.errorText ?? "backend protocol error");
|
|
4390
|
+
}
|
|
4391
|
+
throw new McpError2(outcome.evidence.errorCode ?? ErrorCode2.InternalError, describeFailure(outcome.evidence));
|
|
4392
|
+
}
|
|
4393
|
+
// ── five mode ────────────────────────────────────────────────────────────
|
|
4394
|
+
async handleDraft(args) {
|
|
4395
|
+
const need = typeof args?.need === "string" ? args.need.trim() : "";
|
|
4396
|
+
if (need === "") {
|
|
4397
|
+
throw new McpError2(ErrorCode2.InvalidParams, "draft requires a non-empty `need`");
|
|
4398
|
+
}
|
|
4399
|
+
const k = clampK(args?.k ?? this.defaultK);
|
|
4400
|
+
const needHash = hashNeed(need);
|
|
4401
|
+
let needVec = null;
|
|
4402
|
+
if (this.embedNeed) {
|
|
4403
|
+
try {
|
|
4404
|
+
needVec = await this.embedNeed(need);
|
|
4405
|
+
if (needVec)
|
|
4406
|
+
this.store.storeNeedVec(needHash, needVec);
|
|
4407
|
+
} catch {
|
|
4408
|
+
needVec = null;
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
const sessionEntries = this.sessionCapabilities();
|
|
4412
|
+
const candidates = this.store.draftCandidates(need, k, needVec, new Set(sessionEntries.keys()));
|
|
4413
|
+
const draftId = `d${++this.draftCounter}`;
|
|
4414
|
+
this.drafts.set(draftId, { need, needHash, rankedIds: candidates.map((c) => c.entry.id) });
|
|
4415
|
+
if (this.drafts.size > 16) {
|
|
4416
|
+
const oldest = this.drafts.keys().next().value;
|
|
4417
|
+
if (oldest)
|
|
4418
|
+
this.drafts.delete(oldest);
|
|
4419
|
+
}
|
|
4420
|
+
const starters = candidates.map((c) => toCard(sessionEntries.get(c.entry.id)));
|
|
4421
|
+
return {
|
|
4422
|
+
content: [
|
|
4423
|
+
{
|
|
4424
|
+
type: "text",
|
|
4425
|
+
// Compact, not pretty-printed: indentation added +46–53% marginal token
|
|
4426
|
+
// cost on BPE tokenizers (lab-measured) — an own-goal against the very
|
|
4427
|
+
// token-savings pitch this draft exists to deliver (audit D9a).
|
|
4428
|
+
text: JSON.stringify({
|
|
4429
|
+
need,
|
|
4430
|
+
draft_id: draftId,
|
|
4431
|
+
starters,
|
|
4432
|
+
usage: "Invoke with call({tool: <id>, args: {\u2026}, draft_id}). Re-draft when your need changes."
|
|
4433
|
+
})
|
|
4434
|
+
}
|
|
4435
|
+
]
|
|
4436
|
+
};
|
|
4437
|
+
}
|
|
4438
|
+
async handleFiveCall(args) {
|
|
4439
|
+
const id = typeof args?.tool === "string" ? args.tool : "";
|
|
4440
|
+
const callArgs = args?.args;
|
|
4441
|
+
if (id === "")
|
|
4442
|
+
throw new McpError2(ErrorCode2.InvalidParams, "call requires `tool`");
|
|
4443
|
+
if (callArgs !== void 0 && (callArgs === null || typeof callArgs !== "object" || Array.isArray(callArgs))) {
|
|
4444
|
+
throw new McpError2(ErrorCode2.InvalidParams, "call `args` must be an object");
|
|
4445
|
+
}
|
|
4446
|
+
const draft = args?.draft_id ? this.drafts.get(args.draft_id) ?? null : null;
|
|
4447
|
+
const skill = id.startsWith("skill__") ? this.skills.get(id) : void 0;
|
|
4448
|
+
if (skill) {
|
|
4449
|
+
this.record(id, "skill", {}, 0, callArgs, draft?.needHash ?? null, true);
|
|
4450
|
+
return {
|
|
4451
|
+
content: [
|
|
4452
|
+
{ type: "text", text: JSON.stringify(skillInvocationResult(skill), null, 2) }
|
|
4453
|
+
]
|
|
4454
|
+
};
|
|
4455
|
+
}
|
|
4456
|
+
const target = this.manager.lookup(id);
|
|
4457
|
+
if (!target)
|
|
4458
|
+
throw new McpError2(ErrorCode2.InvalidParams, `Unknown capability: ${id}`);
|
|
4459
|
+
const outcome = await this.manager.call(target.backend, target.toolName, callArgs, target.entry.outputSchema);
|
|
4460
|
+
const cls = this.record(id, target.backend, outcome.evidence, outcome.latencyMs, callArgs, draft?.needHash ?? null);
|
|
4461
|
+
const base = outcome.result ?? errorResult(describeFailure(outcome.evidence));
|
|
4462
|
+
if (base.isError === true && SUGGESTION_CLASSES.has(cls)) {
|
|
4463
|
+
const suggestion = this.sixthManSuggestion(draft, id, callArgs);
|
|
4464
|
+
if (suggestion) {
|
|
4465
|
+
try {
|
|
4466
|
+
this.store.recordSuggestion(this.sessionId, id, suggestion.tool);
|
|
4467
|
+
} catch {
|
|
4468
|
+
}
|
|
4469
|
+
const content = Array.isArray(base.content) ? [...base.content] : [];
|
|
4470
|
+
content.push({
|
|
4471
|
+
type: "text",
|
|
4472
|
+
text: JSON.stringify({ _roster: { suggested_alternate: suggestion } })
|
|
4473
|
+
});
|
|
4474
|
+
return { ...base, content };
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
return base;
|
|
4478
|
+
}
|
|
4479
|
+
/**
|
|
4480
|
+
* Sixth Man — SUGGEST-ONLY (owner decision 2026-07-04). Roster never
|
|
4481
|
+
* auto-fires a second tool; the agent decides. args_compatible tells it
|
|
4482
|
+
* whether its args validate against the alternate's schema as-is.
|
|
4483
|
+
*/
|
|
4484
|
+
sixthManSuggestion(draft, failedId, args) {
|
|
4485
|
+
if (!draft)
|
|
4486
|
+
return null;
|
|
4487
|
+
const failedSource = parseNamespacedId(failedId)?.source;
|
|
4488
|
+
for (const candidateId of draft.rankedIds) {
|
|
4489
|
+
if (candidateId === failedId)
|
|
4490
|
+
continue;
|
|
4491
|
+
if (parseNamespacedId(candidateId)?.source === failedSource)
|
|
4492
|
+
continue;
|
|
4493
|
+
if (this.skills.has(candidateId))
|
|
4494
|
+
continue;
|
|
4495
|
+
const found = this.manager.lookup(candidateId);
|
|
4496
|
+
if (!found)
|
|
4497
|
+
continue;
|
|
4498
|
+
const entry = found.entry;
|
|
4499
|
+
let compatible = false;
|
|
4500
|
+
try {
|
|
4501
|
+
if (entry.inputSchema) {
|
|
4502
|
+
compatible = argsMatchSchema(entry.inputSchema, args ?? {});
|
|
4503
|
+
}
|
|
4504
|
+
} catch {
|
|
4505
|
+
compatible = false;
|
|
4506
|
+
}
|
|
4507
|
+
return {
|
|
4508
|
+
tool: candidateId,
|
|
4509
|
+
reason: `the bench suggests ${candidateId} for the same need ("${draft.need}")`,
|
|
4510
|
+
args_compatible: compatible
|
|
4511
|
+
};
|
|
4512
|
+
}
|
|
4513
|
+
return null;
|
|
4514
|
+
}
|
|
4515
|
+
// ── shared ───────────────────────────────────────────────────────────────
|
|
4516
|
+
record(capability, source, evidence, latencyMs, args, needHash, explored = false) {
|
|
4517
|
+
const outcomeClass = classifyOutcome(evidence);
|
|
4518
|
+
const argsHash = hashArgs(args);
|
|
4519
|
+
try {
|
|
4520
|
+
this.store.recordOutcome({
|
|
4521
|
+
session: this.sessionId,
|
|
4522
|
+
source,
|
|
4523
|
+
capability,
|
|
4524
|
+
outcomeClass,
|
|
4525
|
+
latencyMs,
|
|
4526
|
+
argsHash,
|
|
4527
|
+
needHash,
|
|
4528
|
+
explored
|
|
4529
|
+
});
|
|
4530
|
+
} catch {
|
|
4531
|
+
}
|
|
4532
|
+
return outcomeClass;
|
|
4533
|
+
}
|
|
4534
|
+
};
|
|
4535
|
+
function argsMatchSchema(schema, args) {
|
|
4536
|
+
const ajv = new Ajv2020({ strict: false });
|
|
4537
|
+
return ajv.validate(stripDialect(schema), args);
|
|
4538
|
+
}
|
|
4539
|
+
function stripDialect(value) {
|
|
4540
|
+
if (Array.isArray(value))
|
|
4541
|
+
return value.map(stripDialect);
|
|
4542
|
+
if (value && typeof value === "object") {
|
|
4543
|
+
const out = {};
|
|
4544
|
+
for (const [k, v] of Object.entries(value)) {
|
|
4545
|
+
if (k === "$schema")
|
|
4546
|
+
continue;
|
|
4547
|
+
out[k] = stripDialect(v);
|
|
4548
|
+
}
|
|
4549
|
+
return out;
|
|
4550
|
+
}
|
|
4551
|
+
return value;
|
|
4552
|
+
}
|
|
4553
|
+
function clampK(k) {
|
|
4554
|
+
const n = typeof k === "number" && Number.isFinite(k) ? Math.round(k) : 5;
|
|
4555
|
+
return Math.max(1, Math.min(10, n));
|
|
4556
|
+
}
|
|
4557
|
+
function errorResult(message) {
|
|
4558
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
4559
|
+
}
|
|
4560
|
+
function describeFailure(evidence) {
|
|
4561
|
+
if (evidence.timedOut)
|
|
4562
|
+
return "call timed out";
|
|
4563
|
+
if (evidence.transportError)
|
|
4564
|
+
return `backend unreachable${evidence.errorText ? `: ${evidence.errorText}` : ""}`;
|
|
4565
|
+
return evidence.errorText || "call failed";
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
// src/dense.ts
|
|
4569
|
+
import crossSpawn from "cross-spawn";
|
|
4570
|
+
import fs13 from "node:fs";
|
|
4571
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
4572
|
+
import path14 from "node:path";
|
|
4573
|
+
function denseRuntimeDir2() {
|
|
4574
|
+
return path14.join(rosterHome(), "runtime");
|
|
4575
|
+
}
|
|
4576
|
+
function denseModulesDir() {
|
|
4577
|
+
return path14.join(denseRuntimeDir2(), "node_modules");
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4580
|
+
// src/shutdown.ts
|
|
4581
|
+
function installGracefulShutdown(targets, options = {}) {
|
|
4582
|
+
const controller = new AbortController();
|
|
4583
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
4584
|
+
const emit = options.onMessage ?? ((message) => void process.stderr.write(message));
|
|
4585
|
+
const { manager, store, server } = targets;
|
|
4586
|
+
let started = false;
|
|
4587
|
+
const signals = ["SIGINT", "SIGTERM"];
|
|
4588
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
4589
|
+
const onEof = () => {
|
|
4590
|
+
void shutdown("client disconnected (stdin EOF)", 0);
|
|
4591
|
+
};
|
|
4592
|
+
const shutdown = async (reason, exitCode) => {
|
|
4593
|
+
if (started) return;
|
|
4594
|
+
started = true;
|
|
4595
|
+
controller.abort();
|
|
4596
|
+
process.stdin.removeListener("end", onEof);
|
|
4597
|
+
process.stdin.removeListener("close", onEof);
|
|
4598
|
+
emit(`roster: shutting down (${reason})
|
|
4599
|
+
`);
|
|
4600
|
+
try {
|
|
4601
|
+
await manager.close();
|
|
4602
|
+
} catch {
|
|
4603
|
+
}
|
|
4604
|
+
try {
|
|
4605
|
+
store.close();
|
|
4606
|
+
} catch {
|
|
4607
|
+
}
|
|
4608
|
+
for (const [sig, handler] of handlers) process.removeListener(sig, handler);
|
|
4609
|
+
exit(exitCode);
|
|
4610
|
+
};
|
|
4611
|
+
for (const sig of signals) {
|
|
4612
|
+
const handler = () => void shutdown(sig, sig === "SIGINT" ? 130 : 143);
|
|
4613
|
+
handlers.set(sig, handler);
|
|
4614
|
+
process.on(sig, handler);
|
|
4615
|
+
}
|
|
4616
|
+
process.stdin.on("end", onEof);
|
|
4617
|
+
process.stdin.on("close", onEof);
|
|
4618
|
+
server.onclose = () => {
|
|
4619
|
+
void shutdown("transport closed", 0);
|
|
4620
|
+
};
|
|
4621
|
+
return controller.signal;
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
// src/serve.ts
|
|
4625
|
+
async function serve(modeOverride) {
|
|
4626
|
+
const bootStarted = Date.now();
|
|
4627
|
+
setDenseRuntimeDir(denseModulesDir());
|
|
4628
|
+
const config = loadConfig();
|
|
4629
|
+
const mode = modeOverride ?? config.mode;
|
|
4630
|
+
const store = new CoachStore(openCoachDb(coachDbPath()));
|
|
4631
|
+
const manager = new BackendManager();
|
|
4632
|
+
const unavailable = /* @__PURE__ */ new Set();
|
|
4633
|
+
const scannedSkills = scanSkillSources([
|
|
4634
|
+
...config.skillSources,
|
|
4635
|
+
...defaultSkillSources({ home: homeDir() })
|
|
4636
|
+
]);
|
|
4637
|
+
const allowReview = process.env.ROSTER_ALLOW_REVIEW_SKILLS === "1";
|
|
4638
|
+
const skills = scannedSkills;
|
|
4639
|
+
for (const skill of scannedSkills) {
|
|
4640
|
+
const trust = trustScan(skill);
|
|
4641
|
+
if (trust.status !== "review") continue;
|
|
4642
|
+
const rules = [...new Set(trust.findings.map((x) => x.rule))].join(", ");
|
|
4643
|
+
process.stderr.write(
|
|
4644
|
+
`roster: ${allowReview ? "SERVING review-flagged" : "WITHHELD review-flagged"} skill "${skill.slug}" [${rules}]${allowReview ? " (ROSTER_ALLOW_REVIEW_SKILLS=1)" : " \u2014 set ROSTER_ALLOW_REVIEW_SKILLS=1 to serve it after reviewing"}
|
|
4645
|
+
`
|
|
4646
|
+
);
|
|
4647
|
+
}
|
|
4648
|
+
let embedNeed;
|
|
4649
|
+
if (config.embeddings === "auto" && !process.env.ROSTER_NO_FETCH) {
|
|
4650
|
+
embedNeed = makeLazyEmbedder(store);
|
|
4651
|
+
}
|
|
4652
|
+
let markReady;
|
|
4653
|
+
const ready = new Promise((resolve) => {
|
|
4654
|
+
markReady = resolve;
|
|
4655
|
+
});
|
|
4656
|
+
const roster = new RosterServer({ mode, manager, store, skills, embedNeed, allowReviewSkills: allowReview, ready });
|
|
4657
|
+
const transport = new StdioServerTransport();
|
|
4658
|
+
const shutdown = installGracefulShutdown({ manager, store, server: roster.server });
|
|
4659
|
+
await roster.server.connect(transport);
|
|
4660
|
+
for (const [name, entry] of Object.entries(config.servers)) {
|
|
4661
|
+
if (shutdown.aborted) return;
|
|
4662
|
+
if (!entry.command) {
|
|
4663
|
+
process.stderr.write(`roster: skipping "${name}" (url backends land post-launch; stdio only for now)
|
|
4664
|
+
`);
|
|
4665
|
+
unavailable.add(stableBackendName(name));
|
|
4666
|
+
continue;
|
|
4667
|
+
}
|
|
4668
|
+
try {
|
|
4669
|
+
await manager.connect({ name, command: entry.command, args: entry.args, env: entry.env });
|
|
4670
|
+
} catch (err) {
|
|
4671
|
+
if (shutdown.aborted) return;
|
|
4672
|
+
unavailable.add(stableBackendName(name));
|
|
4673
|
+
process.stderr.write(
|
|
4674
|
+
`roster: backend "${name}" failed to connect (its learned state is preserved): ${err instanceof Error ? err.message : err}
|
|
4675
|
+
`
|
|
4676
|
+
);
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
if (shutdown.aborted) return;
|
|
4680
|
+
try {
|
|
4681
|
+
roster.syncCapabilities(unavailable, bootStarted);
|
|
4682
|
+
} catch (err) {
|
|
4683
|
+
process.stderr.write(
|
|
4684
|
+
`roster: capability sync failed (serving with existing index): ${err instanceof Error ? err.message : err}
|
|
4685
|
+
`
|
|
4686
|
+
);
|
|
4687
|
+
}
|
|
4688
|
+
try {
|
|
4689
|
+
const maint = store.runMaintenanceIfDue();
|
|
4690
|
+
if (maint.ran && maint.oats) {
|
|
4691
|
+
process.stderr.write(`roster: refreshed routing (${maint.oats.adjusted} tools tuned from your outcomes)
|
|
4692
|
+
`);
|
|
4693
|
+
}
|
|
4694
|
+
} catch (err) {
|
|
4695
|
+
process.stderr.write(`roster: maintenance skipped: ${err instanceof Error ? err.message : err}
|
|
4696
|
+
`);
|
|
4697
|
+
}
|
|
4698
|
+
markReady();
|
|
4699
|
+
process.stderr.write(
|
|
4700
|
+
`roster: serving ${manager.allTools().length} tool(s) + ${roster.servedSkillCount()} skill(s) in ${mode} mode
|
|
4701
|
+
`
|
|
4702
|
+
);
|
|
4703
|
+
}
|
|
4704
|
+
var WARMUP_MAX_ATTEMPTS = 3;
|
|
4705
|
+
var WARMUP_RETRY_BACKOFF_MS = 6e4;
|
|
4706
|
+
function makeLazyEmbedder(store) {
|
|
4707
|
+
let provider = null;
|
|
4708
|
+
let warm = false;
|
|
4709
|
+
let warming = null;
|
|
4710
|
+
let attempts = 0;
|
|
4711
|
+
let nextRetryAt = 0;
|
|
4712
|
+
const warmup = async () => {
|
|
4713
|
+
if (!await TransformersEmbeddings.isAvailable()) {
|
|
4714
|
+
attempts = WARMUP_MAX_ATTEMPTS;
|
|
4715
|
+
return;
|
|
4716
|
+
}
|
|
4717
|
+
provider ??= new TransformersEmbeddings();
|
|
4718
|
+
await provider.embed(["roster warmup"]);
|
|
4719
|
+
const modelId = provider.modelId;
|
|
4720
|
+
store.ensureEmbeddingModel(modelId);
|
|
4721
|
+
const alreadyEmbedded = store.vecCapabilityIds();
|
|
4722
|
+
const entries = store.listCapabilities({ includeQuarantined: true }).filter((e) => !alreadyEmbedded.has(e.id));
|
|
4723
|
+
const BATCH = 16;
|
|
4724
|
+
for (let i = 0; i < entries.length; i += BATCH) {
|
|
4725
|
+
const batch = entries.slice(i, i + BATCH);
|
|
4726
|
+
const texts = batch.map((e) => `${e.name}
|
|
4727
|
+
${e.description}
|
|
4728
|
+
${e.body ?? ""}`.slice(0, 2e3));
|
|
4729
|
+
const vecs = await provider.embed(texts, "document");
|
|
4730
|
+
batch.forEach((entry, j) => {
|
|
4731
|
+
const vec = vecs[j];
|
|
4732
|
+
if (vec) {
|
|
4733
|
+
store.storeBaseVec(entry.id, vec, Date.now(), {
|
|
4734
|
+
defHash: defHash(entry),
|
|
4735
|
+
modelId
|
|
4736
|
+
});
|
|
4737
|
+
}
|
|
4738
|
+
});
|
|
4739
|
+
}
|
|
4740
|
+
warm = true;
|
|
4741
|
+
};
|
|
4742
|
+
return async (need) => {
|
|
4743
|
+
if (!warm) {
|
|
4744
|
+
if (warming === null && attempts < WARMUP_MAX_ATTEMPTS && Date.now() >= nextRetryAt) {
|
|
4745
|
+
attempts += 1;
|
|
4746
|
+
warming = warmup().catch(() => {
|
|
4747
|
+
nextRetryAt = Date.now() + WARMUP_RETRY_BACKOFF_MS;
|
|
4748
|
+
}).finally(() => {
|
|
4749
|
+
if (!warm) warming = null;
|
|
4750
|
+
});
|
|
4751
|
+
}
|
|
4752
|
+
return null;
|
|
4753
|
+
}
|
|
4754
|
+
if (!provider) return null;
|
|
4755
|
+
const [vec] = await provider.embed([need], "query");
|
|
4756
|
+
return vec ?? null;
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
export {
|
|
4760
|
+
CLIENTS,
|
|
4761
|
+
WRITE_CLIENTS,
|
|
4762
|
+
buildReceipt,
|
|
4763
|
+
defaultConfig,
|
|
4764
|
+
discoverClients,
|
|
4765
|
+
ejectClient,
|
|
4766
|
+
init,
|
|
4767
|
+
loadConfig,
|
|
4768
|
+
mergeServers,
|
|
4769
|
+
parseJsonc,
|
|
4770
|
+
renderReceipt,
|
|
4771
|
+
saveConfig,
|
|
4772
|
+
saveReceipt,
|
|
4773
|
+
serve,
|
|
4774
|
+
serverIdentity,
|
|
4775
|
+
syncClient,
|
|
4776
|
+
updateConfig,
|
|
4777
|
+
withFileLockSync
|
|
4778
|
+
};
|