@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.11
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 +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/dashboard/provider.js +13 -2
- package/out/analytics/dashboard/webview/panel.js +3 -2
- package/out/analytics/identity.js +0 -0
- package/out/analytics/service.js +29 -25
- package/out/analytics/sessionEvents.js +100 -24
- package/out/auth/authBaseUrl.js +59 -0
- package/out/auth/bootstrapApply.js +9 -10
- package/out/auth/brokerClient.js +48 -14
- package/out/auth/provider.js +8 -21
- package/out/auth/sessionStore.js +3 -40
- package/out/auth/signInFlow.js +2 -1
- package/out/auth-broker/config.js +50 -5
- package/out/auth-broker/server.js +126 -26
- package/out/auth-broker/store.js +25 -24
- package/out/authority/warrant.js +206 -0
- package/out/branding.js +27 -1
- package/out/cli/commands/conformance.js +249 -0
- package/out/cli/commands/data-plane.js +151 -0
- package/out/cli/commands/deploy.js +1 -0
- package/out/cli/commands/gateway.js +4 -0
- package/out/cli/commands/pack.js +182 -3
- package/out/cli/commands/sandbox.js +351 -0
- package/out/cli/commands/serve.js +1145 -130
- package/out/cli/commands/store.js +12 -4
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +18 -2
- package/out/cli/data-plane/registry.js +9 -0
- package/out/cli/data-plane/remote.js +106 -0
- package/out/cli/data-plane/sidecar.js +225 -0
- package/out/cli/deploy/layers/identity.js +88 -4
- package/out/cli/deploy-init.js +89 -24
- package/out/cli/deps.js +106 -2
- package/out/cli/idp-init.js +35 -0
- package/out/cli/index.js +171 -17
- package/out/cli/pack-install-ref.js +15 -0
- package/out/cli/pack-loader.js +8 -1
- package/out/cli/pack-runtime.js +61 -0
- package/out/cli/runtime.js +35 -1
- package/out/cli/serve-hardening.js +260 -14
- package/out/cli/utils.js +12 -1
- package/out/engines/base.js +32 -3
- package/out/engines/claude.js +33 -15
- package/out/engines/codex.js +111 -27
- package/out/engines/custom.js +15 -1
- package/out/evidence/sessionShip.js +643 -0
- package/out/extension.js +11 -2
- package/out/gateway/bridge.js +40 -2
- package/out/gateway/client.js +34 -1
- package/out/gateway/installSource.js +68 -0
- package/out/gateway/lifecycle.js +185 -51
- package/out/gateway/outboundPaths.js +4 -1
- package/out/gateway/secretsPreflight.js +90 -0
- package/out/gateway/service.js +7 -2
- package/out/headless/index.js +25 -2
- package/out/live-e2e/mcp-demo/server.mjs +32 -0
- package/out/mcp.js +30 -0
- package/out/pack/caps.js +271 -0
- package/out/pack/governor.js +326 -0
- package/out/pack/lease-schema.js +273 -0
- package/out/pack/lease.js +682 -0
- package/out/pack/reconcile.js +217 -0
- package/out/pack/signature.js +839 -0
- package/out/remote/handleRemoteChat.js +44 -3
- package/out/remote/remoteTrust.js +277 -0
- package/out/remote/serveClient.js +52 -5
- package/out/runner/host.js +141 -0
- package/out/runner/protocol.js +128 -0
- package/out/runner/remoteEngine.js +166 -0
- package/out/runtime/interfaces/edit-tracker.js +2 -0
- package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
- package/out/sandbox/containerDriver.js +572 -0
- package/out/sandbox/processDriver.js +318 -0
- package/out/sandbox/sandboxRunner.js +303 -0
- package/out/sandbox/support.js +301 -0
- package/out/sandbox/types.js +55 -0
- package/out/scripts/agentide-litellm.sh +67 -5
- package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
- package/out/scripts/analytics/setup-analytics.ps1 +45 -14
- package/out/scripts/analytics/setup-analytics.sh +106 -15
- package/out/scripts/analytics/start-analytics.sh +86 -14
- package/out/scripts/gateway/setup-gateway.sh +72 -32
- package/out/shared/models.js +11 -110
- package/out/spotlight/install.js +7 -1
- package/out/store/index.js +41 -10
- package/out/sync.js +3 -1
- package/out/test/analytics.test.js +162 -23
- package/out/test/auth-baseurl.test.js +132 -0
- package/out/test/auth-bootstrapApply.test.js +27 -6
- package/out/test/auth-broker-hardening.test.js +227 -0
- package/out/test/auth-broker.test.js +59 -9
- package/out/test/branding.test.js +13 -0
- package/out/test/caps-governor.test.js +460 -0
- package/out/test/codex-new-file-survival.test.js +119 -0
- package/out/test/configuration-scopes.test.js +131 -0
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +380 -0
- package/out/test/deploy-init.test.js +47 -1
- package/out/test/gateway-client.test.js +28 -0
- package/out/test/gateway-install-source.test.js +111 -0
- package/out/test/gateway-lifecycle.test.js +1 -1
- package/out/test/gateway-outbound.test.js +7 -0
- package/out/test/gateway-secrets-preflight.test.js +127 -0
- package/out/test/gateway-session-routing.test.js +18 -0
- package/out/test/governor-hardening.test.js +222 -0
- package/out/test/headless-sync.test.js +22 -0
- package/out/test/lease-signature.test.js +357 -0
- package/out/test/lease-trust-root.test.js +273 -0
- package/out/test/lease-validation.test.js +224 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/mcp.test.js +27 -1
- package/out/test/pack-admission.test.js +216 -0
- package/out/test/pack-digest-parity.test.js +140 -0
- package/out/test/pack-reconcile.test.js +183 -0
- package/out/test/pack-signature-artifact.test.js +103 -0
- package/out/test/pack-signature.test.js +176 -0
- package/out/test/pack-trust-root.test.js +202 -0
- package/out/test/remote-trust.test.js +242 -0
- package/out/test/runner-protocol.test.js +249 -0
- package/out/test/sandbox-driver.test.js +553 -0
- package/out/test/sandbox-hardening.test.js +406 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +227 -15
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +618 -0
- package/out/test/spotlightPublish.test.js +28 -0
- package/out/test/store-install-safe.test.js +69 -0
- package/out/test/tls-bypass.test.js +90 -0
- package/out/test/warrant.test.js +138 -0
- package/out/utils.js +46 -9
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseStrictJsonConfig = parseStrictJsonConfig;
|
|
7
|
+
exports.collectMissingChannelEnvSecrets = collectMissingChannelEnvSecrets;
|
|
8
|
+
exports.disableChannelsInConfigText = disableChannelsInConfigText;
|
|
9
|
+
const MAX_WALK_DEPTH = 8;
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Parse config content as strict JSON, or undefined when it does not parse.
|
|
15
|
+
*
|
|
16
|
+
* OpenClaw accepts JSON5 (the starter template has comments). A JSON5 file cannot be
|
|
17
|
+
* round-tripped through JSON.parse/stringify without losing comments, so callers must
|
|
18
|
+
* treat `undefined` as "hands off — detect nothing, rewrite nothing".
|
|
19
|
+
*/
|
|
20
|
+
function parseStrictJsonConfig(content) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(content);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function collectEnvRefs(value, out, depth) {
|
|
29
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
for (const item of value) {
|
|
34
|
+
collectEnvRefs(item, out, depth + 1);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!isRecord(value)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (value.source === 'env' && typeof value.id === 'string' && value.id.trim()) {
|
|
42
|
+
out.add(value.id.trim());
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const child of Object.values(value)) {
|
|
46
|
+
collectEnvRefs(child, out, depth + 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* List enabled channels whose env-sourced secret refs resolve to nothing in `env`.
|
|
51
|
+
*
|
|
52
|
+
* A channel counts as enabled unless it says `enabled: false` — a configured channel is
|
|
53
|
+
* intent, and the gateway will try (and fail) to start it either way. Literal token
|
|
54
|
+
* strings are not env refs and are never reported.
|
|
55
|
+
*/
|
|
56
|
+
function collectMissingChannelEnvSecrets(config, env) {
|
|
57
|
+
if (!isRecord(config) || !isRecord(config.channels)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const result = [];
|
|
61
|
+
for (const [name, channel] of Object.entries(config.channels)) {
|
|
62
|
+
if (!isRecord(channel) || channel.enabled === false) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const refs = new Set();
|
|
66
|
+
collectEnvRefs(channel, refs, 0);
|
|
67
|
+
const missing = [...refs].filter((id) => !env[id]?.trim()).sort();
|
|
68
|
+
if (missing.length) {
|
|
69
|
+
result.push({ channel: name, envVars: missing });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Return config text with the named channels set to `enabled: false`, or undefined when
|
|
76
|
+
* the content is not strict JSON (rewriting JSON5 would destroy comments).
|
|
77
|
+
*/
|
|
78
|
+
function disableChannelsInConfigText(content, channels) {
|
|
79
|
+
const config = parseStrictJsonConfig(content);
|
|
80
|
+
if (!isRecord(config) || !isRecord(config.channels)) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
for (const name of channels) {
|
|
84
|
+
const channel = config.channels[name];
|
|
85
|
+
if (isRecord(channel)) {
|
|
86
|
+
channel.enabled = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
90
|
+
}
|
package/out/gateway/service.js
CHANGED
|
@@ -46,10 +46,12 @@ exports.GatewayService = exports.GatewaySessionBindingError = void 0;
|
|
|
46
46
|
* - Session routing: disk-backed route index + per-session gateway-session.json
|
|
47
47
|
* - Publish: user connects an existing IDE session to the gateway
|
|
48
48
|
*/
|
|
49
|
+
const crypto_1 = require("crypto");
|
|
49
50
|
const os = __importStar(require("os"));
|
|
50
51
|
const path = __importStar(require("path"));
|
|
51
52
|
const vscode = __importStar(require("vscode"));
|
|
52
53
|
const scheduledFleetStorage_js_1 = require("../analytics/dashboard/scheduledFleetStorage.js");
|
|
54
|
+
const branding_js_1 = require("../branding.js");
|
|
53
55
|
const bridge_js_1 = require("./bridge.js");
|
|
54
56
|
const gateway_scope_errors_js_1 = require("./gateway-scope-errors.js");
|
|
55
57
|
const gatewayRouteIndex_js_1 = require("./gatewayRouteIndex.js");
|
|
@@ -769,7 +771,10 @@ class GatewayService {
|
|
|
769
771
|
*/
|
|
770
772
|
createSessionAwareChatUIHandler(agentId, packName) {
|
|
771
773
|
return async (message, sessionKey, peerInfo, emitter, runContext) => {
|
|
772
|
-
|
|
774
|
+
// Unguessable: this token is a bearer capability for the run's output stream, and
|
|
775
|
+
// `getGatewayEmitter` is a destructive first-reader-wins read. A predictable value
|
|
776
|
+
// lets any chat message beginning with `[gw:...]` steal or drop another run's stream.
|
|
777
|
+
const gatewayRequestToken = `gw-${(0, crypto_1.randomUUID)()}`;
|
|
773
778
|
GatewayService.gatewayEmitters.set(gatewayRequestToken, emitter);
|
|
774
779
|
const markedMessage = `[gw:${gatewayRequestToken}]${message}`;
|
|
775
780
|
try {
|
|
@@ -983,7 +988,7 @@ class GatewayService {
|
|
|
983
988
|
const tokenRaw = (gwLive.get("token") ?? "").trim() || (this.config.token ?? "").trim();
|
|
984
989
|
if (!url) {
|
|
985
990
|
if (!silent) {
|
|
986
|
-
vscode.window.showWarningMessage(
|
|
991
|
+
vscode.window.showWarningMessage(`Gateway URL not configured. Set ${(0, branding_js_1.toSettingsDisplayKey)("agentide.gateway.url")} in settings.`);
|
|
987
992
|
}
|
|
988
993
|
return;
|
|
989
994
|
}
|
package/out/headless/index.js
CHANGED
|
@@ -350,7 +350,18 @@ function createHeadlessRunner(config) {
|
|
|
350
350
|
// Capture warrant userId here (inside serve's runWithCredential continuation), not
|
|
351
351
|
// later at tool emit — custom-engine IPC callbacks can lose/stick ambient ALS.
|
|
352
352
|
const sessionId = options.sessionId ?? `headless-${Date.now()}`;
|
|
353
|
-
const
|
|
353
|
+
const provenance = config.provenance?.();
|
|
354
|
+
const observableEmitter = (0, sessionEvents_1.wrapEmitterWithObservability)(baseEmitter, sessionEventWriter, sessionId, requestId, (0, credential_context_1.currentCredential)()?.userId, provenance && {
|
|
355
|
+
policy_version: provenance.policyVersion,
|
|
356
|
+
pack_digest: provenance.packDigest,
|
|
357
|
+
engine_version: provenance.engineVersion,
|
|
358
|
+
tier: provenance.tier,
|
|
359
|
+
// Layer 2 must be joinable to an instance ON ITS OWN. Without this the session
|
|
360
|
+
// lane could only be attributed by going through Layer 1 (the OTEL span), which
|
|
361
|
+
// defeats the point of self-stamping: with a shared collector spool, the
|
|
362
|
+
// shipper's INSTANCE_ID cannot know which instance actually ran a turn.
|
|
363
|
+
instance_id: provenance.instanceId,
|
|
364
|
+
});
|
|
354
365
|
const requestOptions = {
|
|
355
366
|
modelId: options.modelId,
|
|
356
367
|
engineId: options.engineId,
|
|
@@ -362,12 +373,24 @@ function createHeadlessRunner(config) {
|
|
|
362
373
|
// the key for the engine's destination — passing the collapsed key here would let
|
|
363
374
|
// it win over the correct provider key (the original bug). Back-compat single-key
|
|
364
375
|
// callers keep options.apiKey.
|
|
365
|
-
|
|
376
|
+
//
|
|
377
|
+
// A per-run `modelToken` outranks both. That is safe precisely where it is used:
|
|
378
|
+
// inside a sandbox there are no provider keys to lose a precedence fight with
|
|
379
|
+
// (the credential invariant), and the warrant IS the only route to a model.
|
|
380
|
+
apiKey: options.modelToken ?? (hasSplitKeys ? undefined : config.apiKey),
|
|
366
381
|
baseUrl: config.baseUrl,
|
|
367
382
|
useLiteLLM: config.useLiteLLM,
|
|
368
383
|
agentPackId: packId,
|
|
369
384
|
agentPackVersion: config.packVersion,
|
|
385
|
+
policyVersion: provenance?.policyVersion,
|
|
386
|
+
packDigest: provenance?.packDigest,
|
|
387
|
+
engineVersion: provenance?.engineVersion,
|
|
388
|
+
tier: provenance?.tier,
|
|
389
|
+
instanceId: provenance?.instanceId,
|
|
370
390
|
permissions: options.permissions,
|
|
391
|
+
// Only ever set by a sandbox child. In serve's own process the engine reads the
|
|
392
|
+
// credential frame directly and this is undefined.
|
|
393
|
+
engineHomeUserKey: options.engineHomeUserKey,
|
|
371
394
|
requestId, // Same requestId used by ObservableStreamEmitter for Layer 1/2 correlation
|
|
372
395
|
};
|
|
373
396
|
// Emit turn_start event
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
|
|
5
|
+
const BALANCES = {
|
|
6
|
+
'alice@corp.com': 4242,
|
|
7
|
+
'bob@corp.com': 99,
|
|
8
|
+
};
|
|
9
|
+
const TURN = 'ai.agenthippo/turn';
|
|
10
|
+
|
|
11
|
+
const server = new Server({ name: 'e2e-demo', version: '0.0.1' }, { capabilities: { tools: {} } });
|
|
12
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
13
|
+
tools: [{
|
|
14
|
+
name: 'get_my_balance',
|
|
15
|
+
description: 'Return the signed-in user balance. No args.',
|
|
16
|
+
inputSchema: { type: 'object', properties: {} },
|
|
17
|
+
}],
|
|
18
|
+
}));
|
|
19
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
20
|
+
const turn = (req.params._meta ?? {})[TURN] ?? null;
|
|
21
|
+
const userId = typeof turn?.userId === 'string' ? turn.userId.trim() : '';
|
|
22
|
+
if (!userId) {
|
|
23
|
+
return { isError: true, content: [{ type: 'text', text: 'fail-closed: no verified user' }] };
|
|
24
|
+
}
|
|
25
|
+
const bal = BALANCES[userId];
|
|
26
|
+
if (bal === undefined) {
|
|
27
|
+
return { isError: true, content: [{ type: 'text', text: `fail-closed: no balance for ${userId}` }] };
|
|
28
|
+
}
|
|
29
|
+
console.error(`[e2e-connector] ${userId} → ${bal}`);
|
|
30
|
+
return { content: [{ type: 'text', text: JSON.stringify({ userId, balance: bal }) }] };
|
|
31
|
+
});
|
|
32
|
+
await server.connect(new StdioServerTransport());
|
package/out/mcp.js
CHANGED
|
@@ -44,6 +44,7 @@ exports.generateCodexMcpConfig = generateCodexMcpConfig;
|
|
|
44
44
|
exports.getDefaultMcpConfig = getDefaultMcpConfig;
|
|
45
45
|
exports.loadBundledMcpServers = loadBundledMcpServers;
|
|
46
46
|
exports.resolveHttpMcpHeaders = resolveHttpMcpHeaders;
|
|
47
|
+
exports.referenceHttpMcpHeaders = referenceHttpMcpHeaders;
|
|
47
48
|
exports.formatInlineMcpServers = formatInlineMcpServers;
|
|
48
49
|
exports.serializeInlineMcpServers = serializeInlineMcpServers;
|
|
49
50
|
exports.parseInlineMcpServers = parseInlineMcpServers;
|
|
@@ -555,6 +556,35 @@ function resolveHttpMcpHeaders(server, turnEnv) {
|
|
|
555
556
|
}
|
|
556
557
|
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
557
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* The variable names the Claude CLI's `${NAME}` config expansion accepts. A `bearerTokenEnv`
|
|
561
|
+
* outside this shape would ship a header the CLI sends verbatim, so it falls back to in-memory
|
|
562
|
+
* resolution instead.
|
|
563
|
+
*/
|
|
564
|
+
const CLI_EXPANDABLE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
565
|
+
/**
|
|
566
|
+
* Claude-engine variant of {@link resolveHttpMcpHeaders}: emit a `${NAME}` REFERENCE and let the
|
|
567
|
+
* CLI expand it from its own environment at connect time, instead of resolving the secret here.
|
|
568
|
+
*
|
|
569
|
+
* The Agent SDK serializes the whole mcpServers object into `--mcp-config <json>` argv, and
|
|
570
|
+
* /proc/<pid>/cmdline is world-readable — no ptrace gate, unlike environ — so a resolved
|
|
571
|
+
* Authorization header there is visible to every uid on the host and to execve-argv audit
|
|
572
|
+
* tooling. The reference keeps the turn nonce at environ-level exposure, which the engine child
|
|
573
|
+
* already carries for the egress CLI. Expansion verified live on CLI 2.1.220, including the
|
|
574
|
+
* failure mode this relies on: an UNSET name passes through literally and the data gateway
|
|
575
|
+
* rejects it — fail closed, never fail open.
|
|
576
|
+
*/
|
|
577
|
+
function referenceHttpMcpHeaders(server, turnEnv) {
|
|
578
|
+
const name = server.bearerTokenEnv;
|
|
579
|
+
if (name && !CLI_EXPANDABLE_ENV_NAME.test(name)) {
|
|
580
|
+
return resolveHttpMcpHeaders(server, turnEnv);
|
|
581
|
+
}
|
|
582
|
+
const headers = { ...(server.headers ?? {}) };
|
|
583
|
+
if (name) {
|
|
584
|
+
headers['Authorization'] = `Bearer \${${name}}`;
|
|
585
|
+
}
|
|
586
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
587
|
+
}
|
|
558
588
|
/**
|
|
559
589
|
* Convert resolved pack MCP servers to the inline record shape engines consume.
|
|
560
590
|
*
|
package/out/pack/caps.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CapMeter = exports.CAP_ENV_ALIASES = exports.parseCapPolicy = exports.STRICTEST_ON_BREACH = exports.ON_BREACH_ALIASES = exports.ON_BREACH_ACTIONS = exports.LEASE_CAP_FIELDS = exports.LEASE_CAP_ALIASES = exports.DEPRECATED_CAP_SPELLINGS = void 0;
|
|
7
|
+
exports.resolveOnBreach = resolveOnBreach;
|
|
8
|
+
exports.mergeCaps = mergeCaps;
|
|
9
|
+
exports.capsAreUnbounded = capsAreUnbounded;
|
|
10
|
+
exports.capsFromEnv = capsFromEnv;
|
|
11
|
+
/**
|
|
12
|
+
* Caps and the live kill switch — the enforcement half of the control plane (serve-v4-clean §5).
|
|
13
|
+
*
|
|
14
|
+
* `assertAdmitted` already answers "may this artifact run here, now?" at sandbox CREATION. That is
|
|
15
|
+
* necessary and not sufficient for an enterprise control plane, which also needs:
|
|
16
|
+
*
|
|
17
|
+
* caps — a bound on what one session may consume, enforced by serve rather than requested of
|
|
18
|
+
* the agent. A cap the agent could talk its way past is not a cap.
|
|
19
|
+
* kill — "stop now", not "stop admitting". Lease revocation alone leaves running sandboxes alive
|
|
20
|
+
* until their TTL (up to 30 minutes); an operator hitting the emergency stop means the
|
|
21
|
+
* work ends, so the lease watcher below destroys live sandboxes too.
|
|
22
|
+
*
|
|
23
|
+
* Precedence is deliberate: an operator's local limit may only ever be *tighter* than the
|
|
24
|
+
* control plane's. A self-hosted deployment can impose more restraint on itself, never less —
|
|
25
|
+
* so a compromised or misconfigured instance cannot raise its own ceiling by editing a flag.
|
|
26
|
+
*/
|
|
27
|
+
const lease_schema_1 = require("./lease-schema");
|
|
28
|
+
/**
|
|
29
|
+
* Re-exported so callers outside pack/ have ONE import site for the cap vocabulary.
|
|
30
|
+
*
|
|
31
|
+
* The tables themselves live in `lease-schema.ts`, next to the lease validator that consumes them,
|
|
32
|
+
* because the whole point is that a lease field, an env var and a CLI flag are held to the same
|
|
33
|
+
* vocabulary. Adding an alias in one place changes all three.
|
|
34
|
+
*/
|
|
35
|
+
var lease_schema_2 = require("./lease-schema");
|
|
36
|
+
Object.defineProperty(exports, "DEPRECATED_CAP_SPELLINGS", { enumerable: true, get: function () { return lease_schema_2.DEPRECATED_CAP_SPELLINGS; } });
|
|
37
|
+
Object.defineProperty(exports, "LEASE_CAP_ALIASES", { enumerable: true, get: function () { return lease_schema_2.LEASE_CAP_ALIASES; } });
|
|
38
|
+
Object.defineProperty(exports, "LEASE_CAP_FIELDS", { enumerable: true, get: function () { return lease_schema_2.LEASE_CAP_FIELDS; } });
|
|
39
|
+
Object.defineProperty(exports, "ON_BREACH_ACTIONS", { enumerable: true, get: function () { return lease_schema_2.ON_BREACH_ACTIONS; } });
|
|
40
|
+
Object.defineProperty(exports, "ON_BREACH_ALIASES", { enumerable: true, get: function () { return lease_schema_2.ON_BREACH_ALIASES; } });
|
|
41
|
+
Object.defineProperty(exports, "STRICTEST_ON_BREACH", { enumerable: true, get: function () { return lease_schema_2.STRICTEST_ON_BREACH; } });
|
|
42
|
+
Object.defineProperty(exports, "parseCapPolicy", { enumerable: true, get: function () { return lease_schema_2.parseCapPolicy; } });
|
|
43
|
+
/** Env var aliases, old spelling → current spelling. */
|
|
44
|
+
exports.CAP_ENV_ALIASES = {
|
|
45
|
+
AGENTHIPPO_MAX_CONCURRENT_CELLS: 'AGENTHIPPO_MAX_CONCURRENT_SANDBOXES',
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a breach action from ANY untrusted source (env, CLI flag, lease field).
|
|
49
|
+
*
|
|
50
|
+
* The governing rule, and the reason this is one function rather than three:
|
|
51
|
+
* **an unrecognised action fails closed to the STRICTER action, loudly. It never softens.**
|
|
52
|
+
*
|
|
53
|
+
* It used to soften, in two places at once. `capsFromEnv` mapped an unknown value to `undefined`,
|
|
54
|
+
* and `cli/index.ts` did the same for `--cap-on-breach`; `mergeCaps` then found no
|
|
55
|
+
* `'destroy-sandbox'` on either side and `governor.enforce` defaulted to `revoke-inference`. So a
|
|
56
|
+
* typo — or the perfectly reasonable pre-rename value `destroy-cell` — quietly downgraded
|
|
57
|
+
* enforcement from "end the session" to "take away its model route", while the console showed the
|
|
58
|
+
* stricter setting the operator had typed.
|
|
59
|
+
*
|
|
60
|
+
* @param source what to name in the warning, e.g. `AGENTHIPPO_CAP_ON_BREACH` or `--cap-on-breach`.
|
|
61
|
+
*/
|
|
62
|
+
function resolveOnBreach(raw, source) {
|
|
63
|
+
const value = raw?.trim();
|
|
64
|
+
if (!value) {
|
|
65
|
+
return { action: undefined, warnings: [] };
|
|
66
|
+
}
|
|
67
|
+
if (value in lease_schema_1.ON_BREACH_ACTIONS) {
|
|
68
|
+
return { action: value, warnings: [] };
|
|
69
|
+
}
|
|
70
|
+
const aliased = lease_schema_1.ON_BREACH_ALIASES[value];
|
|
71
|
+
if (aliased) {
|
|
72
|
+
return {
|
|
73
|
+
action: aliased,
|
|
74
|
+
warnings: [
|
|
75
|
+
`${source}="${value}" is DEPRECATED; use "${lease_schema_1.DEPRECATED_CAP_SPELLINGS[value] ?? aliased}". ` +
|
|
76
|
+
`Treating it as "${aliased}". The old spelling will stop being accepted.`,
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
action: lease_schema_1.STRICTEST_ON_BREACH,
|
|
82
|
+
warnings: [
|
|
83
|
+
`${source}="${value}" is not a breach action this build understands. ` +
|
|
84
|
+
`FAILING CLOSED to "${lease_schema_1.STRICTEST_ON_BREACH}" — an unreadable enforcement setting must never ` +
|
|
85
|
+
`weaken enforcement. Known actions: ${Object.keys(lease_schema_1.ON_BREACH_ACTIONS).join(', ')}.`,
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Ceiling on metered sessions, so an unbounded instance cannot grow meters without limit. */
|
|
90
|
+
const MAX_METERED_SESSIONS = 10_000;
|
|
91
|
+
/**
|
|
92
|
+
* Tighten-only merge. `local` may lower any bound the control plane set, never raise it, and may
|
|
93
|
+
* only escalate the breach action (revoke → destroy), never soften it.
|
|
94
|
+
*/
|
|
95
|
+
function mergeCaps(controlPlane, local) {
|
|
96
|
+
const cp = controlPlane ?? {};
|
|
97
|
+
const lo = local ?? {};
|
|
98
|
+
const tighter = (a, b) => {
|
|
99
|
+
if (a === undefined) {
|
|
100
|
+
return b;
|
|
101
|
+
}
|
|
102
|
+
if (b === undefined) {
|
|
103
|
+
return a;
|
|
104
|
+
}
|
|
105
|
+
return Math.min(a, b);
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
maxTurnsPerSession: tighter(cp.maxTurnsPerSession, lo.maxTurnsPerSession),
|
|
109
|
+
maxSessionSeconds: tighter(cp.maxSessionSeconds, lo.maxSessionSeconds),
|
|
110
|
+
maxConcurrentSandboxes: tighter(cp.maxConcurrentSandboxes, lo.maxConcurrentSandboxes),
|
|
111
|
+
// destroy is stricter than revoke; either side may escalate, neither may soften.
|
|
112
|
+
onBreach: cp.onBreach === 'destroy-sandbox' || lo.onBreach === 'destroy-sandbox'
|
|
113
|
+
? 'destroy-sandbox'
|
|
114
|
+
: (cp.onBreach ?? lo.onBreach),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** True when no bound is set at all — lets callers skip the machinery entirely. */
|
|
118
|
+
function capsAreUnbounded(caps) {
|
|
119
|
+
return caps.maxTurnsPerSession === undefined
|
|
120
|
+
&& caps.maxSessionSeconds === undefined
|
|
121
|
+
&& caps.maxConcurrentSandboxes === undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Per-session meter. Checked BEFORE a turn runs: a cap discovered afterwards has already let the
|
|
125
|
+
* spend happen, so `check` is the gate and `record` only advances the counters.
|
|
126
|
+
*/
|
|
127
|
+
class CapMeter {
|
|
128
|
+
caps;
|
|
129
|
+
sessions = new Map();
|
|
130
|
+
constructor(caps) {
|
|
131
|
+
this.caps = caps;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Adopt caps the control plane pushed since boot.
|
|
135
|
+
*
|
|
136
|
+
* Caps used to be read from the lease exactly ONCE, at construction, so tightening a cap in
|
|
137
|
+
* the control plane did nothing until every instance was restarted — the fleet reported the
|
|
138
|
+
* new policy while enforcing the old one. Live-caught by the control-plane acceptance test.
|
|
139
|
+
* Existing per-session counters are deliberately kept: a lowered cap must bite immediately on
|
|
140
|
+
* sessions already over it, not hand them a fresh allowance.
|
|
141
|
+
*/
|
|
142
|
+
setCaps(caps) {
|
|
143
|
+
this.caps = caps;
|
|
144
|
+
}
|
|
145
|
+
/** Current policy in force — surfaced on the admin/health endpoint. */
|
|
146
|
+
policy() {
|
|
147
|
+
return this.caps;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* @param liveSandboxes Current live sandbox count — concurrency is an instance-wide bound, so it is
|
|
151
|
+
* supplied by the caller rather than tracked here.
|
|
152
|
+
*/
|
|
153
|
+
check(sessionKey, liveSandboxes, nowMs = Date.now()) {
|
|
154
|
+
const usage = this.sessions.get(sessionKey);
|
|
155
|
+
if (this.caps.maxTurnsPerSession !== undefined && usage && usage.turns >= this.caps.maxTurnsPerSession) {
|
|
156
|
+
return {
|
|
157
|
+
allowed: false,
|
|
158
|
+
cap: 'turns',
|
|
159
|
+
reason: `session turn cap reached (${usage.turns}/${this.caps.maxTurnsPerSession})`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (this.caps.maxSessionSeconds !== undefined && usage) {
|
|
163
|
+
const elapsed = Math.floor((nowMs - usage.startedAtMs) / 1000);
|
|
164
|
+
if (elapsed >= this.caps.maxSessionSeconds) {
|
|
165
|
+
return {
|
|
166
|
+
allowed: false,
|
|
167
|
+
cap: 'duration',
|
|
168
|
+
reason: `session duration cap reached (${elapsed}s/${this.caps.maxSessionSeconds}s)`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Concurrency applies only to sessions that would need a NEW sandbox; an established session
|
|
173
|
+
// reuses its own, so counting it against the cap would strand it once the fleet filled up.
|
|
174
|
+
if (this.caps.maxConcurrentSandboxes !== undefined && !usage && liveSandboxes >= this.caps.maxConcurrentSandboxes) {
|
|
175
|
+
return {
|
|
176
|
+
allowed: false,
|
|
177
|
+
cap: 'concurrency',
|
|
178
|
+
reason: `instance is at its concurrent sandbox cap (${liveSandboxes}/${this.caps.maxConcurrentSandboxes})`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { allowed: true };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Advance the counters for a turn that actually ran.
|
|
185
|
+
*
|
|
186
|
+
* Metering happens even when caps are currently unbounded, and that is the point: caps can be
|
|
187
|
+
* tightened from the control plane mid-flight, and a session that ran fifty turns under an
|
|
188
|
+
* unbounded policy must not then receive a fresh allowance the moment a cap appears. Counting
|
|
189
|
+
* only while a cap happens to be set is precisely the kind of gap an agent can be steered into.
|
|
190
|
+
*/
|
|
191
|
+
record(sessionKey, nowMs = Date.now()) {
|
|
192
|
+
const existing = this.sessions.get(sessionKey);
|
|
193
|
+
if (existing) {
|
|
194
|
+
existing.turns += 1;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Bounded so an unbounded, long-lived instance cannot accumulate meters forever; the
|
|
198
|
+
// oldest session is the one least likely to still be running.
|
|
199
|
+
if (this.sessions.size >= MAX_METERED_SESSIONS) {
|
|
200
|
+
let oldestKey;
|
|
201
|
+
let oldestAt = Infinity;
|
|
202
|
+
for (const [key, usage] of this.sessions) {
|
|
203
|
+
if (usage.startedAtMs < oldestAt) {
|
|
204
|
+
oldestAt = usage.startedAtMs;
|
|
205
|
+
oldestKey = key;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (oldestKey) {
|
|
209
|
+
this.sessions.delete(oldestKey);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this.sessions.set(sessionKey, { turns: 1, startedAtMs: nowMs });
|
|
213
|
+
}
|
|
214
|
+
/** Drop a session's meter — called when its sandbox is destroyed so a fresh session starts clean. */
|
|
215
|
+
forget(sessionKey) {
|
|
216
|
+
this.sessions.delete(sessionKey);
|
|
217
|
+
}
|
|
218
|
+
usage(sessionKey) {
|
|
219
|
+
const u = this.sessions.get(sessionKey);
|
|
220
|
+
if (!u) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return { turns: u.turns, ageSeconds: Math.floor((Date.now() - u.startedAtMs) / 1000) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
exports.CapMeter = CapMeter;
|
|
227
|
+
/**
|
|
228
|
+
* Parse `AGENTHIPPO_MAX_*` style env, ignoring blanks and non-numbers rather than guessing.
|
|
229
|
+
*
|
|
230
|
+
* LOCAL config, so the rules differ from a lease deliberately: a typo in an operator's own env is
|
|
231
|
+
* dropped (they can see and fix it, and dropping it only affects their own instance), whereas the
|
|
232
|
+
* same typo arriving from a control plane means the fleet believes a cap is in force that is not.
|
|
233
|
+
* The `onBreach` vocabulary, however, comes from the same table `parseCapPolicy` uses, so a value
|
|
234
|
+
* that is valid in a lease is valid here and vice versa — including any back-compat aliases layered
|
|
235
|
+
* into `ON_BREACH_ALIASES`.
|
|
236
|
+
*/
|
|
237
|
+
function capsFromEnv(env = process.env, warn = (m) => console.warn(`Warning: ${m}`)) {
|
|
238
|
+
const num = (raw) => {
|
|
239
|
+
const v = parseInt((raw ?? '').trim(), 10);
|
|
240
|
+
return Number.isFinite(v) && v > 0 ? v : undefined;
|
|
241
|
+
};
|
|
242
|
+
/** Current spelling wins; a deprecated one is honoured once, loudly. */
|
|
243
|
+
const numWithAlias = (current) => {
|
|
244
|
+
const direct = num(env[current]);
|
|
245
|
+
if (direct !== undefined) {
|
|
246
|
+
return direct;
|
|
247
|
+
}
|
|
248
|
+
for (const [old, replacement] of Object.entries(exports.CAP_ENV_ALIASES)) {
|
|
249
|
+
if (replacement !== current) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const legacy = num(env[old]);
|
|
253
|
+
if (legacy === undefined) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
warn(`${old} is DEPRECATED; use ${current}. Honouring it for now.`);
|
|
257
|
+
return legacy;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
};
|
|
261
|
+
const breach = resolveOnBreach(env.AGENTHIPPO_CAP_ON_BREACH, 'AGENTHIPPO_CAP_ON_BREACH');
|
|
262
|
+
for (const w of breach.warnings) {
|
|
263
|
+
warn(w);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
maxTurnsPerSession: numWithAlias('AGENTHIPPO_MAX_TURNS_PER_SESSION'),
|
|
267
|
+
maxSessionSeconds: numWithAlias('AGENTHIPPO_MAX_SESSION_SECONDS'),
|
|
268
|
+
maxConcurrentSandboxes: numWithAlias('AGENTHIPPO_MAX_CONCURRENT_SANDBOXES'),
|
|
269
|
+
onBreach: breach.action,
|
|
270
|
+
};
|
|
271
|
+
}
|