@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
package/src/sdk/client.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import Conf from 'conf';
2
2
  import { chmodSync } from 'node:fs';
3
- import type { ApiResponse, Environment, EnvironmentHosts, IndemnConfig } from './types.js';
4
- import { CliError, cliVersion, isDebug, SessionExpiredError } from '../core/runtime.js';
3
+ import type { ApiResponse, Environment, EnvironmentHosts, IndemnConfig, OrgSummary } from './types.js';
4
+ import { CliError, cliVersion, indemnHost, isDebug, SessionExpiredError } from '../core/runtime.js';
5
5
  import { copilotAuthHeader, darwinAuthHeader, resolveCredential, type Credential } from './credentials.js';
6
- import { exchangeIdToken, needsRenewal, refreshIdToken, toStoredSession } from './session.js';
7
- import { findLinkFile, resolveOrg, type LinkFile, type OrgChoice, type ResolvedOrg } from '../core/org-resolver.js';
6
+ import { tokenExpiry } from './session.js';
7
+ import { findLinkFile, resolveOrg, type LinkFile, type ResolvedOrg } from '../core/org-resolver.js';
8
8
  import { emitOrgResolved, getAskers, getOrgFlag, scopedOrg } from '../core/scope.js';
9
9
 
10
10
  export const DEFAULT_HOSTS: Record<Environment, EnvironmentHosts> = {
@@ -60,7 +60,6 @@ function sleep(ms: number): Promise<void> {
60
60
  }
61
61
 
62
62
  /** One renewal at a time per process, shared by every client. */
63
- let renewing: Promise<void> | null = null;
64
63
 
65
64
  export interface OrgContext extends ResolvedOrg {
66
65
  name?: string;
@@ -73,7 +72,7 @@ export class IndemnClient {
73
72
  private hosts: EnvironmentHosts;
74
73
  private resolvedOrg?: Promise<OrgContext>;
75
74
  private linkCache?: LinkFile | null;
76
- private membershipCache?: OrgChoice[];
75
+ private membershipCache?: OrgSummary[];
77
76
 
78
77
  private ignoreLink: boolean;
79
78
 
@@ -91,10 +90,10 @@ export class IndemnClient {
91
90
  const defaultHosts = DEFAULT_HOSTS[this.environment];
92
91
  const configHosts = config?.hosts?.[this.environment];
93
92
  this.hosts = {
94
- copilot: process.env.INDEMN_COPILOT_URL || configHosts?.copilot || defaultHosts.copilot,
95
- evaluations: process.env.INDEMN_EVALS_URL || configHosts?.evaluations || defaultHosts.evaluations,
96
- middleware: process.env.INDEMN_MIDDLEWARE_URL || configHosts?.middleware || defaultHosts.middleware,
97
- platform: process.env.INDEMN_PLATFORM_URL || configHosts?.platform || defaultHosts.platform,
93
+ copilot: indemnHost(process.env.INDEMN_COPILOT_URL || configHosts?.copilot || defaultHosts.copilot, 'The copilot host'),
94
+ evaluations: indemnHost(process.env.INDEMN_EVALS_URL || configHosts?.evaluations || defaultHosts.evaluations, 'The evaluations host'),
95
+ middleware: indemnHost(process.env.INDEMN_MIDDLEWARE_URL || configHosts?.middleware || defaultHosts.middleware, 'The middleware host'),
96
+ platform: ((p) => p && indemnHost(p, 'The V2 platform host'))(process.env.INDEMN_PLATFORM_URL || configHosts?.platform || defaultHosts.platform),
98
97
  };
99
98
  }
100
99
 
@@ -108,6 +107,7 @@ export class IndemnClient {
108
107
  projectName: 'indemn',
109
108
  configName: 'config',
110
109
  cwd: `${process.env.HOME}/.indemn`,
110
+ configFileMode: 0o600, // owner-only from the first write: it holds the session and refresh token
111
111
  });
112
112
  }
113
113
 
@@ -166,52 +166,34 @@ export class IndemnClient {
166
166
  }
167
167
 
168
168
  /**
169
- * Get a fresh copilot session with the saved Firebase refresh token, save
170
- * it, and revoke the old one. Accounts with MFA are asked for the emailed
171
- * code when a terminal or MCP host can ask; otherwise the person must log in.
169
+ * The credential for the next request. A saved login lasts its 24 hours and is never renewed (no refresh
170
+ * token is kept): once it ends, nothing is sent and `indemn login` gives a new one.
172
171
  */
173
- async renewSession(): Promise<void> {
174
- if (renewing) return renewing;
175
- renewing = (async () => {
176
- const config = IndemnClient.loadConfig();
177
- const old = config?.session;
178
- if (!config || !old?.refresh_token) {
179
- throw new SessionExpiredError('Session expired. Run `indemn login`, then retry.');
180
- }
181
- const { idToken, refreshToken } = await refreshIdToken(config.environment, old.refresh_token);
182
- const { token } = await exchangeIdToken(
183
- (path, body, headers) => this.copilotPostUnauth(path, body, headers),
184
- idToken,
185
- getAskers().mfaCode,
186
- );
187
- IndemnClient.saveConfig({ session: toStoredSession(token, refreshToken) });
188
- try {
189
- await this.send('copilot', 'POST', `${this.hosts.copilot}/auth/firebase/logout`, {
190
- auth: false,
191
- headers: { Authorization: `JWT ${old.token}` },
192
- });
193
- } catch {
194
- // Best effort: the old token expires within the hour anyway.
195
- }
196
- })().finally(() => {
197
- renewing = null;
198
- });
199
- return renewing;
200
- }
201
-
202
- /** The credential for the next request, renewed first when a saved session is about to expire. */
203
172
  private async freshCredential(): Promise<Credential> {
204
- let cred = this.requireCredential();
173
+ const cred = this.requireCredential();
205
174
  if (cred.kind === 'token' && cred.source === 'config') {
206
175
  const session = IndemnClient.loadConfig()?.session;
207
- if (session && needsRenewal(session)) {
208
- await this.renewSession();
209
- cred = this.requireCredential();
176
+ // A refresh token kept by an earlier version is deleted: nothing that outlives logout stays on disk.
177
+ if (session?.refresh_token) IndemnClient.saveConfig({ session: { token: session.token, expires_at: session.expires_at } });
178
+ if (session?.expires_at && new Date(session.expires_at).getTime() <= Date.now()) {
179
+ throw new SessionExpiredError('Your login has ended (logins last 24 hours). Run `indemn login`, then retry.');
210
180
  }
181
+ } else if (cred.kind === 'token') {
182
+ // A host's session (Darwin hands over the person's Copilot session): over once its 24 hours are.
183
+ const expiry = tokenExpiry(cred.token);
184
+ if (expiry && expiry.getTime() <= Date.now()) throw new SessionExpiredError(this.loginAgain('Your Copilot session has ended.'));
211
185
  }
212
186
  return cred;
213
187
  }
214
188
 
189
+ /** How access comes back once the session has ended: a host's session (Darwin) by signing in to Copilot again. */
190
+ private loginAgain(what: string): string {
191
+ const cred = this.getCredential();
192
+ return cred?.kind === 'token' && cred.source === 'env'
193
+ ? `${what} Sign in to Copilot again, then retry.`
194
+ : `${what} Run \`indemn login\`, then retry.`;
195
+ }
196
+
215
197
  private async authHeader(): Promise<string> {
216
198
  return copilotAuthHeader(await this.freshCredential());
217
199
  }
@@ -225,16 +207,18 @@ export class IndemnClient {
225
207
  return this.linkCache;
226
208
  }
227
209
 
228
- /** Orgs the user belongs to: saved at login, else asked live from copilot. */
229
- async memberships(): Promise<OrgChoice[]> {
230
- if (this.membershipCache) return this.membershipCache;
210
+ /** Orgs the user belongs to: saved at login, else (or `live`) asked from copilot; a live answer updates the login's list. */
211
+ async memberships(opts: { live?: boolean } = {}): Promise<OrgSummary[]> {
212
+ if (this.membershipCache && !opts.live) return this.membershipCache;
231
213
  const cred = this.requireCredential();
232
214
  const saved = cred.source === 'config' ? IndemnClient.loadConfig()?.orgs : undefined;
233
- if (saved?.length) {
215
+ if (saved?.length && !opts.live) {
234
216
  this.membershipCache = saved;
235
217
  } else {
236
218
  const res = await this.copilotGet<{ userOrganizations?: Array<{ _id: string; name: string }> }>('/organization');
237
219
  this.membershipCache = (res.userOrganizations ?? []).map((o) => ({ id: String(o._id), name: o.name }));
220
+ // An empty answer never replaces the login's list (the check against it would be off from then on).
221
+ if (cred.source === 'config' && this.membershipCache.length) IndemnClient.saveConfig({ orgs: this.membershipCache });
238
222
  }
239
223
  return this.membershipCache;
240
224
  }
@@ -251,6 +235,8 @@ export class IndemnClient {
251
235
  link: this.getLink(),
252
236
  environment: this.environment,
253
237
  memberships: () => this.memberships(),
238
+ known: this.getCredential()?.source === 'config' ? IndemnClient.loadConfig()?.orgs : undefined,
239
+ refresh: () => this.memberships({ live: true }),
254
240
  ask: getAskers().chooseOrg,
255
241
  });
256
242
  const known = this.membershipCache ?? IndemnClient.loadConfig()?.orgs ?? [];
@@ -286,13 +272,16 @@ export class IndemnClient {
286
272
  * In a linked folder with an agent list, refuse any agent that is not on
287
273
  * the list before a request leaves the machine.
288
274
  */
289
- private assertAgentInScope(path: string): void {
275
+ private assertAgentInScope(path: string, body?: unknown): void {
290
276
  const link = this.getLink();
291
277
  if (!link || link.agents.length === 0) return;
292
- const match = /\/bots\/([a-f0-9]{24})(?:\/|$|\?)/i.exec(path);
293
- if (match && !link.agents.includes(match[1])) {
278
+ // The agent in the path (copilot /bots/<id>, V2 /v1-agents/<id>) and in the body (evaluations, simulations).
279
+ const b = (body && typeof body === 'object' && !(body instanceof FormData) ? body : {}) as Record<string, unknown>;
280
+ const ids = [/\/(?:bots|v1-agents)\/([a-f0-9]{24})(?:\/|$|\?)/i.exec(path)?.[1], b.bot_id, b.agent_id, ...(Array.isArray(b.bot_ids) ? b.bot_ids : [])];
281
+ const outside = ids.find((id): id is string => typeof id === 'string' && /^[a-f0-9]{24}$/i.test(id) && !link.agents.includes(id));
282
+ if (outside) {
294
283
  throw new CliError(
295
- `Agent ${match[1]} is not linked in ${link.path}. Linked agents: ${link.agents.join(', ')}. ` +
284
+ `Agent ${outside} is not linked in ${link.path}. Linked agents: ${link.agents.join(', ')}. ` +
296
285
  'Add it with `indemn link` or work from another folder.',
297
286
  );
298
287
  }
@@ -303,7 +292,6 @@ export class IndemnClient {
303
292
  if (path.includes(':org_id')) {
304
293
  resolvedPath = path.replace(':org_id', await this.currentOrgId());
305
294
  }
306
- this.assertAgentInScope(resolvedPath);
307
295
  return `${this.hosts.copilot}${resolvedPath}`;
308
296
  }
309
297
 
@@ -317,6 +305,7 @@ export class IndemnClient {
317
305
  * create twice).
318
306
  */
319
307
  private async send(backend: Backend, method: Method, url: string, opts: SendOptions = {}): Promise<Response> {
308
+ this.assertAgentInScope(new URL(url).pathname, opts.body);
320
309
  const headers: Record<string, string> = {
321
310
  'User-Agent': `indemn-cli/${cliVersion()}`,
322
311
  'X-Indemn-Client': clientKind,
@@ -373,12 +362,7 @@ export class IndemnClient {
373
362
  */
374
363
  private async copilotRequest<T>(method: Method, path: string, opts: { body?: unknown; query?: Record<string, string> } = {}): Promise<T> {
375
364
  const url = this.withQuery(await this.buildCopilotUrl(path), opts.query);
376
- let res = await this.send('copilot', method, url, { body: opts.body });
377
- const cred = this.getCredential();
378
- if (res.status === 401 && cred?.kind === 'token' && cred.source === 'config') {
379
- await this.renewSession();
380
- res = await this.send('copilot', method, url, { body: opts.body });
381
- }
365
+ const res = await this.send('copilot', method, url, { body: opts.body });
382
366
  return this.handleCopilotResponse<T>(res);
383
367
  }
384
368
 
@@ -390,7 +374,7 @@ export class IndemnClient {
390
374
  /** A copilot request whose answer is a file or audio, returned as bytes. */
391
375
  async copilotBinary(method: Method, path: string, body?: unknown): Promise<{ contentType: string; data: Buffer }> {
392
376
  const res = await this.send('copilot', method, await this.buildCopilotUrl(path), { body });
393
- if (res.status === 401) throw new SessionExpiredError('Authentication failed. Run `indemn login` to re-authenticate.');
377
+ if (res.status === 401) throw new SessionExpiredError(this.loginAgain('Authentication failed.'));
394
378
  if (!res.ok) throw new Error(`API error (${res.status}): ${await res.text()}`);
395
379
  return { contentType: res.headers.get('content-type') ?? 'application/octet-stream', data: Buffer.from(await res.arrayBuffer()) };
396
380
  }
@@ -426,19 +410,13 @@ export class IndemnClient {
426
410
  const base = this.hosts.platform;
427
411
  if (!base) throw new CliError('No V2 platform host is configured for this environment.');
428
412
  const url = this.withQuery(`${base}${path}`, opts.query);
429
- this.assertAgentInScope(path);
430
413
  const headersFor = async (): Promise<Record<string, string>> => {
431
414
  const headers: Record<string, string> = { Authorization: darwinAuthHeader(await this.freshCredential()) };
432
415
  if (opts.orgHeader !== false) headers['X-Organization-Id'] = await this.currentOrgId();
433
416
  return headers;
434
417
  };
435
- let res = await this.send('platform', method, url, { body: opts.body, headers: await headersFor() });
436
- const cred = this.getCredential();
437
- if (res.status === 401 && cred?.kind === 'token' && cred.source === 'config') {
438
- await this.renewSession();
439
- res = await this.send('platform', method, url, { body: opts.body, headers: await headersFor() });
440
- }
441
- if (res.status === 401) throw new SessionExpiredError('The V2 platform rejected the session. Run `indemn login`, then retry.');
418
+ const res = await this.send('platform', method, url, { body: opts.body, headers: await headersFor() });
419
+ if (res.status === 401) throw new SessionExpiredError(this.loginAgain('The V2 platform rejected the session.'));
442
420
  if (!res.ok) {
443
421
  const text = await res.text();
444
422
  let detail = text;
@@ -480,7 +458,7 @@ export class IndemnClient {
480
458
 
481
459
  private async handleCopilotResponse<T>(res: Response): Promise<T> {
482
460
  if (res.status === 401) {
483
- throw new SessionExpiredError('Authentication failed. Run `indemn login` to re-authenticate.');
461
+ throw new SessionExpiredError(this.loginAgain('Authentication failed.'));
484
462
  }
485
463
  if (res.status === 403) {
486
464
  // Keep the server's reason: "Tickets are not enabled for this organization." is not a permission problem.
@@ -98,7 +98,7 @@ export async function planConfigChange(client: IndemnClient, agentId: string, ch
98
98
  }
99
99
  let lifecycle = 'unknown';
100
100
  try {
101
- lifecycle = (await new AgentInsightsSDK(client).detail(agentId)).lifecycle ?? 'prototype';
101
+ lifecycle = (await new AgentInsightsSDK(client).detail(agentId)).lifecycle ?? 'unknown';
102
102
  } catch {
103
103
  // The V2 platform may be unreachable; callers treat "unknown" as published (mayBeLive).
104
104
  }
@@ -171,7 +171,7 @@ export class ConversationsSDK {
171
171
  if (conversation.isTestMode !== true || conversation.attributes?.isTestMode === false) {
172
172
  throw new CliError(
173
173
  `Conversation ${requestId} was stored as a live conversation, not a test. Nothing was sent. ` +
174
- 'Mark it as test with `indemn conversations mark-test` and report this.',
174
+ 'If this CLI started it, mark it as test with `indemn conversations mark-test` and report this.',
175
175
  );
176
176
  }
177
177
  if (org && org !== expectedOrgId) {
@@ -1,5 +1,5 @@
1
1
  import type { IndemnClient } from './client.js';
2
- import { CliError } from '../core/runtime.js';
2
+ import { CliError, uploadable } from '../core/runtime.js';
3
3
  import { assertSkillNameAllowed } from './skills.js';
4
4
  import type { BotFunction, CreateFunctionInput, UpdateFunctionInput, FunctionParameter } from './types.js';
5
5
 
@@ -126,7 +126,7 @@ export class FunctionsSDK {
126
126
  const { readFileSync } = await import('node:fs');
127
127
  const { basename } = await import('node:path');
128
128
  const form = new FormData();
129
- form.append('file', new Blob([readFileSync(source.filePath)]), basename(source.filePath));
129
+ form.append('file', new Blob([readFileSync(uploadable(source.filePath))]), basename(source.filePath));
130
130
  return this.client.copilotPostForm(path, form);
131
131
  }
132
132
  return this.client.copilotPost(path, { text: source.text });
@@ -1,6 +1,6 @@
1
1
  import type { IndemnClient } from './client.js';
2
2
  import { darwinAuthHeader } from './credentials.js';
3
- import { CliError } from '../core/runtime.js';
3
+ import { CliError, indemnHost } from '../core/runtime.js';
4
4
 
5
5
  export interface JarvisToolEvent {
6
6
  name: string;
@@ -16,7 +16,7 @@ export interface JarvisReply {
16
16
 
17
17
  /** wss://<platform host>/ws, derived from the REST base (…/api/v1), or INDEMN_PLATFORM_WS_URL. */
18
18
  export function jarvisSocketUrl(client: IndemnClient): string {
19
- if (process.env.INDEMN_PLATFORM_WS_URL) return process.env.INDEMN_PLATFORM_WS_URL;
19
+ if (process.env.INDEMN_PLATFORM_WS_URL) return indemnHost(process.env.INDEMN_PLATFORM_WS_URL, 'INDEMN_PLATFORM_WS_URL');
20
20
  const base = client.getHosts().platform;
21
21
  if (!base) throw new CliError('No V2 platform host is configured for this environment.');
22
22
  const u = new URL(base);
package/src/sdk/kb.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { IndemnClient } from './client.js';
2
- import { CliError, EXIT } from '../core/runtime.js';
2
+ import { CliError, EXIT, uploadable } from '../core/runtime.js';
3
3
  import type {
4
4
  KnowledgeBase,
5
5
  CreateKBInput,
@@ -88,7 +88,7 @@ export class KBSDK {
88
88
  if (selected) {
89
89
  try { site = new URL(url).origin; } catch { site = url; }
90
90
  }
91
- return this.client.copilotPost(
91
+ const result = await this.client.copilotPost<{ status?: string; message?: string }>(
92
92
  `/organization/:org_id/knowledge-bases/${kbId}/import`,
93
93
  {
94
94
  type: 'url',
@@ -96,6 +96,9 @@ export class KBSDK {
96
96
  sources: [{ url: site, crawl_mode: selected ? 'selected' : 'full', selected_urls: selected ? [url] : [], exclude_urls: [], js_script: '' }],
97
97
  },
98
98
  );
99
+ // copilot answers a crawl that never started (unreachable url) with success and status "error".
100
+ assertJobStarted(result, 'Crawl');
101
+ return result;
99
102
  }
100
103
 
101
104
  /**
@@ -111,6 +114,7 @@ export class KBSDK {
111
114
  if (!fs.existsSync(filePath)) {
112
115
  throw new Error(`File not found: ${filePath}`);
113
116
  }
117
+ uploadable(filePath);
114
118
  const fileName = path.basename(filePath);
115
119
  const ext = path.extname(fileName).slice(1).toLowerCase();
116
120
  const allowed = ['pdf', 'docx', 'doc', 'txt', 'csv', 'xls', 'xlsx', 'json'];
@@ -138,7 +142,7 @@ export class KBSDK {
138
142
  const fs = await import('fs');
139
143
  const path = await import('path');
140
144
  const formData = new FormData();
141
- formData.append('file', new Blob([fs.readFileSync(filePath)]), path.basename(filePath));
145
+ formData.append('file', new Blob([fs.readFileSync(uploadable(filePath))]), path.basename(filePath));
142
146
  const stored = await this.client.copilotPostForm<{ url?: string; signedUrl?: string; data?: { url?: string; signedUrl?: string } }>(
143
147
  '/organization/:org_id/files/upload-org?generateSignedUrl=true',
144
148
  formData,
@@ -264,7 +268,7 @@ export class KBSDK {
264
268
  * Crawl and file-import status. copilot has no status route, so this reads
265
269
  * the KB's non-manual sources and their scrape jobs.
266
270
  */
267
- async scrapeStatus(kbId: string): Promise<Array<{ id?: string; name?: string; source?: string; status?: string; job_status?: string; error?: string; completed_at?: string }>> {
271
+ async scrapeStatus(kbId: string): Promise<Array<{ id?: string; name?: string; source?: string; status?: string; job_id?: string; job_status?: string; error?: string; completed_at?: string }>> {
268
272
  const kb = await this.getKB(kbId) as unknown as { fileWebSources?: Array<Record<string, any>> };
269
273
  return (kb.fileWebSources ?? []).map((s) => ({
270
274
  id: s._id ? String(s._id) : undefined,
@@ -272,6 +276,8 @@ export class KBSDK {
272
276
  // and keep the link without its signature.
273
277
  ...(s.type === 'file' ? { name: s.source_details?.file_name ?? undefined, source: s.source ? String(s.source).split('?')[0] : undefined } : { source: s.source }),
274
278
  status: s.status,
279
+ // The crawl or import that brought the source in (the job id a crawl tool returned as _id).
280
+ job_id: s.source_details?.id_scrape_request?._id ? String(s.source_details.id_scrape_request._id) : undefined,
275
281
  job_status: s.source_details?.id_scrape_request?.status,
276
282
  error: s.error_message ?? s.source_details?.id_scrape_request?.message,
277
283
  completed_at: s.source_details?.id_scrape_request?.completed_at ?? undefined,
package/src/sdk/orgs.ts CHANGED
@@ -13,10 +13,7 @@ export class OrgsSDK {
13
13
 
14
14
  /** Orgs the logged-in person belongs to or created. Also refreshes the list saved at login. */
15
15
  async listOrganizations(): Promise<OrgSummary[]> {
16
- const res = await this.client.copilotGet<{ userOrganizations?: Array<{ _id: string; name: string }> }>('/organization');
17
- const orgs = (res.userOrganizations ?? []).map((o) => ({ id: String(o._id), name: o.name }));
18
- if (this.client.getCredential()?.source === 'config') IndemnClient.saveConfig({ orgs });
19
- return orgs;
16
+ return this.client.memberships({ live: true });
20
17
  }
21
18
 
22
19
  /** People with owner, admin or agent roles in the org, one row per person. */
@@ -2,9 +2,7 @@ import type { Environment, StoredSession } from './types.js';
2
2
 
3
3
  export type { StoredSession };
4
4
  import { SessionExpiredError } from '../core/runtime.js';
5
- import { FIREBASE_CONFIGS } from './auth.js';
6
5
 
7
- const SECURE_TOKEN_URL = 'https://securetoken.googleapis.com/v1/token';
8
6
 
9
7
  export type UnauthPost = <T>(path: string, body: unknown, headers?: Record<string, string>) => Promise<T>;
10
8
 
@@ -23,28 +21,7 @@ export function tokenExpiry(token: string): Date | null {
23
21
  }
24
22
 
25
23
  /** Renew when less than `windowMs` (default one hour) of life is left. Unknown expiry never renews. */
26
- export function needsRenewal(session: StoredSession, now: Date = new Date(), windowMs = 60 * 60 * 1000): boolean {
27
- const expiry = session.expires_at ? new Date(session.expires_at) : tokenExpiry(session.token);
28
- if (!expiry || Number.isNaN(expiry.getTime())) return false;
29
- return expiry.getTime() - now.getTime() < windowMs;
30
- }
31
24
 
32
- /** Trade the Firebase refresh token for a fresh Firebase ID token (Google secure-token API). */
33
- export async function refreshIdToken(env: Environment, refreshToken: string): Promise<{ idToken: string; refreshToken: string }> {
34
- const url = `${SECURE_TOKEN_URL}?key=${encodeURIComponent(FIREBASE_CONFIGS[env].apiKey)}`;
35
- const res = await fetch(url, {
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
38
- body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }).toString(),
39
- });
40
- if (!res.ok) {
41
- // Revoked, password changed or account disabled: a real login is the right answer.
42
- throw new SessionExpiredError(`Session expired and could not be renewed. ${RELOGIN}`);
43
- }
44
- const json = await res.json() as { id_token?: string; refresh_token?: string };
45
- if (!json.id_token) throw new SessionExpiredError(`Session renewal returned no token. ${RELOGIN}`);
46
- return { idToken: json.id_token, refreshToken: json.refresh_token || refreshToken };
47
- }
48
25
 
49
26
  function stripScheme(token: string): string {
50
27
  return token.replace(/^JWT\s+/i, '');
@@ -84,7 +61,7 @@ export async function exchangeIdToken(
84
61
  }
85
62
 
86
63
  /** Package a copilot token for storage. */
87
- export function toStoredSession(token: string, refreshToken?: string): StoredSession {
64
+ export function toStoredSession(token: string): StoredSession {
88
65
  const expiry = tokenExpiry(token);
89
- return { token, expires_at: expiry?.toISOString(), refresh_token: refreshToken };
66
+ return { token, expires_at: expiry?.toISOString() };
90
67
  }
@@ -78,6 +78,12 @@ export async function openTestChat(
78
78
  const firstMessage = opts.sessionId ? undefined : (await new ConfigSDK(client).getConfig(agent._id).catch(() => undefined))?.first_message;
79
79
  const customData = firstMessage?.text ? { first_message: firstMessage } : undefined;
80
80
 
81
+ // A conversation named by the caller may be a customer's live one: it is checked before anything is sent to it,
82
+ // and never ended here.
83
+ if (opts.sessionId) {
84
+ if (!projectId) throw new Error(`Agent ${agent._id} has no project; cannot confirm the conversation is a test. Nothing was sent.`);
85
+ await new ConversationsSDK(client).verifyTestConversation(projectId, opts.sessionId, orgId, { timeoutMs: 0 });
86
+ }
81
87
  const session = new ChatSession(agent._id, client.getHosts().middleware, opts.sessionId, { tester, preview, customData });
82
88
  const transcript: string[] = [];
83
89
  let receiving = false;
@@ -92,7 +98,7 @@ export async function openTestChat(
92
98
  if (!projectId) throw new Error(`Agent ${agent._id} has no project; cannot confirm the conversation is a test. Nothing was sent.`);
93
99
  await new ConversationsSDK(client).verifyTestConversation(projectId, sessionId, orgId, { timeoutMs: opts.verifyTimeoutMs });
94
100
  } catch (err) {
95
- await session.disconnect();
101
+ await session.disconnect({ end: !opts.sessionId });
96
102
  throw err;
97
103
  }
98
104
  return {
@@ -132,19 +138,23 @@ export async function exchange(
132
138
  message: string,
133
139
  opts: { greetingWaitMs?: number; timeoutMs?: number; settleMs?: number } = {},
134
140
  ): Promise<{ greeting: string; response: string }> {
135
- if (!chat.resumed) {
136
- await until(() => chat.transcript.length > 0 && !chat.isReceiving(), opts.greetingWaitMs ?? 8_000);
141
+ const timeoutMs = opts.timeoutMs ?? 60_000;
142
+ // A new conversation's greeting must be complete first, or it would be taken for the reply.
143
+ if (!chat.resumed && !(await until(() => chat.transcript.length > 0 && !chat.isReceiving(), opts.greetingWaitMs ?? timeoutMs))) {
144
+ throw new Error(`The agent's greeting did not finish within ${Math.round((opts.greetingWaitMs ?? timeoutMs) / 1000)} seconds; nothing was sent.`);
137
145
  }
138
146
  const greeting = chat.transcript.join('\n');
139
147
  const start = chat.transcript.length;
148
+ const deadline = Date.now() + timeoutMs;
140
149
  chat.session.send(message);
141
150
 
142
- const gotReply = await until(() => chat.transcript.length > start && !chat.isReceiving(), opts.timeoutMs ?? 60_000);
143
- if (!gotReply) throw new Error(`Agent did not respond within ${Math.round((opts.timeoutMs ?? 60_000) / 1000)} seconds.`);
151
+ const gotReply = await until(() => chat.transcript.length > start && !chat.isReceiving(), timeoutMs);
152
+ if (!gotReply) throw new Error(`Agent did not respond within ${Math.round(timeoutMs / 1000)} seconds.`);
144
153
  let seen = chat.transcript.length;
145
154
  for (;;) {
146
155
  await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? 1_500));
147
- if (chat.transcript.length === seen && !chat.isReceiving()) break;
156
+ // A stream that never finishes ends the wait at the deadline, with what arrived.
157
+ if ((chat.transcript.length === seen && !chat.isReceiving()) || Date.now() >= deadline) break;
148
158
  seen = chat.transcript.length;
149
159
  }
150
160
  return { greeting, response: chat.transcript.slice(start).join('\n') };
@@ -40,6 +40,20 @@ describe('indemn init', () => {
40
40
  expect(settings.permissions.allow).not.toContain('mcp__indemn__list_agents');
41
41
  });
42
42
 
43
+ it('moves an older Indemn entry in .mcp.json to this version (only the version), and keeps an entry the user wrote', () => {
44
+ const mcpJson = () => JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8')).mcpServers.indemn;
45
+ const older = { command: 'npx', args: ['-y', '@indemn/cli@1.7.4', '--org', 'org1', 'mcp', 'serve'], env: { INDEMN_ENV: 'dev' } };
46
+ writeFileSync(join(dir, '.mcp.json'), JSON.stringify({ mcpServers: { indemn: older } }));
47
+ expect(initProject(dir, { packageRoot }).mcp).toBe('updated');
48
+ expect(mcpJson()).toEqual({ ...older, args: ['-y', MCP_SERVER_ENTRY.args[1], '--org', 'org1', 'mcp', 'serve'] });
49
+ expect(initProject(dir, { packageRoot, force: true }).mcp).toBe('kept');
50
+
51
+ const own = { command: 'node', args: ['./dist/index.js', 'mcp', 'serve'] };
52
+ writeFileSync(join(dir, '.mcp.json'), JSON.stringify({ mcpServers: { indemn: own } }));
53
+ expect(initProject(dir, { packageRoot, force: true }).mcp).toBe('kept');
54
+ expect(mcpJson()).toEqual(own);
55
+ });
56
+
43
57
  it('classifies every tool: guarded or destructive tools ask', () => {
44
58
  const { allow, ask } = permissionBlock();
45
59
  expect(allow.length + ask.length).toBe(Object.keys(TOOL_META).length);
@@ -15,10 +15,46 @@ describe('resolveOrg precedence', () => {
15
15
  it.each([
16
16
  ['flag', { flag: B.id }, B.id],
17
17
  ['env', { envOrg: B.id }, B.id],
18
- ])('%s decides without looking up memberships', async (source, extra, id) => {
18
+ ])('%s decides without looking up memberships (checked against the orgs saved at login)', async (source, extra, id) => {
19
19
  const memberships = vi.fn();
20
- await expect(resolveOrg({ ...base, ...extra, memberships })).resolves.toMatchObject({ id, source });
20
+ const refresh = vi.fn();
21
+ await expect(resolveOrg({ ...base, ...extra, memberships, known: [A, B], refresh })).resolves.toMatchObject({ id, source });
21
22
  expect(memberships).not.toHaveBeenCalled();
23
+ expect(refresh).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it('looks again when an explicit org is not among the saved ones (joined since login)', async () => {
27
+ const C: OrgChoice = { id: 'cccccccccccccccccccccccc', name: 'Org C' };
28
+ const refresh = vi.fn().mockResolvedValue([A, B, C]);
29
+ await expect(resolveOrg({ ...base, flag: C.id, memberships: vi.fn(), known: [A, B], refresh })).resolves.toMatchObject({ id: C.id, source: 'flag' });
30
+ expect(refresh).toHaveBeenCalledOnce();
31
+ });
32
+
33
+ it('names your current orgs when the explicit org is not among them either', async () => {
34
+ const C: OrgChoice = { id: 'cccccccccccccccccccccccc', name: 'Org C' };
35
+ const refresh = vi.fn().mockResolvedValue([B, C]);
36
+ await expect(resolveOrg({ ...base, flag: A.id, memberships: vi.fn(), known: [B], refresh }))
37
+ .rejects.toThrow(`You are not a member of org ${A.id}. Your orgs: ${B.name} (${B.id}), ${C.name} (${C.id}).`);
38
+ });
39
+
40
+ it('refuses when the orgs looked up again are none at all', async () => {
41
+ await expect(resolveOrg({ ...base, flag: A.id, memberships: vi.fn(), known: [B], refresh: vi.fn().mockResolvedValue([]) }))
42
+ .rejects.toThrow(`You are not a member of org ${A.id}, or of any organization on this platform.`);
43
+ });
44
+
45
+ it("leaves the org to copilot's own check when the orgs cannot be looked up", async () => {
46
+ const refresh = vi.fn().mockRejectedValue(new Error('offline'));
47
+ await expect(resolveOrg({ ...base, flag: 'cccccccccccccccccccccccc', memberships: vi.fn(), known: [A, B], refresh }))
48
+ .resolves.toMatchObject({ id: 'cccccccccccccccccccccccc' });
49
+ });
50
+
51
+ it('names your orgs when an explicit org is not one of them (a typo, or another account)', async () => {
52
+ await expect(resolveOrg({ ...base, flag: 'cccccccccccccccccccccccc', memberships: vi.fn(), known: [A, B] }))
53
+ .rejects.toThrow(new RegExp(`not a member of org c+.*${A.name} \\(${A.id}\\)`));
54
+ });
55
+
56
+ it("without saved orgs (an API key, a host's session) an explicit org is left to copilot's own check", async () => {
57
+ await expect(resolveOrg({ ...base, flag: B.id, memberships: vi.fn() })).resolves.toMatchObject({ id: B.id });
22
58
  });
23
59
 
24
60
  it('a linked folder decides without looking up memberships', async () => {
@@ -90,4 +90,16 @@ describe('org for each request', () => {
90
90
  expect(calls[0].url).toContain(`/organization/${A}/`);
91
91
  expect(ask).not.toHaveBeenCalled();
92
92
  });
93
+
94
+ it('a linked folder also refuses an unlinked agent on the V2 platform and in evaluation calls', async () => {
95
+ const project = join(home.dir, 'project');
96
+ mkdirSync(join(project, '.indemn'), { recursive: true });
97
+ writeFileSync(join(project, '.indemn', 'project.json'), JSON.stringify({ org_id: A, environment: 'dev', agents: [AGENT_1] }));
98
+ vi.spyOn(process, 'cwd').mockReturnValue(project);
99
+ const { calls } = recordFetch();
100
+ const client = new IndemnClient();
101
+ await expect(client.platformRequest('PATCH', `/v1-agents/${AGENT_2}/lifecycle`, { body: { lifecycle: 'beta' } })).rejects.toThrow(/not linked/);
102
+ await expect(client.evalsPost('/api/v1/evaluations', { bot_id: AGENT_2 })).rejects.toThrow(/not linked/);
103
+ expect(calls).toHaveLength(0);
104
+ });
93
105
  });
@@ -27,12 +27,21 @@ export async function connectPair(
27
27
  }
28
28
  const [a, b] = InMemoryTransport.createLinkedPair();
29
29
  await Promise.all([server.connect(a), client.connect(b)]);
30
- const call = async (name: string, args: Record<string, unknown> = {}) => {
30
+ const once = async (name: string, args: Record<string, unknown>) => {
31
31
  const res = await client.callTool({ name, arguments: args });
32
32
  const content = res.content as Array<{ type: string; text: string }>;
33
33
  let parsed: unknown = content[0]?.text;
34
34
  try { parsed = JSON.parse(content[0].text); } catch { /* plain text */ }
35
35
  return { raw: res, body: parsed as any, isError: Boolean(res.isError) };
36
36
  };
37
+ // As a model on a host without approval forms must: a change is shown as a dry run before `confirm: true`.
38
+ const call = async (name: string, args: Record<string, unknown> = {}) => {
39
+ if (args.confirm === true && !opts.elicit) {
40
+ const { confirm: _confirm, ...rest } = args;
41
+ const first = await once(name, rest);
42
+ if (first.body?.dry_run !== true) return first;
43
+ }
44
+ return once(name, args);
45
+ };
37
46
  return { server, client, call, questions, close: () => Promise.all([client.close(), server.close()]) };
38
47
  }