@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.
Files changed (142) hide show
  1. package/dist/agent-check.d.ts +1 -0
  2. package/dist/agent-check.js +105 -0
  3. package/dist/agent-check.js.map +1 -0
  4. package/dist/api/server.d.ts +64 -0
  5. package/dist/api/server.js +1368 -0
  6. package/dist/api/server.js.map +1 -0
  7. package/dist/api/ws.d.ts +14 -0
  8. package/dist/api/ws.js +214 -0
  9. package/dist/api/ws.js.map +1 -0
  10. package/dist/applevz-check.d.ts +1 -0
  11. package/dist/applevz-check.js +280 -0
  12. package/dist/applevz-check.js.map +1 -0
  13. package/dist/backups.d.ts +29 -0
  14. package/dist/backups.js +76 -0
  15. package/dist/backups.js.map +1 -0
  16. package/dist/capacity.d.ts +63 -0
  17. package/dist/capacity.js +95 -0
  18. package/dist/capacity.js.map +1 -0
  19. package/dist/config.d.ts +241 -0
  20. package/dist/config.js +152 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/cost.d.ts +9 -0
  23. package/dist/cost.js +19 -0
  24. package/dist/cost.js.map +1 -0
  25. package/dist/density-bench.d.ts +1 -0
  26. package/dist/density-bench.js +97 -0
  27. package/dist/density-bench.js.map +1 -0
  28. package/dist/driver/agent-driver.d.ts +67 -0
  29. package/dist/driver/agent-driver.js +197 -0
  30. package/dist/driver/agent-driver.js.map +1 -0
  31. package/dist/driver/agent.d.ts +94 -0
  32. package/dist/driver/agent.js +319 -0
  33. package/dist/driver/agent.js.map +1 -0
  34. package/dist/driver/applevz.d.ts +95 -0
  35. package/dist/driver/applevz.js +527 -0
  36. package/dist/driver/applevz.js.map +1 -0
  37. package/dist/driver/container.d.ts +99 -0
  38. package/dist/driver/container.js +733 -0
  39. package/dist/driver/container.js.map +1 -0
  40. package/dist/driver/fc-api.d.ts +85 -0
  41. package/dist/driver/fc-api.js +173 -0
  42. package/dist/driver/fc-api.js.map +1 -0
  43. package/dist/driver/firecracker.d.ts +126 -0
  44. package/dist/driver/firecracker.js +560 -0
  45. package/dist/driver/firecracker.js.map +1 -0
  46. package/dist/driver/index.d.ts +17 -0
  47. package/dist/driver/index.js +61 -0
  48. package/dist/driver/index.js.map +1 -0
  49. package/dist/driver/router.d.ts +82 -0
  50. package/dist/driver/router.js +159 -0
  51. package/dist/driver/router.js.map +1 -0
  52. package/dist/driver/types.d.ts +234 -0
  53. package/dist/driver/types.js +2 -0
  54. package/dist/driver/types.js.map +1 -0
  55. package/dist/driver/unsupported.d.ts +50 -0
  56. package/dist/driver/unsupported.js +81 -0
  57. package/dist/driver/unsupported.js.map +1 -0
  58. package/dist/driver/vz-image.d.ts +35 -0
  59. package/dist/driver/vz-image.js +75 -0
  60. package/dist/driver/vz-image.js.map +1 -0
  61. package/dist/e2e-harness.d.ts +1 -0
  62. package/dist/e2e-harness.js +99 -0
  63. package/dist/e2e-harness.js.map +1 -0
  64. package/dist/egress-check.d.ts +8 -0
  65. package/dist/egress-check.js +317 -0
  66. package/dist/egress-check.js.map +1 -0
  67. package/dist/fc-check.d.ts +1 -0
  68. package/dist/fc-check.js +113 -0
  69. package/dist/fc-check.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +114 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/kernels.d.ts +22 -0
  74. package/dist/kernels.js +134 -0
  75. package/dist/kernels.js.map +1 -0
  76. package/dist/lifecycle.d.ts +49 -0
  77. package/dist/lifecycle.js +111 -0
  78. package/dist/lifecycle.js.map +1 -0
  79. package/dist/logger.d.ts +16 -0
  80. package/dist/logger.js +67 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/metrics.d.ts +46 -0
  83. package/dist/metrics.js +92 -0
  84. package/dist/metrics.js.map +1 -0
  85. package/dist/net/firewall.d.ts +4 -0
  86. package/dist/net/firewall.js +80 -0
  87. package/dist/net/firewall.js.map +1 -0
  88. package/dist/pricing.d.ts +36 -0
  89. package/dist/pricing.js +81 -0
  90. package/dist/pricing.js.map +1 -0
  91. package/dist/proxy/allowlist-default.d.ts +23 -0
  92. package/dist/proxy/allowlist-default.js +53 -0
  93. package/dist/proxy/allowlist-default.js.map +1 -0
  94. package/dist/proxy/allowlist.d.ts +43 -0
  95. package/dist/proxy/allowlist.js +87 -0
  96. package/dist/proxy/allowlist.js.map +1 -0
  97. package/dist/proxy/egress.d.ts +49 -0
  98. package/dist/proxy/egress.js +599 -0
  99. package/dist/proxy/egress.js.map +1 -0
  100. package/dist/proxy/ratelimit.d.ts +49 -0
  101. package/dist/proxy/ratelimit.js +74 -0
  102. package/dist/proxy/ratelimit.js.map +1 -0
  103. package/dist/proxy/server.d.ts +26 -0
  104. package/dist/proxy/server.js +202 -0
  105. package/dist/proxy/server.js.map +1 -0
  106. package/dist/router-check.d.ts +1 -0
  107. package/dist/router-check.js +97 -0
  108. package/dist/router-check.js.map +1 -0
  109. package/dist/smoke-fc.d.ts +1 -0
  110. package/dist/smoke-fc.js +183 -0
  111. package/dist/smoke-fc.js.map +1 -0
  112. package/dist/smoke-microvm.d.ts +1 -0
  113. package/dist/smoke-microvm.js +187 -0
  114. package/dist/smoke-microvm.js.map +1 -0
  115. package/dist/smoke-remote.d.ts +20 -0
  116. package/dist/smoke-remote.js +155 -0
  117. package/dist/smoke-remote.js.map +1 -0
  118. package/dist/smoke-vz.d.ts +1 -0
  119. package/dist/smoke-vz.js +161 -0
  120. package/dist/smoke-vz.js.map +1 -0
  121. package/dist/smoke.d.ts +7 -0
  122. package/dist/smoke.js +962 -0
  123. package/dist/smoke.js.map +1 -0
  124. package/dist/store.d.ts +124 -0
  125. package/dist/store.js +578 -0
  126. package/dist/store.js.map +1 -0
  127. package/dist/tracing.d.ts +45 -0
  128. package/dist/tracing.js +144 -0
  129. package/dist/tracing.js.map +1 -0
  130. package/dist/types.d.ts +274 -0
  131. package/dist/types.js +3 -0
  132. package/dist/types.js.map +1 -0
  133. package/dist/util.d.ts +27 -0
  134. package/dist/util.js +63 -0
  135. package/dist/util.js.map +1 -0
  136. package/dist/warmpool-check.d.ts +1 -0
  137. package/dist/warmpool-check.js +83 -0
  138. package/dist/warmpool-check.js.map +1 -0
  139. package/dist/web/dashboard.d.ts +1 -0
  140. package/dist/web/dashboard.js +456 -0
  141. package/dist/web/dashboard.js.map +1 -0
  142. 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