@indemn/cli 1.5.0 → 1.7.0
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +9 -4
- package/.github/workflows/ci.yml +10 -0
- package/.github/workflows/publish.yml +2 -2
- package/.mcp.json +1 -4
- package/CONTRIBUTING.md +3 -1
- package/README.md +69 -176
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +226 -18
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/analytics.d.ts +3 -0
- package/dist/cli/analytics.d.ts.map +1 -0
- package/dist/cli/analytics.js +133 -0
- package/dist/cli/analytics.js.map +1 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +62 -17
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/billing.d.ts +3 -0
- package/dist/cli/billing.d.ts.map +1 -0
- package/dist/cli/billing.js +23 -0
- package/dist/cli/billing.js.map +1 -0
- package/dist/cli/campaigns.d.ts +3 -0
- package/dist/cli/campaigns.d.ts.map +1 -0
- package/dist/cli/campaigns.js +34 -0
- package/dist/cli/campaigns.js.map +1 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +69 -102
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +71 -27
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/conversations.d.ts +3 -0
- package/dist/cli/conversations.d.ts.map +1 -0
- package/dist/cli/conversations.js +129 -0
- package/dist/cli/conversations.js.map +1 -0
- package/dist/cli/distribution.d.ts +3 -0
- package/dist/cli/distribution.d.ts.map +1 -0
- package/dist/cli/distribution.js +122 -0
- package/dist/cli/distribution.js.map +1 -0
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +315 -71
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/functions.d.ts.map +1 -1
- package/dist/cli/functions.js +125 -35
- package/dist/cli/functions.js.map +1 -1
- package/dist/cli/index.js +63 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +27 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +152 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/kb.d.ts.map +1 -1
- package/dist/cli/kb.js +141 -15
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/link.d.ts +21 -0
- package/dist/cli/link.d.ts.map +1 -0
- package/dist/cli/link.js +112 -0
- package/dist/cli/link.js.map +1 -0
- package/dist/cli/org.d.ts.map +1 -1
- package/dist/cli/org.js +79 -67
- package/dist/cli/org.js.map +1 -1
- package/dist/cli/platform.d.ts +3 -0
- package/dist/cli/platform.d.ts.map +1 -0
- package/dist/cli/platform.js +133 -0
- package/dist/cli/platform.js.map +1 -0
- package/dist/cli/tickets.d.ts +3 -0
- package/dist/cli/tickets.d.ts.map +1 -0
- package/dist/cli/tickets.js +125 -0
- package/dist/cli/tickets.js.map +1 -0
- package/dist/cli/utils/output.d.ts +15 -9
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +53 -46
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts +3 -0
- package/dist/cli/voice.d.ts.map +1 -0
- package/dist/cli/voice.js +206 -0
- package/dist/cli/voice.js.map +1 -0
- package/dist/core/diff.d.ts +16 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +57 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/org-resolver.d.ts +37 -0
- package/dist/core/org-resolver.d.ts.map +1 -0
- package/dist/core/org-resolver.js +73 -0
- package/dist/core/org-resolver.js.map +1 -0
- package/dist/core/runtime.d.ts +36 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +62 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/scope.d.ts +33 -0
- package/dist/core/scope.d.ts.map +1 -0
- package/dist/core/scope.js +37 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/mcp/registry.d.ts +67 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +195 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +87 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts +21 -0
- package/dist/mcp/tool-meta.d.ts.map +1 -0
- package/dist/mcp/tool-meta.js +203 -0
- package/dist/mcp/tool-meta.js.map +1 -0
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +1462 -215
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/agent-bundle.d.ts +99 -0
- package/dist/sdk/agent-bundle.d.ts.map +1 -0
- package/dist/sdk/agent-bundle.js +211 -0
- package/dist/sdk/agent-bundle.js.map +1 -0
- package/dist/sdk/agents.d.ts +54 -1
- package/dist/sdk/agents.d.ts.map +1 -1
- package/dist/sdk/agents.js +59 -5
- package/dist/sdk/agents.js.map +1 -1
- package/dist/sdk/analytics.d.ts +158 -0
- package/dist/sdk/analytics.d.ts.map +1 -0
- package/dist/sdk/analytics.js +185 -0
- package/dist/sdk/analytics.js.map +1 -0
- package/dist/sdk/auth.d.ts +34 -12
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +60 -77
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/billing.d.ts +25 -0
- package/dist/sdk/billing.d.ts.map +1 -0
- package/dist/sdk/billing.js +61 -0
- package/dist/sdk/billing.js.map +1 -0
- package/dist/sdk/campaigns.d.ts +35 -0
- package/dist/sdk/campaigns.d.ts.map +1 -0
- package/dist/sdk/campaigns.js +59 -0
- package/dist/sdk/campaigns.js.map +1 -0
- package/dist/sdk/chat.d.ts +25 -2
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +37 -10
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +93 -5
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +362 -100
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.d.ts +74 -0
- package/dist/sdk/config-change.d.ts.map +1 -0
- package/dist/sdk/config-change.js +117 -0
- package/dist/sdk/config-change.js.map +1 -0
- package/dist/sdk/config.d.ts.map +1 -1
- package/dist/sdk/config.js +2 -1
- package/dist/sdk/config.js.map +1 -1
- package/dist/sdk/conversations.d.ts +101 -0
- package/dist/sdk/conversations.d.ts.map +1 -0
- package/dist/sdk/conversations.js +155 -0
- package/dist/sdk/conversations.js.map +1 -0
- package/dist/sdk/credentials.d.ts +25 -0
- package/dist/sdk/credentials.d.ts.map +1 -0
- package/dist/sdk/credentials.js +44 -0
- package/dist/sdk/credentials.js.map +1 -0
- package/dist/sdk/distribution.d.ts +94 -0
- package/dist/sdk/distribution.d.ts.map +1 -0
- package/dist/sdk/distribution.js +100 -0
- package/dist/sdk/distribution.js.map +1 -0
- package/dist/sdk/eval.d.ts +81 -0
- package/dist/sdk/eval.d.ts.map +1 -1
- package/dist/sdk/eval.js +117 -0
- package/dist/sdk/eval.js.map +1 -1
- package/dist/sdk/functions.d.ts +14 -1
- package/dist/sdk/functions.d.ts.map +1 -1
- package/dist/sdk/functions.js +48 -5
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/jarvis-chat.d.ts +28 -0
- package/dist/sdk/jarvis-chat.d.ts.map +1 -0
- package/dist/sdk/jarvis-chat.js +117 -0
- package/dist/sdk/jarvis-chat.js.map +1 -0
- package/dist/sdk/kb.d.ts +69 -1
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +111 -24
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts +13 -0
- package/dist/sdk/orgs.d.ts.map +1 -0
- package/dist/sdk/orgs.js +59 -0
- package/dist/sdk/orgs.js.map +1 -0
- package/dist/sdk/platform.d.ts +62 -0
- package/dist/sdk/platform.d.ts.map +1 -0
- package/dist/sdk/platform.js +95 -0
- package/dist/sdk/platform.js.map +1 -0
- package/dist/sdk/session.d.ts +25 -0
- package/dist/sdk/session.d.ts.map +1 -0
- package/dist/sdk/session.js +69 -0
- package/dist/sdk/session.js.map +1 -0
- package/dist/sdk/skills.d.ts +46 -0
- package/dist/sdk/skills.d.ts.map +1 -0
- package/dist/sdk/skills.js +203 -0
- package/dist/sdk/skills.js.map +1 -0
- package/dist/sdk/test-chat.d.ts +52 -0
- package/dist/sdk/test-chat.d.ts.map +1 -0
- package/dist/sdk/test-chat.js +134 -0
- package/dist/sdk/test-chat.js.map +1 -0
- package/dist/sdk/tickets.d.ts +98 -0
- package/dist/sdk/tickets.d.ts.map +1 -0
- package/dist/sdk/tickets.js +91 -0
- package/dist/sdk/tickets.js.map +1 -0
- package/dist/sdk/types.d.ts +91 -6
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/voice.d.ts +147 -0
- package/dist/sdk/voice.d.ts.map +1 -0
- package/dist/sdk/voice.js +236 -0
- package/dist/sdk/voice.js.map +1 -0
- package/package.json +22 -3
- package/scripts/check-skills.mjs +25 -0
- package/skills/agent-builder/SKILL.md +89 -252
- package/skills/evaluations/SKILL.md +23 -234
- package/skills/evaluations/references/analysis.md +95 -0
- package/skills/evaluations/references/cli-reference.md +28 -0
- package/skills/evaluations/references/json-formats.md +100 -0
- package/src/cli/agents.ts +202 -22
- package/src/cli/analytics.ts +138 -0
- package/src/cli/auth.ts +56 -18
- package/src/cli/billing.ts +25 -0
- package/src/cli/campaigns.ts +33 -0
- package/src/cli/chat.ts +66 -115
- package/src/cli/config.ts +65 -31
- package/src/cli/conversations.ts +133 -0
- package/src/cli/distribution.ts +116 -0
- package/src/cli/eval.ts +254 -70
- package/src/cli/functions.ts +122 -36
- package/src/cli/index.ts +58 -55
- package/src/cli/init.ts +154 -0
- package/src/cli/kb.ts +120 -17
- package/src/cli/link.ts +117 -0
- package/src/cli/org.ts +81 -75
- package/src/cli/platform.ts +128 -0
- package/src/cli/tickets.ts +130 -0
- package/src/cli/utils/output.ts +51 -42
- package/src/cli/voice.ts +204 -0
- package/src/core/diff.ts +52 -0
- package/src/core/org-resolver.ts +113 -0
- package/src/core/runtime.ts +73 -0
- package/src/core/scope.ts +64 -0
- package/src/mcp/registry.ts +236 -0
- package/src/mcp/server.ts +84 -2
- package/src/mcp/tool-meta.ts +241 -0
- package/src/mcp/tools.ts +1529 -265
- package/src/sdk/agent-bundle.ts +245 -0
- package/src/sdk/agents.ts +74 -5
- package/src/sdk/analytics.ts +220 -0
- package/src/sdk/auth.ts +87 -107
- package/src/sdk/billing.ts +59 -0
- package/src/sdk/campaigns.ts +60 -0
- package/src/sdk/chat.ts +44 -10
- package/src/sdk/client.ts +373 -102
- package/src/sdk/config-change.ts +148 -0
- package/src/sdk/config.ts +2 -1
- package/src/sdk/conversations.ts +199 -0
- package/src/sdk/credentials.ts +53 -0
- package/src/sdk/distribution.ts +132 -0
- package/src/sdk/eval.ts +140 -0
- package/src/sdk/functions.ts +52 -6
- package/src/sdk/index.ts +2 -0
- package/src/sdk/jarvis-chat.ts +112 -0
- package/src/sdk/kb.ts +116 -29
- package/src/sdk/orgs.ts +62 -0
- package/src/sdk/platform.ts +115 -0
- package/src/sdk/session.ts +90 -0
- package/src/sdk/skills.ts +188 -0
- package/src/sdk/test-chat.ts +151 -0
- package/src/sdk/tickets.ts +120 -0
- package/src/sdk/types.ts +88 -6
- package/src/sdk/voice.ts +262 -0
- package/test/cli/entry.test.ts +34 -0
- package/test/cli/init.test.ts +66 -0
- package/test/cli/link.test.ts +57 -0
- package/test/core/org-resolver.test.ts +92 -0
- package/test/core/runtime.test.ts +30 -0
- package/test/core/scope.test.ts +93 -0
- package/test/harness.test.ts +24 -0
- package/test/helpers/config-home.ts +22 -0
- package/test/helpers/fake-middleware.ts +56 -0
- package/test/helpers/fetch-recorder.ts +75 -0
- package/test/helpers/jwt.ts +9 -0
- package/test/helpers/mcp-pair.ts +38 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
- package/test/mcp/askers.test.ts +60 -0
- package/test/mcp/elicit-timeout.test.ts +29 -0
- package/test/mcp/registry.test.ts +169 -0
- package/test/mcp/shaping.test.ts +41 -0
- package/test/mcp/tools-snapshot.test.ts +42 -0
- package/test/sdk/agents.test.ts +232 -0
- package/test/sdk/analytics.test.ts +89 -0
- package/test/sdk/auth.test.ts +70 -0
- package/test/sdk/billing.test.ts +47 -0
- package/test/sdk/campaigns.test.ts +43 -0
- package/test/sdk/client.test.ts +80 -0
- package/test/sdk/config-change.test.ts +142 -0
- package/test/sdk/conversations.test.ts +80 -0
- package/test/sdk/credentials.test.ts +33 -0
- package/test/sdk/distribution.test.ts +55 -0
- package/test/sdk/eval.test.ts +155 -0
- package/test/sdk/kb-upload.test.ts +50 -0
- package/test/sdk/kb.test.ts +95 -0
- package/test/sdk/orgs.test.ts +48 -0
- package/test/sdk/platform.test.ts +89 -0
- package/test/sdk/renewal.test.ts +115 -0
- package/test/sdk/session.test.ts +60 -0
- package/test/sdk/skills.test.ts +235 -0
- package/test/sdk/test-chat.test.ts +112 -0
- package/test/sdk/tickets.test.ts +42 -0
- package/test/sdk/voice.test.ts +147 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
4
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
5
|
+
import { connectPair } from '../helpers/mcp-pair.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { EvalSDK, gateVerdict } from '../../src/sdk/eval.js';
|
|
8
|
+
import { registerTools } from '../../src/mcp/tools.js';
|
|
9
|
+
import type { EvaluationRun } from '../../src/sdk/types.js';
|
|
10
|
+
|
|
11
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
12
|
+
const AGENT = '111111111111111111111111';
|
|
13
|
+
const FOREIGN = '999999999999999999999999';
|
|
14
|
+
const run = (over: Partial<EvaluationRun>): EvaluationRun => ({ run_id: 'r1', status: 'completed', total: 10, passed: 9, ...over });
|
|
15
|
+
|
|
16
|
+
describe('CI gate verdict (same rules as eval_gate.py)', () => {
|
|
17
|
+
it('passes above the threshold and fails below it', () => {
|
|
18
|
+
expect(gateVerdict(run({}), { minPassRate: 0.9 })).toMatchObject({ pass: true, passRate: 0.9 });
|
|
19
|
+
expect(gateVerdict(run({}), { minPassRate: 0.95 }).reasons).toEqual(['pass rate 0.900 < 0.95']);
|
|
20
|
+
});
|
|
21
|
+
it('blocks a failed run unless the policy is warn', () => {
|
|
22
|
+
expect(gateVerdict(run({ status: 'failed' }), {}).reasons).toEqual(['run status=failed']);
|
|
23
|
+
expect(gateVerdict(run({ status: 'failed' }), { errorPolicy: 'warn' }).pass).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it('fails an empty run, slow runs, and errored cases when asked', () => {
|
|
26
|
+
expect(gateVerdict(run({ total: 0, passed: 0 }), {}).reasons).toEqual(['no cases evaluated']);
|
|
27
|
+
expect(gateVerdict(run({ latency: { p95_ms: 9000 } }), { maxP95Ms: 5000 }).reasons).toEqual(['p95 latency 9000 > 5000']);
|
|
28
|
+
expect(gateVerdict(run({ errors: 2 }), { failOnError: true }).reasons).toEqual(['2 case(s) errored']);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('evaluations stay inside the org', () => {
|
|
33
|
+
let home: ReturnType<typeof useTempHome>;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
cleanEnv();
|
|
36
|
+
home = useTempHome();
|
|
37
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
38
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => home.cleanup());
|
|
41
|
+
|
|
42
|
+
const agents = [
|
|
43
|
+
on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] }),
|
|
44
|
+
on('GET', `/v2/bots/${FOREIGN}`, { status: 404, text: 'nope' }),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
it('refuses to read a run of another org\'s agent', async () => {
|
|
48
|
+
recordFetch([...agents, on('GET', '/api/v1/runs/r9', { json: { run_id: 'r9', agent_id: FOREIGN, status: 'completed' } })]);
|
|
49
|
+
await expect(new EvalSDK(new IndemnClient()).getRunInOrg('r9')).rejects.toThrow(/not in the organization/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('MCP run_evaluation asks first, refuses a foreign agent, and turns a date range into transcript mode', async () => {
|
|
53
|
+
const { calls } = recordFetch([
|
|
54
|
+
...agents,
|
|
55
|
+
on('GET', '/api/v1/test-sets/ts1', { json: { test_set_id: 'ts1', agent_id: AGENT, items: [{}, {}] } }),
|
|
56
|
+
on('POST', '/api/v1/evaluations', { json: { run_id: 'r1', status: 'pending', total: 2 } }),
|
|
57
|
+
]);
|
|
58
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
59
|
+
const dry = await pair.call('run_evaluation', { bot_id: AGENT, test_set_id: 'ts1' });
|
|
60
|
+
expect(dry.body.dry_run).toBe(true);
|
|
61
|
+
expect(dry.body.would).toMatch(/paid model calls and cannot be cancelled/);
|
|
62
|
+
|
|
63
|
+
const foreign = await pair.call('run_evaluation', { bot_id: FOREIGN, test_set_id: 'ts1', confirm: true });
|
|
64
|
+
expect(foreign.isError).toBe(true);
|
|
65
|
+
expect(calls.some((c) => c.url.endsWith('/api/v1/evaluations'))).toBe(false);
|
|
66
|
+
|
|
67
|
+
await pair.call('run_evaluation', { bot_id: AGENT, test_set_id: 'ts1', date_from: '2026-09-01', date_to: '2026-09-28', confirm: true });
|
|
68
|
+
const trigger = calls.find((c) => c.url.endsWith('/api/v1/evaluations'))!;
|
|
69
|
+
expect(trigger.body).toMatchObject({ bot_id: AGENT, test_set_id: 'ts1', date_from: '2026-09-01', date_to: '2026-09-28', mode: 'transcript' });
|
|
70
|
+
await pair.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('MCP lists rubrics and test sets only for an agent of the org', async () => {
|
|
74
|
+
const { calls } = recordFetch([...agents, on('GET', '/api/v1/rubrics', { json: [] }), on('GET', '/api/v1/test-sets', { json: [] })]);
|
|
75
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
76
|
+
for (const tool of ['list_rubrics', 'list_test_sets']) {
|
|
77
|
+
expect((await pair.call(tool, {})).isError, tool).toBe(true);
|
|
78
|
+
expect((await pair.call(tool, { agent_id: FOREIGN })).isError, tool).toBe(true);
|
|
79
|
+
expect((await pair.call(tool, { agent_id: AGENT })).isError, tool).toBeFalsy();
|
|
80
|
+
}
|
|
81
|
+
const listed = calls.filter((c) => /\/api\/v1\/(rubrics|test-sets)/.test(c.url));
|
|
82
|
+
expect(listed).toHaveLength(2);
|
|
83
|
+
expect(listed.every((c) => c.url.includes(`agent_id=${AGENT}`))).toBe(true);
|
|
84
|
+
await pair.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('MCP rubric and test-set lists take org_id with several orgs, and never report "which org?" as a foreign agent', async () => {
|
|
88
|
+
const TWO = 'cccccccccccccccccccccccc';
|
|
89
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }, { id: TWO, name: 'QA B' }] });
|
|
90
|
+
const { calls } = recordFetch([...agents, on('GET', '/api/v1/rubrics', { json: [] }), on('GET', '/api/v1/test-sets', { json: [] })]);
|
|
91
|
+
await expect(new EvalSDK(new IndemnClient()).assertAgentInOrg(AGENT)).rejects.not.toThrow(/not in the organization/);
|
|
92
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
93
|
+
for (const tool of ['list_rubrics', 'list_test_sets']) {
|
|
94
|
+
const res = await pair.call(tool, { agent_id: AGENT, org_id: ORG });
|
|
95
|
+
expect(res.isError, tool).toBeFalsy();
|
|
96
|
+
}
|
|
97
|
+
expect(calls.filter((c) => c.url.includes(`/organization/${ORG}/ai-studio/v2/bots/${AGENT}`))).toHaveLength(2);
|
|
98
|
+
await pair.close();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('MCP rubric and test-set tools act only on items of an agent in this org, and deletes ask first', async () => {
|
|
102
|
+
const { calls } = recordFetch([...agents,
|
|
103
|
+
on('GET', '/api/v1/rubrics/r-foreign', { json: { rubric_id: 'r-foreign', agent_id: FOREIGN } }),
|
|
104
|
+
on('GET', '/api/v1/test-sets/t-own', { json: { test_set_id: 't-own', agent_id: AGENT } }),
|
|
105
|
+
on('DELETE', '/api/v1/test-sets/t-own', { json: {} }),
|
|
106
|
+
]);
|
|
107
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
108
|
+
expect((await pair.call('get_rubric', { rubric_id: 'r-foreign' })).isError).toBe(true);
|
|
109
|
+
expect((await pair.call('delete_rubric', { rubric_id: 'r-foreign', confirm: true })).isError).toBe(true);
|
|
110
|
+
expect((await pair.call('create_rubric', { name: 'x', rules: [], agent_id: FOREIGN })).isError).toBe(true);
|
|
111
|
+
expect((await pair.call('delete_test_set', { test_set_id: 't-own' })).body.dry_run).toBe(true);
|
|
112
|
+
await pair.call('delete_test_set', { test_set_id: 't-own', confirm: true });
|
|
113
|
+
expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.url)).toEqual([expect.stringContaining('/api/v1/test-sets/t-own')]);
|
|
114
|
+
await pair.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('MCP run_evaluation takes agent_id like every other tool (bot_id still works)', async () => {
|
|
118
|
+
const { calls } = recordFetch([...agents, on('GET', '/api/v1/test-sets/ts1', { json: { test_set_id: 'ts1', agent_id: AGENT, items: [{}] } }),
|
|
119
|
+
on('POST', '/api/v1/evaluations', { json: { run_id: 'r1' } })]);
|
|
120
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
121
|
+
expect(String((await pair.call('run_evaluation', { test_set_id: 'ts1', confirm: true })).body)).toMatch(/Give agent_id/);
|
|
122
|
+
await pair.call('run_evaluation', { agent_id: AGENT, test_set_id: 'ts1', confirm: true });
|
|
123
|
+
expect(calls.filter((c) => c.method === 'POST').map((c) => c.body)).toEqual([expect.objectContaining({ bot_id: AGENT, test_set_id: 'ts1' })]);
|
|
124
|
+
await pair.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('MCP get_eval_status waits for a running run instead of being called every few seconds', async () => {
|
|
128
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
|
|
129
|
+
let polls = 0;
|
|
130
|
+
const { calls } = recordFetch([...agents, on('GET', '/runs/r1', () => {
|
|
131
|
+
polls += 1;
|
|
132
|
+
return { json: { run_id: 'r1', agent_id: AGENT, status: polls < 3 ? 'running' : 'completed' } };
|
|
133
|
+
})]);
|
|
134
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
135
|
+
const pending = pair.call('get_eval_status', { run_id: 'r1' });
|
|
136
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
137
|
+
const res = await pending;
|
|
138
|
+
vi.useRealTimers();
|
|
139
|
+
expect(res.body.data.status).toBe('completed');
|
|
140
|
+
expect(polls).toBe(3);
|
|
141
|
+
expect(calls.filter((c) => c.url.includes('/v2/bots/')).length).toBe(1); // org checked once
|
|
142
|
+
const now = await pair.call('get_eval_status', { run_id: 'r1', wait_seconds: 0 });
|
|
143
|
+
expect(now.body.data.status).toBe('completed');
|
|
144
|
+
await pair.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('MCP run_evaluation refuses a test set written for another agent', async () => {
|
|
148
|
+
recordFetch([...agents, on('GET', '/api/v1/test-sets/ts2', { json: { test_set_id: 'ts2', agent_id: 'someone-else', items: [{}] } })]);
|
|
149
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
150
|
+
const res = await pair.call('run_evaluation', { bot_id: AGENT, test_set_id: 'ts2', confirm: true });
|
|
151
|
+
expect(res.isError).toBe(true);
|
|
152
|
+
expect(JSON.stringify(res.raw.content)).toContain('written for agent someone-else');
|
|
153
|
+
await pair.close();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
6
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
7
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
8
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
9
|
+
import { KBSDK } from '../../src/sdk/kb.js';
|
|
10
|
+
|
|
11
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
12
|
+
|
|
13
|
+
describe('adding a document to a knowledge base', () => {
|
|
14
|
+
let home: ReturnType<typeof useTempHome>;
|
|
15
|
+
let dir: string;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
cleanEnv();
|
|
18
|
+
home = useTempHome();
|
|
19
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
20
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), 'indemn-kbfile-'));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => home.cleanup());
|
|
24
|
+
|
|
25
|
+
it('uploads to the org file store, then imports from the signed link (like the dashboard)', async () => {
|
|
26
|
+
const pdf = join(dir, 'Evaluation Report.pdf');
|
|
27
|
+
writeFileSync(pdf, '%PDF-1.4 test');
|
|
28
|
+
const { calls } = recordFetch([
|
|
29
|
+
on('POST', '/files/upload-org', { json: { url: 'https://s3/plain.pdf', signedUrl: 'https://s3/signed.pdf?sig=1' } }),
|
|
30
|
+
on('POST', '/knowledge-bases/kb1/import', { json: { success: true, data: { _id: 'job1', status: 'started' } } }),
|
|
31
|
+
]);
|
|
32
|
+
await new KBSDK(new IndemnClient()).uploadFile('kb1', pdf);
|
|
33
|
+
const upload = new URL(calls[0].url);
|
|
34
|
+
expect(upload.pathname).toBe(`/api/organization/${ORG}/files/upload-org`);
|
|
35
|
+
expect(upload.searchParams.get('generateSignedUrl')).toBe('true');
|
|
36
|
+
expect(calls.some((c) => c.url.includes('upload-urls'))).toBe(false);
|
|
37
|
+
expect(calls[1].body).toEqual({ type: 'file', sources: [{ name: 'Evaluation Report.pdf', file_type: 'pdf', url: 'https://s3/signed.pdf?sig=1' }] });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('refuses unsupported types and files over 10 MB before uploading', async () => {
|
|
41
|
+
const png = join(dir, 'logo.png');
|
|
42
|
+
writeFileSync(png, 'x');
|
|
43
|
+
const { calls } = recordFetch();
|
|
44
|
+
await expect(new KBSDK(new IndemnClient()).uploadFile('kb1', png)).rejects.toThrow(/Unsupported file type/);
|
|
45
|
+
const big = join(dir, 'big.pdf');
|
|
46
|
+
writeFileSync(big, Buffer.alloc(10 * 1024 * 1024 + 1));
|
|
47
|
+
await expect(new KBSDK(new IndemnClient()).uploadFile('kb1', big)).rejects.toThrow(/limit is 10 MB/);
|
|
48
|
+
expect(calls).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
4
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
5
|
+
import { connectPair } from '../helpers/mcp-pair.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { KBSDK } from '../../src/sdk/kb.js';
|
|
8
|
+
import { registerTools } from '../../src/mcp/tools.js';
|
|
9
|
+
|
|
10
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
11
|
+
|
|
12
|
+
describe('knowledge bases', () => {
|
|
13
|
+
let home: ReturnType<typeof useTempHome>;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
cleanEnv();
|
|
16
|
+
home = useTempHome();
|
|
17
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
18
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => home.cleanup());
|
|
21
|
+
const sdk = () => new KBSDK(new IndemnClient());
|
|
22
|
+
|
|
23
|
+
it('imports a website with the source shape the dashboard sends', async () => {
|
|
24
|
+
const { calls } = recordFetch([on('POST', '/knowledge-bases/kb1/import', { json: { success: true, data: {} } })]);
|
|
25
|
+
await sdk().scrapeUrl('kb1', 'https://www.acme.test/faq', 'selected');
|
|
26
|
+
await sdk().scrapeUrl('kb1', 'https://www.acme.test');
|
|
27
|
+
const [one, full] = calls.filter((c) => c.url.endsWith('/import')).map((c) => c.body);
|
|
28
|
+
expect(one).toEqual({ type: 'url', manual: false, sources: [{ url: 'https://www.acme.test', crawl_mode: 'selected', selected_urls: ['https://www.acme.test/faq'], exclude_urls: [], js_script: '' }] });
|
|
29
|
+
expect(full).toEqual({ type: 'url', manual: false, sources: [{ url: 'https://www.acme.test', crawl_mode: 'full', selected_urls: [], exclude_urls: [], js_script: '' }] });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('creates with the uppercase type copilot stores', async () => {
|
|
33
|
+
const { calls } = recordFetch([on('POST', '/knowledge-bases', { json: { success: true, data: { _id: 'kb1' } } })]);
|
|
34
|
+
await sdk().createKB({ name: 'FAQ', type: 'qna' } as never);
|
|
35
|
+
expect(calls[0].body).toEqual({ name: 'FAQ', type: 'QNA' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('treats an import that copilot reports as success but whose job failed as a failure', async () => {
|
|
39
|
+
recordFetch([on('POST', '/import', { json: { success: true, data: { status: 'error', message: 'unreachable url' } } })]);
|
|
40
|
+
await expect(sdk().importKB('kb1', { type: 'url', sources: [{ url: 'https://x' }] })).rejects.toThrow('Import failed: unreachable url');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('re-imports a source and fails loudly the same way', async () => {
|
|
44
|
+
const { calls } = recordFetch([on('PUT', '/data-source/ds1/re-import', { json: { success: true, data: { status: 'error', message: 'not a url' } } })]);
|
|
45
|
+
await expect(sdk().reimport('kb1', 'ds1')).rejects.toThrow('Re-import failed: not a url');
|
|
46
|
+
expect(calls[0].body).toEqual({});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reads scrape status from the knowledge base sources', async () => {
|
|
50
|
+
recordFetch([on('GET', '/knowledge-bases/kb1', { json: { success: true, data: [{ _id: 'kb1', fileWebSources: [
|
|
51
|
+
{ _id: 's1', source: 'https://a', status: 'pending', source_details: { id_scrape_request: { status: 'started' } } },
|
|
52
|
+
{ _id: 's2', source: 'b.pdf', status: 'failed', error_message: 'bad pdf' },
|
|
53
|
+
] }] } })]);
|
|
54
|
+
expect(await sdk().scrapeStatus('kb1')).toEqual([
|
|
55
|
+
{ id: 's1', source: 'https://a', status: 'pending', job_status: 'started', error: undefined, completed_at: undefined },
|
|
56
|
+
{ id: 's2', source: 'b.pdf', status: 'failed', job_status: undefined, error: 'bad pdf', completed_at: undefined },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('asks for gaps with 0-based pages and test conversations excluded by default', async () => {
|
|
61
|
+
const { calls } = recordFetch([on('GET', '/unanswered/embeddings', { json: { success: true, data: { responses: [] } } })]);
|
|
62
|
+
await sdk().gaps({ agentId: 'b1' });
|
|
63
|
+
const q = new URL(calls[0].url).searchParams;
|
|
64
|
+
expect(Object.fromEntries(q)).toEqual({ page: '0', limit: '20', isDismissed: 'false', isTestMode: 'false', id_bot: 'b1' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('MCP lists knowledge bases by page, with a source count instead of every crawled page', async () => {
|
|
68
|
+
const pages = Array.from({ length: 3000 }, (_, i) => ({ url: `https://acme.test/p${i}`, status: 'done' }));
|
|
69
|
+
recordFetch([on('GET', '/knowledge-bases', { json: Array.from({ length: 12 }, (_, i) => ({ _id: `kb${i}`, name: `KB ${i}`, fileWebSources: pages, kbSampleFaqs: ['q'] })) })]);
|
|
70
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
71
|
+
const res = await pair.call('list_knowledge_bases', { limit: 5, page: 3 });
|
|
72
|
+
expect(res.body.data).toMatchObject({ page: 3, limit: 5, total: 12, has_more: false });
|
|
73
|
+
const link = (id: string) => expect.stringMatching(new RegExp(`/#/organization/[^/]+/ai-studio/knowledge-base/${id}$`));
|
|
74
|
+
expect(res.body.data.items).toEqual([{ _id: 'kb10', name: 'KB 10', sources: 3000, url: link('kb10') }, { _id: 'kb11', name: 'KB 11', sources: 3000, url: link('kb11') }]);
|
|
75
|
+
await pair.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('MCP delete tools wait for confirmation', async () => {
|
|
79
|
+
const { calls } = recordFetch([
|
|
80
|
+
on('GET', '/knowledge-bases/kb1', { json: { success: true, data: { _id: 'kb1', name: 'Coverage FAQ' } } }),
|
|
81
|
+
on('GET', '/v2/bots/b1', { json: [{ _id: 'b1', name: 'Wedding Bot' }] }),
|
|
82
|
+
]);
|
|
83
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
84
|
+
for (const [tool, args] of [
|
|
85
|
+
['delete_knowledge_base', { kb_id: 'kb1' }],
|
|
86
|
+
['delete_kb_data', { kb_id: 'kb1', source_id: 's1' }],
|
|
87
|
+
['unlink_kb', { agent_id: 'b1', kb_id: 'kb1' }],
|
|
88
|
+
] as const) {
|
|
89
|
+
const res = await pair.call(tool, args);
|
|
90
|
+
expect(res.body.dry_run, tool).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0); // only name lookups
|
|
93
|
+
await pair.close();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
4
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
5
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
6
|
+
import { OrgsSDK } from '../../src/sdk/orgs.js';
|
|
7
|
+
|
|
8
|
+
const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
9
|
+
|
|
10
|
+
describe('OrgsSDK', () => {
|
|
11
|
+
let home: ReturnType<typeof useTempHome>;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
cleanEnv();
|
|
14
|
+
home = useTempHome();
|
|
15
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [] });
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => home.cleanup());
|
|
18
|
+
|
|
19
|
+
it('lists orgs and refreshes the saved list', async () => {
|
|
20
|
+
recordFetch([on('GET', '/organization', { json: { success: true, userOrganizations: [{ _id: A, name: 'QA A', active: true }] } })]);
|
|
21
|
+
await expect(new OrgsSDK(new IndemnClient()).listOrganizations()).resolves.toEqual([{ id: A, name: 'QA A' }]);
|
|
22
|
+
expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('lists members once per person with only safe fields', async () => {
|
|
26
|
+
const user = { _id: 'u9', email: 'x@y.z', firstname: 'Ada', lastname: 'L', password: 'hash' };
|
|
27
|
+
recordFetch([on('GET', `/organization/${A}/projects-users`, {
|
|
28
|
+
json: { success: true, projectUsers: [
|
|
29
|
+
{ role: 'owner', status: 'active', id_user: user, id_project: { name: 'P1' } },
|
|
30
|
+
{ role: 'owner', status: 'active', id_user: user, id_project: { name: 'P2' } },
|
|
31
|
+
] },
|
|
32
|
+
})]);
|
|
33
|
+
const members = await new OrgsSDK(new IndemnClient({ orgId: A })).listMembers();
|
|
34
|
+
expect(members).toEqual([{ user_id: 'u9', email: 'x@y.z', name: 'Ada L', role: 'owner', status: 'active', projects: ['P1', 'P2'] }]);
|
|
35
|
+
expect(JSON.stringify(members)).not.toContain('hash');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('treats copilot "no members" as an empty list', async () => {
|
|
39
|
+
recordFetch([on('GET', '/projects-users', { json: { success: false, message: 'Projects and users not found for the given organization.' } })]);
|
|
40
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).listMembers()).resolves.toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('lists channels', async () => {
|
|
44
|
+
const { calls } = recordFetch([on('GET', '/ai-studio/channels', { json: { success: true, data: [{ _id: 'c1', name: 'WEB', type: 'web' }] } })]);
|
|
45
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).listChannels()).resolves.toEqual([{ _id: 'c1', name: 'WEB', type: 'web', description: undefined }]);
|
|
46
|
+
expect(new URL(calls[0].url).pathname).toBe(`/api/organization/${A}/ai-studio/channels`);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import type { AddressInfo } from 'node:net';
|
|
4
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
5
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
6
|
+
import { fakeJwt } from '../helpers/jwt.js';
|
|
7
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
8
|
+
import { PlatformSDK } from '../../src/sdk/platform.js';
|
|
9
|
+
import { jarvisChat } from '../../src/sdk/jarvis-chat.js';
|
|
10
|
+
|
|
11
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
12
|
+
const USER = '555555555555555555555555';
|
|
13
|
+
|
|
14
|
+
describe('V2 platform and Jarvis', () => {
|
|
15
|
+
let home: ReturnType<typeof useTempHome>;
|
|
16
|
+
let token: string;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
cleanEnv();
|
|
19
|
+
home = useTempHome();
|
|
20
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
21
|
+
token = fakeJwt({ _id: USER, email: 'qa@indemn.ai', exp: Math.floor(Date.now() / 1000) + 5 * 3600 });
|
|
22
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: USER, session: { token }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => home.cleanup());
|
|
25
|
+
|
|
26
|
+
it('lists with the bare session token and the org header', async () => {
|
|
27
|
+
const { calls } = recordFetch([on('GET', 'devplatform.indemn.ai/api/v1/templates', { json: [] })]);
|
|
28
|
+
await new PlatformSDK(new IndemnClient()).list('templates');
|
|
29
|
+
expect(calls[0].headers).toMatchObject({ authorization: `Bearer ${token}`, 'x-organization-id': ORG });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('never writes to the shared component or connector catalogs', async () => {
|
|
33
|
+
const { calls } = recordFetch();
|
|
34
|
+
await expect(new PlatformSDK(new IndemnClient()).create('components', { name: 'x' })).rejects.toMatchObject({ exitCode: 2 });
|
|
35
|
+
await expect(new PlatformSDK(new IndemnClient()).remove('connectors', 'c1')).rejects.toThrow(/only reads/);
|
|
36
|
+
expect(calls).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('checks the agent is visible to the org before running a simulation', async () => {
|
|
40
|
+
const { calls } = recordFetch([on('GET', '/agents/a9', { status: 403, json: { detail: 'Access denied' } })]);
|
|
41
|
+
await expect(new PlatformSDK(new IndemnClient()).runSimulation('a9')).rejects.toThrow(/Access denied/);
|
|
42
|
+
expect(calls.some((c) => c.url.includes('/simulations/run'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('creates a Jarvis job and waits until it completes', async () => {
|
|
46
|
+
let polls = 0;
|
|
47
|
+
const { calls } = recordFetch([
|
|
48
|
+
on('POST', '/jarvis/jobs', { json: { job_id: 'jarvis_job_1', status: 'pending' } }),
|
|
49
|
+
on('GET', '/jarvis/jobs/jarvis_job_1', () => ({ json: { job_id: 'jarvis_job_1', status: ++polls < 2 ? 'running' : 'completed', progress: { results: [{ bot_id: 'b1', success: true }] } } })),
|
|
50
|
+
]);
|
|
51
|
+
const sdk = new PlatformSDK(new IndemnClient());
|
|
52
|
+
const job = await sdk.createJarvisJob(['b1']);
|
|
53
|
+
expect(calls[0].body).toEqual({ job_type: 'baseline_generation', bot_ids: ['b1'] });
|
|
54
|
+
expect((await sdk.waitForJarvisJob(job.job_id, { intervalMs: 5 })).status).toBe('completed');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('chats with Jarvis over the WebSocket: thread from a new conversation, reply until done, tools deduplicated', async () => {
|
|
58
|
+
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
59
|
+
const seen: { url?: string; auth?: string; sent?: any } = {};
|
|
60
|
+
wss.on('connection', (socket, req) => {
|
|
61
|
+
seen.url = req.url;
|
|
62
|
+
seen.auth = req.headers.authorization;
|
|
63
|
+
socket.on('message', (raw) => {
|
|
64
|
+
const msg = JSON.parse(String(raw));
|
|
65
|
+
if (msg.type !== 'jarvis') return;
|
|
66
|
+
seen.sent = msg;
|
|
67
|
+
const tool = (id: string, status: string) => socket.send(JSON.stringify({ type: 'jarvis_tool', payload: { event: status === 'running' ? 'start' : 'end', tool_call: { id, name: 'list_templates', status } } }));
|
|
68
|
+
tool('llm-1', 'running');
|
|
69
|
+
tool('uuid-2', 'running');
|
|
70
|
+
tool('uuid-2', 'success');
|
|
71
|
+
socket.send(JSON.stringify({ type: 'jarvis_chunk', payload: { id: 'r', content: 'Hello', done: false } }));
|
|
72
|
+
socket.send(JSON.stringify({ type: 'jarvis_chunk', payload: { id: 'r', content: ' world', done: false } }));
|
|
73
|
+
socket.send(JSON.stringify({ type: 'jarvis_chunk', payload: { id: 'r', content: '', done: true } }));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
await new Promise((r) => wss.once('listening', r));
|
|
77
|
+
vi.stubEnv('INDEMN_PLATFORM_WS_URL', `ws://127.0.0.1:${(wss.address() as AddressInfo).port}/ws`);
|
|
78
|
+
recordFetch([on('POST', '/conversations', { json: { conversation_id: 'conv_1' } })]);
|
|
79
|
+
|
|
80
|
+
const res = await jarvisChat(new IndemnClient(), 'Which templates exist?', { context: { federation_bot_id: 'b1' } });
|
|
81
|
+
expect(res).toEqual({ conversation_id: 'conv_1', reply: 'Hello world', tools: [{ name: 'list_templates', status: 'success' }] });
|
|
82
|
+
const q = new URL(`ws://x${seen.url}`).searchParams;
|
|
83
|
+
expect(q.get('user_id')).toBe(USER);
|
|
84
|
+
expect(q.get('organization_id')).toBe(ORG);
|
|
85
|
+
expect(seen.sent.payload).toEqual({ content: 'Which templates exist?', thread_id: 'conv_1', context: { federation_bot_id: 'b1' } });
|
|
86
|
+
expect(seen.auth).toBe(`Bearer ${token}`);
|
|
87
|
+
await new Promise((r) => wss.close(r));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
4
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
5
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
6
|
+
import { setAskers } from '../../src/core/scope.js';
|
|
7
|
+
import { SessionExpiredError } from '../../src/core/runtime.js';
|
|
8
|
+
|
|
9
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
10
|
+
const BOTS = `/organization/${ORG}/ai-studio/v2/bots`;
|
|
11
|
+
|
|
12
|
+
function login(token: string, refresh = 'r1') {
|
|
13
|
+
IndemnClient.saveConfig({
|
|
14
|
+
environment: 'dev', user_id: 'u1', email: 'qa@indemn.ai',
|
|
15
|
+
session: { token, refresh_token: refresh }, orgs: [{ id: ORG, name: 'QA A' }],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('session renewal', () => {
|
|
20
|
+
let home: ReturnType<typeof useTempHome>;
|
|
21
|
+
beforeEach(() => { cleanEnv(); home = useTempHome(); setAskers({}); });
|
|
22
|
+
afterEach(() => home.cleanup());
|
|
23
|
+
|
|
24
|
+
it('sends the saved session to copilot with the JWT scheme', async () => {
|
|
25
|
+
const token = jwtExpiringIn(5 * 3_600_000);
|
|
26
|
+
login(token);
|
|
27
|
+
const { calls } = recordFetch();
|
|
28
|
+
await new IndemnClient().copilotGet(BOTS);
|
|
29
|
+
expect(calls).toHaveLength(1);
|
|
30
|
+
expect(calls[0].headers.authorization).toBe(`JWT ${token}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renews silently under an hour of life, revokes the old token, then sends the request', async () => {
|
|
34
|
+
const old = jwtExpiringIn(30 * 60_000);
|
|
35
|
+
const fresh = jwtExpiringIn(24 * 3_600_000, { jti: 'new' });
|
|
36
|
+
login(old);
|
|
37
|
+
const { calls } = recordFetch([
|
|
38
|
+
on('POST', 'securetoken.googleapis.com', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
39
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, token: `JWT ${fresh}`, user: { _id: 'u1' } } }),
|
|
40
|
+
on('POST', '/auth/firebase/logout', { json: { success: true } }),
|
|
41
|
+
]);
|
|
42
|
+
await new IndemnClient().copilotGet(BOTS);
|
|
43
|
+
expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
|
|
44
|
+
'POST /v1/token',
|
|
45
|
+
'POST /api/auth/firebase/signin',
|
|
46
|
+
'POST /api/auth/firebase/logout',
|
|
47
|
+
`GET /api${BOTS}`,
|
|
48
|
+
]);
|
|
49
|
+
expect(calls[2].headers.authorization).toBe(`JWT ${old}`);
|
|
50
|
+
expect(calls[3].headers.authorization).toBe(`JWT ${fresh}`);
|
|
51
|
+
expect(IndemnClient.loadConfig()?.session).toMatchObject({ token: fresh, refresh_token: 'r2' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('with MFA and nobody to ask, stops with exit 3 and never sends the request', async () => {
|
|
55
|
+
login(jwtExpiringIn(10 * 60_000));
|
|
56
|
+
const { calls } = recordFetch([
|
|
57
|
+
on('POST', 'securetoken', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
58
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, requiresMfa: true, mfaSessionId: 's1' } }),
|
|
59
|
+
]);
|
|
60
|
+
const err = await new IndemnClient().copilotGet(BOTS).catch((e) => e);
|
|
61
|
+
expect(err).toBeInstanceOf(SessionExpiredError);
|
|
62
|
+
expect(err.exitCode).toBe(3);
|
|
63
|
+
expect(calls.some((c) => c.url.includes('/ai-studio/'))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('with MFA and a way to ask, asks once and continues', async () => {
|
|
67
|
+
login(jwtExpiringIn(10 * 60_000));
|
|
68
|
+
const ask = vi.fn(async () => '123456');
|
|
69
|
+
setAskers({ mfaCode: ask });
|
|
70
|
+
const fresh = jwtExpiringIn(24 * 3_600_000);
|
|
71
|
+
recordFetch([
|
|
72
|
+
on('POST', 'securetoken', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
73
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, requiresMfa: true, mfaSessionId: 's1' } }),
|
|
74
|
+
on('POST', '/auth/firebase/mfa/verify', { json: { success: true, token: `JWT ${fresh}`, user: { _id: 'u1' } } }),
|
|
75
|
+
]);
|
|
76
|
+
await new IndemnClient().copilotGet(BOTS);
|
|
77
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(IndemnClient.loadConfig()?.session?.token).toBe(fresh);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('re-reads the saved login on every request', async () => {
|
|
82
|
+
login(jwtExpiringIn(5 * 3_600_000, { n: 1 }));
|
|
83
|
+
const { calls } = recordFetch();
|
|
84
|
+
const client = new IndemnClient();
|
|
85
|
+
await client.copilotGet(BOTS);
|
|
86
|
+
const second = jwtExpiringIn(5 * 3_600_000, { n: 2 });
|
|
87
|
+
login(second);
|
|
88
|
+
await client.copilotGet(BOTS);
|
|
89
|
+
expect(calls[1].headers.authorization).toBe(`JWT ${second}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('on a 401 renews once and retries once; a second 401 means log in again', async () => {
|
|
93
|
+
login(jwtExpiringIn(5 * 3_600_000));
|
|
94
|
+
let gets = 0;
|
|
95
|
+
const { calls } = recordFetch([
|
|
96
|
+
on('GET', '/ai-studio/', () => (++gets === 1 ? { status: 401, text: 'revoked' } : { json: { success: true, data: [] } })),
|
|
97
|
+
on('POST', 'securetoken', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
98
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, token: `JWT ${jwtExpiringIn(24 * 3_600_000)}`, user: { _id: 'u1' } } }),
|
|
99
|
+
]);
|
|
100
|
+
await expect(new IndemnClient().copilotGet(BOTS)).resolves.toEqual([]);
|
|
101
|
+
expect(calls.filter((c) => c.url.includes('/ai-studio/'))).toHaveLength(2);
|
|
102
|
+
|
|
103
|
+
recordFetch([
|
|
104
|
+
on('GET', '/ai-studio/', { status: 401, text: 'revoked' }),
|
|
105
|
+
on('POST', 'securetoken', { json: { id_token: 'id3', refresh_token: 'r3' } }),
|
|
106
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, token: `JWT ${jwtExpiringIn(24 * 3_600_000)}`, user: { _id: 'u1' } } }),
|
|
107
|
+
]);
|
|
108
|
+
await expect(new IndemnClient().copilotGet(BOTS)).rejects.toBeInstanceOf(SessionExpiredError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('with no login at all, exits 3', async () => {
|
|
112
|
+
recordFetch();
|
|
113
|
+
await expect(new IndemnClient().copilotGet(BOTS)).rejects.toMatchObject({ exitCode: 3 });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { fakeJwt, jwtExpiringIn } from '../helpers/jwt.js';
|
|
4
|
+
import { exchangeIdToken, needsRenewal, refreshIdToken, tokenExpiry } from '../../src/sdk/session.js';
|
|
5
|
+
import { FIREBASE_CONFIGS } from '../../src/sdk/auth.js';
|
|
6
|
+
import { SessionExpiredError } from '../../src/core/runtime.js';
|
|
7
|
+
|
|
8
|
+
describe('token expiry', () => {
|
|
9
|
+
it('reads exp from the token', () => {
|
|
10
|
+
expect(tokenExpiry(fakeJwt({ exp: 1_900_000_000 }))?.toISOString()).toBe(new Date(1_900_000_000_000).toISOString());
|
|
11
|
+
expect(tokenExpiry(fakeJwt({}))).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renews under one hour of life, not with five hours, never without an expiry', () => {
|
|
15
|
+
expect(needsRenewal({ token: jwtExpiringIn(30 * 60_000) })).toBe(true);
|
|
16
|
+
expect(needsRenewal({ token: jwtExpiringIn(5 * 3_600_000) })).toBe(false);
|
|
17
|
+
expect(needsRenewal({ token: fakeJwt({}) })).toBe(false);
|
|
18
|
+
expect(needsRenewal({ token: fakeJwt({}), expires_at: new Date(Date.now() - 1000).toISOString() })).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('refreshIdToken', () => {
|
|
23
|
+
it('posts the refresh token to Google with the environment web key', async () => {
|
|
24
|
+
const { calls } = recordFetch([on('POST', 'securetoken.googleapis.com', { json: { id_token: 'id2', refresh_token: 'r2' } })]);
|
|
25
|
+
await expect(refreshIdToken('dev', 'r1')).resolves.toEqual({ idToken: 'id2', refreshToken: 'r2' });
|
|
26
|
+
expect(calls[0].url).toBe(`https://securetoken.googleapis.com/v1/token?key=${FIREBASE_CONFIGS.dev.apiKey}`);
|
|
27
|
+
expect(calls[0].body).toBe('grant_type=refresh_token&refresh_token=r1');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('turns a rejected refresh token into a log-in-again error', async () => {
|
|
31
|
+
recordFetch([on('POST', 'securetoken', { status: 400, json: { error: { message: 'TOKEN_EXPIRED' } } })]);
|
|
32
|
+
const err = await refreshIdToken('production', 'dead').catch((e) => e);
|
|
33
|
+
expect(err).toBeInstanceOf(SessionExpiredError);
|
|
34
|
+
expect(err.exitCode).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('exchangeIdToken', () => {
|
|
39
|
+
it('returns the copilot token without the JWT prefix', async () => {
|
|
40
|
+
const post = vi.fn(async () => ({ token: 'JWT abc', user: { _id: 'u1', email: 'qa@indemn.ai' } }));
|
|
41
|
+
await expect(exchangeIdToken(post as never, 'id1')).resolves.toEqual({ token: 'abc', userId: 'u1', email: 'qa@indemn.ai' });
|
|
42
|
+
expect(post).toHaveBeenCalledWith('/auth/firebase/signin', { idToken: 'id1' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('asks for the MFA code and verifies it', async () => {
|
|
46
|
+
const post = vi.fn()
|
|
47
|
+
.mockResolvedValueOnce({ requiresMfa: true, mfaSessionId: 's1' })
|
|
48
|
+
.mockResolvedValueOnce({ token: 'JWT def', user: { _id: 'u1' } });
|
|
49
|
+
const ask = vi.fn(async () => ' 123456 ');
|
|
50
|
+
await expect(exchangeIdToken(post as never, 'id1', ask)).resolves.toMatchObject({ token: 'def' });
|
|
51
|
+
expect(post).toHaveBeenLastCalledWith('/auth/firebase/mfa/verify', { sessionId: 's1', code: '123456' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('cannot complete MFA without a way to ask', async () => {
|
|
55
|
+
const post = vi.fn(async () => ({ requiresMfa: true, mfaSessionId: 's1' }));
|
|
56
|
+
const err = await exchangeIdToken(post as never, 'id1').catch((e) => e);
|
|
57
|
+
expect(err).toBeInstanceOf(SessionExpiredError);
|
|
58
|
+
expect(err.message).toMatch(/indemn login.*terminal/);
|
|
59
|
+
});
|
|
60
|
+
});
|