@indemn/cli 1.7.7 → 1.7.8

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.
Files changed (59) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -1
  4. package/dist/cli/auth.d.ts.map +1 -1
  5. package/dist/cli/auth.js +95 -62
  6. package/dist/cli/auth.js.map +1 -1
  7. package/dist/mcp/registry.d.ts +12 -0
  8. package/dist/mcp/registry.d.ts.map +1 -1
  9. package/dist/mcp/registry.js +36 -3
  10. package/dist/mcp/registry.js.map +1 -1
  11. package/dist/mcp/server.d.ts.map +1 -1
  12. package/dist/mcp/server.js +15 -1
  13. package/dist/mcp/server.js.map +1 -1
  14. package/dist/mcp/tools.d.ts.map +1 -1
  15. package/dist/mcp/tools.js +16 -0
  16. package/dist/mcp/tools.js.map +1 -1
  17. package/dist/sdk/access.d.ts +60 -0
  18. package/dist/sdk/access.d.ts.map +1 -0
  19. package/dist/sdk/access.js +115 -0
  20. package/dist/sdk/access.js.map +1 -0
  21. package/dist/sdk/auth.d.ts +7 -0
  22. package/dist/sdk/auth.d.ts.map +1 -1
  23. package/dist/sdk/auth.js +21 -2
  24. package/dist/sdk/auth.js.map +1 -1
  25. package/dist/sdk/browser-login.d.ts +49 -0
  26. package/dist/sdk/browser-login.d.ts.map +1 -0
  27. package/dist/sdk/browser-login.js +159 -0
  28. package/dist/sdk/browser-login.js.map +1 -0
  29. package/dist/sdk/client.d.ts +2 -0
  30. package/dist/sdk/client.d.ts.map +1 -1
  31. package/dist/sdk/client.js +31 -3
  32. package/dist/sdk/client.js.map +1 -1
  33. package/dist/sdk/credentials.d.ts +2 -0
  34. package/dist/sdk/credentials.d.ts.map +1 -1
  35. package/dist/sdk/credentials.js +4 -0
  36. package/dist/sdk/credentials.js.map +1 -1
  37. package/dist/sdk/eval.d.ts +3 -3
  38. package/dist/sdk/eval.js +3 -3
  39. package/dist/sdk/types.d.ts +3 -0
  40. package/dist/sdk/types.d.ts.map +1 -1
  41. package/dist/sdk/types.js +0 -1
  42. package/dist/sdk/types.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/cli/auth.ts +93 -63
  45. package/src/mcp/registry.ts +43 -4
  46. package/src/mcp/server.ts +20 -1
  47. package/src/mcp/tools.ts +15 -0
  48. package/src/sdk/access.ts +143 -0
  49. package/src/sdk/auth.ts +23 -2
  50. package/src/sdk/browser-login.ts +193 -0
  51. package/src/sdk/client.ts +25 -3
  52. package/src/sdk/credentials.ts +5 -0
  53. package/src/sdk/eval.ts +3 -3
  54. package/src/sdk/types.ts +3 -0
  55. package/test/mcp/access.test.ts +158 -0
  56. package/test/sdk/auth.test.ts +2 -1
  57. package/test/sdk/browser-login.test.ts +147 -0
  58. package/test/sdk/client.test.ts +134 -1
  59. package/test/sdk/credentials.test.ts +6 -1
@@ -0,0 +1,158 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
4
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
5
+ import { jwtExpiringIn } from '../helpers/jwt.js';
6
+ import { connectPair } from '../helpers/mcp-pair.js';
7
+ import { IndemnClient } from '../../src/sdk/client.js';
8
+ import { registerTools } from '../../src/mcp/tools.js';
9
+ import { refreshAccess, resetAccess, usableSomewhere, type Access } from '../../src/sdk/access.js';
10
+ import type { Registry } from '../../src/mcp/registry.js';
11
+
12
+ const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
13
+ const B = 'bbbbbbbbbbbbbbbbbbbbbbbb';
14
+
15
+ function accessWith(bTickets = false): Access {
16
+ return {
17
+ user: { id: 'u1', email: 'pat@example.test', name: 'Pat' },
18
+ is_indemn_staff: false,
19
+ capabilities: [],
20
+ orgs: [
21
+ { org_id: A, org_name: 'QA A', role: 'admin', is_org_admin: true, capabilities: ['member', 'tickets'],
22
+ projects: [{ project_id: 'p1', name: 'A main', role: 'admin', capabilities: ['conversations:messages', 'conversations:read'] }] },
23
+ { org_id: B, org_name: 'QA B', role: 'guest', is_org_admin: false, capabilities: bTickets ? ['member', 'tickets'] : ['member'],
24
+ projects: [{ project_id: 'p2', name: 'B main', role: 'guest', capabilities: ['conversations:messages'] }] },
25
+ ],
26
+ };
27
+ }
28
+
29
+ describe('role-aware MCP tools', () => {
30
+ let home: ReturnType<typeof useTempHome>;
31
+ let registry: Registry;
32
+
33
+ beforeEach(() => {
34
+ cleanEnv();
35
+ home = useTempHome();
36
+ resetAccess();
37
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
38
+ IndemnClient.saveConfig({
39
+ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) },
40
+ orgs: [{ id: A, name: 'QA A' }, { id: B, name: 'QA B' }],
41
+ access: accessWith(),
42
+ });
43
+ });
44
+ afterEach(() => { resetAccess(); home.cleanup(); });
45
+
46
+ function setup(server: Parameters<typeof registerTools>[0]) {
47
+ registry = registerTools(server, new IndemnClient());
48
+ registry.applyVisibility();
49
+ }
50
+
51
+ async function toolNames(pair: Awaited<ReturnType<typeof connectPair>>) {
52
+ return (await pair.client.listTools()).tools.map((t) => t.name);
53
+ }
54
+
55
+ it('hides tools the person can use nowhere, keeps the rest', async () => {
56
+ recordFetch();
57
+ const pair = await connectPair(setup);
58
+ const names = await toolNames(pair);
59
+ expect(names).toContain('list_tickets'); // tickets on in QA A
60
+ expect(names).toContain('get_conversation'); // admin in A's project
61
+ expect(names).toContain('list_agents'); // membership only
62
+ expect(names).not.toContain('set_org_admin_settings'); // staff only
63
+ expect(names).not.toContain('create_sandbox_org'); // support account only
64
+ expect(names).not.toContain('mark_conversation_test'); // staff + agent
65
+ await pair.close();
66
+ });
67
+
68
+ it('a folder linked to one org counts only that org', async () => {
69
+ vi.stubEnv('INDEMN_ORG_ID', B);
70
+ recordFetch();
71
+ const pair = await connectPair(setup);
72
+ const names = await toolNames(pair);
73
+ expect(names).not.toContain('list_tickets');
74
+ expect(names).not.toContain('get_conversation');
75
+ expect(names).toContain('list_agents');
76
+ await pair.close();
77
+ });
78
+
79
+ it('refuses early, with the reason, where the org has no access (after asking again)', async () => {
80
+ const { calls } = recordFetch([on('GET', '/auth/me/access', { json: accessWith() })]);
81
+ const pair = await connectPair(setup);
82
+ const res = await pair.call('list_tickets', { org_id: B });
83
+ expect(res.isError).toBe(true);
84
+ expect(res.body).toContain('Tickets are not turned on for QA B');
85
+ expect(calls.some((c) => c.url.includes('/auth/me/access'))).toBe(true); // stale access is re-checked first
86
+ expect(calls.some((c) => c.url.includes('/tickets'))).toBe(false);
87
+ await pair.close();
88
+ });
89
+
90
+ it('a refresh that grants access lets the call through', async () => {
91
+ const { calls } = recordFetch([on('GET', '/auth/me/access', { json: accessWith(true) })]);
92
+ const pair = await connectPair(setup);
93
+ const res = await pair.call('list_tickets', { org_id: B });
94
+ expect(res.isError).toBe(false);
95
+ expect(calls.some((c) => c.url.includes(`/organization/${B}/tickets`))).toBe(true);
96
+ await pair.close();
97
+ });
98
+
99
+ it('concurrent calls for different orgs each get their own answer', async () => {
100
+ const { calls } = recordFetch([on('GET', '/auth/me/access', { json: accessWith() })]);
101
+ const pair = await connectPair(setup);
102
+ const [inA, inB, inA2] = await Promise.all([
103
+ pair.call('list_tickets', { org_id: A }),
104
+ pair.call('list_tickets', { org_id: B }),
105
+ pair.call('list_tickets', { org_id: A }),
106
+ ]);
107
+ expect(inA.isError).toBe(false);
108
+ expect(inA2.isError).toBe(false);
109
+ expect(inB.isError).toBe(true);
110
+ expect(inA.body.context.org_id).toBe(A);
111
+ const ticketCalls = calls.filter((c) => c.url.includes('/tickets'));
112
+ expect(ticketCalls).toHaveLength(2);
113
+ expect(ticketCalls.every((c) => c.url.includes(`/organization/${A}/tickets`))).toBe(true);
114
+ await pair.close();
115
+ });
116
+
117
+ it('a server refusal names the person\'s role', async () => {
118
+ recordFetch([
119
+ on('GET', '/auth/me/access', { json: accessWith() }),
120
+ on('GET', '/ai-studio/v2/bots', { status: 403, json: { success: false, msg: 'Forbidden' } }),
121
+ ]);
122
+ const pair = await connectPair(setup);
123
+ const res = await pair.call('list_agents', { org_id: B });
124
+ expect(res.isError).toBe(true);
125
+ expect(res.body).toContain('Not allowed');
126
+ expect(res.body).toContain('Your role: guest in QA B.');
127
+ await pair.close();
128
+ });
129
+
130
+ it('tells the host when the visible tools change', async () => {
131
+ vi.stubEnv('INDEMN_ORG_ID', B);
132
+ recordFetch([on('GET', '/auth/me/access', { json: accessWith(true) })]);
133
+ const pair = await connectPair(setup);
134
+ let changed = 0;
135
+ pair.client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { changed += 1; });
136
+ expect(await toolNames(pair)).not.toContain('list_tickets');
137
+ await refreshAccess(new IndemnClient());
138
+ registry.applyVisibility();
139
+ await vi.waitFor(() => expect(changed).toBeGreaterThan(0));
140
+ expect(await toolNames(pair)).toContain('list_tickets');
141
+ await pair.close();
142
+ });
143
+
144
+ it('unknown access hides nothing', () => {
145
+ expect(usableSomewhere('set_org_admin_settings', null)).toBe(true);
146
+ expect(usableSomewhere('list_tickets', null)).toBe(true);
147
+ });
148
+
149
+ it('a host session (INDEMN_TOKEN) never writes access to this machine', async () => {
150
+ IndemnClient.clearConfig();
151
+ vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(3_600_000));
152
+ vi.stubEnv('INDEMN_ENV', 'dev');
153
+ recordFetch([on('GET', '/auth/me/access', { json: accessWith() })]);
154
+ const got = await refreshAccess(new IndemnClient());
155
+ expect(got?.orgs).toHaveLength(2);
156
+ expect(IndemnClient.loadConfig()).toBeNull();
157
+ });
158
+ });
@@ -24,7 +24,8 @@ describe('token-first login', () => {
24
24
  ]);
25
25
  const result = await login('qa@indemn.ai', 'pw', 'dev', { firebaseSignIn: fakeFirebase });
26
26
 
27
- expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(['POST /api/auth/firebase/signin', 'GET /api/organization']);
27
+ // Then what the person may do, kept with the login (display only).
28
+ expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(['POST /api/auth/firebase/signin', 'GET /api/organization', 'GET /api/auth/me/access']);
28
29
  expect(calls.some((c) => c.url.includes('/auth/api-keys'))).toBe(false);
29
30
  expect(calls[1].headers.authorization).toBe(`JWT ${token}`);
30
31
  expect(result.orgs).toEqual([{ id: A._id, name: 'QA A' }, { id: B._id, name: 'QA B' }]);
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { request } from 'node:http';
5
+ import { join } from 'node:path';
6
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
7
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
8
+ import { jwtExpiringIn } from '../helpers/jwt.js';
9
+ import { approveUrl, createPkce, dashboardUrl, startLoopback } from '../../src/sdk/browser-login.js';
10
+ import { loginWithBrowser } from '../../src/sdk/auth.js';
11
+
12
+ /** GET a loopback URL with node:http (global fetch is recorded in these tests). */
13
+ function hit(url: string): Promise<{ status: number; body: string }> {
14
+ return new Promise((resolve, reject) => {
15
+ request(url, (res) => {
16
+ let body = '';
17
+ res.on('data', (c) => (body += c));
18
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
19
+ }).on('error', reject).end();
20
+ });
21
+ }
22
+
23
+ describe('PKCE and URLs', () => {
24
+ beforeEach(() => cleanEnv());
25
+
26
+ it('challenge is the base64url SHA-256 of the verifier; values are fresh each time', () => {
27
+ const a = createPkce();
28
+ const b = createPkce();
29
+ expect(a.challenge).toBe(createHash('sha256').update(a.verifier).digest('base64url'));
30
+ expect(a.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
31
+ expect(a.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/);
32
+ expect(a.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
33
+ expect(a.verifier).not.toBe(b.verifier);
34
+ expect(a.state).not.toBe(b.state);
35
+ });
36
+
37
+ it('dashboard is the copilot host without /api, or INDEMN_DASHBOARD_URL (Indemn or localhost only)', () => {
38
+ expect(dashboardUrl('https://devcopilot.indemn.ai/api')).toBe('https://devcopilot.indemn.ai');
39
+ vi.stubEnv('INDEMN_DASHBOARD_URL', 'http://localhost:5174/');
40
+ expect(dashboardUrl('https://devcopilot.indemn.ai/api')).toBe('http://localhost:5174');
41
+ vi.stubEnv('INDEMN_DASHBOARD_URL', 'https://evil.example');
42
+ expect(() => dashboardUrl('https://devcopilot.indemn.ai/api')).toThrow(/indemn\.ai/);
43
+ });
44
+
45
+ it('approve URL carries port, state and challenge but never the verifier', () => {
46
+ const p = createPkce();
47
+ const url = approveUrl('https://copilot.indemn.ai', 50123, p);
48
+ expect(url.startsWith('https://copilot.indemn.ai/#/cli-login?')).toBe(true);
49
+ const q = new URLSearchParams(url.split('?')[1]);
50
+ expect(q.get('port')).toBe('50123');
51
+ expect(q.get('state')).toBe(p.state);
52
+ expect(q.get('challenge')).toBe(p.challenge);
53
+ expect(url).not.toContain(p.verifier);
54
+ });
55
+ });
56
+
57
+ describe('loopback listener', () => {
58
+ it('binds 127.0.0.1, ignores a wrong state, takes the first matching callback, then closes', async () => {
59
+ const loop = await startLoopback('the-state', 10_000);
60
+ const base = `http://127.0.0.1:${loop.port}`;
61
+ expect((await hit(`${base}/other`)).status).toBe(404);
62
+ expect((await hit(`${base}/callback?code=x&state=wrong`)).status).toBe(400);
63
+ const ok = await hit(`${base}/callback?code=the-code&state=the-state`);
64
+ expect(ok.status).toBe(200);
65
+ expect(ok.body).toContain('close this tab');
66
+ expect(ok.body).not.toContain('the-code');
67
+ await expect(loop.result).resolves.toEqual({ code: 'the-code' });
68
+ await expect(hit(`${base}/callback?code=again&state=the-state`)).rejects.toThrow();
69
+ });
70
+
71
+ it('reports a cancel from the browser', async () => {
72
+ const loop = await startLoopback('s', 10_000);
73
+ await hit(`http://127.0.0.1:${loop.port}/callback?error=access_denied&state=s`);
74
+ await expect(loop.result).resolves.toEqual({ error: 'access_denied' });
75
+ });
76
+
77
+ it('times out', async () => {
78
+ const loop = await startLoopback('s', 50);
79
+ await expect(loop.result).rejects.toThrow(/Timed out/);
80
+ });
81
+ });
82
+
83
+ describe('loginWithBrowser', () => {
84
+ let home: ReturnType<typeof useTempHome>;
85
+ beforeEach(() => { cleanEnv(); home = useTempHome(); });
86
+ afterEach(() => home.cleanup());
87
+
88
+ /** A fake browser: reads the approve URL and calls back like the dashboard would. */
89
+ function approvingBrowser(seen: { challenge?: string }, outcome: 'approve' | 'cancel' = 'approve') {
90
+ return async (url: string) => {
91
+ const q = new URLSearchParams(url.split('?')[1]);
92
+ seen.challenge = q.get('challenge') ?? undefined;
93
+ const back = new URLSearchParams(outcome === 'approve' ? { code: 'one-time-code', state: q.get('state')! } : { error: 'access_denied', state: q.get('state')! });
94
+ setTimeout(() => void hit(`http://127.0.0.1:${q.get('port')}/callback?${back}`), 10);
95
+ return true;
96
+ };
97
+ }
98
+
99
+ it('exchanges the code with the verifier and saves the session like a normal login', async () => {
100
+ const token = jwtExpiringIn(24 * 3_600_000, { cli: true });
101
+ const { calls } = recordFetch([
102
+ on('POST', '/auth/cli/token', { json: { token: `JWT ${token}`, expires_at: 'x', user: { _id: 'u1', email: 'pat@example.test' } } }),
103
+ on('GET', '/organization', { json: { success: true, userOrganizations: [{ _id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Acme' }] } }),
104
+ ]);
105
+ const seen: { challenge?: string } = {};
106
+ const result = await loginWithBrowser('dev', { open: approvingBrowser(seen), timeoutMs: 10_000 });
107
+
108
+ const exchange = calls.find((c) => c.url.endsWith('/auth/cli/token'))!;
109
+ expect(new URL(exchange.url).origin).toBe('https://devcopilot.indemn.ai');
110
+ expect(exchange.headers.authorization).toBeUndefined();
111
+ const body = exchange.body as { code: string; code_verifier: string };
112
+ expect(body.code).toBe('one-time-code');
113
+ expect(createHash('sha256').update(body.code_verifier).digest('base64url')).toBe(seen.challenge);
114
+
115
+ expect(result.email).toBe('pat@example.test');
116
+ expect(result.orgs).toEqual([{ id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Acme' }]);
117
+ const saved = JSON.parse(readFileSync(join(home.dir, '.indemn', 'config.json'), 'utf8'));
118
+ expect(saved.session.token).toBe(token);
119
+ expect(saved.email).toBe('pat@example.test');
120
+ expect(saved).not.toHaveProperty('api_key');
121
+ if (process.platform !== 'win32') {
122
+ expect(statSync(join(home.dir, '.indemn', 'config.json')).mode & 0o777).toBe(0o600);
123
+ expect(statSync(join(home.dir, '.indemn')).mode & 0o777).toBe(0o700);
124
+ }
125
+ });
126
+
127
+ it('cancel in the browser saves nothing', async () => {
128
+ recordFetch([]);
129
+ await expect(loginWithBrowser('dev', { open: approvingBrowser({}, 'cancel'), timeoutMs: 10_000 })).rejects.toThrow(/cancelled/);
130
+ expect(() => readFileSync(join(home.dir, '.indemn', 'config.json'))).toThrow();
131
+ });
132
+
133
+ it('a refused code says to log in again', async () => {
134
+ recordFetch([on('POST', '/auth/cli/token', { status: 400, json: { success: false, error: 'invalid_grant' } })]);
135
+ await expect(loginWithBrowser('dev', { open: approvingBrowser({}), timeoutMs: 10_000 })).rejects.toThrow(/not accepted/);
136
+ });
137
+
138
+ it('shows the URL even when the browser cannot be opened', async () => {
139
+ recordFetch([]);
140
+ const shown: Array<[string, boolean]> = [];
141
+ const pending = loginWithBrowser('dev', { open: async () => false, showUrl: (u, o) => shown.push([u, o]), timeoutMs: 100 });
142
+ await expect(pending).rejects.toThrow(/Timed out/);
143
+ expect(shown).toHaveLength(1);
144
+ expect(shown[0][1]).toBe(false);
145
+ expect(shown[0][0]).toContain('/#/cli-login?');
146
+ });
147
+ });
@@ -1,8 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { readFileSync } from 'node:fs';
3
- import { on, recordFetch } from '../helpers/fetch-recorder.js';
3
+ import { on, recordFetch, type RecordedCall } from '../helpers/fetch-recorder.js';
4
4
  import { cleanEnv, useTempHome } from '../helpers/config-home.js';
5
+ import { jwtExpiringIn } from '../helpers/jwt.js';
5
6
  import { IndemnClient } from '../../src/sdk/client.js';
7
+ import { runWithOrg } from '../../src/core/scope.js';
8
+ import { EXIT, SessionExpiredError } from '../../src/core/runtime.js';
6
9
 
7
10
  const version = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
8
11
  const DEV_KEY = `ind_dev_${'a'.repeat(64)}`;
@@ -91,3 +94,133 @@ describe('IndemnClient request path', () => {
91
94
  expect(() => jarvisSocketUrl(new IndemnClient())).toThrow(/only to Indemn/);
92
95
  });
93
96
  });
97
+
98
+ describe('evaluations requests carry the credential', () => {
99
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
100
+ const TWO = 'cccccccccccccccccccccccc';
101
+ let home: ReturnType<typeof useTempHome>;
102
+ beforeEach(() => {
103
+ cleanEnv();
104
+ home = useTempHome();
105
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
106
+ });
107
+ afterEach(() => home.cleanup());
108
+
109
+ const evalsCall = (calls: RecordedCall[]) => calls.find((c) => c.url.startsWith('https://devevaluations.indemn.ai/'))!;
110
+
111
+ it('sends a saved session as Bearer, and copilot and Darwin keep their own schemes', async () => {
112
+ const token = jwtExpiringIn(5 * 3_600_000);
113
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token }, orgs: [{ id: ORG, name: 'QA A' }] });
114
+ const { calls } = recordFetch([on('GET', '/v1-agents', { json: [] })]);
115
+ const client = new IndemnClient();
116
+ await client.evalsGet('/api/v1/rubrics');
117
+ await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
118
+ await client.platformRequest('GET', '/v1-agents');
119
+ expect(calls.map((c) => c.headers.authorization)).toEqual([`Bearer ${token}`, `JWT ${token}`, `Bearer ${token}`]);
120
+ expect(calls[2].headers['x-organization-id']).toBe(ORG);
121
+ });
122
+
123
+ it('sends an API key as Bearer (evaluations checks it and pins it to the key\'s org)', async () => {
124
+ vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
125
+ const { calls } = recordFetch();
126
+ const client = new IndemnClient({ orgId: 'org1' });
127
+ await client.evalsPost('/api/v1/rubrics', { name: 'r' });
128
+ await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
129
+ expect(calls.map((c) => c.headers.authorization)).toEqual([`Bearer ${DEV_KEY}`, `Bearer ${DEV_KEY}`]);
130
+ });
131
+
132
+ it('passes on the session a host hands over (Darwin starting the toolbox for Jarvis)', async () => {
133
+ const token = jwtExpiringIn(5 * 3_600_000);
134
+ vi.stubEnv('INDEMN_TOKEN', `JWT ${token}`);
135
+ vi.stubEnv('INDEMN_ENV', 'dev');
136
+ const { calls } = recordFetch();
137
+ await new IndemnClient().evalsGet('/api/v1/runs/r1');
138
+ expect(evalsCall(calls).headers.authorization).toBe(`Bearer ${token}`);
139
+ });
140
+
141
+ it('names the org once one is in effect: an MCP call\'s scope or the org this client already settled on', async () => {
142
+ vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
143
+ const { calls } = recordFetch();
144
+ const client = new IndemnClient({ orgId: 'org1' });
145
+ await client.evalsGet('/api/v1/rubrics');
146
+ expect(calls[0].headers['x-organization-id']).toBeUndefined();
147
+ await runWithOrg('org-scoped', () => client.evalsGet('/api/v1/rubrics'));
148
+ expect(calls[1].headers['x-organization-id']).toBe('org-scoped');
149
+ await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
150
+ await client.evalsGet('/api/v1/rubrics');
151
+ expect(calls[3].headers['x-organization-id']).toBe('org1');
152
+ });
153
+
154
+ it('never asks which org or looks one up just to call evaluations', async () => {
155
+ 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' }] });
156
+ const { calls } = recordFetch();
157
+ await new IndemnClient().evalsGet('/api/v1/runs', { agent_id: 'a1' });
158
+ expect(calls).toHaveLength(1);
159
+ expect(calls[0].headers.authorization).toMatch(/^Bearer /);
160
+ expect(calls[0].headers['x-organization-id']).toBeUndefined();
161
+ });
162
+
163
+ it('sends nothing without a credential or with auth: false', async () => {
164
+ const { calls } = recordFetch();
165
+ await new IndemnClient().evalsGet('/api/v1/rubrics');
166
+ expect(calls[0].headers.authorization).toBeUndefined();
167
+ expect(calls[0].headers['x-organization-id']).toBeUndefined();
168
+
169
+ vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
170
+ const client = new IndemnClient();
171
+ // No evaluations caller passes auth: false today; the switch is honoured like copilot's.
172
+ await (client as unknown as { send: (b: string, m: string, u: string, o: object) => Promise<Response> })
173
+ .send('evaluations', 'GET', 'https://devevaluations.indemn.ai/api/v1/rubrics', { auth: false });
174
+ expect(calls[1].headers.authorization).toBeUndefined();
175
+ });
176
+
177
+ it('refuses locally once a saved login has ended, like copilot', async () => {
178
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(-60_000), expires_at: new Date(Date.now() - 60_000).toISOString() } });
179
+ const { calls } = recordFetch();
180
+ await expect(new IndemnClient().evalsGet('/api/v1/rubrics')).rejects.toThrow(/indemn login/);
181
+ expect(calls).toHaveLength(0);
182
+ });
183
+
184
+ it('turns a 401 from evaluations into "log in again", keeping the service\'s reason', async () => {
185
+ vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
186
+ recordFetch([on('GET', '/api/v1/rubrics', { status: 401, json: { detail: 'Invalid or expired credential' } })]);
187
+ const unauth = await new IndemnClient().evalsGet('/api/v1/rubrics').catch((e) => e);
188
+ expect(unauth).toBeInstanceOf(SessionExpiredError);
189
+ expect(unauth.exitCode).toBe(EXIT.AUTH);
190
+ expect(unauth.message).toMatch(/Invalid or expired credential.*Run `indemn login`/);
191
+ });
192
+
193
+ it('reports a 403 from evaluations as a denial with the service\'s reason, not as a login problem', async () => {
194
+ vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
195
+ recordFetch([
196
+ on('POST', '/api/v1/rubrics', { status: 403, json: { detail: 'Access denied to this agent' } }),
197
+ on('GET', '/api/v1/test-sets', { status: 403, json: { detail: "API key's organization is no longer accessible" } }),
198
+ ]);
199
+ const client = new IndemnClient();
200
+ const denied = await client.evalsPost('/api/v1/rubrics', { name: 'r' }).catch((e) => e);
201
+ expect(denied).not.toBeInstanceOf(SessionExpiredError);
202
+ expect(denied.exitCode).toBe(EXIT.ERROR);
203
+ expect(denied.message).toBe('The evaluations service denied access (Access denied to this agent).');
204
+ const gone = await client.evalsGet('/api/v1/test-sets').catch((e) => e);
205
+ expect(gone.message).toBe("The evaluations service denied access (API key's organization is no longer accessible).");
206
+ expect(gone.message).not.toMatch(/indemn login|Sign in/);
207
+ });
208
+
209
+ it('a host\'s session is told to sign in to Copilot again on a 401', async () => {
210
+ vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(5 * 3_600_000));
211
+ vi.stubEnv('INDEMN_ENV', 'dev');
212
+ recordFetch([on('GET', '/api/v1/runs', { status: 401, json: { detail: 'expired' } })]);
213
+ await expect(new IndemnClient().evalsGet('/api/v1/runs')).rejects.toThrow(/Sign in to Copilot again/);
214
+ });
215
+
216
+ it('never prints the evaluations credential with --debug', async () => {
217
+ vi.stubEnv('INDEMN_DEBUG', '1');
218
+ vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
219
+ const writes: string[] = [];
220
+ vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string) => { writes.push(String(chunk)); return true; }) as never);
221
+ recordFetch();
222
+ await new IndemnClient().evalsGet('/api/v1/rubrics');
223
+ expect(writes.join('')).toContain('GET https://devevaluations.indemn.ai/api/v1/rubrics');
224
+ expect(writes.join('')).not.toContain(DEV_KEY);
225
+ });
226
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { copilotAuthHeader, darwinAuthHeader, resolveCredential } from '../../src/sdk/credentials.js';
2
+ import { copilotAuthHeader, darwinAuthHeader, evaluationsAuthHeader, resolveCredential } from '../../src/sdk/credentials.js';
3
3
  import type { IndemnConfig } from '../../src/sdk/types.js';
4
4
 
5
5
  const DEV_KEY = `ind_dev_${'a'.repeat(64)}`;
@@ -30,4 +30,9 @@ describe('auth headers', () => {
30
30
  expect(darwinAuthHeader({ kind: 'token', token: 't', source: 'config', environment: 'dev' })).toBe('Bearer t');
31
31
  expect(() => darwinAuthHeader({ kind: 'api_key', key: DEV_KEY, source: 'env', environment: 'dev' })).toThrow(/login session/);
32
32
  });
33
+
34
+ it('sends the session or an API key to evaluations as Bearer', () => {
35
+ expect(evaluationsAuthHeader({ kind: 'token', token: 't', source: 'config', environment: 'dev' })).toBe('Bearer t');
36
+ expect(evaluationsAuthHeader({ kind: 'api_key', key: DEV_KEY, source: 'env', environment: 'dev' })).toBe(`Bearer ${DEV_KEY}`);
37
+ });
33
38
  });