@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,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* A VS Code setting with no `scope` defaults to `window`, which means a repository's
|
|
6
|
+
* `.vscode/settings.json` can set it. For a setting that names an auth endpoint, an install
|
|
7
|
+
* source, a credential or a spawned command, that is remote code execution or credential theft
|
|
8
|
+
* by checkout. This test is the regression guard: adding a sensitive setting without a scope
|
|
9
|
+
* fails here.
|
|
10
|
+
*--------------------------------------------------------------------------------------------*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
require("mocha");
|
|
46
|
+
const assert = __importStar(require("assert"));
|
|
47
|
+
const fs = __importStar(require("node:fs"));
|
|
48
|
+
const path = __importStar(require("node:path"));
|
|
49
|
+
function loadPackageJson() {
|
|
50
|
+
// out/test/<file>.js → out → extensions/agentide
|
|
51
|
+
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
52
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Settings a workspace must never be able to set. Each entry says WHY, because the reason is the
|
|
56
|
+
* thing a future reviewer needs in order to decide whether a new setting belongs on this list.
|
|
57
|
+
*/
|
|
58
|
+
const MUST_NOT_BE_WORKSPACE_SETTABLE = {
|
|
59
|
+
'agentide.auth.baseUrl': 'receives the PKCE code and the AgentHippo access token',
|
|
60
|
+
'agentide.storeRepo': 'names where packs/skills/engines are installed from',
|
|
61
|
+
'agentide.store.indexUrl': 'names where the install index is fetched from',
|
|
62
|
+
'agentide.anywhere.installSource': 'names a globally installed package whose postinstall runs',
|
|
63
|
+
'agentide.anywhere.enabled': 'enables a gateway process',
|
|
64
|
+
'agentide.anywhere.autoStart': 'starts a gateway process',
|
|
65
|
+
'agentide.anywhere.autoConnect': 'connects a gateway process',
|
|
66
|
+
'agentide.anywhere.port': 'selects the local gateway port',
|
|
67
|
+
'agentide.anywhere.hidden': 'an enterprise admin control',
|
|
68
|
+
'agentide.cli.path': 'an executable path',
|
|
69
|
+
'agentide.cli.installSource': 'a download-and-execute source',
|
|
70
|
+
'agentide.additionalEngineDir': 'a directory scanned for engines that are then executed',
|
|
71
|
+
'agentide.fallbackDirectory': 'a filesystem location used as the working root',
|
|
72
|
+
'agentide.permissions.fileAccess': 'the agent filesystem permission policy',
|
|
73
|
+
'agentide.gateway.url': 'a network destination carrying agent traffic',
|
|
74
|
+
'agentide.gateway.token': 'a credential',
|
|
75
|
+
'agentide.gateway.autoConnect': 'connects to a gateway at startup',
|
|
76
|
+
'agentide.gateway.autoRegister': 'registers packs with a gateway',
|
|
77
|
+
'agentide.providers.litellm.baseUrl': 'receives every prompt and the routing API key',
|
|
78
|
+
'agentide.providers.anthropic.baseUrl': 'receives every prompt and the Anthropic key',
|
|
79
|
+
'agentide.AgentHippoModelGateway.url': 'receives every prompt and the sign-in JWT',
|
|
80
|
+
'agentide.skills.remote.enabled': 'enables fetching agent instructions from a registry',
|
|
81
|
+
'agentide.skills.remote.registryUrl': 'names an install source for agent instructions',
|
|
82
|
+
'agentide.skills.remote.syncOnStartup': 'triggers that fetch automatically',
|
|
83
|
+
'agentide.skills.remote.allowOverwriteLocal': 'lets fetched content replace local files',
|
|
84
|
+
'agentide.analytics.fleetScriptSources': 'every entry is a command line this extension spawns',
|
|
85
|
+
'agentide.analytics.fleetSources': 'selects which of those command lines run',
|
|
86
|
+
'agentide.analytics.defaultDashboardPrompt': 'a prompt fed to an agent that writes and runs scripts',
|
|
87
|
+
'agentide.analytics.duckdb.tempDirectory': 'a filesystem path written to by the extension',
|
|
88
|
+
};
|
|
89
|
+
const SAFE_SCOPES = new Set(['application', 'machine']);
|
|
90
|
+
suite('configuration scopes', () => {
|
|
91
|
+
const pkg = loadPackageJson();
|
|
92
|
+
const properties = pkg.contributes.configuration.properties;
|
|
93
|
+
test('every security-sensitive setting is declared and application/machine scoped', () => {
|
|
94
|
+
const failures = [];
|
|
95
|
+
for (const [key, why] of Object.entries(MUST_NOT_BE_WORKSPACE_SETTABLE)) {
|
|
96
|
+
const prop = properties[key];
|
|
97
|
+
if (!prop) {
|
|
98
|
+
failures.push(`${key}: not declared in contributes.configuration (undeclared settings are workspace-settable) — ${why}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const scope = prop.scope ?? 'window';
|
|
102
|
+
if (!SAFE_SCOPES.has(scope)) {
|
|
103
|
+
failures.push(`${key}: scope "${scope}" lets a repository set it — ${why}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
assert.deepStrictEqual(failures, [], `\n${failures.join('\n')}\n`);
|
|
107
|
+
});
|
|
108
|
+
test('the attack from the audit is closed: agentide.auth.baseUrl is application-scoped', () => {
|
|
109
|
+
assert.strictEqual(properties['agentide.auth.baseUrl'].scope, 'application');
|
|
110
|
+
});
|
|
111
|
+
test('the pinned gateway install default is preserved', () => {
|
|
112
|
+
const prop = properties['agentide.anywhere.installSource'];
|
|
113
|
+
assert.strictEqual(prop.default, '@agenthippo-ai/hippoclaw@2026.7.28', 'the deliberate version pin (and its rationale) must not be replaced by a floating tag');
|
|
114
|
+
});
|
|
115
|
+
test('the extension declares limited untrusted-workspace support', () => {
|
|
116
|
+
const caps = pkg.capabilities?.untrustedWorkspaces;
|
|
117
|
+
assert.ok(caps, 'capabilities.untrustedWorkspaces is declared');
|
|
118
|
+
assert.strictEqual(caps.supported, 'limited');
|
|
119
|
+
assert.ok((caps.description ?? '').length > 40, 'the description explains what is limited');
|
|
120
|
+
});
|
|
121
|
+
test('every sensitive setting is also listed as a restricted configuration', () => {
|
|
122
|
+
const restricted = new Set(pkg.capabilities?.untrustedWorkspaces?.restrictedConfigurations ?? []);
|
|
123
|
+
const missing = Object.keys(MUST_NOT_BE_WORKSPACE_SETTABLE).filter((k) => !restricted.has(k));
|
|
124
|
+
assert.deepStrictEqual(missing, [], `not listed in restrictedConfigurations: ${missing.join(', ')}`);
|
|
125
|
+
});
|
|
126
|
+
test('restrictedConfigurations names only settings that exist', () => {
|
|
127
|
+
const unknown = (pkg.capabilities?.untrustedWorkspaces?.restrictedConfigurations ?? [])
|
|
128
|
+
.filter((k) => !properties[k]);
|
|
129
|
+
assert.deepStrictEqual(unknown, [], `restrictedConfigurations references undeclared settings: ${unknown.join(', ')}`);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -247,4 +247,36 @@ suite('data-plane engine wiring: loadMcpServers + claude/custom shape (no API ke
|
|
|
247
247
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
248
248
|
}
|
|
249
249
|
});
|
|
250
|
+
test('the engine-home key crosses a process boundary that the credential deliberately does not', async () => {
|
|
251
|
+
const base = path.join(os.tmpdir(), 'ah-home-base');
|
|
252
|
+
// In serve: the credential frame is authoritative and no wire value is needed.
|
|
253
|
+
const inServe = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com' }, () => (0, paths_1.userScopedEngineHomeFromKey)(base, (0, credential_context_1.resolveEngineHomeUserKey)()));
|
|
254
|
+
assert.strictEqual(inServe, (0, paths_1.userScopedEngineHome)(base, 'alice@corp.com'));
|
|
255
|
+
// In a sandbox child: NO credential frame — which is the whole reason this regressed. The
|
|
256
|
+
// key arrives on the run frame and must land on the identical path.
|
|
257
|
+
const key = (0, paths_1.engineHomeUserKey)('alice@corp.com');
|
|
258
|
+
const inSandbox = (0, paths_1.userScopedEngineHomeFromKey)(base, (0, credential_context_1.resolveEngineHomeUserKey)(key));
|
|
259
|
+
assert.strictEqual(inSandbox, inServe, 'a sandboxed turn must resolve the same home as an in-process one');
|
|
260
|
+
// Distinct users stay distinct across the boundary — the property that was silently absent.
|
|
261
|
+
const bob = (0, paths_1.userScopedEngineHomeFromKey)(base, (0, credential_context_1.resolveEngineHomeUserKey)((0, paths_1.engineHomeUserKey)('bob@corp.com')));
|
|
262
|
+
assert.notStrictEqual(inSandbox, bob);
|
|
263
|
+
assert.ok(!inSandbox.includes('alice@corp.com'), 'the key is a hash, never the raw identity');
|
|
264
|
+
});
|
|
265
|
+
test('a credential frame outranks the wire, and a malformed key is ignored rather than trusted', async () => {
|
|
266
|
+
const base = path.join(os.tmpdir(), 'ah-home-base');
|
|
267
|
+
// A child cannot widen its own scope by sending someone else's key: where a frame exists,
|
|
268
|
+
// it wins.
|
|
269
|
+
const spoofed = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com' }, () => (0, credential_context_1.resolveEngineHomeUserKey)((0, paths_1.engineHomeUserKey)('bob@corp.com')));
|
|
270
|
+
assert.strictEqual(spoofed, (0, paths_1.engineHomeUserKey)('alice@corp.com'));
|
|
271
|
+
// The key is a path segment taken off the wire, so anything but the hex this produces
|
|
272
|
+
// falls back to the base dir instead of aiming an engine home elsewhere on disk.
|
|
273
|
+
for (const bad of ['../../etc', 'alice@corp.com', 'ZZZZ', '', 'abc']) {
|
|
274
|
+
assert.strictEqual((0, paths_1.userScopedEngineHomeFromKey)(base, bad), base, `rejected: ${JSON.stringify(bad)}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
test('no verified user anywhere leaves the path untouched, so the IDE and single-tenant CLI are unaffected', () => {
|
|
278
|
+
const base = path.join(os.tmpdir(), 'ah-home-base');
|
|
279
|
+
assert.strictEqual((0, credential_context_1.resolveEngineHomeUserKey)(), undefined);
|
|
280
|
+
assert.strictEqual((0, paths_1.userScopedEngineHomeFromKey)(base, (0, credential_context_1.resolveEngineHomeUserKey)()), base);
|
|
281
|
+
});
|
|
250
282
|
});
|
|
@@ -47,6 +47,8 @@ const path = __importStar(require("path"));
|
|
|
47
47
|
const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
48
48
|
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
49
49
|
const data_plane_1 = require("../cli/data-plane");
|
|
50
|
+
const data_plane_2 = require("../cli/commands/data-plane");
|
|
51
|
+
const identity_1 = require("../analytics/identity");
|
|
50
52
|
// The fixture connector must live INSIDE the extension tree so its `import` of
|
|
51
53
|
// @modelcontextprotocol/sdk resolves by walking up to extensions/agentide/node_modules.
|
|
52
54
|
const FIXTURE_DIR = path.join(__dirname, 'data-plane-fixture');
|
|
@@ -242,3 +244,381 @@ suite('data-plane: TurnRegistry unit properties', () => {
|
|
|
242
244
|
assert.strictEqual(reg.size, 1, 'the expired entry must have been pruned');
|
|
243
245
|
});
|
|
244
246
|
});
|
|
247
|
+
suite('data-plane: sidecar EDGE registration (P1 fix — no forging authority in the agent container)', function () {
|
|
248
|
+
this.timeout(30_000);
|
|
249
|
+
const EDGE_TOKEN = 'edge-only-secret';
|
|
250
|
+
let sidecar;
|
|
251
|
+
let controlBase;
|
|
252
|
+
suiteSetup(async () => {
|
|
253
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
254
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
255
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
256
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
|
|
257
|
+
registerToken: EDGE_TOKEN,
|
|
258
|
+
gatewayPort: 0,
|
|
259
|
+
controlPort: 0,
|
|
260
|
+
bindHost: '127.0.0.1',
|
|
261
|
+
});
|
|
262
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
263
|
+
});
|
|
264
|
+
suiteTeardown(async () => {
|
|
265
|
+
await sidecar?.close();
|
|
266
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
267
|
+
});
|
|
268
|
+
async function edgeRegister(user, token = EDGE_TOKEN) {
|
|
269
|
+
// Shaped like Caddy forward_auth: GET, no body, verified identity in X-Agent-User.
|
|
270
|
+
return fetch(`${controlBase}/edge/turn`, {
|
|
271
|
+
headers: { Authorization: `Bearer ${token}`, 'X-Agent-User': user },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
test('the edge mints a nonce for the verified user; the nonce works at the gateway as that user', async () => {
|
|
275
|
+
const res = await edgeRegister('alice@corp.com');
|
|
276
|
+
assert.strictEqual(res.status, 200);
|
|
277
|
+
const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
|
|
278
|
+
assert.ok(nonce, 'nonce must ride the response header for Caddy copy_headers');
|
|
279
|
+
const who = await callWhoami(sidecar.gatewayUrl, nonce);
|
|
280
|
+
assert.strictEqual(who.userId, 'alice@corp.com');
|
|
281
|
+
});
|
|
282
|
+
test('edge registration without the edge token → 401 (the engine can route here but cannot forge)', async () => {
|
|
283
|
+
const res = await edgeRegister('victim@corp.com', 'stolen-or-missing');
|
|
284
|
+
assert.strictEqual(res.status, 401);
|
|
285
|
+
});
|
|
286
|
+
test('edge registration without a verified identity header → 400, fail closed', async () => {
|
|
287
|
+
const res = await fetch(`${controlBase}/edge/turn`, { headers: { Authorization: `Bearer ${EDGE_TOKEN}` } });
|
|
288
|
+
assert.strictEqual(res.status, 400);
|
|
289
|
+
});
|
|
290
|
+
test('serve releases an adopted turn via the gateway using ONLY the nonce; the nonce then fails closed', async () => {
|
|
291
|
+
const res = await edgeRegister('bob@corp.com');
|
|
292
|
+
const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
|
|
293
|
+
const edge = new data_plane_1.EdgeDataPlane({ gatewayUrl: sidecar.gatewayUrl });
|
|
294
|
+
const turn = edge.adoptTurn(nonce);
|
|
295
|
+
assert.strictEqual(turn.env.AGENTHIPPO_TURN_NONCE, nonce);
|
|
296
|
+
assert.strictEqual(turn.env.AGENTHIPPO_DATA_MCP_URL, sidecar.gatewayUrl);
|
|
297
|
+
const who = await callWhoami(sidecar.gatewayUrl, nonce);
|
|
298
|
+
assert.strictEqual(who.userId, 'bob@corp.com');
|
|
299
|
+
await turn.release();
|
|
300
|
+
await assert.rejects(() => callWhoami(sidecar.gatewayUrl, nonce), /401|Unauthorized/i);
|
|
301
|
+
});
|
|
302
|
+
/**
|
|
303
|
+
* PR-F1 / agent-identity.md §8.5 G1.
|
|
304
|
+
*
|
|
305
|
+
* The edge forwarded `X-Agent-User` and nothing else, and `/edge/turn` registered with no
|
|
306
|
+
* inboundToken — so edge mode silently supported the `identity` provider ALONE. A
|
|
307
|
+
* `passthrough` deployment (Databricks-style OBO) booted clean and then failed closed on
|
|
308
|
+
* every single tool call, and an RFC 8693 `exchange` provider had no subject_token at all.
|
|
309
|
+
*/
|
|
310
|
+
test('release with a bogus nonce is a harmless no-op (204) and touches no other turn', async () => {
|
|
311
|
+
const res = await edgeRegister('carol@corp.com');
|
|
312
|
+
const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
|
|
313
|
+
const del = await fetch(new URL('/turn', sidecar.gatewayUrl), {
|
|
314
|
+
method: 'DELETE',
|
|
315
|
+
headers: { Authorization: 'Bearer not-a-real-nonce' },
|
|
316
|
+
});
|
|
317
|
+
assert.strictEqual(del.status, 204);
|
|
318
|
+
const who = await callWhoami(sidecar.gatewayUrl, nonce);
|
|
319
|
+
assert.strictEqual(who.userId, 'carol@corp.com', 'an unrelated release must not end a live turn');
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
/**
|
|
323
|
+
* PLAN/agent-identity.md §8.5 G1 (build record §10.5).
|
|
324
|
+
*
|
|
325
|
+
* The edge forwarded `X-Agent-User` (an identifier) and nothing else, and `/edge/turn` registered
|
|
326
|
+
* with no inboundToken. So edge mode silently supported the `identity` provider ALONE: a
|
|
327
|
+
* `passthrough` deployment (Databricks-style OBO) booted clean and then failed closed on every
|
|
328
|
+
* single tool call, and an RFC 8693 `exchange` provider had no `subject_token` to exchange at all.
|
|
329
|
+
*
|
|
330
|
+
* This suite runs a PASSTHROUGH sidecar behind the edge — the combination that could not work.
|
|
331
|
+
*/
|
|
332
|
+
suite('data-plane: sidecar EDGE + passthrough (PR-F1 — the edge must forward the assertion)', function () {
|
|
333
|
+
this.timeout(30_000);
|
|
334
|
+
const EDGE_TOKEN = 'edge-assertion-token';
|
|
335
|
+
let sidecar;
|
|
336
|
+
let controlBase;
|
|
337
|
+
suiteSetup(async () => {
|
|
338
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
339
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
340
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
341
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'passthrough' },
|
|
342
|
+
registerToken: EDGE_TOKEN,
|
|
343
|
+
gatewayPort: 0,
|
|
344
|
+
controlPort: 0,
|
|
345
|
+
bindHost: '127.0.0.1',
|
|
346
|
+
});
|
|
347
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
348
|
+
});
|
|
349
|
+
suiteTeardown(async () => {
|
|
350
|
+
await sidecar?.close();
|
|
351
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
352
|
+
});
|
|
353
|
+
const register = (user, assertion) => fetch(`${controlBase}/edge/turn`, {
|
|
354
|
+
headers: {
|
|
355
|
+
Authorization: `Bearer ${EDGE_TOKEN}`,
|
|
356
|
+
'X-Agent-User': user,
|
|
357
|
+
...(assertion ? { [data_plane_1.ASSERTION_HEADER]: assertion } : {}),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
test('with the assertion forwarded, a passthrough turn reaches the backend as that user', async () => {
|
|
361
|
+
const res = await register('alice@corp.com', 'Bearer alice-platform-token');
|
|
362
|
+
assert.strictEqual(res.status, 200);
|
|
363
|
+
const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
364
|
+
assert.strictEqual(who.userId, 'alice@corp.com');
|
|
365
|
+
assert.strictEqual(who.credential, 'alice-platform-token', 'the assertion becomes the turn inboundToken, and passthrough hands it to the connector');
|
|
366
|
+
});
|
|
367
|
+
test('a bare "Bearer " prefix is stripped — the connector gets the token, not the scheme', async () => {
|
|
368
|
+
const res = await register('bob@corp.com', 'Bearer bob-token');
|
|
369
|
+
const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
370
|
+
assert.strictEqual(who.credential, 'bob-token');
|
|
371
|
+
});
|
|
372
|
+
test('an assertion sent WITHOUT the scheme prefix is taken verbatim', async () => {
|
|
373
|
+
const res = await register('carol@corp.com', 'carol-raw-token');
|
|
374
|
+
const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
375
|
+
assert.strictEqual(who.credential, 'carol-raw-token');
|
|
376
|
+
});
|
|
377
|
+
test('WITHOUT the assertion the turn still registers, but every call fails closed — the pre-PR-F1 behaviour', async () => {
|
|
378
|
+
const res = await register('dave@corp.com');
|
|
379
|
+
assert.strictEqual(res.status, 200, 'registration itself must not depend on the assertion');
|
|
380
|
+
const { client, transport } = mcpClient(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
381
|
+
await client.connect(transport);
|
|
382
|
+
const out = await client.callTool({ name: 'whoami', arguments: {} });
|
|
383
|
+
await client.close();
|
|
384
|
+
assert.strictEqual(out.isError, true);
|
|
385
|
+
assert.match(out.content[0].text, /fail-closed/, 'no inbound credential must never fall back to a shared one');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
/**
|
|
389
|
+
* PLAN/agent-identity.md §10.1 — the audit writer.
|
|
390
|
+
*
|
|
391
|
+
* The sidecar accepted an optional `audit` and `dataPlaneSidecarCommand` never passed one. Because
|
|
392
|
+
* every emit site is `opts.audit?.(…)`, the entire data-plane trust-boundary record was silently
|
|
393
|
+
* discarded in sidecar and edge mode — the two shapes the blueprints deploy. The compliance claim
|
|
394
|
+
* ("which user touched which rows") had no writer exactly where it was being made.
|
|
395
|
+
*
|
|
396
|
+
* These tests pin both halves: the sink the command builds actually writes, and the sidecar
|
|
397
|
+
* actually emits through it on the authorize / deny / reject paths.
|
|
398
|
+
*/
|
|
399
|
+
suite('data-plane: sidecar audit sink (PR-0 — the record must have a writer)', function () {
|
|
400
|
+
this.timeout(30_000);
|
|
401
|
+
const TOKEN = 'audit-suite-token';
|
|
402
|
+
const AUDIT_ROOT = path.join(FIXTURE_DIR, 'audit-state');
|
|
403
|
+
let sidecar;
|
|
404
|
+
let controlBase;
|
|
405
|
+
let auditDir;
|
|
406
|
+
const rows = () => {
|
|
407
|
+
if (!fs.existsSync(auditDir)) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
return fs.readdirSync(auditDir)
|
|
411
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
412
|
+
.flatMap((f) => fs.readFileSync(path.join(auditDir, f), 'utf8')
|
|
413
|
+
.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
|
|
414
|
+
};
|
|
415
|
+
// The writer is fire-and-forget (`void writer.append(...)`) so the audit write must never
|
|
416
|
+
// delay a tool call. Poll briefly rather than sleeping a fixed amount.
|
|
417
|
+
const waitForEvent = async (eventType,
|
|
418
|
+
// Without a predicate this matches the FIRST row of the type, which may be one an earlier
|
|
419
|
+
// test wrote — and then the row under test may not have landed yet. Pass a predicate
|
|
420
|
+
// whenever a specific row is meant.
|
|
421
|
+
where = () => true, timeoutMs = 5_000) => {
|
|
422
|
+
const deadline = Date.now() + timeoutMs;
|
|
423
|
+
for (;;) {
|
|
424
|
+
const hit = rows().find((r) => r.event_type === eventType && where(r.payload));
|
|
425
|
+
if (hit) {
|
|
426
|
+
return hit;
|
|
427
|
+
}
|
|
428
|
+
if (Date.now() > deadline) {
|
|
429
|
+
assert.fail(`no matching "${eventType}" event within ${timeoutMs}ms; saw: ${JSON.stringify(rows().map((r) => r.event_type))}`);
|
|
430
|
+
}
|
|
431
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
suiteSetup(async () => {
|
|
435
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
436
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
437
|
+
const sink = await (0, data_plane_2.createDataPlaneAuditSink)(AUDIT_ROOT);
|
|
438
|
+
assert.strictEqual(sink.ok, true, sink.error);
|
|
439
|
+
assert.strictEqual(sink.ephemeral, false, 'an explicit state dir is durable');
|
|
440
|
+
auditDir = sink.auditDir;
|
|
441
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
442
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
|
|
443
|
+
registerToken: TOKEN,
|
|
444
|
+
gatewayPort: 0,
|
|
445
|
+
controlPort: 0,
|
|
446
|
+
bindHost: '127.0.0.1',
|
|
447
|
+
audit: sink.audit,
|
|
448
|
+
});
|
|
449
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
450
|
+
});
|
|
451
|
+
suiteTeardown(async () => {
|
|
452
|
+
await sidecar?.close();
|
|
453
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
454
|
+
});
|
|
455
|
+
test('an authorized tool call is recorded with the tool and the verified principal', async () => {
|
|
456
|
+
const res = await fetch(`${controlBase}/edge/turn`, {
|
|
457
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'dana@corp.com' },
|
|
458
|
+
});
|
|
459
|
+
const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
|
|
460
|
+
const who = await callWhoami(sidecar.gatewayUrl, nonce);
|
|
461
|
+
assert.strictEqual(who.userId, 'dana@corp.com');
|
|
462
|
+
const ev = await waitForEvent('tool_authorize');
|
|
463
|
+
const payload = ev.payload;
|
|
464
|
+
assert.strictEqual(payload.tool, 'whoami');
|
|
465
|
+
assert.strictEqual(payload.userId, (0, identity_1.hashIdentity)('dana@corp.com'));
|
|
466
|
+
assert.strictEqual(payload.provider, 'identity');
|
|
467
|
+
});
|
|
468
|
+
test('an edge registration is recorded', async () => {
|
|
469
|
+
const ev = await waitForEvent('sidecar_edge_register');
|
|
470
|
+
assert.strictEqual(ev.payload.userId, (0, identity_1.hashIdentity)('dana@corp.com'));
|
|
471
|
+
});
|
|
472
|
+
/**
|
|
473
|
+
* These records ship wholesale to the evidence server, so whatever lands here leaves the
|
|
474
|
+
* machine. The tool-event lane already pseudonymises (ObservableStreamEmitter → hashIdentity);
|
|
475
|
+
* the data-plane lane must not be the one that exports raw addresses. In EDGE mode the sidecar
|
|
476
|
+
* registers with `sessionKey: userId`, so without this the evidence FILE is `alice@corp.com.jsonl`.
|
|
477
|
+
*/
|
|
478
|
+
test('no raw address reaches the record — not in a payload, not in a filename', () => {
|
|
479
|
+
const files = fs.readdirSync(auditDir);
|
|
480
|
+
assert.ok(files.length > 0, 'the suite must have produced evidence by now');
|
|
481
|
+
for (const f of files) {
|
|
482
|
+
assert.ok(!f.includes('@'), `evidence filename must not carry an address: ${f}`);
|
|
483
|
+
}
|
|
484
|
+
const serialized = JSON.stringify(rows());
|
|
485
|
+
const anon = (0, identity_1.hashIdentity)('dana@corp.com');
|
|
486
|
+
assert.ok(anon, 'hashIdentity must yield a pseudonym for a non-empty address');
|
|
487
|
+
assert.ok(!serialized.includes('dana@corp.com'), 'a raw address must never reach the record');
|
|
488
|
+
assert.ok(serialized.includes(anon), 'the salted hash is the join key and must be present');
|
|
489
|
+
});
|
|
490
|
+
test('a rejected registration is recorded — the reject path is the one an auditor asks about', async () => {
|
|
491
|
+
const res = await fetch(`${controlBase}/edge/turn`, {
|
|
492
|
+
headers: { Authorization: 'Bearer wrong-token', 'X-Agent-User': 'mallory@corp.com' },
|
|
493
|
+
});
|
|
494
|
+
assert.strictEqual(res.status, 401);
|
|
495
|
+
const ev = await waitForEvent('sidecar_register_reject');
|
|
496
|
+
assert.strictEqual(ev.payload.reason, 'bad_token');
|
|
497
|
+
});
|
|
498
|
+
test('an unknown nonce at the gateway is recorded, and never logs the raw nonce', async () => {
|
|
499
|
+
await assert.rejects(() => callWhoami(sidecar.gatewayUrl, 'forged-nonce-value'), /401|Unauthorized/i);
|
|
500
|
+
const ev = await waitForEvent('nonce_reject');
|
|
501
|
+
const payload = ev.payload;
|
|
502
|
+
assert.strictEqual(payload.reason, 'unknown_or_expired');
|
|
503
|
+
assert.ok(payload.nonce_sha256_prefix, 'a hash prefix must be present');
|
|
504
|
+
const serialized = JSON.stringify(rows());
|
|
505
|
+
assert.ok(!serialized.includes('forged-nonce-value'), 'a raw nonce must never reach the audit record');
|
|
506
|
+
});
|
|
507
|
+
test('the assertion is fingerprinted in the record, never carried raw', async () => {
|
|
508
|
+
const secret = 'super-secret-user-assertion-value';
|
|
509
|
+
await fetch(`${controlBase}/edge/turn`, {
|
|
510
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'erin@corp.com', [data_plane_1.ASSERTION_HEADER]: `Bearer ${secret}` },
|
|
511
|
+
});
|
|
512
|
+
const ev = await waitForEvent('sidecar_edge_register', (p) => p.userId === (0, identity_1.hashIdentity)('erin@corp.com'));
|
|
513
|
+
assert.match(String(ev.payload.assertion), /^sha256:[0-9a-f]{12}$/, 'a hash prefix must prove which assertion the turn used');
|
|
514
|
+
assert.ok(!JSON.stringify(rows()).includes(secret), 'a user assertion is a bearer credential and must never reach the record');
|
|
515
|
+
});
|
|
516
|
+
test('an unwritable audit root reports ok:false rather than throwing, so the caller sets the policy', async () => {
|
|
517
|
+
// A path whose parent is a FILE — mkdir -p cannot succeed.
|
|
518
|
+
const blocker = path.join(FIXTURE_DIR, 'not-a-dir');
|
|
519
|
+
fs.writeFileSync(blocker, 'x', 'utf8');
|
|
520
|
+
const sink = await (0, data_plane_2.createDataPlaneAuditSink)(path.join(blocker, 'nested'));
|
|
521
|
+
assert.strictEqual(sink.ok, false);
|
|
522
|
+
assert.ok(sink.error, 'the failure reason must be reportable to the operator');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
/**
|
|
526
|
+
* PLAN/agent-identity.md §10.6 — the audit stamp and the agent field.
|
|
527
|
+
*
|
|
528
|
+
* Design §1's third failure was "the auditor's second question is unanswerable" — *why could the
|
|
529
|
+
* invoice agent reach the HR schema at all*. PR-0 gave the record a writer, which made "which user
|
|
530
|
+
* touched which rows" answerable. It did not make "via which agent" answerable: rows carried
|
|
531
|
+
* `{tool, userId, provider}` and nothing about the pack. The agent was only ever IMPLIED, by which
|
|
532
|
+
* deployment happened to write the file — and that implication is exactly what breaks when two
|
|
533
|
+
* serves share one connector (§4.4).
|
|
534
|
+
*/
|
|
535
|
+
suite('data-plane: every audit row names the AGENT, not just the user', function () {
|
|
536
|
+
this.timeout(30_000);
|
|
537
|
+
const TOKEN = 'agent-stamp-token';
|
|
538
|
+
const AUDIT_ROOT = path.join(FIXTURE_DIR, 'agent-stamp-state');
|
|
539
|
+
const LOADED = { pack: 'invoice-agent', version: '1.4.0' };
|
|
540
|
+
let sidecar;
|
|
541
|
+
let controlBase;
|
|
542
|
+
let auditDir;
|
|
543
|
+
const rows = () => (fs.existsSync(auditDir) ? fs.readdirSync(auditDir) : [])
|
|
544
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
545
|
+
.flatMap((f) => fs.readFileSync(path.join(auditDir, f), 'utf8')
|
|
546
|
+
.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
|
|
547
|
+
const waitFor = async (pred) => {
|
|
548
|
+
const deadline = Date.now() + 5_000;
|
|
549
|
+
for (;;) {
|
|
550
|
+
const hit = rows().find(pred);
|
|
551
|
+
if (hit) {
|
|
552
|
+
return hit;
|
|
553
|
+
}
|
|
554
|
+
if (Date.now() > deadline) {
|
|
555
|
+
assert.fail(`no matching row; saw ${JSON.stringify(rows().map((r) => r.event_type))}`);
|
|
556
|
+
}
|
|
557
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
suiteSetup(async () => {
|
|
561
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
562
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
563
|
+
const sink = await (0, data_plane_2.createDataPlaneAuditSink)(AUDIT_ROOT, LOADED);
|
|
564
|
+
auditDir = sink.auditDir;
|
|
565
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
566
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
|
|
567
|
+
registerToken: TOKEN, gatewayPort: 0, controlPort: 0, bindHost: '127.0.0.1',
|
|
568
|
+
audit: sink.audit, agent: LOADED,
|
|
569
|
+
});
|
|
570
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
571
|
+
});
|
|
572
|
+
suiteTeardown(async () => {
|
|
573
|
+
await sidecar?.close();
|
|
574
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
575
|
+
});
|
|
576
|
+
test('a tool call records WHICH AGENT served it — the auditor\'s second question', async () => {
|
|
577
|
+
const res = await fetch(`${controlBase}/edge/turn`, {
|
|
578
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'alice@corp.com' },
|
|
579
|
+
});
|
|
580
|
+
await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
581
|
+
const ev = await waitFor((r) => r.event_type === 'tool_authorize');
|
|
582
|
+
assert.deepStrictEqual(ev.payload.agent, { ...LOADED, digest: null }, 'without this the row says which USER touched the rows and nothing about the agent');
|
|
583
|
+
});
|
|
584
|
+
test('the sidecar claims no digest it has not verified', async () => {
|
|
585
|
+
const ev = await waitFor((r) => r.event_type === 'tool_authorize');
|
|
586
|
+
assert.strictEqual(ev.payload.agent.digest, null, 'pack-loader does not verify signatures in the sidecar; a digest here would dress an '
|
|
587
|
+
+ 'unverified read as an attestation');
|
|
588
|
+
});
|
|
589
|
+
test('PR-8: a registration records the agent it CLAIMS, marked as asserted', async () => {
|
|
590
|
+
await fetch(`${controlBase}/turns`, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
593
|
+
body: JSON.stringify({ userId: 'bob@corp.com', sessionKey: 's-claim', agent: { pack: 'invoice-agent' } }),
|
|
594
|
+
});
|
|
595
|
+
const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'invoice-agent');
|
|
596
|
+
assert.strictEqual(ev.payload.binding, 'asserted', 'the register token is shared, so the claim is only as good as whoever holds it — '
|
|
597
|
+
+ 'evidence must never present it as verified');
|
|
598
|
+
assert.strictEqual(ev.payload.agentMismatch, undefined);
|
|
599
|
+
});
|
|
600
|
+
test('PR-8 / §4.4: a DIFFERENT agent on the same connector is flagged, not silently accepted', async () => {
|
|
601
|
+
// The invariant E2 rests on: one connector, one agent. It is unenforceable over a shared
|
|
602
|
+
// bearer, so the deliverable is that it becomes VISIBLE the moment it is violated.
|
|
603
|
+
const res = await fetch(`${controlBase}/turns`, {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
606
|
+
body: JSON.stringify({ userId: 'mallory@corp.com', sessionKey: 's-other', agent: { pack: 'hr-agent' } }),
|
|
607
|
+
});
|
|
608
|
+
assert.strictEqual(res.status, 200, 'it must still register — refusing on an unverifiable claim would let anyone with the '
|
|
609
|
+
+ 'token deny service by lying about the pack');
|
|
610
|
+
const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'hr-agent');
|
|
611
|
+
assert.strictEqual(ev.payload.agentMismatch, true);
|
|
612
|
+
assert.strictEqual(ev.payload.loadedAgent, 'invoice-agent');
|
|
613
|
+
});
|
|
614
|
+
test('a registration with no agent claim still works — the field is optional', async () => {
|
|
615
|
+
const res = await fetch(`${controlBase}/turns`, {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
618
|
+
body: JSON.stringify({ userId: 'carol@corp.com', sessionKey: 's-none' }),
|
|
619
|
+
});
|
|
620
|
+
assert.strictEqual(res.status, 200);
|
|
621
|
+
const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'none');
|
|
622
|
+
assert.strictEqual(ev.payload.agentMismatch, undefined, 'absent is not a mismatch');
|
|
623
|
+
});
|
|
624
|
+
});
|
|
@@ -424,7 +424,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
424
424
|
const rendered = svc
|
|
425
425
|
.replace(/\$\{SERVICE\}/g, 'customer-service-edge')
|
|
426
426
|
.replace(/\$\{IMAGE\}/g, 'us-docker.pkg.dev/x/y/z:latest')
|
|
427
|
-
.replace(/\$\{LITELLM_IMAGE\}/g, 'ghcr.io/agenthippo
|
|
427
|
+
.replace(/\$\{LITELLM_IMAGE\}/g, 'ghcr.io/agenthippoai/agenthippo-litellm:2026.06')
|
|
428
428
|
.replace(/\$\{PUBLIC_HOSTNAME\}/g, 'cs.agenthippo.ai')
|
|
429
429
|
.replace(/\$\{ALLOWED_DOMAINS\}/g, 'agenthippo.ai');
|
|
430
430
|
assert.doesNotThrow(() => yaml.load(rendered), 'rendered service.yaml is valid YAML');
|
|
@@ -676,6 +676,52 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
676
676
|
assert.ok(files['Dockerfile'].includes('--www-dir'), 'serve enables static chat UI after SSO');
|
|
677
677
|
});
|
|
678
678
|
}
|
|
679
|
+
test('colocate + edgeTurnConnector emits the P1 edge turn-registration block (obo-inprogress §2)', () => {
|
|
680
|
+
const ctx = {
|
|
681
|
+
...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example', colocate: true,
|
|
682
|
+
edgeTurnConnector: 'databricks-analyst-connector:9091',
|
|
683
|
+
};
|
|
684
|
+
const snippet = (0, deploy_init_1.renderTarget)('vm', ctx)['agent-route.caddy'];
|
|
685
|
+
// Hygiene: a client-supplied nonce header must never pass through.
|
|
686
|
+
assert.ok(snippet.includes('request_header -X-Agent-Turn-Nonce'), 'inbound X-Agent-Turn-Nonce is stripped at the edge');
|
|
687
|
+
// PR-F1 (agent-identity.md §8.5 G1): the edge must forward the user ASSERTION, not just
|
|
688
|
+
// the identifier, or the turn carries no inboundToken — `passthrough` then fails closed on
|
|
689
|
+
// every call and an `exchange` provider has no subject_token. Forged copies are stripped
|
|
690
|
+
// for the same reason as the nonce: this one becomes the turn's inboundToken.
|
|
691
|
+
assert.ok(snippet.includes('request_header -X-Agent-User-Assertion'), 'inbound X-Agent-User-Assertion is stripped at the edge');
|
|
692
|
+
assert.ok(snippet.includes('copy_headers X-Agent-User X-Agent-User-Assertion'), 'the verified assertion is copied from /api/ide/me alongside the identity');
|
|
693
|
+
// Only POST chat turns register.
|
|
694
|
+
assert.ok(snippet.includes('@turnStart'), 'chat-turn matcher present');
|
|
695
|
+
assert.ok(/@turnStart\s*\{\s*\n\s*method POST\s*\n\s*path \/v1\/chat \/v1\/chat\/stream/m.test(snippet), '@turnStart matches POST /v1/chat and /v1/chat/stream only');
|
|
696
|
+
// The registration hop itself: forward_auth to the connector control API, nonce copied
|
|
697
|
+
// onto the proxied request, register token from the EDGE stack env (never the agent).
|
|
698
|
+
assert.ok(snippet.includes('forward_auth @turnStart databricks-analyst-connector:9091'), 'turn registration targets the connector control API');
|
|
699
|
+
assert.ok(snippet.includes('uri /edge/turn'), 'registration path is /edge/turn');
|
|
700
|
+
assert.ok(snippet.includes('copy_headers X-Agent-Turn-Nonce'), 'minted nonce copied onto the request');
|
|
701
|
+
assert.ok(snippet.includes('header_up Authorization "Bearer {$DATABRICKS_ANALYST_DATA_PLANE_TOKEN}"'), 'register token comes from the derived edge-stack env var');
|
|
702
|
+
// Ordering (the Caddy footgun the hand-built overlay documents): two forward_auth in one
|
|
703
|
+
// handle do NOT run in source order unless wrapped in route {} — identity must run first
|
|
704
|
+
// so X-Agent-User exists when the sidecar mints the nonce.
|
|
705
|
+
assert.ok(snippet.includes('route {'), 'gated sequence wrapped in route {} to force source order');
|
|
706
|
+
const apiBlock = snippet.slice(snippet.indexOf('handle @apiWithAuth'));
|
|
707
|
+
const identityIdx = apiBlock.indexOf('forward_auth auth-broker:9780');
|
|
708
|
+
const turnIdx = apiBlock.indexOf('forward_auth @turnStart');
|
|
709
|
+
const proxyIdx = apiBlock.indexOf('reverse_proxy databricks-analyst-agent:8080');
|
|
710
|
+
assert.ok(identityIdx >= 0 && turnIdx > identityIdx && proxyIdx > turnIdx, 'order inside route {}: identity → turn registration → proxy');
|
|
711
|
+
// Both gated paths carry the hop: Bearer API clients AND cookie-auth browser clients.
|
|
712
|
+
assert.strictEqual(snippet.split('uri /edge/turn').length - 1, 2, 'turn registration on both the @apiWithAuth and browser catch-all paths');
|
|
713
|
+
// The operator README says where the token goes (IdP stack env, never the agent).
|
|
714
|
+
const readme = (0, deploy_init_1.renderTarget)('vm', ctx)['README.md'];
|
|
715
|
+
assert.ok(readme.includes('DATABRICKS_ANALYST_DATA_PLANE_TOKEN'), 'README documents the edge-stack register token env var');
|
|
716
|
+
});
|
|
717
|
+
test('without edgeTurnConnector the route snippet is unchanged — no /edge/turn, no route {}', () => {
|
|
718
|
+
const ctx = {
|
|
719
|
+
...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example', colocate: true,
|
|
720
|
+
};
|
|
721
|
+
const snippet = (0, deploy_init_1.renderTarget)('vm', ctx)['agent-route.caddy'];
|
|
722
|
+
assert.ok(!snippet.includes('/edge/turn') && !snippet.includes('@turnStart')
|
|
723
|
+
&& !snippet.includes('X-Agent-Turn-Nonce') && !snippet.includes('route {'), 'turn registration is strictly opt-in; default output must not change');
|
|
724
|
+
});
|
|
679
725
|
test('--colocate is a no-op without --ingress edge-fed (still ships the normal self-issued edge)', () => {
|
|
680
726
|
const files = (0, deploy_init_1.renderTarget)('vm', { ...CTX, colocate: true });
|
|
681
727
|
assert.ok(files['Caddyfile'], 'no ingress edge-fed → colocate does not apply, normal vm artifacts render');
|