@openape/proxy 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,15 +1,130 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
3
25
 
4
26
  // src/index.ts
27
+ var import_node_fs3 = require("fs");
5
28
  var import_node_http = require("http");
29
+ var import_node_os = require("os");
30
+ var import_node_path2 = require("path");
6
31
  var import_node_util = require("util");
7
32
 
8
- // src/config.ts
33
+ // src/ca-store.ts
9
34
  var import_node_fs = require("fs");
35
+ var import_node_path = require("path");
36
+ var import_node_forge = __toESM(require("node-forge"), 1);
37
+ function loadOrCreateCa(opts) {
38
+ if ((0, import_node_fs.existsSync)(opts.certPath) && (0, import_node_fs.existsSync)(opts.keyPath)) {
39
+ return {
40
+ certPem: (0, import_node_fs.readFileSync)(opts.certPath, "utf-8"),
41
+ keyPem: (0, import_node_fs.readFileSync)(opts.keyPath, "utf-8"),
42
+ created: false
43
+ };
44
+ }
45
+ const keys = import_node_forge.default.pki.rsa.generateKeyPair({ bits: 2048 });
46
+ const cert = import_node_forge.default.pki.createCertificate();
47
+ cert.publicKey = keys.publicKey;
48
+ cert.serialNumber = String(Date.now());
49
+ cert.validity.notBefore = /* @__PURE__ */ new Date();
50
+ cert.validity.notAfter = new Date(Date.now() + 10 * 365 * 864e5);
51
+ const attrs = [
52
+ // @types/node-forge types valueTagClass as asn1.Class, but node-forge's
53
+ // x509.js reads it as an asn1.Type at runtime (see comment above). Cast to
54
+ // satisfy the too-narrow typing without changing runtime behaviour.
55
+ { name: "commonName", value: opts.subjectCN, valueTagClass: import_node_forge.default.asn1.Type.UTF8 }
56
+ ];
57
+ cert.setSubject(attrs);
58
+ cert.setIssuer(attrs);
59
+ cert.setExtensions([
60
+ { name: "basicConstraints", cA: true },
61
+ { name: "keyUsage", keyCertSign: true, cRLSign: true },
62
+ { name: "subjectKeyIdentifier" }
63
+ ]);
64
+ cert.sign(keys.privateKey, import_node_forge.default.md.sha256.create());
65
+ const certPem = import_node_forge.default.pki.certificateToPem(cert);
66
+ const keyPem = import_node_forge.default.pki.privateKeyToPem(keys.privateKey);
67
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(opts.certPath), { recursive: true, mode: 448 });
68
+ (0, import_node_fs.writeFileSync)(opts.certPath, certPem, { mode: 384 });
69
+ (0, import_node_fs.writeFileSync)(opts.keyPath, keyPem, { mode: 384 });
70
+ return { certPem, keyPem, created: true };
71
+ }
72
+ function mintLeafCert(ca, hostname) {
73
+ const caCert = import_node_forge.default.pki.certificateFromPem(ca.certPem);
74
+ const caKey = import_node_forge.default.pki.privateKeyFromPem(ca.keyPem);
75
+ const keys = import_node_forge.default.pki.rsa.generateKeyPair({ bits: 2048 });
76
+ const cert = import_node_forge.default.pki.createCertificate();
77
+ cert.publicKey = keys.publicKey;
78
+ cert.serialNumber = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
79
+ cert.validity.notBefore = /* @__PURE__ */ new Date();
80
+ const expiresAt = Date.now() + 24 * 36e5;
81
+ cert.validity.notAfter = new Date(expiresAt);
82
+ cert.setSubject([
83
+ // See note in createCaCert: valueTagClass is an asn1.Type at runtime;
84
+ // cast around the too-narrow @types/node-forge annotation.
85
+ { name: "commonName", value: hostname, valueTagClass: import_node_forge.default.asn1.Type.UTF8 }
86
+ ]);
87
+ cert.setIssuer(caCert.subject.attributes);
88
+ cert.setExtensions([
89
+ { name: "basicConstraints", cA: false },
90
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true },
91
+ { name: "extKeyUsage", serverAuth: true },
92
+ { name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }
93
+ ]);
94
+ cert.sign(caKey, import_node_forge.default.md.sha256.create());
95
+ return {
96
+ certPem: import_node_forge.default.pki.certificateToPem(cert),
97
+ keyPem: import_node_forge.default.pki.privateKeyToPem(keys.privateKey),
98
+ expiresAt
99
+ };
100
+ }
101
+ function createLeafCertCache(ca, opts) {
102
+ const cache = /* @__PURE__ */ new Map();
103
+ return {
104
+ get(hostname) {
105
+ const existing = cache.get(hostname);
106
+ if (existing && existing.expiresAt > Date.now()) {
107
+ cache.delete(hostname);
108
+ cache.set(hostname, existing);
109
+ return existing;
110
+ }
111
+ const fresh = mintLeafCert(ca, hostname);
112
+ cache.set(hostname, fresh);
113
+ while (cache.size > opts.capacity) {
114
+ const oldestKey = cache.keys().next().value;
115
+ if (oldestKey === void 0) break;
116
+ cache.delete(oldestKey);
117
+ }
118
+ return fresh;
119
+ }
120
+ };
121
+ }
122
+
123
+ // src/config.ts
124
+ var import_node_fs2 = require("fs");
10
125
  var import_smol_toml = require("smol-toml");
11
126
  function loadMultiAgentConfig(path, overrides) {
12
- const raw = (0, import_node_fs.readFileSync)(path, "utf-8");
127
+ const raw = (0, import_node_fs2.readFileSync)(path, "utf-8");
13
128
  let parsed;
14
129
  if (path.endsWith(".json")) {
15
130
  parsed = JSON.parse(raw);
@@ -68,26 +183,26 @@ function matchesRule(rule, domain, method, path) {
68
183
  }
69
184
  return true;
70
185
  }
71
- function evaluateRules(config2, domain, method, path) {
72
- for (const rule of config2.deny) {
186
+ function evaluateRules(config, domain, method, path) {
187
+ for (const rule of config.deny) {
73
188
  if (matchesRule(rule, domain, method, path)) {
74
189
  return { type: "deny", note: rule.note };
75
190
  }
76
191
  }
77
- for (const rule of config2.allow) {
192
+ for (const rule of config.allow) {
78
193
  if (matchesRule(rule, domain, method, path)) {
79
194
  return { type: "allow" };
80
195
  }
81
196
  }
82
- for (const rule of config2.grant_required) {
197
+ for (const rule of config.grant_required) {
83
198
  if (matchesRule(rule, domain, method, path)) {
84
199
  return { type: "grant_required", rule };
85
200
  }
86
201
  }
87
- if (config2.proxy.default_action === "block") {
202
+ if (config.proxy.default_action === "block") {
88
203
  return { type: "deny", note: "No matching rule (default: block)" };
89
204
  }
90
- if (config2.proxy.default_action === "allow") {
205
+ if (config.proxy.default_action === "allow") {
91
206
  return { type: "allow" };
92
207
  }
93
208
  return {
@@ -277,14 +392,14 @@ function isPrivateIP(ip) {
277
392
  if ((0, import_node_net.isIP)(ip) === 6) return isPrivateIPv6(ip);
278
393
  return false;
279
394
  }
280
- async function checkEgress(hostname2) {
281
- if ((0, import_node_net.isIP)(hostname2)) {
282
- return isPrivateIP(hostname2) ? { kind: "private" } : { kind: "ok" };
395
+ async function checkEgress(hostname) {
396
+ if ((0, import_node_net.isIP)(hostname)) {
397
+ return isPrivateIP(hostname) ? { kind: "private" } : { kind: "ok" };
283
398
  }
284
- if (hostname2 === "localhost") return { kind: "private" };
399
+ if (hostname === "localhost") return { kind: "private" };
285
400
  let settled;
286
401
  try {
287
- settled = await Promise.allSettled([(0, import_promises.resolve4)(hostname2), (0, import_promises.resolve6)(hostname2)]);
402
+ settled = await Promise.allSettled([(0, import_promises.resolve4)(hostname), (0, import_promises.resolve6)(hostname)]);
288
403
  } catch {
289
404
  return { kind: "unresolvable", reason: "dns-error" };
290
405
  }
@@ -300,17 +415,100 @@ async function checkEgress(hostname2) {
300
415
 
301
416
  // src/connect.ts
302
417
  var import_node_net2 = require("net");
303
- async function handleConnect(config2, grantsClients, req, clientSocket, _head) {
418
+
419
+ // src/mitm-connect.ts
420
+ var import_node_tls = require("tls");
421
+ function handleMitmConnect(opts) {
422
+ const leaf = opts.leafCache.get(opts.host);
423
+ const ctx = (0, import_node_tls.createSecureContext)({ cert: leaf.certPem, key: leaf.keyPem });
424
+ const tls = new import_node_tls.TLSSocket(opts.clientSocket, { isServer: true, secureContext: ctx });
425
+ opts.clientSocket.resume();
426
+ let buffer = Buffer.alloc(0);
427
+ let dispatched = false;
428
+ const onData = (chunk) => {
429
+ if (dispatched) return;
430
+ buffer = Buffer.concat([buffer, chunk]);
431
+ const headerEnd = buffer.indexOf("\r\n\r\n");
432
+ if (headerEnd < 0) return;
433
+ const headerBlock = buffer.subarray(0, headerEnd).toString("utf-8");
434
+ const lines = headerBlock.split("\r\n");
435
+ const requestLine = lines[0] ?? "";
436
+ const [method, path] = requestLine.split(" ");
437
+ const headers = /* @__PURE__ */ new Map();
438
+ for (const line of lines.slice(1)) {
439
+ const i = line.indexOf(":");
440
+ if (i > 0) headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
441
+ }
442
+ const decision = opts.onRequest({
443
+ method: method ?? "",
444
+ host: opts.host,
445
+ path: path ?? "/",
446
+ headers,
447
+ // inject decisions use only target+headers; raw body bytes still flow through to upstream below
448
+ body: null
449
+ });
450
+ if (decision.type === "short-circuit") {
451
+ dispatched = true;
452
+ const body = Buffer.from(decision.body, "utf-8");
453
+ tls.write(`HTTP/1.1 ${decision.status} OK\r
454
+ Content-Length: ${body.length}\r
455
+ \r
456
+ `);
457
+ tls.write(body);
458
+ tls.end();
459
+ return;
460
+ }
461
+ dispatched = true;
462
+ tls.pause();
463
+ const upstream = (0, import_node_tls.connect)({
464
+ host: opts.host,
465
+ port: opts.port,
466
+ servername: opts.host,
467
+ rejectUnauthorized: opts.upstreamRejectUnauthorized ?? true
468
+ });
469
+ upstream.once("secureConnect", () => {
470
+ const mutated = decision.mutatedHeaders;
471
+ const newHeaderLines = [];
472
+ for (const line of lines.slice(1)) {
473
+ const i = line.indexOf(":");
474
+ if (i <= 0) continue;
475
+ const lower = line.slice(0, i).trim().toLowerCase();
476
+ if (mutated.has(lower)) continue;
477
+ newHeaderLines.push(line);
478
+ }
479
+ for (const [name, value] of mutated.entries()) {
480
+ newHeaderLines.push(`${capitalizeHeader(name)}: ${value}`);
481
+ }
482
+ const fullRequest = [requestLine, ...newHeaderLines, "", ""].join("\r\n");
483
+ upstream.write(fullRequest);
484
+ const remainder = buffer.subarray(headerEnd + 4);
485
+ if (remainder.length > 0) upstream.write(remainder);
486
+ upstream.pipe(tls);
487
+ tls.removeListener("data", onData);
488
+ tls.pipe(upstream);
489
+ tls.resume();
490
+ });
491
+ upstream.on("error", () => tls.end());
492
+ };
493
+ tls.on("data", onData);
494
+ tls.on("error", () => opts.clientSocket.destroy());
495
+ }
496
+ function capitalizeHeader(name) {
497
+ return name.split("-").map((p) => (p[0]?.toUpperCase() ?? "") + p.slice(1)).join("-");
498
+ }
499
+
500
+ // src/connect.ts
501
+ async function handleConnect(config, grantsClients, req, clientSocket, head, deps) {
304
502
  const target = req.url ?? "";
305
503
  const [host, portStr] = target.split(":");
306
- const port2 = Number.parseInt(portStr || "443");
307
- if (!host || !port2) {
504
+ const port = Number.parseInt(portStr || "443");
505
+ if (!host || !port) {
308
506
  clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
309
507
  clientSocket.destroy();
310
508
  return;
311
509
  }
312
510
  const idpHosts = new Set(
313
- config2.agents.map((a) => {
511
+ config.agents.map((a) => {
314
512
  try {
315
513
  return new URL(a.idp_url).hostname.toLowerCase();
316
514
  } catch {
@@ -328,46 +526,51 @@ async function handleConnect(config2, grantsClients, req, clientSocket, _head) {
328
526
  path: target,
329
527
  rule: "idp-system-bypass"
330
528
  });
331
- tunnel(host, port2, clientSocket);
529
+ tunnel(host, port, clientSocket);
332
530
  return;
333
531
  }
334
- const mandatoryAuth = config2.proxy.mandatory_auth ?? false;
532
+ const mandatoryAuth = config.proxy.mandatory_auth ?? false;
533
+ const daemonMode = deps?.daemonMode ?? false;
335
534
  let agentEmail;
336
- try {
337
- const authHeader = req.headers["proxy-authorization"];
338
- let identity = null;
339
- for (const agentConf2 of config2.agents) {
340
- identity = await verifyAgentAuth(
341
- authHeader ?? null,
342
- agentConf2.idp_url,
343
- mandatoryAuth && config2.agents.length === 1
344
- );
345
- if (identity) break;
346
- }
347
- if (mandatoryAuth && !identity) {
348
- throw new AuthError("JWT required");
349
- }
350
- agentEmail = identity?.email;
351
- if (agentEmail) {
352
- const known = config2.agents.find((a) => a.email === agentEmail);
353
- if (!known) {
354
- clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
535
+ if (daemonMode) {
536
+ agentEmail = config.agents[0]?.email;
537
+ } else {
538
+ try {
539
+ const authHeader = req.headers["proxy-authorization"];
540
+ let identity = null;
541
+ for (const agentConf2 of config.agents) {
542
+ identity = await verifyAgentAuth(
543
+ authHeader ?? null,
544
+ agentConf2.idp_url,
545
+ mandatoryAuth && config.agents.length === 1
546
+ );
547
+ if (identity) break;
548
+ }
549
+ if (mandatoryAuth && !identity) {
550
+ throw new AuthError("JWT required");
551
+ }
552
+ agentEmail = identity?.email;
553
+ if (agentEmail) {
554
+ const known = config.agents.find((a) => a.email === agentEmail);
555
+ if (!known) {
556
+ clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
557
+ clientSocket.destroy();
558
+ return;
559
+ }
560
+ } else if (config.agents.length > 1) {
561
+ throw new AuthError("JWT required for multi-agent proxy");
562
+ }
563
+ } catch (err) {
564
+ if (err instanceof AuthError) {
565
+ clientSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
355
566
  clientSocket.destroy();
356
567
  return;
357
568
  }
358
- } else if (config2.agents.length > 1) {
359
- throw new AuthError("JWT required for multi-agent proxy");
360
- }
361
- } catch (err) {
362
- if (err instanceof AuthError) {
363
- clientSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
364
- clientSocket.destroy();
365
- return;
569
+ throw err;
366
570
  }
367
- throw err;
368
571
  }
369
572
  const egress = await checkEgress(host);
370
- const auditAgent = agentEmail ?? config2.agents[0]?.email ?? "unknown";
573
+ const auditAgent = agentEmail ?? config.agents[0]?.email ?? "unknown";
371
574
  if (egress.kind === "private") {
372
575
  writeAudit({
373
576
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -402,7 +605,7 @@ DNS lookup failed for ${host} (${egress.reason}).\r
402
605
  clientSocket.destroy();
403
606
  return;
404
607
  }
405
- const agentConf = agentEmail ? config2.agents.find((a) => a.email === agentEmail) : config2.agents[0];
608
+ const agentConf = agentEmail ? config.agents.find((a) => a.email === agentEmail) : config.agents[0];
406
609
  if (!agentConf) {
407
610
  clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
408
611
  clientSocket.destroy();
@@ -411,10 +614,10 @@ DNS lookup failed for ${host} (${egress.reason}).\r
411
614
  const effectiveEmail = agentEmail ?? agentConf.email;
412
615
  const rulesConfig = {
413
616
  proxy: {
414
- listen: config2.proxy.listen,
617
+ listen: config.proxy.listen,
415
618
  idp_url: agentConf.idp_url,
416
619
  agent_email: agentConf.email,
417
- default_action: config2.proxy.default_action
620
+ default_action: config.proxy.default_action
418
621
  },
419
622
  allow: agentConf.allow ?? [],
420
623
  deny: agentConf.deny ?? [],
@@ -455,7 +658,7 @@ Blocked:${note}\r
455
658
  audience: "ape-proxy",
456
659
  grantType: action.rule.grant_type,
457
660
  permissions: action.rule.permissions,
458
- reason: `CONNECT ${host}:${port2}`,
661
+ reason: `CONNECT ${host}:${port}`,
459
662
  duration: action.rule.duration
460
663
  });
461
664
  const decided = await grantsClient.waitForApproval(grant.id);
@@ -484,10 +687,40 @@ Grant request failed: ${msg}\r
484
687
  } else {
485
688
  writeAudit({ ...baseAudit, action: "allow", rule: "allow-list" });
486
689
  }
487
- tunnel(host, port2, clientSocket);
690
+ if (deps?.secretsStore && deps?.leafCache) {
691
+ clientSocket.pause();
692
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
693
+ if (head.length > 0) {
694
+ clientSocket.unshift(head);
695
+ }
696
+ const store = deps.secretsStore;
697
+ handleMitmConnect({
698
+ clientSocket,
699
+ host,
700
+ port,
701
+ leafCache: deps.leafCache,
702
+ upstreamRejectUnauthorized: deps.upstreamRejectUnauthorized,
703
+ onRequest: (mreq) => {
704
+ const portSuffix = port === 443 ? "" : `:${port}`;
705
+ const targetUrl = new URL(`https://${mreq.host}${portSuffix}${mreq.path}`);
706
+ const match = store.findFor(targetUrl);
707
+ if (!match) return { type: "forward", mutatedHeaders: /* @__PURE__ */ new Map() };
708
+ const rendered = match.template.replace(/\$\{value\}/g, match.value);
709
+ console.error(
710
+ `[openape-proxy] injected secret '${match.name}' into ${match.header} for ${mreq.method} ${targetUrl.href}`
711
+ );
712
+ return {
713
+ type: "forward",
714
+ mutatedHeaders: /* @__PURE__ */ new Map([[match.header.toLowerCase(), rendered]])
715
+ };
716
+ }
717
+ });
718
+ return;
719
+ }
720
+ tunnel(host, port, clientSocket);
488
721
  }
489
- function tunnel(host, port2, clientSocket) {
490
- const targetSocket = (0, import_node_net2.connect)(port2, host, () => {
722
+ function tunnel(host, port, clientSocket) {
723
+ const targetSocket = (0, import_node_net2.connect)(port, host, () => {
491
724
  clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
492
725
  targetSocket.pipe(clientSocket);
493
726
  clientSocket.pipe(targetSocket);
@@ -513,30 +746,33 @@ async function computeRequestHash(method, targetUrl, body) {
513
746
  }
514
747
  return hash.digest("hex");
515
748
  }
516
- function buildGrantsClients(config2) {
749
+ function buildGrantsClients(config) {
517
750
  const grantsClients = /* @__PURE__ */ new Map();
518
- for (const agent of config2.agents) {
751
+ for (const agent of config.agents) {
519
752
  grantsClients.set(agent.email, new GrantsClient(agent.idp_url));
520
753
  }
521
754
  return grantsClients;
522
755
  }
523
- function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(config2)) {
524
- for (const agent of config2.agents) {
756
+ function createMultiAgentProxy(config, grantsClients = buildGrantsClients(config), deps = {}) {
757
+ for (const agent of config.agents) {
525
758
  if (!grantsClients.has(agent.email)) {
526
759
  grantsClients.set(agent.email, new GrantsClient(agent.idp_url));
527
760
  }
528
761
  }
529
- const mandatoryAuth = config2.proxy.mandatory_auth ?? false;
762
+ const mandatoryAuth = config.proxy.mandatory_auth ?? false;
763
+ const secretsStore = deps.secretsStore;
764
+ const daemonMode = deps.daemonMode ?? false;
765
+ const fetchImpl = deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
530
766
  return {
531
- port: Number.parseInt(config2.proxy.listen.split(":")[1] || "9090"),
532
- hostname: config2.proxy.listen.split(":")[0] || "127.0.0.1",
767
+ port: Number.parseInt(config.proxy.listen.split(":")[1] || "9090"),
768
+ hostname: config.proxy.listen.split(":")[0] || "127.0.0.1",
533
769
  async fetch(req) {
534
770
  const url = new URL(req.url);
535
771
  const startTime = Date.now();
536
772
  if (url.pathname === "/healthz") {
537
773
  return Response.json({
538
774
  status: "ok",
539
- agents: config2.agents.map((a) => a.email)
775
+ agents: config.agents.map((a) => a.email)
540
776
  });
541
777
  }
542
778
  const targetUrl = url.pathname.slice(1) + url.search;
@@ -564,33 +800,37 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
564
800
  }
565
801
  const bodyBuffer = req.body ? await req.arrayBuffer() : null;
566
802
  let agentIdentity = null;
567
- try {
568
- for (const agentConf2 of config2.agents) {
569
- agentIdentity = await verifyAgentAuth(
570
- req.headers.get("proxy-authorization"),
571
- agentConf2.idp_url,
572
- mandatoryAuth && config2.agents.length === 1
573
- );
574
- if (agentIdentity) break;
575
- }
576
- if (mandatoryAuth && !agentIdentity) {
577
- throw new AuthError("JWT required");
578
- }
579
- } catch (err) {
580
- if (err instanceof AuthError) {
581
- return new Response(`Unauthorized: ${err.message}`, { status: 401 });
803
+ if (daemonMode) {
804
+ agentIdentity = { email: config.agents[0].email, act: "agent" };
805
+ } else {
806
+ try {
807
+ for (const agentConf2 of config.agents) {
808
+ agentIdentity = await verifyAgentAuth(
809
+ req.headers.get("proxy-authorization"),
810
+ agentConf2.idp_url,
811
+ mandatoryAuth && config.agents.length === 1
812
+ );
813
+ if (agentIdentity) break;
814
+ }
815
+ if (mandatoryAuth && !agentIdentity) {
816
+ throw new AuthError("JWT required");
817
+ }
818
+ } catch (err) {
819
+ if (err instanceof AuthError) {
820
+ return new Response(`Unauthorized: ${err.message}`, { status: 401 });
821
+ }
822
+ throw err;
582
823
  }
583
- throw err;
584
824
  }
585
825
  const agentEmail = agentIdentity?.email;
586
826
  let agentConf;
587
827
  if (agentEmail) {
588
- agentConf = config2.agents.find((a) => a.email === agentEmail);
828
+ agentConf = config.agents.find((a) => a.email === agentEmail);
589
829
  if (!agentConf) {
590
830
  return new Response(`Forbidden: unknown agent ${agentEmail}`, { status: 403 });
591
831
  }
592
- } else if (config2.agents.length === 1) {
593
- agentConf = config2.agents[0];
832
+ } else if (config.agents.length === 1) {
833
+ agentConf = config.agents[0];
594
834
  } else {
595
835
  return new Response("Unauthorized: JWT required for multi-agent proxy", { status: 401 });
596
836
  }
@@ -598,10 +838,10 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
598
838
  const grantsClient = grantsClients.get(agentConf.email);
599
839
  const rulesConfig = {
600
840
  proxy: {
601
- listen: config2.proxy.listen,
841
+ listen: config.proxy.listen,
602
842
  idp_url: agentConf.idp_url,
603
843
  agent_email: agentConf.email,
604
- default_action: config2.proxy.default_action
844
+ default_action: config.proxy.default_action
605
845
  },
606
846
  allow: agentConf.allow ?? [],
607
847
  deny: agentConf.deny ?? [],
@@ -621,7 +861,7 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
621
861
  }
622
862
  if (action.type === "allow") {
623
863
  writeAudit({ ...baseAudit, action: "allow", rule: "allow-list", grant_id: null });
624
- return forwardRequest(req, targetUrl, bodyBuffer);
864
+ return forwardRequest(req, targetUrl, targetParsed, bodyBuffer, secretsStore, fetchImpl);
625
865
  }
626
866
  const rule = action.rule;
627
867
  const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`];
@@ -640,13 +880,13 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
640
880
  grant_id: existing.id,
641
881
  request_hash: requestHash
642
882
  });
643
- return forwardRequest(req, targetUrl, bodyBuffer);
883
+ return forwardRequest(req, targetUrl, targetParsed, bodyBuffer, secretsStore, fetchImpl);
644
884
  }
645
- if (config2.proxy.default_action === "block") {
885
+ if (config.proxy.default_action === "block") {
646
886
  writeAudit({ ...baseAudit, action: "deny", rule: "no-grant (block mode)", grant_id: null });
647
887
  return new Response("No grant \u2014 blocked", { status: 403 });
648
888
  }
649
- if (config2.proxy.default_action === "request-async") {
889
+ if (config.proxy.default_action === "request-async") {
650
890
  const grant = await grantsClient.requestGrant({
651
891
  requester: effectiveEmail,
652
892
  targetHost: domain,
@@ -695,7 +935,7 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
695
935
  request_hash: requestHash,
696
936
  waited_ms: waitedMs
697
937
  });
698
- return forwardRequest(req, targetUrl, bodyBuffer);
938
+ return forwardRequest(req, targetUrl, targetParsed, bodyBuffer, secretsStore, fetchImpl);
699
939
  }
700
940
  writeAudit({
701
941
  ...baseAudit,
@@ -718,9 +958,12 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
718
958
  }
719
959
  };
720
960
  }
721
- function createNodeHandler(config2) {
722
- const grantsClients = buildGrantsClients(config2);
723
- const proxy = createMultiAgentProxy(config2, grantsClients);
961
+ function createNodeHandler(config, deps = {}) {
962
+ const grantsClients = buildGrantsClients(config);
963
+ const proxy = createMultiAgentProxy(config, grantsClients, {
964
+ secretsStore: deps.secretsStore,
965
+ daemonMode: deps.daemonMode
966
+ });
724
967
  return {
725
968
  handleRequest(req, res) {
726
969
  const reqUrl = req.url || "/";
@@ -768,24 +1011,39 @@ function createNodeHandler(config2) {
768
1011
  });
769
1012
  },
770
1013
  handleConnect(req, socket, head) {
771
- handleConnect(config2, grantsClients, req, socket, head);
1014
+ handleConnect(config, grantsClients, req, socket, head, {
1015
+ secretsStore: deps.secretsStore,
1016
+ leafCache: deps.leafCache,
1017
+ daemonMode: deps.daemonMode
1018
+ });
772
1019
  }
773
1020
  };
774
1021
  }
775
- async function forwardRequest(originalReq, targetUrl, cachedBody) {
1022
+ async function forwardRequest(originalReq, targetUrl, targetParsed, cachedBody, secretsStore, fetchImpl) {
776
1023
  const headers = new Headers(originalReq.headers);
777
1024
  headers.delete("proxy-authorization");
778
1025
  headers.delete("proxy-connection");
779
1026
  headers.delete("host");
1027
+ if (secretsStore) {
1028
+ const match = secretsStore.findFor(targetParsed);
1029
+ if (match) {
1030
+ const rendered = match.template.replace(/\$\{value\}/g, match.value);
1031
+ headers.set(match.header, rendered);
1032
+ console.error(
1033
+ `[openape-proxy] injected secret '${match.name}' into ${match.header} for ${originalReq.method} ${targetUrl}`
1034
+ );
1035
+ }
1036
+ }
780
1037
  const body = cachedBody && cachedBody.byteLength > 0 ? cachedBody : null;
781
1038
  try {
782
- const res = await fetch(targetUrl, {
1039
+ const upstreamReq = new Request(targetUrl, {
783
1040
  method: originalReq.method,
784
1041
  headers,
785
1042
  body,
786
1043
  duplex: "half",
787
1044
  redirect: "manual"
788
1045
  });
1046
+ const res = await fetchImpl(upstreamReq);
789
1047
  const responseHeaders = new Headers(res.headers);
790
1048
  responseHeaders.delete("transfer-encoding");
791
1049
  responseHeaders.delete("connection");
@@ -800,56 +1058,220 @@ async function forwardRequest(originalReq, targetUrl, cachedBody) {
800
1058
  }
801
1059
  }
802
1060
 
1061
+ // src/secrets-store.ts
1062
+ var import_smol_toml2 = require("smol-toml");
1063
+
1064
+ // src/secrets-match.ts
1065
+ function compileGlob(glob) {
1066
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1067
+ return new RegExp(`^${escaped}$`);
1068
+ }
1069
+ function literalPrefixLen(glob) {
1070
+ const i = glob.indexOf("*");
1071
+ return i < 0 ? glob.length : i;
1072
+ }
1073
+ function targetString(url) {
1074
+ const port = url.port ? `:${url.port}` : "";
1075
+ const path = url.pathname === "/" ? "" : url.pathname;
1076
+ return `${url.hostname}${port}${path}`;
1077
+ }
1078
+ function matchSecret(target, entries) {
1079
+ const targetStr = targetString(target);
1080
+ let best = null;
1081
+ for (let i = 0; i < entries.length; i++) {
1082
+ const entry = entries[i];
1083
+ const re = compileGlob(entry.target);
1084
+ if (!re.test(targetStr) && !re.test(`${targetStr}/`)) continue;
1085
+ const prefix = literalPrefixLen(entry.target);
1086
+ if (!best || prefix > best.prefix) {
1087
+ best = { entry, prefix, idx: i };
1088
+ }
1089
+ }
1090
+ return best?.entry ?? null;
1091
+ }
1092
+
1093
+ // src/secrets-store.ts
1094
+ var MAX_BLOB_BYTES = 4 * 1024;
1095
+ var SUPPORTED_VERSION = "1";
1096
+ function parseSecretsBlob(toml) {
1097
+ if (Buffer.byteLength(toml, "utf-8") > MAX_BLOB_BYTES) {
1098
+ throw new Error(`Secrets blob too large (max ${MAX_BLOB_BYTES} bytes / 4 KiB)`);
1099
+ }
1100
+ const parsed = (0, import_smol_toml2.parse)(toml);
1101
+ const version = parsed.version;
1102
+ if (version !== SUPPORTED_VERSION) {
1103
+ throw new Error(`Unsupported or missing version (expected "${SUPPORTED_VERSION}", got ${JSON.stringify(version)})`);
1104
+ }
1105
+ const secretsBlock = parsed.secrets;
1106
+ const entries = [];
1107
+ if (secretsBlock) {
1108
+ for (const [name, raw] of Object.entries(secretsBlock)) {
1109
+ const required = ["target", "header", "template", "value"];
1110
+ for (const field of required) {
1111
+ if (!raw[field] || typeof raw[field] !== "string") {
1112
+ throw new Error(`Secret '${name}' is missing required field '${field}'`);
1113
+ }
1114
+ }
1115
+ entries.push({
1116
+ name,
1117
+ target: raw.target,
1118
+ header: raw.header,
1119
+ template: raw.template,
1120
+ value: raw.value
1121
+ });
1122
+ }
1123
+ }
1124
+ const literalPrefix = (glob) => {
1125
+ const i = glob.indexOf("*");
1126
+ return i < 0 ? glob : glob.slice(0, i);
1127
+ };
1128
+ const seen = /* @__PURE__ */ new Map();
1129
+ for (const e of entries) {
1130
+ const key = `${literalPrefix(e.target)}|${e.target}`;
1131
+ if (seen.has(key)) {
1132
+ throw new Error(`Duplicate target with identical specificity: '${e.target}' (entries '${seen.get(key)}' and '${e.name}')`);
1133
+ }
1134
+ seen.set(key, e.name);
1135
+ }
1136
+ return {
1137
+ entries,
1138
+ findFor: (target) => matchSecret(target, entries)
1139
+ };
1140
+ }
1141
+
803
1142
  // src/index.ts
1143
+ function loadIdentity() {
1144
+ const path = (0, import_node_path2.join)((0, import_node_os.homedir)(), ".config", "apes", "auth.json");
1145
+ if (!(0, import_node_fs3.existsSync)(path)) {
1146
+ console.error(`[openape-proxy] missing ${path} \u2014 run \`apes login\` first`);
1147
+ process.exit(2);
1148
+ }
1149
+ const raw = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf-8"));
1150
+ const bearer = raw.access_token ?? raw.bearer;
1151
+ if (!raw.email || !raw.idp || !bearer) {
1152
+ console.error(`[openape-proxy] malformed ${path} \u2014 re-run \`apes login\``);
1153
+ process.exit(2);
1154
+ }
1155
+ return { email: raw.email, idpUrl: raw.idp, bearer };
1156
+ }
804
1157
  var { values } = (0, import_node_util.parseArgs)({
805
1158
  options: {
806
1159
  config: { type: "string", short: "c", default: "config.toml" },
807
1160
  "dry-run": { type: "boolean", default: false },
808
- "mandatory-auth": { type: "boolean", default: false }
1161
+ "mandatory-auth": { type: "boolean", default: false },
1162
+ global: { type: "boolean", default: false },
1163
+ port: { type: "string" }
809
1164
  }
810
1165
  });
811
- var configPath = values.config;
812
- console.log(`[openape-proxy] Loading config from ${configPath}`);
813
- var config = loadMultiAgentConfig(configPath, {
814
- mandatoryAuth: values["mandatory-auth"] || void 0
815
- });
816
- if (values["dry-run"]) {
817
- console.log("[openape-proxy] DRY RUN mode \u2014 logging only, not blocking");
818
- console.log("[openape-proxy] Config loaded:");
819
- console.log(` Listen: ${config.proxy.listen}`);
820
- console.log(` Default action: ${config.proxy.default_action}`);
821
- console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
822
- console.log(` Agents: ${config.agents.length}`);
823
- for (const agent of config.agents) {
824
- const allowCount = agent.allow?.length ?? 0;
825
- const denyCount = agent.deny?.length ?? 0;
826
- const grantCount = agent.grant_required?.length ?? 0;
827
- console.log(` ${agent.email} (${agent.idp_url}) \u2014 ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`);
1166
+ if (values.global) {
1167
+ if (process.versions.bun) {
1168
+ console.error(
1169
+ "[openape-proxy] --global mode requires Node (Bun is not supported).\n Bun's node:tls compat layer does not handle TLSSocket-on-existing-socket\n for the CONNECT-MITM pipeline. Re-run with `node` directly, or use the\n built `openape-proxy` binary (its shebang invokes Node)."
1170
+ );
1171
+ process.exit(2);
1172
+ }
1173
+ const stdinBuf = [];
1174
+ let total = 0;
1175
+ let aborted = false;
1176
+ process.stdin.on("data", (chunk) => {
1177
+ if (aborted) return;
1178
+ total += chunk.length;
1179
+ if (total > 8 * 1024) {
1180
+ console.error("[openape-proxy] stdin > 8 KiB, refusing");
1181
+ aborted = true;
1182
+ process.exit(2);
1183
+ }
1184
+ stdinBuf.push(chunk);
1185
+ });
1186
+ process.stdin.on("end", () => {
1187
+ if (aborted) return;
1188
+ const blob = Buffer.concat(stdinBuf).toString("utf-8");
1189
+ if (!blob.trim()) {
1190
+ console.error("[openape-proxy] --global requires secrets TOML on stdin");
1191
+ process.exit(2);
1192
+ }
1193
+ let store;
1194
+ try {
1195
+ store = parseSecretsBlob(blob);
1196
+ } catch (err) {
1197
+ console.error(`[openape-proxy] secrets parse error: ${err.message}`);
1198
+ process.exit(2);
1199
+ }
1200
+ const identity = loadIdentity();
1201
+ const port = Number.parseInt(values.port ?? "18789");
1202
+ const ca = loadOrCreateCa({
1203
+ certPath: (0, import_node_path2.join)((0, import_node_os.homedir)(), ".openape", "proxy", "ca.crt"),
1204
+ keyPath: (0, import_node_path2.join)((0, import_node_os.homedir)(), ".openape", "proxy", "ca.key"),
1205
+ subjectCN: `OpenApe Proxy CA (${identity.email})`
1206
+ });
1207
+ if (ca.created) {
1208
+ console.log(`[openape-proxy] generated new CA at ~/.openape/proxy/ca.crt`);
1209
+ }
1210
+ const leafCache = createLeafCertCache(ca, { capacity: 256 });
1211
+ const config = {
1212
+ proxy: { listen: `127.0.0.1:${port}`, default_action: "allow" },
1213
+ agents: [{ email: identity.email, idp_url: identity.idpUrl }]
1214
+ };
1215
+ const handler = createNodeHandler(config, { secretsStore: store, leafCache, daemonMode: true });
1216
+ const server = (0, import_node_http.createServer)(handler.handleRequest);
1217
+ server.on("connect", handler.handleConnect);
1218
+ server.listen(port, "127.0.0.1", () => {
1219
+ const addr = server.address();
1220
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
1221
+ console.log(`[openape-proxy] identity: ${identity.email} (${identity.idpUrl})`);
1222
+ const names = store.entries.map((e) => e.name).join(", ");
1223
+ console.log(`[openape-proxy] secrets: ${names}`);
1224
+ console.log(`[openape-proxy] export OPENAPE_PROXY=127.0.0.1:${actualPort}`);
1225
+ console.log(`[openape-proxy] listening on 127.0.0.1:${actualPort}`);
1226
+ });
1227
+ process.on("SIGINT", () => server.close(() => process.exit(0)));
1228
+ process.on("SIGTERM", () => server.close(() => process.exit(0)));
1229
+ });
1230
+ process.stdin.resume();
1231
+ } else {
1232
+ const configPath = values.config;
1233
+ console.log(`[openape-proxy] Loading config from ${configPath}`);
1234
+ const config = loadMultiAgentConfig(configPath, {
1235
+ mandatoryAuth: values["mandatory-auth"] || void 0
1236
+ });
1237
+ if (values["dry-run"]) {
1238
+ console.log("[openape-proxy] DRY RUN mode \u2014 logging only, not blocking");
1239
+ console.log("[openape-proxy] Config loaded:");
1240
+ console.log(` Listen: ${config.proxy.listen}`);
1241
+ console.log(` Default action: ${config.proxy.default_action}`);
1242
+ console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
1243
+ console.log(` Agents: ${config.agents.length}`);
1244
+ for (const agent of config.agents) {
1245
+ const allowCount = agent.allow?.length ?? 0;
1246
+ const denyCount = agent.deny?.length ?? 0;
1247
+ const grantCount = agent.grant_required?.length ?? 0;
1248
+ console.log(` ${agent.email} (${agent.idp_url}) \u2014 ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`);
1249
+ }
1250
+ process.exit(0);
828
1251
  }
829
- process.exit(0);
1252
+ const handler = createNodeHandler(config);
1253
+ const port = Number.parseInt(config.proxy.listen.split(":")[1] || "9090");
1254
+ const hostname = config.proxy.listen.split(":")[0] || "127.0.0.1";
1255
+ const server = (0, import_node_http.createServer)(handler.handleRequest);
1256
+ server.on("connect", handler.handleConnect);
1257
+ server.listen(port, hostname, () => {
1258
+ const addr = server.address();
1259
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
1260
+ console.log(`[openape-proxy] Listening on http://${hostname}:${actualPort}`);
1261
+ console.log(`[openape-proxy] CONNECT tunneling enabled`);
1262
+ console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
1263
+ console.log(`[openape-proxy] Agents: ${config.agents.map((a) => a.email).join(", ")}`);
1264
+ console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`);
1265
+ });
1266
+ process.on("SIGINT", () => {
1267
+ console.log("\n[openape-proxy] Shutting down...");
1268
+ server.close();
1269
+ process.exit(0);
1270
+ });
1271
+ process.on("SIGTERM", () => {
1272
+ console.log("[openape-proxy] Shutting down...");
1273
+ server.close();
1274
+ process.exit(0);
1275
+ });
830
1276
  }
831
- var handler = createNodeHandler(config);
832
- var port = Number.parseInt(config.proxy.listen.split(":")[1] || "9090");
833
- var hostname = config.proxy.listen.split(":")[0] || "127.0.0.1";
834
- var server = (0, import_node_http.createServer)(handler.handleRequest);
835
- server.on("connect", handler.handleConnect);
836
- server.listen(port, hostname, () => {
837
- const addr = server.address();
838
- const actualPort = typeof addr === "object" && addr ? addr.port : port;
839
- console.log(`[openape-proxy] Listening on http://${hostname}:${actualPort}`);
840
- console.log(`[openape-proxy] CONNECT tunneling enabled`);
841
- console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
842
- console.log(`[openape-proxy] Agents: ${config.agents.map((a) => a.email).join(", ")}`);
843
- console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`);
844
- });
845
- process.on("SIGINT", () => {
846
- console.log("\n[openape-proxy] Shutting down...");
847
- server.close();
848
- process.exit(0);
849
- });
850
- process.on("SIGTERM", () => {
851
- console.log("[openape-proxy] Shutting down...");
852
- server.close();
853
- process.exit(0);
854
- });
855
1277
  //# sourceMappingURL=index.cjs.map