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.
- package/README.md +7 -56
- package/dist/cli.js +117 -23
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.js +31 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/tools/claudeCode.js +43 -14
- package/dist/tools/claudeCode.js.map +1 -1
- package/dist/tools/codex.js +12 -4
- package/dist/tools/codex.js.map +1 -1
- package/dist/tools/copilotChat.js +8 -6
- package/dist/tools/copilotChat.js.map +1 -1
- package/dist/tools/manual.js +26 -5
- package/dist/tools/manual.js.map +1 -1
- package/dist/tools/opencode.js +7 -5
- package/dist/tools/opencode.js.map +1 -1
- package/dist/tools/shellEnv.js +43 -1
- package/dist/tools/shellEnv.js.map +1 -1
- package/dist/util/jsonFile.js +43 -5
- package/dist/util/jsonFile.js.map +1 -1
- package/dist/util/persist.js +61 -0
- package/dist/util/persist.js.map +1 -1
- package/dist/util/profile.js +8 -0
- package/dist/util/profile.js.map +1 -1
- package/package.json +1 -1
- package/dist/commands/agent.js +0 -347
- package/dist/commands/agent.js.map +0 -1
- package/dist/commands/agentProxy.js +0 -670
- package/dist/commands/agentProxy.js.map +0 -1
- package/dist/util/agentAllowlist.js +0 -107
- package/dist/util/agentAllowlist.js.map +0 -1
- package/dist/util/agentCa.js +0 -209
- package/dist/util/agentCa.js.map +0 -1
- package/dist/util/agentPaths.js +0 -38
- package/dist/util/agentPaths.js.map +0 -1
- package/dist/util/agentService.js +0 -215
- package/dist/util/agentService.js.map +0 -1
- package/dist/util/agentTrust.js +0 -517
- 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
|