@xagents_in/unmask 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -2
- package/dist/cli/hosted.mjs +251 -0
- package/dist/cli/main.mjs +210 -0
- package/dist/core/acquire.mjs +283 -0
- package/dist/core/archive.mjs +485 -0
- package/dist/core/compare.mjs +230 -0
- package/dist/core/manifests.mjs +548 -0
- package/dist/core/online.mjs +310 -0
- package/dist/core/pipeline.mjs +345 -0
- package/dist/core/policy.mjs +92 -0
- package/dist/core/report.mjs +506 -0
- package/dist/core/resolve.mjs +186 -0
- package/dist/core/source.mjs +106 -0
- package/dist/core/syntax.mjs +340 -0
- package/dist/model/client.mjs +1257 -0
- package/dist/model/config.mjs +90 -0
- package/dist/server/app.mjs +299 -0
- package/dist/server/main.mjs +146 -0
- package/dist/server/queue.mjs +177 -0
- package/dist/server/runner.mjs +280 -0
- package/dist/server/store.mjs +105 -0
- package/dist/server/worker.mjs +57 -0
- package/dist/version.json +1 -0
- package/dist/viewer/render.mjs +255 -0
- package/dist/viewer/shell.mjs +73 -0
- package/package.json +39 -4
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export async function modelClientFromEnv(env = process.env) {
|
|
2
|
+
if (env.UNMASK_MODEL === undefined || env.UNMASK_MODEL === "off")
|
|
3
|
+
return undefined;
|
|
4
|
+
if (
|
|
5
|
+
env.UNMASK_MODEL !== "on" ||
|
|
6
|
+
(env.UNMASK_MODEL_QUOTA_VERIFIED !== undefined &&
|
|
7
|
+
!["true", "false"].includes(env.UNMASK_MODEL_QUOTA_VERIFIED))
|
|
8
|
+
)
|
|
9
|
+
throw new TypeError("Invalid model configuration");
|
|
10
|
+
const preferred = env.GEMINI_API_KEY;
|
|
11
|
+
const alias = env.UNMASK_MODEL_KEY;
|
|
12
|
+
if (preferred !== undefined && alias !== undefined && preferred !== alias)
|
|
13
|
+
throw new TypeError("Conflicting model key configuration");
|
|
14
|
+
const apiKey = preferred ?? alias;
|
|
15
|
+
if (env.UNMASK_MODEL_QUOTA_VERIFIED === "false" && apiKey === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (
|
|
18
|
+
typeof apiKey !== "string" ||
|
|
19
|
+
apiKey.length < 1 ||
|
|
20
|
+
apiKey.length > 4096 ||
|
|
21
|
+
/[\s\x00-\x1f\x7f]/u.test(apiKey)
|
|
22
|
+
)
|
|
23
|
+
throw new TypeError("Invalid model key configuration");
|
|
24
|
+
const quota = (name, fallback) => {
|
|
25
|
+
const value = env[name] ?? fallback;
|
|
26
|
+
if (
|
|
27
|
+
typeof value !== "string" ||
|
|
28
|
+
!/^[1-9][0-9]*$/.test(value) ||
|
|
29
|
+
!Number.isSafeInteger(Number(value))
|
|
30
|
+
)
|
|
31
|
+
throw new TypeError("Invalid model quota configuration");
|
|
32
|
+
return Number(value);
|
|
33
|
+
};
|
|
34
|
+
const provider = env.UNMASK_MODEL_PROVIDER ?? "google";
|
|
35
|
+
if (!["google", "gemini"].includes(provider))
|
|
36
|
+
throw new TypeError("Invalid model provider configuration");
|
|
37
|
+
const legacy =
|
|
38
|
+
env.UNMASK_MODEL_REQUESTS_PER_MINUTE !== undefined ||
|
|
39
|
+
env.UNMASK_MODEL_DAILY_REQUESTS !== undefined;
|
|
40
|
+
if (
|
|
41
|
+
legacy &&
|
|
42
|
+
(env.UNMASK_MODEL_QUOTA_VERIFIED !== "true" ||
|
|
43
|
+
env.UNMASK_MODEL_GENERATIONS_PER_MINUTE !== undefined ||
|
|
44
|
+
env.UNMASK_MODEL_GENERATIONS_PER_DAY !== undefined)
|
|
45
|
+
)
|
|
46
|
+
throw new TypeError("Conflicting model quota configuration");
|
|
47
|
+
const options = {
|
|
48
|
+
apiKey,
|
|
49
|
+
provider,
|
|
50
|
+
quotaVerified: env.UNMASK_MODEL_QUOTA_VERIFIED === "true",
|
|
51
|
+
...(legacy
|
|
52
|
+
? {
|
|
53
|
+
requestsPerMinute: quota("UNMASK_MODEL_REQUESTS_PER_MINUTE"),
|
|
54
|
+
inputTokensPerMinute: quota("UNMASK_MODEL_INPUT_TOKENS_PER_MINUTE"),
|
|
55
|
+
dailyRequests: quota("UNMASK_MODEL_DAILY_REQUESTS"),
|
|
56
|
+
}
|
|
57
|
+
: {
|
|
58
|
+
localLimits: {
|
|
59
|
+
generationsPerMinute: quota(
|
|
60
|
+
"UNMASK_MODEL_GENERATIONS_PER_MINUTE",
|
|
61
|
+
"3",
|
|
62
|
+
),
|
|
63
|
+
inputTokensPerMinute: quota(
|
|
64
|
+
"UNMASK_MODEL_INPUT_TOKENS_PER_MINUTE",
|
|
65
|
+
"12000",
|
|
66
|
+
),
|
|
67
|
+
generationsPerDay: quota("UNMASK_MODEL_GENERATIONS_PER_DAY", "100"),
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
const { createGemmaClient, DEFAULT_MODEL_ROUTE } =
|
|
72
|
+
await import("./client.mjs");
|
|
73
|
+
if (env.UNMASK_MODEL_ROUTE !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
if (
|
|
76
|
+
typeof env.UNMASK_MODEL_ROUTE !== "string" ||
|
|
77
|
+
env.UNMASK_MODEL_ROUTE.length > 2048
|
|
78
|
+
)
|
|
79
|
+
throw new Error();
|
|
80
|
+
options.route = JSON.parse(env.UNMASK_MODEL_ROUTE);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new TypeError("Invalid model route configuration");
|
|
83
|
+
}
|
|
84
|
+
if (env.UNMASK_MODEL_ID !== undefined)
|
|
85
|
+
options.modelId = env.UNMASK_MODEL_ID;
|
|
86
|
+
} else if (env.UNMASK_MODEL_ID !== undefined)
|
|
87
|
+
options.modelId = env.UNMASK_MODEL_ID;
|
|
88
|
+
else options.route = DEFAULT_MODEL_ROUTE;
|
|
89
|
+
return createGemmaClient(options);
|
|
90
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createQueue, resolveLimits, systemClock } from "./queue.mjs";
|
|
3
|
+
import { createReportStore } from "./store.mjs";
|
|
4
|
+
import { renderReport } from "../viewer/render.mjs";
|
|
5
|
+
import {
|
|
6
|
+
renderHome,
|
|
7
|
+
renderStatus,
|
|
8
|
+
renderError,
|
|
9
|
+
renderCompleted,
|
|
10
|
+
} from "../viewer/shell.mjs";
|
|
11
|
+
|
|
12
|
+
export function parseNameAtExactVersion(value) {
|
|
13
|
+
if (typeof value !== "string" || value.length > 300 || value !== value.trim())
|
|
14
|
+
return null;
|
|
15
|
+
const match =
|
|
16
|
+
/^(?<name>(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)@(?<version>(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$/.exec(
|
|
17
|
+
value,
|
|
18
|
+
);
|
|
19
|
+
if (
|
|
20
|
+
!match ||
|
|
21
|
+
match.groups.name.length > 214 ||
|
|
22
|
+
["node_modules", "favicon.ico"].includes(match.groups.name)
|
|
23
|
+
)
|
|
24
|
+
return null;
|
|
25
|
+
const prerelease = match.groups.version
|
|
26
|
+
.split("+")[0]
|
|
27
|
+
.split("-")
|
|
28
|
+
.slice(1)
|
|
29
|
+
.join("-");
|
|
30
|
+
if (prerelease.split(".").some((part) => /^0\d+$/.test(part))) return null;
|
|
31
|
+
if (
|
|
32
|
+
match.groups.version
|
|
33
|
+
.split(/[.+-]/)
|
|
34
|
+
.slice(0, 3)
|
|
35
|
+
.some((part) => !Number.isSafeInteger(Number(part)))
|
|
36
|
+
)
|
|
37
|
+
return null;
|
|
38
|
+
return `${match.groups.name}@${match.groups.version}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bodyError(status) {
|
|
42
|
+
return Object.assign(new Error("Request body rejected"), { status });
|
|
43
|
+
}
|
|
44
|
+
function readBody(req, limits) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let size = 0,
|
|
47
|
+
chunks = [],
|
|
48
|
+
done = false;
|
|
49
|
+
const timer = setTimeout(
|
|
50
|
+
() => finish(bodyError(408)),
|
|
51
|
+
limits.bodyTimeoutMs,
|
|
52
|
+
);
|
|
53
|
+
timer.unref();
|
|
54
|
+
function finish(error) {
|
|
55
|
+
if (done) return;
|
|
56
|
+
done = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
req.removeListener("data", data);
|
|
59
|
+
req.removeListener("end", end);
|
|
60
|
+
req.removeListener("error", fail);
|
|
61
|
+
req.removeListener("aborted", fail);
|
|
62
|
+
if (error) {
|
|
63
|
+
chunks = [];
|
|
64
|
+
req.on("error", () => {});
|
|
65
|
+
req.resume();
|
|
66
|
+
reject(error);
|
|
67
|
+
} else resolve(Buffer.concat(chunks, size).toString("utf8"));
|
|
68
|
+
}
|
|
69
|
+
const data = (chunk) => {
|
|
70
|
+
size += chunk.length;
|
|
71
|
+
if (size > limits.maxBodyBytes) finish(bodyError(413));
|
|
72
|
+
else chunks.push(chunk);
|
|
73
|
+
};
|
|
74
|
+
const end = () => finish();
|
|
75
|
+
const fail = () => finish(bodyError(400));
|
|
76
|
+
if (Number(req.headers["content-length"]) > limits.maxBodyBytes) {
|
|
77
|
+
finish(bodyError(413));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
req.on("data", data);
|
|
81
|
+
req.on("end", end);
|
|
82
|
+
req.on("error", fail);
|
|
83
|
+
req.on("aborted", fail);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createCacheKeyPrefix(versionInfo, limits) {
|
|
88
|
+
return JSON.stringify([
|
|
89
|
+
versionInfo.version,
|
|
90
|
+
versionInfo.commit,
|
|
91
|
+
versionInfo.tarballSha256,
|
|
92
|
+
versionInfo.analyzerVersion,
|
|
93
|
+
versionInfo.policyVersion,
|
|
94
|
+
versionInfo.limitsVersion,
|
|
95
|
+
versionInfo.modelConfigDigest,
|
|
96
|
+
limits,
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createApp({
|
|
101
|
+
runAnalysis,
|
|
102
|
+
versionInfo,
|
|
103
|
+
limits: overrides,
|
|
104
|
+
clock = systemClock,
|
|
105
|
+
} = {}) {
|
|
106
|
+
if (typeof runAnalysis !== "function")
|
|
107
|
+
throw new TypeError("runAnalysis is required");
|
|
108
|
+
if (
|
|
109
|
+
!versionInfo ||
|
|
110
|
+
["version", "commit", "tarballSha256"].some(
|
|
111
|
+
(key) => typeof versionInfo[key] !== "string",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
throw new TypeError(
|
|
115
|
+
"versionInfo requires version, commit and tarballSha256 strings",
|
|
116
|
+
);
|
|
117
|
+
const limits = resolveLimits(overrides);
|
|
118
|
+
const version = Object.fromEntries(
|
|
119
|
+
["version", "commit", "tarballSha256"].map((key) => [
|
|
120
|
+
key,
|
|
121
|
+
versionInfo[key],
|
|
122
|
+
]),
|
|
123
|
+
);
|
|
124
|
+
const store = createReportStore({
|
|
125
|
+
maxEntries: limits.maxReports,
|
|
126
|
+
maxBytes: limits.maxReportBytes,
|
|
127
|
+
ttlMs: limits.reportTtlMs,
|
|
128
|
+
clock,
|
|
129
|
+
});
|
|
130
|
+
const keyPrefix = createCacheKeyPrefix(versionInfo, limits);
|
|
131
|
+
const queue = createQueue({ runAnalysis, store, limits, clock, keyPrefix });
|
|
132
|
+
const server = createServer(
|
|
133
|
+
{ maxHeaderSize: 8192, headersTimeout: 10000, requestTimeout: 15000 },
|
|
134
|
+
(req, res) => {
|
|
135
|
+
function send(
|
|
136
|
+
status,
|
|
137
|
+
body,
|
|
138
|
+
type = "text/html; charset=utf-8",
|
|
139
|
+
headers = {},
|
|
140
|
+
) {
|
|
141
|
+
if (res.destroyed || res.writableEnded) return;
|
|
142
|
+
res.writeHead(status, {
|
|
143
|
+
"content-type": type,
|
|
144
|
+
"x-content-type-options": "nosniff",
|
|
145
|
+
"referrer-policy": "no-referrer",
|
|
146
|
+
"cache-control": "no-store",
|
|
147
|
+
"x-frame-options": "DENY",
|
|
148
|
+
...headers,
|
|
149
|
+
});
|
|
150
|
+
res.end(body);
|
|
151
|
+
}
|
|
152
|
+
function json(status, value, headers) {
|
|
153
|
+
send(
|
|
154
|
+
status,
|
|
155
|
+
JSON.stringify(value),
|
|
156
|
+
"application/json; charset=utf-8",
|
|
157
|
+
headers,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
function error(status, title, message, headers) {
|
|
161
|
+
send(status, renderError({ title, message }), undefined, headers);
|
|
162
|
+
}
|
|
163
|
+
async function handle() {
|
|
164
|
+
const path = req.url;
|
|
165
|
+
if (path === "/check") {
|
|
166
|
+
if (req.method !== "POST")
|
|
167
|
+
return error(
|
|
168
|
+
405,
|
|
169
|
+
"Use the release form",
|
|
170
|
+
"A check starts only when you submit the form.",
|
|
171
|
+
{ allow: "POST" },
|
|
172
|
+
);
|
|
173
|
+
if (
|
|
174
|
+
req.headers.origin &&
|
|
175
|
+
req.headers.origin !== `http://${req.headers.host}` &&
|
|
176
|
+
req.headers.origin !== `https://${req.headers.host}`
|
|
177
|
+
)
|
|
178
|
+
return error(
|
|
179
|
+
403,
|
|
180
|
+
"Request origin rejected",
|
|
181
|
+
"Open the release form on this site to start a check.",
|
|
182
|
+
);
|
|
183
|
+
if (req.headers["sec-fetch-site"] === "cross-site")
|
|
184
|
+
return error(
|
|
185
|
+
403,
|
|
186
|
+
"Request origin rejected",
|
|
187
|
+
"Open the release form on this site to start a check.",
|
|
188
|
+
);
|
|
189
|
+
if (
|
|
190
|
+
!/^application\/x-www-form-urlencoded(?:\s*;\s*charset=utf-8)?$/i.test(
|
|
191
|
+
req.headers["content-type"] || "",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return error(
|
|
195
|
+
415,
|
|
196
|
+
"Use the release form",
|
|
197
|
+
"Submit a package name and exact version with the release form.",
|
|
198
|
+
);
|
|
199
|
+
let body;
|
|
200
|
+
try {
|
|
201
|
+
body = await readBody(req, limits);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return error(
|
|
204
|
+
e.status || 400,
|
|
205
|
+
e.status === 413
|
|
206
|
+
? "Request too large"
|
|
207
|
+
: "Request body unavailable",
|
|
208
|
+
"Submit only a package name and exact version.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const params = new URLSearchParams(body);
|
|
212
|
+
const value = params.get("package") || "";
|
|
213
|
+
const name = params.size === 1 && parseNameAtExactVersion(value);
|
|
214
|
+
if (!name)
|
|
215
|
+
return send(
|
|
216
|
+
400,
|
|
217
|
+
renderHome({
|
|
218
|
+
value: value.slice(0, 300),
|
|
219
|
+
error:
|
|
220
|
+
"Enter an npm package name and exact version. Tags, ranges and URLs are not accepted.",
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
const result = queue.submit(name);
|
|
224
|
+
if (!result.job)
|
|
225
|
+
return send(
|
|
226
|
+
429,
|
|
227
|
+
renderHome({
|
|
228
|
+
value,
|
|
229
|
+
error:
|
|
230
|
+
"The shared global limit is six new checks per ten minutes, with one active check and one waiting slot.",
|
|
231
|
+
retryAfter: result.retryAfter,
|
|
232
|
+
}),
|
|
233
|
+
undefined,
|
|
234
|
+
{ "retry-after": String(result.retryAfter) },
|
|
235
|
+
);
|
|
236
|
+
return send(303, "", "text/plain; charset=utf-8", {
|
|
237
|
+
location: `/r/${result.job.id}`,
|
|
238
|
+
"x-unmask-reused": String(result.reused),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (req.method !== "GET")
|
|
242
|
+
return error(
|
|
243
|
+
405,
|
|
244
|
+
"Method unavailable",
|
|
245
|
+
"Use the release form to start a check.",
|
|
246
|
+
{ allow: "GET" },
|
|
247
|
+
);
|
|
248
|
+
if (path === "/") return send(200, renderHome());
|
|
249
|
+
if (path === "/healthz") return json(200, { status: "ok" });
|
|
250
|
+
if (path === "/version") return json(200, version);
|
|
251
|
+
const match = /^\/r\/([a-f0-9]{32})(\.json)?$/.exec(path);
|
|
252
|
+
if (!match)
|
|
253
|
+
return error(
|
|
254
|
+
404,
|
|
255
|
+
"Page unavailable",
|
|
256
|
+
"Return to the release form to check a package.",
|
|
257
|
+
);
|
|
258
|
+
const job = queue.get(match[1]);
|
|
259
|
+
if (!job)
|
|
260
|
+
return error(
|
|
261
|
+
404,
|
|
262
|
+
"Report unavailable",
|
|
263
|
+
"This report may have expired, been evicted or been lost in a restart. Submit the package again to request a new check.",
|
|
264
|
+
);
|
|
265
|
+
const pending = ["queued", "running"].includes(job.status);
|
|
266
|
+
if (match[2]) {
|
|
267
|
+
if (job.report)
|
|
268
|
+
return json(200, job.report, {
|
|
269
|
+
"content-disposition":
|
|
270
|
+
'attachment; filename="unmask-report.json"',
|
|
271
|
+
});
|
|
272
|
+
const { key, ...publicJob } = job;
|
|
273
|
+
return json(
|
|
274
|
+
pending ? 202 : 200,
|
|
275
|
+
publicJob,
|
|
276
|
+
pending ? { "retry-after": "3" } : {},
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (job.report)
|
|
280
|
+
return send(200, renderCompleted(renderReport(job.report), job));
|
|
281
|
+
return send(
|
|
282
|
+
200,
|
|
283
|
+
renderStatus(job),
|
|
284
|
+
undefined,
|
|
285
|
+
pending ? { "retry-after": "3" } : {},
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
handle().catch(() =>
|
|
289
|
+
error(
|
|
290
|
+
500,
|
|
291
|
+
"Request could not finish",
|
|
292
|
+
"Return to the release form to try again.",
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
server.on("close", () => queue.close());
|
|
298
|
+
return server;
|
|
299
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { createApp } from "./app.mjs";
|
|
6
|
+
import { createRunner } from "./runner.mjs";
|
|
7
|
+
import { modelClientFromEnv } from "../model/config.mjs";
|
|
8
|
+
export { modelClientFromEnv };
|
|
9
|
+
|
|
10
|
+
export async function loadVersionInfo({
|
|
11
|
+
metadataURL = new URL("../version.json", import.meta.url),
|
|
12
|
+
tarballSha256 = process.env.UNMASK_TARBALL_SHA256,
|
|
13
|
+
} = {}) {
|
|
14
|
+
const metadata = JSON.parse(
|
|
15
|
+
await readFile(new URL("../../package.json", import.meta.url), "utf8"),
|
|
16
|
+
);
|
|
17
|
+
let commit = "unknown";
|
|
18
|
+
try {
|
|
19
|
+
const build = JSON.parse(await readFile(metadataURL, "utf8"));
|
|
20
|
+
if (
|
|
21
|
+
build.version !== metadata.version ||
|
|
22
|
+
typeof build.dirty !== "boolean" ||
|
|
23
|
+
!/^(?:[a-f0-9]{40}|unknown)$/.test(build.commit) ||
|
|
24
|
+
!/^[a-f0-9]{64}$/.test(build.sourceSha256)
|
|
25
|
+
)
|
|
26
|
+
throw new TypeError("Invalid build version metadata");
|
|
27
|
+
if (!build.dirty) commit = build.commit;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code !== "ENOENT") throw error;
|
|
30
|
+
}
|
|
31
|
+
if (tarballSha256 !== undefined && !/^[a-f0-9]{64}$/.test(tarballSha256))
|
|
32
|
+
throw new TypeError("Invalid tarball digest");
|
|
33
|
+
return {
|
|
34
|
+
version: metadata.version,
|
|
35
|
+
commit,
|
|
36
|
+
tarballSha256: tarballSha256 || "unknown",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function startServer({
|
|
41
|
+
host = process.env.HOST || "127.0.0.1",
|
|
42
|
+
port = Number(process.env.PORT || 3000),
|
|
43
|
+
tempRoot = path.join(
|
|
44
|
+
os.tmpdir(),
|
|
45
|
+
`unmask-server-${process.getuid?.() ?? "local"}`,
|
|
46
|
+
),
|
|
47
|
+
runnerOptions = {},
|
|
48
|
+
versionInfo,
|
|
49
|
+
modelClient,
|
|
50
|
+
} = {}) {
|
|
51
|
+
if (
|
|
52
|
+
typeof host !== "string" ||
|
|
53
|
+
!host ||
|
|
54
|
+
!Number.isSafeInteger(port) ||
|
|
55
|
+
port < 0 ||
|
|
56
|
+
port > 65535
|
|
57
|
+
)
|
|
58
|
+
throw new TypeError("Invalid server address");
|
|
59
|
+
const client =
|
|
60
|
+
modelClient === null
|
|
61
|
+
? undefined
|
|
62
|
+
: modelClient === undefined
|
|
63
|
+
? await modelClientFromEnv()
|
|
64
|
+
: modelClient;
|
|
65
|
+
const run = await createRunner({
|
|
66
|
+
...runnerOptions,
|
|
67
|
+
tempRoot,
|
|
68
|
+
modelClient: client,
|
|
69
|
+
});
|
|
70
|
+
const shutdown = new AbortController();
|
|
71
|
+
const pending = new Set();
|
|
72
|
+
const runAnalysis = (spec, { signal } = {}) => {
|
|
73
|
+
const operation = run(spec, {
|
|
74
|
+
signal: signal
|
|
75
|
+
? AbortSignal.any([signal, shutdown.signal])
|
|
76
|
+
: shutdown.signal,
|
|
77
|
+
});
|
|
78
|
+
pending.add(operation);
|
|
79
|
+
operation.finally(() => pending.delete(operation)).catch(() => {});
|
|
80
|
+
return operation;
|
|
81
|
+
};
|
|
82
|
+
const server = createApp({
|
|
83
|
+
runAnalysis,
|
|
84
|
+
versionInfo: {
|
|
85
|
+
...(versionInfo || (await loadVersionInfo())),
|
|
86
|
+
modelConfigDigest:
|
|
87
|
+
client?.configHash || versionInfo?.modelConfigDigest || "disabled",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
let closing;
|
|
91
|
+
function close() {
|
|
92
|
+
closing ||= (async () => {
|
|
93
|
+
shutdown.abort();
|
|
94
|
+
const stopped = new Promise((resolve, reject) => {
|
|
95
|
+
server.close((error) =>
|
|
96
|
+
error && error.code !== "ERR_SERVER_NOT_RUNNING"
|
|
97
|
+
? reject(error)
|
|
98
|
+
: resolve(),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
server.closeIdleConnections();
|
|
102
|
+
await Promise.all([
|
|
103
|
+
stopped,
|
|
104
|
+
...Array.from(pending, (operation) => operation.catch(() => {})),
|
|
105
|
+
]);
|
|
106
|
+
})();
|
|
107
|
+
return closing;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await new Promise((resolve, reject) => {
|
|
111
|
+
server.once("error", reject);
|
|
112
|
+
server.listen(port, host, () => {
|
|
113
|
+
server.removeListener("error", reject);
|
|
114
|
+
resolve();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
await close();
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
return { server, close };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function main() {
|
|
125
|
+
const runtime = await startServer();
|
|
126
|
+
const stop = async () => {
|
|
127
|
+
try {
|
|
128
|
+
await runtime.close();
|
|
129
|
+
} catch {
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
process.once("SIGINT", stop);
|
|
134
|
+
process.once("SIGTERM", stop);
|
|
135
|
+
const address = runtime.server.address();
|
|
136
|
+
console.log(`UNMASK listening on ${address.address}:${address.port}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
process.argv[1] &&
|
|
141
|
+
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
|
|
142
|
+
)
|
|
143
|
+
main().catch(() => {
|
|
144
|
+
console.error("UNMASK server could not start");
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
4
|
+
maxBodyBytes: 4096,
|
|
5
|
+
bodyTimeoutMs: 10000,
|
|
6
|
+
queueTtlMs: 300000,
|
|
7
|
+
executionMs: 240000,
|
|
8
|
+
maxNewChecks: 6,
|
|
9
|
+
admissionWindowMs: 600000,
|
|
10
|
+
maxReportBytes: 64 * 1024 * 1024,
|
|
11
|
+
maxReports: 200,
|
|
12
|
+
reportTtlMs: 1800000,
|
|
13
|
+
});
|
|
14
|
+
export const systemClock = { now: Date.now, setTimeout, clearTimeout };
|
|
15
|
+
|
|
16
|
+
export function resolveLimits(overrides = {}) {
|
|
17
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
18
|
+
if (
|
|
19
|
+
!(key in DEFAULT_LIMITS) ||
|
|
20
|
+
!Number.isSafeInteger(value) ||
|
|
21
|
+
value < 1 ||
|
|
22
|
+
value > DEFAULT_LIMITS[key]
|
|
23
|
+
)
|
|
24
|
+
throw new TypeError(`Invalid limit: ${key}`);
|
|
25
|
+
}
|
|
26
|
+
return { ...DEFAULT_LIMITS, ...overrides };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createQueue({
|
|
30
|
+
runAnalysis,
|
|
31
|
+
store,
|
|
32
|
+
limits,
|
|
33
|
+
clock = systemClock,
|
|
34
|
+
keyPrefix = "",
|
|
35
|
+
}) {
|
|
36
|
+
let active,
|
|
37
|
+
waiting,
|
|
38
|
+
closed = false;
|
|
39
|
+
let admissions = [];
|
|
40
|
+
function persist(job) {
|
|
41
|
+
if (!store.put(job)) {
|
|
42
|
+
const { report, ...small } = job;
|
|
43
|
+
store.put({ ...small, status: "incomplete", reason: "report-too-large" });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function expireWaiting(job) {
|
|
47
|
+
if (waiting !== job) return;
|
|
48
|
+
waiting = undefined;
|
|
49
|
+
job.status = "incomplete";
|
|
50
|
+
job.reason = "queue-expired";
|
|
51
|
+
job.finishedAt = clock.now();
|
|
52
|
+
clock.clearTimeout(job.queueTimer);
|
|
53
|
+
delete job.queueTimer;
|
|
54
|
+
persist(job);
|
|
55
|
+
}
|
|
56
|
+
function begin(job) {
|
|
57
|
+
active = job;
|
|
58
|
+
waiting = undefined;
|
|
59
|
+
if (job.queueTimer !== undefined) clock.clearTimeout(job.queueTimer);
|
|
60
|
+
delete job.queueTimer;
|
|
61
|
+
job.status = "running";
|
|
62
|
+
job.startedAt = clock.now();
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timer = clock.setTimeout(() => {
|
|
65
|
+
job.status = "incomplete";
|
|
66
|
+
job.reason = "execution-timeout";
|
|
67
|
+
job.finishedAt = clock.now();
|
|
68
|
+
controller.abort();
|
|
69
|
+
persist(job);
|
|
70
|
+
}, limits.executionMs);
|
|
71
|
+
timer?.unref?.();
|
|
72
|
+
job.cancel = () => {
|
|
73
|
+
clock.clearTimeout(timer);
|
|
74
|
+
controller.abort();
|
|
75
|
+
};
|
|
76
|
+
Promise.resolve()
|
|
77
|
+
.then(() =>
|
|
78
|
+
runAnalysis(job.nameAtExactVersion, { signal: controller.signal }),
|
|
79
|
+
)
|
|
80
|
+
.then((report) => {
|
|
81
|
+
if (job.status !== "running" || closed) return;
|
|
82
|
+
if (
|
|
83
|
+
!report ||
|
|
84
|
+
typeof report !== "object" ||
|
|
85
|
+
Array.isArray(report) ||
|
|
86
|
+
![0, 2, 3].includes(report.policy?.exitCode)
|
|
87
|
+
)
|
|
88
|
+
throw new TypeError("Invalid report");
|
|
89
|
+
const status = report.policy.exitCode === 2 ? "incomplete" : "done";
|
|
90
|
+
const finishedAt = clock.now();
|
|
91
|
+
persist({ ...publicJob(job), status, report, finishedAt });
|
|
92
|
+
job.status = status;
|
|
93
|
+
job.finishedAt = finishedAt;
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
if (job.status !== "running" || closed) return;
|
|
97
|
+
job.status = "failed";
|
|
98
|
+
job.reason = "analysis-failed";
|
|
99
|
+
job.finishedAt = clock.now();
|
|
100
|
+
delete job.report;
|
|
101
|
+
persist(publicJob(job));
|
|
102
|
+
})
|
|
103
|
+
.finally(() => {
|
|
104
|
+
clock.clearTimeout(timer);
|
|
105
|
+
active = undefined;
|
|
106
|
+
if (waiting && !closed) {
|
|
107
|
+
if (clock.now() - waiting.createdAt >= limits.queueTtlMs)
|
|
108
|
+
expireWaiting(waiting);
|
|
109
|
+
else begin(waiting);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function publicJob(job) {
|
|
114
|
+
const { cancel, queueTimer, report, ...metadata } = job;
|
|
115
|
+
return report ? { ...metadata, report } : metadata;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
submit(nameAtExactVersion) {
|
|
119
|
+
const key = `${keyPrefix}\n${nameAtExactVersion}`;
|
|
120
|
+
const existing =
|
|
121
|
+
[active, waiting].find(
|
|
122
|
+
(job) =>
|
|
123
|
+
job?.key === key && ["queued", "running"].includes(job.status),
|
|
124
|
+
) || store.findKey(key);
|
|
125
|
+
if (existing) return { job: publicJob(existing), reused: true };
|
|
126
|
+
admissions = admissions.filter(
|
|
127
|
+
(time) => clock.now() - time < limits.admissionWindowMs,
|
|
128
|
+
);
|
|
129
|
+
if (
|
|
130
|
+
closed ||
|
|
131
|
+
(active && waiting) ||
|
|
132
|
+
admissions.length >= limits.maxNewChecks
|
|
133
|
+
)
|
|
134
|
+
return {
|
|
135
|
+
retryAfter: Math.max(
|
|
136
|
+
1,
|
|
137
|
+
Math.ceil(
|
|
138
|
+
(admissions.length >= limits.maxNewChecks
|
|
139
|
+
? admissions[0] + limits.admissionWindowMs - clock.now()
|
|
140
|
+
: limits.executionMs) / 1000,
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
};
|
|
144
|
+
const job = {
|
|
145
|
+
id: randomBytes(16).toString("hex"),
|
|
146
|
+
key,
|
|
147
|
+
nameAtExactVersion,
|
|
148
|
+
status: "queued",
|
|
149
|
+
createdAt: clock.now(),
|
|
150
|
+
};
|
|
151
|
+
admissions.push(clock.now());
|
|
152
|
+
if (!active) begin(job);
|
|
153
|
+
else {
|
|
154
|
+
waiting = job;
|
|
155
|
+
job.queueTimer = clock.setTimeout(
|
|
156
|
+
() => expireWaiting(job),
|
|
157
|
+
limits.queueTtlMs,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return { job: publicJob(job), reused: false };
|
|
161
|
+
},
|
|
162
|
+
get(id) {
|
|
163
|
+
const job = [active, waiting].find((item) => item?.id === id);
|
|
164
|
+
return job && ["queued", "running"].includes(job.status)
|
|
165
|
+
? publicJob(job)
|
|
166
|
+
: store.get(id);
|
|
167
|
+
},
|
|
168
|
+
close() {
|
|
169
|
+
closed = true;
|
|
170
|
+
if (waiting) {
|
|
171
|
+
clock.clearTimeout(waiting.queueTimer);
|
|
172
|
+
waiting = undefined;
|
|
173
|
+
}
|
|
174
|
+
active?.cancel();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|