@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
|
@@ -37,18 +37,26 @@ 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");
|
|
58
|
+
const sessionEvents_1 = require("../../analytics/sessionEvents");
|
|
59
|
+
const base_1 = require("../../engines/base");
|
|
52
60
|
const state_sync_1 = require("../state-sync");
|
|
53
61
|
const cli_agent_id_js_1 = require("../../gateway/cli-agent-id.js");
|
|
54
62
|
const openclaw_channel_routing_js_1 = require("../../gateway/openclaw-channel-routing.js");
|
|
@@ -79,8 +87,11 @@ async function serveCommand(options) {
|
|
|
79
87
|
const defaultModel = (0, headless_1.resolveModelAlias)(rawModel);
|
|
80
88
|
const rawCheapModel = pack.manifest.spec.cheapModel?.trim();
|
|
81
89
|
const claudeCheapModel = rawCheapModel ? (0, headless_1.resolveModelAlias)(rawCheapModel) : undefined;
|
|
82
|
-
|
|
83
|
-
|
|
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).`);
|
|
84
95
|
for (const line of (0, utils_1.getLiteLLMQuickstartHintLines)()) {
|
|
85
96
|
console.error(line);
|
|
86
97
|
}
|
|
@@ -90,14 +101,76 @@ async function serveCommand(options) {
|
|
|
90
101
|
const bundledPromptsDir = (0, utils_1.findBundledBasePromptsDir)();
|
|
91
102
|
await (0, headless_1.syncPackToWorkspace)(options.workspace, pack.packPath, packId, defaultEngine, syncLogger, bundledPromptsDir);
|
|
92
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}`);
|
|
93
106
|
// Resolve persistent session state (serve-v3 §A1: replaces the tmpdir default).
|
|
94
107
|
const { dir: storagePath, ephemeral } = (0, serve_hardening_1.resolveStateDir)(options.stateDir);
|
|
95
108
|
const bindHost = (0, serve_hardening_1.resolveBindHost)(options.host);
|
|
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);
|
|
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
|
+
};
|
|
96
165
|
const turnLimiter = new serve_hardening_1.TurnLimiter(options.maxTurnsPerSession ?? 0);
|
|
97
166
|
// Per-identity request-rate cap (req/min). Keyed on the trusted user id (falling back to
|
|
98
167
|
// client IP) so rotating session_id can't bypass it. AGENTHIPPO_SERVE_RATE_LIMIT=0 disables.
|
|
99
168
|
const rateLimitPerMin = Number(process.env.AGENTHIPPO_SERVE_RATE_LIMIT ?? 120);
|
|
100
169
|
const rateLimiter = new serve_hardening_1.RateLimiter(Number.isFinite(rateLimitPerMin) ? rateLimitPerMin : 120);
|
|
170
|
+
// Process-wide inflight cap (serve-v4 P0.1). Prevents a burst from spawning N engine
|
|
171
|
+
// subprocesses and OOMing the container. AGENTHIPPO_MAX_CONCURRENT_TURNS=0 disables.
|
|
172
|
+
const turnGate = (0, serve_hardening_1.createTurnGateFromEnv)();
|
|
173
|
+
const warnGateReject = (0, serve_hardening_1.createRejectionWarner)();
|
|
101
174
|
let landingPageHtml;
|
|
102
175
|
if (options.wwwDir?.trim()) {
|
|
103
176
|
const indexPath = path.join(path.resolve(options.wwwDir.trim()), 'index.html');
|
|
@@ -111,21 +184,34 @@ async function serveCommand(options) {
|
|
|
111
184
|
if (ephemeral) {
|
|
112
185
|
console.warn(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} Session state is in a tmpdir (ephemeral). Pass --state-dir or set AGENTHIPPO_HOME to persist sessions across restarts.`);
|
|
113
186
|
}
|
|
114
|
-
// Q1: object-store state sync (no-op unless AGENTHIPPO_STATE_S3_URI is set).
|
|
115
|
-
//
|
|
187
|
+
// Q1 + serve-v4 P1.2: object-store state sync (no-op unless AGENTHIPPO_STATE_S3_URI is set).
|
|
188
|
+
// Multi-replica: AGENTHIPPO_STATE_RESTORE_BOOT=none + restore-on-miss per session.
|
|
189
|
+
// Single-instance default: boot-restore the whole prefix (backward compatible).
|
|
116
190
|
const s3State = (0, state_sync_1.resolveS3StateConfig)();
|
|
191
|
+
const restoreBoot = (0, state_sync_1.resolveRestoreBootMode)();
|
|
117
192
|
if (s3State) {
|
|
118
193
|
// Preflight: state sync shells out to `aws`. If it's missing, sessions would
|
|
119
194
|
// silently never persist — fail fast since durable state was explicitly opted into.
|
|
120
195
|
if (!(await (0, state_sync_1.isAwsCliAvailable)())) {
|
|
121
196
|
console.error(`${utils_1.COLORS.red}Error: AGENTHIPPO_STATE_S3_URI is set but the \`aws\` CLI was not found.${utils_1.COLORS.reset}`);
|
|
122
|
-
console.error(' State sync requires the AWS CLI. Install it
|
|
197
|
+
console.error(' State sync requires the AWS CLI (S3-compatible via --endpoint-url). Install it or unset AGENTHIPPO_STATE_S3_URI.');
|
|
123
198
|
process.exit(1);
|
|
124
199
|
}
|
|
125
|
-
|
|
126
|
-
|
|
200
|
+
if (restoreBoot === 'all') {
|
|
201
|
+
console.log(`State sync: boot-restore from ${s3State.uri} …`);
|
|
202
|
+
await (0, state_sync_1.restoreState)(s3State, storagePath, (m) => console.log(` ${m}`));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
console.log(`State sync: restore-on-miss from ${s3State.uri} (boot restore disabled)`);
|
|
206
|
+
}
|
|
127
207
|
}
|
|
128
208
|
const statePusher = s3State ? (0, state_sync_1.makeStatePusher)(s3State, storagePath, options.verbose ? (m) => console.error(m) : undefined) : null;
|
|
209
|
+
const stateLog = options.verbose ? (m) => console.error(m) : undefined;
|
|
210
|
+
const prepareSession = async (sessionKey) => {
|
|
211
|
+
if (s3State) {
|
|
212
|
+
await (0, state_sync_1.ensureSessionLocal)(s3State, storagePath, sessionKey, stateLog);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
129
215
|
const logger = options.verbose ? new headless_1.ConsoleLogger('[Server]') : new headless_1.NoOpLogger();
|
|
130
216
|
// Find SDK CLI paths
|
|
131
217
|
const claudeCodePath = (0, utils_1.findClaudeCodePath)();
|
|
@@ -150,12 +236,18 @@ async function serveCommand(options) {
|
|
|
150
236
|
};
|
|
151
237
|
const runner = (0, headless_1.createHeadlessRunner)({
|
|
152
238
|
storagePath,
|
|
239
|
+
analyticsSessionsDir,
|
|
153
240
|
packId,
|
|
154
241
|
packVersion: pack?.manifest.metadata.version,
|
|
155
242
|
bundledPromptsDir,
|
|
156
243
|
apiKey: apiConfig.apiKey,
|
|
157
244
|
baseUrl: apiConfig.baseUrl,
|
|
158
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,
|
|
159
251
|
logger,
|
|
160
252
|
freshState: false,
|
|
161
253
|
claudeCodeExecutablePath: claudeCodePath,
|
|
@@ -192,9 +284,15 @@ async function serveCommand(options) {
|
|
|
192
284
|
return;
|
|
193
285
|
}
|
|
194
286
|
// Readiness — pack synced (+ gateway connected when bridged).
|
|
287
|
+
// Inflight/queue depth for operators and load balancers (serve-v4 P0.1).
|
|
195
288
|
if (req.method === 'GET' && req.url === '/ready') {
|
|
196
289
|
res.writeHead(ready ? 200 : 503, { 'Content-Type': 'application/json' });
|
|
197
|
-
res.end(JSON.stringify({
|
|
290
|
+
res.end(JSON.stringify({
|
|
291
|
+
ready,
|
|
292
|
+
inflight: turnGate.inflight,
|
|
293
|
+
queued: turnGate.queued,
|
|
294
|
+
max: turnGate.max,
|
|
295
|
+
}));
|
|
198
296
|
return;
|
|
199
297
|
}
|
|
200
298
|
const urlPath = req.url?.split('?')[0] ?? '';
|
|
@@ -208,22 +306,57 @@ async function serveCommand(options) {
|
|
|
208
306
|
const body = await parseBody(req);
|
|
209
307
|
const userId = (0, serve_hardening_1.resolveUserId)(req.headers, body);
|
|
210
308
|
const dataToken = (0, serve_hardening_1.resolveDataToken)(req.headers);
|
|
211
|
-
|
|
309
|
+
// Rate-limit on the trusted proxy identity (or client IP), never the client-supplied
|
|
310
|
+
// body user_id — otherwise a caller rotates user_id per request to reset the bucket.
|
|
311
|
+
const rlKey = (0, serve_hardening_1.resolveUserId)(req.headers, undefined) ?? (0, serve_hardening_1.clientIp)(req);
|
|
212
312
|
if (!rateLimiter.tryConsume(rlKey)) {
|
|
213
313
|
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(rateLimiter.retryAfterSeconds(rlKey)) });
|
|
214
314
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
215
315
|
return;
|
|
216
316
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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' }));
|
|
221
324
|
return;
|
|
222
325
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
326
|
+
// Acquire before turn-limiter so a capacity reject does not burn a turn slot.
|
|
327
|
+
const release = await turnGate.acquire();
|
|
328
|
+
if (!release) {
|
|
329
|
+
warnGateReject(`TurnGate: rejecting /v1/chat (inflight=${turnGate.inflight} queued=${turnGate.queued} max=${turnGate.max})`);
|
|
330
|
+
res.writeHead(429, {
|
|
331
|
+
'Content-Type': 'application/json',
|
|
332
|
+
'Retry-After': String(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS),
|
|
333
|
+
});
|
|
334
|
+
res.end(JSON.stringify({ error: 'At capacity' }));
|
|
335
|
+
return;
|
|
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;
|
|
341
|
+
try {
|
|
342
|
+
if (!turnLimiter.tryConsume(sessionKey)) {
|
|
343
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
344
|
+
res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
await prepareSession(sessionKey);
|
|
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) });
|
|
352
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
353
|
+
res.end(JSON.stringify(result));
|
|
354
|
+
statePusher?.schedule(sessionKey); // per-session delta push, off the response path
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
turnReg?.release();
|
|
358
|
+
release();
|
|
359
|
+
}
|
|
227
360
|
return;
|
|
228
361
|
}
|
|
229
362
|
// Streaming chat endpoint
|
|
@@ -231,25 +364,63 @@ async function serveCommand(options) {
|
|
|
231
364
|
const body = await parseBody(req);
|
|
232
365
|
const userId = (0, serve_hardening_1.resolveUserId)(req.headers, body);
|
|
233
366
|
const dataToken = (0, serve_hardening_1.resolveDataToken)(req.headers);
|
|
234
|
-
|
|
367
|
+
// Rate-limit on the trusted proxy identity (or client IP), never the client-supplied
|
|
368
|
+
// body user_id — otherwise a caller rotates user_id per request to reset the bucket.
|
|
369
|
+
const rlKey = (0, serve_hardening_1.resolveUserId)(req.headers, undefined) ?? (0, serve_hardening_1.clientIp)(req);
|
|
235
370
|
if (!rateLimiter.tryConsume(rlKey)) {
|
|
236
371
|
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(rateLimiter.retryAfterSeconds(rlKey)) });
|
|
237
372
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
238
373
|
return;
|
|
239
374
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
383
|
+
// Acquire BEFORE writeHead(200, SSE) so a reject still gets a clean 429.
|
|
384
|
+
const release = await turnGate.acquire();
|
|
385
|
+
if (!release) {
|
|
386
|
+
warnGateReject(`TurnGate: rejecting /v1/chat/stream (inflight=${turnGate.inflight} queued=${turnGate.queued} max=${turnGate.max})`);
|
|
387
|
+
res.writeHead(429, {
|
|
388
|
+
'Content-Type': 'application/json',
|
|
389
|
+
'Retry-After': String(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS),
|
|
390
|
+
});
|
|
391
|
+
res.end(JSON.stringify({ error: 'At capacity' }));
|
|
244
392
|
return;
|
|
245
393
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
394
|
+
// Turn-scoped data capability — same rules as /v1/chat above.
|
|
395
|
+
let turnReg = null;
|
|
396
|
+
try {
|
|
397
|
+
if (!turnLimiter.tryConsume(sessionKey)) {
|
|
398
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
399
|
+
res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
await prepareSession(sessionKey);
|
|
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) });
|
|
418
|
+
statePusher?.schedule(sessionKey); // per-session delta push, off the response path
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
turnReg?.release();
|
|
422
|
+
release();
|
|
423
|
+
}
|
|
253
424
|
return;
|
|
254
425
|
}
|
|
255
426
|
// 404
|
|
@@ -289,7 +460,7 @@ async function serveCommand(options) {
|
|
|
289
460
|
}
|
|
290
461
|
console.log(`Workspace: ${options.workspace}`);
|
|
291
462
|
if (pack) {
|
|
292
|
-
console.log(`Pack: ${pack.manifest.metadata.name}`);
|
|
463
|
+
console.log(`Pack: ${pack.manifest.metadata.name}@${pack.manifest.metadata.version}`);
|
|
293
464
|
console.log(`Gateway: ${gatewayAgentId}`);
|
|
294
465
|
}
|
|
295
466
|
console.log(BANNER_LINE);
|
|
@@ -312,17 +483,50 @@ async function serveCommand(options) {
|
|
|
312
483
|
}
|
|
313
484
|
}
|
|
314
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
|
+
}
|
|
315
512
|
try {
|
|
316
513
|
gatewayBridge = await startServeGatewayBridge({
|
|
317
514
|
gatewayAgentId,
|
|
318
515
|
packName: pack.manifest.metadata.name,
|
|
319
516
|
workspace: options.workspace,
|
|
320
|
-
|
|
517
|
+
registerTurn: registerServeTurn,
|
|
518
|
+
brokerGatewayToken,
|
|
321
519
|
engine: defaultEngine,
|
|
322
520
|
defaultModel,
|
|
323
521
|
runner,
|
|
324
522
|
permissions,
|
|
325
|
-
|
|
523
|
+
turnGate,
|
|
524
|
+
rateLimiter,
|
|
525
|
+
turnLimiter,
|
|
526
|
+
warnGateReject,
|
|
527
|
+
prepareSession,
|
|
528
|
+
onTurnComplete: (sessionId) => statePusher?.schedule(sessionId),
|
|
529
|
+
logWarrantDenied,
|
|
326
530
|
});
|
|
327
531
|
}
|
|
328
532
|
catch (err) {
|
|
@@ -360,6 +564,22 @@ async function serveCommand(options) {
|
|
|
360
564
|
}
|
|
361
565
|
gatewayBridge = null;
|
|
362
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
|
+
}
|
|
363
583
|
server.close(() => {
|
|
364
584
|
runner.dispose();
|
|
365
585
|
process.exit(0);
|
|
@@ -383,15 +603,18 @@ async function parseBody(req) {
|
|
|
383
603
|
throw new Error('Invalid JSON');
|
|
384
604
|
}
|
|
385
605
|
}
|
|
386
|
-
async function handleChat(runner, body, defaultEngine, defaultModel, workspace, permissions, userId) {
|
|
606
|
+
async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, userId, turnEnv) {
|
|
387
607
|
const result = await runner.run({
|
|
388
608
|
engineId: resolveRequestedEngineId(body.engine, defaultEngine),
|
|
389
609
|
modelId: body.model || defaultModel,
|
|
390
610
|
message: body.message,
|
|
391
611
|
workspaceRoot: workspace,
|
|
392
|
-
|
|
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,
|
|
393
615
|
analyticsUserId: userId,
|
|
394
616
|
permissions,
|
|
617
|
+
turnEnv,
|
|
395
618
|
});
|
|
396
619
|
return {
|
|
397
620
|
success: result.success,
|
|
@@ -401,6 +624,85 @@ async function handleChat(runner, body, defaultEngine, defaultModel, workspace,
|
|
|
401
624
|
error: result.error,
|
|
402
625
|
};
|
|
403
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
|
+
}
|
|
404
706
|
/**
|
|
405
707
|
* Register this serve process as an OpenClaw external agent (WebSocket `agent.run` → headless runner).
|
|
406
708
|
* Enables `openclaw agent --agent <packId> --message "…"` against the same gateway.
|
|
@@ -409,7 +711,9 @@ async function startServeGatewayBridge(params) {
|
|
|
409
711
|
const { GatewayBridge: Bridge } = await import('../../gateway/bridge.js');
|
|
410
712
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
411
713
|
const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL ?? `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`;
|
|
412
|
-
|
|
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;
|
|
413
717
|
const bridge = new Bridge({ gatewayUrl, token });
|
|
414
718
|
await bridge.connect();
|
|
415
719
|
await bridge.registerAgent({
|
|
@@ -426,19 +730,65 @@ async function startServeGatewayBridge(params) {
|
|
|
426
730
|
: (requestedEngine || params.engine);
|
|
427
731
|
// #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
|
|
428
732
|
const userId = (0, serve_hardening_1.peerToUserId)(opts.peerInfo);
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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,
|
|
437
746
|
emitter: opts.emitter,
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
});
|
|
442
792
|
},
|
|
443
793
|
});
|
|
444
794
|
console.log(`${utils_1.COLORS.green}✓${utils_1.COLORS.reset} Gateway bridge: registered "${params.gatewayAgentId}" at ${gatewayUrl}`);
|
|
@@ -502,7 +852,7 @@ async function bindToGatewayChannels(agentId, bindSpec) {
|
|
|
502
852
|
client.close();
|
|
503
853
|
}
|
|
504
854
|
}
|
|
505
|
-
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) {
|
|
506
856
|
let streamEnded = false;
|
|
507
857
|
const sendEvent = (type, data) => {
|
|
508
858
|
if (streamEnded || res.writableEnded) {
|
|
@@ -556,10 +906,12 @@ async function handleStreamingChat(runner, body, defaultEngine, defaultModel, wo
|
|
|
556
906
|
modelId: body.model || defaultModel,
|
|
557
907
|
message: body.message,
|
|
558
908
|
workspaceRoot: workspace,
|
|
559
|
-
|
|
909
|
+
// The user-namespaced key (never raw body.session_id) — see resolveServeSessionKey.
|
|
910
|
+
sessionId: sessionKey,
|
|
560
911
|
analyticsUserId: userId,
|
|
561
912
|
emitter,
|
|
562
913
|
permissions,
|
|
914
|
+
turnEnv,
|
|
563
915
|
});
|
|
564
916
|
if (!streamEnded) {
|
|
565
917
|
sendEvent('done', {});
|