open-managed-agents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +248 -0
  3. package/bin/oma.mjs +499 -0
  4. package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
  5. package/dist/scripts/alpha-smoke-models.mjs +92 -0
  6. package/dist/scripts/alpha-smoke.mjs +581 -0
  7. package/dist/scripts/oma-doctor.js +230 -0
  8. package/dist/scripts/oma-models.js +299 -0
  9. package/dist/scripts/oma-workspaces.js +133 -0
  10. package/dist/src/control-plane/admin/auth.js +43 -0
  11. package/dist/src/control-plane/admin/routes.js +76 -0
  12. package/dist/src/control-plane/admin/service.js +102 -0
  13. package/dist/src/control-plane/admission.js +90 -0
  14. package/dist/src/control-plane/agents/routes.js +63 -0
  15. package/dist/src/control-plane/agents/service.js +687 -0
  16. package/dist/src/control-plane/agents/store.js +391 -0
  17. package/dist/src/control-plane/agents/types.js +1 -0
  18. package/dist/src/control-plane/api-constants.js +3 -0
  19. package/dist/src/control-plane/app.js +907 -0
  20. package/dist/src/control-plane/console/auth.js +68 -0
  21. package/dist/src/control-plane/console/static.js +112 -0
  22. package/dist/src/control-plane/deployment-runtime-config.js +328 -0
  23. package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
  24. package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
  25. package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
  26. package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
  27. package/dist/src/control-plane/deployment-storage.js +354 -0
  28. package/dist/src/control-plane/egress/image.js +48 -0
  29. package/dist/src/control-plane/egress/policy.js +571 -0
  30. package/dist/src/control-plane/egress/presets.js +77 -0
  31. package/dist/src/control-plane/egress/proxy.js +72 -0
  32. package/dist/src/control-plane/egress/ssrf.js +106 -0
  33. package/dist/src/control-plane/egress/utils/debug.js +14 -0
  34. package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
  35. package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
  36. package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
  37. package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
  38. package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
  39. package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
  40. package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
  41. package/dist/src/control-plane/environments/routes.js +54 -0
  42. package/dist/src/control-plane/environments/service.js +112 -0
  43. package/dist/src/control-plane/environments/store.js +132 -0
  44. package/dist/src/control-plane/environments/types.js +1 -0
  45. package/dist/src/control-plane/errors.js +81 -0
  46. package/dist/src/control-plane/events/broadcaster.js +217 -0
  47. package/dist/src/control-plane/events/constants.js +2 -0
  48. package/dist/src/control-plane/events/persist.js +39 -0
  49. package/dist/src/control-plane/events/request.js +190 -0
  50. package/dist/src/control-plane/events/routes.js +159 -0
  51. package/dist/src/control-plane/events/runtime-helpers.js +134 -0
  52. package/dist/src/control-plane/events/service.js +1985 -0
  53. package/dist/src/control-plane/events/session-guards.js +29 -0
  54. package/dist/src/control-plane/events/sse.js +6 -0
  55. package/dist/src/control-plane/events/store.js +772 -0
  56. package/dist/src/control-plane/events/tool-persistence.js +102 -0
  57. package/dist/src/control-plane/events/types.js +21 -0
  58. package/dist/src/control-plane/files/routes.js +121 -0
  59. package/dist/src/control-plane/files/service.js +65 -0
  60. package/dist/src/control-plane/files/store-common.js +87 -0
  61. package/dist/src/control-plane/files/store-local.js +488 -0
  62. package/dist/src/control-plane/files/store-memory.js +270 -0
  63. package/dist/src/control-plane/files/store.js +2 -0
  64. package/dist/src/control-plane/files/types.js +7 -0
  65. package/dist/src/control-plane/http.js +25 -0
  66. package/dist/src/control-plane/ids.js +43 -0
  67. package/dist/src/control-plane/logging.js +263 -0
  68. package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
  69. package/dist/src/control-plane/models/catalog.js +78 -0
  70. package/dist/src/control-plane/models/config-security.js +376 -0
  71. package/dist/src/control-plane/models/deployment-config.js +93 -0
  72. package/dist/src/control-plane/models/routes.js +32 -0
  73. package/dist/src/control-plane/models/service.js +138 -0
  74. package/dist/src/control-plane/observability/instruments.js +65 -0
  75. package/dist/src/control-plane/observability/metrics.js +125 -0
  76. package/dist/src/control-plane/observability/routes.js +95 -0
  77. package/dist/src/control-plane/openapi/document.js +282 -0
  78. package/dist/src/control-plane/openapi/routes.js +78 -0
  79. package/dist/src/control-plane/request-idempotency.js +85 -0
  80. package/dist/src/control-plane/secrets/envelope.js +132 -0
  81. package/dist/src/control-plane/secrets/master-key.js +44 -0
  82. package/dist/src/control-plane/secrets/routes.js +20 -0
  83. package/dist/src/control-plane/secrets/service.js +71 -0
  84. package/dist/src/control-plane/secrets/store.js +212 -0
  85. package/dist/src/control-plane/secrets/types.js +3 -0
  86. package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
  87. package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
  88. package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
  89. package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
  90. package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
  91. package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
  92. package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
  93. package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
  94. package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
  95. package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
  96. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
  97. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
  98. package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
  99. package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
  100. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
  101. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
  102. package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
  103. package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
  104. package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
  105. package/dist/src/control-plane/sessions/pi/translator.js +100 -0
  106. package/dist/src/control-plane/sessions/request.js +164 -0
  107. package/dist/src/control-plane/sessions/resources.js +63 -0
  108. package/dist/src/control-plane/sessions/routes.js +88 -0
  109. package/dist/src/control-plane/sessions/serialize.js +24 -0
  110. package/dist/src/control-plane/sessions/service.js +733 -0
  111. package/dist/src/control-plane/sessions/store.js +538 -0
  112. package/dist/src/control-plane/sessions/types.js +1 -0
  113. package/dist/src/control-plane/skills/archive.js +169 -0
  114. package/dist/src/control-plane/skills/routes.js +41 -0
  115. package/dist/src/control-plane/skills/service.js +29 -0
  116. package/dist/src/control-plane/skills/store.js +233 -0
  117. package/dist/src/control-plane/skills/types.js +5 -0
  118. package/dist/src/control-plane/sqlite-transaction.js +15 -0
  119. package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
  120. package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
  121. package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
  122. package/dist/src/control-plane/vaults/routes.js +66 -0
  123. package/dist/src/control-plane/vaults/service.js +553 -0
  124. package/dist/src/control-plane/vaults/store.js +691 -0
  125. package/dist/src/control-plane/vaults/types.js +1 -0
  126. package/dist/src/control-plane/wake-loop.js +65 -0
  127. package/dist/src/control-plane/wiring.js +92 -0
  128. package/dist/src/control-plane/workspace.js +4 -0
  129. package/dist/src/control-plane/workspaces/store.js +158 -0
  130. package/dist/src/egress-proxy-main.js +103 -0
  131. package/dist/src/main.js +124 -0
  132. package/dist/src/types/agents.js +1 -0
  133. package/dist/src/types/common.js +1 -0
  134. package/dist/src/types/environments.js +1 -0
  135. package/dist/src/types/events.js +121 -0
  136. package/dist/src/types/files.js +1 -0
  137. package/dist/src/types/json.js +19 -0
  138. package/dist/src/types/sessions.js +1 -0
  139. package/dist/ui/managed-agents-console/README.md +52 -0
  140. package/dist/ui/managed-agents-console/docs/agents.md +23 -0
  141. package/dist/ui/managed-agents-console/docs/console.md +23 -0
  142. package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
  143. package/dist/ui/managed-agents-console/docs/environments.md +27 -0
  144. package/dist/ui/managed-agents-console/docs/events.md +19 -0
  145. package/dist/ui/managed-agents-console/docs/files.md +17 -0
  146. package/dist/ui/managed-agents-console/docs/github.md +11 -0
  147. package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
  148. package/dist/ui/managed-agents-console/docs/memory.md +11 -0
  149. package/dist/ui/managed-agents-console/docs/migration.md +17 -0
  150. package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
  151. package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
  152. package/dist/ui/managed-agents-console/docs/overview.md +41 -0
  153. package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
  154. package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
  155. package/dist/ui/managed-agents-console/docs/reference.md +27 -0
  156. package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
  157. package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
  158. package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
  159. package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
  160. package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
  161. package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
  162. package/dist/ui/managed-agents-console/docs/skills.md +15 -0
  163. package/dist/ui/managed-agents-console/docs/tools.md +23 -0
  164. package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
  165. package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
  166. package/dist/ui/managed-agents-console/index.html +47 -0
  167. package/dist/ui/managed-agents-console/serve.mjs +116 -0
  168. package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
  169. package/dist/ui/managed-agents-console/src/api.js +1043 -0
  170. package/dist/ui/managed-agents-console/src/app.jsx +633 -0
  171. package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
  172. package/dist/ui/managed-agents-console/src/console.css +733 -0
  173. package/dist/ui/managed-agents-console/src/data.js +188 -0
  174. package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
  175. package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
  176. package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
  177. package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
  178. package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
  179. package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
  180. package/dist/ui/managed-agents-console/src/sse.js +185 -0
  181. package/dist/ui/managed-agents-console/src/states.jsx +90 -0
  182. package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
  183. package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
  184. package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
  185. package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
  186. package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
  187. package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
  188. package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
  189. package/dist/ui/openapi-docs/VENDOR.md +27 -0
  190. package/dist/ui/openapi-docs/index.html +16 -0
  191. package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
  192. package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
  193. package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
  194. package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
  195. package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
  196. package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
  197. package/package.json +53 -0
@@ -0,0 +1,72 @@
1
+ // Public egress-proxy surface (plan 0117a). OMA control-plane code imports the
2
+ // vendored srt proxy stack ONLY through this module, never from ./vendor/
3
+ // directly — so the vendored implementation stays behind a seam we can re-pin
4
+ // (ADR 0016 §1) without touching call sites.
5
+ //
6
+ // NOT YET A COMPLETE EGRESS BOUNDARY. The proxy is vendored (0117a) with a safe
7
+ // constructor and, as of 0117b, a default SSRF/private-IP deny (see below).
8
+ // Still owed: egress policy + credential injection (0117c) and Docker
9
+ // proxy-only-egress wiring (0117d). Until those land, `createEgressProxy` is a
10
+ // building block, not the wired control-plane boundary.
11
+ import { isIP } from "node:net";
12
+ import { createHttpProxyServer, } from "./vendor/http-proxy.js";
13
+ import { createPinnedLookup, isBlockedAddress } from "./ssrf.js";
14
+ export { createMitmCA, disposeMitmCA, } from "./vendor/mitm-ca.js";
15
+ export function createEgressProxy(options) {
16
+ if (typeof options.proxyAuthToken !== "string" ||
17
+ options.proxyAuthToken.trim() === "") {
18
+ throw new Error("createEgressProxy requires a non-empty proxyAuthToken — the vendored " +
19
+ "proxy is fail-open (any host process could reach it) without one.");
20
+ }
21
+ // Defensive against JS callers who bypass the Omit type. Both routes would
22
+ // move dials outside the pinned-lookup SSRF check, so reject them at runtime.
23
+ const raw = options;
24
+ if (raw.parentProxy !== undefined) {
25
+ throw new Error("createEgressProxy does not support parentProxy: upstream-proxy chaining " +
26
+ "is unused in OMA v1 and is not honored on the TLS-terminated leg.");
27
+ }
28
+ if (raw.getMitmSocketPath !== undefined) {
29
+ throw new Error("createEgressProxy does not support getMitmSocketPath: an external MITM " +
30
+ "unix-socket route bypasses the pinned-lookup SSRF check. OMA terminates " +
31
+ "TLS in-process via mitmCA.");
32
+ }
33
+ if (raw.lookup !== undefined) {
34
+ throw new Error("createEgressProxy does not accept a caller lookup: it would replace the " +
35
+ "SSRF-validating resolver. Use dangerouslyAllowPrivateAddressesForTest " +
36
+ "for loopback fixtures.");
37
+ }
38
+ // SSRF/private-IP deny by default (ADR 0016 §5), in two halves:
39
+ //
40
+ // 1. DNS names: unless the caller overrides `lookup`, every upstream dial
41
+ // resolves through a validating lookup that denies private/loopback/
42
+ // reserved targets and pins the vetted IP.
43
+ // 2. IP LITERALS: Node structurally skips the `lookup` hook when the host
44
+ // is already a numeric literal (verified), so a literal like
45
+ // `169.254.169.254` would bypass half 1 entirely. Both the CONNECT and
46
+ // plain-HTTP handlers call `filter(port, host)` before dialing, so we
47
+ // wrap the caller's filter to also deny blocked IP literals. This is the
48
+ // literal half of the deny and covers every dial path.
49
+ const callerFilter = options.filter;
50
+ const guardedFilter = (port, host, socket) => {
51
+ const family = isIP(host);
52
+ if (family !== 0 && isBlockedAddress(host, family))
53
+ return false;
54
+ return callerFilter(port, host, socket);
55
+ };
56
+ // The pinned lookup is set AFTER spreading options so a caller cannot replace
57
+ // it. The only relaxation is the loud test flag, which turns off the private-IP
58
+ // half (loopback fixtures) — the literal filter-wrap still runs regardless.
59
+ const lookup = options.dangerouslyAllowPrivateAddressesForTest
60
+ ? createPinnedLookup({ allowAddress: () => true })
61
+ : createPinnedLookup();
62
+ const { dangerouslyAllowPrivateAddressesForTest: _omit, ...rest } = options;
63
+ return createHttpProxyServer({
64
+ ...rest,
65
+ filter: guardedFilter,
66
+ lookup,
67
+ // Opaque (uninspected) byte tunnels are an explicit per-host grant, never
68
+ // a fallback (ADR 0016 §3): the vendor default allows them, OMA denies
69
+ // unless the caller's hook (from the egress policy) says otherwise.
70
+ allowOpaqueTunnel: options.allowOpaqueTunnel ?? (() => false),
71
+ });
72
+ }
@@ -0,0 +1,106 @@
1
+ // SSRF / private-IP egress deny (plan 0117b, ADR 0016 §5).
2
+ //
3
+ // The vendored proxy allowlists a *hostname* and then dials that hostname, so
4
+ // an allowlisted name that resolves to a private/loopback address — or that
5
+ // flips via DNS rebinding between check and connect — would reach internal
6
+ // services. The fix is connect-to-a-pinned-vetted-IP: resolve once, reject if
7
+ // ANY resolved address is in a blocked range, and hand the vetted IP to the
8
+ // socket so Node connects to exactly what we validated (no re-resolution, so no
9
+ // TOCTOU). The hostname is preserved separately as TLS SNI/servername by the
10
+ // caller. This is Node's idiomatic seam: `net`/`tls`/`https` all accept a
11
+ // `lookup` option with the `dns.lookup` signature.
12
+ import { lookup as dnsLookup } from "node:dns";
13
+ import { BlockList, isIPv4 } from "node:net";
14
+ // Ranges an egress target must never resolve into. Deny-by-listing the
15
+ // dangerous space (RFC1918 + loopback + link-local + ULA + CGNAT + special-use)
16
+ // rather than allow-listing public space, so a new reserved range fails closed
17
+ // only if we miss it — hence the broad reserved/multicast entries too.
18
+ function buildBlockList() {
19
+ const b = new BlockList();
20
+ // IPv4
21
+ b.addSubnet("0.0.0.0", 8, "ipv4"); // "this host"
22
+ b.addSubnet("10.0.0.0", 8, "ipv4"); // private
23
+ b.addSubnet("100.64.0.0", 10, "ipv4"); // CGNAT
24
+ b.addSubnet("127.0.0.0", 8, "ipv4"); // loopback
25
+ b.addSubnet("169.254.0.0", 16, "ipv4"); // link-local (incl. cloud metadata 169.254.169.254)
26
+ b.addSubnet("172.16.0.0", 12, "ipv4"); // private
27
+ b.addSubnet("192.0.0.0", 24, "ipv4"); // IETF protocol assignments
28
+ b.addSubnet("192.0.2.0", 24, "ipv4"); // TEST-NET-1
29
+ b.addSubnet("192.168.0.0", 16, "ipv4"); // private
30
+ b.addSubnet("198.18.0.0", 15, "ipv4"); // benchmarking
31
+ b.addSubnet("198.51.100.0", 24, "ipv4"); // TEST-NET-2
32
+ b.addSubnet("203.0.113.0", 24, "ipv4"); // TEST-NET-3
33
+ b.addSubnet("224.0.0.0", 4, "ipv4"); // multicast
34
+ b.addSubnet("240.0.0.0", 4, "ipv4"); // reserved + 255.255.255.255
35
+ // IPv6
36
+ b.addAddress("::", "ipv6"); // unspecified
37
+ b.addAddress("::1", "ipv6"); // loopback
38
+ b.addSubnet("fc00::", 7, "ipv6"); // unique local (ULA)
39
+ b.addSubnet("fe80::", 10, "ipv6"); // link-local
40
+ b.addSubnet("ff00::", 8, "ipv6"); // multicast
41
+ b.addSubnet("2001:db8::", 32, "ipv6"); // documentation
42
+ // Ranges that embed an IPv4 destination inside a global-scope IPv6 literal.
43
+ // A hostname resolving to one of these could reach a private IPv4 through a
44
+ // NAT64/6to4 gateway while the IPv6 address itself looks public. OMA does not
45
+ // egress via these transition mechanisms, so deny them outright (targets are
46
+ // reached over native dual-stack). If NAT64-only egress is ever needed, the
47
+ // enhancement is to decode the embedded IPv4 and run it through the IPv4
48
+ // rules instead of blanket-denying.
49
+ b.addSubnet("64:ff9b::", 96, "ipv6"); // NAT64 well-known prefix
50
+ b.addSubnet("64:ff9b:1::", 48, "ipv6"); // NAT64 local-use prefix
51
+ b.addSubnet("2002::", 16, "ipv6"); // 6to4
52
+ b.addSubnet("2001::", 32, "ipv6"); // Teredo
53
+ return b;
54
+ }
55
+ const BLOCK_LIST = buildBlockList();
56
+ // An IPv4-mapped IPv6 address (::ffff:a.b.c.d) tunnels an IPv4 destination
57
+ // through an IPv6 literal; check the embedded IPv4 against the IPv4 rules so a
58
+ // mapped private address can't slip past the IPv6 checks.
59
+ function mappedIpv4(address) {
60
+ const dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(address);
61
+ if (dotted)
62
+ return dotted[1];
63
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address);
64
+ if (!hex)
65
+ return undefined;
66
+ const hi = Number.parseInt(hex[1], 16);
67
+ const lo = Number.parseInt(hex[2], 16);
68
+ return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
69
+ }
70
+ export function isBlockedAddress(address, family) {
71
+ const mapped = mappedIpv4(address);
72
+ if (mapped !== undefined)
73
+ return BLOCK_LIST.check(mapped, "ipv4");
74
+ return BLOCK_LIST.check(address, family === 6 ? "ipv6" : "ipv4");
75
+ }
76
+ // Returns a `dns.lookup`-compatible function that resolves the hostname,
77
+ // rejects the whole request if ANY resolved address is blocked (so an attacker
78
+ // mixing one public + one private A record cannot gamble on Node's choice), and
79
+ // otherwise returns the vetted address(es) for Node to connect to directly.
80
+ export function createPinnedLookup(opts = {}) {
81
+ return (hostname, options, callback) => {
82
+ dnsLookup(hostname, { all: true }, (err, addresses) => {
83
+ if (err) {
84
+ callback(err, "", 0);
85
+ return;
86
+ }
87
+ for (const a of addresses) {
88
+ if (opts.allowAddress?.(a.address, a.family))
89
+ continue;
90
+ if (isBlockedAddress(a.address, a.family)) {
91
+ callback(Object.assign(new Error(`egress denied: ${hostname} resolves to ${a.address}, a blocked (private/loopback/reserved) range`), { code: "EGRESS_SSRF_BLOCKED" }), "", 0);
92
+ return;
93
+ }
94
+ }
95
+ const wantsAll = typeof options === "object" && options !== null && "all" in options
96
+ ? Boolean(options.all)
97
+ : false;
98
+ if (wantsAll) {
99
+ callback(null, addresses);
100
+ return;
101
+ }
102
+ const first = addresses[0];
103
+ callback(null, first.address, isIPv4(first.address) ? 4 : 6);
104
+ });
105
+ };
106
+ }
@@ -0,0 +1,14 @@
1
+ // OMA vendor shim: replaces srt's utils/debug. The vendored proxy files import
2
+ // `logForDebugging` from here, gated on SRT_DEBUG (kept so upstream call sites
3
+ // need no edits). Routed through OMA's logger (0121 C1) so vendor messages get
4
+ // the same scrubbing as everything else; `detail` (not `message`) because the
5
+ // denylist redacts content-bearing keys and these are proxy-decision strings.
6
+ import { log } from "../../logging.js";
7
+ export function logForDebugging(message, options) {
8
+ if (!process.env.SRT_DEBUG)
9
+ return;
10
+ if (options?.level === "warn")
11
+ log.warn("egress_vendor_debug", { detail: message });
12
+ else
13
+ log.error("egress_vendor_debug", { detail: message });
14
+ }
@@ -0,0 +1,335 @@
1
+ import { Agent, createServer } from 'node:http';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { connect } from 'node:net';
5
+ import { URL } from 'node:url';
6
+ import { logForDebugging } from "../utils/debug.js";
7
+ import { decideAndRespond, } from "./request-filter.js";
8
+ import { peekForClientHello, terminateAndForward, } from "./tls-terminate-proxy.js";
9
+ import { connectViaParentProxy, dialDirect, openConnectTunnel, proxyAuthHeader, selectParentProxyUrl, shouldBypassParentProxy, stripBrackets, stripHopByHop, } from "./parent-proxy.js";
10
+ export function createHttpProxyServer(options) {
11
+ const server = createServer();
12
+ const checkAuth = (got) => {
13
+ if (!options.proxyAuthToken)
14
+ return true;
15
+ const m = /^basic\s+([a-z0-9+/=]+)\s*$/i.exec(got ?? '');
16
+ if (!m)
17
+ return false;
18
+ const decoded = Buffer.from(m[1], 'base64').toString('utf8');
19
+ const sep = decoded.indexOf(':');
20
+ return sep > 0 && decoded.slice(sep + 1) === options.proxyAuthToken;
21
+ };
22
+ // Handle CONNECT requests for HTTPS traffic
23
+ server.on('connect', async (req, socket, head) => {
24
+ // Attach error handler immediately to prevent unhandled errors
25
+ socket.on('error', err => {
26
+ logForDebugging(`Client socket error: ${err.message}`, { level: 'error' });
27
+ });
28
+ // Track client liveness so we can abort the upstream dial if they bail.
29
+ let clientGone = false;
30
+ socket.once('close', () => {
31
+ clientGone = true;
32
+ });
33
+ try {
34
+ if (!checkAuth(req.headers['proxy-authorization'])) {
35
+ socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n' +
36
+ 'Proxy-Authenticate: Basic realm="srt"\r\n\r\n');
37
+ return;
38
+ }
39
+ const target = parseConnectTarget(req.url);
40
+ if (!target) {
41
+ logForDebugging(`Invalid CONNECT request: ${req.url}`, {
42
+ level: 'error',
43
+ });
44
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
45
+ return;
46
+ }
47
+ const { hostname, port } = target;
48
+ const allowed = await options.filter(port, hostname, socket);
49
+ if (!allowed) {
50
+ logForDebugging(`Connection blocked to ${hostname}:${port}`, {
51
+ level: 'error',
52
+ });
53
+ socket.end('HTTP/1.1 403 Forbidden\r\n' +
54
+ 'Content-Type: text/plain\r\n' +
55
+ 'X-Proxy-Error: blocked-by-allowlist\r\n' +
56
+ '\r\n' +
57
+ 'Connection blocked by network allowlist');
58
+ return;
59
+ }
60
+ // Decide upstream route:
61
+ // in-process TLS termination
62
+ // > external MITM unix socket
63
+ // > parent HTTP proxy
64
+ // > direct
65
+ // (tlsTerminate and mitmProxy are mutually exclusive at the config
66
+ // layer, so the first two never both apply.)
67
+ let wrote200 = false;
68
+ if (options.mitmCA &&
69
+ (options.shouldTerminateTLS?.(hostname, port) ?? true)) {
70
+ if (clientGone)
71
+ return;
72
+ // We can only terminate TLS. CONNECT also carries non-TLS streams —
73
+ // notably SSH on Linux, where the sandbox's own GIT_SSH_COMMAND
74
+ // routes `ssh` through this proxy via `socat - PROXY:`. Send 200 so
75
+ // the client transmits its first bytes, sniff for a ClientHello, and
76
+ // only terminate if it is one. Non-TLS falls through to the opaque
77
+ // tunnel below — i.e. base-sandbox behaviour, hostname-allowlisted
78
+ // but not content-inspected (same as the SOCKS path).
79
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
80
+ wrote200 = true;
81
+ const peeked = await peekForClientHello(socket, head);
82
+ if (clientGone)
83
+ return;
84
+ if (peeked.isTLS) {
85
+ terminateAndForward(options.mitmCA, options.filterRequest, options.mutateHeaders, socket, peeked.head,
86
+ // OMA: thread the validating lookup to the terminated upstream leg.
87
+ { hostname, port, upstreamCA: options.tlsTerminateUpstreamCA, lookup: options.lookup });
88
+ return;
89
+ }
90
+ logForDebugging(`[tls-terminate] non-TLS bytes on CONNECT ${hostname}:${port}; opaque-tunnelling`);
91
+ head = peeked.head; // OMA: @types/node buffer-variance skew
92
+ }
93
+ else if (options.mitmCA) {
94
+ // Per-host termination opt-out: the policy exempts this host (mTLS
95
+ // upstream, cert-pinning client), so skip the MITM entirely and
96
+ // take the opaque tunnel below, exactly as if mitmCA were unset.
97
+ logForDebugging(`[tls-terminate] policy exempts ${hostname}:${port}; opaque-tunnelling`);
98
+ }
99
+ // OMA: single choke point for every route that reaches the opaque
100
+ // tunnel below (non-TLS bytes after the sniff, a shouldTerminateTLS
101
+ // opt-out, or no mitmCA). ADR 0016 §3: path policy and injection are
102
+ // guarantees only for terminated TLS, so an uninspected byte tunnel
103
+ // must be an explicit per-host grant, not a fallback a client can
104
+ // trigger by speaking non-TLS bytes.
105
+ if (options.allowOpaqueTunnel &&
106
+ !options.allowOpaqueTunnel(hostname, port)) {
107
+ logForDebugging(`[tls-terminate] opaque tunnel to ${hostname}:${port} denied by policy`, { level: 'error' });
108
+ if (wrote200)
109
+ socket.destroy();
110
+ else
111
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
112
+ return;
113
+ }
114
+ const mitmSocketPath = options.getMitmSocketPath?.(hostname);
115
+ const parentUrl = !mitmSocketPath &&
116
+ options.parentProxy &&
117
+ !shouldBypassParentProxy(options.parentProxy, hostname)
118
+ ? selectParentProxyUrl(options.parentProxy, { isHttps: true })
119
+ : undefined;
120
+ let upstream;
121
+ try {
122
+ if (mitmSocketPath) {
123
+ logForDebugging(`Routing CONNECT ${hostname}:${port} through MITM proxy at ${mitmSocketPath}`);
124
+ upstream = await openConnectTunnel({
125
+ dial: () => connect({ path: mitmSocketPath }),
126
+ readyEvent: 'connect',
127
+ destHost: hostname,
128
+ destPort: port,
129
+ });
130
+ }
131
+ else if (parentUrl) {
132
+ upstream = await connectViaParentProxy(parentUrl, hostname, port);
133
+ }
134
+ else {
135
+ upstream = await dialDirect(hostname, port, undefined, options.lookup); // OMA: validating lookup on the opaque-tunnel dial
136
+ }
137
+ }
138
+ catch (err) {
139
+ logForDebugging(`CONNECT tunnel failed: ${err.message}`, {
140
+ level: 'error',
141
+ });
142
+ // If we already sent 200 (mitmCA sniff path), an HTTP status line now
143
+ // would land inside the tunnel as payload. Just close.
144
+ if (wrote200)
145
+ socket.destroy();
146
+ else
147
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
148
+ return;
149
+ }
150
+ if (clientGone) {
151
+ upstream.on('error', () => { }); // swallow post-resolve errors
152
+ upstream.destroy();
153
+ return;
154
+ }
155
+ if (!wrote200) {
156
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
157
+ }
158
+ // Forward any bytes the client sent in the same packet as the CONNECT
159
+ // (Node delivers these as the `head` buffer, not via the socket stream),
160
+ // plus anything the ClientHello sniff consumed when mitmCA is on.
161
+ if (head.length)
162
+ upstream.write(head);
163
+ upstream.pipe(socket);
164
+ socket.pipe(upstream);
165
+ upstream.on('error', err => {
166
+ logForDebugging(`CONNECT tunnel failed: ${err.message}`, {
167
+ level: 'error',
168
+ });
169
+ socket.destroy();
170
+ });
171
+ socket.on('close', () => upstream.destroy());
172
+ upstream.on('close', () => socket.destroy());
173
+ }
174
+ catch (err) {
175
+ logForDebugging(`Error handling CONNECT: ${err}`, { level: 'error' });
176
+ socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
177
+ }
178
+ });
179
+ // Handle regular HTTP requests
180
+ server.on('request', async (req, res) => {
181
+ try {
182
+ if (!checkAuth(req.headers['proxy-authorization'])) {
183
+ res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="srt"' });
184
+ res.end();
185
+ return;
186
+ }
187
+ const url = new URL(req.url);
188
+ const hostname = stripBrackets(url.hostname);
189
+ const port = url.port
190
+ ? parseInt(url.port, 10)
191
+ : url.protocol === 'https:'
192
+ ? 443
193
+ : 80;
194
+ const allowed = await options.filter(port, hostname, req.socket);
195
+ if (!allowed) {
196
+ logForDebugging(`HTTP request blocked to ${hostname}:${port}`, {
197
+ level: 'error',
198
+ });
199
+ res.writeHead(403, {
200
+ 'Content-Type': 'text/plain',
201
+ 'X-Proxy-Error': 'blocked-by-allowlist',
202
+ });
203
+ res.end('Connection blocked by network allowlist');
204
+ return;
205
+ }
206
+ // Client may have disconnected while we awaited the filter; bail now
207
+ // rather than dialing an upstream nobody will read from.
208
+ if (req.socket.destroyed)
209
+ return;
210
+ const fwdHeaders = { ...stripHopByHop(req.headers), host: url.host };
211
+ // OMA: same request context as the terminated path (ADR 0016 §6).
212
+ options.mutateHeadersPlaintext?.(fwdHeaders, hostname, {
213
+ method: req.method ?? 'GET',
214
+ path: `${url.pathname}${url.search}`,
215
+ port,
216
+ });
217
+ // Decide upstream route: MITM unix socket > parent HTTP proxy > direct.
218
+ const mitmSocketPath = options.getMitmSocketPath?.(hostname);
219
+ const parentUrl = !mitmSocketPath &&
220
+ options.parentProxy &&
221
+ !shouldBypassParentProxy(options.parentProxy, hostname)
222
+ ? selectParentProxyUrl(options.parentProxy, {
223
+ isHttps: url.protocol === 'https:',
224
+ })
225
+ : undefined;
226
+ // Reconstruct the absolute URI from parsed components rather than
227
+ // forwarding the client's raw req.url. This ensures the upstream proxy
228
+ // sees exactly the host we allowlist-checked, closing URL-parser
229
+ // differential bypasses.
230
+ const absUrl = `${url.protocol}//${url.host}${url.pathname}${url.search}`;
231
+ // Per-request filter applies to plain HTTP too — otherwise a sandboxed
232
+ // client could bypass it by using http:// where the upstream serves it.
233
+ let body = req;
234
+ if (options.filterRequest) {
235
+ const ac = new AbortController();
236
+ res.once('close', () => ac.abort());
237
+ const out = await decideAndRespond(options.filterRequest, req, res, absUrl, ac.signal, { leg: 'plain' });
238
+ if (out === null)
239
+ return;
240
+ body = out;
241
+ }
242
+ let proxyReq;
243
+ if (mitmSocketPath) {
244
+ logForDebugging(`Routing HTTP ${req.method} ${hostname}:${port} through MITM proxy at ${mitmSocketPath}`);
245
+ const mitmAgent = new Agent({
246
+ // @ts-expect-error - socketPath is valid but not in types
247
+ socketPath: mitmSocketPath,
248
+ });
249
+ proxyReq = httpRequest({
250
+ agent: mitmAgent,
251
+ path: absUrl,
252
+ method: req.method,
253
+ headers: fwdHeaders,
254
+ }, proxyRes => {
255
+ res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
256
+ proxyRes.pipe(res);
257
+ });
258
+ }
259
+ else if (parentUrl) {
260
+ const parentHost = stripBrackets(parentUrl.hostname);
261
+ const parentPort = Number(parentUrl.port) || (parentUrl.protocol === 'https:' ? 443 : 80);
262
+ const auth = proxyAuthHeader(parentUrl);
263
+ const requestFn = parentUrl.protocol === 'https:' ? httpsRequest : httpRequest;
264
+ proxyReq = requestFn({
265
+ hostname: parentHost,
266
+ port: parentPort,
267
+ path: absUrl,
268
+ method: req.method,
269
+ headers: auth
270
+ ? { ...fwdHeaders, 'proxy-authorization': auth }
271
+ : fwdHeaders,
272
+ }, proxyRes => {
273
+ res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
274
+ proxyRes.pipe(res);
275
+ });
276
+ }
277
+ else {
278
+ const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
279
+ proxyReq = requestFn({
280
+ hostname,
281
+ port,
282
+ path: url.pathname + url.search,
283
+ method: req.method,
284
+ headers: fwdHeaders,
285
+ // OMA: validating lookup on the plain-HTTP direct dial too, so a
286
+ // DNS name resolving to a private IP is denied here as well as on
287
+ // the CONNECT/terminated paths.
288
+ ...(options.lookup ? { lookup: options.lookup } : {}),
289
+ }, proxyRes => {
290
+ res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
291
+ proxyRes.pipe(res);
292
+ });
293
+ }
294
+ proxyReq.on('error', err => {
295
+ logForDebugging(`Proxy request failed: ${err.message}`, {
296
+ level: 'error',
297
+ });
298
+ if (!res.headersSent) {
299
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
300
+ res.end('Bad Gateway');
301
+ }
302
+ else {
303
+ res.destroy();
304
+ }
305
+ });
306
+ // Tear down the upstream request if the client goes away mid-flight.
307
+ res.on('close', () => proxyReq.destroy());
308
+ body.pipe(proxyReq);
309
+ }
310
+ catch (err) {
311
+ logForDebugging(`Error handling HTTP request: ${err}`, { level: 'error' });
312
+ if (!res.headersSent) {
313
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
314
+ res.end('Internal Server Error');
315
+ }
316
+ else {
317
+ res.destroy();
318
+ }
319
+ }
320
+ });
321
+ return server;
322
+ }
323
+ /**
324
+ * Parse a CONNECT request-target into host + port. Handles both plain
325
+ * `host:port` and bracketed IPv6 `[::1]:port`.
326
+ */
327
+ function parseConnectTarget(target) {
328
+ const m = /^\[([^\]]+)\]:(\d+)$/.exec(target) ?? /^([^:]+):(\d+)$/.exec(target);
329
+ if (!m)
330
+ return undefined;
331
+ const port = Number(m[2]);
332
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
333
+ return undefined;
334
+ return { hostname: m[1], port };
335
+ }