mancode 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +87 -10
- package/README.md +60 -10
- package/dist/{chunk-NG72XZ3R.js → chunk-E2K22WYH.js} +2323 -1008
- package/dist/chunk-E2K22WYH.js.map +1 -0
- package/dist/chunk-GI24QVXE.js +1099 -0
- package/dist/chunk-GI24QVXE.js.map +1 -0
- package/dist/chunk-IRZQYMHD.js +334 -0
- package/dist/chunk-IRZQYMHD.js.map +1 -0
- package/dist/chunk-THOE33LU.js +1124 -0
- package/dist/chunk-THOE33LU.js.map +1 -0
- package/dist/{chunk-RDPFQODS.js → chunk-WJ6WARGG.js} +495 -250
- package/dist/chunk-WJ6WARGG.js.map +1 -0
- package/dist/chunk-WRBNOPFA.js +1235 -0
- package/dist/chunk-WRBNOPFA.js.map +1 -0
- package/dist/cli.js +22917 -21503
- package/dist/cli.js.map +1 -1
- package/dist/gateway/worker.d.ts +2 -0
- package/dist/gateway/worker.js +181 -0
- package/dist/gateway/worker.js.map +1 -0
- package/dist/privacy-gateway-Q4GD2JXF.js +20 -0
- package/dist/store-GSLSLZ7D.js +11 -0
- package/dist/{v3-adapter-7KIOYYWS.js → v3-adapter-T3IKK3LU.js} +4 -2
- package/dist/v3-adapter-T3IKK3LU.js.map +1 -0
- package/docs/README.md +34 -0
- package/docs/privacy-guide.md +76 -0
- package/docs/privacy-implementation-plan.md +101 -0
- package/docs/privacy-rule-sources.md +22 -0
- package/docs/privacy-upstream-license.txt +661 -0
- package/package.json +8 -3
- package/dist/chunk-NG72XZ3R.js.map +0 -1
- package/dist/chunk-RDPFQODS.js.map +0 -1
- package/dist/store-C3G3HN3N.js +0 -9
- /package/dist/{store-C3G3HN3N.js.map → privacy-gateway-Q4GD2JXF.js.map} +0 -0
- /package/dist/{v3-adapter-7KIOYYWS.js.map → store-GSLSLZ7D.js.map} +0 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GatewayError,
|
|
3
|
+
SseDecoder,
|
|
4
|
+
encodeSse,
|
|
5
|
+
gatewayErrorCode
|
|
6
|
+
} from "./chunk-GI24QVXE.js";
|
|
7
|
+
import {
|
|
8
|
+
readProjectRuntimeContext
|
|
9
|
+
} from "./chunk-WRBNOPFA.js";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_RULE_IDS,
|
|
12
|
+
RULESET_VERSION
|
|
13
|
+
} from "./chunk-IRZQYMHD.js";
|
|
14
|
+
|
|
15
|
+
// src/commands/privacy-gateway.ts
|
|
16
|
+
import { execFile } from "child_process";
|
|
17
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
18
|
+
import { rm as rm2 } from "fs/promises";
|
|
19
|
+
import path2 from "path";
|
|
20
|
+
import { promisify } from "util";
|
|
21
|
+
|
|
22
|
+
// src/gateway/config.ts
|
|
23
|
+
import {
|
|
24
|
+
createHash,
|
|
25
|
+
createHmac,
|
|
26
|
+
randomBytes,
|
|
27
|
+
timingSafeEqual
|
|
28
|
+
} from "crypto";
|
|
29
|
+
import { constants } from "fs";
|
|
30
|
+
import {
|
|
31
|
+
chmod,
|
|
32
|
+
lstat,
|
|
33
|
+
mkdir,
|
|
34
|
+
open,
|
|
35
|
+
readdir,
|
|
36
|
+
realpath,
|
|
37
|
+
rename,
|
|
38
|
+
rm,
|
|
39
|
+
writeFile
|
|
40
|
+
} from "fs/promises";
|
|
41
|
+
import os from "os";
|
|
42
|
+
import path from "path";
|
|
43
|
+
var UPSTREAMS = {
|
|
44
|
+
openai: {
|
|
45
|
+
origin: "https://api.openai.com",
|
|
46
|
+
protocol: "responses",
|
|
47
|
+
envKey: "OPENAI_API_KEY"
|
|
48
|
+
},
|
|
49
|
+
anthropic: {
|
|
50
|
+
origin: "https://api.anthropic.com",
|
|
51
|
+
protocol: "messages",
|
|
52
|
+
envKey: "ANTHROPIC_API_KEY"
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var hashValue = (value) => createHash("sha256").update(value).digest("hex");
|
|
56
|
+
async function gatewayLocation(root) {
|
|
57
|
+
const resolved = await realpath(root);
|
|
58
|
+
let workspaceId = null;
|
|
59
|
+
let checkoutId = null;
|
|
60
|
+
let initialized = false;
|
|
61
|
+
try {
|
|
62
|
+
await lstat(path.join(resolved, ".mancode"));
|
|
63
|
+
initialized = true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT")
|
|
66
|
+
throw new GatewayError("MANCODE_GATEWAY_SCOPE_UNAVAILABLE");
|
|
67
|
+
}
|
|
68
|
+
if (initialized) {
|
|
69
|
+
try {
|
|
70
|
+
const runtime = await readProjectRuntimeContext(resolved);
|
|
71
|
+
workspaceId = runtime.workspaceId;
|
|
72
|
+
checkoutId = runtime.checkoutId;
|
|
73
|
+
} catch {
|
|
74
|
+
throw new GatewayError("MANCODE_GATEWAY_SCOPE_UNAVAILABLE");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const physical = await lstat(resolved);
|
|
78
|
+
const scope = {
|
|
79
|
+
principal: hashValue(`${os.homedir()}:${process.getuid?.() ?? "user"}`),
|
|
80
|
+
checkout: hashValue(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
root: resolved,
|
|
83
|
+
device: physical.dev,
|
|
84
|
+
inode: physical.ino,
|
|
85
|
+
created: physical.birthtimeMs,
|
|
86
|
+
workspaceId,
|
|
87
|
+
checkoutId
|
|
88
|
+
})
|
|
89
|
+
),
|
|
90
|
+
workspaceId,
|
|
91
|
+
checkoutId
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
directory: path.join(
|
|
95
|
+
os.homedir(),
|
|
96
|
+
".mancode",
|
|
97
|
+
"privacy-gateway",
|
|
98
|
+
scope.checkout
|
|
99
|
+
),
|
|
100
|
+
scope
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function assertKeys(value, expected) {
|
|
104
|
+
if (Object.keys(value).some((key) => !expected.includes(key)) || expected.some((key) => !(key in value)))
|
|
105
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_INVALID");
|
|
106
|
+
}
|
|
107
|
+
function parseGatewayConfig(value, scope) {
|
|
108
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
109
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_INVALID");
|
|
110
|
+
const config = value;
|
|
111
|
+
assertKeys(config, [
|
|
112
|
+
"schemaVersion",
|
|
113
|
+
"enabled",
|
|
114
|
+
"upstreamId",
|
|
115
|
+
"envKey",
|
|
116
|
+
"clientHost",
|
|
117
|
+
"accessToken",
|
|
118
|
+
"port",
|
|
119
|
+
"scope",
|
|
120
|
+
"ruleIds",
|
|
121
|
+
"rulesetVersion"
|
|
122
|
+
]);
|
|
123
|
+
if (config.schemaVersion !== 1 || typeof config.enabled !== "boolean" || !(config.upstreamId === "openai" || config.upstreamId === "anthropic") || typeof config.envKey !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(config.envKey) || typeof config.accessToken !== "string" || !/^[a-f0-9]{64}$/.test(config.accessToken) || !Number.isInteger(config.port) || Number(config.port) < 1024 || Number(config.port) > 65535 || !["codex-cli/0.153.4", "claude-code/2.1.142", "unverified"].includes(
|
|
124
|
+
String(config.clientHost)
|
|
125
|
+
) || config.rulesetVersion !== RULESET_VERSION || !Array.isArray(config.ruleIds) || config.ruleIds.length === 0 || config.ruleIds.some(
|
|
126
|
+
(id) => typeof id !== "string" || !DEFAULT_RULE_IDS.includes(id)
|
|
127
|
+
) || new Set(config.ruleIds).size !== config.ruleIds.length)
|
|
128
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_INVALID");
|
|
129
|
+
const binding = config.scope;
|
|
130
|
+
if (config.clientHost === "codex-cli/0.153.4" && config.upstreamId !== "openai" || config.clientHost === "claude-code/2.1.142" && config.upstreamId !== "anthropic")
|
|
131
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_UPSTREAM_MISMATCH");
|
|
132
|
+
if (binding?.principal !== scope.principal || binding?.checkout !== scope.checkout || binding?.workspaceId !== scope.workspaceId || binding?.checkoutId !== scope.checkoutId || Object.keys(binding).length !== 4)
|
|
133
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_SCOPE_MISMATCH");
|
|
134
|
+
return { ...config, scope: { ...scope } };
|
|
135
|
+
}
|
|
136
|
+
async function readPrivateJson(file) {
|
|
137
|
+
let handle;
|
|
138
|
+
try {
|
|
139
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
140
|
+
const stat = await handle.stat();
|
|
141
|
+
if (!stat.isFile() || stat.size > 64 * 1024 || process.platform !== "win32" && ((stat.mode & 63) !== 0 || stat.uid !== process.getuid?.()))
|
|
142
|
+
throw new GatewayError("MANCODE_GATEWAY_PRIVATE_FILE_UNSAFE");
|
|
143
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code === "ENOENT") return null;
|
|
146
|
+
if (error instanceof GatewayError) throw error;
|
|
147
|
+
throw new GatewayError("MANCODE_GATEWAY_PRIVATE_FILE_INVALID");
|
|
148
|
+
} finally {
|
|
149
|
+
await handle?.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function readGatewayConfig(root) {
|
|
153
|
+
const location = await gatewayLocation(root);
|
|
154
|
+
const value = await readPrivateJson(
|
|
155
|
+
path.join(location.directory, "config.json")
|
|
156
|
+
);
|
|
157
|
+
return value === null ? null : parseGatewayConfig(value, location.scope);
|
|
158
|
+
}
|
|
159
|
+
async function ensurePrivateDirectory(directory) {
|
|
160
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
161
|
+
const stat = await lstat(directory);
|
|
162
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== "win32" && stat.uid !== process.getuid?.())
|
|
163
|
+
throw new GatewayError("MANCODE_GATEWAY_PRIVATE_DIRECTORY_UNSAFE");
|
|
164
|
+
await chmod(directory, 448);
|
|
165
|
+
}
|
|
166
|
+
async function writePrivateJson(file, value) {
|
|
167
|
+
const temporary = `${file}.${randomBytes(8).toString("hex")}.tmp`;
|
|
168
|
+
try {
|
|
169
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}
|
|
170
|
+
`, {
|
|
171
|
+
flag: "wx",
|
|
172
|
+
mode: 384
|
|
173
|
+
});
|
|
174
|
+
await rename(temporary, file);
|
|
175
|
+
} finally {
|
|
176
|
+
await rm(temporary, { force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function configureGateway(root, options) {
|
|
180
|
+
const binding = await gatewayLocation(root);
|
|
181
|
+
if (!binding.scope.workspaceId || !binding.scope.checkoutId)
|
|
182
|
+
throw new GatewayError("MANCODE_GATEWAY_NOT_INITIALIZED");
|
|
183
|
+
return withGatewayLifecycleLock(root, async () => {
|
|
184
|
+
const location = await gatewayLocation(root);
|
|
185
|
+
const existing = await readGatewayConfig(root);
|
|
186
|
+
const config = parseGatewayConfig(
|
|
187
|
+
{
|
|
188
|
+
schemaVersion: 1,
|
|
189
|
+
enabled: options.enabled,
|
|
190
|
+
upstreamId: options.upstreamId ?? existing?.upstreamId ?? "openai",
|
|
191
|
+
envKey: options.envKey ?? (options.upstreamId && options.upstreamId !== existing?.upstreamId ? UPSTREAMS[options.upstreamId].envKey : existing?.envKey) ?? UPSTREAMS[options.upstreamId ?? "openai"].envKey,
|
|
192
|
+
clientHost: options.clientHost ?? (options.upstreamId && options.upstreamId !== existing?.upstreamId ? "unverified" : existing?.clientHost) ?? "unverified",
|
|
193
|
+
accessToken: existing?.accessToken ?? randomBytes(32).toString("hex"),
|
|
194
|
+
port: options.port ?? existing?.port ?? 17641,
|
|
195
|
+
scope: location.scope,
|
|
196
|
+
ruleIds: existing?.ruleIds ?? [...DEFAULT_RULE_IDS],
|
|
197
|
+
rulesetVersion: RULESET_VERSION
|
|
198
|
+
},
|
|
199
|
+
location.scope
|
|
200
|
+
);
|
|
201
|
+
await writePrivateJson(
|
|
202
|
+
path.join(location.directory, "config.json"),
|
|
203
|
+
config
|
|
204
|
+
);
|
|
205
|
+
return config;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function withGatewayLifecycleLock(root, action) {
|
|
209
|
+
const location = await gatewayLocation(root);
|
|
210
|
+
await ensurePrivateDirectory(path.dirname(location.directory));
|
|
211
|
+
await ensurePrivateDirectory(location.directory);
|
|
212
|
+
const lock = path.join(location.directory, "config-locks");
|
|
213
|
+
const release = await acquireConfigLock(lock);
|
|
214
|
+
try {
|
|
215
|
+
return await action();
|
|
216
|
+
} finally {
|
|
217
|
+
await release();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function acquireConfigLock(lock) {
|
|
221
|
+
await ensurePrivateDirectory(lock);
|
|
222
|
+
const owner = {
|
|
223
|
+
processId: process.pid,
|
|
224
|
+
nonce: randomBytes(16).toString("hex"),
|
|
225
|
+
choosing: true,
|
|
226
|
+
ticket: 0
|
|
227
|
+
};
|
|
228
|
+
const candidate = path.join(lock, `${owner.nonce}.json`);
|
|
229
|
+
const contenders = async () => {
|
|
230
|
+
const result = [];
|
|
231
|
+
for (const name of await readdir(lock)) {
|
|
232
|
+
if (!/^[a-f0-9]{32}\.json$/.test(name)) continue;
|
|
233
|
+
const file = path.join(lock, name);
|
|
234
|
+
const current = await readPrivateJson(file);
|
|
235
|
+
if (!current) continue;
|
|
236
|
+
if (!Number.isSafeInteger(current.processId) || current.processId < 1 || current.nonce !== name.slice(0, -5) || typeof current.choosing !== "boolean" || !Number.isSafeInteger(current.ticket) || current.ticket < 0)
|
|
237
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_LOCK_UNVERIFIED");
|
|
238
|
+
if (!processIsAlive(current.processId)) {
|
|
239
|
+
await rm(file, { force: true });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
result.push(current);
|
|
243
|
+
}
|
|
244
|
+
if (result.length > 64)
|
|
245
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_BUSY", 409);
|
|
246
|
+
return result;
|
|
247
|
+
};
|
|
248
|
+
await writePrivateJson(candidate, owner);
|
|
249
|
+
let acquired = false;
|
|
250
|
+
try {
|
|
251
|
+
owner.ticket = Math.max(0, ...(await contenders()).map((other) => other.ticket)) + 1;
|
|
252
|
+
if (!Number.isSafeInteger(owner.ticket))
|
|
253
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_BUSY", 409);
|
|
254
|
+
owner.choosing = false;
|
|
255
|
+
await writePrivateJson(candidate, owner);
|
|
256
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
257
|
+
const blocked = (await contenders()).some(
|
|
258
|
+
(other) => other.nonce !== owner.nonce && (other.choosing || other.ticket < owner.ticket || other.ticket === owner.ticket && other.nonce < owner.nonce)
|
|
259
|
+
);
|
|
260
|
+
if (!blocked) {
|
|
261
|
+
acquired = true;
|
|
262
|
+
return async () => {
|
|
263
|
+
await rm(candidate, { force: true });
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
267
|
+
}
|
|
268
|
+
throw new GatewayError("MANCODE_GATEWAY_CONFIG_BUSY", 409);
|
|
269
|
+
} finally {
|
|
270
|
+
if (!acquired) await rm(candidate, { force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function processIsAlive(pid) {
|
|
274
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return true;
|
|
275
|
+
try {
|
|
276
|
+
process.kill(pid, 0);
|
|
277
|
+
return true;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return error.code !== "ESRCH";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function gatewayConfigDigest(config) {
|
|
283
|
+
return hashValue(
|
|
284
|
+
JSON.stringify({
|
|
285
|
+
upstreamId: config.upstreamId,
|
|
286
|
+
envKey: config.envKey,
|
|
287
|
+
clientHost: config.clientHost,
|
|
288
|
+
port: config.port,
|
|
289
|
+
scope: config.scope,
|
|
290
|
+
rulesetVersion: config.rulesetVersion,
|
|
291
|
+
ruleIds: config.ruleIds,
|
|
292
|
+
tokenBinding: hashValue(config.accessToken)
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
function constantTokenEquals(left, right) {
|
|
297
|
+
const a = Buffer.from(left);
|
|
298
|
+
const b = Buffer.from(right);
|
|
299
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
300
|
+
}
|
|
301
|
+
function controlProof(token, instanceId, loadedDigest, challenge, action = "probe") {
|
|
302
|
+
return createHmac("sha256", token).update(
|
|
303
|
+
JSON.stringify({
|
|
304
|
+
purpose: "mancode-gateway-control-v1",
|
|
305
|
+
instanceId,
|
|
306
|
+
loadedDigest,
|
|
307
|
+
challenge,
|
|
308
|
+
action
|
|
309
|
+
})
|
|
310
|
+
).digest("hex");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/gateway/server.ts
|
|
314
|
+
import { randomUUID } from "crypto";
|
|
315
|
+
import { once } from "events";
|
|
316
|
+
import {
|
|
317
|
+
createServer
|
|
318
|
+
} from "http";
|
|
319
|
+
|
|
320
|
+
// src/gateway/worker-client.ts
|
|
321
|
+
import { Worker } from "worker_threads";
|
|
322
|
+
var GatewayWorker = class {
|
|
323
|
+
worker;
|
|
324
|
+
sequence = 0;
|
|
325
|
+
closed = false;
|
|
326
|
+
pending = /* @__PURE__ */ new Map();
|
|
327
|
+
constructor(scope, rules, host, url = new URL("./gateway/worker.js", import.meta.url)) {
|
|
328
|
+
this.worker = new Worker(url, {
|
|
329
|
+
workerData: { scope, rules, host },
|
|
330
|
+
resourceLimits: {
|
|
331
|
+
maxOldGenerationSizeMb: 128,
|
|
332
|
+
maxYoungGenerationSizeMb: 32
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
this.worker.on(
|
|
336
|
+
"message",
|
|
337
|
+
(message) => {
|
|
338
|
+
const pending = this.pending.get(message.sequence);
|
|
339
|
+
if (!pending) return;
|
|
340
|
+
clearTimeout(pending.timer);
|
|
341
|
+
this.pending.delete(message.sequence);
|
|
342
|
+
if (message.error)
|
|
343
|
+
pending.reject(new GatewayError(message.error, message.status));
|
|
344
|
+
else pending.resolve(message.value);
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
this.worker.on("error", () => {
|
|
348
|
+
void this.close();
|
|
349
|
+
});
|
|
350
|
+
this.worker.on("exit", () => {
|
|
351
|
+
void this.close();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
call(operation) {
|
|
355
|
+
if (this.closed)
|
|
356
|
+
return Promise.reject(
|
|
357
|
+
new GatewayError("MANCODE_GATEWAY_WORKER_UNAVAILABLE", 503)
|
|
358
|
+
);
|
|
359
|
+
if (this.pending.size >= 32)
|
|
360
|
+
return Promise.reject(
|
|
361
|
+
new GatewayError("MANCODE_GATEWAY_WORKER_QUEUE_LIMIT", 429)
|
|
362
|
+
);
|
|
363
|
+
const sequence = ++this.sequence;
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
const timer = setTimeout(() => {
|
|
366
|
+
void this.close();
|
|
367
|
+
}, 1e4);
|
|
368
|
+
this.pending.set(sequence, {
|
|
369
|
+
resolve: (value) => resolve(value),
|
|
370
|
+
reject,
|
|
371
|
+
timer
|
|
372
|
+
});
|
|
373
|
+
this.worker.postMessage({ sequence, operation });
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
async close() {
|
|
377
|
+
if (this.closed) return;
|
|
378
|
+
this.closed = true;
|
|
379
|
+
for (const pending of this.pending.values()) {
|
|
380
|
+
clearTimeout(pending.timer);
|
|
381
|
+
pending.reject(
|
|
382
|
+
new GatewayError("MANCODE_GATEWAY_WORKER_UNAVAILABLE", 503)
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
this.pending.clear();
|
|
386
|
+
await this.worker.terminate();
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// src/gateway/server.ts
|
|
391
|
+
var GATEWAY_LIMITS = {
|
|
392
|
+
requestBytes: 1024 * 1024,
|
|
393
|
+
responseBytes: 4 * 1024 * 1024,
|
|
394
|
+
streamBytes: 16 * 1024 * 1024,
|
|
395
|
+
concurrentRequests: 8,
|
|
396
|
+
idleMs: 3e4,
|
|
397
|
+
generationMs: 3e5,
|
|
398
|
+
drainMs: 5e3
|
|
399
|
+
};
|
|
400
|
+
async function readBody(request, signal) {
|
|
401
|
+
const length = request.headers["content-length"];
|
|
402
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > GATEWAY_LIMITS.requestBytes))
|
|
403
|
+
throw new GatewayError("MANCODE_GATEWAY_REQUEST_LIMIT", 413);
|
|
404
|
+
const parts = [];
|
|
405
|
+
let total = 0;
|
|
406
|
+
for await (const chunk of request) {
|
|
407
|
+
signal.throwIfAborted();
|
|
408
|
+
total += chunk.length;
|
|
409
|
+
if (total > GATEWAY_LIMITS.requestBytes)
|
|
410
|
+
throw new GatewayError("MANCODE_GATEWAY_REQUEST_LIMIT", 413);
|
|
411
|
+
parts.push(chunk);
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
415
|
+
Buffer.concat(parts)
|
|
416
|
+
);
|
|
417
|
+
} catch {
|
|
418
|
+
throw new GatewayError("MANCODE_GATEWAY_INVALID_UTF8");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function emit(response, text, signal) {
|
|
422
|
+
signal.throwIfAborted();
|
|
423
|
+
if (!response.write(text)) await once(response, "drain", { signal });
|
|
424
|
+
}
|
|
425
|
+
function errorResponse(response, error) {
|
|
426
|
+
if (response.headersSent) {
|
|
427
|
+
response.destroy();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
response.writeHead(error instanceof GatewayError ? error.status : 502, {
|
|
431
|
+
"content-type": "application/json",
|
|
432
|
+
"cache-control": "no-store",
|
|
433
|
+
connection: "close"
|
|
434
|
+
});
|
|
435
|
+
response.end(
|
|
436
|
+
JSON.stringify({
|
|
437
|
+
error: {
|
|
438
|
+
code: gatewayErrorCode(error),
|
|
439
|
+
message: "Gateway request was not forwarded or could not be completed safely."
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
async function startGatewayServer(config, dependencies = {}) {
|
|
445
|
+
if (!config.enabled) throw new GatewayError("MANCODE_GATEWAY_DISABLED", 503);
|
|
446
|
+
const upstream = UPSTREAMS[config.upstreamId];
|
|
447
|
+
const upstreamKey = dependencies.upstreamKey ?? process.env[config.envKey];
|
|
448
|
+
if (!upstreamKey || /[\r\n]/.test(upstreamKey))
|
|
449
|
+
throw new GatewayError("MANCODE_GATEWAY_UPSTREAM_KEY_UNAVAILABLE", 503);
|
|
450
|
+
const instanceId = randomUUID();
|
|
451
|
+
const loadedDigest = gatewayConfigDigest(config);
|
|
452
|
+
const worker = new GatewayWorker(
|
|
453
|
+
hashValue(
|
|
454
|
+
JSON.stringify({
|
|
455
|
+
scope: config.scope,
|
|
456
|
+
instanceId,
|
|
457
|
+
upstream: config.upstreamId,
|
|
458
|
+
loadedDigest
|
|
459
|
+
})
|
|
460
|
+
),
|
|
461
|
+
config.ruleIds,
|
|
462
|
+
config.clientHost,
|
|
463
|
+
dependencies.workerUrl
|
|
464
|
+
);
|
|
465
|
+
const fetchUpstream = dependencies.fetchUpstream ?? fetch;
|
|
466
|
+
const active = /* @__PURE__ */ new Set();
|
|
467
|
+
let draining = false;
|
|
468
|
+
let port = config.port;
|
|
469
|
+
let routeObservedAt = null;
|
|
470
|
+
let observedHostBinding = null;
|
|
471
|
+
let opaqueBlocks = 0;
|
|
472
|
+
let lastAudit = null;
|
|
473
|
+
let resolveClosed = () => {
|
|
474
|
+
};
|
|
475
|
+
const closed = new Promise((resolve) => {
|
|
476
|
+
resolveClosed = resolve;
|
|
477
|
+
});
|
|
478
|
+
let stopping;
|
|
479
|
+
const health = () => ({
|
|
480
|
+
instanceId,
|
|
481
|
+
loadedDigest,
|
|
482
|
+
scope: config.scope,
|
|
483
|
+
state: draining ? "draining" : "accepting",
|
|
484
|
+
port,
|
|
485
|
+
activeRequests: active.size,
|
|
486
|
+
routeVerified: false,
|
|
487
|
+
routeObservedAt,
|
|
488
|
+
observedHostBinding,
|
|
489
|
+
coverage: "text-only-with-opaque-exclusions",
|
|
490
|
+
opaqueBlocks,
|
|
491
|
+
lastAudit,
|
|
492
|
+
history: "memory-only; restart invalidates previous_response_id"
|
|
493
|
+
});
|
|
494
|
+
const server = createServer(
|
|
495
|
+
{
|
|
496
|
+
maxHeaderSize: 16 * 1024,
|
|
497
|
+
requestTimeout: GATEWAY_LIMITS.idleMs,
|
|
498
|
+
headersTimeout: 1e4
|
|
499
|
+
},
|
|
500
|
+
(request, response) => {
|
|
501
|
+
void handle(request, response).catch(
|
|
502
|
+
(error) => errorResponse(response, error)
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
server.maxConnections = 24;
|
|
507
|
+
server.keepAliveTimeout = 5e3;
|
|
508
|
+
server.on("upgrade", (_request, socket) => {
|
|
509
|
+
socket.end(
|
|
510
|
+
"HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
server.on("connect", (_request, socket) => {
|
|
514
|
+
socket.end(
|
|
515
|
+
"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
server.on("clientError", (_error, socket) => {
|
|
519
|
+
socket.end(
|
|
520
|
+
"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
const stop = () => {
|
|
524
|
+
if (stopping) return stopping;
|
|
525
|
+
draining = true;
|
|
526
|
+
stopping = new Promise((resolve) => {
|
|
527
|
+
const timer = setTimeout(() => {
|
|
528
|
+
for (const controller of active) controller.abort();
|
|
529
|
+
server.closeAllConnections();
|
|
530
|
+
}, dependencies.drainMs ?? GATEWAY_LIMITS.drainMs);
|
|
531
|
+
server.close(() => {
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
void worker.close().finally(() => {
|
|
534
|
+
resolve();
|
|
535
|
+
resolveClosed();
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
server.closeIdleConnections();
|
|
539
|
+
});
|
|
540
|
+
return stopping;
|
|
541
|
+
};
|
|
542
|
+
async function handle(request, response) {
|
|
543
|
+
if (request.headers.host !== `127.0.0.1:${port}` || request.headers.origin !== void 0 || request.url?.startsWith("http"))
|
|
544
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_REJECTED", 403);
|
|
545
|
+
const challenge = request.method === "GET" ? /^\/__mancode\/probe\?challenge=([a-f0-9]{64})$/.exec(
|
|
546
|
+
request.url ?? ""
|
|
547
|
+
)?.[1] : void 0;
|
|
548
|
+
if (challenge) {
|
|
549
|
+
response.writeHead(200, {
|
|
550
|
+
"content-type": "application/json",
|
|
551
|
+
"cache-control": "no-store"
|
|
552
|
+
});
|
|
553
|
+
response.end(
|
|
554
|
+
JSON.stringify({
|
|
555
|
+
instanceId,
|
|
556
|
+
loadedDigest,
|
|
557
|
+
challenge,
|
|
558
|
+
proof: controlProof(
|
|
559
|
+
config.accessToken,
|
|
560
|
+
instanceId,
|
|
561
|
+
loadedDigest,
|
|
562
|
+
challenge
|
|
563
|
+
)
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const authorization = request.headers.authorization ?? (typeof request.headers["x-api-key"] === "string" ? `Bearer ${request.headers["x-api-key"]}` : "");
|
|
569
|
+
const controlAction = request.method === "GET" && request.url === "/__mancode/health" ? "health" : request.method === "POST" && request.url === "/__mancode/stop" ? "stop" : void 0;
|
|
570
|
+
const nonce = request.headers["x-mancode-control-nonce"];
|
|
571
|
+
const proof = request.headers["x-mancode-control-proof"];
|
|
572
|
+
const provedControl = controlAction && request.headers["x-mancode-instance"] === instanceId && typeof nonce === "string" && /^[a-f0-9]{64}$/.test(nonce) && typeof proof === "string" && constantTokenEquals(
|
|
573
|
+
proof,
|
|
574
|
+
controlProof(
|
|
575
|
+
config.accessToken,
|
|
576
|
+
instanceId,
|
|
577
|
+
loadedDigest,
|
|
578
|
+
nonce,
|
|
579
|
+
controlAction
|
|
580
|
+
)
|
|
581
|
+
);
|
|
582
|
+
if (!provedControl && !constantTokenEquals(authorization, `Bearer ${config.accessToken}`))
|
|
583
|
+
throw new GatewayError("MANCODE_GATEWAY_AUTH_REQUIRED", 401);
|
|
584
|
+
if (request.method === "GET" && request.url === "/__mancode/health") {
|
|
585
|
+
response.writeHead(200, {
|
|
586
|
+
"content-type": "application/json",
|
|
587
|
+
"cache-control": "no-store"
|
|
588
|
+
});
|
|
589
|
+
response.end(JSON.stringify(health()));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (request.method === "POST" && request.url === "/__mancode/stop") {
|
|
593
|
+
draining = true;
|
|
594
|
+
response.writeHead(202, {
|
|
595
|
+
"content-type": "application/json",
|
|
596
|
+
connection: "close"
|
|
597
|
+
});
|
|
598
|
+
response.end(JSON.stringify({ instanceId, state: "draining" }));
|
|
599
|
+
setImmediate(() => {
|
|
600
|
+
void stop();
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (draining) throw new GatewayError("MANCODE_GATEWAY_DRAINING", 503);
|
|
605
|
+
const metadata = request.method === "GET" && /^\/v1\/models(?:\?client_version=\d+\.\d+\.\d+)?$/.test(
|
|
606
|
+
request.url ?? ""
|
|
607
|
+
);
|
|
608
|
+
const generationPath = upstream.protocol === "responses" ? request.url === "/v1/responses" : request.url === "/v1/messages" || request.url === "/v1/messages?beta=true";
|
|
609
|
+
if (!metadata && (request.method !== "POST" || !generationPath))
|
|
610
|
+
throw new GatewayError("MANCODE_GATEWAY_PATH_UNSUPPORTED", 404);
|
|
611
|
+
if (request.headers["content-encoding"] && request.headers["content-encoding"] !== "identity")
|
|
612
|
+
throw new GatewayError("MANCODE_GATEWAY_ENCODING_UNSUPPORTED", 415);
|
|
613
|
+
if (!metadata && !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
|
|
614
|
+
request.headers["content-type"] ?? ""
|
|
615
|
+
))
|
|
616
|
+
throw new GatewayError("MANCODE_GATEWAY_CONTENT_TYPE_UNSUPPORTED", 415);
|
|
617
|
+
if (active.size >= GATEWAY_LIMITS.concurrentRequests)
|
|
618
|
+
throw new GatewayError("MANCODE_GATEWAY_CONCURRENCY_LIMIT", 429);
|
|
619
|
+
const controller = new AbortController();
|
|
620
|
+
active.add(controller);
|
|
621
|
+
const id = randomUUID();
|
|
622
|
+
const timer = setTimeout(
|
|
623
|
+
() => controller.abort(),
|
|
624
|
+
GATEWAY_LIMITS.generationMs
|
|
625
|
+
);
|
|
626
|
+
request.setTimeout(GATEWAY_LIMITS.idleMs, () => {
|
|
627
|
+
controller.abort();
|
|
628
|
+
request.destroy();
|
|
629
|
+
});
|
|
630
|
+
response.on("close", () => {
|
|
631
|
+
if (!response.writableEnded) controller.abort();
|
|
632
|
+
});
|
|
633
|
+
let began = false;
|
|
634
|
+
try {
|
|
635
|
+
const headers = {
|
|
636
|
+
accept: "application/json, text/event-stream",
|
|
637
|
+
"accept-encoding": "identity"
|
|
638
|
+
};
|
|
639
|
+
if (config.upstreamId === "openai")
|
|
640
|
+
headers.authorization = `Bearer ${upstreamKey}`;
|
|
641
|
+
else {
|
|
642
|
+
headers["x-api-key"] = upstreamKey;
|
|
643
|
+
const version = request.headers["anthropic-version"];
|
|
644
|
+
if (version !== void 0 && (typeof version !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(version)))
|
|
645
|
+
throw new GatewayError("MANCODE_GATEWAY_HEADER_UNSUPPORTED");
|
|
646
|
+
headers["anthropic-version"] = typeof version === "string" ? version : "2023-06-01";
|
|
647
|
+
const beta = request.headers["anthropic-beta"];
|
|
648
|
+
if (beta !== void 0) {
|
|
649
|
+
if (typeof beta !== "string" || !/^[a-z0-9, -]{1,512}$/.test(beta))
|
|
650
|
+
throw new GatewayError("MANCODE_GATEWAY_HEADER_UNSUPPORTED");
|
|
651
|
+
headers["anthropic-beta"] = beta;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
let body;
|
|
655
|
+
if (!metadata) {
|
|
656
|
+
const input = await readBody(request, controller.signal);
|
|
657
|
+
const masked = await worker.call({
|
|
658
|
+
kind: "begin",
|
|
659
|
+
id,
|
|
660
|
+
body: input,
|
|
661
|
+
protocol: upstream.protocol
|
|
662
|
+
});
|
|
663
|
+
began = true;
|
|
664
|
+
controller.signal.throwIfAborted();
|
|
665
|
+
body = masked.body;
|
|
666
|
+
opaqueBlocks += masked.opaqueBlocks;
|
|
667
|
+
headers["content-type"] = "application/json";
|
|
668
|
+
} else if (request.headers["content-length"] || request.headers["transfer-encoding"])
|
|
669
|
+
throw new GatewayError("MANCODE_GATEWAY_METADATA_BODY_REJECTED");
|
|
670
|
+
const upstreamResponse = await fetchUpstream(
|
|
671
|
+
`${upstream.origin}${request.url}`,
|
|
672
|
+
{
|
|
673
|
+
method: request.method,
|
|
674
|
+
headers,
|
|
675
|
+
body,
|
|
676
|
+
redirect: "manual",
|
|
677
|
+
signal: controller.signal
|
|
678
|
+
}
|
|
679
|
+
);
|
|
680
|
+
if (!upstreamResponse.ok || !upstreamResponse.body)
|
|
681
|
+
throw new GatewayError("MANCODE_GATEWAY_UPSTREAM_REJECTED", 502);
|
|
682
|
+
const encoding = upstreamResponse.headers.get("content-encoding");
|
|
683
|
+
if (encoding && encoding !== "identity")
|
|
684
|
+
throw new GatewayError("MANCODE_GATEWAY_ENCODING_UNSUPPORTED", 502);
|
|
685
|
+
const contentType = upstreamResponse.headers.get("content-type") ?? "";
|
|
686
|
+
if (!metadata) {
|
|
687
|
+
routeObservedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
688
|
+
observedHostBinding = request.headers["x-mancode-host"] === config.clientHost ? config.clientHost : "unverified-client";
|
|
689
|
+
}
|
|
690
|
+
const reader = upstreamResponse.body.getReader();
|
|
691
|
+
let total = 0;
|
|
692
|
+
const parts = [];
|
|
693
|
+
const decoder = new SseDecoder();
|
|
694
|
+
const streaming = !metadata && /^text\/event-stream(?:;|$)/i.test(contentType);
|
|
695
|
+
if (!streaming && !/^application\/json(?:;|$)/i.test(contentType))
|
|
696
|
+
throw new GatewayError(
|
|
697
|
+
"MANCODE_GATEWAY_UPSTREAM_CONTENT_UNSUPPORTED",
|
|
698
|
+
502
|
|
699
|
+
);
|
|
700
|
+
if (streaming)
|
|
701
|
+
response.writeHead(200, {
|
|
702
|
+
"content-type": "text/event-stream",
|
|
703
|
+
"cache-control": "no-store",
|
|
704
|
+
"x-mancode-coverage": "text-only; opaque-blocks-excluded"
|
|
705
|
+
});
|
|
706
|
+
for (; ; ) {
|
|
707
|
+
const idle = setTimeout(
|
|
708
|
+
() => controller.abort(),
|
|
709
|
+
GATEWAY_LIMITS.idleMs
|
|
710
|
+
);
|
|
711
|
+
let result;
|
|
712
|
+
try {
|
|
713
|
+
result = await reader.read();
|
|
714
|
+
} finally {
|
|
715
|
+
clearTimeout(idle);
|
|
716
|
+
}
|
|
717
|
+
controller.signal.throwIfAborted();
|
|
718
|
+
if (result.done) break;
|
|
719
|
+
total += result.value.length;
|
|
720
|
+
if (total > (streaming ? GATEWAY_LIMITS.streamBytes : GATEWAY_LIMITS.responseBytes))
|
|
721
|
+
throw new GatewayError("MANCODE_GATEWAY_RESPONSE_LIMIT", 502);
|
|
722
|
+
if (!streaming) parts.push(Buffer.from(result.value));
|
|
723
|
+
else
|
|
724
|
+
for (const frame of decoder.push(result.value)) {
|
|
725
|
+
const transformed = await worker.call({ kind: "frame", id, frame });
|
|
726
|
+
opaqueBlocks = Math.max(opaqueBlocks, transformed.opaqueBlocks);
|
|
727
|
+
for (const output of transformed.frames)
|
|
728
|
+
await emit(response, encodeSse(output), controller.signal);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (streaming) {
|
|
732
|
+
decoder.push(new Uint8Array(), true);
|
|
733
|
+
const finished = await worker.call({
|
|
734
|
+
kind: "finish",
|
|
735
|
+
id
|
|
736
|
+
});
|
|
737
|
+
lastAudit = finished.audit;
|
|
738
|
+
response.end();
|
|
739
|
+
} else {
|
|
740
|
+
let original;
|
|
741
|
+
try {
|
|
742
|
+
original = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
743
|
+
Buffer.concat(parts)
|
|
744
|
+
);
|
|
745
|
+
} catch {
|
|
746
|
+
throw new GatewayError("MANCODE_GATEWAY_INVALID_UTF8", 502);
|
|
747
|
+
}
|
|
748
|
+
const output = metadata ? { body: original, audit: null } : await worker.call({
|
|
749
|
+
kind: "response",
|
|
750
|
+
id,
|
|
751
|
+
body: original
|
|
752
|
+
});
|
|
753
|
+
lastAudit = output.audit;
|
|
754
|
+
response.writeHead(200, {
|
|
755
|
+
"content-type": "application/json",
|
|
756
|
+
"cache-control": "no-store",
|
|
757
|
+
"x-mancode-coverage": metadata ? "metadata-endpoint" : "text-only; opaque-blocks-excluded"
|
|
758
|
+
});
|
|
759
|
+
response.end(output.body);
|
|
760
|
+
}
|
|
761
|
+
} catch (error) {
|
|
762
|
+
dependencies.onDiagnostic?.(gatewayErrorCode(error));
|
|
763
|
+
controller.abort();
|
|
764
|
+
errorResponse(response, error);
|
|
765
|
+
} finally {
|
|
766
|
+
clearTimeout(timer);
|
|
767
|
+
active.delete(controller);
|
|
768
|
+
if (began) await worker.call({ kind: "release", id }).catch(() => {
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
await new Promise((resolve, reject) => {
|
|
774
|
+
server.once("error", reject);
|
|
775
|
+
server.listen(config.port, "127.0.0.1", () => {
|
|
776
|
+
server.removeListener("error", reject);
|
|
777
|
+
resolve();
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
const address = server.address();
|
|
781
|
+
if (!address || typeof address === "string")
|
|
782
|
+
throw new GatewayError("MANCODE_GATEWAY_BIND_FAILED", 503);
|
|
783
|
+
port = address.port;
|
|
784
|
+
return { port, instanceId, health, stop, closed };
|
|
785
|
+
} catch {
|
|
786
|
+
await worker.close();
|
|
787
|
+
throw new GatewayError("MANCODE_GATEWAY_BIND_FAILED", 503);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/commands/privacy-gateway.ts
|
|
792
|
+
async function control(config, record, action) {
|
|
793
|
+
const challenge = randomBytes2(32).toString("hex");
|
|
794
|
+
const probe = await fetch(
|
|
795
|
+
`http://127.0.0.1:${record.port}/__mancode/probe?challenge=${challenge}`,
|
|
796
|
+
{ signal: AbortSignal.timeout(750), redirect: "error" }
|
|
797
|
+
);
|
|
798
|
+
const proof = await readControlJson(probe, 2048);
|
|
799
|
+
if (proof.instanceId !== record.instanceId || proof.loadedDigest !== record.loadedDigest || proof.challenge !== challenge || typeof proof.proof !== "string" || !constantTokenEquals(
|
|
800
|
+
proof.proof,
|
|
801
|
+
controlProof(
|
|
802
|
+
config.accessToken,
|
|
803
|
+
record.instanceId,
|
|
804
|
+
record.loadedDigest,
|
|
805
|
+
challenge
|
|
806
|
+
)
|
|
807
|
+
))
|
|
808
|
+
throw new GatewayError("MANCODE_GATEWAY_RUNTIME_UNCONFIRMED");
|
|
809
|
+
const response = await fetch(
|
|
810
|
+
`http://127.0.0.1:${record.port}/__mancode/${action}`,
|
|
811
|
+
{
|
|
812
|
+
method: action === "stop" ? "POST" : "GET",
|
|
813
|
+
headers: {
|
|
814
|
+
"x-mancode-instance": record.instanceId,
|
|
815
|
+
"x-mancode-control-nonce": challenge,
|
|
816
|
+
"x-mancode-control-proof": controlProof(
|
|
817
|
+
config.accessToken,
|
|
818
|
+
record.instanceId,
|
|
819
|
+
record.loadedDigest,
|
|
820
|
+
challenge,
|
|
821
|
+
action
|
|
822
|
+
)
|
|
823
|
+
},
|
|
824
|
+
signal: AbortSignal.timeout(750),
|
|
825
|
+
redirect: "error"
|
|
826
|
+
}
|
|
827
|
+
);
|
|
828
|
+
return readControlJson(response, 32 * 1024);
|
|
829
|
+
}
|
|
830
|
+
async function readControlJson(response, limit) {
|
|
831
|
+
if (!response.ok || !response.body)
|
|
832
|
+
throw new GatewayError("MANCODE_GATEWAY_RUNTIME_UNCONFIRMED");
|
|
833
|
+
const reader = response.body.getReader();
|
|
834
|
+
const parts = [];
|
|
835
|
+
let size = 0;
|
|
836
|
+
for (; ; ) {
|
|
837
|
+
const value = await reader.read();
|
|
838
|
+
if (value.done) break;
|
|
839
|
+
size += value.value.length;
|
|
840
|
+
if (size > limit) {
|
|
841
|
+
await reader.cancel();
|
|
842
|
+
throw new GatewayError("MANCODE_GATEWAY_RUNTIME_UNCONFIRMED");
|
|
843
|
+
}
|
|
844
|
+
parts.push(Buffer.from(value.value));
|
|
845
|
+
}
|
|
846
|
+
return JSON.parse(Buffer.concat(parts).toString("utf8"));
|
|
847
|
+
}
|
|
848
|
+
async function inspectRuntime(root, config) {
|
|
849
|
+
const location = await gatewayLocation(root);
|
|
850
|
+
const raw = await readPrivateJson(
|
|
851
|
+
path2.join(location.directory, "runtime.json")
|
|
852
|
+
);
|
|
853
|
+
if (raw === null) return { state: "stopped", record: null, health: null };
|
|
854
|
+
const record = raw;
|
|
855
|
+
if (record.schemaVersion !== 1 || typeof record.instanceId !== "string" || !/^[a-f0-9-]{36}$/.test(record.instanceId) || !Number.isSafeInteger(record.processId) || record.processId < 1 || !Number.isInteger(record.port) || record.port < 1024 || record.port > 65535 || typeof record.loadedDigest !== "string" || !/^[a-f0-9]{64}$/.test(record.loadedDigest) || JSON.stringify(record.scope) !== JSON.stringify(config.scope))
|
|
856
|
+
throw new GatewayError("MANCODE_GATEWAY_RUNTIME_RECORD_INVALID");
|
|
857
|
+
if (!processIsAlive(record.processId))
|
|
858
|
+
return { state: "stopped", record, health: null };
|
|
859
|
+
try {
|
|
860
|
+
const health = await control(config, record, "health");
|
|
861
|
+
if (health.instanceId !== record.instanceId || health.loadedDigest !== record.loadedDigest || JSON.stringify(health.scope) !== JSON.stringify(record.scope) || health.port !== record.port || !["accepting", "draining"].includes(health.state) || health.routeVerified !== false)
|
|
862
|
+
throw new GatewayError("MANCODE_GATEWAY_RUNTIME_SCOPE_MISMATCH");
|
|
863
|
+
return { state: health.state, record, health };
|
|
864
|
+
} catch {
|
|
865
|
+
return {
|
|
866
|
+
state: processIsAlive(record.processId) ? "unconfirmed" : "stopped",
|
|
867
|
+
record,
|
|
868
|
+
health: null
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function readPrivacyGatewayStatus(root = process.cwd()) {
|
|
873
|
+
const empty = {
|
|
874
|
+
schemaVersion: 1,
|
|
875
|
+
configured: false,
|
|
876
|
+
enabled: false,
|
|
877
|
+
configurationStatus: "missing",
|
|
878
|
+
runtime: "stopped",
|
|
879
|
+
routeVerified: false,
|
|
880
|
+
routeObservedAt: null,
|
|
881
|
+
observedHostBinding: null,
|
|
882
|
+
loadedDigest: null,
|
|
883
|
+
configuredDigest: null,
|
|
884
|
+
pendingRestart: false,
|
|
885
|
+
scope: null,
|
|
886
|
+
upstreamId: null,
|
|
887
|
+
clientHost: null,
|
|
888
|
+
coverage: "Responses/Anthropic Messages HTTP/SSE text only; opaque thinking/signature/encrypted blocks excluded; unsupported executable tool restoration blocked"
|
|
889
|
+
};
|
|
890
|
+
try {
|
|
891
|
+
const config = await readGatewayConfig(root);
|
|
892
|
+
if (!config) return empty;
|
|
893
|
+
const runtime = await inspectRuntime(root, config);
|
|
894
|
+
const configuredDigest = gatewayConfigDigest(config);
|
|
895
|
+
return {
|
|
896
|
+
...empty,
|
|
897
|
+
configured: true,
|
|
898
|
+
enabled: config.enabled,
|
|
899
|
+
configurationStatus: "valid",
|
|
900
|
+
runtime: runtime.state,
|
|
901
|
+
configuredDigest,
|
|
902
|
+
loadedDigest: runtime.health?.loadedDigest ?? null,
|
|
903
|
+
pendingRestart: Boolean(
|
|
904
|
+
runtime.health && runtime.health.loadedDigest !== configuredDigest
|
|
905
|
+
),
|
|
906
|
+
routeObservedAt: runtime.health?.routeObservedAt ?? null,
|
|
907
|
+
observedHostBinding: runtime.health?.observedHostBinding ?? null,
|
|
908
|
+
scope: config.scope,
|
|
909
|
+
upstreamId: config.upstreamId,
|
|
910
|
+
clientHost: config.clientHost
|
|
911
|
+
};
|
|
912
|
+
} catch (error) {
|
|
913
|
+
return {
|
|
914
|
+
...empty,
|
|
915
|
+
enabled: null,
|
|
916
|
+
configurationStatus: "invalid",
|
|
917
|
+
runtime: "unconfirmed",
|
|
918
|
+
error: gatewayErrorCode(error)
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function configurePrivacyGateway(root, options) {
|
|
923
|
+
await configureGateway(root, options);
|
|
924
|
+
return readPrivacyGatewayStatus(root);
|
|
925
|
+
}
|
|
926
|
+
async function disablePrivacyGateway(root) {
|
|
927
|
+
const config = await readGatewayConfig(root);
|
|
928
|
+
if (!config) return readPrivacyGatewayStatus(root);
|
|
929
|
+
await configureGateway(root, { enabled: false });
|
|
930
|
+
const runtime = await inspectRuntime(root, config);
|
|
931
|
+
if (runtime.health && runtime.record) {
|
|
932
|
+
const stopped = await control(config, runtime.record, "stop");
|
|
933
|
+
if (stopped.instanceId !== runtime.record.instanceId)
|
|
934
|
+
throw new GatewayError("MANCODE_GATEWAY_RUNTIME_SCOPE_MISMATCH");
|
|
935
|
+
for (let attempt = 0; attempt < 60; attempt++) {
|
|
936
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
937
|
+
const status = await readPrivacyGatewayStatus(root);
|
|
938
|
+
if (status.runtime === "stopped") return status;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return readPrivacyGatewayStatus(root);
|
|
942
|
+
}
|
|
943
|
+
async function verifyInstalledHost(config) {
|
|
944
|
+
if (config.clientHost === "unverified") return "unverified";
|
|
945
|
+
const executable = config.clientHost.startsWith("codex-cli/") ? "codex" : "claude";
|
|
946
|
+
try {
|
|
947
|
+
const result = await promisify(execFile)(executable, ["--version"], {
|
|
948
|
+
timeout: 5e3,
|
|
949
|
+
maxBuffer: 16 * 1024
|
|
950
|
+
});
|
|
951
|
+
const actual = executable === "codex" ? /codex-cli (\d+\.\d+\.\d+)/.exec(result.stdout)?.[1] : /^(\d+\.\d+\.\d+) \(Claude Code\)/.exec(result.stdout)?.[1];
|
|
952
|
+
if (actual !== config.clientHost.split("/")[1])
|
|
953
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_VERSION_UNVERIFIED");
|
|
954
|
+
return config.clientHost;
|
|
955
|
+
} catch {
|
|
956
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_VERSION_UNVERIFIED");
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
async function runPrivacyGateway(root, dependencies = {}) {
|
|
960
|
+
const binding = await gatewayLocation(root);
|
|
961
|
+
if (!binding.scope.workspaceId || !binding.scope.checkoutId)
|
|
962
|
+
throw new GatewayError("MANCODE_GATEWAY_NOT_INITIALIZED");
|
|
963
|
+
const { config, server, file } = await withGatewayLifecycleLock(
|
|
964
|
+
root,
|
|
965
|
+
async () => {
|
|
966
|
+
const config2 = await readGatewayConfig(root);
|
|
967
|
+
if (!config2?.enabled) throw new GatewayError("MANCODE_GATEWAY_DISABLED");
|
|
968
|
+
await verifyInstalledHost(config2);
|
|
969
|
+
const runtime = await inspectRuntime(root, config2);
|
|
970
|
+
if (runtime.state !== "stopped")
|
|
971
|
+
throw new GatewayError(
|
|
972
|
+
"MANCODE_GATEWAY_ALREADY_RUNNING_OR_UNCONFIRMED"
|
|
973
|
+
);
|
|
974
|
+
const location = await gatewayLocation(root);
|
|
975
|
+
const server2 = await (dependencies.startServer ?? startGatewayServer)(
|
|
976
|
+
config2
|
|
977
|
+
);
|
|
978
|
+
const record = {
|
|
979
|
+
schemaVersion: 1,
|
|
980
|
+
instanceId: server2.instanceId,
|
|
981
|
+
processId: process.pid,
|
|
982
|
+
port: server2.port,
|
|
983
|
+
scope: config2.scope,
|
|
984
|
+
loadedDigest: gatewayConfigDigest(config2),
|
|
985
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
986
|
+
};
|
|
987
|
+
const file2 = path2.join(location.directory, "runtime.json");
|
|
988
|
+
try {
|
|
989
|
+
await writePrivateJson(file2, record);
|
|
990
|
+
} catch (error) {
|
|
991
|
+
await server2.stop();
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
return { config: config2, server: server2, file: file2 };
|
|
995
|
+
}
|
|
996
|
+
);
|
|
997
|
+
const stop = () => {
|
|
998
|
+
void server.stop();
|
|
999
|
+
};
|
|
1000
|
+
try {
|
|
1001
|
+
process.once("SIGINT", stop);
|
|
1002
|
+
process.once("SIGTERM", stop);
|
|
1003
|
+
process.stdout.write(
|
|
1004
|
+
`${JSON.stringify({ gateway: "running", ...server.health(), clientHost: config.clientHost, note: "Dedicated checkout instance. Route verification requires current client evidence. No client configuration was changed." })}
|
|
1005
|
+
`
|
|
1006
|
+
);
|
|
1007
|
+
await server.closed;
|
|
1008
|
+
} finally {
|
|
1009
|
+
process.removeListener("SIGINT", stop);
|
|
1010
|
+
process.removeListener("SIGTERM", stop);
|
|
1011
|
+
await server.stop();
|
|
1012
|
+
const current = await readPrivateJson(file);
|
|
1013
|
+
if (current?.instanceId === server.instanceId)
|
|
1014
|
+
await rm2(file, { force: true });
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function printPrivacyGatewayConfig(root, host) {
|
|
1018
|
+
const config = await readGatewayConfig(root);
|
|
1019
|
+
if (!config) throw new GatewayError("MANCODE_GATEWAY_NOT_CONFIGURED");
|
|
1020
|
+
const { directory } = await gatewayLocation(root);
|
|
1021
|
+
const note = `# Review before applying. Read accessToken from the private file ${path2.join(directory, "config.json")}.
|
|
1022
|
+
# Put it in MANCODE_GATEWAY_TOKEN for this shell only. No credentials are printed here.
|
|
1023
|
+
# Only this checkout is covered; no desktop, subscription, or cloud routing claim.
|
|
1024
|
+
`;
|
|
1025
|
+
if (host === "codex") {
|
|
1026
|
+
if (config.upstreamId !== "openai")
|
|
1027
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_UPSTREAM_MISMATCH");
|
|
1028
|
+
return `${note}# User-level Codex configuration fragment; choose this provider explicitly.
|
|
1029
|
+
[model_providers.mancode_privacy]
|
|
1030
|
+
name = "mancode privacy"
|
|
1031
|
+
base_url = "http://127.0.0.1:${config.port}/v1"
|
|
1032
|
+
wire_api = "responses"
|
|
1033
|
+
env_key = "MANCODE_GATEWAY_TOKEN"
|
|
1034
|
+
supports_websockets = false
|
|
1035
|
+
http_headers = { "X-Mancode-Host" = "${config.clientHost}" }
|
|
1036
|
+
`;
|
|
1037
|
+
}
|
|
1038
|
+
if (host === "claude") {
|
|
1039
|
+
if (config.upstreamId !== "anthropic")
|
|
1040
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_UPSTREAM_MISMATCH");
|
|
1041
|
+
return `${note}# Shell fragment for an explicit Claude Code API-key session.
|
|
1042
|
+
export ANTHROPIC_BASE_URL='http://127.0.0.1:${config.port}'
|
|
1043
|
+
export ANTHROPIC_API_KEY="$MANCODE_GATEWAY_TOKEN"
|
|
1044
|
+
export ANTHROPIC_CUSTOM_HEADERS='X-Mancode-Host: ${config.clientHost}'
|
|
1045
|
+
# Run the gateway in a separate shell with the real upstream env-key reference.
|
|
1046
|
+
`;
|
|
1047
|
+
}
|
|
1048
|
+
throw new GatewayError("MANCODE_GATEWAY_HOST_UNSUPPORTED");
|
|
1049
|
+
}
|
|
1050
|
+
function registerPrivacyGatewayCommands(privacy) {
|
|
1051
|
+
const gateway = privacy.command("gateway").description("Configure and run the optional private local model gateway");
|
|
1052
|
+
const execute = (action) => action().then((value) => {
|
|
1053
|
+
if (value !== void 0)
|
|
1054
|
+
process.stdout.write(
|
|
1055
|
+
`${typeof value === "string" ? value : JSON.stringify(value, null, 2)}
|
|
1056
|
+
`
|
|
1057
|
+
);
|
|
1058
|
+
}).catch((error) => {
|
|
1059
|
+
process.stderr.write(
|
|
1060
|
+
`${JSON.stringify({ error: gatewayErrorCode(error) })}
|
|
1061
|
+
`
|
|
1062
|
+
);
|
|
1063
|
+
process.exitCode = 2;
|
|
1064
|
+
});
|
|
1065
|
+
gateway.command("enable").description("Record intent; does not start or reroute a client").option("--upstream <id>", "openai or anthropic").option(
|
|
1066
|
+
"--env-key <name>",
|
|
1067
|
+
"Reference to the upstream key environment variable"
|
|
1068
|
+
).option(
|
|
1069
|
+
"--client-host <host>",
|
|
1070
|
+
"codex-cli/0.153.4, claude-code/2.1.142, or unverified"
|
|
1071
|
+
).option("--port <port>", "Loopback port, 1024\u201365535").option("--json").action(
|
|
1072
|
+
(options) => execute(
|
|
1073
|
+
() => configurePrivacyGateway(process.cwd(), {
|
|
1074
|
+
enabled: true,
|
|
1075
|
+
upstreamId: options.upstream,
|
|
1076
|
+
envKey: options.envKey,
|
|
1077
|
+
clientHost: options.clientHost,
|
|
1078
|
+
port: options.port === void 0 ? void 0 : Number(options.port)
|
|
1079
|
+
})
|
|
1080
|
+
)
|
|
1081
|
+
);
|
|
1082
|
+
gateway.command("disable").description(
|
|
1083
|
+
"Stop new requests and drain the current instance; never enable plaintext forwarding"
|
|
1084
|
+
).option("--json").action(() => execute(() => disablePrivacyGateway(process.cwd())));
|
|
1085
|
+
gateway.command("status").description("Read configured intent and authenticated runtime evidence").option("--json").action(() => execute(() => readPrivacyGatewayStatus()));
|
|
1086
|
+
gateway.command("doctor").description(
|
|
1087
|
+
"Read local runtime and installed host version; does not call a model"
|
|
1088
|
+
).option("--json").action(
|
|
1089
|
+
() => execute(async () => {
|
|
1090
|
+
const status = await readPrivacyGatewayStatus();
|
|
1091
|
+
const config = await readGatewayConfig(process.cwd());
|
|
1092
|
+
let hostVersion = "unverified";
|
|
1093
|
+
if (config) {
|
|
1094
|
+
try {
|
|
1095
|
+
hostVersion = await verifyInstalledHost(config);
|
|
1096
|
+
} catch {
|
|
1097
|
+
hostVersion = "version-mismatch-or-unavailable";
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
...status,
|
|
1102
|
+
installedHost: hostVersion,
|
|
1103
|
+
upstreamKeyPresent: Boolean(config && process.env[config.envKey]),
|
|
1104
|
+
routeVerified: false
|
|
1105
|
+
};
|
|
1106
|
+
})
|
|
1107
|
+
);
|
|
1108
|
+
gateway.command("run").description("Run one dedicated checkout instance in the foreground").action(() => execute(() => runPrivacyGateway(process.cwd())));
|
|
1109
|
+
gateway.command("print-config").description(
|
|
1110
|
+
"Print a reviewable fragment without credentials or applying it"
|
|
1111
|
+
).requiredOption("--host <host>", "codex or claude").action(
|
|
1112
|
+
(options) => execute(() => printPrivacyGatewayConfig(process.cwd(), options.host))
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export {
|
|
1117
|
+
readPrivacyGatewayStatus,
|
|
1118
|
+
configurePrivacyGateway,
|
|
1119
|
+
disablePrivacyGateway,
|
|
1120
|
+
runPrivacyGateway,
|
|
1121
|
+
printPrivacyGatewayConfig,
|
|
1122
|
+
registerPrivacyGatewayCommands
|
|
1123
|
+
};
|
|
1124
|
+
//# sourceMappingURL=chunk-THOE33LU.js.map
|