@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.
- package/out/agentpack/index.js +2 -1
- package/out/agentpack/paths.js +23 -0
- package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
- package/out/analytics/dashboard/webview/panel.js +290 -80
- package/out/analytics/sessionEvents.js +21 -4
- package/out/auth/brokerClient.js +1 -1
- package/out/auth/onboardingPrompt.js +112 -0
- package/out/auth/provider.js +1 -1
- package/out/auth-broker/config.js +8 -3
- package/out/auth-broker/pkce.js +3 -1
- package/out/auth-broker/server.js +43 -1
- package/out/auth-broker/store.js +6 -5
- package/out/cli/commands/ask.js +3 -0
- package/out/cli/commands/chat.js +3 -0
- package/out/cli/commands/deploy.js +49 -10
- package/out/cli/commands/gateway.js +54 -0
- package/out/cli/commands/idp.js +85 -0
- package/out/cli/commands/serve.js +401 -49
- package/out/cli/credential-context.js +47 -69
- package/out/cli/data-plane/broker.js +78 -0
- package/out/cli/data-plane/connector-host.js +121 -0
- package/out/cli/data-plane/egress.js +189 -0
- package/out/cli/data-plane/gateway.js +195 -0
- package/out/cli/data-plane/index.js +137 -0
- package/out/cli/data-plane/registry.js +80 -0
- package/out/cli/deploy/blueprints/registry.js +84 -0
- package/out/cli/deploy/layers/identity.js +91 -0
- package/out/cli/deploy-init.js +1156 -285
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +46 -8
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +217 -12
- package/out/cli/state-sync.js +318 -29
- package/out/engines/claude.js +26 -5
- package/out/engines/codex.js +24 -5
- package/out/engines/custom.js +46 -12
- package/out/extension.js +2 -0
- package/out/gateway/outboundSend.js +6 -6
- package/out/headless/index.js +24 -6
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +88 -61
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +5 -2
- package/out/test/analytics.test.js +93 -0
- package/out/test/auth-broker.test.js +59 -2
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +55 -0
- package/out/test/custom-env-layering.test.js +151 -0
- package/out/test/data-plane-engine-wiring.test.js +250 -0
- package/out/test/data-plane.test.js +244 -0
- package/out/test/deploy-init.test.js +287 -5
- package/out/test/egress-broker.test.js +172 -0
- package/out/test/fleetDisplayUtils.test.js +4 -0
- package/out/test/idp-init.test.js +151 -0
- package/out/test/mcp-obo.test.js +52 -27
- package/out/test/mcp.test.js +60 -4
- package/out/test/model-routing.test.js +133 -0
- package/out/test/serve-gateway-turn.test.js +267 -0
- package/out/test/serve-hardening.test.js +187 -0
- package/out/test/serveClient.test.js +37 -2
- package/out/test/state-sync-peer.test.js +83 -1
- package/out/utils.js +27 -11
- 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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
return storage.run(
|
|
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
|
+
}
|