@agenthippo-ai/agenthippo 0.1.1 → 0.2.3-dataplane

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 (64) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -9
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/server.js +36 -0
  11. package/out/cli/commands/ask.js +3 -0
  12. package/out/cli/commands/chat.js +3 -0
  13. package/out/cli/commands/deploy.js +49 -10
  14. package/out/cli/commands/gateway.js +54 -0
  15. package/out/cli/commands/idp.js +85 -0
  16. package/out/cli/commands/serve.js +315 -55
  17. package/out/cli/credential-context.js +42 -80
  18. package/out/cli/data-plane/broker.js +78 -0
  19. package/out/cli/data-plane/connector-host.js +121 -0
  20. package/out/cli/data-plane/egress.js +189 -0
  21. package/out/cli/data-plane/gateway.js +195 -0
  22. package/out/cli/data-plane/index.js +137 -0
  23. package/out/cli/data-plane/registry.js +80 -0
  24. package/out/cli/deploy/blueprints/registry.js +84 -0
  25. package/out/cli/deploy/layers/identity.js +91 -0
  26. package/out/cli/deploy-init.js +1105 -335
  27. package/out/cli/idp-init.js +303 -0
  28. package/out/cli/index.js +37 -7
  29. package/out/cli/pack-loader.js +37 -2
  30. package/out/cli/runtime.js +10 -3
  31. package/out/cli/serve-hardening.js +60 -1
  32. package/out/cli/state-sync.js +16 -15
  33. package/out/engines/claude.js +26 -5
  34. package/out/engines/codex.js +24 -5
  35. package/out/engines/custom.js +46 -12
  36. package/out/extension.js +2 -0
  37. package/out/gateway/outboundSend.js +6 -6
  38. package/out/headless/index.js +24 -9
  39. package/out/live-probe/connector.mjs +8 -0
  40. package/out/live-probe/env-report.mjs +8 -0
  41. package/out/mcp.js +85 -67
  42. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  43. package/out/shared/models.js +1 -1
  44. package/out/shared/providers.js +2 -2
  45. package/out/test/analytics.test.js +42 -17
  46. package/out/test/auth-broker.test.js +53 -0
  47. package/out/test/blueprint-registry.test.js +105 -0
  48. package/out/test/credential-context.test.js +23 -28
  49. package/out/test/custom-env-layering.test.js +151 -0
  50. package/out/test/data-plane-engine-wiring.test.js +250 -0
  51. package/out/test/data-plane.test.js +244 -0
  52. package/out/test/deploy-init.test.js +255 -4
  53. package/out/test/egress-broker.test.js +172 -0
  54. package/out/test/fleetDisplayUtils.test.js +4 -0
  55. package/out/test/idp-init.test.js +151 -0
  56. package/out/test/mcp-obo.test.js +52 -27
  57. package/out/test/mcp.test.js +43 -3
  58. package/out/test/model-routing.test.js +133 -0
  59. package/out/test/serve-gateway-turn.test.js +267 -0
  60. package/out/test/serve-hardening.test.js +80 -0
  61. package/out/test/serveClient.test.js +32 -0
  62. package/out/test/state-sync-peer.test.js +18 -5
  63. package/out/utils.js +27 -11
  64. package/package.json +1 -1
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.renderIdpArtifacts = renderIdpArtifacts;
7
+ /**
8
+ * `agenthippo idp init` — generator for the central IdP stack (PLAN/deploy.md, "customer 0").
9
+ *
10
+ * Why this exists: the org IdP used to be static files checked into this source repo
11
+ * (enterprise-deploy/idp/), which meant the actual identity routes could drift from
12
+ * renderIdentityLayer() (fixed once already) AND — the bigger problem — an enterprise
13
+ * customer standing up their own IdP had no way to get any of it at all, since they only
14
+ * receive the compiled `agenthippo` binary, never this repo. Generating it here, from the
15
+ * same shared identity/auth-env/Dockerfile renderers every `deploy init` target already
16
+ * uses, fixes both: it ships in the binary, and it can never drift from what agents expect.
17
+ *
18
+ * AgentHippo's own auth.agenthippo.ai instance is "customer 0" — stood up with this exact
19
+ * command, not a bespoke internal setup.
20
+ */
21
+ const identity_1 = require("./deploy/layers/identity");
22
+ const deploy_init_1 = require("./deploy-init");
23
+ function resolveCookieDomain(ctx) {
24
+ if (ctx.cookieDomain?.trim()) {
25
+ return ctx.cookieDomain.trim();
26
+ }
27
+ const parts = ctx.hostname.split('.');
28
+ return parts.length > 2 ? `.${parts.slice(-2).join('.')}` : `.${ctx.hostname}`;
29
+ }
30
+ function renderIdpComposeYaml() {
31
+ return [
32
+ '# Central IdP. See PLAN/deploy-runbook.md scenario E/F.',
33
+ '#',
34
+ '# ./run.sh',
35
+ '#',
36
+ '# Env split (matches `agenthippo deploy init`):',
37
+ '# ~/.agent-hippo/.env (+ optional ./.env) — hostname only',
38
+ '# ~/.agent-hippo/.env.auth (+ optional ./.env.auth) — JWT/OAuth; broker + oauth2-proxy ONLY',
39
+ '# Profiles: edge (SSO stack), tunnel (Cloudflare → Caddy)',
40
+ '# Org identity infra only — no agent runs here. Per-agent edges are generated separately',
41
+ '# by `agenthippo deploy init` and deployed on their own host/service.',
42
+ 'services:',
43
+ ' oauth2-proxy:',
44
+ ' profiles: ["edge"]',
45
+ ' image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1',
46
+ ' env_file:',
47
+ ' - path: ${HOME}/.agent-hippo/.env',
48
+ ' required: false',
49
+ ' - path: .env',
50
+ ' required: false',
51
+ ' - path: ${HOME}/.agent-hippo/.env.auth',
52
+ ' required: false',
53
+ ' - path: .env.auth',
54
+ ' required: false',
55
+ ' environment:',
56
+ ' OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180',
57
+ ' OAUTH2_PROXY_UPSTREAMS: file:///dev/null',
58
+ ' OAUTH2_PROXY_SET_XAUTHREQUEST: "true"',
59
+ ' OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER: "true"',
60
+ ' OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"',
61
+ ' OAUTH2_PROXY_REVERSE_PROXY: "true"',
62
+ ' expose: ["4180"]',
63
+ '',
64
+ ' auth-broker:',
65
+ ' profiles: ["edge"]',
66
+ ' build:',
67
+ ' context: .',
68
+ ' dockerfile: Dockerfile.auth-broker',
69
+ ' env_file:',
70
+ ' - path: ${HOME}/.agent-hippo/.env',
71
+ ' required: false',
72
+ ' - path: .env',
73
+ ' required: false',
74
+ ' - path: ${HOME}/.agent-hippo/.env.auth',
75
+ ' required: false',
76
+ ' - path: .env.auth',
77
+ ' required: false',
78
+ ' environment:',
79
+ ' AGENTHIPPO_AUTH_PORT: "9780"',
80
+ ' AGENTHIPPO_AUTH_DATA_DIR: /data',
81
+ ' # Proxy hops in front of the broker: 1 = Caddy only (edge profile), 2 = Cloudflare',
82
+ ' # Tunnel → Caddy (run.sh sets 2; at 1 the rate limiter would key every tunnel',
83
+ ' # client on cloudflared\'s IP — one shared bucket).',
84
+ ' AGENTHIPPO_XFF_TRUSTED_HOPS: ${AGENTHIPPO_XFF_TRUSTED_HOPS:-1}',
85
+ ' volumes: ["auth-broker-data:/data"]',
86
+ ' expose: ["9780"]',
87
+ '',
88
+ ' caddy:',
89
+ ' profiles: ["edge"]',
90
+ ' image: caddy:2.8-alpine',
91
+ ' env_file:',
92
+ ' - path: ${HOME}/.agent-hippo/.env',
93
+ ' required: false',
94
+ ' - path: .env',
95
+ ' required: false',
96
+ ' # With the tunnel profile, set AGENTHIPPO_CADDY_BIND=127.0.0.1 so a public-IP host',
97
+ ' # cannot be hit directly on 80/443, bypassing Cloudflare.',
98
+ ' # Bare-metal / BYO nginx or ALB already on 80/443? Set AGENTHIPPO_CADDY_HTTP_PORT /',
99
+ ' # AGENTHIPPO_CADDY_HTTPS_PORT to alternate host ports and point your own reverse proxy',
100
+ ' # at them — the container still listens on 80 internally, only the host mapping moves.',
101
+ ' ports:',
102
+ ' - "${AGENTHIPPO_CADDY_BIND:-0.0.0.0}:${AGENTHIPPO_CADDY_HTTP_PORT:-80}:80"',
103
+ ' - "${AGENTHIPPO_CADDY_BIND:-0.0.0.0}:${AGENTHIPPO_CADDY_HTTPS_PORT:-443}:443"',
104
+ ' volumes:',
105
+ ' - ./Caddyfile:/etc/caddy/Caddyfile:ro',
106
+ ' # Colocated agents (deploy init --colocate) drop a route snippet here — see Caddyfile.',
107
+ ' - ./agents:/etc/caddy/agents:ro',
108
+ ' networks: [default, colocate]',
109
+ ' depends_on: [oauth2-proxy, auth-broker]',
110
+ '',
111
+ ' cloudflared:',
112
+ ' profiles: ["tunnel"]',
113
+ ' image: cloudflare/cloudflared:2024.12.2',
114
+ ' restart: unless-stopped',
115
+ ' command: tunnel --config /etc/cloudflared/config.yml run',
116
+ ' volumes:',
117
+ ' - ./cloudflared.yml:/etc/cloudflared/config.yml:ro',
118
+ ' # Set by run.sh from its argument (./run.sh /path/to/tunnel-credentials.json);',
119
+ ' # falls back to the conventional location for a direct `docker compose` invocation.',
120
+ ' - ${AGENTHIPPO_TUNNEL_CREDENTIALS:-${HOME}/.agent-hippo/tunnel-credentials.json}:/etc/cloudflared/credentials.json:ro',
121
+ ' depends_on: [caddy]',
122
+ '',
123
+ 'volumes:',
124
+ ' auth-broker-data:',
125
+ '',
126
+ 'networks:',
127
+ ' default:',
128
+ ' # Stable, explicitly-named network (not the implicit "idp_default") so a colocated agent\'s',
129
+ ' # own compose project can join it reliably regardless of which directory `docker compose`',
130
+ ' # runs from. Only Caddy needs it — its per-agent snippets `reverse_proxy` straight to the',
131
+ ' # agent container by name; auth-broker is reached over the (already-shared) default network.',
132
+ ' colocate:',
133
+ ' name: agenthippo-idp-colocate',
134
+ '',
135
+ ].join('\n');
136
+ }
137
+ function renderIdpCaddyfile() {
138
+ const identityLines = (0, identity_1.renderIdentityLayer)({
139
+ mode: 'self-issued',
140
+ oauth2ProxyAddr: 'oauth2-proxy:4180',
141
+ authBrokerAddr: 'auth-broker:9780',
142
+ healthProxy: [' reverse_proxy auth-broker:9780'],
143
+ // No agent behind the IdP: a Bearer request to /v1/* doesn't exist here (404, not a
144
+ // silent pass-through), and an unauthenticated browser sees a plain confirmation once
145
+ // past the SSO gate.
146
+ agentProxy: [' respond "not_found" 404'],
147
+ catchallBody: [' respond "AgentHippo IdP" 200'],
148
+ });
149
+ return [
150
+ '# SSO edge + Cloudflare Tunnel. Set AGENTHIPPO_PUBLIC_HOSTNAME in .env (Caddy reads it below).',
151
+ '# TLS terminates at Cloudflare; origin listens on HTTP only.',
152
+ '{',
153
+ ' auto_https off',
154
+ '}',
155
+ '',
156
+ '# Same-host colocation: agents deployed with `deploy init --ingress edge-fed --colocate`',
157
+ '# on THIS host ship a route snippet instead of their own Caddy/oauth2-proxy/auth-broker.',
158
+ '# Drop it in ./agents/<name>.caddy (mounted in docker-compose.yml) and',
159
+ '# `docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile` to pick it up —',
160
+ '# no rebuild, no restart of the IdP itself.',
161
+ 'import agents/*.caddy',
162
+ '',
163
+ `http://{$AGENTHIPPO_PUBLIC_HOSTNAME} {`,
164
+ ...identityLines,
165
+ '}',
166
+ '',
167
+ ].join('\n');
168
+ }
169
+ /** Render every artifact for `agenthippo idp init` (relative path → file content). Pure: no fs writes. */
170
+ function renderIdpArtifacts(ctx) {
171
+ const cookieDomain = resolveCookieDomain(ctx);
172
+ return {
173
+ 'docker-compose.yml': renderIdpComposeYaml(),
174
+ 'Dockerfile.auth-broker': (0, deploy_init_1.renderAuthBrokerDockerfile)(),
175
+ 'Caddyfile': renderIdpCaddyfile(),
176
+ '.env.example': [
177
+ '# Caddy hostname for the central IdP. No agent runs on this stack — auth secrets:',
178
+ '# see .env.auth.example → ~/.agent-hippo/.env.auth (broker + oauth2-proxy only).',
179
+ '',
180
+ `AGENTHIPPO_PUBLIC_HOSTNAME=${ctx.hostname}`,
181
+ '',
182
+ '# Proxy hops / Caddy bind (optional; run.sh sets XFF hops=2 for tunnel)',
183
+ '# AGENTHIPPO_XFF_TRUSTED_HOPS=1',
184
+ '# AGENTHIPPO_CADDY_BIND=0.0.0.0',
185
+ '',
186
+ '# Bare metal / BYO nginx or ALB already on 80/443? Rebind Caddy to alternate host ports',
187
+ '# and point your reverse proxy at them (container still listens on 80 internally):',
188
+ '# AGENTHIPPO_CADDY_HTTP_PORT=8080',
189
+ '# AGENTHIPPO_CADDY_HTTPS_PORT=8443',
190
+ '',
191
+ ].join('\n'),
192
+ '.env.auth.example': (0, deploy_init_1.renderAuthEnvExample)({ hostname: ctx.hostname, fed: false, cookieDomain }),
193
+ 'cloudflared.yml.example': [
194
+ '# Copy to cloudflared.yml; the credentials JSON lives in ~/.agent-hippo/ (never in the repo).',
195
+ '# cloudflared tunnel create agenthippo-auth',
196
+ '# cp ~/.cloudflared/<tunnel-id>.json ~/.agent-hippo/tunnel-credentials.json',
197
+ `# cloudflared tunnel route dns agenthippo-auth ${ctx.hostname}`,
198
+ 'tunnel: REPLACE_WITH_TUNNEL_ID',
199
+ 'credentials-file: /etc/cloudflared/credentials.json',
200
+ 'ingress:',
201
+ ` - hostname: ${ctx.hostname}`,
202
+ ' service: http://caddy:80',
203
+ ' - service: http_status:404',
204
+ '',
205
+ ].join('\n'),
206
+ 'run.sh': [
207
+ '#!/usr/bin/env bash',
208
+ '# Start central IdP: Caddy + oauth2-proxy + auth-broker (+ Cloudflare tunnel, if requested).',
209
+ '# No agent here — per-agent edges are generated separately by `agenthippo deploy init`.',
210
+ '#',
211
+ '# Usage:',
212
+ '# ./run.sh bare metal / BYO nginx or ALB in front',
213
+ '# ./run.sh /path/to/tunnel-credentials.json Cloudflare Tunnel',
214
+ '#',
215
+ '# Bare metal / BYO nginx or ALB: run with no argument — starts --profile edge only.',
216
+ '# If 80/443 are already taken by your reverse proxy, set AGENTHIPPO_CADDY_HTTP_PORT/',
217
+ '# _HTTPS_PORT in .env first so Caddy rebinds elsewhere.',
218
+ 'set -euo pipefail',
219
+ 'cd "$(dirname "$0")"',
220
+ 'if [[ $# -ge 1 ]]; then',
221
+ ' export AGENTHIPPO_TUNNEL_CREDENTIALS="$1"',
222
+ ' [[ -f "$AGENTHIPPO_TUNNEL_CREDENTIALS" ]] || {',
223
+ ' echo "Tunnel credentials file not found: $AGENTHIPPO_TUNNEL_CREDENTIALS" >&2; exit 1; }',
224
+ ' # Two proxy hops behind the tunnel (cloudflared → Caddy) — see docker-compose.yml.',
225
+ ' export AGENTHIPPO_XFF_TRUSTED_HOPS="${AGENTHIPPO_XFF_TRUSTED_HOPS:-2}"',
226
+ ' docker compose --profile edge --profile tunnel up -d --build',
227
+ ` echo "IdP: https://\${AGENTHIPPO_PUBLIC_HOSTNAME:-${ctx.hostname}} (via Cloudflare Tunnel)"`,
228
+ 'else',
229
+ ' echo "No tunnel credentials path given — starting --profile edge only (no tunnel)."',
230
+ ' echo "Front this host with your own nginx/ALB; see AGENTHIPPO_CADDY_HTTP_PORT/AGENTHIPPO_CADDY_BIND in .env."',
231
+ ' echo "(To use a Cloudflare Tunnel instead: ./run.sh /path/to/tunnel-credentials.json)"',
232
+ ' docker compose --profile edge up -d --build',
233
+ ` echo "IdP: http://\${AGENTHIPPO_PUBLIC_HOSTNAME:-${ctx.hostname}} (behind your own reverse proxy)"`,
234
+ 'fi',
235
+ '',
236
+ ].join('\n'),
237
+ 'agents/README.md': [
238
+ '# Colocated agent routes',
239
+ '',
240
+ 'Drop `*.caddy` route snippets here for agents deployed on **this same host** via:',
241
+ '',
242
+ '```bash',
243
+ 'agenthippo deploy init <pack> --target compose --ingress edge-fed --colocate',
244
+ '```',
245
+ '',
246
+ 'That command generates `deploy/agent-route.caddy` (copy it here, renamed to something',
247
+ 'identifying the agent) plus a `docker-compose.yml` containing only the `agent` service',
248
+ '(no Caddy/oauth2-proxy/auth-broker of its own — it joins this IdP\'s',
249
+ '`agenthippo-idp-colocate` network and rides the IdP\'s existing edge).',
250
+ '',
251
+ 'After copying a new snippet in:',
252
+ '',
253
+ '```bash',
254
+ 'docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile',
255
+ '```',
256
+ '',
257
+ 'No rebuild, no restart of the IdP itself. See `../Caddyfile`\'s `import agents/*.caddy` and',
258
+ '`PLAN/deploy-runbook.md` scenario F for the full picture.',
259
+ '',
260
+ ].join('\n'),
261
+ 'README.md': [
262
+ '# Central IdP',
263
+ '',
264
+ '**One per organization.** Hosts Google SSO (oauth2-proxy) and the IDE auth-broker only —',
265
+ 'no agent runs here. Agent edges are generated and deployed separately.',
266
+ '',
267
+ 'Generated by `agenthippo idp init` — regenerate (don\'t hand-edit `Caddyfile`, which comes',
268
+ 'straight from the same identity module every agent\'s Caddyfile uses) if you need to change',
269
+ 'routing: that lives in the CLI itself, not in this output.',
270
+ '',
271
+ 'This stack runs on **any host with Docker** — local laptop, team VM, cloud VM, or on-prem.',
272
+ '`./run.sh` picks the path below based on whether you pass it a tunnel credentials path.',
273
+ '',
274
+ '## Quick start',
275
+ '',
276
+ '```bash',
277
+ 'cp .env.example ~/.agent-hippo/.env',
278
+ 'cp .env.auth.example ~/.agent-hippo/.env.auth # fill in; never put AGENTHIPPO_AUTH_JWT_SECRET in .env',
279
+ '```',
280
+ '',
281
+ '**Cloudflare Tunnel** (no public ports to manage yourself):',
282
+ '',
283
+ '```bash',
284
+ 'cp cloudflared.yml.example cloudflared.yml # set tunnel id',
285
+ 'cloudflared tunnel create agenthippo-auth # writes ~/.cloudflared/<tunnel-id>.json',
286
+ 'cp ~/.cloudflared/<tunnel-id>.json ~/.agent-hippo/tunnel-credentials.json',
287
+ './run.sh ~/.agent-hippo/tunnel-credentials.json',
288
+ '```',
289
+ '',
290
+ '**Bare metal / BYO nginx or ALB already fronting this host:**',
291
+ '',
292
+ '```bash',
293
+ '# skip cloudflared.yml entirely. If 80/443 are already taken by your reverse proxy,',
294
+ '# set AGENTHIPPO_CADDY_HTTP_PORT/AGENTHIPPO_CADDY_HTTPS_PORT in .env first.',
295
+ './run.sh # no argument — starts --profile edge only',
296
+ '# point your nginx/ALB at this host\'s Caddy (AGENTHIPPO_CADDY_BIND / _HTTP_PORT above)',
297
+ '```',
298
+ '',
299
+ 'Agent edges (e.g. `cs.corp.example`) are separate — see `PLAN/deploy-runbook.md` scenarios E/F.',
300
+ '',
301
+ ].join('\n'),
302
+ };
303
+ }
package/out/cli/index.js CHANGED
@@ -77,6 +77,7 @@ const ask_1 = require("./commands/ask");
77
77
  const serve_1 = require("./commands/serve");
78
78
  const auth_broker_1 = require("./commands/auth-broker");
79
79
  const deploy_1 = require("./commands/deploy");
80
+ const idp_1 = require("./commands/idp");
80
81
  const pack_1 = require("./commands/pack");
81
82
  const store_1 = require("./commands/store");
82
83
  const anywhere_1 = require("./commands/anywhere");
@@ -247,22 +248,29 @@ program
247
248
  // Deploy command - generate platform-native deploy artifacts
248
249
  program
249
250
  .command('deploy <action> [pack]')
250
- .description('Generate a self-contained deploy staging dir (init). Targets: vm, compose, cloudrun, databricks-app, spcs, cloudflare, all')
251
+ .description('Generate a self-contained deploy staging dir (init). Targets: vm, compose, cloudrun, databricks-app, spcs, cloudflare, aws-ecs, azure-container-apps, all. Or pick a --blueprint instead of raw --target/--ingress.')
251
252
  .option('-t, --target <target>', 'Deploy target', 'vm')
252
- .option('-o, --out <path>', 'Staging directory (default: ./deploy-out/<pack>-<target>)')
253
+ .option('--blueprint <id>', 'Named preset (sandbox, prospect-demo, team-pilot, regulated-air-gapped, enterprise-fleet) — resolves --target/--ingress for you (PLAN/deploy.md §3); overrides both when set')
254
+ .option('--platform <platform>', 'Which platform to use within --blueprint\'s supported set (defaults to the blueprint\'s first); ignored without --blueprint')
255
+ .option('-o, --out <path>', 'Staging directory (default: ./deploy-out/<pack>-<target> relative to cwd; run from the pack parent, not from inside the pack)')
253
256
  .option('--packdir <path>', 'Pack directory (defaults to [pack] arg or cwd)')
254
257
  .option('--image <ref>', 'Runtime image ref (default: engine runtime:latest, or :latest-cursor for cursor packs)')
255
258
  .option('-p, --port <port>', 'Container port', '8080')
256
259
  .option('--gateway <channels>', 'Agent Anywhere: bind channels (e.g. "slack") + add OpenClaw gateway service (compose target)')
257
260
  .option('--hostname <host>', 'Public hostname for SSO edge (Caddy, OAuth, tunnel)', 'auth.agenthippo.ai')
258
- .option('--ingress <mode>', 'Cloud Run ingress: iap (built-in IAP), edge (per-service AgentHippo edge + Google OAuth), or edge-fed (federated: auth via auth.agenthippo.ai, no per-service OAuth)', 'iap')
261
+ .option('--ingress <mode>', 'iap (Cloud Run built-in IAP only), edge (self-issued AgentHippo edge + Google OAuth — vm/compose/cloudrun/aws-ecs/azure-container-apps), or edge-fed (federated: auth via the central IdP, no per-target OAuth client — same targets as edge)', 'iap')
259
262
  .option('--idp-url <url>', 'edge-fed: central IdP base URL browsers redirect to for login (e.g. https://auth.agenthippo.ai)', 'https://auth.agenthippo.ai')
260
263
  .option('--cookie-domain <domain>', 'edge-fed: parent domain the shared auth cookie is scoped to (e.g. .agenthippo.ai)', '.agenthippo.ai')
264
+ .option('--colocate', 'edge-fed + vm/compose only: ride the org IdP\'s already-running Caddy + auth-broker instead of shipping this agent\'s own (PLAN/deploy.md §4 state 4)')
265
+ .option('--model-routing <mode>', 'Cloud Run + edge-fed: sidecar (in-pod LiteLLM+OTEL, default) or gateway (agent → gateway.agenthippo.ai directly, lean)')
261
266
  .option('--force', 'Overwrite existing files')
262
267
  .action(async (action, pack, options) => {
263
268
  const ingress = options.ingress === 'edge' ? 'edge' : options.ingress === 'edge-fed' ? 'edge-fed' : 'iap';
269
+ const modelRouting = options.modelRouting === 'gateway' ? 'gateway' : options.modelRouting === 'sidecar' ? 'sidecar' : undefined;
264
270
  await (0, deploy_1.deployCommand)(action, pack, {
265
271
  target: options.target,
272
+ blueprint: options.blueprint,
273
+ platform: options.platform,
266
274
  out: options.out,
267
275
  image: options.image,
268
276
  gateway: options.gateway,
@@ -273,6 +281,24 @@ program
273
281
  ingress,
274
282
  idpUrl: options.idpUrl,
275
283
  cookieDomain: options.cookieDomain,
284
+ colocate: Boolean(options.colocate),
285
+ modelRouting,
286
+ });
287
+ });
288
+ // IdP command - generate the central IdP stack (org identity infra, set up once)
289
+ program
290
+ .command('idp <action>')
291
+ .description('Generate the central IdP stack (init) — Caddy + oauth2-proxy + auth-broker. One per organization; agents federate against it with `deploy init --ingress edge-fed`.')
292
+ .option('--hostname <host>', 'Public hostname the IdP is reachable at (e.g. auth.corp.example) — required')
293
+ .option('--cookie-domain <domain>', 'Parent domain the shared auth cookie is scoped to (e.g. .corp.example); defaults to the hostname\'s own domain')
294
+ .option('-o, --out <path>', 'Output directory (default: ./enterprise-deploy/idp)')
295
+ .option('--force', 'Overwrite existing files')
296
+ .action(async (action, options) => {
297
+ await (0, idp_1.idpCommand)(action, {
298
+ hostname: options.hostname,
299
+ cookieDomain: options.cookieDomain,
300
+ out: options.out,
301
+ force: Boolean(options.force),
276
302
  });
277
303
  });
278
304
  // Pack command - pack management
@@ -326,8 +352,12 @@ const gatewayCmd = program.command('gateway').description('Gateway outbound mess
326
352
  gatewayCmd
327
353
  .command('send')
328
354
  .description('Send a proactive message via the gateway (WebSocket RPC; hippoclaw CLI fallback)')
329
- .requiredOption('--outbound <path>', 'Session gateway-outbound.json (see AGENTHIPPO_GATEWAY_OUTBOUND_FILE)')
330
- .requiredOption('-a, --agent <id>', 'Agent pack id')
355
+ // --outbound/--agent are optional: under `serve` with the egress broker active
356
+ // (AGENTHIPPO_EGRESS_URL + AGENTHIPPO_TURN_NONCE set), the broker resolves the destination
357
+ // server-side from the turn nonce and neither is needed (secure-obo-design §7.6). Off-serve
358
+ // the direct path still requires both (validated in gatewaySendCommand).
359
+ .option('--outbound <path>', 'Session gateway-outbound.json (see AGENTHIPPO_GATEWAY_OUTBOUND_FILE)')
360
+ .option('-a, --agent <id>', 'Agent pack id')
331
361
  .requiredOption('-m, --message <text>', 'Message to send')
332
362
  .option('--media <path-or-url>', 'Attach media (image/audio/video/document); local path or URL. Sent via gateway RPC so the bot token resolves from the gateway runtime snapshot.')
333
363
  .option('-t, --target <target>', 'Explicit target (e.g. channel:C01234567); defaults to last inbound route')
@@ -336,8 +366,8 @@ gatewayCmd
336
366
  .option('--json', 'Output handle + result as JSON')
337
367
  .action(async (options) => {
338
368
  await (0, gateway_1.gatewaySendCommand)({
339
- outbound: options.outbound,
340
- agent: options.agent,
369
+ outbound: options.outbound ?? '',
370
+ agent: options.agent ?? '',
341
371
  message: options.message,
342
372
  media: options.media,
343
373
  target: options.target,
@@ -42,6 +42,7 @@ exports.loadAgentPack = loadAgentPack;
42
42
  exports.getDefaultConfig = getDefaultConfig;
43
43
  exports.getEnvDefaultModel = getEnvDefaultModel;
44
44
  exports.getApiConfig = getApiConfig;
45
+ exports.hasCredentialForEngine = hasCredentialForEngine;
45
46
  exports.getFileAccess = getFileAccess;
46
47
  /**
47
48
  * Agent Pack Loader
@@ -330,6 +331,14 @@ function validateManifest(manifest, sourcePath) {
330
331
  if (manifest.spec?.cheapModel !== undefined && typeof manifest.spec.cheapModel !== 'string') {
331
332
  errors.push('Invalid spec.cheapModel: must be a string when provided');
332
333
  }
334
+ if (manifest.spec?.dataPlane !== undefined) {
335
+ if (typeof manifest.spec.dataPlane !== 'object' || !manifest.spec.dataPlane.connector?.trim()) {
336
+ errors.push('Invalid spec.dataPlane: requires a `connector` path (stdio MCP module run broker-side)');
337
+ }
338
+ if (manifest.spec.dataPlane?.provider !== undefined && typeof manifest.spec.dataPlane.provider !== 'string') {
339
+ errors.push('Invalid spec.dataPlane.provider: must be a string when provided');
340
+ }
341
+ }
333
342
  if (errors.length > 0) {
334
343
  throw new Error(`Invalid agent manifest at ${sourcePath}:\n - ${errors.join('\n - ')}`);
335
344
  }
@@ -354,20 +363,46 @@ function getEnvDefaultModel() {
354
363
  }
355
364
  /**
356
365
  * Get API configuration from environment.
366
+ *
367
+ * Returns the credentials keyed by destination (litellm / openai / anthropic) rather than a
368
+ * single collapsed key. The engine selects the one matching the endpoint it dials — see
369
+ * resolveApiRouting — so a codex deployment can no longer send an Anthropic key to OpenAI.
357
370
  */
358
371
  function getApiConfig() {
359
372
  const litellmApiKey = getEnvVar('AGENTHIPPO_LITELLM_API_KEY', 'AGENTIDE_LITELLM_API_KEY');
360
373
  const litellmBaseUrl = getEnvVar('AGENTHIPPO_LITELLM_BASE_URL', 'AGENTIDE_LITELLM_BASE_URL');
361
374
  const anthropicApiKey = process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY;
362
375
  const openaiApiKey = process.env.OPENAI_API_KEY;
363
- const apiKey = litellmApiKey || anthropicApiKey || openaiApiKey;
364
376
  const useLiteLLM = Boolean(litellmApiKey && litellmBaseUrl);
365
377
  return {
366
- apiKey,
378
+ // Kept only for the "is any credential present at all?" boot check; routing never uses it.
379
+ apiKey: litellmApiKey || anthropicApiKey || openaiApiKey,
367
380
  baseUrl: litellmBaseUrl,
368
381
  useLiteLLM,
382
+ litellmApiKey,
383
+ openaiApiKey,
384
+ anthropicApiKey,
369
385
  };
370
386
  }
387
+ /**
388
+ * Does the environment carry a usable credential for `engine`? Engine-aware so a codex
389
+ * deployment with only an Anthropic key (or vice-versa) fails loudly at boot instead of
390
+ * 401-ing at the first model call. A configured LiteLLM proxy satisfies every engine.
391
+ */
392
+ function hasCredentialForEngine(engine, cfg = getApiConfig()) {
393
+ if (cfg.useLiteLLM) {
394
+ return true;
395
+ }
396
+ const e = engine.trim().toLowerCase();
397
+ if (e === 'claude') {
398
+ return Boolean(cfg.anthropicApiKey);
399
+ }
400
+ if (e === 'codex') {
401
+ return Boolean(cfg.openaiApiKey);
402
+ }
403
+ // Custom engines can authenticate via their own manifest-declared env var — don't gate them here.
404
+ return true;
405
+ }
371
406
  /**
372
407
  * Get file access mode from pack manifest.
373
408
  */
@@ -315,9 +315,16 @@ function buildCliEnvPatch(args) {
315
315
  chatSessionId: args.chatSessionId,
316
316
  }),
317
317
  };
318
- const gatewayToken = readOpenClawGatewayToken(args.baseEnv);
319
- if (gatewayToken) {
320
- env[exports.OPENCLAW_GATEWAY_TOKEN_ENV] = gatewayToken;
318
+ // When serve runs the egress broker (secure-obo-design §7.6), the gateway token must NOT
319
+ // reach the agent's trust domain: the broker holds it, the agent presents only the turn
320
+ // nonce. AGENTHIPPO_EGRESS_URL in the process env is that signal — with it set, never bake
321
+ // the token into a child env (this closes §7.4 gap 1 for custom engines; codex/claude are
322
+ // covered by serve stripping the token from its own process.env).
323
+ if (!process.env.AGENTHIPPO_EGRESS_URL?.trim()) {
324
+ const gatewayToken = readOpenClawGatewayToken(args.baseEnv);
325
+ if (gatewayToken) {
326
+ env[exports.OPENCLAW_GATEWAY_TOKEN_ENV] = gatewayToken;
327
+ }
321
328
  }
322
329
  // Claude SDK refuses nested sessions when CLAUDECODE is present.
323
330
  // Force-unset in child runtime for CLI ask/chat/serve subprocesses.
@@ -36,13 +36,16 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.TurnGate = exports.TURN_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = exports.IDENTITY_HEADER_ENV = exports.RateLimiter = exports.XFF_TRUSTED_HOPS_ENV = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
39
+ exports.TurnGate = exports.TURN_LIMIT_BUSY_MESSAGE = exports.RATE_LIMIT_BUSY_MESSAGE = exports.TURN_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = exports.SESSION_WITHOUT_USER_REASON = exports.IDENTITY_HEADER_ENV = exports.RateLimiter = exports.XFF_TRUSTED_HOPS_ENV = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
40
40
  exports.resolveStateDir = resolveStateDir;
41
+ exports.resolveAnalyticsSessionsDir = resolveAnalyticsSessionsDir;
41
42
  exports.resolveBindHost = resolveBindHost;
42
43
  exports.clientIp = clientIp;
43
44
  exports.resolveUserId = resolveUserId;
44
45
  exports.resolveDataToken = resolveDataToken;
45
46
  exports.peerToUserId = peerToUserId;
47
+ exports.userNamespaceHash = userNamespaceHash;
48
+ exports.resolveServeSessionKey = resolveServeSessionKey;
46
49
  exports.corsHeaders = corsHeaders;
47
50
  exports.createTurnGateFromEnv = createTurnGateFromEnv;
48
51
  exports.createRejectionWarner = createRejectionWarner;
@@ -60,6 +63,8 @@ exports.readBodyWithCap = readBodyWithCap;
60
63
  */
61
64
  const os = __importStar(require("os"));
62
65
  const path = __importStar(require("path"));
66
+ const crypto = __importStar(require("crypto"));
67
+ const paths_1 = require("../utils/paths");
63
68
  /** Default cap on a single request body (1 MiB). Chat messages are small; large bodies are abuse or bugs. */
64
69
  exports.DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
65
70
  /**
@@ -82,6 +87,26 @@ function resolveStateDir(stateDirOpt, env = process.env) {
82
87
  }
83
88
  return { dir: path.join(os.tmpdir(), 'agenthippo-server'), ephemeral: true };
84
89
  }
90
+ /**
91
+ * Where serve's Layer 2 analytics (tool-call/warrant JSONL) should live. Precedence:
92
+ * 1. AGENT_HOME_DIR / AGENTHIPPO_HOME set → getAgentideHome()/analytics/sessions — the
93
+ * exact location sessionJsonlFleet.ts and query-analytics.sh already scan by default,
94
+ * so this data stays discoverable to Spotlight/DuckDB.
95
+ * 2. Neither set, but the resolved state dir is non-ephemeral (an explicit --state-dir
96
+ * was given) → <stateDir>/analytics/sessions. Not the canonical scan location, but
97
+ * still durable — never regress below whatever durability the caller already
98
+ * established just to be discoverable.
99
+ * 3. Neither set and state is ephemeral (no --state-dir, no home-dir env var at all) →
100
+ * falls through to getAgentideHome()'s own os.homedir() default, matching the
101
+ * already-correct IDE-local behavior — nothing durable was configured either way.
102
+ */
103
+ function resolveAnalyticsSessionsDir(stateDir, stateDirEphemeral, env = process.env) {
104
+ const homeOverride = env.AGENT_HOME_DIR?.trim() || env.AGENTHIPPO_HOME?.trim();
105
+ if (!homeOverride && !stateDirEphemeral) {
106
+ return path.join(stateDir, 'analytics', 'sessions');
107
+ }
108
+ return path.join((0, paths_1.getAgentideHome)(), 'analytics', 'sessions');
109
+ }
85
110
  /** Loopback default — safe everywhere a human runs `serve` locally. */
86
111
  exports.DEFAULT_BIND_HOST = '127.0.0.1';
87
112
  /** Env var for the listen address (used by generated deploy Dockerfiles). */
@@ -275,6 +300,36 @@ function peerToUserId(peer) {
275
300
  const channel = peer.channel?.trim();
276
301
  return channel ? `${channel}:${id}` : id;
277
302
  }
303
+ /** Stable reason code when a session_id is presented without a verified user on a pinned-identity deployment. */
304
+ exports.SESSION_WITHOUT_USER_REASON = 'session_without_verified_user';
305
+ /** Filesystem/S3-safe per-user namespace segment (user ids are emails/subjects — never raw path segments). */
306
+ function userNamespaceHash(userId) {
307
+ return crypto.createHash('sha256').update(userId).digest('hex').slice(0, 16);
308
+ }
309
+ /**
310
+ * Namespace the session key by the verified user (secure-obo-design §3.4 / §8 Phase 0).
311
+ *
312
+ * `session_id` is client-supplied; un-namespaced, any authenticated user who guesses another
313
+ * user's session_id attaches to that session's conversation state (which holds the other
314
+ * user's row-filtered query results). Prefixing the key with a hash of the verified user makes
315
+ * cross-user attachment structurally impossible without changing client behavior.
316
+ *
317
+ * Fail-closed rule: when the deployment pins an identity header (it declared a real
318
+ * proxy-verified identity exists) and a request carries a session_id but no verified user,
319
+ * reject — never let an unauthenticated caller name a session. Unpinned deployments
320
+ * (local dev, no identity boundary at all) keep today's raw-key behavior.
321
+ */
322
+ function resolveServeSessionKey(sessionId, userId, env = process.env) {
323
+ const sid = sessionId?.trim();
324
+ const uid = userId?.trim();
325
+ if (uid) {
326
+ return { sessionKey: `u${userNamespaceHash(uid)}--${sid || 'default'}` };
327
+ }
328
+ if (sid && env[exports.IDENTITY_HEADER_ENV]?.trim()) {
329
+ return { sessionKey: '', rejected: exports.SESSION_WITHOUT_USER_REASON };
330
+ }
331
+ return { sessionKey: sid || 'anon-unknown' };
332
+ }
278
333
  /** Build CORS headers. Serve is always behind a proxy; CORS is wide-open here. */
279
334
  function corsHeaders() {
280
335
  return {
@@ -320,6 +375,10 @@ exports.TURN_QUEUE_ENV = 'AGENTHIPPO_TURN_QUEUE';
320
375
  exports.TURN_GATE_RETRY_AFTER_SECONDS = 15;
321
376
  /** User-visible capacity message for gateway channels (Slack/Telegram can't receive HTTP 429). */
322
377
  exports.TURN_GATE_BUSY_MESSAGE = 'At capacity — please retry in a moment';
378
+ /** User-visible message for gateway channels when the per-identity RateLimiter rejects. */
379
+ exports.RATE_LIMIT_BUSY_MESSAGE = 'Rate limit exceeded — please retry in a moment';
380
+ /** User-visible message for gateway channels when the per-session TurnLimiter rejects. */
381
+ exports.TURN_LIMIT_BUSY_MESSAGE = 'Per-session turn limit reached';
323
382
  /**
324
383
  * Process-wide concurrency gate for engine turns (semaphore + bounded FIFO).
325
384
  *
@@ -216,17 +216,20 @@ async function pathExists(p) {
216
216
  return false;
217
217
  }
218
218
  }
219
- async function copyDirIfExists(src, dest) {
220
- if (!(await pathExists(src))) {
221
- return;
222
- }
223
- await fsp.mkdir(dest, { recursive: true });
224
- await fsp.cp(src, dest, { recursive: true, force: true });
225
- }
226
219
  /**
227
- * Build a local staging tree for session S:
228
- * provider-sessions/<shards for S>
229
- * agents/ (replica snapshot — sticky-by-user keeps this scoped)
220
+ * Build a local staging tree for session S: provider-sessions/<shards for S> only.
221
+ *
222
+ * `<stateDir>/agents/<agentId>/{claude,codex}` (the SDK's own on-disk session/config
223
+ * cache — see agentpack/paths.ts) is deliberately NOT included here. That directory is
224
+ * shared by every session/user of the same pack on this replica, keyed only by agentId —
225
+ * never session-scoped. Treating it as part of one session's push/restore let one
226
+ * session's stale snapshot force-overwrite the live SDK cache for every other
227
+ * concurrently active session on the replica (fs.cp force:true in the old
228
+ * mergeSessionStagingIntoState). It's local-disk-only now, same as before object-store
229
+ * sync existed: it lives and dies with its replica. A resume target missing after a
230
+ * replica change already falls back to a fresh SDK session — the same path every
231
+ * session's true first turn on any replica already takes — so this doesn't introduce a
232
+ * new failure mode, it just stops the wrong-session-data clobber.
230
233
  */
231
234
  async function stageSessionPushDir(stateDir, sessionId, stagingDir) {
232
235
  await fsp.rm(stagingDir, { recursive: true, force: true });
@@ -239,11 +242,10 @@ async function stageSessionPushDir(stateDir, sessionId, stagingDir) {
239
242
  await fsp.copyFile(path.join(stateDir, 'provider-sessions', name), path.join(psDest, name));
240
243
  }
241
244
  }
242
- await copyDirIfExists(path.join(stateDir, 'agents'), path.join(stagingDir, 'agents'));
243
- const hasAgents = await pathExists(path.join(stagingDir, 'agents'));
244
- return shards.length > 0 || hasAgents;
245
+ return shards.length > 0;
245
246
  }
246
- /** Merge a restored staging tree back into stateDir. */
247
+ /** Merge a restored staging tree back into stateDir (provider-session shards only — see
248
+ * stageSessionPushDir for why the SDK config cache is intentionally excluded). */
247
249
  async function mergeSessionStagingIntoState(stagingDir, stateDir) {
248
250
  const psSrc = path.join(stagingDir, 'provider-sessions');
249
251
  if (await pathExists(psSrc)) {
@@ -256,7 +258,6 @@ async function mergeSessionStagingIntoState(stagingDir, stateDir) {
256
258
  await fsp.copyFile(path.join(psSrc, name), path.join(psDest, name));
257
259
  }
258
260
  }
259
- await copyDirIfExists(path.join(stagingDir, 'agents'), path.join(stateDir, 'agents'));
260
261
  }
261
262
  /** Download state from the bucket into stateDir on boot. An empty bucket is a no-op. */
262
263
  async function restoreState(cfg, stateDir, log) {