@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.
- 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 -9
- 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/server.js +36 -0
- 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 +315 -55
- package/out/cli/credential-context.js +42 -80
- 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 +1105 -335
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +37 -7
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +60 -1
- package/out/cli/state-sync.js +16 -15
- 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 -9
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +85 -67
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +2 -2
- package/out/test/analytics.test.js +42 -17
- package/out/test/auth-broker.test.js +53 -0
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +23 -28
- 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 +255 -4
- 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 +43 -3
- 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 +80 -0
- package/out/test/serveClient.test.js +32 -0
- package/out/test/state-sync-peer.test.js +18 -5
- package/out/utils.js +27 -11
- package/package.json +1 -1
|
@@ -37,18 +37,24 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
37
37
|
})();
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.serveCommand = serveCommand;
|
|
40
|
+
exports.resolveGatewaySessionKey = resolveGatewaySessionKey;
|
|
41
|
+
exports.runGatewayTurn = runGatewayTurn;
|
|
40
42
|
/**
|
|
41
43
|
* Serve Command - HTTP Server
|
|
42
44
|
*/
|
|
43
45
|
const fs = __importStar(require("fs"));
|
|
44
46
|
const http = __importStar(require("http"));
|
|
45
47
|
const path = __importStar(require("path"));
|
|
48
|
+
const node_crypto_1 = require("node:crypto");
|
|
46
49
|
const headless_1 = require("../../headless");
|
|
47
50
|
const pack_loader_1 = require("../pack-loader");
|
|
48
51
|
const utils_1 = require("../utils");
|
|
49
52
|
const runtime_1 = require("../runtime");
|
|
50
53
|
const serve_hardening_1 = require("../serve-hardening");
|
|
51
54
|
const credential_context_1 = require("../credential-context");
|
|
55
|
+
const data_plane_1 = require("../data-plane");
|
|
56
|
+
const runtime_2 = require("../runtime");
|
|
57
|
+
const outboundSend_1 = require("../../gateway/outboundSend");
|
|
52
58
|
const sessionEvents_1 = require("../../analytics/sessionEvents");
|
|
53
59
|
const base_1 = require("../../engines/base");
|
|
54
60
|
const state_sync_1 = require("../state-sync");
|
|
@@ -81,8 +87,11 @@ async function serveCommand(options) {
|
|
|
81
87
|
const defaultModel = (0, headless_1.resolveModelAlias)(rawModel);
|
|
82
88
|
const rawCheapModel = pack.manifest.spec.cheapModel?.trim();
|
|
83
89
|
const claudeCheapModel = rawCheapModel ? (0, headless_1.resolveModelAlias)(rawCheapModel) : undefined;
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// Engine-aware: a codex deploy needs an OpenAI (or LiteLLM) key; a claude deploy needs an
|
|
91
|
+
// Anthropic (or LiteLLM) key. Fail loudly at boot rather than 401 at the first model call.
|
|
92
|
+
if (!(0, pack_loader_1.hasCredentialForEngine)(defaultEngine, apiConfig)) {
|
|
93
|
+
const need = defaultEngine === 'claude' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
|
|
94
|
+
console.error(`Error: No usable API key for engine "${defaultEngine}". Set ${need}, or configure the LiteLLM proxy (AGENTHIPPO_LITELLM_API_KEY + AGENTHIPPO_LITELLM_BASE_URL).`);
|
|
86
95
|
for (const line of (0, utils_1.getLiteLLMQuickstartHintLines)()) {
|
|
87
96
|
console.error(line);
|
|
88
97
|
}
|
|
@@ -92,14 +101,67 @@ async function serveCommand(options) {
|
|
|
92
101
|
const bundledPromptsDir = (0, utils_1.findBundledBasePromptsDir)();
|
|
93
102
|
await (0, headless_1.syncPackToWorkspace)(options.workspace, pack.packPath, packId, defaultEngine, syncLogger, bundledPromptsDir);
|
|
94
103
|
console.log(`OK: Loaded agent: ${pack.manifest.metadata.name} v${pack.manifest.metadata.version}`);
|
|
104
|
+
// Structural attribution (pack-standard WS2) — greppable for CISO/SRE boot audits.
|
|
105
|
+
console.log(`agenthippo pack=${pack.manifest.metadata.name}@${pack.manifest.metadata.version} engine=${defaultEngine}`);
|
|
95
106
|
// Resolve persistent session state (serve-v3 §A1: replaces the tmpdir default).
|
|
96
107
|
const { dir: storagePath, ephemeral } = (0, serve_hardening_1.resolveStateDir)(options.stateDir);
|
|
97
108
|
const bindHost = (0, serve_hardening_1.resolveBindHost)(options.host);
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// precedes
|
|
101
|
-
|
|
109
|
+
// Passed to createHeadlessRunner below so the runner's own SessionEventWriter uses the same
|
|
110
|
+
// root — a fail-closed warrant denial lands in the same per-session JSONL as the tool events
|
|
111
|
+
// it precedes. The denial happens before runner.run() is ever called, so it needs its own
|
|
112
|
+
// writer here too. See resolveAnalyticsSessionsDir's docstring for the precedence.
|
|
113
|
+
const analyticsSessionsDir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)(storagePath, ephemeral);
|
|
114
|
+
const sessionEventWriter = new sessionEvents_1.SessionEventWriter(analyticsSessionsDir);
|
|
102
115
|
const logWarrantDenied = (sessionKey) => (reason) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), 'warrant_denied', { reason });
|
|
116
|
+
// Data-plane gateway (secure-obo-design §3): when the pack declares spec.dataPlane,
|
|
117
|
+
// serve runs the connector broker-side and engines get a per-turn nonce instead of the
|
|
118
|
+
// credential. The raw data credential never crosses into the agent's trust domain.
|
|
119
|
+
const dataPlaneCfg = (0, data_plane_1.resolveDataPlaneConfig)(pack.manifest.spec, pack.packPath);
|
|
120
|
+
// One shared registry: a single per-turn nonce grants BOTH the data-plane and egress
|
|
121
|
+
// capabilities (secure-obo-design §7.6 step 2). Created when either broker will run.
|
|
122
|
+
const turnRegistry = (dataPlaneCfg || options.gatewayBridge) ? new data_plane_1.TurnRegistry() : null;
|
|
123
|
+
const auditEvent = (sessionKey, event, fields) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), event, fields);
|
|
124
|
+
let dataPlane = null;
|
|
125
|
+
if (dataPlaneCfg) {
|
|
126
|
+
try {
|
|
127
|
+
dataPlane = await data_plane_1.DataPlane.start(dataPlaneCfg, {
|
|
128
|
+
logger: options.verbose ? new headless_1.ConsoleLogger('[DataPlane]') : new headless_1.NoOpLogger(),
|
|
129
|
+
audit: auditEvent,
|
|
130
|
+
registry: turnRegistry ?? undefined,
|
|
131
|
+
});
|
|
132
|
+
// Boot-stable process env: engines resolve ${env:AGENTHIPPO_DATA_MCP_URL} from it,
|
|
133
|
+
// so codex's config.toml stays byte-identical across turns. The NONCE is never set
|
|
134
|
+
// here — it rides the per-spawn child env only.
|
|
135
|
+
process.env[data_plane_1.DATA_MCP_URL_ENV] = dataPlane.url;
|
|
136
|
+
console.log(`OK: Data gateway (provider=${dataPlane.broker.provider.name}) on loopback — connector: ${dataPlaneCfg.connectorPath}`);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
console.error(`${utils_1.COLORS.red}Error: failed to start the data-plane gateway:${utils_1.COLORS.reset} ${err instanceof Error ? err.message : err}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Egress broker (secure-obo-design §7.6): capture the gateway token broker-side BEFORE any
|
|
144
|
+
// engine spawns, so it can be stripped from the process env the children inherit. The broker
|
|
145
|
+
// starts alongside the gateway bridge below; the token is held only in this closure.
|
|
146
|
+
const brokerGatewayToken = options.gatewayBridge ? (0, runtime_2.readOpenClawGatewayToken)() : undefined;
|
|
147
|
+
let egressBroker = null;
|
|
148
|
+
// Mint one nonce per turn and assemble the per-spawn child env. Returns null outside any
|
|
149
|
+
// broker (no capability to grant). The URLs ride process.env (boot-stable) for config
|
|
150
|
+
// invariance; only the nonce is per-spawn.
|
|
151
|
+
const registerServeTurn = (reg) => {
|
|
152
|
+
if (!turnRegistry) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const { nonce, release } = turnRegistry.register(reg);
|
|
156
|
+
const env = { [data_plane_1.TURN_NONCE_ENV]: nonce };
|
|
157
|
+
if (dataPlane) {
|
|
158
|
+
env[data_plane_1.DATA_MCP_URL_ENV] = dataPlane.url;
|
|
159
|
+
}
|
|
160
|
+
if (egressBroker) {
|
|
161
|
+
env[data_plane_1.EGRESS_URL_ENV] = egressBroker.url;
|
|
162
|
+
}
|
|
163
|
+
return { nonce, env, release };
|
|
164
|
+
};
|
|
103
165
|
const turnLimiter = new serve_hardening_1.TurnLimiter(options.maxTurnsPerSession ?? 0);
|
|
104
166
|
// Per-identity request-rate cap (req/min). Keyed on the trusted user id (falling back to
|
|
105
167
|
// client IP) so rotating session_id can't bypass it. AGENTHIPPO_SERVE_RATE_LIMIT=0 disables.
|
|
@@ -174,12 +236,18 @@ async function serveCommand(options) {
|
|
|
174
236
|
};
|
|
175
237
|
const runner = (0, headless_1.createHeadlessRunner)({
|
|
176
238
|
storagePath,
|
|
239
|
+
analyticsSessionsDir,
|
|
177
240
|
packId,
|
|
178
241
|
packVersion: pack?.manifest.metadata.version,
|
|
179
242
|
bundledPromptsDir,
|
|
180
243
|
apiKey: apiConfig.apiKey,
|
|
181
244
|
baseUrl: apiConfig.baseUrl,
|
|
182
245
|
useLiteLLM: apiConfig.useLiteLLM,
|
|
246
|
+
// Per-destination keys so each engine authenticates to the endpoint it actually dials
|
|
247
|
+
// (codex→OpenAI, claude→Anthropic, or the LiteLLM proxy) — no provider-blind collapse.
|
|
248
|
+
litellmApiKey: apiConfig.litellmApiKey,
|
|
249
|
+
openaiApiKey: apiConfig.openaiApiKey,
|
|
250
|
+
anthropicApiKey: apiConfig.anthropicApiKey,
|
|
183
251
|
logger,
|
|
184
252
|
freshState: false,
|
|
185
253
|
claudeCodeExecutablePath: claudeCodePath,
|
|
@@ -246,7 +314,15 @@ async function serveCommand(options) {
|
|
|
246
314
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
247
315
|
return;
|
|
248
316
|
}
|
|
249
|
-
|
|
317
|
+
// User-namespaced session key (secure-obo-design §3.4): kills cross-user session
|
|
318
|
+
// attachment via a guessed session_id. Fails closed on pinned-identity deployments.
|
|
319
|
+
const { sessionKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId);
|
|
320
|
+
if (rejected) {
|
|
321
|
+
sessionEventWriter.append('unverified', (0, base_1.generateRequestId)(), 'warrant_denied', { reason: rejected });
|
|
322
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
323
|
+
res.end(JSON.stringify({ error: 'session_id requires an authenticated user' }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
250
326
|
// Acquire before turn-limiter so a capacity reject does not burn a turn slot.
|
|
251
327
|
const release = await turnGate.acquire();
|
|
252
328
|
if (!release) {
|
|
@@ -258,6 +334,10 @@ async function serveCommand(options) {
|
|
|
258
334
|
res.end(JSON.stringify({ error: 'At capacity' }));
|
|
259
335
|
return;
|
|
260
336
|
}
|
|
337
|
+
// Turn-scoped data capability: mint a nonce bound to the verified user; the
|
|
338
|
+
// engine gets it via per-spawn env only. Deleted in the finally — a stale
|
|
339
|
+
// engine/tool fails closed at the gateway after that (secure-obo-design §3.1).
|
|
340
|
+
let turnReg = null;
|
|
261
341
|
try {
|
|
262
342
|
if (!turnLimiter.tryConsume(sessionKey)) {
|
|
263
343
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
@@ -265,12 +345,16 @@ async function serveCommand(options) {
|
|
|
265
345
|
return;
|
|
266
346
|
}
|
|
267
347
|
await prepareSession(sessionKey);
|
|
268
|
-
|
|
348
|
+
if (userId?.trim()) {
|
|
349
|
+
turnReg = registerServeTurn({ userId: userId.trim(), sessionKey, inboundToken: dataToken });
|
|
350
|
+
}
|
|
351
|
+
const result = await (0, credential_context_1.runWithCredential)({ userId, dataToken }, () => handleChat(runner, body, sessionKey, defaultEngine, defaultModel, options.workspace, permissions, userId, turnReg?.env), { onDenied: logWarrantDenied(sessionKey) });
|
|
269
352
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
270
353
|
res.end(JSON.stringify(result));
|
|
271
354
|
statePusher?.schedule(sessionKey); // per-session delta push, off the response path
|
|
272
355
|
}
|
|
273
356
|
finally {
|
|
357
|
+
turnReg?.release();
|
|
274
358
|
release();
|
|
275
359
|
}
|
|
276
360
|
return;
|
|
@@ -288,7 +372,14 @@ async function serveCommand(options) {
|
|
|
288
372
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
289
373
|
return;
|
|
290
374
|
}
|
|
291
|
-
|
|
375
|
+
// Same user-namespaced session key + fail-closed rule as /v1/chat above.
|
|
376
|
+
const { sessionKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId);
|
|
377
|
+
if (rejected) {
|
|
378
|
+
sessionEventWriter.append('unverified', (0, base_1.generateRequestId)(), 'warrant_denied', { reason: rejected });
|
|
379
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
380
|
+
res.end(JSON.stringify({ error: 'session_id requires an authenticated user' }));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
292
383
|
// Acquire BEFORE writeHead(200, SSE) so a reject still gets a clean 429.
|
|
293
384
|
const release = await turnGate.acquire();
|
|
294
385
|
if (!release) {
|
|
@@ -300,6 +391,8 @@ async function serveCommand(options) {
|
|
|
300
391
|
res.end(JSON.stringify({ error: 'At capacity' }));
|
|
301
392
|
return;
|
|
302
393
|
}
|
|
394
|
+
// Turn-scoped data capability — same rules as /v1/chat above.
|
|
395
|
+
let turnReg = null;
|
|
303
396
|
try {
|
|
304
397
|
if (!turnLimiter.tryConsume(sessionKey)) {
|
|
305
398
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
@@ -307,15 +400,25 @@ async function serveCommand(options) {
|
|
|
307
400
|
return;
|
|
308
401
|
}
|
|
309
402
|
await prepareSession(sessionKey);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
403
|
+
if (userId?.trim()) {
|
|
404
|
+
turnReg = registerServeTurn({ userId: userId.trim(), sessionKey, inboundToken: dataToken });
|
|
405
|
+
}
|
|
406
|
+
// Fail-closed credential check MUST run before SSE writeHead(200).
|
|
407
|
+
// Otherwise a warrant denial lands after headers are committed, the outer
|
|
408
|
+
// catch can only append raw JSON (not an SSE error event), and the IDE
|
|
409
|
+
// client's SSE parser falls through to emitter.done() → silent success.
|
|
410
|
+
await (0, credential_context_1.runWithCredential)({ userId, dataToken }, async () => {
|
|
411
|
+
res.writeHead(200, {
|
|
412
|
+
'Content-Type': 'text/event-stream',
|
|
413
|
+
'Cache-Control': 'no-cache',
|
|
414
|
+
'Connection': 'keep-alive',
|
|
415
|
+
});
|
|
416
|
+
await handleStreamingChat(runner, body, sessionKey, defaultEngine, defaultModel, options.workspace, permissions, res, userId, turnReg?.env);
|
|
417
|
+
}, { onDenied: logWarrantDenied(sessionKey) });
|
|
316
418
|
statePusher?.schedule(sessionKey); // per-session delta push, off the response path
|
|
317
419
|
}
|
|
318
420
|
finally {
|
|
421
|
+
turnReg?.release();
|
|
319
422
|
release();
|
|
320
423
|
}
|
|
321
424
|
return;
|
|
@@ -357,7 +460,7 @@ async function serveCommand(options) {
|
|
|
357
460
|
}
|
|
358
461
|
console.log(`Workspace: ${options.workspace}`);
|
|
359
462
|
if (pack) {
|
|
360
|
-
console.log(`Pack: ${pack.manifest.metadata.name}`);
|
|
463
|
+
console.log(`Pack: ${pack.manifest.metadata.name}@${pack.manifest.metadata.version}`);
|
|
361
464
|
console.log(`Gateway: ${gatewayAgentId}`);
|
|
362
465
|
}
|
|
363
466
|
console.log(BANNER_LINE);
|
|
@@ -380,17 +483,46 @@ async function serveCommand(options) {
|
|
|
380
483
|
}
|
|
381
484
|
}
|
|
382
485
|
if (options.gatewayBridge) {
|
|
486
|
+
// Egress broker (secure-obo-design §7.6): start before the bridge so the URL is in
|
|
487
|
+
// process.env when the first channel turn spawns an engine. The broker holds the
|
|
488
|
+
// gateway token; the agent presents only the turn nonce.
|
|
489
|
+
if (turnRegistry) {
|
|
490
|
+
try {
|
|
491
|
+
egressBroker = await (0, data_plane_1.startEgressBroker)({
|
|
492
|
+
registry: turnRegistry,
|
|
493
|
+
workspaceRoot: options.workspace,
|
|
494
|
+
agentId: pack.manifest.metadata.name,
|
|
495
|
+
send: async (handle, message, media) => {
|
|
496
|
+
const r = await (0, outboundSend_1.sendGatewayOutboundMessage)(handle, { message, media, token: brokerGatewayToken });
|
|
497
|
+
return { ok: r.ok, via: r.via, stdout: r.stdout, stderr: r.stderr };
|
|
498
|
+
},
|
|
499
|
+
logger: options.verbose ? new headless_1.ConsoleLogger('[Egress]') : new headless_1.NoOpLogger(),
|
|
500
|
+
audit: auditEvent,
|
|
501
|
+
});
|
|
502
|
+
process.env[data_plane_1.EGRESS_URL_ENV] = egressBroker.url;
|
|
503
|
+
// Now that the broker holds the token, strip it from the process env so no
|
|
504
|
+
// engine child (all spread process.env) inherits it (§7.4 gap 1 close).
|
|
505
|
+
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
506
|
+
console.log('OK: Egress broker on loopback — gateway token stays broker-side; agent sends via turn nonce');
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
console.error(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} Egress broker failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
383
512
|
try {
|
|
384
513
|
gatewayBridge = await startServeGatewayBridge({
|
|
385
514
|
gatewayAgentId,
|
|
386
515
|
packName: pack.manifest.metadata.name,
|
|
387
516
|
workspace: options.workspace,
|
|
388
|
-
|
|
517
|
+
registerTurn: registerServeTurn,
|
|
518
|
+
brokerGatewayToken,
|
|
389
519
|
engine: defaultEngine,
|
|
390
520
|
defaultModel,
|
|
391
521
|
runner,
|
|
392
522
|
permissions,
|
|
393
523
|
turnGate,
|
|
524
|
+
rateLimiter,
|
|
525
|
+
turnLimiter,
|
|
394
526
|
warnGateReject,
|
|
395
527
|
prepareSession,
|
|
396
528
|
onTurnComplete: (sessionId) => statePusher?.schedule(sessionId),
|
|
@@ -432,6 +564,22 @@ async function serveCommand(options) {
|
|
|
432
564
|
}
|
|
433
565
|
gatewayBridge = null;
|
|
434
566
|
}
|
|
567
|
+
if (dataPlane) {
|
|
568
|
+
try {
|
|
569
|
+
await dataPlane.close();
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// best-effort
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (egressBroker) {
|
|
576
|
+
try {
|
|
577
|
+
await egressBroker.close();
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// best-effort
|
|
581
|
+
}
|
|
582
|
+
}
|
|
435
583
|
server.close(() => {
|
|
436
584
|
runner.dispose();
|
|
437
585
|
process.exit(0);
|
|
@@ -455,15 +603,18 @@ async function parseBody(req) {
|
|
|
455
603
|
throw new Error('Invalid JSON');
|
|
456
604
|
}
|
|
457
605
|
}
|
|
458
|
-
async function handleChat(runner, body, defaultEngine, defaultModel, workspace, permissions, userId) {
|
|
606
|
+
async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, userId, turnEnv) {
|
|
459
607
|
const result = await runner.run({
|
|
460
608
|
engineId: resolveRequestedEngineId(body.engine, defaultEngine),
|
|
461
609
|
modelId: body.model || defaultModel,
|
|
462
610
|
message: body.message,
|
|
463
611
|
workspaceRoot: workspace,
|
|
464
|
-
|
|
612
|
+
// The user-namespaced key (never raw body.session_id) so engine conversation
|
|
613
|
+
// state and per-session artifacts are per-user — see resolveServeSessionKey.
|
|
614
|
+
sessionId: sessionKey,
|
|
465
615
|
analyticsUserId: userId,
|
|
466
616
|
permissions,
|
|
617
|
+
turnEnv,
|
|
467
618
|
});
|
|
468
619
|
return {
|
|
469
620
|
success: result.success,
|
|
@@ -473,6 +624,85 @@ async function handleChat(runner, body, defaultEngine, defaultModel, workspace,
|
|
|
473
624
|
error: result.error,
|
|
474
625
|
};
|
|
475
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* Session key for a gateway turn. Prefers the caller's own session identifiers; when the
|
|
629
|
+
* sender has a resolved identity but no session id, keys off that identity (stable, no
|
|
630
|
+
* collision risk — it's unique to that sender). Only when NEITHER a session id nor an
|
|
631
|
+
* identity is available does this fall back to a per-message unique key, rather than a
|
|
632
|
+
* shared 'gateway-anon' bucket — a shared bucket would let two unrelated, genuinely
|
|
633
|
+
* unidentified senders share one TurnLimiter budget and, when AGENTHIPPO_STATE_S3_URI is
|
|
634
|
+
* set, one S3 session-state prefix (ensureSessionLocal/pushSessionState), letting one
|
|
635
|
+
* anonymous sender's state be restored into another's turn.
|
|
636
|
+
*
|
|
637
|
+
* `durable` is false only for that one-off fallback key. A key that is by construction
|
|
638
|
+
* never reused can never hit its own TurnLimiter cap and can never have prior S3 state to
|
|
639
|
+
* restore or a future turn to restore it for — callers should skip per-session tracking
|
|
640
|
+
* (TurnLimiter, prepareSession/ensureSessionLocal, onTurnComplete/statePusher.schedule) for
|
|
641
|
+
* it entirely, rather than accumulate a permanent, never-useful entry in those stores.
|
|
642
|
+
*
|
|
643
|
+
* `sessionKey` is namespaced by the resolved sender identity when an explicit session id is
|
|
644
|
+
* supplied (secure-obo-design §3.4 — a caller-supplied id must not attach to another
|
|
645
|
+
* sender's engine session). `outboundKey` preserves the raw gateway key: the bridge records
|
|
646
|
+
* reply handles under it (bridge.ts upsertGatewayOutboundHandle), so the outbound-routing
|
|
647
|
+
* file must keep resolving from the un-namespaced key or reply-to-sender breaks.
|
|
648
|
+
*/
|
|
649
|
+
function resolveGatewaySessionKey(opts, userId) {
|
|
650
|
+
const explicit = opts.sessionId?.trim() || opts.gatewaySessionKey?.trim();
|
|
651
|
+
if (explicit) {
|
|
652
|
+
const sessionKey = userId ? `u${(0, serve_hardening_1.userNamespaceHash)(userId)}--${explicit}` : explicit;
|
|
653
|
+
return { sessionKey, outboundKey: explicit, durable: true };
|
|
654
|
+
}
|
|
655
|
+
if (userId) {
|
|
656
|
+
// Already keyed on the sender's own identity — no cross-user attachment surface.
|
|
657
|
+
return { sessionKey: `gateway-${userId}`, outboundKey: `gateway-${userId}`, durable: true };
|
|
658
|
+
}
|
|
659
|
+
// randomUUID (122 bits), not generateRequestId (~32 bits) — this is the sole thing
|
|
660
|
+
// standing between two unrelated anonymous senders and a real collision, so it needs to
|
|
661
|
+
// be architecturally collision-free, not just statistically unlikely.
|
|
662
|
+
const anon = `gateway-anon-${(0, node_crypto_1.randomUUID)()}`;
|
|
663
|
+
return { sessionKey: anon, outboundKey: anon, durable: false };
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Gateway-channel turn admission: rate limit → turn gate (capacity) → per-session turn cap,
|
|
667
|
+
* then run. Extracted from the gateway bridge's messageHandler so this is directly testable
|
|
668
|
+
* without a live gateway connection — this exact sequence (mirroring the HTTP endpoints'
|
|
669
|
+
* admission order) previously didn't exist for gateway-originated turns at all: channel
|
|
670
|
+
* messages skipped both the identity-keyed rate limiter and the per-session turn cap.
|
|
671
|
+
* Gateway channels can't receive an HTTP 429, so each rejection emits a short channel
|
|
672
|
+
* message instead (best-effort — a channel-notice failure must not mask the rejection).
|
|
673
|
+
*/
|
|
674
|
+
async function runGatewayTurn(params) {
|
|
675
|
+
const notify = async (text) => {
|
|
676
|
+
try {
|
|
677
|
+
await params.emitter?.text(text);
|
|
678
|
+
await params.emitter?.done();
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// best-effort channel notice
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
if (!params.rateLimiter.tryConsume(params.rlKey)) {
|
|
685
|
+
await notify(serve_hardening_1.RATE_LIMIT_BUSY_MESSAGE);
|
|
686
|
+
return { success: false, error: 'rate_limited' };
|
|
687
|
+
}
|
|
688
|
+
// Acquire before the turn-limiter so a capacity reject does not burn a turn slot.
|
|
689
|
+
const release = await params.turnGate.acquire();
|
|
690
|
+
if (!release) {
|
|
691
|
+
params.warnGateReject(`TurnGate: rejecting gateway turn (inflight=${params.turnGate.inflight} queued=${params.turnGate.queued} max=${params.turnGate.max})`);
|
|
692
|
+
await notify(serve_hardening_1.TURN_GATE_BUSY_MESSAGE);
|
|
693
|
+
return { success: false, error: 'busy' };
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
if (params.trackSession !== false && !params.turnLimiter.tryConsume(params.sessionKey)) {
|
|
697
|
+
await notify(serve_hardening_1.TURN_LIMIT_BUSY_MESSAGE);
|
|
698
|
+
return { success: false, error: 'turn_limit' };
|
|
699
|
+
}
|
|
700
|
+
return await params.run();
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
release();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
476
706
|
/**
|
|
477
707
|
* Register this serve process as an OpenClaw external agent (WebSocket `agent.run` → headless runner).
|
|
478
708
|
* Enables `openclaw agent --agent <packId> --message "…"` against the same gateway.
|
|
@@ -481,7 +711,9 @@ async function startServeGatewayBridge(params) {
|
|
|
481
711
|
const { GatewayBridge: Bridge } = await import('../../gateway/bridge.js');
|
|
482
712
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
483
713
|
const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL ?? `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`;
|
|
484
|
-
|
|
714
|
+
// Use the captured token — serve strips OPENCLAW_GATEWAY_TOKEN from process.env once the
|
|
715
|
+
// egress broker holds it, so the bridge must not depend on the env var (§7.6).
|
|
716
|
+
const token = params.brokerGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
485
717
|
const bridge = new Bridge({ gatewayUrl, token });
|
|
486
718
|
await bridge.connect();
|
|
487
719
|
await bridge.registerAgent({
|
|
@@ -498,39 +730,65 @@ async function startServeGatewayBridge(params) {
|
|
|
498
730
|
: (requestedEngine || params.engine);
|
|
499
731
|
// #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
|
|
500
732
|
const userId = (0, serve_hardening_1.peerToUserId)(opts.peerInfo);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
733
|
+
const { sessionKey, outboundKey, durable } = resolveGatewaySessionKey(opts, userId);
|
|
734
|
+
return runGatewayTurn({
|
|
735
|
+
rateLimiter: params.rateLimiter,
|
|
736
|
+
turnLimiter: params.turnLimiter,
|
|
737
|
+
turnGate: params.turnGate,
|
|
738
|
+
warnGateReject: params.warnGateReject,
|
|
739
|
+
// Same identity-keyed cap the HTTP endpoints enforce — a channel sender is already
|
|
740
|
+
// trusted enough here to gate the OBO credential scope below, so it's trusted enough
|
|
741
|
+
// to rate-limit on directly. Unidentified senders share one 'gateway' bucket rather
|
|
742
|
+
// than being exempt from throttling entirely.
|
|
743
|
+
rlKey: userId ?? 'gateway',
|
|
744
|
+
sessionKey,
|
|
745
|
+
trackSession: durable,
|
|
746
|
+
emitter: opts.emitter,
|
|
747
|
+
run: async () => {
|
|
748
|
+
// A one-off anonymous sessionKey can never have prior state to restore, and
|
|
749
|
+
// nothing will ever look it up again to push state for — skip both S3 round-trips.
|
|
750
|
+
if (durable) {
|
|
751
|
+
await params.prepareSession(sessionKey);
|
|
752
|
+
}
|
|
753
|
+
// Channel-originated turns carry no inbound platform token: with the
|
|
754
|
+
// passthrough provider they fail closed at the gateway (an unlinked chat
|
|
755
|
+
// peer gets NO data credential — secure-obo-design §7.5 bind-before-
|
|
756
|
+
// credential); the identity provider maps the peer id server-side instead.
|
|
757
|
+
// egressStoreKey is the RAW gateway key (outboundKey) the bridge records reply
|
|
758
|
+
// handles under — it lets the egress broker resolve this session's bound
|
|
759
|
+
// destinations server-side (§7.6). userId gates BOTH capabilities: an
|
|
760
|
+
// unidentified peer gets neither a nonce nor an egress route.
|
|
761
|
+
const turnReg = userId?.trim()
|
|
762
|
+
? params.registerTurn({ userId: userId.trim(), sessionKey, egressStoreKey: outboundKey })
|
|
763
|
+
: null;
|
|
764
|
+
try {
|
|
765
|
+
const result = await (0, credential_context_1.runWithCredential)({ userId }, () => params.runner.run({
|
|
766
|
+
engineId: effectiveEngine,
|
|
767
|
+
modelId: params.defaultModel,
|
|
768
|
+
message,
|
|
769
|
+
workspaceRoot: params.workspace,
|
|
770
|
+
// Use the resolved sessionKey (not the raw opts fields) so the engine's own
|
|
771
|
+
// session state and the S3 restore/push above key off the same, user-namespaced
|
|
772
|
+
// identity. The outbound-routing file (buildSessionEnvPatch) keeps the raw
|
|
773
|
+
// gateway key — the bridge records reply handles under it, so namespacing it
|
|
774
|
+
// here would break reply-to-sender. See resolveGatewaySessionKey.
|
|
775
|
+
sessionId: sessionKey,
|
|
776
|
+
gatewaySessionKey: outboundKey,
|
|
777
|
+
analyticsUserId: userId,
|
|
778
|
+
emitter: opts.emitter,
|
|
779
|
+
permissions: params.permissions,
|
|
780
|
+
turnEnv: turnReg?.env,
|
|
781
|
+
}), { onDenied: params.logWarrantDenied(sessionKey) });
|
|
782
|
+
if (durable) {
|
|
783
|
+
params.onTurnComplete?.(sessionKey);
|
|
784
|
+
}
|
|
785
|
+
return { success: result.success, error: result.error };
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
turnReg?.release();
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
});
|
|
534
792
|
},
|
|
535
793
|
});
|
|
536
794
|
console.log(`${utils_1.COLORS.green}✓${utils_1.COLORS.reset} Gateway bridge: registered "${params.gatewayAgentId}" at ${gatewayUrl}`);
|
|
@@ -594,7 +852,7 @@ async function bindToGatewayChannels(agentId, bindSpec) {
|
|
|
594
852
|
client.close();
|
|
595
853
|
}
|
|
596
854
|
}
|
|
597
|
-
async function handleStreamingChat(runner, body, defaultEngine, defaultModel, workspace, permissions, res, userId) {
|
|
855
|
+
async function handleStreamingChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, res, userId, turnEnv) {
|
|
598
856
|
let streamEnded = false;
|
|
599
857
|
const sendEvent = (type, data) => {
|
|
600
858
|
if (streamEnded || res.writableEnded) {
|
|
@@ -648,10 +906,12 @@ async function handleStreamingChat(runner, body, defaultEngine, defaultModel, wo
|
|
|
648
906
|
modelId: body.model || defaultModel,
|
|
649
907
|
message: body.message,
|
|
650
908
|
workspaceRoot: workspace,
|
|
651
|
-
|
|
909
|
+
// The user-namespaced key (never raw body.session_id) — see resolveServeSessionKey.
|
|
910
|
+
sessionId: sessionKey,
|
|
652
911
|
analyticsUserId: userId,
|
|
653
912
|
emitter,
|
|
654
913
|
permissions,
|
|
914
|
+
turnEnv,
|
|
655
915
|
});
|
|
656
916
|
if (!streamEnded) {
|
|
657
917
|
sendEvent('done', {});
|