@hotcell/daemon 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/dist/agent-check.d.ts +1 -0
- package/dist/agent-check.js +105 -0
- package/dist/agent-check.js.map +1 -0
- package/dist/api/server.d.ts +64 -0
- package/dist/api/server.js +1368 -0
- package/dist/api/server.js.map +1 -0
- package/dist/api/ws.d.ts +14 -0
- package/dist/api/ws.js +214 -0
- package/dist/api/ws.js.map +1 -0
- package/dist/applevz-check.d.ts +1 -0
- package/dist/applevz-check.js +280 -0
- package/dist/applevz-check.js.map +1 -0
- package/dist/backups.d.ts +29 -0
- package/dist/backups.js +76 -0
- package/dist/backups.js.map +1 -0
- package/dist/capacity.d.ts +63 -0
- package/dist/capacity.js +95 -0
- package/dist/capacity.js.map +1 -0
- package/dist/config.d.ts +241 -0
- package/dist/config.js +152 -0
- package/dist/config.js.map +1 -0
- package/dist/cost.d.ts +9 -0
- package/dist/cost.js +19 -0
- package/dist/cost.js.map +1 -0
- package/dist/density-bench.d.ts +1 -0
- package/dist/density-bench.js +97 -0
- package/dist/density-bench.js.map +1 -0
- package/dist/driver/agent-driver.d.ts +67 -0
- package/dist/driver/agent-driver.js +197 -0
- package/dist/driver/agent-driver.js.map +1 -0
- package/dist/driver/agent.d.ts +94 -0
- package/dist/driver/agent.js +319 -0
- package/dist/driver/agent.js.map +1 -0
- package/dist/driver/applevz.d.ts +95 -0
- package/dist/driver/applevz.js +527 -0
- package/dist/driver/applevz.js.map +1 -0
- package/dist/driver/container.d.ts +99 -0
- package/dist/driver/container.js +733 -0
- package/dist/driver/container.js.map +1 -0
- package/dist/driver/fc-api.d.ts +85 -0
- package/dist/driver/fc-api.js +173 -0
- package/dist/driver/fc-api.js.map +1 -0
- package/dist/driver/firecracker.d.ts +126 -0
- package/dist/driver/firecracker.js +560 -0
- package/dist/driver/firecracker.js.map +1 -0
- package/dist/driver/index.d.ts +17 -0
- package/dist/driver/index.js +61 -0
- package/dist/driver/index.js.map +1 -0
- package/dist/driver/router.d.ts +82 -0
- package/dist/driver/router.js +159 -0
- package/dist/driver/router.js.map +1 -0
- package/dist/driver/types.d.ts +234 -0
- package/dist/driver/types.js +2 -0
- package/dist/driver/types.js.map +1 -0
- package/dist/driver/unsupported.d.ts +50 -0
- package/dist/driver/unsupported.js +81 -0
- package/dist/driver/unsupported.js.map +1 -0
- package/dist/driver/vz-image.d.ts +35 -0
- package/dist/driver/vz-image.js +75 -0
- package/dist/driver/vz-image.js.map +1 -0
- package/dist/e2e-harness.d.ts +1 -0
- package/dist/e2e-harness.js +99 -0
- package/dist/e2e-harness.js.map +1 -0
- package/dist/egress-check.d.ts +8 -0
- package/dist/egress-check.js +317 -0
- package/dist/egress-check.js.map +1 -0
- package/dist/fc-check.d.ts +1 -0
- package/dist/fc-check.js +113 -0
- package/dist/fc-check.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/kernels.d.ts +22 -0
- package/dist/kernels.js +134 -0
- package/dist/kernels.js.map +1 -0
- package/dist/lifecycle.d.ts +49 -0
- package/dist/lifecycle.js +111 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.js +67 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.d.ts +46 -0
- package/dist/metrics.js +92 -0
- package/dist/metrics.js.map +1 -0
- package/dist/net/firewall.d.ts +4 -0
- package/dist/net/firewall.js +80 -0
- package/dist/net/firewall.js.map +1 -0
- package/dist/pricing.d.ts +36 -0
- package/dist/pricing.js +81 -0
- package/dist/pricing.js.map +1 -0
- package/dist/proxy/allowlist-default.d.ts +23 -0
- package/dist/proxy/allowlist-default.js +53 -0
- package/dist/proxy/allowlist-default.js.map +1 -0
- package/dist/proxy/allowlist.d.ts +43 -0
- package/dist/proxy/allowlist.js +87 -0
- package/dist/proxy/allowlist.js.map +1 -0
- package/dist/proxy/egress.d.ts +49 -0
- package/dist/proxy/egress.js +599 -0
- package/dist/proxy/egress.js.map +1 -0
- package/dist/proxy/ratelimit.d.ts +49 -0
- package/dist/proxy/ratelimit.js +74 -0
- package/dist/proxy/ratelimit.js.map +1 -0
- package/dist/proxy/server.d.ts +26 -0
- package/dist/proxy/server.js +202 -0
- package/dist/proxy/server.js.map +1 -0
- package/dist/router-check.d.ts +1 -0
- package/dist/router-check.js +97 -0
- package/dist/router-check.js.map +1 -0
- package/dist/smoke-fc.d.ts +1 -0
- package/dist/smoke-fc.js +183 -0
- package/dist/smoke-fc.js.map +1 -0
- package/dist/smoke-microvm.d.ts +1 -0
- package/dist/smoke-microvm.js +187 -0
- package/dist/smoke-microvm.js.map +1 -0
- package/dist/smoke-remote.d.ts +20 -0
- package/dist/smoke-remote.js +155 -0
- package/dist/smoke-remote.js.map +1 -0
- package/dist/smoke-vz.d.ts +1 -0
- package/dist/smoke-vz.js +161 -0
- package/dist/smoke-vz.js.map +1 -0
- package/dist/smoke.d.ts +7 -0
- package/dist/smoke.js +962 -0
- package/dist/smoke.js.map +1 -0
- package/dist/store.d.ts +124 -0
- package/dist/store.js +578 -0
- package/dist/store.js.map +1 -0
- package/dist/tracing.d.ts +45 -0
- package/dist/tracing.js +144 -0
- package/dist/tracing.js.map +1 -0
- package/dist/types.d.ts +274 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +27 -0
- package/dist/util.js +63 -0
- package/dist/util.js.map +1 -0
- package/dist/warmpool-check.d.ts +1 -0
- package/dist/warmpool-check.js +83 -0
- package/dist/warmpool-check.js.map +1 -0
- package/dist/web/dashboard.d.ts +1 -0
- package/dist/web/dashboard.js +456 -0
- package/dist/web/dashboard.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"allowlist.js","sourceRoot":"","sources":["../../src/proxy/allowlist.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import type { Config } from "../config.js";
|
|
3
|
+
import { type ModelPrices } from "../pricing.js";
|
|
4
|
+
import type { SandboxStore } from "../store.js";
|
|
5
|
+
import { Allowlist } from "./allowlist.js";
|
|
6
|
+
import { RateLimiter } from "./ratelimit.js";
|
|
7
|
+
/**
|
|
8
|
+
* Egress credential proxy — an LLM gateway, not a TLS-MITM forward proxy.
|
|
9
|
+
*
|
|
10
|
+
* Sandboxes call `http://<egress>/<provider>/<path>` with a per-sandbox **egress
|
|
11
|
+
* token** in place of a real key (e.g. `OPENAI_BASE_URL=http://egress/openai`,
|
|
12
|
+
* `OPENAI_API_KEY=<egress-token>`). The gateway resolves the token to a sandbox,
|
|
13
|
+
* injects the **real provider key** (held on the daemon host, never inside the
|
|
14
|
+
* sandbox), forwards to the provider, and meters the call (bytes + parsed
|
|
15
|
+
* prompt/completion tokens). This gives "reach any LLM provider without baking in
|
|
16
|
+
* keys" + per-sandbox cost/observability, with no CA/cert install (which a
|
|
17
|
+
* transparent HTTPS forward proxy would require).
|
|
18
|
+
*/
|
|
19
|
+
export interface ResolvedProvider {
|
|
20
|
+
/** Upstream base URL, e.g. `https://api.openai.com`. */
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
/** Header the provider authenticates with (e.g. `authorization`, `x-api-key`). */
|
|
23
|
+
authHeader: string;
|
|
24
|
+
/** Render the auth header value from the real key (e.g. `Bearer ${k}`). */
|
|
25
|
+
format: (key: string) => string;
|
|
26
|
+
/** The real provider key, injected on every forwarded call. */
|
|
27
|
+
apiKey: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the provider registry from configured keys. A provider exists only when a
|
|
31
|
+
* key (`SBX_PROVIDER_KEY_<NAME>`) is set AND a shape is known — either a built-in
|
|
32
|
+
* default or an operator-supplied `providerConfigs` entry. Custom config fields
|
|
33
|
+
* override the built-in, so an operator can repoint a built-in (e.g. route
|
|
34
|
+
* `openai` through a Cloudflare AI Gateway prefix) without code.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildProviders(config: Config): Record<string, ResolvedProvider>;
|
|
37
|
+
interface Deps {
|
|
38
|
+
config: Config;
|
|
39
|
+
store: SandboxStore;
|
|
40
|
+
providers: Record<string, ResolvedProvider>;
|
|
41
|
+
/** Model→price table for computing cost when a provider doesn't report one. */
|
|
42
|
+
prices?: ModelPrices;
|
|
43
|
+
/** Shared per-token rate limiter (created here if not supplied). */
|
|
44
|
+
limiter?: RateLimiter;
|
|
45
|
+
/** Forward-proxy domain allowlist (loaded from config here if not supplied). */
|
|
46
|
+
allowlist?: Allowlist;
|
|
47
|
+
}
|
|
48
|
+
export declare function createEgressProxy(deps: Deps): Server;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { connect as netConnect } from "node:net";
|
|
3
|
+
import { log } from "../logger.js";
|
|
4
|
+
import { computeModelCost } from "../pricing.js";
|
|
5
|
+
import { startSpan } from "../tracing.js";
|
|
6
|
+
import { BodyTooLargeError, readBodyCapped, sendJson, sendText } from "../util.js";
|
|
7
|
+
import { loadAllowlist, normalizeHost } from "./allowlist.js";
|
|
8
|
+
import { RateLimiter } from "./ratelimit.js";
|
|
9
|
+
/**
|
|
10
|
+
* Built-in providers: base URL + auth header shape, keyed by lower-case name.
|
|
11
|
+
* Providers with a fixed public base URL live here; ones whose base URL is
|
|
12
|
+
* deployment-specific (Azure OpenAI, a Cloudflare AI Gateway prefix, a self-hosted
|
|
13
|
+
* endpoint) are added by the operator as **custom providers** via
|
|
14
|
+
* `SBX_PROVIDER_<NAME>_BASEURL` / `_AUTHHEADER` / `_FORMAT` + `SBX_PROVIDER_KEY_<NAME>`.
|
|
15
|
+
*
|
|
16
|
+
* Cloudflare AI Gateway is just a base-URL prefix in front of a real provider, so
|
|
17
|
+
* it needs no special mechanism — point a custom provider's base URL at it and
|
|
18
|
+
* reuse the upstream's auth shape, e.g.:
|
|
19
|
+
* SBX_PROVIDER_CFOPENAI_BASEURL=https://gateway.ai.cloudflare.com/v1/<acct>/<gw>/openai
|
|
20
|
+
* SBX_PROVIDER_CFOPENAI_AUTHHEADER=authorization
|
|
21
|
+
* SBX_PROVIDER_CFOPENAI_FORMAT="Bearer {key}"
|
|
22
|
+
* SBX_PROVIDER_KEY_CFOPENAI=<the OpenAI key>
|
|
23
|
+
* The sandbox then calls `http://<egress>/cfopenai/...` and the gateway injects the
|
|
24
|
+
* key + meters the call, same as any built-in.
|
|
25
|
+
*/
|
|
26
|
+
const PROVIDER_DEFAULTS = {
|
|
27
|
+
openai: {
|
|
28
|
+
baseUrl: "https://api.openai.com",
|
|
29
|
+
authHeader: "authorization",
|
|
30
|
+
format: (k) => `Bearer ${k}`,
|
|
31
|
+
},
|
|
32
|
+
anthropic: {
|
|
33
|
+
baseUrl: "https://api.anthropic.com",
|
|
34
|
+
authHeader: "x-api-key",
|
|
35
|
+
format: (k) => k,
|
|
36
|
+
},
|
|
37
|
+
openrouter: {
|
|
38
|
+
baseUrl: "https://openrouter.ai/api",
|
|
39
|
+
authHeader: "authorization",
|
|
40
|
+
format: (k) => `Bearer ${k}`,
|
|
41
|
+
},
|
|
42
|
+
google: {
|
|
43
|
+
baseUrl: "https://generativelanguage.googleapis.com",
|
|
44
|
+
authHeader: "x-goog-api-key",
|
|
45
|
+
format: (k) => k,
|
|
46
|
+
},
|
|
47
|
+
// Alias of `google` so either provider name works in the route + key env.
|
|
48
|
+
gemini: {
|
|
49
|
+
baseUrl: "https://generativelanguage.googleapis.com",
|
|
50
|
+
authHeader: "x-goog-api-key",
|
|
51
|
+
format: (k) => k,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Build the provider registry from configured keys. A provider exists only when a
|
|
56
|
+
* key (`SBX_PROVIDER_KEY_<NAME>`) is set AND a shape is known — either a built-in
|
|
57
|
+
* default or an operator-supplied `providerConfigs` entry. Custom config fields
|
|
58
|
+
* override the built-in, so an operator can repoint a built-in (e.g. route
|
|
59
|
+
* `openai` through a Cloudflare AI Gateway prefix) without code.
|
|
60
|
+
*/
|
|
61
|
+
export function buildProviders(config) {
|
|
62
|
+
const providers = {};
|
|
63
|
+
for (const [name, apiKey] of Object.entries(config.providerKeys)) {
|
|
64
|
+
if (!apiKey)
|
|
65
|
+
continue;
|
|
66
|
+
const def = PROVIDER_DEFAULTS[name];
|
|
67
|
+
const custom = config.providerConfigs[name];
|
|
68
|
+
const baseUrl = custom?.baseUrl ?? def?.baseUrl;
|
|
69
|
+
const authHeader = custom?.authHeader ?? def?.authHeader;
|
|
70
|
+
let format = def?.format;
|
|
71
|
+
if (custom?.formatTemplate) {
|
|
72
|
+
const tmpl = custom.formatTemplate;
|
|
73
|
+
format = (k) => tmpl.replace("{key}", k);
|
|
74
|
+
}
|
|
75
|
+
else if (format === undefined && custom) {
|
|
76
|
+
// Custom provider without a format template → send the raw key.
|
|
77
|
+
format = (k) => k;
|
|
78
|
+
}
|
|
79
|
+
if (baseUrl && authHeader && format !== undefined) {
|
|
80
|
+
providers[name] = { baseUrl, authHeader, format, apiKey };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return providers;
|
|
84
|
+
}
|
|
85
|
+
export function createEgressProxy(deps) {
|
|
86
|
+
const resolved = {
|
|
87
|
+
...deps,
|
|
88
|
+
limiter: deps.limiter ?? new RateLimiter(),
|
|
89
|
+
allowlist: deps.allowlist ?? loadAllowlist(deps.config),
|
|
90
|
+
providerHosts: computeProviderHosts(deps.providers),
|
|
91
|
+
};
|
|
92
|
+
const server = createServer((req, res) => {
|
|
93
|
+
handle(req, res, resolved).catch((err) => {
|
|
94
|
+
sendText(res, 500, `egress proxy error: ${String(err?.message ?? err)}`);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
// HTTPS forward-proxy: clients (pip/npm/git via HTTPS_PROXY) open a CONNECT
|
|
98
|
+
// tunnel, which Node surfaces as the `connect` event rather than a request.
|
|
99
|
+
server.on("connect", (req, socket, head) => {
|
|
100
|
+
handleConnect(req, socket, head, resolved).catch(() => {
|
|
101
|
+
try {
|
|
102
|
+
socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* socket already gone */
|
|
106
|
+
}
|
|
107
|
+
socket.destroy();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
return server;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Hostnames of every known LLM provider — both configured and the built-in
|
|
114
|
+
* defaults — so the forward/CONNECT path can DENY a direct tunnel to them. LLM
|
|
115
|
+
* traffic must go through the base-URL-rewrite path (where the real key is
|
|
116
|
+
* injected and the call metered); a direct tunnel would bypass both. The sandbox
|
|
117
|
+
* holds no real provider key, so a tunnel is useless to it — we deny+log to make
|
|
118
|
+
* any bypass attempt visible.
|
|
119
|
+
*/
|
|
120
|
+
function computeProviderHosts(providers) {
|
|
121
|
+
const hosts = new Set();
|
|
122
|
+
const add = (baseUrl) => {
|
|
123
|
+
try {
|
|
124
|
+
hosts.add(new URL(baseUrl).hostname.toLowerCase());
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* ignore malformed */
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
for (const def of Object.values(PROVIDER_DEFAULTS))
|
|
131
|
+
add(def.baseUrl);
|
|
132
|
+
for (const p of Object.values(providers))
|
|
133
|
+
add(p.baseUrl);
|
|
134
|
+
return hosts;
|
|
135
|
+
}
|
|
136
|
+
const HOP_REQUEST = new Set([
|
|
137
|
+
"host",
|
|
138
|
+
"connection",
|
|
139
|
+
"content-length",
|
|
140
|
+
"accept-encoding",
|
|
141
|
+
"x-hotcell-egress",
|
|
142
|
+
"x-sbx-egress",
|
|
143
|
+
"authorization",
|
|
144
|
+
]);
|
|
145
|
+
const HOP_RESPONSE = new Set([
|
|
146
|
+
"content-encoding",
|
|
147
|
+
"transfer-encoding",
|
|
148
|
+
"content-length",
|
|
149
|
+
"connection",
|
|
150
|
+
]);
|
|
151
|
+
const USAGE_PARSE_CAP = 256 * 1024;
|
|
152
|
+
async function handle(req, res, deps) {
|
|
153
|
+
const { store, providers } = deps;
|
|
154
|
+
// Forward-proxy (plaintext HTTP via HTTP_PROXY): the request target is an
|
|
155
|
+
// absolute URL (`GET http://host/path`) rather than an origin-form path. This is
|
|
156
|
+
// the non-LLM allowlist path — no key injection.
|
|
157
|
+
if (req.url && /^https?:\/\//i.test(req.url)) {
|
|
158
|
+
return handleForwardHttp(req, res, deps);
|
|
159
|
+
}
|
|
160
|
+
const url = new URL(req.url ?? "/", "http://egress.local");
|
|
161
|
+
if (url.pathname === "/healthz") {
|
|
162
|
+
return sendJson(res, 200, { ok: true, providers: Object.keys(providers) });
|
|
163
|
+
}
|
|
164
|
+
const match = url.pathname.match(/^\/([^/]+)\/(.*)$/);
|
|
165
|
+
if (!match) {
|
|
166
|
+
return sendText(res, 404, "route: /<provider>/<path>");
|
|
167
|
+
}
|
|
168
|
+
const [, providerName, rest] = match;
|
|
169
|
+
const token = extractToken(req);
|
|
170
|
+
if (!token)
|
|
171
|
+
return sendText(res, 401, "missing egress token");
|
|
172
|
+
const record = store.resolveEgressTokenFull(token);
|
|
173
|
+
if (!record)
|
|
174
|
+
return sendText(res, 403, "invalid egress token");
|
|
175
|
+
const sandboxId = record.sandboxId;
|
|
176
|
+
const policy = record.policy;
|
|
177
|
+
const provider = providers[providerName];
|
|
178
|
+
if (!provider)
|
|
179
|
+
return sendText(res, 404, `unknown provider: ${providerName}`);
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
// --- policy enforcement (cheapest, most-decisive checks first) -----------
|
|
182
|
+
if (policy.expiresAt && now > Date.parse(policy.expiresAt)) {
|
|
183
|
+
return sendText(res, 403, "egress token expired");
|
|
184
|
+
}
|
|
185
|
+
if (policy.providers && !policy.providers.includes(providerName)) {
|
|
186
|
+
return sendText(res, 403, `provider not in token scope: ${providerName}`);
|
|
187
|
+
}
|
|
188
|
+
if (policy.spendCapUsd !== undefined && record.spendUsd >= policy.spendCapUsd) {
|
|
189
|
+
return sendText(res, 402, "egress spend cap exceeded (token)");
|
|
190
|
+
}
|
|
191
|
+
// Per-sandbox ceiling: a hard cap across ALL of the sandbox's tokens, so an
|
|
192
|
+
// abused token can't exceed it even before it's revoked.
|
|
193
|
+
const sb = store.get(sandboxId);
|
|
194
|
+
if (sb?.egressSpendCapUsd && sb.usage.providerCost >= sb.egressSpendCapUsd) {
|
|
195
|
+
return sendText(res, 402, "egress spend cap exceeded (sandbox)");
|
|
196
|
+
}
|
|
197
|
+
const method = req.method ?? "GET";
|
|
198
|
+
let reqBody;
|
|
199
|
+
try {
|
|
200
|
+
reqBody =
|
|
201
|
+
method === "GET" || method === "HEAD"
|
|
202
|
+
? undefined
|
|
203
|
+
: await readBodyCapped(req, deps.config.maxBodyBytes);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (err instanceof BodyTooLargeError)
|
|
207
|
+
return sendText(res, 413, "request body too large");
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
// Model allowlist: parse the request's `model` and gate it. A request with no
|
|
211
|
+
// model field (e.g. embeddings without one) is allowed even when `models` is
|
|
212
|
+
// set, so non-chat routes aren't broken — only an explicit, disallowed model
|
|
213
|
+
// is rejected.
|
|
214
|
+
const reqModel = reqBody ? parseModel(reqBody) : undefined;
|
|
215
|
+
if (policy.models && reqModel && !matchModel(policy.models, reqModel)) {
|
|
216
|
+
return sendText(res, 403, `model not allowed: ${reqModel}`);
|
|
217
|
+
}
|
|
218
|
+
// Rate limit: check the token budget (read-only) before consuming a call slot,
|
|
219
|
+
// so a token-exhausted call doesn't burn the call budget too.
|
|
220
|
+
if (policy.rateLimit) {
|
|
221
|
+
const tok = deps.limiter.checkTokens(token, policy.rateLimit, now);
|
|
222
|
+
if (!tok.ok) {
|
|
223
|
+
res.setHeader("retry-after", Math.ceil(tok.retryAfterMs / 1000));
|
|
224
|
+
return sendText(res, 429, "egress rate limit (tokens) exceeded");
|
|
225
|
+
}
|
|
226
|
+
const call = deps.limiter.allow(token, policy.rateLimit, now);
|
|
227
|
+
if (!call.ok) {
|
|
228
|
+
res.setHeader("retry-after", Math.ceil(call.retryAfterMs / 1000));
|
|
229
|
+
return sendText(res, 429, "egress rate limit (calls) exceeded");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
store.touch(sandboxId);
|
|
233
|
+
const span = startSpan("egress.provider_call", {
|
|
234
|
+
"sandbox.id": sandboxId,
|
|
235
|
+
"egress.provider": providerName,
|
|
236
|
+
});
|
|
237
|
+
const upstreamUrl = provider.baseUrl.replace(/\/$/, "") + "/" + rest + (url.search || "");
|
|
238
|
+
const headers = new Headers();
|
|
239
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
240
|
+
if (HOP_REQUEST.has(k.toLowerCase()) || v == null)
|
|
241
|
+
continue;
|
|
242
|
+
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
243
|
+
}
|
|
244
|
+
headers.set(provider.authHeader, provider.format(provider.apiKey));
|
|
245
|
+
let bytes = reqBody?.length ?? 0;
|
|
246
|
+
let upstream;
|
|
247
|
+
try {
|
|
248
|
+
upstream = await fetch(upstreamUrl, { method, headers, body: reqBody });
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
span.setStatus("error").end();
|
|
252
|
+
return sendText(res, 502, `upstream fetch failed: ${String(err)}`);
|
|
253
|
+
}
|
|
254
|
+
const respHeaders = {};
|
|
255
|
+
upstream.headers.forEach((value, key) => {
|
|
256
|
+
if (!HOP_RESPONSE.has(key.toLowerCase()))
|
|
257
|
+
respHeaders[key] = value;
|
|
258
|
+
});
|
|
259
|
+
res.writeHead(upstream.status, respHeaders);
|
|
260
|
+
// Stream the response through to the sandbox (so token-by-token streaming still
|
|
261
|
+
// works) while keeping a rolling TAIL copy to parse usage from — token counts
|
|
262
|
+
// and the OpenRouter `usage.cost` live in the final chunk (the last SSE frame
|
|
263
|
+
// for streaming, the end of the body for a JSON response), so the tail, not the
|
|
264
|
+
// head, is what we need.
|
|
265
|
+
const tail = [];
|
|
266
|
+
let tailBytes = 0;
|
|
267
|
+
if (upstream.body) {
|
|
268
|
+
const reader = upstream.body.getReader();
|
|
269
|
+
for (;;) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done)
|
|
272
|
+
break;
|
|
273
|
+
const buf = Buffer.from(value);
|
|
274
|
+
res.write(buf);
|
|
275
|
+
bytes += buf.length;
|
|
276
|
+
tail.push(buf);
|
|
277
|
+
tailBytes += buf.length;
|
|
278
|
+
while (tail.length > 1 && tailBytes - (tail[0]?.length ?? 0) >= USAGE_PARSE_CAP) {
|
|
279
|
+
const dropped = tail.shift();
|
|
280
|
+
tailBytes -= dropped?.length ?? 0;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
res.end();
|
|
285
|
+
const { tokensIn, tokensOut, cost: reportedCost, model: respModel } = parseUsage(Buffer.concat(tail));
|
|
286
|
+
// Cost: the provider's own figure is authoritative (OpenRouter inlines
|
|
287
|
+
// `usage.cost`); otherwise compute it from the model price table. Never sum the
|
|
288
|
+
// two — the result is folded into `providerCost`, keeping one source of truth.
|
|
289
|
+
const model = respModel ?? reqModel;
|
|
290
|
+
const cost = reportedCost > 0 ? reportedCost : computeModelCost(model, tokensIn, tokensOut, deps.prices ?? {});
|
|
291
|
+
store.addProviderUsage(sandboxId, { bytes, tokensIn, tokensOut, cost });
|
|
292
|
+
if (cost > 0)
|
|
293
|
+
store.addEgressTokenSpend(token, cost);
|
|
294
|
+
if (policy.rateLimit)
|
|
295
|
+
deps.limiter.note(token, policy.rateLimit, tokensIn + tokensOut, now);
|
|
296
|
+
span
|
|
297
|
+
.setAttribute("http.status_code", upstream.status)
|
|
298
|
+
.setAttribute("egress.tokens_in", tokensIn)
|
|
299
|
+
.setAttribute("egress.tokens_out", tokensOut)
|
|
300
|
+
.setAttribute("egress.cost", cost);
|
|
301
|
+
if (model)
|
|
302
|
+
span.setAttribute("egress.model", model);
|
|
303
|
+
if (upstream.status >= 500)
|
|
304
|
+
span.setStatus("error");
|
|
305
|
+
span.end();
|
|
306
|
+
log.info("egress call", {
|
|
307
|
+
sandbox: sandboxId,
|
|
308
|
+
provider: providerName,
|
|
309
|
+
status: upstream.status,
|
|
310
|
+
model,
|
|
311
|
+
bytes,
|
|
312
|
+
tokensIn,
|
|
313
|
+
tokensOut,
|
|
314
|
+
cost,
|
|
315
|
+
traceId: span.traceId,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/** Parse the `model` field from a JSON request body (best-effort). */
|
|
319
|
+
function parseModel(body) {
|
|
320
|
+
try {
|
|
321
|
+
const o = JSON.parse(body.toString("utf8"));
|
|
322
|
+
return typeof o?.model === "string" ? o.model : undefined;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Match a model id against an allowlist. An entry ending in `*` is a prefix glob
|
|
330
|
+
* (`gpt-4*` matches `gpt-4o`); otherwise it's an exact, case-insensitive match.
|
|
331
|
+
*/
|
|
332
|
+
function matchModel(patterns, model) {
|
|
333
|
+
const m = model.toLowerCase();
|
|
334
|
+
return patterns.some((p) => {
|
|
335
|
+
const pat = p.toLowerCase();
|
|
336
|
+
return pat.endsWith("*") ? m.startsWith(pat.slice(0, -1)) : m === pat;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/** Pull the egress token from `X-Hotcell-Egress` (legacy `X-Sbx-Egress`) or a Bearer Authorization header. */
|
|
340
|
+
function extractToken(req) {
|
|
341
|
+
const direct = req.headers["x-hotcell-egress"] ?? req.headers["x-sbx-egress"];
|
|
342
|
+
if (typeof direct === "string" && direct)
|
|
343
|
+
return direct;
|
|
344
|
+
const auth = req.headers["authorization"];
|
|
345
|
+
if (typeof auth === "string" && auth.startsWith("Bearer "))
|
|
346
|
+
return auth.slice(7);
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Pull the egress token from a forward-proxy request: `Proxy-Authorization`
|
|
351
|
+
* (`Bearer <token>` or `Basic base64(<token>:)` — what `HTTPS_PROXY=http://<token>@host`
|
|
352
|
+
* sends) or the `X-Hotcell-Egress` (legacy `X-Sbx-Egress`) header.
|
|
353
|
+
*/
|
|
354
|
+
function extractProxyToken(req) {
|
|
355
|
+
const pa = req.headers["proxy-authorization"];
|
|
356
|
+
if (typeof pa === "string") {
|
|
357
|
+
if (pa.startsWith("Bearer "))
|
|
358
|
+
return pa.slice(7);
|
|
359
|
+
if (pa.startsWith("Basic ")) {
|
|
360
|
+
const decoded = Buffer.from(pa.slice(6), "base64").toString("utf8");
|
|
361
|
+
const user = decoded.split(":")[0];
|
|
362
|
+
if (user)
|
|
363
|
+
return user;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const direct = req.headers["x-hotcell-egress"] ?? req.headers["x-sbx-egress"];
|
|
367
|
+
if (typeof direct === "string" && direct)
|
|
368
|
+
return direct;
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
/** Split a CONNECT authority (`host:port`, `[v6]:port`) into host + port. */
|
|
372
|
+
function parseAuthority(s) {
|
|
373
|
+
if (s.startsWith("[")) {
|
|
374
|
+
const end = s.indexOf("]");
|
|
375
|
+
const host = s.slice(1, end);
|
|
376
|
+
const port = Number(s.slice(end + 2)) || 443;
|
|
377
|
+
return { host, port };
|
|
378
|
+
}
|
|
379
|
+
const i = s.lastIndexOf(":");
|
|
380
|
+
if (i === -1)
|
|
381
|
+
return { host: s, port: 443 };
|
|
382
|
+
return { host: s.slice(0, i), port: Number(s.slice(i + 1)) || 443 };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Plaintext-HTTP forward-proxy path (apt/pip over http://, via `HTTP_PROXY`):
|
|
386
|
+
* resolve the token, deny LLM-provider hosts (those go through the gateway path)
|
|
387
|
+
* and any host not on the allowlist, then forward + meter bytes. No key injection.
|
|
388
|
+
*/
|
|
389
|
+
async function handleForwardHttp(req, res, deps) {
|
|
390
|
+
let target;
|
|
391
|
+
try {
|
|
392
|
+
target = new URL(req.url);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return sendText(res, 400, "bad forward-proxy target");
|
|
396
|
+
}
|
|
397
|
+
const host = normalizeHost(target.host);
|
|
398
|
+
const token = extractProxyToken(req);
|
|
399
|
+
const rec = token ? deps.store.resolveEgressTokenFull(token) : undefined;
|
|
400
|
+
if (!rec) {
|
|
401
|
+
res.setHeader("proxy-authenticate", 'Basic realm="hotcell-egress"');
|
|
402
|
+
return sendText(res, 407, "proxy authentication required");
|
|
403
|
+
}
|
|
404
|
+
if (deps.providerHosts.has(host)) {
|
|
405
|
+
log.warn("egress forward denied: LLM provider host", { sandbox: rec.sandboxId, host });
|
|
406
|
+
return sendText(res, 403, `direct egress to LLM provider blocked: ${host} — use the gateway path`);
|
|
407
|
+
}
|
|
408
|
+
const decision = deps.allowlist.check(host);
|
|
409
|
+
if (!decision.allow) {
|
|
410
|
+
log.warn("egress forward denied", { sandbox: rec.sandboxId, host, reason: decision.reason });
|
|
411
|
+
return sendText(res, 403, `egress to ${host} denied (${decision.reason})`);
|
|
412
|
+
}
|
|
413
|
+
const method = req.method ?? "GET";
|
|
414
|
+
let reqBody;
|
|
415
|
+
try {
|
|
416
|
+
reqBody =
|
|
417
|
+
method === "GET" || method === "HEAD"
|
|
418
|
+
? undefined
|
|
419
|
+
: await readBodyCapped(req, deps.config.maxBodyBytes);
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
if (err instanceof BodyTooLargeError)
|
|
423
|
+
return sendText(res, 413, "request body too large");
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
426
|
+
const headers = new Headers();
|
|
427
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
428
|
+
const lk = k.toLowerCase();
|
|
429
|
+
if (v == null ||
|
|
430
|
+
lk === "proxy-authorization" ||
|
|
431
|
+
lk === "proxy-connection" ||
|
|
432
|
+
lk === "x-hotcell-egress" ||
|
|
433
|
+
lk === "x-sbx-egress" ||
|
|
434
|
+
HOP_REQUEST.has(lk)) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
438
|
+
}
|
|
439
|
+
let bytes = reqBody?.length ?? 0;
|
|
440
|
+
let upstream;
|
|
441
|
+
try {
|
|
442
|
+
upstream = await fetch(target, { method, headers, body: reqBody, redirect: "manual" });
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
return sendText(res, 502, `forward fetch failed: ${String(err)}`);
|
|
446
|
+
}
|
|
447
|
+
const respHeaders = {};
|
|
448
|
+
upstream.headers.forEach((value, key) => {
|
|
449
|
+
if (!HOP_RESPONSE.has(key.toLowerCase()))
|
|
450
|
+
respHeaders[key] = value;
|
|
451
|
+
});
|
|
452
|
+
res.writeHead(upstream.status, respHeaders);
|
|
453
|
+
if (upstream.body) {
|
|
454
|
+
const reader = upstream.body.getReader();
|
|
455
|
+
for (;;) {
|
|
456
|
+
const { done, value } = await reader.read();
|
|
457
|
+
if (done)
|
|
458
|
+
break;
|
|
459
|
+
const buf = Buffer.from(value);
|
|
460
|
+
res.write(buf);
|
|
461
|
+
bytes += buf.length;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
res.end();
|
|
465
|
+
deps.store.addEgress(rec.sandboxId, bytes);
|
|
466
|
+
log.info("egress forward", { sandbox: rec.sandboxId, host, status: upstream.status, bytes });
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* HTTPS forward-proxy path (`CONNECT host:443`, via `HTTPS_PROXY`): resolve the
|
|
470
|
+
* token, deny LLM-provider hosts + non-allowlisted hosts (by SNI/authority — the
|
|
471
|
+
* TLS body stays opaque, no MITM), then open a raw TCP tunnel and meter the bytes
|
|
472
|
+
* both ways. This is the main path for pip/npm/git, which use HTTPS.
|
|
473
|
+
*/
|
|
474
|
+
async function handleConnect(req, clientSocket, head, deps) {
|
|
475
|
+
const { host, port } = parseAuthority(req.url ?? "");
|
|
476
|
+
const nhost = normalizeHost(host);
|
|
477
|
+
const token = extractProxyToken(req);
|
|
478
|
+
const rec = token ? deps.store.resolveEgressTokenFull(token) : undefined;
|
|
479
|
+
if (!rec) {
|
|
480
|
+
clientSocket.write('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="hotcell-egress"\r\n\r\n');
|
|
481
|
+
clientSocket.destroy();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Provider-domain guard MUST precede the allowlist: a wide allowlist entry must
|
|
485
|
+
// never re-open a key-bypass tunnel to an LLM provider.
|
|
486
|
+
if (deps.providerHosts.has(nhost)) {
|
|
487
|
+
log.warn("egress CONNECT denied: LLM provider host", { sandbox: rec.sandboxId, host: nhost });
|
|
488
|
+
clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
489
|
+
clientSocket.destroy();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const decision = deps.allowlist.check(nhost);
|
|
493
|
+
if (!decision.allow) {
|
|
494
|
+
log.warn("egress CONNECT denied", { sandbox: rec.sandboxId, host: nhost, reason: decision.reason });
|
|
495
|
+
clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
496
|
+
clientSocket.destroy();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const upstream = netConnect(port, host);
|
|
500
|
+
let bytes = head?.length ?? 0;
|
|
501
|
+
let metered = false;
|
|
502
|
+
const finish = () => {
|
|
503
|
+
if (metered)
|
|
504
|
+
return;
|
|
505
|
+
metered = true;
|
|
506
|
+
deps.store.addEgress(rec.sandboxId, bytes);
|
|
507
|
+
};
|
|
508
|
+
const teardown = () => {
|
|
509
|
+
finish();
|
|
510
|
+
upstream.destroy();
|
|
511
|
+
clientSocket.destroy();
|
|
512
|
+
};
|
|
513
|
+
upstream.on("connect", () => {
|
|
514
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
515
|
+
if (head && head.length)
|
|
516
|
+
upstream.write(head);
|
|
517
|
+
clientSocket.on("data", (c) => (bytes += c.length));
|
|
518
|
+
upstream.on("data", (c) => (bytes += c.length));
|
|
519
|
+
clientSocket.pipe(upstream);
|
|
520
|
+
upstream.pipe(clientSocket);
|
|
521
|
+
log.info("egress CONNECT", { sandbox: rec.sandboxId, host: nhost, port });
|
|
522
|
+
});
|
|
523
|
+
upstream.on("error", () => {
|
|
524
|
+
if (!metered) {
|
|
525
|
+
try {
|
|
526
|
+
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
/* client gone */
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
teardown();
|
|
533
|
+
});
|
|
534
|
+
upstream.on("close", teardown);
|
|
535
|
+
clientSocket.on("error", teardown);
|
|
536
|
+
clientSocket.on("close", teardown);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Best-effort usage accounting from the provider's response. Reads the `usage`
|
|
540
|
+
* object — token counts (OpenAI `prompt_tokens`/`completion_tokens` or Anthropic
|
|
541
|
+
* `input_tokens`/`output_tokens`), the **authoritative USD cost** OpenRouter
|
|
542
|
+
* reports as `usage.cost`, and the resolved `model` — from a JSON body or the last
|
|
543
|
+
* SSE `data:` frame.
|
|
544
|
+
*/
|
|
545
|
+
function parseUsage(buf) {
|
|
546
|
+
const text = buf.toString("utf8");
|
|
547
|
+
const fromObj = (o) => {
|
|
548
|
+
const u = o?.usage;
|
|
549
|
+
const model = typeof o?.model === "string" ? o.model : undefined;
|
|
550
|
+
if (!u)
|
|
551
|
+
return null;
|
|
552
|
+
const inn = u.prompt_tokens ?? u.input_tokens;
|
|
553
|
+
const out = u.completion_tokens ?? u.output_tokens;
|
|
554
|
+
const cost = u.cost; // OpenRouter: real USD cost, inline in every response
|
|
555
|
+
if (inn == null && out == null && cost == null)
|
|
556
|
+
return null;
|
|
557
|
+
return {
|
|
558
|
+
tokensIn: Number(inn || 0),
|
|
559
|
+
tokensOut: Number(out || 0),
|
|
560
|
+
cost: Number(cost || 0),
|
|
561
|
+
model,
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
try {
|
|
565
|
+
const whole = fromObj(JSON.parse(text));
|
|
566
|
+
if (whole)
|
|
567
|
+
return whole;
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// not a single JSON object; fall through to SSE scan
|
|
571
|
+
}
|
|
572
|
+
// SSE: the usage frame and the model often arrive in different frames, so keep
|
|
573
|
+
// the last seen of each independently.
|
|
574
|
+
let best = null;
|
|
575
|
+
let lastModel;
|
|
576
|
+
for (const line of text.split(/\r?\n/)) {
|
|
577
|
+
const t = line.trim();
|
|
578
|
+
if (!t.startsWith("data:"))
|
|
579
|
+
continue;
|
|
580
|
+
const payload = t.slice(5).trim();
|
|
581
|
+
if (!payload || payload === "[DONE]")
|
|
582
|
+
continue;
|
|
583
|
+
try {
|
|
584
|
+
const o = JSON.parse(payload);
|
|
585
|
+
if (typeof o?.model === "string")
|
|
586
|
+
lastModel = o.model;
|
|
587
|
+
const r = fromObj(o);
|
|
588
|
+
if (r)
|
|
589
|
+
best = r;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// skip non-JSON frames
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (best)
|
|
596
|
+
return { ...best, model: best.model ?? lastModel };
|
|
597
|
+
return { tokensIn: 0, tokensOut: 0, cost: 0, model: lastModel };
|
|
598
|
+
}
|
|
599
|
+
//# sourceMappingURL=egress.js.map
|