@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.9

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 (130) hide show
  1. package/out/analytics/dashboard/provider.js +13 -2
  2. package/out/analytics/dashboard/webview/panel.js +3 -2
  3. package/out/analytics/identity.js +0 -0
  4. package/out/analytics/service.js +23 -25
  5. package/out/analytics/sessionEvents.js +98 -23
  6. package/out/auth/authBaseUrl.js +59 -0
  7. package/out/auth/bootstrapApply.js +9 -10
  8. package/out/auth/brokerClient.js +48 -14
  9. package/out/auth/provider.js +8 -21
  10. package/out/auth/sessionStore.js +3 -40
  11. package/out/auth/signInFlow.js +2 -1
  12. package/out/auth-broker/config.js +50 -5
  13. package/out/auth-broker/server.js +122 -26
  14. package/out/auth-broker/store.js +25 -24
  15. package/out/authority/warrant.js +206 -0
  16. package/out/branding.js +27 -1
  17. package/out/cli/commands/conformance.js +249 -0
  18. package/out/cli/commands/data-plane.js +67 -0
  19. package/out/cli/commands/deploy.js +1 -0
  20. package/out/cli/commands/gateway.js +4 -0
  21. package/out/cli/commands/pack.js +182 -3
  22. package/out/cli/commands/sandbox.js +332 -0
  23. package/out/cli/commands/serve.js +875 -52
  24. package/out/cli/commands/store.js +12 -4
  25. package/out/cli/data-plane/gateway.js +28 -5
  26. package/out/cli/data-plane/index.js +17 -2
  27. package/out/cli/data-plane/registry.js +9 -0
  28. package/out/cli/data-plane/remote.js +106 -0
  29. package/out/cli/data-plane/sidecar.js +161 -0
  30. package/out/cli/deploy/layers/identity.js +85 -4
  31. package/out/cli/deploy-init.js +89 -24
  32. package/out/cli/deps.js +106 -2
  33. package/out/cli/idp-init.js +35 -0
  34. package/out/cli/index.js +155 -16
  35. package/out/cli/pack-install-ref.js +15 -0
  36. package/out/cli/pack-loader.js +8 -1
  37. package/out/cli/pack-runtime.js +61 -0
  38. package/out/cli/runtime.js +35 -1
  39. package/out/cli/serve-hardening.js +80 -3
  40. package/out/cli/utils.js +12 -1
  41. package/out/engines/base.js +32 -3
  42. package/out/engines/claude.js +33 -15
  43. package/out/engines/codex.js +110 -26
  44. package/out/engines/custom.js +8 -0
  45. package/out/evidence/sessionShip.js +610 -0
  46. package/out/extension.js +11 -2
  47. package/out/gateway/bridge.js +40 -2
  48. package/out/gateway/client.js +34 -1
  49. package/out/gateway/installSource.js +68 -0
  50. package/out/gateway/lifecycle.js +185 -51
  51. package/out/gateway/outboundPaths.js +4 -1
  52. package/out/gateway/secretsPreflight.js +90 -0
  53. package/out/gateway/service.js +7 -2
  54. package/out/headless/index.js +17 -1
  55. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  56. package/out/mcp.js +30 -0
  57. package/out/pack/caps.js +271 -0
  58. package/out/pack/governor.js +309 -0
  59. package/out/pack/lease-schema.js +270 -0
  60. package/out/pack/lease.js +640 -0
  61. package/out/pack/reconcile.js +217 -0
  62. package/out/pack/signature.js +839 -0
  63. package/out/remote/handleRemoteChat.js +44 -3
  64. package/out/remote/remoteTrust.js +277 -0
  65. package/out/remote/serveClient.js +52 -5
  66. package/out/runner/host.js +131 -0
  67. package/out/runner/protocol.js +108 -0
  68. package/out/runner/remoteEngine.js +130 -0
  69. package/out/runtime/interfaces/edit-tracker.js +2 -0
  70. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  71. package/out/sandbox/containerDriver.js +570 -0
  72. package/out/sandbox/processDriver.js +315 -0
  73. package/out/sandbox/sandboxRunner.js +191 -0
  74. package/out/sandbox/support.js +301 -0
  75. package/out/sandbox/types.js +55 -0
  76. package/out/scripts/agentide-litellm.sh +67 -5
  77. package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
  78. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
  79. package/out/scripts/analytics/setup-analytics.ps1 +37 -13
  80. package/out/scripts/analytics/setup-analytics.sh +93 -13
  81. package/out/scripts/analytics/start-analytics.sh +86 -14
  82. package/out/scripts/gateway/setup-gateway.sh +72 -32
  83. package/out/shared/models.js +11 -110
  84. package/out/spotlight/install.js +7 -1
  85. package/out/store/index.js +41 -10
  86. package/out/sync.js +3 -1
  87. package/out/test/analytics.test.js +162 -23
  88. package/out/test/auth-baseurl.test.js +132 -0
  89. package/out/test/auth-bootstrapApply.test.js +27 -6
  90. package/out/test/auth-broker-hardening.test.js +227 -0
  91. package/out/test/auth-broker.test.js +59 -9
  92. package/out/test/branding.test.js +13 -0
  93. package/out/test/caps-governor.test.js +455 -0
  94. package/out/test/codex-new-file-survival.test.js +119 -0
  95. package/out/test/configuration-scopes.test.js +131 -0
  96. package/out/test/data-plane.test.js +67 -0
  97. package/out/test/deploy-init.test.js +40 -0
  98. package/out/test/gateway-client.test.js +28 -0
  99. package/out/test/gateway-install-source.test.js +111 -0
  100. package/out/test/gateway-lifecycle.test.js +1 -1
  101. package/out/test/gateway-outbound.test.js +7 -0
  102. package/out/test/gateway-secrets-preflight.test.js +127 -0
  103. package/out/test/gateway-session-routing.test.js +18 -0
  104. package/out/test/governor-hardening.test.js +217 -0
  105. package/out/test/headless-sync.test.js +22 -0
  106. package/out/test/lease-signature.test.js +357 -0
  107. package/out/test/lease-trust-root.test.js +273 -0
  108. package/out/test/lease-validation.test.js +169 -0
  109. package/out/test/lease.test.js +114 -0
  110. package/out/test/mcp.test.js +27 -1
  111. package/out/test/pack-admission.test.js +216 -0
  112. package/out/test/pack-digest-parity.test.js +140 -0
  113. package/out/test/pack-reconcile.test.js +183 -0
  114. package/out/test/pack-signature-artifact.test.js +103 -0
  115. package/out/test/pack-signature.test.js +176 -0
  116. package/out/test/pack-trust-root.test.js +202 -0
  117. package/out/test/remote-trust.test.js +242 -0
  118. package/out/test/runner-protocol.test.js +249 -0
  119. package/out/test/sandbox-driver.test.js +553 -0
  120. package/out/test/sandbox-hardening.test.js +395 -0
  121. package/out/test/sandbox-runner.test.js +343 -0
  122. package/out/test/serve-hardening.test.js +45 -6
  123. package/out/test/serveClient.test.js +6 -5
  124. package/out/test/sessionShip.test.js +560 -0
  125. package/out/test/spotlightPublish.test.js +28 -0
  126. package/out/test/store-install-safe.test.js +69 -0
  127. package/out/test/tls-bypass.test.js +90 -0
  128. package/out/test/warrant.test.js +138 -0
  129. package/out/utils.js +46 -9
  130. package/package.json +1 -1
@@ -39,12 +39,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.getStoredAuthSession = getStoredAuthSession;
40
40
  exports.persistAuthSession = persistAuthSession;
41
41
  exports.applyIdeBootstrapForContext = applyIdeBootstrapForContext;
42
- exports.refreshRoutingCredentialsForContext = refreshRoutingCredentialsForContext;
43
42
  exports.getAgentHippoAccessToken = getAgentHippoAccessToken;
44
43
  exports.clearAuthSession = clearAuthSession;
45
44
  exports.storedSessionToVsCode = storedSessionToVsCode;
46
45
  const vscode = __importStar(require("vscode"));
47
46
  const brokerClient_js_1 = require("./brokerClient.js");
47
+ const authBaseUrl_js_1 = require("./authBaseUrl.js");
48
48
  const bootstrapApply_js_1 = require("./bootstrapApply.js");
49
49
  const SESSION_SECRET_KEY = 'agenthippo.auth.session';
50
50
  /**
@@ -94,7 +94,7 @@ async function persistAuthSession(secrets, tokens, bootstrap, scopes) {
94
94
  return toVsCodeSession(session);
95
95
  }
96
96
  async function applyIdeBootstrapForContext(context, accessToken) {
97
- const baseUrl = (0, brokerClient_js_1.getAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide').get('auth.baseUrl'));
97
+ const baseUrl = (0, authBaseUrl_js_1.resolveAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide'), (m) => console.warn(m));
98
98
  const bootstrap = await (0, brokerClient_js_1.fetchBootstrap)(baseUrl, accessToken);
99
99
  await (0, bootstrapApply_js_1.applyIdeBootstrap)(bootstrap, {
100
100
  getConfiguration: (section) => vscode.workspace.getConfiguration(section),
@@ -110,43 +110,6 @@ async function applyIdeBootstrapForContext(context, accessToken) {
110
110
  });
111
111
  return bootstrap;
112
112
  }
113
- /**
114
- * Refresh only the LiteLLM routing credentials (lighter than re-fetching the full bootstrap)
115
- * and re-apply them, merging into the last cached bootstrap. Handles server-side rotation of
116
- * the team routing key without requiring a re-sign-in. No-op if no bootstrap has been applied.
117
- */
118
- async function refreshRoutingCredentialsForContext(context, accessToken) {
119
- const cached = context.globalState.get(bootstrapApply_js_1.TEAM_BOOTSTRAP_CACHE_KEY);
120
- if (!cached) {
121
- return;
122
- }
123
- const baseUrl = (0, brokerClient_js_1.getAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide').get('auth.baseUrl'));
124
- const refreshed = await (0, brokerClient_js_1.fetchBootstrapRefresh)(baseUrl, accessToken);
125
- // Skip the settings write when nothing rotated: applyIdeBootstrap re-writes store + routing
126
- // and setAPIKey fires onDidChangeKeys on every call, so re-applying an unchanged key every
127
- // few minutes would churn listeners and SecretStorage.
128
- const keyChanged = refreshed.routing.litellmApiKey !== cached.routing.litellmApiKey;
129
- const baseUrlChanged = refreshed.routing.litellmBaseUrl !== cached.routing.litellmBaseUrl;
130
- if (!keyChanged && !baseUrlChanged) {
131
- return;
132
- }
133
- const merged = {
134
- ...cached,
135
- routing: { ...cached.routing, ...refreshed.routing },
136
- };
137
- await (0, bootstrapApply_js_1.applyIdeBootstrap)(merged, {
138
- getConfiguration: (section) => vscode.workspace.getConfiguration(section),
139
- ConfigurationTarget: vscode.ConfigurationTarget,
140
- setParticipantApiKey: async (args) => {
141
- await vscode.commands.executeCommand('agentide.setParticipantApiKey', args);
142
- await vscode.commands.executeCommand('agentide.auth.applyBootstrapKeys', args);
143
- },
144
- globalStateUpdate: async (key, value) => {
145
- await context.globalState.update(key, value);
146
- },
147
- globalStateGet: (key) => context.globalState.get(key),
148
- });
149
- }
150
113
  async function getAgentHippoAccessToken(context) {
151
114
  const stored = await getStoredAuthSession(context.secrets);
152
115
  if (!stored) {
@@ -157,7 +120,7 @@ async function getAgentHippoAccessToken(context) {
157
120
  }
158
121
  // Near expiry — refresh exactly once across concurrent callers.
159
122
  if (!inFlightRefresh) {
160
- const baseUrl = (0, brokerClient_js_1.getAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide').get('auth.baseUrl'));
123
+ const baseUrl = (0, authBaseUrl_js_1.resolveAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide'), (m) => console.warn(m));
161
124
  const refreshing = stored;
162
125
  inFlightRefresh = (async () => {
163
126
  try {
@@ -40,6 +40,7 @@ exports.runAgentHippoSignInFlow = runAgentHippoSignInFlow;
40
40
  exports.ensureAgentHippoSignedIn = ensureAgentHippoSignedIn;
41
41
  const vscode = __importStar(require("vscode"));
42
42
  const brokerClient_js_1 = require("./brokerClient.js");
43
+ const authBaseUrl_js_1 = require("./authBaseUrl.js");
43
44
  const pkce_js_1 = require("./pkce.js");
44
45
  const callbackUriParse_js_1 = require("./callbackUriParse.js");
45
46
  const types_js_1 = require("./types.js");
@@ -77,7 +78,7 @@ function extractRedirectUriFromText(text) {
77
78
  * Falls back to pasted redirect URL when loopback does not fire (remote/SSH/headless).
78
79
  */
79
80
  async function runAgentHippoSignInFlow(context, options) {
80
- const baseUrl = (0, brokerClient_js_1.getAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide').get('auth.baseUrl'));
81
+ const baseUrl = (0, authBaseUrl_js_1.resolveAuthBrokerBaseUrl)(vscode.workspace.getConfiguration('agentide'), (m) => console.warn(m));
81
82
  const scopes = options?.scopes ?? [...types_js_1.AUTH_SCOPES];
82
83
  const loopback = new loopbackAuthServer_js_1.AgentHippoLoopbackAuthServer();
83
84
  const verifier = (0, pkce_js_1.generateCodeVerifier)();
@@ -3,7 +3,8 @@
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
4
  *--------------------------------------------------------------------------------------------*/
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.DEFAULT_LITELLM_API_KEY = exports.DEFAULT_JWT_SECRET = void 0;
6
+ exports.DEFAULT_AUTH_BIND = exports.AUTH_PROXY_SECRET_HEADER = exports.DEFAULT_JWT_SECRET = void 0;
7
+ exports.resolveAuthBrokerBind = resolveAuthBrokerBind;
7
8
  exports.loadAuthBrokerConfig = loadAuthBrokerConfig;
8
9
  exports.emailDomainAllowed = emailDomainAllowed;
9
10
  function parseDomains(raw) {
@@ -14,8 +15,50 @@ function parseDomains(raw) {
14
15
  }
15
16
  /** Well-known default secret used in dev/test. Never acceptable in production. */
16
17
  exports.DEFAULT_JWT_SECRET = 'dev-agenthippo-auth-secret-change-me';
17
- /** Well-known default LiteLLM team key used in dev/test. Never acceptable in production. */
18
- exports.DEFAULT_LITELLM_API_KEY = 'ahk_team_dev_key';
18
+ /**
19
+ * Header the trusted proxy sets to prove it is the trusted proxy.
20
+ *
21
+ * `X-Auth-Request-Email` is set by oauth2-proxy AFTER a real Google login, but nothing about the
22
+ * header itself proves it came from oauth2-proxy — anything that can reach the broker's port can
23
+ * send it. The proxy therefore also sets this header from a secret only it and the broker know.
24
+ */
25
+ exports.AUTH_PROXY_SECRET_HEADER = 'x-agenthippo-proxy-secret';
26
+ /**
27
+ * Where the broker listens. 127.0.0.1 by default: this port mints tokens, and every intended caller
28
+ * (the Caddy/oauth2-proxy sidecar) is same-host. Binding a routable interface is an explicit,
29
+ * separately-flagged decision — see `resolveAuthBrokerBind`.
30
+ */
31
+ exports.DEFAULT_AUTH_BIND = '127.0.0.1';
32
+ const LOOPBACK_BINDS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
33
+ /**
34
+ * Resolve (and gate) the bind address.
35
+ *
36
+ * The old default was `0.0.0.0`, which put a token-minting endpoint on every interface: reach
37
+ * :9780 directly, forge `X-Auth-Request-Email`, get an auth code for any address, and exchange it
38
+ * at the ungated `/oauth/token`. Loopback is now the default, and a public bind must be asked for
39
+ * TWICE — the address plus `AGENTHIPPO_AUTH_ALLOW_PUBLIC_BIND=1` — so it can never happen by
40
+ * inheriting a stray env var from a base image.
41
+ */
42
+ function resolveAuthBrokerBind(env = process.env) {
43
+ const requested = env.AGENTHIPPO_AUTH_BIND?.trim() || exports.DEFAULT_AUTH_BIND;
44
+ const isPublic = !LOOPBACK_BINDS.has(requested.toLowerCase());
45
+ if (!isPublic) {
46
+ return { address: requested, isPublic: false };
47
+ }
48
+ if (env.AGENTHIPPO_AUTH_ALLOW_PUBLIC_BIND !== '1') {
49
+ throw new Error(`Refusing to bind the auth broker to "${requested}". This port issues authorization codes ` +
50
+ `and access tokens, and a directly reachable broker can be asked to mint a token for an ` +
51
+ `arbitrary email by forging X-Auth-Request-Email.\n` +
52
+ ` - Same-host proxy (recommended): bind 127.0.0.1 and run Caddy/oauth2-proxy alongside it.\n` +
53
+ ` - SEPARATE CONTAINER for the proxy (the compose/Cloud Run layout, where Caddy dials\n` +
54
+ ` "auth-broker:9780"): the broker must listen on the container's network interface, so set\n` +
55
+ ` BOTH AGENTHIPPO_AUTH_BIND=${requested} AND AGENTHIPPO_AUTH_ALLOW_PUBLIC_BIND=1,\n` +
56
+ ` and make sure the port is not published outside the private network.\n` +
57
+ `In either case set AGENTHIPPO_AUTH_PROXY_SECRET (and have the proxy send it) so a forged ` +
58
+ `X-Auth-Request-Email is rejected — that is what actually stops the token-minting attack.`);
59
+ }
60
+ return { address: requested, isPublic: true };
61
+ }
19
62
  function loadAuthBrokerConfig(env = process.env) {
20
63
  const jwtSecret = env.AGENTHIPPO_AUTH_JWT_SECRET?.trim() || exports.DEFAULT_JWT_SECRET;
21
64
  const publicBaseUrl = (env.AGENTHIPPO_AUTH_PUBLIC_URL?.trim() || `http://127.0.0.1:${env.AGENTHIPPO_AUTH_PORT?.trim() || '9780'}`).replace(/\/+$/, '');
@@ -38,15 +81,17 @@ function loadAuthBrokerConfig(env = process.env) {
38
81
  cookieName: env.AGENTHIPPO_AUTH_COOKIE_NAME?.trim() || 'ah_token',
39
82
  cookieDomain: env.AGENTHIPPO_AUTH_COOKIE_DOMAIN?.trim() || '',
40
83
  idpBaseUrl: (env.AGENTHIPPO_AUTH_IDP_URL?.trim() || '').replace(/\/+$/, ''),
84
+ proxySecret: env.AGENTHIPPO_AUTH_PROXY_SECRET?.trim() || '',
85
+ trustProxyHeadersUnverified: env.AGENTHIPPO_AUTH_TRUST_PROXY_UNVERIFIED === '1',
41
86
  dataDir: env.AGENTHIPPO_AUTH_DATA_DIR?.trim() || undefined,
87
+ teamConfigured: Boolean(env.AGENTHIPPO_TEAM_ID?.trim()),
42
88
  team: {
43
89
  id: env.AGENTHIPPO_TEAM_ID?.trim() || 'team_default',
44
90
  name: env.AGENTHIPPO_TEAM_NAME?.trim() || 'Default Team',
45
91
  storeRepo,
46
92
  storeIndexUrl: env.AGENTHIPPO_TEAM_STORE_INDEX_URL?.trim() || `https://${storeOwner}.github.io/${storeName}`,
47
93
  githubOrg: env.AGENTHIPPO_TEAM_GITHUB_ORG?.trim() || storeOwner,
48
- litellmBaseUrl: env.AGENTHIPPO_TEAM_LITELLM_BASE_URL?.trim() || 'http://127.0.0.1:4000',
49
- litellmApiKey: env.AGENTHIPPO_TEAM_LITELLM_API_KEY?.trim() || exports.DEFAULT_LITELLM_API_KEY,
94
+ gatewayUrl: env.AGENTHIPPO_TEAM_GATEWAY_URL?.trim() || 'https://gateway.agenthippo.ai/v1',
50
95
  },
51
96
  };
52
97
  }
@@ -36,6 +36,8 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.isAllowedRedirectUri = isAllowedRedirectUri;
40
+ exports.hasTrustedProxyProof = hasTrustedProxyProof;
39
41
  exports.createAuthBrokerServer = createAuthBrokerServer;
40
42
  exports.startAuthBrokerServer = startAuthBrokerServer;
41
43
  const http = __importStar(require("http"));
@@ -65,11 +67,60 @@ function isAllowedRedirectUri(redirectUri) {
65
67
  catch {
66
68
  return false;
67
69
  }
70
+ // No embedded credentials, no query, no fragment. A permitted shape with a `?next=…` rider or a
71
+ // `user:pass@` prefix is a different destination wearing an allowed hostname; the exchange binds
72
+ // the code to this exact string, so anything that isn't the literal callback is refused here.
73
+ if (target.username || target.password || target.search || target.hash) {
74
+ return false;
75
+ }
68
76
  if (target.protocol === 'http:') {
69
- return (target.hostname === '127.0.0.1' || target.hostname === 'localhost') && target.pathname === '/callback';
77
+ // The loopback callback is the real IDE/CLI flow (loopbackAuthServer.ts binds an OS-assigned
78
+ // port on 127.0.0.1), so the port cannot be pinned. The port is not what makes this safe:
79
+ // the code is single-use, bound to THIS redirect_uri at issue time and verified against it at
80
+ // /oauth/token, and PKCE binds it to a verifier only the initiating client holds. An attacker
81
+ // who forges a loopback redirect_uri still receives a code they cannot exchange.
82
+ // `localhost` is accepted for parity with the client, but nothing else resolves here.
83
+ if (target.hostname !== '127.0.0.1' && target.hostname !== 'localhost') {
84
+ return false;
85
+ }
86
+ if (target.pathname !== '/callback') {
87
+ return false;
88
+ }
89
+ // A bare `http://127.0.0.1/callback` (port 80) is not something the loopback server ever
90
+ // asks for — it needs a bindable ephemeral port — and port 80 is the one loopback port a
91
+ // non-root process cannot claim, so refuse it rather than leave the shape open.
92
+ return target.port !== '' && Number(target.port) > 1024;
93
+ }
94
+ if (target.protocol === 'https:') {
95
+ // No https redirect target is a client of this broker: the IDE uses the editor URI scheme
96
+ // and the CLI uses loopback. Allowing https here would make "redirect to my server" a
97
+ // one-line manifest away.
98
+ return false;
70
99
  }
71
100
  return (0, callbackUriParse_js_1.isAuthCallbackParts)(target.host, target.pathname);
72
101
  }
102
+ /**
103
+ * Does this request carry the trusted proxy's proof?
104
+ *
105
+ * `X-Auth-Request-Email` is only meaningful when it was set by our oauth2-proxy sidecar after a real
106
+ * Google login. Nothing about the header says so, so the proxy also stamps a shared secret and this
107
+ * is where it is checked — constant-time, so a mismatched secret cannot be recovered byte by byte.
108
+ */
109
+ function hasTrustedProxyProof(headers, expectedSecret) {
110
+ if (!expectedSecret) {
111
+ return false;
112
+ }
113
+ const raw = headers[config_js_1.AUTH_PROXY_SECRET_HEADER];
114
+ const presented = typeof raw === 'string' ? raw : Array.isArray(raw) ? String(raw[0] ?? '') : '';
115
+ if (!presented) {
116
+ return false;
117
+ }
118
+ // Hash both sides first: timingSafeEqual throws on a length mismatch, which would itself leak
119
+ // the secret's length.
120
+ const a = crypto.createHash('sha256').update(presented).digest();
121
+ const b = crypto.createHash('sha256').update(expectedSecret).digest();
122
+ return crypto.timingSafeEqual(a, b);
123
+ }
73
124
  function sendJson(res, status, body) {
74
125
  res.writeHead(status, { 'Content-Type': 'application/json' });
75
126
  res.end(JSON.stringify(body));
@@ -161,11 +212,27 @@ function createAuthBrokerServer(config) {
161
212
  throw new Error('AGENTHIPPO_AUTH_JWT_SECRET must be set to a strong secret in production. ' +
162
213
  'Generate one with: openssl rand -hex 32');
163
214
  }
164
- // Fail-closed: a known-default LiteLLM key would hand every team member the same
165
- // publicly-known credential if the operator forgot to set the real one.
166
- if (!resolved.devMode && resolved.team.litellmApiKey === config_js_1.DEFAULT_LITELLM_API_KEY) {
167
- throw new Error('AGENTHIPPO_TEAM_LITELLM_API_KEY must be set in production. ' +
168
- 'The default dev key must not be issued to real users via bootstrap.');
215
+ // Fail-closed: without a proxy proof, `X-Auth-Request-Email` is a self-asserted claim. Anything
216
+ // that can reach this port could send `X-Auth-Request-Email: ceo@example.com` with a loopback
217
+ // redirect_uri to /oauth/authorize and exchange the resulting code at the ungated /oauth/token —
218
+ // a full account takeover with no credential. Same posture as the JWT/LiteLLM defaults above:
219
+ // refuse to boot rather than run a broker whose identity input is unauthenticated.
220
+ if (!resolved.devMode && !resolved.proxySecret && !resolved.trustProxyHeadersUnverified) {
221
+ throw new Error('AGENTHIPPO_AUTH_PROXY_SECRET must be set in production. The broker trusts ' +
222
+ 'X-Auth-Request-Email from the oauth2-proxy/Caddy forward_auth sidecar; without a shared ' +
223
+ 'secret proving the header came from that sidecar, any caller that reaches this port can ' +
224
+ 'mint a token for an arbitrary email.\n' +
225
+ ' 1. Generate one: openssl rand -hex 32\n' +
226
+ ` 2. Set it here: AGENTHIPPO_AUTH_PROXY_SECRET=<secret>\n` +
227
+ ` 3. Have the proxy send it, e.g. in the Caddyfile:\n` +
228
+ ` header_up ${config_js_1.AUTH_PROXY_SECRET_HEADER} {env.AGENTHIPPO_AUTH_PROXY_SECRET}\n` +
229
+ 'If the broker is genuinely unreachable except through the proxy and you accept the risk, ' +
230
+ 'set AGENTHIPPO_AUTH_TRUST_PROXY_UNVERIFIED=1.');
231
+ }
232
+ if (!resolved.devMode && !resolved.proxySecret && resolved.trustProxyHeadersUnverified) {
233
+ console.warn('[auth-broker] WARNING: AGENTHIPPO_AUTH_TRUST_PROXY_UNVERIFIED=1 — X-Auth-Request-Email is ' +
234
+ 'accepted without proof. Anything that can reach this port can mint a token for any email. ' +
235
+ 'Set AGENTHIPPO_AUTH_PROXY_SECRET to close this.');
169
236
  }
170
237
  // Warn when no domain restriction is configured: any SSO-authenticated email can sign in.
171
238
  if (!resolved.devMode && resolved.allowedEmailDomains.length === 0) {
@@ -229,11 +296,12 @@ function createAuthBrokerServer(config) {
229
296
  // IdP's own broker still hits getUser for its locally-created users, so this fallback is
230
297
  // a no-op there.
231
298
  if (!user) {
299
+ const teamIdFromToken = typeof payload.team_id === 'string' && payload.team_id ? payload.team_id : undefined;
232
300
  user = {
233
301
  id: String(payload.sub),
234
302
  email: String(payload.email),
235
303
  displayName: typeof payload.name === 'string' && payload.name ? payload.name : String(payload.email),
236
- teamId: typeof payload.team_id === 'string' && payload.team_id ? payload.team_id : resolved.team.id,
304
+ teamId: teamIdFromToken ?? (resolved.teamConfigured ? resolved.team.id : undefined),
237
305
  };
238
306
  }
239
307
  if (payload.exp) {
@@ -253,6 +321,33 @@ function createAuthBrokerServer(config) {
253
321
  }
254
322
  return user;
255
323
  }
324
+ /**
325
+ * Read the SSO-asserted email, but only if the request proved it came from the trusted proxy.
326
+ * Returns `undefined` when there is no email, and `null` when an email was present but the proof
327
+ * was missing/wrong — those are different failures and the second one is worth auditing loudly.
328
+ */
329
+ const proxyAssertedEmail = (req) => {
330
+ const email = (req.headers['x-auth-request-email'] || '').trim().toLowerCase();
331
+ if (!email) {
332
+ return undefined;
333
+ }
334
+ // devMode and the explicit unverified opt-in are the only ways past the proof.
335
+ if (resolved.devMode || (!resolved.proxySecret && resolved.trustProxyHeadersUnverified)) {
336
+ return email;
337
+ }
338
+ if (!hasTrustedProxyProof(req.headers, resolved.proxySecret)) {
339
+ return null;
340
+ }
341
+ return email;
342
+ };
343
+ const rejectForgedProxyHeader = (res, ip, pathname) => {
344
+ audit('proxy_header.unverified', { ip, path: pathname });
345
+ sendJson(res, 401, {
346
+ error: 'unauthorized',
347
+ error_description: `X-Auth-Request-Email was present without a valid ${config_js_1.AUTH_PROXY_SECRET_HEADER} header. ` +
348
+ 'Only the trusted reverse proxy may assert an identity to this broker.',
349
+ });
350
+ };
256
351
  const server = http.createServer(async (req, res) => {
257
352
  try {
258
353
  const host = req.headers.host || `127.0.0.1:${resolved.port}`;
@@ -308,8 +403,14 @@ function createAuthBrokerServer(config) {
308
403
  }
309
404
  // Caddy forward_auth (via oauth2-proxy) sets X-Auth-Request-Email after the user
310
405
  // logs in with Google. The authorize endpoint is gated behind forward_auth in the
311
- // Caddyfile so this header is always present when the request reaches the broker.
312
- const userEmail = (req.headers['x-auth-request-email'] || '').trim().toLowerCase();
406
+ // Caddyfile so this header is always present when the request reaches the broker —
407
+ // but a caller that reaches the broker DIRECTLY can set it too, which is why the
408
+ // proxy also has to prove who it is.
409
+ const userEmail = proxyAssertedEmail(req);
410
+ if (userEmail === null) {
411
+ rejectForgedProxyHeader(res, ip, pathname);
412
+ return;
413
+ }
313
414
  if (!userEmail) {
314
415
  sendJson(res, 401, { error: 'unauthorized', error_description: 'No authenticated user (missing X-Auth-Request-Email)' });
315
416
  return;
@@ -335,7 +436,11 @@ function createAuthBrokerServer(config) {
335
436
  // (same as /oauth/authorize) — X-Auth-Request-Email is only set once Google login
336
437
  // completes. Federated agent services never hit this path (they have no oauth2-proxy),
337
438
  // so it 401s there, which is correct.
338
- const userEmail = (req.headers['x-auth-request-email'] || '').trim().toLowerCase();
439
+ const userEmail = proxyAssertedEmail(req);
440
+ if (userEmail === null) {
441
+ rejectForgedProxyHeader(res, ip, pathname);
442
+ return;
443
+ }
339
444
  if (!userEmail) {
340
445
  sendJson(res, 401, { error: 'unauthorized', error_description: 'No authenticated user (missing X-Auth-Request-Email)' });
341
446
  return;
@@ -519,20 +624,6 @@ function createAuthBrokerServer(config) {
519
624
  sendJson(res, 200, store.buildBootstrap(user));
520
625
  return;
521
626
  }
522
- if (req.method === 'POST' && pathname === '/api/ide/bootstrap/refresh') {
523
- if (!user) {
524
- sendJson(res, 401, { error: 'unauthorized' });
525
- return;
526
- }
527
- const routing = store.refreshRoutingCredentials();
528
- sendJson(res, 200, {
529
- routing: {
530
- litellmBaseUrl: resolved.team.litellmBaseUrl,
531
- ...routing,
532
- },
533
- });
534
- return;
535
- }
536
627
  if (req.method === 'POST' && pathname === '/api/ide/signout') {
537
628
  if (user) {
538
629
  store.revokeRefreshTokensForUser(user.id);
@@ -572,9 +663,14 @@ function createAuthBrokerServer(config) {
572
663
  }
573
664
  async function startAuthBrokerServer(config) {
574
665
  const broker = createAuthBrokerServer(config);
575
- const bind = process.env.AGENTHIPPO_AUTH_BIND?.trim() || '0.0.0.0';
666
+ const bind = (0, config_js_1.resolveAuthBrokerBind)();
667
+ if (bind.isPublic) {
668
+ console.warn(`[auth-broker] WARNING: listening on ${bind.address}:${broker.config.port} — this ` +
669
+ `token-issuing endpoint is reachable from off-host (AGENTHIPPO_AUTH_ALLOW_PUBLIC_BIND=1). ` +
670
+ `Ensure only the reverse proxy can reach it and that AGENTHIPPO_AUTH_PROXY_SECRET is set.`);
671
+ }
576
672
  await new Promise((resolve, reject) => {
577
- broker.server.listen(broker.config.port, bind, () => resolve());
673
+ broker.server.listen(broker.config.port, bind.address, () => resolve());
578
674
  broker.server.once('error', reject);
579
675
  });
580
676
  return broker;
@@ -136,7 +136,7 @@ class AuthBrokerStore {
136
136
  id: `usr_${crypto.randomBytes(8).toString('hex')}`,
137
137
  email: normalized,
138
138
  displayName: displayName?.trim() || normalized.split('@')[0] || normalized,
139
- teamId: this.config.team.id,
139
+ ...(this.config.teamConfigured ? { teamId: this.config.team.id } : {}),
140
140
  };
141
141
  this.users.set(user.id, user);
142
142
  this.persist();
@@ -210,7 +210,7 @@ class AuthBrokerStore {
210
210
  sub: user.id,
211
211
  email: user.email,
212
212
  name: user.displayName,
213
- team_id: user.teamId,
213
+ ...(user.teamId ? { team_id: user.teamId } : {}),
214
214
  scope,
215
215
  }, this.config.jwtSecret, this.config.accessTokenTtlSec);
216
216
  const refreshToken = this.createRefreshToken(user.id, clientId);
@@ -221,6 +221,9 @@ class AuthBrokerStore {
221
221
  };
222
222
  }
223
223
  buildBootstrap(user) {
224
+ // No AGENTHIPPO_TEAM_ID ⇒ solo deployment: no team object, and nothing is team-managed —
225
+ // store/routing still apply as defaults but are not locked against the user changing them.
226
+ const teamConfigured = this.config.teamConfigured;
224
227
  return {
225
228
  version: 1,
226
229
  user: {
@@ -228,34 +231,28 @@ class AuthBrokerStore {
228
231
  email: user.email,
229
232
  displayName: user.displayName,
230
233
  },
231
- team: {
232
- id: this.config.team.id,
233
- name: this.config.team.name,
234
- role: 'member',
235
- },
234
+ ...(teamConfigured
235
+ ? {
236
+ team: {
237
+ id: this.config.team.id,
238
+ name: this.config.team.name,
239
+ role: 'member',
240
+ },
241
+ }
242
+ : {}),
236
243
  store: {
237
244
  repo: this.config.team.storeRepo,
238
245
  indexUrl: this.config.team.storeIndexUrl,
239
246
  githubOrg: this.config.team.githubOrg,
240
247
  },
241
248
  routing: {
242
- litellmBaseUrl: this.config.team.litellmBaseUrl,
243
- litellmApiKey: this.config.team.litellmApiKey,
244
- // No litellmApiKeyExpiresAt: the team key is static — a fabricated expiry
245
- // would only pretend rotation exists. Per-user virtual keys are the upgrade path.
249
+ inferenceGatewayUrl: this.config.team.gatewayUrl,
246
250
  },
247
251
  policy: {
248
- byokAllowed: true,
249
- teamManagedSettings: ['store', 'routing'],
252
+ teamManagedSettings: teamConfigured ? ['store', 'routing'] : [],
250
253
  },
251
254
  };
252
255
  }
253
- refreshRoutingCredentials() {
254
- // The team key is static (no rotation backend yet), so no expiry is reported.
255
- return {
256
- litellmApiKey: this.config.team.litellmApiKey,
257
- };
258
- }
259
256
  buildMe(user) {
260
257
  return {
261
258
  user: {
@@ -263,11 +260,15 @@ class AuthBrokerStore {
263
260
  email: user.email,
264
261
  displayName: user.displayName,
265
262
  },
266
- team: {
267
- id: this.config.team.id,
268
- name: this.config.team.name,
269
- role: 'member',
270
- },
263
+ ...(this.config.teamConfigured
264
+ ? {
265
+ team: {
266
+ id: this.config.team.id,
267
+ name: this.config.team.name,
268
+ role: 'member',
269
+ },
270
+ }
271
+ : {}),
271
272
  };
272
273
  }
273
274
  }