@indemn/cli 1.7.6 → 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 (66) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +1 -1
  4. package/README.md +3 -1
  5. package/dist/cli/auth.d.ts.map +1 -1
  6. package/dist/cli/auth.js +95 -62
  7. package/dist/cli/auth.js.map +1 -1
  8. package/dist/mcp/registry.d.ts +12 -0
  9. package/dist/mcp/registry.d.ts.map +1 -1
  10. package/dist/mcp/registry.js +39 -4
  11. package/dist/mcp/registry.js.map +1 -1
  12. package/dist/mcp/server.d.ts.map +1 -1
  13. package/dist/mcp/server.js +15 -1
  14. package/dist/mcp/server.js.map +1 -1
  15. package/dist/mcp/tool-meta.d.ts.map +1 -1
  16. package/dist/mcp/tool-meta.js +2 -1
  17. package/dist/mcp/tool-meta.js.map +1 -1
  18. package/dist/mcp/tools.d.ts.map +1 -1
  19. package/dist/mcp/tools.js +19 -7
  20. package/dist/mcp/tools.js.map +1 -1
  21. package/dist/sdk/access.d.ts +60 -0
  22. package/dist/sdk/access.d.ts.map +1 -0
  23. package/dist/sdk/access.js +115 -0
  24. package/dist/sdk/access.js.map +1 -0
  25. package/dist/sdk/auth.d.ts +7 -0
  26. package/dist/sdk/auth.d.ts.map +1 -1
  27. package/dist/sdk/auth.js +21 -2
  28. package/dist/sdk/auth.js.map +1 -1
  29. package/dist/sdk/browser-login.d.ts +49 -0
  30. package/dist/sdk/browser-login.d.ts.map +1 -0
  31. package/dist/sdk/browser-login.js +159 -0
  32. package/dist/sdk/browser-login.js.map +1 -0
  33. package/dist/sdk/client.d.ts +2 -0
  34. package/dist/sdk/client.d.ts.map +1 -1
  35. package/dist/sdk/client.js +31 -3
  36. package/dist/sdk/client.js.map +1 -1
  37. package/dist/sdk/credentials.d.ts +2 -0
  38. package/dist/sdk/credentials.d.ts.map +1 -1
  39. package/dist/sdk/credentials.js +4 -0
  40. package/dist/sdk/credentials.js.map +1 -1
  41. package/dist/sdk/eval.d.ts +3 -3
  42. package/dist/sdk/eval.js +3 -3
  43. package/dist/sdk/types.d.ts +3 -0
  44. package/dist/sdk/types.d.ts.map +1 -1
  45. package/dist/sdk/types.js +0 -1
  46. package/dist/sdk/types.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cli/auth.ts +93 -63
  49. package/src/mcp/registry.ts +46 -5
  50. package/src/mcp/server.ts +20 -1
  51. package/src/mcp/tool-meta.ts +2 -1
  52. package/src/mcp/tools.ts +18 -7
  53. package/src/sdk/access.ts +143 -0
  54. package/src/sdk/auth.ts +23 -2
  55. package/src/sdk/browser-login.ts +193 -0
  56. package/src/sdk/client.ts +25 -3
  57. package/src/sdk/credentials.ts +5 -0
  58. package/src/sdk/eval.ts +3 -3
  59. package/src/sdk/types.ts +3 -0
  60. package/test/mcp/access.test.ts +158 -0
  61. package/test/mcp/registry.test.ts +14 -0
  62. package/test/mcp/tools-snapshot.test.ts +10 -0
  63. package/test/sdk/auth.test.ts +2 -1
  64. package/test/sdk/browser-login.test.ts +147 -0
  65. package/test/sdk/client.test.ts +134 -1
  66. package/test/sdk/credentials.test.ts +6 -1
package/src/sdk/client.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import Conf from 'conf';
2
2
  import { chmodSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
3
4
  import type { ApiResponse, Environment, EnvironmentHosts, IndemnConfig, OrgSummary } from './types.js';
4
5
  import { CliError, cliVersion, indemnHost, isDebug, SessionExpiredError } from '../core/runtime.js';
5
- import { copilotAuthHeader, darwinAuthHeader, resolveCredential, type Credential } from './credentials.js';
6
+ import { copilotAuthHeader, darwinAuthHeader, evaluationsAuthHeader, resolveCredential, type Credential } from './credentials.js';
6
7
  import { tokenExpiry } from './session.js';
7
8
  import { findLinkFile, resolveOrg, type LinkFile, type ResolvedOrg } from '../core/org-resolver.js';
8
9
  import { emitOrgResolved, getAskers, getOrgFlag, scopedOrg } from '../core/scope.js';
@@ -71,6 +72,8 @@ export class IndemnClient {
71
72
  private environment: Environment;
72
73
  private hosts: EnvironmentHosts;
73
74
  private resolvedOrg?: Promise<OrgContext>;
75
+ /** The org `resolvedOrg` settled on, readable without waiting or asking. */
76
+ private settledOrgId?: string;
74
77
  private linkCache?: LinkFile | null;
75
78
  private membershipCache?: OrgSummary[];
76
79
 
@@ -126,6 +129,7 @@ export class IndemnClient {
126
129
  orgs: store.get('orgs'),
127
130
  api_key: apiKey,
128
131
  hosts: store.get('hosts', DEFAULT_HOSTS),
132
+ access: store.get('access'),
129
133
  };
130
134
  } catch {
131
135
  return null;
@@ -138,9 +142,10 @@ export class IndemnClient {
138
142
  if (value === undefined) store.delete(key as keyof IndemnConfig);
139
143
  else store.set(key as keyof IndemnConfig, value);
140
144
  }
141
- // Restrict config file to owner-only (contains credentials)
145
+ // Restrict the config file and its folder to owner-only (they hold the session)
142
146
  try {
143
147
  chmodSync(store.path, 0o600);
148
+ chmodSync(dirname(store.path), 0o700);
144
149
  } catch {
145
150
  // Non-fatal — Windows doesn't support Unix permissions
146
151
  }
@@ -246,6 +251,7 @@ export class IndemnClient {
246
251
  if (explicit || opts.cache === false) return run();
247
252
  if (!this.resolvedOrg) {
248
253
  this.resolvedOrg = run().then((org) => {
254
+ this.settledOrgId = org.id;
249
255
  emitOrgResolved({ id: org.id, name: org.name, source: org.source, environment: this.environment });
250
256
  return org;
251
257
  });
@@ -322,6 +328,12 @@ export class IndemnClient {
322
328
  }
323
329
  if (backend === 'copilot' && opts.auth !== false) {
324
330
  headers['Authorization'] = await this.authHeader();
331
+ } else if (backend === 'evaluations' && opts.auth !== false && this.getCredential()) {
332
+ // Without any credential the request goes out bare, as before; the service then answers 401.
333
+ headers['Authorization'] = evaluationsAuthHeader(await this.freshCredential());
334
+ // Only an org already in effect: the service resolves the user's orgs itself, so never ask or look one up here.
335
+ const org = scopedOrg() ?? this.settledOrgId;
336
+ if (org) headers['X-Organization-Id'] = org;
325
337
  }
326
338
 
327
339
  const attempts = method === 'GET' ? RETRY_DELAYS_MS.length + 1 : 1;
@@ -429,7 +441,7 @@ export class IndemnClient {
429
441
  return res.json() as Promise<T>;
430
442
  }
431
443
 
432
- // Evaluations Service requests (no /api/ prefix, no org_id, no auth)
444
+ // Evaluations Service requests (no /api/ prefix, no org_id in the path; the credential goes as Bearer, see send())
433
445
  async evalsGet<T = unknown>(path: string, query?: Record<string, string>): Promise<T> {
434
446
  const res = await this.send('evaluations', 'GET', this.withQuery(this.buildEvalsUrl(path), query));
435
447
  return this.handleEvalsResponse<T>(res);
@@ -490,6 +502,16 @@ export class IndemnClient {
490
502
  }
491
503
 
492
504
  private async handleEvalsResponse<T>(res: Response): Promise<T> {
505
+ if (res.status === 401 || res.status === 403) {
506
+ // Keep the service's reason. 401: no valid credential (log in again). 403: signed in but not allowed.
507
+ const text = await res.text().catch(() => '');
508
+ let detail: unknown = text;
509
+ try { detail = (JSON.parse(text) as { detail?: unknown }).detail ?? text; } catch { /* plain text */ }
510
+ const reason = (typeof detail === 'string' ? detail : JSON.stringify(detail)).trim();
511
+ const shown = reason && !reason.startsWith('<') ? ` (${reason.slice(0, 200)})` : '';
512
+ if (res.status === 401) throw new SessionExpiredError(this.loginAgain(`The evaluations service refused the request${shown}.`));
513
+ throw new CliError(`The evaluations service denied access${shown}.`);
514
+ }
493
515
  if (!res.ok) {
494
516
  const text = await res.text();
495
517
  throw new Error(`Evaluations API error (${res.status}): ${text}`);
@@ -51,3 +51,8 @@ export function darwinAuthHeader(c: Credential): string {
51
51
  }
52
52
  return `Bearer ${c.token}`;
53
53
  }
54
+
55
+ /** The evaluations service takes the session or an API key, both as Bearer; it checks a key and pins it to the key's org. */
56
+ export function evaluationsAuthHeader(c: Credential): string {
57
+ return `Bearer ${c.kind === 'token' ? c.token : c.key}`;
58
+ }
package/src/sdk/eval.ts CHANGED
@@ -33,9 +33,9 @@ export class EvalSDK {
33
33
  }
34
34
 
35
35
  /**
36
- * The evaluations service checks nothing about orgs. Before acting on an
37
- * agent, confirm through copilot (which refuses other orgs' agents) that it
38
- * belongs to the org this command acts on.
36
+ * The evaluations service now enforces org scope itself; this check is defense
37
+ * in depth. Before acting on an agent, confirm through copilot (which refuses
38
+ * other orgs' agents) that it belongs to the org this command acts on.
39
39
  */
40
40
  async assertAgentInOrg(agentId: string): Promise<void> {
41
41
  const { AgentsSDK } = await import('./agents.js');
package/src/sdk/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Access } from './access.js';
1
2
  // Environment and configuration types
2
3
 
3
4
  export type Environment = 'dev' | 'production';
@@ -53,6 +54,8 @@ export interface IndemnConfig {
53
54
  org_id?: string;
54
55
  org_name?: string;
55
56
  hosts: Record<Environment, EnvironmentHosts>;
57
+ /** What the person may do (GET /auth/me/access), saved with the login; display only. */
58
+ access?: Access;
56
59
  }
57
60
 
58
61
  // API response types
@@ -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
+ });
@@ -92,6 +92,20 @@ describe('registry', () => {
92
92
  await pair.close();
93
93
  });
94
94
 
95
+ it('shows Proceed ticked, so approving is Enter twice, and still takes an untick as a no', async () => {
96
+ const { register, handler } = setup();
97
+ const pair = await connectPair(register, { elicit: () => ({ action: 'accept', content: { confirm: false } }) });
98
+ const res = await pair.call('wipe', { id: 't1' });
99
+ expect(pair.questions[0].requestedSchema).toEqual({
100
+ type: 'object',
101
+ properties: { confirm: { type: 'boolean', title: 'Proceed', description: 'Ticked already: press Enter twice to approve (Space unticks).', default: true } },
102
+ required: ['confirm'],
103
+ });
104
+ expect(res.body).toMatchObject({ cancelled: true, reason: 'Proceed was not ticked. Nothing was changed.' });
105
+ expect(handler).not.toHaveBeenCalled();
106
+ await pair.close();
107
+ });
108
+
95
109
  it('names things in the approval instead of showing their ids', async () => {
96
110
  const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: '{}' }] }));
97
111
  const pair = await connectPair((server) => {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { registerTools } from '../../src/mcp/tools.js';
3
+ import { TOOL_META } from '../../src/mcp/tool-meta.js';
3
4
  import { IndemnClient } from '../../src/sdk/client.js';
4
5
  import { connectPair } from '../helpers/mcp-pair.js';
5
6
  import { cleanEnv } from '../helpers/config-home.js';
@@ -28,6 +29,9 @@ describe('MCP tool list', () => {
28
29
  for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file']) {
29
30
  expect(props(name), name).toContain('confirm');
30
31
  }
32
+ // The person chooses how much of a website is crawled: only scrape_url crawls, and it needs their choice.
33
+ expect((tools.find((t) => t.name === 'scrape_url')!.inputSchema as { required?: string[] }).required).toContain('crawl_mode');
34
+ expect(props('add_kb_data')).not.toContain('source_url');
31
35
  // Test items and rubric rules have declared fields (Gemini cannot fill open objects).
32
36
  const itemsOf = (name: string, field: string) => ((tools.find((t) => t.name === name)!.inputSchema as { properties: Record<string, { items?: { properties?: object } }> }).properties[field].items?.properties ?? {});
33
37
  // ... and no nested objects: Gemini writes constructors for them that Google rejects.
@@ -43,4 +47,10 @@ describe('MCP tool list', () => {
43
47
  expect(skillProps).not.toContain('authorization');
44
48
  await pair.close();
45
49
  });
50
+
51
+ it('says in the crawl approval whether it is the whole site or one page', () => {
52
+ const describe = TOOL_META.scrape_url.describe!;
53
+ expect(describe({ kb_id: 'kb1', url: 'https://www.aaa.com' })).toMatch(/^This will crawl the whole site from https:\/\/www\.aaa\.com \(up to 100 pages\) into knowledge base kb1;/);
54
+ expect(describe({ kb_id: 'kb1', url: 'https://www.aaa.com/pricing', crawl_mode: 'selected' })).toMatch(/^This will import only the page https:\/\/www\.aaa\.com\/pricing into knowledge base kb1;/);
55
+ });
46
56
  });
@@ -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
  });