@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +2 -2
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +10 -6
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +8 -2
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +15 -5
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/kb.js +1 -1
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/utils/output.d.ts +0 -1
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +0 -4
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts.map +1 -1
- package/dist/cli/voice.js +4 -1
- package/dist/cli/voice.js.map +1 -1
- package/dist/core/org-resolver.d.ts +4 -0
- package/dist/core/org-resolver.d.ts.map +1 -1
- package/dist/core/org-resolver.js +11 -0
- package/dist/core/org-resolver.js.map +1 -1
- package/dist/core/runtime.d.ts +9 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +49 -0
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/scope.d.ts +0 -1
- package/dist/core/scope.d.ts.map +1 -1
- package/dist/core/scope.js.map +1 -1
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +15 -2
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +2 -19
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.js +5 -5
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.js +6 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/report/export.d.ts.map +1 -1
- package/dist/report/export.js +2 -3
- package/dist/report/export.js.map +1 -1
- package/dist/report/generate.d.ts.map +1 -1
- package/dist/report/generate.js +3 -3
- package/dist/report/generate.js.map +1 -1
- package/dist/sdk/auth.d.ts +8 -3
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +13 -5
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +1 -0
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +11 -9
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +50 -67
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.js +1 -1
- package/dist/sdk/config-change.js.map +1 -1
- package/dist/sdk/conversations.js +1 -1
- package/dist/sdk/conversations.js.map +1 -1
- package/dist/sdk/functions.js +2 -2
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/jarvis-chat.js +2 -2
- package/dist/sdk/jarvis-chat.js.map +1 -1
- package/dist/sdk/kb.d.ts +1 -0
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +9 -3
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts.map +1 -1
- package/dist/sdk/orgs.js +1 -6
- package/dist/sdk/orgs.js.map +1 -1
- package/dist/sdk/session.d.ts +2 -9
- package/dist/sdk/session.d.ts.map +1 -1
- package/dist/sdk/session.js +2 -27
- package/dist/sdk/session.js.map +1 -1
- package/dist/sdk/test-chat.d.ts.map +1 -1
- package/dist/sdk/test-chat.js +17 -6
- package/dist/sdk/test-chat.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/auth.ts +9 -6
- package/src/cli/eval.ts +6 -2
- package/src/cli/index.ts +2 -2
- package/src/cli/init.ts +14 -6
- package/src/cli/kb.ts +1 -1
- package/src/cli/utils/output.ts +0 -4
- package/src/cli/voice.ts +3 -1
- package/src/core/org-resolver.ts +18 -0
- package/src/core/runtime.ts +46 -0
- package/src/core/scope.ts +0 -1
- package/src/mcp/registry.ts +17 -2
- package/src/mcp/server.ts +2 -19
- package/src/mcp/tool-meta.ts +5 -5
- package/src/mcp/tools.ts +6 -6
- package/src/report/export.ts +2 -1
- package/src/report/generate.tsx +3 -2
- package/src/sdk/auth.ts +15 -5
- package/src/sdk/chat.ts +1 -0
- package/src/sdk/client.ts +50 -72
- package/src/sdk/config-change.ts +1 -1
- package/src/sdk/conversations.ts +1 -1
- package/src/sdk/functions.ts +2 -2
- package/src/sdk/jarvis-chat.ts +2 -2
- package/src/sdk/kb.ts +10 -4
- package/src/sdk/orgs.ts +1 -4
- package/src/sdk/session.ts +2 -25
- package/src/sdk/test-chat.ts +16 -6
- package/test/cli/init.test.ts +14 -0
- package/test/core/org-resolver.test.ts +38 -2
- package/test/core/scope.test.ts +12 -0
- package/test/helpers/mcp-pair.ts +10 -1
- package/test/mcp/darwin-fixes.test.ts +18 -17
- package/test/mcp/registry.test.ts +11 -0
- package/test/mcp/shaping.test.ts +18 -0
- package/test/mcp/tools-snapshot.test.ts +4 -0
- package/test/sdk/auth.test.ts +11 -2
- package/test/sdk/client.test.ts +13 -0
- package/test/sdk/config-change.test.ts +1 -1
- package/test/sdk/kb-upload.test.ts +21 -0
- package/test/sdk/kb.test.ts +8 -3
- package/test/sdk/orgs.test.ts +18 -0
- package/test/sdk/renewal.test.ts +43 -64
- package/test/sdk/session.test.ts +1 -23
- package/test/sdk/skills.test.ts +20 -18
- 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 {
|
|
7
|
-
import { findLinkFile, resolveOrg, type LinkFile, type
|
|
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?:
|
|
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
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
173
|
+
const cred = this.requireCredential();
|
|
205
174
|
if (cred.kind === 'token' && cred.source === 'config') {
|
|
206
175
|
const session = IndemnClient.loadConfig()?.session;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
229
|
-
async memberships(): Promise<
|
|
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
|
-
|
|
293
|
-
|
|
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 ${
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
436
|
-
|
|
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.
|
|
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.
|
package/src/sdk/config-change.ts
CHANGED
|
@@ -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 ?? '
|
|
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
|
}
|
package/src/sdk/conversations.ts
CHANGED
|
@@ -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
|
-
'
|
|
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) {
|
package/src/sdk/functions.ts
CHANGED
|
@@ -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 });
|
package/src/sdk/jarvis-chat.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/sdk/session.ts
CHANGED
|
@@ -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
|
|
64
|
+
export function toStoredSession(token: string): StoredSession {
|
|
88
65
|
const expiry = tokenExpiry(token);
|
|
89
|
-
return { token, expires_at: expiry?.toISOString()
|
|
66
|
+
return { token, expires_at: expiry?.toISOString() };
|
|
90
67
|
}
|
package/src/sdk/test-chat.ts
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
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(),
|
|
143
|
-
if (!gotReply) throw new Error(`Agent did not respond within ${Math.round(
|
|
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
|
-
|
|
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') };
|
package/test/cli/init.test.ts
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/test/core/scope.test.ts
CHANGED
|
@@ -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
|
});
|
package/test/helpers/mcp-pair.ts
CHANGED
|
@@ -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
|
|
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
|
}
|