@indemn/cli 1.7.3 → 1.7.5

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 (132) 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 +2 -2
  5. package/dist/cli/auth.d.ts.map +1 -1
  6. package/dist/cli/auth.js +10 -6
  7. package/dist/cli/auth.js.map +1 -1
  8. package/dist/cli/eval.d.ts.map +1 -1
  9. package/dist/cli/eval.js +8 -2
  10. package/dist/cli/eval.js.map +1 -1
  11. package/dist/cli/index.js +2 -2
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/init.d.ts +1 -1
  14. package/dist/cli/init.d.ts.map +1 -1
  15. package/dist/cli/init.js +15 -5
  16. package/dist/cli/init.js.map +1 -1
  17. package/dist/cli/kb.js +1 -1
  18. package/dist/cli/kb.js.map +1 -1
  19. package/dist/cli/utils/output.d.ts +0 -1
  20. package/dist/cli/utils/output.d.ts.map +1 -1
  21. package/dist/cli/utils/output.js +0 -4
  22. package/dist/cli/utils/output.js.map +1 -1
  23. package/dist/cli/voice.d.ts.map +1 -1
  24. package/dist/cli/voice.js +4 -1
  25. package/dist/cli/voice.js.map +1 -1
  26. package/dist/core/org-resolver.d.ts +4 -0
  27. package/dist/core/org-resolver.d.ts.map +1 -1
  28. package/dist/core/org-resolver.js +11 -0
  29. package/dist/core/org-resolver.js.map +1 -1
  30. package/dist/core/runtime.d.ts +9 -0
  31. package/dist/core/runtime.d.ts.map +1 -1
  32. package/dist/core/runtime.js +49 -0
  33. package/dist/core/runtime.js.map +1 -1
  34. package/dist/core/scope.d.ts +0 -1
  35. package/dist/core/scope.d.ts.map +1 -1
  36. package/dist/core/scope.js.map +1 -1
  37. package/dist/mcp/registry.d.ts.map +1 -1
  38. package/dist/mcp/registry.js +15 -2
  39. package/dist/mcp/registry.js.map +1 -1
  40. package/dist/mcp/server.d.ts +1 -1
  41. package/dist/mcp/server.d.ts.map +1 -1
  42. package/dist/mcp/server.js +2 -19
  43. package/dist/mcp/server.js.map +1 -1
  44. package/dist/mcp/tool-meta.js +5 -5
  45. package/dist/mcp/tool-meta.js.map +1 -1
  46. package/dist/mcp/tools.js +6 -6
  47. package/dist/mcp/tools.js.map +1 -1
  48. package/dist/report/export.d.ts.map +1 -1
  49. package/dist/report/export.js +2 -3
  50. package/dist/report/export.js.map +1 -1
  51. package/dist/report/generate.d.ts.map +1 -1
  52. package/dist/report/generate.js +3 -3
  53. package/dist/report/generate.js.map +1 -1
  54. package/dist/sdk/auth.d.ts +8 -3
  55. package/dist/sdk/auth.d.ts.map +1 -1
  56. package/dist/sdk/auth.js +13 -5
  57. package/dist/sdk/auth.js.map +1 -1
  58. package/dist/sdk/chat.d.ts.map +1 -1
  59. package/dist/sdk/chat.js +1 -0
  60. package/dist/sdk/chat.js.map +1 -1
  61. package/dist/sdk/client.d.ts +11 -9
  62. package/dist/sdk/client.d.ts.map +1 -1
  63. package/dist/sdk/client.js +50 -67
  64. package/dist/sdk/client.js.map +1 -1
  65. package/dist/sdk/config-change.js +1 -1
  66. package/dist/sdk/config-change.js.map +1 -1
  67. package/dist/sdk/conversations.js +1 -1
  68. package/dist/sdk/conversations.js.map +1 -1
  69. package/dist/sdk/functions.js +2 -2
  70. package/dist/sdk/functions.js.map +1 -1
  71. package/dist/sdk/jarvis-chat.js +2 -2
  72. package/dist/sdk/jarvis-chat.js.map +1 -1
  73. package/dist/sdk/kb.d.ts +1 -0
  74. package/dist/sdk/kb.d.ts.map +1 -1
  75. package/dist/sdk/kb.js +9 -3
  76. package/dist/sdk/kb.js.map +1 -1
  77. package/dist/sdk/orgs.d.ts.map +1 -1
  78. package/dist/sdk/orgs.js +1 -6
  79. package/dist/sdk/orgs.js.map +1 -1
  80. package/dist/sdk/session.d.ts +2 -9
  81. package/dist/sdk/session.d.ts.map +1 -1
  82. package/dist/sdk/session.js +2 -27
  83. package/dist/sdk/session.js.map +1 -1
  84. package/dist/sdk/test-chat.d.ts.map +1 -1
  85. package/dist/sdk/test-chat.js +17 -6
  86. package/dist/sdk/test-chat.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/cli/auth.ts +9 -6
  89. package/src/cli/eval.ts +6 -2
  90. package/src/cli/index.ts +2 -2
  91. package/src/cli/init.ts +14 -6
  92. package/src/cli/kb.ts +1 -1
  93. package/src/cli/utils/output.ts +0 -4
  94. package/src/cli/voice.ts +3 -1
  95. package/src/core/org-resolver.ts +18 -0
  96. package/src/core/runtime.ts +46 -0
  97. package/src/core/scope.ts +0 -1
  98. package/src/mcp/registry.ts +17 -2
  99. package/src/mcp/server.ts +2 -19
  100. package/src/mcp/tool-meta.ts +5 -5
  101. package/src/mcp/tools.ts +6 -6
  102. package/src/report/export.ts +2 -1
  103. package/src/report/generate.tsx +3 -2
  104. package/src/sdk/auth.ts +15 -5
  105. package/src/sdk/chat.ts +1 -0
  106. package/src/sdk/client.ts +50 -72
  107. package/src/sdk/config-change.ts +1 -1
  108. package/src/sdk/conversations.ts +1 -1
  109. package/src/sdk/functions.ts +2 -2
  110. package/src/sdk/jarvis-chat.ts +2 -2
  111. package/src/sdk/kb.ts +10 -4
  112. package/src/sdk/orgs.ts +1 -4
  113. package/src/sdk/session.ts +2 -25
  114. package/src/sdk/test-chat.ts +16 -6
  115. package/test/cli/init.test.ts +14 -0
  116. package/test/core/org-resolver.test.ts +38 -2
  117. package/test/core/scope.test.ts +12 -0
  118. package/test/helpers/mcp-pair.ts +10 -1
  119. package/test/mcp/darwin-fixes.test.ts +18 -17
  120. package/test/mcp/registry.test.ts +11 -0
  121. package/test/mcp/shaping.test.ts +18 -0
  122. package/test/mcp/tools-snapshot.test.ts +4 -0
  123. package/test/sdk/auth.test.ts +11 -2
  124. package/test/sdk/client.test.ts +13 -0
  125. package/test/sdk/config-change.test.ts +1 -1
  126. package/test/sdk/kb-upload.test.ts +21 -0
  127. package/test/sdk/kb.test.ts +8 -3
  128. package/test/sdk/orgs.test.ts +18 -0
  129. package/test/sdk/renewal.test.ts +43 -64
  130. package/test/sdk/session.test.ts +1 -23
  131. package/test/sdk/skills.test.ts +20 -18
  132. package/test/sdk/test-chat.test.ts +29 -0
@@ -11,6 +11,7 @@ import { join } from 'path';
11
11
 
12
12
  const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
13
13
  const AGENT = 'bbbbbbbbbbbbbbbbbbbbbbbb';
14
+ const agentFound = on('GET', `/v2/bots/${AGENT}`, { json: { success: true, data: [{ _id: AGENT, name: 'Bot' }] } }); // named in approvals
14
15
  const approve = () => ({ action: 'accept' as const, content: { confirm: true } });
15
16
  const ok = (data: unknown) => ({ json: { success: true, data } });
16
17
 
@@ -30,7 +31,7 @@ describe('Darwin E2E fixes', () => {
30
31
  const { calls } = recordFetch([
31
32
  on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Roadside' })),
32
33
  on('POST', '/knowledge-bases/kb1/import', ok({ status: 'started', message: 'file import has been initiated' })),
33
- ]);
34
+ agentFound]);
34
35
  p = await pair();
35
36
  const text = JSON.stringify({ type: 'file', sources: [{ url: 'https://store.test/org/1790869827796.txt', name: 'Battery jump starts.txt' }] });
36
37
  const res = await p.call('import_kb', { kb_id: 'kb1', data: text });
@@ -48,7 +49,7 @@ describe('Darwin E2E fixes', () => {
48
49
  const { calls } = recordFetch([
49
50
  on('GET', '/tickets/t1', ok({ _id: 't1', ticket_ref: 'TKT-1', notes: 'Old note' })),
50
51
  on('PUT', '/tickets/t1', ok({ _id: 't1', notes: 'Old note\nChecked with towing partner.', status: 'in_progress' })),
51
- ]);
52
+ agentFound]);
52
53
  p = await pair();
53
54
  const res = await p.call('update_ticket', { ticket_id: 't1', status: 'in_progress', note: 'Checked with towing partner.' });
54
55
  expect(res.isError).toBe(false);
@@ -56,7 +57,7 @@ describe('Darwin E2E fixes', () => {
56
57
  });
57
58
 
58
59
  it('eval_gate_verdict judges against 80% when no threshold is given', async () => {
59
- recordFetch([on('GET', '/api/v1/runs/r1', { json: { run_id: 'r1', status: 'completed', total: 3, passed: 1 } })]);
60
+ recordFetch([on('GET', '/api/v1/runs/r1', { json: { run_id: 'r1', status: 'completed', total: 3, passed: 1 } }), agentFound]);
60
61
  p = await pair();
61
62
  const res = await p.call('eval_gate_verdict', { run_id: 'r1' });
62
63
  expect(res.body.data).toMatchObject({ finished: true, pass: false, min_pass_rate: 0.8 });
@@ -64,7 +65,7 @@ describe('Darwin E2E fixes', () => {
64
65
  });
65
66
 
66
67
  it('add_holiday fills the type and the closed message the server requires', async () => {
67
- const { calls } = recordFetch([on('PATCH', '/configurations/holidays', ok({ saved: true }))]);
68
+ const { calls } = recordFetch([on('PATCH', '/configurations/holidays', ok({ saved: true })), agentFound]);
68
69
  p = await pair();
69
70
  const res = await p.call('add_holiday', { date: '2026-12-31', name: "New Year's Eve" });
70
71
  expect(res.isError).toBe(false);
@@ -80,7 +81,7 @@ describe('Darwin E2E fixes', () => {
80
81
  recordFetch([
81
82
  on('GET', '/ai-studio/v2/bots', ok([{ _id: AGENT, name: 'Rudra-voice-3', channels: ['VOICE'], connected_phone: '+12562897334' }, { _id: 'c', name: 'Web', channels: ['WEB'] }])),
82
83
  on('GET', '/voice/configurations/numbers', ok([{ phoneNumber: '+18664857140', friendlyName: '(866) 485-7140', type: 'tollfree' }])),
83
- ]);
84
+ agentFound]);
84
85
  p = await pair();
85
86
  const res = await p.call('list_agent_numbers');
86
87
  expect(res.body.data).toEqual([
@@ -96,9 +97,9 @@ describe('Darwin E2E fixes', () => {
96
97
  on('POST', `/bots/${AGENT}/functions/f1/parameters`, ok([{ _id: 'p1', name: 'zip_code', type: 'string', is_required: true }])),
97
98
  on('PUT', `/bots/${AGENT}/functions/f1`, ok({ _id: 'f1', name: 'Weather', type: 'rest_api' })),
98
99
  on('POST', `/bots/${AGENT}/functions`, ok({ _id: 'f1', name: 'Weather', type: 'rest_api' })),
99
- ]);
100
+ agentFound]);
100
101
  p = await pair();
101
- const res = await p.call('create_function', {
102
+ const res = await p.call('create_function', { confirm: true,
102
103
  agent_id: AGENT, type: 'rest_api', name: 'Weather', url: 'https://httpbin.org/get?zip={{zip_code}}', method: 'GET',
103
104
  parameters: [{ name: 'zip_code', type: 'string', description: 'ZIP code', required: true }],
104
105
  });
@@ -108,7 +109,7 @@ describe('Darwin E2E fixes', () => {
108
109
  });
109
110
 
110
111
  it('keeps the server\'s reason on a 403 instead of "Access denied"', async () => {
111
- recordFetch([on('GET', '/tickets', { status: 403, json: { success: false, error: 'Tickets are not enabled for this organization.' } })]);
112
+ recordFetch([on('GET', '/tickets', { status: 403, json: { success: false, error: 'Tickets are not enabled for this organization.' } }), agentFound]);
112
113
  p = await pair();
113
114
  const res = await p.call('list_tickets');
114
115
  expect(res.isError).toBe(true);
@@ -119,7 +120,7 @@ describe('Darwin E2E fixes', () => {
119
120
  const { calls } = recordFetch([
120
121
  on('GET', '/configurations', ok({ businessHoursConfig: { businessHoursEnabled: false } })),
121
122
  on('PATCH', '/configurations/business-hours', ok({ success: true })),
122
- ]);
123
+ agentFound]);
123
124
  p = await pair();
124
125
  const hours = [1, 2, 3, 4, 5].map((day) => ({ day, slots: [{ start: '08:00', end: '18:00' }] }));
125
126
  const res = await p.call('set_business_hours', { hours: JSON.stringify(hours) });
@@ -137,7 +138,7 @@ describe('Darwin E2E fixes', () => {
137
138
  on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Voice Test Agent' }])),
138
139
  on('GET', '/evaluator-configs', { json: { items: [{ config_id: 'c1' }] } }),
139
140
  on('POST', '/api/v1/evaluate-transcript', { json: { success: true, score: 0.9 } }),
140
- ]);
141
+ agentFound]);
141
142
  p = await pair();
142
143
  const baseline = [{ sender: 'user', text: 'Hi' }, { sender: 'agent', text: 'Hello' }, { sender: 'system', text: 'ended' }];
143
144
  const same = await p.call('evaluate_transcript', { agent_id: AGENT, baseline_transcript: baseline, replay_transcript: baseline });
@@ -153,7 +154,7 @@ describe('Darwin E2E fixes', () => {
153
154
  it('list_kb_data leaves the chunks out unless asked', async () => {
154
155
  recordFetch([on('GET', '/knowledge-bases/kb1/data-source', ok({
155
156
  dataSources: [{ _id: 's1', type: 'text', question: 'Do you tow motorcycles?', answer: 'Yes', trained: true, chunks: [{ text: 'x' }], chunks_text: ['x'] }], total: 1, page: 1, pages: 1,
156
- }))]);
157
+ })), agentFound]);
157
158
  p = await pair();
158
159
  const slim = await p.call('list_kb_data', { kb_id: 'kb1' });
159
160
  expect(slim.body.data.dataSources[0]).toEqual({ _id: 's1', type: 'text', question: 'Do you tow motorcycles?', answer: 'Yes', trained: true });
@@ -169,7 +170,7 @@ describe('Darwin E2E fixes', () => {
169
170
  on('GET', '/files/signed-url', (c) => (c.url.includes('filename=org%2F1790869575391.csv') || c.url.includes('filename=org/1790869575391.csv')
170
171
  ? { json: { success: true, data: { signedUrl: 'https://indemn-dev-attachments.s3.us-east-1.amazonaws.com/org/1790869575391.csv?X-Amz-Signature=abc' } } }
171
172
  : { status: 404, json: { success: false } })),
172
- ]);
173
+ agentFound]);
173
174
  p = await pair();
174
175
  const res = await p.call('export_kb', { kb_id: 'kb1' });
175
176
  expect(res.body.data.download_url).toContain('X-Amz-Signature=abc');
@@ -184,7 +185,7 @@ describe('Darwin E2E fixes', () => {
184
185
  on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Darwin QA Agent', description: 'Roadside help, short answers.' }])),
185
186
  on('POST', '/ai-studio/v2/bots/clone', ok({ new_agent_id: COPY, new_agent_name: 'Darwin QA Clone', new_kb_ids: [] })),
186
187
  on('PUT', `/ai-studio/bots/${COPY}`, ok({ _id: COPY, name: 'Darwin QA Clone', description: 'Roadside help, short answers.' })),
187
- ]);
188
+ agentFound]);
188
189
  p = await pair();
189
190
  const res = await p.call('clone_agent', { agent_id: AGENT, name: 'Darwin QA Clone' });
190
191
  expect(res.isError).toBe(false);
@@ -196,7 +197,7 @@ describe('Darwin E2E fixes', () => {
196
197
  on('GET', '/knowledge-bases/kb1/data-source', ok({ dataSources: [{ _id: 's1', question: 'How late can I buy a policy?', answer: 'Up to 14 days before.' }], total: 1, page: 1, pages: 1 })),
197
198
  on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Darwin KB Test' })),
198
199
  on('PUT', '/knowledge-bases/kb1/data-source/s1', ok({ _id: 's1', question: 'How late can I buy a policy?', answer: 'Up to 7 days before.' })),
199
- ]);
200
+ agentFound]);
200
201
  p = await pair();
201
202
  const res = await p.call('update_kb_data', { kb_id: 'kb1', source_id: 's1', answer: 'Up to 7 days before.' });
202
203
  expect(res.isError).toBe(false);
@@ -209,7 +210,7 @@ describe('Darwin E2E fixes', () => {
209
210
  recordFetch([
210
211
  on('GET', '/audit-logs', ok([{ _id: 'a1', id_user: 'u1', action: 'update', resource_type: 'bot', resource_display: 'Darwin QA Agent', createdAt: '2026-10-01T10:00:00Z' }, { _id: 'a2', id_user: 'u9', action: 'create', createdAt: '2026-10-01T09:00:00Z' }])),
211
212
  on('GET', '/projects-users', ok({ projectUsers: [{ role: 'owner', status: 'active', id_user: { _id: 'u1', email: 'ana@acme.com', firstname: 'Ana', lastname: 'Diaz' }, id_project: { name: 'default' } }] })),
212
- ]);
213
+ agentFound]);
213
214
  p = await pair();
214
215
  const res = await p.call('list_audit_logs', {});
215
216
  expect(res.isError).toBe(false);
@@ -221,7 +222,7 @@ describe('Darwin E2E fixes', () => {
221
222
  const { calls } = recordFetch([
222
223
  on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Darwin QA Agent' }])),
223
224
  on('GET', '/projects-users', ok({ projectUsers: [{ role: 'admin', status: 'active', id_user: { _id: 'cccccccccccccccccccccccc', email: 'ana@acme.com', firstname: 'Ana', lastname: 'Diaz' }, id_project: { name: 'default' } }] })),
224
- ]);
225
+ agentFound]);
225
226
  p = await pair(() => ({ action: 'decline' as const }));
226
227
  await p.call('transfer_agent_owner', { agent_id: AGENT, owner_id: 'cccccccccccccccccccccccc' });
227
228
  expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0); // declined: nothing transferred
@@ -236,7 +237,7 @@ describe('Darwin E2E fixes', () => {
236
237
  { _id: 'f1', type: 'file', source: signed, status: 'success', source_details: { file_name: 'Battery jump starts.txt' } },
237
238
  { _id: 'u1', type: 'url', source: 'https://www.indemn.ai/?ref=1', status: 'success' },
238
239
  ] })),
239
- ]);
240
+ agentFound]);
240
241
  p = await pair(() => ({ action: 'decline' as const }));
241
242
  await p.call('delete_kb_data', { kb_id: 'kb1', source_id: 'f1' });
242
243
  expect(p.questions[0].message).toContain('"Battery jump starts.txt"');
@@ -166,4 +166,15 @@ describe('registry', () => {
166
166
  expect(handler).not.toHaveBeenCalled();
167
167
  await pair.close();
168
168
  });
169
+
170
+ it('on a host without approval forms, confirm: true is taken only for a change already shown as a dry run', async () => {
171
+ const { guardDestructive } = await import('../../src/mcp/registry.js');
172
+ const server = new McpServer({ name: 't', version: '0' });
173
+ const first = await guardDestructive(server, 'delete_agent', { agent_id: 'a1', confirm: true }, 'Delete a1');
174
+ expect(first.proceed).toBe(false); // straight to confirm: shown as a dry run instead
175
+ const again = await guardDestructive(server, 'delete_agent', { confirm: true, agent_id: 'a1' }, 'Delete a1');
176
+ expect(again).toEqual({ proceed: true, byPerson: false });
177
+ const other = await guardDestructive(server, 'delete_agent', { agent_id: 'a2', confirm: true }, 'Delete a2');
178
+ expect(other.proceed).toBe(false); // a different change needs its own dry run
179
+ });
169
180
  });
@@ -32,6 +32,24 @@ describe('result shaping', () => {
32
32
  await pair.close();
33
33
  });
34
34
 
35
+ it('a page trimmed to fit never skips items: it says more exist and where to continue with a smaller limit', async () => {
36
+ const pair = await connectPair((server) => {
37
+ const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
38
+ reg.tool('many', 'x', {}, async () => ok(Array.from({ length: 120 }, (i) => ({ i, pad: 'x'.repeat(4_000) }))));
39
+ });
40
+ for (const page of [1, 3]) { // a middle page and the last one
41
+ const res = await pair.call('many', { page });
42
+ const data = res.body.data;
43
+ expect(data.truncated).toBe(true);
44
+ expect(data.has_more).toBe(true); // items of this page were left out, even on the last page
45
+ const keep = data.returned as number;
46
+ const from = Math.floor(((page - 1) * 50) / keep) + 1; // the page holding this page's first item at limit `keep`
47
+ expect(data.next).toContain(`limit ${keep}`);
48
+ expect(data.next).toContain(`page ${from}`);
49
+ }
50
+ await pair.close();
51
+ });
52
+
35
53
  it('refuses a paged tool that brings its own limit (it would never page)', async () => {
36
54
  await connectPair((server) => {
37
55
  const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
@@ -24,6 +24,10 @@ describe('MCP tool list', () => {
24
24
  for (const name of ['whoami', 'list_organizations', 'eval_quality_metrics']) {
25
25
  expect(props(name), name).not.toContain('org_id');
26
26
  }
27
+ // Writes that change what customers get or reach outside ask first on every host (a dry run, then confirm).
28
+ for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file']) {
29
+ expect(props(name), name).toContain('confirm');
30
+ }
27
31
  // Test items and rubric rules have declared fields (Gemini cannot fill open objects).
28
32
  const itemsOf = (name: string, field: string) => ((tools.find((t) => t.name === name)!.inputSchema as { properties: Record<string, { items?: { properties?: object } }> }).properties[field].items?.properties ?? {});
29
33
  // ... and no nested objects: Gemini writes constructors for them that Google rejects.
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { on, recordFetch } from '../helpers/fetch-recorder.js';
5
5
  import { cleanEnv, useTempHome } from '../helpers/config-home.js';
6
6
  import { jwtExpiringIn } from '../helpers/jwt.js';
7
- import { login, logout, whoami } from '../../src/sdk/auth.js';
7
+ import { customHosts, login, logout, whoami } from '../../src/sdk/auth.js';
8
8
  import { IndemnClient } from '../../src/sdk/client.js';
9
9
 
10
10
  const A = { _id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'QA A' };
@@ -30,7 +30,8 @@ describe('token-first login', () => {
30
30
  expect(result.orgs).toEqual([{ id: A._id, name: 'QA A' }, { id: B._id, name: 'QA B' }]);
31
31
 
32
32
  const saved = JSON.parse(readFileSync(join(home.dir, '.indemn', 'config.json'), 'utf8'));
33
- expect(saved.session).toMatchObject({ token, refresh_token: 'r1' });
33
+ expect(saved.session).toMatchObject({ token });
34
+ expect(saved.session).not.toHaveProperty('refresh_token'); // nothing that outlives logout stays on disk
34
35
  expect(saved.orgs).toHaveLength(2);
35
36
  expect(saved).not.toHaveProperty('api_key');
36
37
  expect(saved).not.toHaveProperty('org_id');
@@ -67,4 +68,12 @@ describe('token-first login', () => {
67
68
  expect(text).not.toContain(token);
68
69
  expect(text).not.toContain('r1');
69
70
  });
71
+
72
+ it("whoami lists only the hosts that are not the platform's defaults", () => {
73
+ const defaults = { copilot: 'https://devcopilot.indemn.ai/api', evaluations: 'https://devevaluations.indemn.ai', middleware: 'https://devproxy.indemn.ai', platform: 'https://devplatform.indemn.ai/api/v1' };
74
+ expect(customHosts({ environment: 'dev', hosts: defaults })).toEqual([]);
75
+ expect(customHosts({ environment: 'dev', hosts: { ...defaults, copilot: 'http://localhost:3000/api', platform: 'http://localhost:8013/api/v1' } }))
76
+ .toEqual([['Copilot', 'http://localhost:3000/api'], ['V2 platform', 'http://localhost:8013/api/v1']]);
77
+ });
70
78
  });
79
+
@@ -77,4 +77,17 @@ describe('IndemnClient request path', () => {
77
77
  expect(out).toContain('200');
78
78
  expect(out).not.toContain(DEV_KEY);
79
79
  });
80
+
81
+ it("sends the session only to Indemn's hosts or this machine", async () => {
82
+ vi.stubEnv('INDEMN_COPILOT_URL', 'https://evil.example.com/api');
83
+ expect(() => new IndemnClient()).toThrow(/only to Indemn/);
84
+ vi.stubEnv('INDEMN_COPILOT_URL', 'http://devcopilot.indemn.ai/api'); // not https
85
+ expect(() => new IndemnClient()).toThrow(/only to Indemn/);
86
+ vi.stubEnv('INDEMN_COPILOT_URL', 'http://localhost:3000/api');
87
+ expect(() => new IndemnClient()).not.toThrow();
88
+ vi.stubEnv('INDEMN_COPILOT_URL', 'https://devcopilot.indemn.ai/api');
89
+ const { jarvisSocketUrl } = await import('../../src/sdk/jarvis-chat.js');
90
+ vi.stubEnv('INDEMN_PLATFORM_WS_URL', 'wss://evil.example.com/ws');
91
+ expect(() => jarvisSocketUrl(new IndemnClient())).toThrow(/only to Indemn/);
92
+ });
80
93
  });
@@ -138,5 +138,5 @@ describe('configuration changes', () => {
138
138
  const ok = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'new', confirm: true, confirm_published: true });
139
139
  expect(ok.body.data.changed).toEqual(['system_prompt']);
140
140
  await pair.close();
141
- });
141
+ }, 15_000); // each status read waits for the unreachable V2 platform, and a change is previewed before it is confirmed
142
142
  });
@@ -47,4 +47,25 @@ describe('adding a document to a knowledge base', () => {
47
47
  await expect(new KBSDK(new IndemnClient()).uploadFile('kb1', big)).rejects.toThrow(/limit is 10 MB/);
48
48
  expect(calls).toHaveLength(0);
49
49
  });
50
+
51
+ it('never uploads a file from a hidden folder such as ~/.indemn (the session lives there)', async () => {
52
+ const { calls } = recordFetch();
53
+ const secret = join(home.dir, '.indemn', 'config.json');
54
+ await expect(new KBSDK(new IndemnClient()).uploadFile('kb1', secret)).rejects.toThrow(/never sent/);
55
+ const { FunctionsSDK } = await import('../../src/sdk/functions.js');
56
+ await expect(new FunctionsSDK(new IndemnClient()).generateFromDoc('a1', { filePath: secret })).rejects.toThrow(/never sent/);
57
+ expect(calls).toHaveLength(0);
58
+ });
59
+
60
+ it('refuses only secret places, not every folder whose name starts with a dot', async () => {
61
+ const { uploadable } = await import('../../src/core/runtime.js');
62
+ const { mkdirSync } = await import('node:fs');
63
+ const at = (...parts: string[]) => { const f = join(home.dir, ...parts); mkdirSync(join(f, '..'), { recursive: true }); writeFileSync(f, 'x'); return f; };
64
+ expect(() => uploadable(at('repo', '.claude', 'worktrees', 'w1', 'Report.pdf'))).not.toThrow(); // a Claude Code worktree
65
+ expect(() => uploadable(at('.local', 'share', 'docs', 'faq.txt'))).not.toThrow();
66
+ expect(() => uploadable(at('.ssh', 'id_rsa'))).toThrow(/never sent/);
67
+ expect(() => uploadable(at('.aws', 'credentials'))).toThrow(/never sent/);
68
+ expect(() => uploadable(at('repo', '.env'))).toThrow(/never sent/); // a dotfile, wherever it is
69
+ });
50
70
  });
71
+
@@ -40,6 +40,11 @@ describe('knowledge bases', () => {
40
40
  await expect(sdk().importKB('kb1', { type: 'url', sources: [{ url: 'https://x' }] })).rejects.toThrow('Import failed: unreachable url');
41
41
  });
42
42
 
43
+ it('reports a website crawl that never started as a failure, not as running', async () => {
44
+ recordFetch([on('POST', '/knowledge-bases/kb1/import', { json: { success: true, data: { status: 'error', message: 'Failed to start scraping: URL is not reachable or invalid' } } })]);
45
+ await expect(sdk().scrapeUrl('kb1', 'https://nowhere.acme.test')).rejects.toThrow('Crawl failed: Failed to start scraping: URL is not reachable or invalid');
46
+ });
47
+
43
48
  it('re-imports a source and fails loudly the same way', async () => {
44
49
  const { calls } = recordFetch([on('PUT', '/data-source/ds1/re-import', { json: { success: true, data: { status: 'error', message: 'not a url' } } })]);
45
50
  await expect(sdk().reimport('kb1', 'ds1')).rejects.toThrow('Re-import failed: not a url');
@@ -48,12 +53,12 @@ describe('knowledge bases', () => {
48
53
 
49
54
  it('reads scrape status from the knowledge base sources', async () => {
50
55
  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' } } },
56
+ { _id: 's1', source: 'https://a', status: 'pending', source_details: { id_scrape_request: { _id: 'j1', status: 'started' } } },
52
57
  { _id: 's2', source: 'b.pdf', status: 'failed', error_message: 'bad pdf' },
53
58
  ] }] } })]);
54
59
  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 },
60
+ { id: 's1', source: 'https://a', status: 'pending', job_id: 'j1', job_status: 'started', error: undefined, completed_at: undefined },
61
+ { id: 's2', source: 'b.pdf', status: 'failed', job_id: undefined, job_status: undefined, error: 'bad pdf', completed_at: undefined },
57
62
  ]);
58
63
  });
59
64
 
@@ -22,6 +22,24 @@ describe('OrgsSDK', () => {
22
22
  expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }]);
23
23
  });
24
24
 
25
+ it('acts in an org joined since login: looked up once, then saved', async () => {
26
+ const C = 'cccccccccccccccccccccccc';
27
+ IndemnClient.saveConfig({ orgs: [{ id: A, name: 'QA A' }] });
28
+ const { calls } = recordFetch([on('GET', '/organization', { json: { success: true, userOrganizations: [{ _id: A, name: 'QA A' }, { _id: C, name: 'QA C' }] } })]);
29
+ await expect(new IndemnClient().resolveOrg(C)).resolves.toMatchObject({ id: C, name: 'QA C', source: 'flag' });
30
+ expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }, { id: C, name: 'QA C' }]);
31
+ await new IndemnClient().resolveOrg(C);
32
+ expect(calls).toHaveLength(1);
33
+ await expect(new IndemnClient().resolveOrg('dddddddddddddddddddddddd')).rejects.toThrow('Your orgs: QA A (aaaaaaaaaaaaaaaaaaaaaaaa), QA C (cccccccccccccccccccccccc).');
34
+ });
35
+
36
+ it('an empty answer from copilot never replaces the orgs saved at login', async () => {
37
+ IndemnClient.saveConfig({ orgs: [{ id: A, name: 'QA A' }] });
38
+ recordFetch([on('GET', '/organization', { json: { success: true, userOrganizations: [] } })]);
39
+ await expect(new IndemnClient().resolveOrg('cccccccccccccccccccccccc')).rejects.toThrow('not a member of org cccccccccccccccccccccccc');
40
+ expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }]);
41
+ });
42
+
25
43
  it('lists members once per person with only safe fields', async () => {
26
44
  const user = { _id: 'u9', email: 'x@y.z', firstname: 'Ada', lastname: 'L', password: 'hash' };
27
45
  recordFetch([on('GET', `/organization/${A}/projects-users`, {
@@ -16,7 +16,7 @@ function login(token: string, refresh = 'r1') {
16
16
  });
17
17
  }
18
18
 
19
- describe('session renewal', () => {
19
+ describe('a saved login: 24 hours, never renewed, no refresh token kept', () => {
20
20
  let home: ReturnType<typeof useTempHome>;
21
21
  beforeEach(() => { cleanEnv(); home = useTempHome(); setAskers({}); });
22
22
  afterEach(() => home.cleanup());
@@ -30,54 +30,6 @@ describe('session renewal', () => {
30
30
  expect(calls[0].headers.authorization).toBe(`JWT ${token}`);
31
31
  });
32
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
33
  it('re-reads the saved login on every request', async () => {
82
34
  login(jwtExpiringIn(5 * 3_600_000, { n: 1 }));
83
35
  const { calls } = recordFetch();
@@ -89,27 +41,54 @@ describe('session renewal', () => {
89
41
  expect(calls[1].headers.authorization).toBe(`JWT ${second}`);
90
42
  });
91
43
 
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);
44
+ it('a login that has ended stops with log in again, without sending anything', async () => {
45
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', email: 'qa@indemn.ai', session: { token: jwtExpiringIn(-60_000), expires_at: new Date(Date.now() - 60_000).toISOString() }, orgs: [{ id: ORG, name: 'QA A' }] });
46
+ const { calls } = recordFetch();
47
+ await expect(new IndemnClient().copilotGet(BOTS)).rejects.toThrow(/Run `indemn login`/);
48
+ expect(calls).toHaveLength(0);
49
+ });
102
50
 
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
- ]);
51
+ it('a login near its end is used as is: nothing renews it', async () => {
52
+ login(jwtExpiringIn(30 * 60_000));
53
+ const { calls } = recordFetch();
54
+ await new IndemnClient().copilotGet(BOTS);
55
+ expect(calls.map((c) => new URL(c.url).pathname)).toEqual([`/api${BOTS}`]); // no Google token call, no new sign-in
56
+ });
57
+
58
+ it('a 401 means log in again: no silent renewal', async () => {
59
+ login(jwtExpiringIn(5 * 3_600_000));
60
+ const { calls } = recordFetch([on('GET', '/ai-studio/', { status: 401, text: 'revoked' })]);
108
61
  await expect(new IndemnClient().copilotGet(BOTS)).rejects.toBeInstanceOf(SessionExpiredError);
62
+ expect(calls.some((c) => c.url.includes('securetoken') || c.url.includes('/auth/firebase/'))).toBe(false);
63
+ });
64
+
65
+ it('a refresh token saved by an earlier version is deleted on first use', async () => {
66
+ login(jwtExpiringIn(5 * 3_600_000), 'r-old');
67
+ recordFetch();
68
+ await new IndemnClient().copilotGet(BOTS);
69
+ expect(IndemnClient.loadConfig()?.session?.refresh_token).toBeUndefined();
70
+ expect(IndemnClient.loadConfig()?.session?.token).toBeTruthy();
109
71
  });
110
72
 
111
73
  it('with no login at all, exits 3', async () => {
112
74
  recordFetch();
113
75
  await expect(new IndemnClient().copilotGet(BOTS)).rejects.toMatchObject({ exitCode: 3 });
114
76
  });
77
+
78
+ it("a host's session (Darwin) that ends or is rejected says to sign in to Copilot again, not indemn login", async () => {
79
+ vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(5 * 3_600_000));
80
+ vi.stubEnv('INDEMN_ENV', 'dev');
81
+ vi.stubEnv('INDEMN_ORG_ID', ORG);
82
+ recordFetch([on('GET', '/ai-studio/', { status: 401, text: 'revoked' })]);
83
+ const rejected = await new IndemnClient().copilotGet(BOTS).catch((e) => e);
84
+ expect(rejected).toBeInstanceOf(SessionExpiredError);
85
+ expect(rejected.message).toMatch(/Sign in to Copilot again/);
86
+ expect(rejected.message).not.toMatch(/indemn login/);
87
+
88
+ vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(-60_000)); // its 24 hours are over
89
+ const { calls } = recordFetch();
90
+ await expect(new IndemnClient().copilotGet(BOTS)).rejects.toThrow(/Sign in to Copilot again/);
91
+ expect(calls).toHaveLength(0);
92
+ });
115
93
  });
94
+
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { on, recordFetch } from '../helpers/fetch-recorder.js';
3
3
  import { fakeJwt, jwtExpiringIn } from '../helpers/jwt.js';
4
- import { exchangeIdToken, needsRenewal, refreshIdToken, tokenExpiry } from '../../src/sdk/session.js';
4
+ import { exchangeIdToken, tokenExpiry } from '../../src/sdk/session.js';
5
5
  import { FIREBASE_CONFIGS } from '../../src/sdk/auth.js';
6
6
  import { SessionExpiredError } from '../../src/core/runtime.js';
7
7
 
@@ -11,28 +11,6 @@ describe('token expiry', () => {
11
11
  expect(tokenExpiry(fakeJwt({}))).toBeNull();
12
12
  });
13
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
14
  });
37
15
 
38
16
  describe('exchangeIdToken', () => {