@agenthippo-ai/agenthippo 0.1.0 → 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 (68) 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 -4
  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/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -2,40 +2,8 @@
2
2
  /*---------------------------------------------------------------------------------------------
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
4
  *--------------------------------------------------------------------------------------------*/
5
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
- if (k2 === undefined) k2 = k;
7
- var desc = Object.getOwnPropertyDescriptor(m, k);
8
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
- desc = { enumerable: true, get: function() { return m[k]; } };
10
- }
11
- Object.defineProperty(o, k2, desc);
12
- }) : (function(o, m, k, k2) {
13
- if (k2 === undefined) k2 = k;
14
- o[k2] = m[k];
15
- }));
16
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
- Object.defineProperty(o, "default", { enumerable: true, value: v });
18
- }) : function(o, v) {
19
- o["default"] = v;
20
- });
21
- var __importStar = (this && this.__importStar) || (function () {
22
- var ownKeys = function(o) {
23
- ownKeys = Object.getOwnPropertyNames || function (o) {
24
- var ar = [];
25
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
- return ar;
27
- };
28
- return ownKeys(o);
29
- };
30
- return function (mod) {
31
- if (mod && mod.__esModule) return mod;
32
- var result = {};
33
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
- __setModuleDefault(result, mod);
35
- return result;
36
- };
37
- })();
38
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ON_DENIED_TIMEOUT_MS = exports.WARRANT_DENIED_REASON = void 0;
39
7
  exports.runWithCredential = runWithCredential;
40
8
  exports.currentCredential = currentCredential;
41
9
  exports.requireUserId = requireUserId;
@@ -49,52 +17,62 @@ exports.requireUserId = requireUserId;
49
17
  * exactly as the Databricks OBO docs prescribe ("read it from the request on
50
18
  * each call … rather than trying to have it persist").
51
19
  *
52
- * Stdio MCP subprocesses cannot see AsyncLocalStorage. When `dataTokenDir` is
53
- * provided, `runWithCredential` writes the per-turn data token to a scoped file
54
- * and exposes `dataTokenFile` so MCP env resolution can pass
55
- * `AGENTHIPPO_DATA_TOKEN_FILE` (read by the MCP server on each tool call).
20
+ * The credential lives in broker (serve) process memory only. It is never
21
+ * written to disk and never crosses into an engine subprocess: engines receive
22
+ * a turn-scoped nonce for the loopback data-plane gateway instead
23
+ * (secure-obo-design §2/§3 — the agent gets a capability, not the credential).
56
24
  */
57
25
  const async_hooks_1 = require("async_hooks");
58
- const node_crypto_1 = require("node:crypto");
59
- const node_fs_1 = require("node:fs");
60
- const path = __importStar(require("path"));
26
+ /** Stable, queryable reason code for the one case runWithCredential rejects. */
27
+ exports.WARRANT_DENIED_REASON = 'token_without_verified_user';
28
+ /** Default bound on `onDenied` so a hung audit write cannot stall admission forever. */
29
+ exports.ON_DENIED_TIMEOUT_MS = 2_000;
61
30
  const storage = new async_hooks_1.AsyncLocalStorage();
62
- function tokenDir(baseDir) {
63
- return path.join(baseDir, 'mcp-data-tokens');
64
- }
65
- async function prepareCredentialScope(cred, options) {
66
- const token = cred.dataToken?.trim();
67
- const baseDir = options?.dataTokenDir?.trim();
68
- if (!token || !baseDir) {
69
- return cred;
31
+ /**
32
+ * Invoke `onDenied` best-effort with a hard timeout. Never throws: logging must
33
+ * not replace or delay the fail-closed rejection that follows.
34
+ */
35
+ async function invokeOnDenied(onDenied, reason, timeoutMs) {
36
+ if (!onDenied) {
37
+ return;
38
+ }
39
+ const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : exports.ON_DENIED_TIMEOUT_MS;
40
+ let timer;
41
+ try {
42
+ await Promise.race([
43
+ Promise.resolve().then(() => onDenied(reason)),
44
+ new Promise((resolve) => {
45
+ timer = setTimeout(resolve, ms);
46
+ timer.unref?.();
47
+ }),
48
+ ]);
49
+ }
50
+ catch {
51
+ // Evidence logging must never suppress or replace the fail-closed error below.
52
+ }
53
+ finally {
54
+ if (timer) {
55
+ clearTimeout(timer);
56
+ }
70
57
  }
71
- const dir = tokenDir(baseDir);
72
- await node_fs_1.promises.mkdir(dir, { recursive: true });
73
- const file = path.join(dir, `${(0, node_crypto_1.randomUUID)()}.token`);
74
- await node_fs_1.promises.writeFile(file, token, { mode: 0o600 });
75
- return { ...cred, dataTokenFile: file };
76
58
  }
77
- async function cleanupCredentialScope(cred) {
78
- if (!cred.dataTokenFile) {
79
- return;
59
+ async function checkCredentialScope(cred, options) {
60
+ const token = cred.dataToken?.trim();
61
+ // A per-turn OBO credential without a verified identity would hand a tool someone's
62
+ // live data-plane token while the turn can't attribute or scope its use to anyone —
63
+ // fail closed before the turn runs, rather than let a tool run with a real credential
64
+ // attached to an unknown/placeholder user.
65
+ if (token && !cred.userId?.trim()) {
66
+ await invokeOnDenied(options?.onDenied, exports.WARRANT_DENIED_REASON, options?.onDeniedTimeoutMs ?? exports.ON_DENIED_TIMEOUT_MS);
67
+ throw new Error(`OBO data token present without a verified user in request scope (fail-closed: ${exports.WARRANT_DENIED_REASON})`);
80
68
  }
81
- await node_fs_1.promises.unlink(cred.dataTokenFile).catch(() => undefined);
82
69
  }
83
70
  /**
84
- * Run `fn` with `cred` as the ambient request-scoped credential.
85
- * When `options.dataTokenDir` is set and `cred.dataToken` is present, writes a
86
- * turn-scoped token file for stdio MCP servers.
71
+ * Run `fn` with `cred` as the ambient request-scoped credential (memory only).
87
72
  */
88
73
  async function runWithCredential(cred, fn, options) {
89
- const scoped = await prepareCredentialScope(cred, options);
90
- return storage.run(scoped, async () => {
91
- try {
92
- return await fn();
93
- }
94
- finally {
95
- await cleanupCredentialScope(scoped);
96
- }
97
- });
74
+ await checkCredentialScope(cred, options);
75
+ return storage.run(cred, async () => fn());
98
76
  }
99
77
  /** The current turn's credential, or `undefined` outside any `runWithCredential` scope. */
100
78
  function currentCredential() {
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CredentialBroker = exports.identityProvider = exports.passthroughProvider = void 0;
7
+ exports.resolveProvider = resolveProvider;
8
+ /** Databricks Apps-style: the platform minted the downscoped per-user token already. */
9
+ exports.passthroughProvider = {
10
+ name: 'passthrough',
11
+ refreshOnInbound: true,
12
+ async acquire({ userId, inboundToken }) {
13
+ const token = inboundToken?.trim();
14
+ if (!token) {
15
+ // The passthrough provider holds nothing of its own — no inbound credential on
16
+ // this turn means no data access. Never fall back to any shared credential.
17
+ throw new Error(`No inbound data credential for user (fail-closed) — the platform did not forward a per-user token on this request (userId=${userId})`);
18
+ }
19
+ // TTL is the platform's problem; cache briefly so mid-turn calls reuse one token.
20
+ return { expiresAt: Date.now() + 10 * 60_000, secretRef: token };
21
+ },
22
+ };
23
+ /** Backends where authorization is a server-side identity mapping (Postgres RLS, SAP B1 vault). */
24
+ exports.identityProvider = {
25
+ name: 'identity',
26
+ async acquire({ userId }) {
27
+ if (!userId.trim()) {
28
+ throw new Error('No verified user (fail-closed)');
29
+ }
30
+ return { expiresAt: Number.MAX_SAFE_INTEGER, secretRef: null };
31
+ },
32
+ };
33
+ function resolveProvider(name) {
34
+ const n = (name ?? 'passthrough').trim().toLowerCase();
35
+ if (n === 'passthrough') {
36
+ return exports.passthroughProvider;
37
+ }
38
+ if (n === 'identity') {
39
+ return exports.identityProvider;
40
+ }
41
+ throw new Error(`Unknown data credential provider "${name}" (supported: passthrough, identity)`);
42
+ }
43
+ class CredentialBroker {
44
+ provider;
45
+ /** Per-user cache — plain in-memory Map, entries dropped on expiry. Never serialized. */
46
+ cache = new Map();
47
+ constructor(provider) {
48
+ this.provider = provider;
49
+ }
50
+ /**
51
+ * Server-side credential selection (secure-obo-design §5 step 7): the caller supplies
52
+ * a verified userId from the TurnRegistry — never anything client-controlled.
53
+ */
54
+ async acquire(userId, inboundToken) {
55
+ if (this.provider.refreshOnInbound) {
56
+ // Passthrough-style providers get the platform token on every legitimate request,
57
+ // so the cache must never bridge a turn that arrived WITHOUT one — that would let
58
+ // a token-less turn (broken forwarding, channel-originated) silently reuse the
59
+ // user's previous token for the cache TTL instead of failing closed. Always
60
+ // re-acquire from exactly what this turn carried. Nothing is cached: caching
61
+ // would only ever serve the token-less case this branch exists to reject.
62
+ return this.provider.acquire({ userId, inboundToken });
63
+ }
64
+ const cached = this.cache.get(userId);
65
+ if (cached && cached.expiresAt > Date.now()) {
66
+ return cached;
67
+ }
68
+ this.cache.delete(userId);
69
+ const fresh = await this.provider.acquire({ userId, inboundToken });
70
+ this.cache.set(userId, fresh);
71
+ return fresh;
72
+ }
73
+ /** Drop a user's cached credential (mid-turn 401 retry path / offboarding). */
74
+ invalidate(userId) {
75
+ this.cache.delete(userId);
76
+ }
77
+ }
78
+ exports.CredentialBroker = CredentialBroker;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.ConnectorHost = exports.TURN_META_KEY = void 0;
40
+ /**
41
+ * ConnectorHost — runs the pack's data connector as a BROKER-owned stdio MCP subprocess
42
+ * (secure-obo-design §3.1 "Data-plane MCP gateway": provider connectors live in the
43
+ * broker's trust domain, not the agent's).
44
+ *
45
+ * The connector is the same familiar shape as the old pack `server.mjs` (a stdio MCP
46
+ * server), but it is spawned by serve — never by the engine — and it receives the turn's
47
+ * credential per call over the private stdio pipe via MCP `_meta`
48
+ * (`ai.agenthippo/turn`), never via env, argv, or disk. Its own node_modules resolve
49
+ * from the connector's directory, so backend SDKs (@databricks/sql, pg, …) stay out of
50
+ * the AgentHippo CLI.
51
+ */
52
+ const path = __importStar(require("path"));
53
+ const fs_1 = require("fs");
54
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
55
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/client/stdio.js");
56
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
57
+ const resolveNodeBinary_1 = require("../../utils/resolveNodeBinary");
58
+ /** `_meta` key carrying the turn context to the connector (namespaced per MCP convention). */
59
+ exports.TURN_META_KEY = 'ai.agenthippo/turn';
60
+ class ConnectorHost {
61
+ client;
62
+ logger;
63
+ connectorPath;
64
+ constructor(client, logger, connectorPath) {
65
+ this.client = client;
66
+ this.logger = logger;
67
+ this.connectorPath = connectorPath;
68
+ }
69
+ static async spawn(opts) {
70
+ const connectorPath = path.resolve(opts.connectorPath);
71
+ await fs_1.promises.access(connectorPath);
72
+ const nodeBin = await (0, resolveNodeBinary_1.resolveNodeBinary)();
73
+ const transport = new stdio_js_1.StdioClientTransport({
74
+ command: nodeBin,
75
+ args: [connectorPath],
76
+ // cwd = connector dir so its own node_modules resolve (backend SDKs live there).
77
+ cwd: path.dirname(connectorPath),
78
+ env: { ...process.env, AGENTHIPPO_DATA_CONNECTOR: '1' },
79
+ stderr: 'pipe',
80
+ });
81
+ const client = new index_js_1.Client({ name: 'agenthippo-data-gateway', version: '1.0.0' });
82
+ await client.connect(transport);
83
+ transport.stderr?.on('data', (chunk) => {
84
+ opts.logger?.warn(`[DataPlane] connector stderr: ${String(chunk).trimEnd()}`);
85
+ });
86
+ opts.logger?.info(`[DataPlane] connector started: ${connectorPath}`);
87
+ return new ConnectorHost(client, opts.logger, connectorPath);
88
+ }
89
+ async listTools() {
90
+ const res = await this.client.listTools();
91
+ return res.tools.map((t) => ({
92
+ name: t.name,
93
+ description: t.description,
94
+ inputSchema: (t.inputSchema ?? { type: 'object' }),
95
+ }));
96
+ }
97
+ /**
98
+ * Forward a tool call with the turn context in `_meta`. The credential crosses only the
99
+ * private serve↔connector stdio pipe — both ends are the broker trust domain.
100
+ */
101
+ async callTool(name, args, turn) {
102
+ const result = await this.client.request({
103
+ method: 'tools/call',
104
+ params: {
105
+ name,
106
+ arguments: args,
107
+ _meta: { [exports.TURN_META_KEY]: { userId: turn.userId, credential: turn.credential } },
108
+ },
109
+ }, types_js_1.CallToolResultSchema);
110
+ return result;
111
+ }
112
+ async close() {
113
+ try {
114
+ await this.client.close();
115
+ }
116
+ catch (e) {
117
+ this.logger?.warn(`[DataPlane] connector close: ${e instanceof Error ? e.message : String(e)}`);
118
+ }
119
+ }
120
+ }
121
+ exports.ConnectorHost = ConnectorHost;
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.EGRESS_URL_ENV = void 0;
40
+ exports.startEgressBroker = startEgressBroker;
41
+ /**
42
+ * Egress broker — the outbound mirror of the data-plane gateway (secure-obo-design §7.6).
43
+ *
44
+ * The agent-anywhere channel token (OpenClaw bot/gateway token) is the exfiltration key §2
45
+ * forbids the agent from holding: with it in the agent env, a prompt-injected pack talks to
46
+ * the gateway RPC directly and bypasses the reply-to-sender handle allowlist (§7.4 gap 1).
47
+ *
48
+ * This broker interposes the same shape as the data plane: the agent presents ONLY the
49
+ * per-turn nonce (the SAME `AGENTHIPPO_TURN_NONCE` the data plane uses — one TurnRegistry
50
+ * entry grants both capabilities, no second secret) and a message. The broker:
51
+ *
52
+ * 1. resolves the nonce → verified {userId, sessionKey, egressStoreKey} (off-turn/expired
53
+ * nonce ⇒ 401, fail closed);
54
+ * 2. loads THAT session's outbound handle store SERVER-SIDE (never a client-supplied path);
55
+ * 3. resolves the destination from the session's already-bound handles — reply-to-sender.
56
+ * `to` may only SELECT AMONG bound handles, never name a new target (§7.6 step 4);
57
+ * 4. calls the real OpenClaw `send` with the BROKER-held token — which never entered the
58
+ * agent's env.
59
+ *
60
+ * What a stolen egress nonce buys (§7.6): exactly what the agent could already do this turn —
61
+ * reply to its own session's bound peers, on-box, until the turn ends. It cannot address a new
62
+ * peer (`user:attacker` never resolves), reach another session's handles (server-side scoping),
63
+ * or reach OpenClaw directly (no token in its env). That is the §3.3 property, for egress.
64
+ */
65
+ const http = __importStar(require("http"));
66
+ const serve_hardening_1 = require("../serve-hardening");
67
+ const registry_1 = require("./registry");
68
+ const outboundHandles_1 = require("../../gateway/outboundHandles");
69
+ const outboundPaths_1 = require("../../gateway/outboundPaths");
70
+ /** Loopback URL of the egress broker; set into the engine child env per boot (turn-invariant). */
71
+ exports.EGRESS_URL_ENV = 'AGENTHIPPO_EGRESS_URL';
72
+ function bearerToken(header) {
73
+ const m = header?.match(/^Bearer\s+(\S+)$/i);
74
+ return m?.[1];
75
+ }
76
+ function noncePrefix(nonce) {
77
+ return nonce ? (0, registry_1.hashNonce)(nonce).slice(0, 12) : 'none';
78
+ }
79
+ /**
80
+ * The session's outbound store lives at a server-derived path — the caller can NEVER name it.
81
+ * The gateway bridge records reply handles under the raw (un-namespaced) gateway key, so the
82
+ * store dir id is that key (see serve.ts resolveGatewaySessionKey `outboundKey`).
83
+ */
84
+ function storePathForTurn(workspaceRoot, egressStoreKey) {
85
+ return (0, outboundPaths_1.getGatewayOutboundFilePath)(workspaceRoot, (0, outboundPaths_1.safeGatewaySessionDirId)(egressStoreKey));
86
+ }
87
+ async function startEgressBroker(opts) {
88
+ const server = http.createServer(async (req, res) => {
89
+ const reject = (code, message, auditEvent, fields) => {
90
+ if (auditEvent) {
91
+ opts.audit?.('unauthorized', auditEvent, fields ?? {});
92
+ }
93
+ res.writeHead(code, { 'Content-Type': 'application/json' });
94
+ res.end(JSON.stringify({ ok: false, error: message }));
95
+ };
96
+ try {
97
+ const urlPath = req.url?.split('?')[0] ?? '';
98
+ if (urlPath !== '/egress/send') {
99
+ reject(404, 'not found');
100
+ return;
101
+ }
102
+ if (req.method !== 'POST') {
103
+ res.writeHead(405, { 'Content-Type': 'application/json', 'Allow': 'POST' });
104
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
105
+ return;
106
+ }
107
+ const nonce = bearerToken(req.headers.authorization);
108
+ const turn = nonce ? opts.registry.resolve(nonce) : undefined;
109
+ if (!turn) {
110
+ // Off-turn / expired / missing — a stale or injected caller fails CLOSED, exactly
111
+ // like the data-plane gateway (§3.2). No token in the agent env to fall back on.
112
+ reject(401, 'Unauthorized: missing, unknown, or expired turn nonce', 'egress_nonce_reject', {
113
+ reason: nonce ? 'unknown_or_expired' : 'missing',
114
+ nonce_sha256_prefix: noncePrefix(nonce),
115
+ });
116
+ return;
117
+ }
118
+ // A channel turn carries the outbound store key; a bare data turn (HTTP /v1/chat, no
119
+ // channel) has none — nothing to reply to, so egress is not available to it.
120
+ const storeKey = turn.egressStoreKey?.trim();
121
+ if (!storeKey) {
122
+ reject(403, 'no outbound route for this turn (not a channel-originated session)', 'egress_deny', { userId: turn.userId, reason: 'no_store_key' });
123
+ return;
124
+ }
125
+ let body;
126
+ try {
127
+ body = JSON.parse(await (0, serve_hardening_1.readBodyWithCap)(req));
128
+ }
129
+ catch {
130
+ reject(400, 'invalid JSON body');
131
+ return;
132
+ }
133
+ const message = typeof body.message === 'string' ? body.message : '';
134
+ if (!message.trim()) {
135
+ reject(400, 'message is required');
136
+ return;
137
+ }
138
+ // `to` is a SELECTOR among bound handles only — never a new destination (§7.6 step 4).
139
+ const to = typeof body.to === 'string' ? body.to.trim() : undefined;
140
+ const media = typeof body.media === 'string' ? body.media.trim() || undefined : undefined;
141
+ // Server-side resolution: the store path is derived from the VERIFIED turn, never the
142
+ // request. `resolveGatewayOutboundHandle` returns only a handle bound by a prior
143
+ // inbound message (reply-to-sender); an unknown `to` matches nothing ⇒ undefined.
144
+ const storePath = storePathForTurn(opts.workspaceRoot, storeKey);
145
+ let handle;
146
+ try {
147
+ handle = await (0, outboundHandles_1.resolveGatewayOutboundHandle)(storePath, opts.agentId, to ? { target: to } : undefined);
148
+ }
149
+ catch (err) {
150
+ opts.logger?.warn(`[Egress] store read failed: ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ if (!handle) {
153
+ // No bound destination — a prompt-injected `to: user:attacker` lands here.
154
+ reject(404, to
155
+ ? `no bound handle matches "${to}" for this session (cannot address a new destination)`
156
+ : 'no reply destination bound for this session yet', 'egress_deny', { userId: turn.userId, reason: 'no_matching_handle', to: to ?? '(reply-to-sender)' });
157
+ return;
158
+ }
159
+ opts.audit?.(turn.sessionKey, 'egress_authorize', {
160
+ userId: turn.userId, channel: handle.channel, target: handle.target,
161
+ });
162
+ const result = await opts.send(handle, message, media);
163
+ res.writeHead(result.ok ? 200 : 502, { 'Content-Type': 'application/json' });
164
+ res.end(JSON.stringify(result));
165
+ }
166
+ catch (err) {
167
+ opts.logger?.warn(`[Egress] broker error: ${err instanceof Error ? err.message : String(err)}`);
168
+ if (!res.headersSent) {
169
+ res.writeHead(500, { 'Content-Type': 'application/json' });
170
+ }
171
+ res.end(JSON.stringify({ ok: false, error: 'Internal error' }));
172
+ }
173
+ });
174
+ await new Promise((resolve, reject) => {
175
+ server.once('error', reject);
176
+ // Loopback ONLY — the broker must never be reachable off-box.
177
+ server.listen(0, '127.0.0.1', () => resolve());
178
+ });
179
+ const address = server.address();
180
+ if (!address || typeof address === 'string') {
181
+ throw new Error('Egress broker failed to bind a loopback port');
182
+ }
183
+ const url = `http://127.0.0.1:${address.port}/egress/send`;
184
+ opts.logger?.info(`[Egress] broker listening at ${url}`);
185
+ return {
186
+ url,
187
+ close: () => new Promise((resolve) => server.close(() => resolve())),
188
+ };
189
+ }