@indemn/cli 1.7.4 → 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 (127) 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/utils/output.d.ts +0 -1
  18. package/dist/cli/utils/output.d.ts.map +1 -1
  19. package/dist/cli/utils/output.js +0 -4
  20. package/dist/cli/utils/output.js.map +1 -1
  21. package/dist/cli/voice.d.ts.map +1 -1
  22. package/dist/cli/voice.js +4 -1
  23. package/dist/cli/voice.js.map +1 -1
  24. package/dist/core/org-resolver.d.ts +4 -0
  25. package/dist/core/org-resolver.d.ts.map +1 -1
  26. package/dist/core/org-resolver.js +11 -0
  27. package/dist/core/org-resolver.js.map +1 -1
  28. package/dist/core/runtime.d.ts +9 -0
  29. package/dist/core/runtime.d.ts.map +1 -1
  30. package/dist/core/runtime.js +49 -0
  31. package/dist/core/runtime.js.map +1 -1
  32. package/dist/core/scope.d.ts +0 -1
  33. package/dist/core/scope.d.ts.map +1 -1
  34. package/dist/core/scope.js.map +1 -1
  35. package/dist/mcp/registry.d.ts.map +1 -1
  36. package/dist/mcp/registry.js +15 -2
  37. package/dist/mcp/registry.js.map +1 -1
  38. package/dist/mcp/server.d.ts +1 -1
  39. package/dist/mcp/server.d.ts.map +1 -1
  40. package/dist/mcp/server.js +2 -19
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/mcp/tool-meta.js +5 -5
  43. package/dist/mcp/tool-meta.js.map +1 -1
  44. package/dist/mcp/tools.js +1 -1
  45. package/dist/mcp/tools.js.map +1 -1
  46. package/dist/report/export.d.ts.map +1 -1
  47. package/dist/report/export.js +2 -3
  48. package/dist/report/export.js.map +1 -1
  49. package/dist/report/generate.d.ts.map +1 -1
  50. package/dist/report/generate.js +3 -3
  51. package/dist/report/generate.js.map +1 -1
  52. package/dist/sdk/auth.d.ts +8 -3
  53. package/dist/sdk/auth.d.ts.map +1 -1
  54. package/dist/sdk/auth.js +13 -5
  55. package/dist/sdk/auth.js.map +1 -1
  56. package/dist/sdk/chat.d.ts.map +1 -1
  57. package/dist/sdk/chat.js +1 -0
  58. package/dist/sdk/chat.js.map +1 -1
  59. package/dist/sdk/client.d.ts +11 -9
  60. package/dist/sdk/client.d.ts.map +1 -1
  61. package/dist/sdk/client.js +50 -67
  62. package/dist/sdk/client.js.map +1 -1
  63. package/dist/sdk/config-change.js +1 -1
  64. package/dist/sdk/config-change.js.map +1 -1
  65. package/dist/sdk/conversations.js +1 -1
  66. package/dist/sdk/conversations.js.map +1 -1
  67. package/dist/sdk/functions.js +2 -2
  68. package/dist/sdk/functions.js.map +1 -1
  69. package/dist/sdk/jarvis-chat.js +2 -2
  70. package/dist/sdk/jarvis-chat.js.map +1 -1
  71. package/dist/sdk/kb.d.ts.map +1 -1
  72. package/dist/sdk/kb.js +3 -2
  73. package/dist/sdk/kb.js.map +1 -1
  74. package/dist/sdk/orgs.d.ts.map +1 -1
  75. package/dist/sdk/orgs.js +1 -6
  76. package/dist/sdk/orgs.js.map +1 -1
  77. package/dist/sdk/session.d.ts +2 -9
  78. package/dist/sdk/session.d.ts.map +1 -1
  79. package/dist/sdk/session.js +2 -27
  80. package/dist/sdk/session.js.map +1 -1
  81. package/dist/sdk/test-chat.d.ts.map +1 -1
  82. package/dist/sdk/test-chat.js +17 -6
  83. package/dist/sdk/test-chat.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/cli/auth.ts +9 -6
  86. package/src/cli/eval.ts +6 -2
  87. package/src/cli/index.ts +2 -2
  88. package/src/cli/init.ts +14 -6
  89. package/src/cli/utils/output.ts +0 -4
  90. package/src/cli/voice.ts +3 -1
  91. package/src/core/org-resolver.ts +18 -0
  92. package/src/core/runtime.ts +46 -0
  93. package/src/core/scope.ts +0 -1
  94. package/src/mcp/registry.ts +17 -2
  95. package/src/mcp/server.ts +2 -19
  96. package/src/mcp/tool-meta.ts +5 -5
  97. package/src/mcp/tools.ts +1 -1
  98. package/src/report/export.ts +2 -1
  99. package/src/report/generate.tsx +3 -2
  100. package/src/sdk/auth.ts +15 -5
  101. package/src/sdk/chat.ts +1 -0
  102. package/src/sdk/client.ts +50 -72
  103. package/src/sdk/config-change.ts +1 -1
  104. package/src/sdk/conversations.ts +1 -1
  105. package/src/sdk/functions.ts +2 -2
  106. package/src/sdk/jarvis-chat.ts +2 -2
  107. package/src/sdk/kb.ts +3 -2
  108. package/src/sdk/orgs.ts +1 -4
  109. package/src/sdk/session.ts +2 -25
  110. package/src/sdk/test-chat.ts +16 -6
  111. package/test/cli/init.test.ts +14 -0
  112. package/test/core/org-resolver.test.ts +38 -2
  113. package/test/core/scope.test.ts +12 -0
  114. package/test/helpers/mcp-pair.ts +10 -1
  115. package/test/mcp/darwin-fixes.test.ts +18 -17
  116. package/test/mcp/registry.test.ts +11 -0
  117. package/test/mcp/shaping.test.ts +18 -0
  118. package/test/mcp/tools-snapshot.test.ts +4 -0
  119. package/test/sdk/auth.test.ts +11 -2
  120. package/test/sdk/client.test.ts +13 -0
  121. package/test/sdk/config-change.test.ts +1 -1
  122. package/test/sdk/kb-upload.test.ts +21 -0
  123. package/test/sdk/orgs.test.ts +18 -0
  124. package/test/sdk/renewal.test.ts +43 -64
  125. package/test/sdk/session.test.ts +1 -23
  126. package/test/sdk/skills.test.ts +20 -18
  127. 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
+
@@ -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', () => {
@@ -13,6 +13,7 @@ import { registerTools } from '../../src/mcp/tools.js';
13
13
 
14
14
  const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
15
15
  const AGENT = '111111111111111111111111';
16
+ const agentFound = on('GET', `/v2/bots/${AGENT}`, { json: { success: true, data: [{ _id: AGENT, name: 'Bot' }] } }); // named in approvals
16
17
 
17
18
  describe('skill names', () => {
18
19
  it('reduces spelling variants of the FAQ retriever to one key, like copilot', () => {
@@ -64,9 +65,9 @@ describe('skills SDK and tools', () => {
64
65
  on('GET', `/bots/${AGENT}/functions`, { json: { success: true, data: [{ _id: 'f1', type: 'rest_api',
65
66
  configurationSchema: { endpoint: 'https://x/date', method: 'POST', payload: '{"date_str":"{{event_date}}","n":"{{other}}"}', authorization: { type: 'no-auth' } } }] } }),
66
67
  on('POST', '/functions/f1/test-rest-api', { json: { statusCode: 200, response: { valid: true } } }),
67
- ]);
68
+ agentFound]);
68
69
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
69
- await pair.call('test_function', { agent_id: AGENT, function_id: 'f1', params: { event_date: '2027-06-12' } });
70
+ await pair.call('test_function', { confirm: true, agent_id: AGENT, function_id: 'f1', params: { event_date: '2027-06-12' } });
70
71
  expect(calls.find((c) => c.url.endsWith('/test-rest-api'))?.body).toEqual({ endpoint: 'https://x/date', method: 'POST',
71
72
  payload: '{"date_str":"2027-06-12","n":"{{other}}"}', authorization: { type: 'no-auth' } });
72
73
  await pair.close();
@@ -78,7 +79,7 @@ describe('skills SDK and tools', () => {
78
79
  const { calls } = recordFetch([
79
80
  on('GET', '/functions/f1/parameters', { json: { success: true, data: [saved] } }),
80
81
  on('POST', '/functions/f1/parameters', { json: { success: true, data: [saved] } }),
81
- ]);
82
+ agentFound]);
82
83
  const sdk = new FunctionsSDK(new IndemnClient());
83
84
  await sdk.addParameter(AGENT, 'f1', { name: 'coverage', type: 'string', required: true, allowedValues: ['Liability', 'Both'] });
84
85
  const posts = () => calls.filter((c) => c.method === 'POST').map((c) => (c.body as unknown[])[0]);
@@ -100,7 +101,7 @@ describe('skills SDK and tools', () => {
100
101
  });
101
102
 
102
103
  it('bulk-creates with the body copilot expects', async () => {
103
- const { calls } = recordFetch([on('POST', '/functions/bulk-create', { json: { success: true, data: [{ _id: 'f1' }] } })]);
104
+ const { calls } = recordFetch([on('POST', '/functions/bulk-create', { json: { success: true, data: [{ _id: 'f1' }] } }), agentFound]);
104
105
  await new FunctionsSDK(new IndemnClient()).bulkCreate(AGENT, [{ type: 'rest_api', name: 'quote', description: 'q' }]);
105
106
  expect(calls[0].body).toEqual({ functions: [{ type: 'rest_api', name: 'quote', description: 'q' }] });
106
107
  });
@@ -116,31 +117,32 @@ describe('skills SDK and tools', () => {
116
117
  on('GET', `/bots/${AGENT}/functions`, { json: { success: true, data: [{ _id: 'f1', type: 'rest_api', description: 'Checks a date',
117
118
  configurationSchema: { endpoint: 'https://x/date', method: 'POST', authorization: { type: 'bearer-token', token: 't' } } }] } }),
118
119
  on('PUT', `/bots/${AGENT}/functions/f1`, { json: { success: true, data: { _id: 'f1' } } }),
119
- ]);
120
+ on('GET', `/v1-agents/${AGENT}`, { json: { id: AGENT, lifecycle: 'prototype' } }), // not live: no approval asked
121
+ , agentFound]);
120
122
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
121
- await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'validate_date', description: 'Checks a date', url: 'https://x/date', method: 'post', payload: { date_str: '{{event_date}}' } });
123
+ await pair.call('create_function', { confirm: true, agent_id: AGENT, type: 'rest_api', name: 'validate_date', description: 'Checks a date', url: 'https://x/date', method: 'post', payload: { date_str: '{{event_date}}' } });
122
124
  expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ type: 'rest_api', name: 'validate_date', description: 'Checks a date' });
123
125
  expect(calls.filter((c) => c.method === 'PUT')[0].body).toEqual({ type: 'rest_api', description: 'Checks a date',
124
126
  configurationSchema: { endpoint: 'https://x/date', method: 'POST', payload: '{"date_str":"{{event_date}}"}', authorization: { type: 'no-auth' }, craftResponse: true } });
125
127
  await pair.call('update_function', { agent_id: AGENT, function_id: 'f1', response_field: 'data.valid' });
126
128
  expect(calls.filter((c) => c.method === 'PUT')[1].body).toEqual({ type: 'rest_api', description: 'Checks a date',
127
129
  configurationSchema: { endpoint: 'https://x/date', method: 'POST', authorization: { type: 'bearer-token', token: 't' }, responseField: 'data.valid' } });
128
- await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'said', url: 'https://x/a', method: 'GET', output_instructions: 'Your quote is {{response.total}}.' });
130
+ await pair.call('create_function', { confirm: true, agent_id: AGENT, type: 'rest_api', name: 'said', url: 'https://x/a', method: 'GET', output_instructions: 'Your quote is {{response.total}}.' });
129
131
  expect(calls.filter((c) => c.method === 'PUT')[2].body.configurationSchema).toMatchObject({ outputInstructions: 'Your quote is {{response.total}}.' });
130
132
  expect(calls.filter((c) => c.method === 'PUT')[2].body.configurationSchema.craftResponse).toBeUndefined();
131
- const missing = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'no_url' });
133
+ const missing = await pair.call('create_function', { confirm: true, agent_id: AGENT, type: 'rest_api', name: 'no_url' });
132
134
  expect(missing.isError).toBe(true);
133
135
  await pair.close();
134
- });
136
+ }, 30_000); // four skill changes, each previewed before it is confirmed
135
137
 
136
138
  it('MCP skill tools take typed REST auth and JSON text for free-form objects (as Gemini sends them)', async () => {
137
139
  const NEW = 'fn-new';
138
140
  const { calls } = recordFetch([
139
141
  on('POST', `/ai-studio/bots/${AGENT}/functions`, { json: { success: true, data: { _id: NEW, type: 'rest_api', name: 'get_quote' } } }),
140
142
  on('PUT', `/functions/${NEW}`, { json: { success: true, data: {} } }),
141
- ]);
143
+ agentFound]);
142
144
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
143
- const res = await pair.call('create_function', {
145
+ const res = await pair.call('create_function', { confirm: true,
144
146
  agent_id: AGENT, type: 'rest_api', name: 'get_quote', url: 'https://api.example.com/quote', method: 'POST',
145
147
  auth_type: 'bearer-token', auth_token: 'abc', auth_key: '', auth_username: '',
146
148
  payload: '{"event_date": "{{event_date}}", "guests": {{guests}}}',
@@ -160,9 +162,9 @@ describe('skills SDK and tools', () => {
160
162
  on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] }),
161
163
  on('GET', `/ai-studio/bots/${AGENT}/functions`, { json: { success: true, data: [existing] } }),
162
164
  on('DELETE', '/functions/f1', { json: { success: true } }),
163
- ]);
165
+ agentFound]);
164
166
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
165
- const dup = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'Get_Quote', url: 'https://y', method: 'POST' });
167
+ const dup = await pair.call('create_function', { confirm: true, agent_id: AGENT, type: 'rest_api', name: 'Get_Quote', url: 'https://y', method: 'POST' });
166
168
  expect(dup.isError).toBe(true);
167
169
  expect(String(dup.body)).toMatch(/already has a skill named "Get_Quote" \(function_id f1\)\. Change it with update_function/);
168
170
  expect(calls.some((c) => c.method === 'POST')).toBe(false);
@@ -180,20 +182,20 @@ describe('skills SDK and tools', () => {
180
182
  on('POST', `/ai-studio/bots/${AGENT}/functions`, { json: { success: true, data: { _id: 'fx', type: 'rest_api', name: 'quote' } } }),
181
183
  on('PUT', '/functions/fx', { status: 500, json: { error: 'Payload must be a valid JSON string.' } }),
182
184
  on('DELETE', '/functions/fx', { json: { success: true } }),
183
- ]);
185
+ agentFound]);
184
186
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
185
- const bad = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'quote', url: 'https://x', method: 'POST', payload: '{"a": 1,}' });
187
+ const bad = await pair.call('create_function', { confirm: true, agent_id: AGENT, type: 'rest_api', name: 'quote', url: 'https://x', method: 'POST', payload: '{"a": 1,}' });
186
188
  expect(bad.isError).toBe(true);
187
189
  expect(String(bad.body)).toMatch(/request body is not valid JSON.*Nothing was saved/);
188
190
  expect(calls.some((c) => c.method === 'POST')).toBe(false);
189
- const refused = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'quote', url: 'https://x', method: 'POST' });
191
+ const refused = await pair.call('create_function', { confirm: true, agent_id: AGENT, type: 'rest_api', name: 'quote', url: 'https://x', method: 'POST' });
190
192
  expect(String(refused.body)).toMatch(/Nothing was saved: the skill's configuration was refused/);
191
193
  expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.url)).toEqual([expect.stringContaining('/functions/fx')]);
192
194
  await pair.close();
193
195
  });
194
196
 
195
197
  it('MCP import_functions warns that everything is replaced and waits for confirmation', async () => {
196
- const { calls } = recordFetch([on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] })]);
198
+ const { calls } = recordFetch([on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] }), agentFound]);
197
199
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
198
200
  const res = await pair.call('import_functions', { agent_id: AGENT, url: 'https://x/skills.json' });
199
201
  expect(res.body).toMatchObject({ dry_run: true });
@@ -212,7 +214,7 @@ describe('skills SDK and tools', () => {
212
214
  on('PUT', `/bots/${AGENT}/functions/f1`, { json: { success: true, data: { _id: 'f1' } } }),
213
215
  on('PUT', `/bots/${AGENT}`, { json: { success: true, data: { _id: AGENT } } }),
214
216
  on('PATCH', `/bots/${AGENT}`, { json: { success: true, data: { _id: AGENT } } }),
215
- ]);
217
+ agentFound]);
216
218
  const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
217
219
  const writes = () => calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length;
218
220