anyray-connect 0.11.39 → 0.11.41

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 (38) hide show
  1. package/README.md +7 -56
  2. package/dist/cli.js +117 -23
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/doctor.js +31 -0
  5. package/dist/commands/doctor.js.map +1 -1
  6. package/dist/tools/claudeCode.js +43 -14
  7. package/dist/tools/claudeCode.js.map +1 -1
  8. package/dist/tools/codex.js +12 -4
  9. package/dist/tools/codex.js.map +1 -1
  10. package/dist/tools/copilotChat.js +8 -6
  11. package/dist/tools/copilotChat.js.map +1 -1
  12. package/dist/tools/manual.js +26 -5
  13. package/dist/tools/manual.js.map +1 -1
  14. package/dist/tools/opencode.js +7 -5
  15. package/dist/tools/opencode.js.map +1 -1
  16. package/dist/tools/shellEnv.js +43 -1
  17. package/dist/tools/shellEnv.js.map +1 -1
  18. package/dist/util/jsonFile.js +43 -5
  19. package/dist/util/jsonFile.js.map +1 -1
  20. package/dist/util/persist.js +61 -0
  21. package/dist/util/persist.js.map +1 -1
  22. package/dist/util/profile.js +8 -0
  23. package/dist/util/profile.js.map +1 -1
  24. package/package.json +1 -1
  25. package/dist/commands/agent.js +0 -347
  26. package/dist/commands/agent.js.map +0 -1
  27. package/dist/commands/agentProxy.js +0 -670
  28. package/dist/commands/agentProxy.js.map +0 -1
  29. package/dist/util/agentAllowlist.js +0 -107
  30. package/dist/util/agentAllowlist.js.map +0 -1
  31. package/dist/util/agentCa.js +0 -209
  32. package/dist/util/agentCa.js.map +0 -1
  33. package/dist/util/agentPaths.js +0 -38
  34. package/dist/util/agentPaths.js.map +0 -1
  35. package/dist/util/agentService.js +0 -215
  36. package/dist/util/agentService.js.map +0 -1
  37. package/dist/util/agentTrust.js +0 -517
  38. package/dist/util/agentTrust.js.map +0 -1
@@ -1,670 +0,0 @@
1
- /**
2
- * `anyray-connect __anyray-agent-proxy` — the device-capture agent daemon.
3
- *
4
- * A loopback HTTP CONNECT proxy the OS system proxy points at (see
5
- * util/agentTrust.ts). It captures AI inference from apps `connect` can't
6
- * redirect — the ChatGPT desktop app, Claude desktop app, Copilot, browsers —
7
- * by intercepting BELOW the app at the network layer (RFC 0002 / 0002a).
8
- *
9
- * Per connection:
10
- * - host NOT on the AI allowlist -> blind CONNECT tunnel: the two sockets are
11
- * piped byte-for-byte, TLS is never terminated, the agent never sees the
12
- * bytes. This is the privacy invariant (RFC 0002 §2) — everything that isn't
13
- * a reviewed AI domain is untouched.
14
- * - host ON the allowlist -> MITM locally: terminate TLS with a leaf
15
- * minted from the device CA, read the HTTP request, and forward it to the
16
- * ORG GATEWAY (not straight to the provider) so spend is metered
17
- * content-free. The app's own Authorization (its ChatGPT/Claude/Copilot
18
- * session) is preserved; the agent injects the origin host, attribution the
19
- * desktop app can't add, and the enrolled dev key.
20
- *
21
- * Only Node built-ins (connect ships as a zero-dep binary). Bound to loopback.
22
- * NEVER logs prompt/response content (privacy invariant) — the MITM path reads
23
- * a request only to forward it, and logs metadata (host/status) at most.
24
- *
25
- * `createInterceptProxy` builds the server (testable, cert minting injected);
26
- * `runAgentProxy` is the profile-loading entrypoint the OS service launches.
27
- */
28
- import http from 'node:http';
29
- import net from 'node:net';
30
- import tls from 'node:tls';
31
- import { lookup as dnsLookup } from 'node:dns/promises';
32
- import { loadProfile } from '../util/persist.js';
33
- import { AGENT_HEADERS, DEFAULT_AGENT_PORT } from '../util/agentPaths.js';
34
- import { decideHost, } from '../util/agentAllowlist.js';
35
- import { ensureCa, mintLeaf } from '../util/agentCa.js';
36
- import { restoreSystemProxy } from '../util/agentTrust.js';
37
- /** Where a decrypted AI request is forwarded on the gateway. */
38
- export const INTAKE_PATH = '/v1/agent-intake';
39
- // Hop-by-hop / length headers we must not forward to the gateway verbatim (the
40
- // fetch client manages host/content-length and decodes content-encoding). Plus
41
- // anything host-specific to the ORIGINAL upstream that the gateway recomputes.
42
- const REQUEST_DROP = new Set([
43
- 'host',
44
- 'connection',
45
- 'keep-alive',
46
- 'proxy-connection',
47
- 'content-length',
48
- 'transfer-encoding',
49
- 'upgrade',
50
- 'accept-encoding',
51
- ]);
52
- const RESPONSE_DROP = new Set([
53
- 'content-encoding',
54
- 'content-length',
55
- 'transfer-encoding',
56
- 'connection',
57
- 'keep-alive',
58
- ]);
59
- // Special-purpose prefixes inside 2000::/3 that are not ordinary globally
60
- // reachable destinations. In particular, 6to4 embeds an IPv4 address and can
61
- // otherwise turn a private IPv4 target into an apparently public IPv6 target.
62
- const NON_FORWARDABLE_IPV6 = new net.BlockList();
63
- NON_FORWARDABLE_IPV6.addSubnet('2001::', 23, 'ipv6');
64
- NON_FORWARDABLE_IPV6.addSubnet('2001:db8::', 32, 'ipv6');
65
- NON_FORWARDABLE_IPV6.addSubnet('2002::', 16, 'ipv6');
66
- NON_FORWARDABLE_IPV6.addSubnet('3fff::', 20, 'ipv6');
67
- /**
68
- * SSRF blocklist for the plain-HTTP forward path (`forwardPlainHttp`). The
69
- * agent binds to 127.0.0.1, but it IS the machine's configured HTTP proxy, so a
70
- * local page/tool can steer an arbitrary `http://` request at it. On a cloud
71
- * dev box that turns the proxy into a confused deputy for credential theft — a
72
- * page that makes the browser fetch `http://169.254.169.254/…` would have us
73
- * relay the instance metadata (and its IAM creds) straight back. Refuse the
74
- * hosts real outbound web traffic never legitimately targets: loopback,
75
- * RFC-1918 / CGNAT / link-local, IPv6 loopback/ULA/link-local, and the cloud
76
- * metadata endpoints. This is a blocklist, NOT an allowlist — forwarding to
77
- * arbitrary *public* hosts is the proxy's job and stays allowed.
78
- *
79
- */
80
- export const isForwardableHttpHost = (hostname) => {
81
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 []
82
- if (!host)
83
- return false;
84
- const BLOCKED_NAMES = new Set([
85
- 'localhost',
86
- '0.0.0.0',
87
- 'metadata',
88
- 'metadata.google.internal',
89
- 'metadata.azure.com',
90
- 'instance-data',
91
- 'host.docker.internal',
92
- ]);
93
- if (BLOCKED_NAMES.has(host))
94
- return false;
95
- if (host.endsWith('.localhost') ||
96
- host.endsWith('.internal') ||
97
- host.endsWith('.local')) {
98
- return false;
99
- }
100
- const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
101
- if (ipv4) {
102
- return isForwardableAddress(host);
103
- }
104
- if (host.includes(':'))
105
- return isForwardableAddress(host);
106
- return true; // ordinary external DNS name
107
- };
108
- /** True only for globally routable IP addresses. */
109
- export const isForwardableAddress = (address) => {
110
- const host = address.toLowerCase().replace(/^\[|\]$/g, '').split('%')[0];
111
- const family = net.isIP(host);
112
- if (family === 4) {
113
- const [a, b, c] = host.split('.').map(Number);
114
- if (a === 0 || a === 10 || a === 127)
115
- return false;
116
- if (a === 100 && b >= 64 && b <= 127)
117
- return false;
118
- if (a === 169 && b === 254)
119
- return false;
120
- if (a === 172 && b >= 16 && b <= 31)
121
- return false;
122
- if (a === 192 && (b === 0 || b === 168))
123
- return false;
124
- if (a === 198 && (b === 18 || b === 19))
125
- return false;
126
- if (a === 198 && b === 51 && c === 100)
127
- return false;
128
- if (a === 203 && b === 0 && c === 113)
129
- return false;
130
- if (a >= 224)
131
- return false;
132
- return true;
133
- }
134
- if (family === 6) {
135
- // Globally routable IPv6 lives in 2000::/3. Refuse every mapped, local,
136
- // multicast, transition, documentation, and other special-purpose range.
137
- const first = Number.parseInt(host.split(':')[0] || '0', 16);
138
- if (first < 0x2000 || first > 0x3fff)
139
- return false;
140
- return !NON_FORWARDABLE_IPV6.check(host, 'ipv6');
141
- }
142
- return false;
143
- };
144
- /** Normalize a URL/DNS host for literal-address and local-name comparisons. */
145
- const normalizeHostname = (hostname) => hostname
146
- .trim()
147
- .toLowerCase()
148
- .replace(/^\[|\]$/g, '')
149
- .replace(/\.$/, '')
150
- .split('%')[0];
151
- /**
152
- * Addresses a client may intentionally use for its own on-device gateway.
153
- * This is range-based (the full IPv4 loopback /8), not tied to one developer's
154
- * IP. The unspecified IPv4 bind address is mapped to loopback before dialing.
155
- */
156
- export const isLocalGatewayAddress = (address) => {
157
- const host = normalizeHostname(address);
158
- const family = net.isIP(host);
159
- if (family === 4) {
160
- return host === '0.0.0.0' || Number(host.split('.')[0]) === 127;
161
- }
162
- return family === 6 && host === '::1';
163
- };
164
- /** A plaintext gateway is safe only when it cannot leave this device. */
165
- const isLocalGatewayHostname = (hostname) => {
166
- const host = normalizeHostname(hostname);
167
- return host === 'localhost' || isLocalGatewayAddress(host);
168
- };
169
- const defaultResolveHost = async (hostname) => {
170
- const addresses = await dnsLookup(hostname, { all: true, verbatim: true });
171
- return addresses.flatMap(({ address, family }) => family === 4 || family === 6 ? [{ address, family }] : []);
172
- };
173
- /** Resolve once, validate the result, then pin the outbound connection to it. */
174
- export const resolveForwardAddress = async (hostname, resolveHost = defaultResolveHost) => {
175
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
176
- if (!isForwardableHttpHost(host))
177
- return null;
178
- const family = net.isIP(host);
179
- if (family === 4 || family === 6) {
180
- return isForwardableAddress(host) ? { address: host, family } : null;
181
- }
182
- const addresses = await resolveHost(host);
183
- for (const { address } of addresses) {
184
- const resolvedFamily = net.isIP(address);
185
- if ((resolvedFamily === 4 || resolvedFamily === 6) &&
186
- isForwardableAddress(address)) {
187
- return { address, family: resolvedFamily };
188
- }
189
- }
190
- return null;
191
- };
192
- /**
193
- * Resolve the configured on-device gateway and pin it to a loopback address.
194
- * A poisoned localhost mapping cannot turn this exception into an SSRF bypass.
195
- */
196
- export const resolveLocalGatewayAddress = async (hostname, resolveHost = defaultResolveHost) => {
197
- const host = normalizeHostname(hostname);
198
- const family = net.isIP(host);
199
- if (family === 4 || family === 6) {
200
- if (!isLocalGatewayAddress(host))
201
- return null;
202
- // 0.0.0.0 is a bind address, not a destination. Preserve the documented
203
- // gateway spelling in Host while dialing the local IPv4 listener.
204
- return host === '0.0.0.0'
205
- ? { address: '127.0.0.1', family: 4 }
206
- : { address: host, family };
207
- }
208
- if (host !== 'localhost')
209
- return null;
210
- const addresses = await resolveHost(host);
211
- const candidates = [];
212
- for (const { address } of addresses) {
213
- const resolvedFamily = net.isIP(normalizeHostname(address));
214
- if ((resolvedFamily === 4 || resolvedFamily === 6) &&
215
- isLocalGatewayAddress(address)) {
216
- candidates.push({
217
- address: normalizeHostname(address),
218
- family: resolvedFamily,
219
- });
220
- }
221
- }
222
- // The default local gateway binds IPv4. A client that intentionally runs an
223
- // IPv6-only gateway can enroll its explicit http://[::1] origin.
224
- return (candidates.find(({ family: candidateFamily }) => candidateFamily === 4) ??
225
- candidates[0] ??
226
- null);
227
- };
228
- /**
229
- * Resolve one plain-HTTP proxy target. The sole non-public exceptions are the
230
- * exact local `http` origins enrolled or managed by Connect for this client.
231
- */
232
- export const resolvePlainHttpAddress = async (target, localForwardOrigins, resolveHost = defaultResolveHost) => {
233
- for (const origin of localForwardOrigins) {
234
- try {
235
- const parsed = new URL(origin);
236
- if (parsed.protocol === 'http:' &&
237
- isLocalGatewayHostname(parsed.hostname) &&
238
- target.host === parsed.host) {
239
- return resolveLocalGatewayAddress(target.hostname, resolveHost);
240
- }
241
- }
242
- catch {
243
- /* invalid client config gets no exception */
244
- }
245
- }
246
- return resolveForwardAddress(target.hostname, resolveHost);
247
- };
248
- /** Build the `x-anyray-metadata` value from base attribution + the app label. */
249
- export const buildMetadata = (attribution, appLabel) => {
250
- const meta = {};
251
- if (attribution?.user)
252
- meta.user = attribution.user;
253
- if (attribution?.team)
254
- meta.team = attribution.team;
255
- // The intercepted app becomes a filterable intent:<id> tag in the console.
256
- meta.intent = appLabel;
257
- meta.intentLabel = appLabel;
258
- const entries = Object.entries(meta).filter(([, v]) => v);
259
- return entries.length ? JSON.stringify(Object.fromEntries(entries)) : undefined;
260
- };
261
- /**
262
- * Build the gateway forward for a decrypted AI request. Pure + exported so the
263
- * header contract is unit-tested without a live TLS handshake.
264
- *
265
- * The upstream the gateway relays to is the ACTUAL host the app contacted
266
- * (`https://<host>`), not the allowlist's canonical host — so a per-plan Copilot
267
- * subdomain or a regional endpoint is preserved. The app's own Authorization is
268
- * kept as-is (the gateway forwards it to the provider); `x-anyray-*` control
269
- * headers carry the origin, attribution, and Anyray key.
270
- */
271
- export const buildGatewayForward = (input) => {
272
- const headers = {};
273
- for (const [name, value] of Object.entries(input.reqHeaders)) {
274
- if (value === undefined)
275
- continue;
276
- const key = name.toLowerCase();
277
- if (REQUEST_DROP.has(key))
278
- continue;
279
- // Never forward a control header the app somehow set — we set our own below.
280
- if (key.startsWith('x-anyray-'))
281
- continue;
282
- headers[key] = Array.isArray(value) ? value.join(', ') : value;
283
- }
284
- headers[AGENT_HEADERS.ORIGIN_HOST] = `https://${input.host}`;
285
- headers[AGENT_HEADERS.ORIGIN_PATH] = input.path;
286
- headers[AGENT_HEADERS.APP] = input.appLabel;
287
- const meta = buildMetadata(input.attribution, input.appLabel);
288
- if (meta)
289
- headers['x-anyray-metadata'] = meta;
290
- // The app's Authorization stays put (the seat token the gateway relays); the
291
- // Anyray key rides its own header so both authenticate the right hop.
292
- if (input.clientKey)
293
- headers['x-anyray-api-key'] = input.clientKey;
294
- return {
295
- url: `${input.gatewayOrigin.replace(/\/+$/, '')}${INTAKE_PATH}`,
296
- headers,
297
- };
298
- };
299
- const readBody = (req) => new Promise((resolve) => {
300
- const chunks = [];
301
- req.on('data', (c) => chunks.push(c));
302
- req.on('end', () => resolve(Buffer.concat(chunks)));
303
- req.on('error', () => resolve(Buffer.concat(chunks)));
304
- });
305
- /** Stream a fetch Response back to the client, minus runtime-owned headers. */
306
- const relayResponse = async (clientRes, upstream) => {
307
- const headers = {};
308
- upstream.headers.forEach((value, key) => {
309
- if (!RESPONSE_DROP.has(key.toLowerCase()))
310
- headers[key] = value;
311
- });
312
- clientRes.writeHead(upstream.status, headers);
313
- if (!upstream.body) {
314
- clientRes.end();
315
- return;
316
- }
317
- const reader = upstream.body.getReader();
318
- const onClientGone = () => void reader.cancel().catch(() => { });
319
- clientRes.once('close', onClientGone);
320
- try {
321
- for (;;) {
322
- const { done, value } = await reader.read();
323
- if (done)
324
- break;
325
- if (!value)
326
- continue;
327
- if (clientRes.destroyed) {
328
- void reader.cancel().catch(() => { });
329
- break;
330
- }
331
- if (!clientRes.write(value)) {
332
- await new Promise((resolve) => {
333
- if (clientRes.destroyed || clientRes.writableEnded)
334
- return resolve();
335
- const done2 = () => {
336
- clientRes.off('drain', done2);
337
- clientRes.off('close', done2);
338
- resolve();
339
- };
340
- clientRes.once('drain', done2);
341
- clientRes.once('close', done2);
342
- });
343
- }
344
- }
345
- }
346
- catch {
347
- /* upstream aborted mid-stream */
348
- }
349
- finally {
350
- clientRes.off('close', onClientGone);
351
- }
352
- if (!clientRes.writableEnded)
353
- clientRes.end();
354
- };
355
- const sendError = (res, status, message) => {
356
- if (res.headersSent) {
357
- res.end();
358
- return;
359
- }
360
- res.writeHead(status, { 'content-type': 'application/json' });
361
- res.end(JSON.stringify({ error: { type: 'anyray_agent', message } }));
362
- };
363
- export const createInterceptProxy = (config) => {
364
- const gatewayOrigin = config.gatewayOrigin.replace(/\/+$/, '');
365
- const localForwardOrigins = [
366
- gatewayOrigin,
367
- ...(config.localForwardOrigins ?? []),
368
- ];
369
- const decide = config.decide ?? decideHost;
370
- const mint = config.mintLeaf ?? mintLeaf;
371
- const resolveHost = config.resolveHost ?? defaultResolveHost;
372
- // The internal server that handles a DECRYPTED request after TLS termination.
373
- // Each MITM'd socket is fed here via `emit('connection', tlsSocket)`; the host
374
- // is stashed on the socket by the CONNECT handler.
375
- const mitmServer = http.createServer((req, res) => {
376
- void handleMitmRequest(req, res).catch((error) => {
377
- console.error('anyray agent: forward error', error instanceof Error ? error.message : String(error));
378
- sendError(res, 502, 'agent forward failed');
379
- });
380
- });
381
- // Never bypass governance by dialing an intercepted AI origin directly. The
382
- // gateway intake is request/response-only today, so reject upgrades until it
383
- // has an explicitly metered WebSocket route.
384
- mitmServer.on('upgrade', (_req, clientSocket) => {
385
- clientSocket.end('HTTP/1.1 501 Not Implemented\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
386
- });
387
- const handleMitmRequest = async (req, res) => {
388
- const sock = req.socket;
389
- const host = sock._anyrayHost;
390
- const appLabel = sock._anyrayApp ?? 'browser';
391
- if (!host) {
392
- sendError(res, 502, 'missing intercepted host');
393
- return;
394
- }
395
- const method = req.method ?? 'GET';
396
- const path = req.url ?? '/';
397
- const body = await readBody(req);
398
- const { url, headers } = buildGatewayForward({
399
- gatewayOrigin,
400
- host,
401
- path,
402
- appLabel,
403
- reqHeaders: req.headers,
404
- clientKey: config.clientKey,
405
- attribution: config.attribution,
406
- });
407
- let upstream;
408
- try {
409
- upstream = await fetch(url, {
410
- method,
411
- headers,
412
- body: body.length ? body : undefined,
413
- duplex: 'half',
414
- });
415
- }
416
- catch (error) {
417
- console.error(`anyray agent: gateway ${gatewayOrigin} unreachable for ${host} — ${error instanceof Error ? error.message : String(error)}`);
418
- sendError(res, 502, 'gateway unreachable');
419
- return;
420
- }
421
- await relayResponse(res, upstream);
422
- };
423
- // The public proxy: handles CONNECT (HTTPS) and origin/absolute-form requests.
424
- const server = http.createServer((req, res) => {
425
- const url = req.url ?? '/';
426
- // Direct health probe (a plain GET to the port, not proxied through us).
427
- if (req.method === 'GET' && url === '/__anyray/health') {
428
- res.writeHead(200, { 'content-type': 'application/json' });
429
- res.end(JSON.stringify({
430
- status: 'ok',
431
- port: config.port,
432
- gatewayOrigin,
433
- mode: 'intercept',
434
- }));
435
- return;
436
- }
437
- // Plain-HTTP proxy request (absolute-form `http://` URL). AI traffic is
438
- // HTTPS (→ CONNECT), so this is non-AI: forward it through untouched, never
439
- // decrypt. We only accept `http://` here — an absolute-form `https://` would
440
- // make http.request() throw synchronously (protocol mismatch), so it's
441
- // rejected rather than crashing the daemon (https always arrives as CONNECT).
442
- if (/^http:\/\//i.test(url)) {
443
- void forwardPlainHttp(req, res);
444
- return;
445
- }
446
- sendError(res, 400, 'anyray agent: unsupported request');
447
- });
448
- // HTTPS interception: the app issues `CONNECT host:443`.
449
- server.on('connect', (req, clientSocket, head) => {
450
- const [rawHost, rawPort] = (req.url ?? '').split(':');
451
- const host = rawHost ?? '';
452
- const port = Number(rawPort) || 443;
453
- const decision = decide(host);
454
- clientSocket.on('error', () => clientSocket.destroy());
455
- if (!decision.mitm) {
456
- tunnel(clientSocket, head, host, port);
457
- return;
458
- }
459
- // A single bad handshake must NEVER crash the daemon — if it died, the OS
460
- // proxy would be left pointing at a dead listener, trapping the machine.
461
- // mitmConnect handles its own failures (pre-handshake → blind tunnel,
462
- // post-handshake → destroy); this .catch() is the last-resort safety net so
463
- // an unexpected rejection degrades one connection instead of the process.
464
- mitmConnect(clientSocket, head, host, decision.entry).catch((error) => {
465
- console.error(`anyray agent: intercept error for ${host} — ${error instanceof Error ? error.message : String(error)}`);
466
- clientSocket.destroy();
467
- });
468
- });
469
- /** Blind pass-through: pipe both directions, never decrypt (the invariant). */
470
- const tunnel = (clientSocket, head, host, port) => {
471
- const upstream = net.connect(port, host, () => {
472
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
473
- if (head?.length)
474
- upstream.write(head);
475
- upstream.pipe(clientSocket);
476
- clientSocket.pipe(upstream);
477
- });
478
- upstream.on('error', () => {
479
- clientSocket.destroy();
480
- upstream.destroy();
481
- });
482
- clientSocket.on('error', () => upstream.destroy());
483
- };
484
- /** Terminate TLS with a minted leaf, then feed the request to the MITM server. */
485
- const mitmConnect = async (clientSocket, head, host, entry) => {
486
- let leaf;
487
- try {
488
- leaf = await mint(host);
489
- }
490
- catch (error) {
491
- // Can't mint (openssl missing / CA broken) — don't break the app: fall
492
- // back to a blind tunnel so the request still reaches the provider.
493
- console.error(`anyray agent: cert mint failed for ${host}, tunneling instead — ${error instanceof Error ? error.message : String(error)}`);
494
- tunnel(clientSocket, head, host, 443);
495
- return;
496
- }
497
- // Past this point we've committed to intercepting: the 200 is written and
498
- // TLS is terminated locally. A failure here can't cleanly downgrade to a
499
- // tunnel (the client already saw the 200), so it destroys the socket rather
500
- // than throwing out of the async handler and risking the daemon.
501
- try {
502
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
503
- // Any bytes the client already sent (the start of its TLS ClientHello) were
504
- // consumed off the raw socket by the CONNECT parse — put them BACK on the
505
- // socket so the TLS layer reads them as part of the handshake. They are
506
- // encrypted TLS bytes, NOT decrypted app data, so they must go through the
507
- // TLSSocket, never be emitted as a plaintext 'data' event.
508
- if (head?.length)
509
- clientSocket.unshift(head);
510
- const tlsSocket = new tls.TLSSocket(clientSocket, {
511
- isServer: true,
512
- key: leaf.key,
513
- cert: leaf.cert,
514
- // Force HTTP/1.1 — the internal http.Server can't speak h2. A client that
515
- // offered h2 falls back to http/1.1 rather than failing the handshake.
516
- ALPNProtocols: ['http/1.1'],
517
- });
518
- tlsSocket._anyrayHost =
519
- host;
520
- tlsSocket._anyrayApp =
521
- entry?.app ?? 'browser';
522
- tlsSocket.on('error', () => tlsSocket.destroy());
523
- mitmServer.emit('connection', tlsSocket);
524
- }
525
- catch (error) {
526
- console.error(`anyray agent: TLS termination failed for ${host} — ${error instanceof Error ? error.message : String(error)}`);
527
- clientSocket.destroy();
528
- }
529
- };
530
- /** Forward a plain-HTTP (non-AI) proxy request straight through, undecrypted. */
531
- const forwardPlainHttp = async (req, res) => {
532
- let target;
533
- try {
534
- target = new URL(req.url ?? '');
535
- }
536
- catch {
537
- sendError(res, 400, 'bad proxy url');
538
- return;
539
- }
540
- // Only http:// reaches here (the request handler filters), but guard anyway:
541
- // http.request() throws synchronously on a non-http protocol, and any other
542
- // sync throw here would take down the daemon.
543
- if (target.protocol !== 'http:') {
544
- sendError(res, 400, 'unsupported proxy protocol');
545
- return;
546
- }
547
- let resolved;
548
- try {
549
- resolved = await resolvePlainHttpAddress(target, localForwardOrigins, resolveHost);
550
- }
551
- catch {
552
- sendError(res, 502, 'upstream hostname could not be resolved');
553
- return;
554
- }
555
- if (!resolved) {
556
- sendError(res, 403, 'anyray agent: refusing to proxy to a non-forwardable host');
557
- return;
558
- }
559
- try {
560
- const headers = {
561
- ...req.headers,
562
- host: target.host,
563
- };
564
- delete headers['proxy-authorization'];
565
- delete headers['proxy-connection'];
566
- // `resolved.address` was resolved once and accepted as either globally
567
- // routable or an exact configured loopback origin; the socket is pinned
568
- // to that exact address.
569
- const upstream = http.request(// lgtm[js/request-forgery]
570
- {
571
- protocol: 'http:',
572
- hostname: resolved.address,
573
- family: resolved.family,
574
- port: target.port || 80,
575
- path: `${target.pathname}${target.search}`,
576
- method: req.method,
577
- headers,
578
- }, (up) => {
579
- res.writeHead(up.statusCode ?? 502, up.headers);
580
- up.pipe(res);
581
- });
582
- upstream.on('error', () => sendError(res, 502, 'upstream unreachable'));
583
- req.pipe(upstream);
584
- }
585
- catch (error) {
586
- console.error('anyray agent: plain-http forward failed —', error instanceof Error ? error.message : String(error));
587
- sendError(res, 502, 'upstream unreachable');
588
- }
589
- };
590
- return { server, mitmServer };
591
- };
592
- /**
593
- * True when forwarding decrypted traffic to `origin` is safe: https, or a
594
- * loopback host (where plaintext http never leaves the machine). A remote
595
- * plaintext-http gateway would put the app's decrypted prompts AND its session
596
- * token on the wire in the clear — unacceptable for a MITM capture agent.
597
- */
598
- export const isSafeGatewayOrigin = (origin) => {
599
- try {
600
- const { protocol, hostname } = new URL(origin);
601
- if (protocol === 'https:')
602
- return true;
603
- return protocol === 'http:' && isLocalGatewayHostname(hostname);
604
- }
605
- catch {
606
- return false;
607
- }
608
- };
609
- /** OS-service entrypoint: load config from the profile, listen, run forever. */
610
- export const runAgentProxy = async () => {
611
- const profile = await loadProfile();
612
- const port = profile.agentPort ?? DEFAULT_AGENT_PORT;
613
- const gatewayOrigin = profile.gateway?.replace(/\/+$/, '');
614
- // Any fatal give-up BEFORE we're listening must revert the system proxy: the
615
- // CLI pointed the OS at us before launching, so exiting without restoring
616
- // would trap the machine behind a dead listener (fail-open, invariant #3).
617
- // This covers a runtime CA failure / bad config at boot, when the supervisor
618
- // relaunches us but the persisted system proxy still points at our port. We do
619
- // NOT hook SIGTERM — the supervisor sends it on every normal restart, and
620
- // reverting the proxy each time would fight the supervisor.
621
- const failOpen = async (message) => {
622
- console.error(`anyray agent: ${message}`);
623
- try {
624
- await restoreSystemProxy(port);
625
- }
626
- catch {
627
- /* best-effort — nothing more we can do here */
628
- }
629
- return 1;
630
- };
631
- if (!gatewayOrigin) {
632
- return failOpen('not configured (missing gateway in ~/.anyray). Run `anyray-connect` then `anyray-connect agent install`.');
633
- }
634
- if (!isSafeGatewayOrigin(gatewayOrigin)) {
635
- return failOpen(`refusing to forward decrypted traffic to a plaintext gateway (${gatewayOrigin}) — ` +
636
- 'use an https:// gateway so session tokens never cross the network in the clear.');
637
- }
638
- // Warm the CA so the first interception doesn't pay generation latency (and
639
- // fails loudly here if openssl is missing, rather than per-request).
640
- try {
641
- await ensureCa();
642
- }
643
- catch (error) {
644
- return failOpen(`could not initialize the device CA — ${error instanceof Error ? error.message : String(error)}`);
645
- }
646
- const proxy = createInterceptProxy({
647
- port,
648
- gatewayOrigin,
649
- localForwardOrigins: profile.fallbackEnabled === true && profile.fallbackPort
650
- ? [`http://127.0.0.1:${profile.fallbackPort}`]
651
- : undefined,
652
- clientKey: profile.clientKey,
653
- attribution: { user: profile.name, team: profile.team },
654
- });
655
- try {
656
- await new Promise((resolve, reject) => {
657
- proxy.server.once('error', reject);
658
- proxy.server.listen(port, '127.0.0.1', () => {
659
- console.error(`anyray agent proxy listening on 127.0.0.1:${port} → ${gatewayOrigin} (intercepting AI domains only)`);
660
- resolve();
661
- });
662
- });
663
- }
664
- catch (error) {
665
- return failOpen(`could not bind 127.0.0.1:${port} — ${error instanceof Error ? error.message : String(error)}`);
666
- }
667
- await new Promise(() => { });
668
- return 0;
669
- };
670
- //# sourceMappingURL=agentProxy.js.map