@indemn/cli 1.7.6 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +3 -1
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +95 -62
- package/dist/cli/auth.js.map +1 -1
- package/dist/mcp/registry.d.ts +12 -0
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +39 -4
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +15 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts.map +1 -1
- package/dist/mcp/tool-meta.js +2 -1
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +19 -7
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/access.d.ts +60 -0
- package/dist/sdk/access.d.ts.map +1 -0
- package/dist/sdk/access.js +115 -0
- package/dist/sdk/access.js.map +1 -0
- package/dist/sdk/auth.d.ts +7 -0
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +21 -2
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/browser-login.d.ts +49 -0
- package/dist/sdk/browser-login.d.ts.map +1 -0
- package/dist/sdk/browser-login.js +159 -0
- package/dist/sdk/browser-login.js.map +1 -0
- package/dist/sdk/client.d.ts +2 -0
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +31 -3
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/credentials.d.ts +2 -0
- package/dist/sdk/credentials.d.ts.map +1 -1
- package/dist/sdk/credentials.js +4 -0
- package/dist/sdk/credentials.js.map +1 -1
- package/dist/sdk/eval.d.ts +3 -3
- package/dist/sdk/eval.js +3 -3
- package/dist/sdk/types.d.ts +3 -0
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/types.js +0 -1
- package/dist/sdk/types.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/auth.ts +93 -63
- package/src/mcp/registry.ts +46 -5
- package/src/mcp/server.ts +20 -1
- package/src/mcp/tool-meta.ts +2 -1
- package/src/mcp/tools.ts +18 -7
- package/src/sdk/access.ts +143 -0
- package/src/sdk/auth.ts +23 -2
- package/src/sdk/browser-login.ts +193 -0
- package/src/sdk/client.ts +25 -3
- package/src/sdk/credentials.ts +5 -0
- package/src/sdk/eval.ts +3 -3
- package/src/sdk/types.ts +3 -0
- package/test/mcp/access.test.ts +158 -0
- package/test/mcp/registry.test.ts +14 -0
- package/test/mcp/tools-snapshot.test.ts +10 -0
- package/test/sdk/auth.test.ts +2 -1
- package/test/sdk/browser-login.test.ts +147 -0
- package/test/sdk/client.test.ts +134 -1
- package/test/sdk/credentials.test.ts +6 -1
package/src/mcp/registry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod/v3';
|
|
2
|
-
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import type { Environment } from '../sdk/types.js';
|
|
5
5
|
import { NeedsInputError, PERSON_TIMEOUT_MS } from '../core/runtime.js';
|
|
@@ -118,7 +118,9 @@ export async function guardDestructive(
|
|
|
118
118
|
message: `${formText} Proceed?`,
|
|
119
119
|
requestedSchema: {
|
|
120
120
|
type: 'object',
|
|
121
|
-
|
|
121
|
+
// Ticked already, so approving takes Enter twice in Claude Code (to Accept, then submit), not Space then
|
|
122
|
+
// Enter. Still one boolean field: Darwin's card reads that as an approval (Approve / Decline).
|
|
123
|
+
properties: { confirm: { type: 'boolean', title: 'Proceed', description: 'Ticked already: press Enter twice to approve (Space unticks).', default: true } },
|
|
122
124
|
required: ['confirm'],
|
|
123
125
|
},
|
|
124
126
|
}, { timeout: PERSON_TIMEOUT_MS });
|
|
@@ -167,10 +169,29 @@ export function createRegistry(
|
|
|
167
169
|
* and the ids that name nothing (no card is shown for those).
|
|
168
170
|
*/
|
|
169
171
|
displayNames?: (args: Args, orgId?: string) => Promise<{ names: Record<string, string>; missing?: string[] }>;
|
|
172
|
+
/**
|
|
173
|
+
* Role awareness (display only; the servers still decide): hide tools the person can't use
|
|
174
|
+
* anywhere, refuse early with the reason when the person's access says so, and name the
|
|
175
|
+
* person's role when a server refuses.
|
|
176
|
+
*/
|
|
177
|
+
access?: {
|
|
178
|
+
visible: (name: string) => boolean;
|
|
179
|
+
deny: (name: string, orgId?: string) => Promise<string | null>;
|
|
180
|
+
explain: (orgId?: string) => Promise<string | null>;
|
|
181
|
+
};
|
|
170
182
|
},
|
|
171
183
|
) {
|
|
172
184
|
const metaTable = opts.meta ?? TOOL_META;
|
|
173
185
|
const registered: string[] = [];
|
|
186
|
+
const handles = new Map<string, RegisteredTool>();
|
|
187
|
+
|
|
188
|
+
// A refusal from the servers names the person's role (handlers report errors as results).
|
|
189
|
+
const withRoleNote = async (result: CallToolResult, orgId?: string): Promise<CallToolResult> => {
|
|
190
|
+
const first = result.isError ? result.content?.[0] : undefined;
|
|
191
|
+
if (!opts.access || !first || first.type !== 'text' || !/^Error: (Not allowed|Access denied)/.test(first.text)) return result;
|
|
192
|
+
const role = await opts.access.explain(orgId).catch(() => null);
|
|
193
|
+
return role ? { ...result, content: [{ ...first, text: `${first.text} ${role}` }, ...result.content.slice(1)] } : result;
|
|
194
|
+
};
|
|
174
195
|
|
|
175
196
|
return {
|
|
176
197
|
tool(name: string, description: string, shape: Shape, handler: Handler, extra: { preview?: (args: Args) => Promise<string>; guardWhen?: (args: Args) => Promise<boolean> } = {}): void {
|
|
@@ -200,12 +221,18 @@ export function createRegistry(
|
|
|
200
221
|
.describe('Set true only after the user has agreed to the dry run. Ignored when the host can ask the user itself.');
|
|
201
222
|
}
|
|
202
223
|
|
|
203
|
-
server.registerTool(
|
|
224
|
+
const handle = server.registerTool(
|
|
204
225
|
name,
|
|
205
226
|
{ title: meta.title, description, inputSchema, annotations: meta.annotations },
|
|
206
227
|
(async (args: Args) => {
|
|
228
|
+
let orgId: string | undefined;
|
|
207
229
|
try {
|
|
208
230
|
const org = orgScoped ? await opts.resolveOrg!(typeof args.org_id === 'string' ? args.org_id : undefined) : undefined;
|
|
231
|
+
orgId = org?.id;
|
|
232
|
+
const refusal = opts.access ? await opts.access.deny(name, org?.id) : null;
|
|
233
|
+
if (refusal) {
|
|
234
|
+
return { content: [{ type: 'text', text: `Error: Not allowed: ${refusal} Nothing was changed.` }], isError: true };
|
|
235
|
+
}
|
|
209
236
|
return await runWithOrg(org?.id, async () => {
|
|
210
237
|
let confirmedByPerson = false;
|
|
211
238
|
// A guarded tool asks every time, or only when guardWhen says so (e.g. the agent may be live).
|
|
@@ -231,7 +258,7 @@ export function createRegistry(
|
|
|
231
258
|
if (!gate.proceed) return gate.result;
|
|
232
259
|
confirmedByPerson = gate.byPerson;
|
|
233
260
|
}
|
|
234
|
-
const result = await handler(args, { confirmedByPerson });
|
|
261
|
+
const result = await withRoleNote(await handler(args, { confirmedByPerson }), orgId);
|
|
235
262
|
return withContext(result, { ...(await opts.context()), ...(confirmedByPerson ? { approved_by_person: true } : {}) }, paginate ? { limit: Number(args.limit ?? 50), page: Number(args.page ?? 1) } : undefined);
|
|
236
263
|
});
|
|
237
264
|
} catch (err) {
|
|
@@ -240,16 +267,30 @@ export function createRegistry(
|
|
|
240
267
|
const message = err.message.replace('Choose one with --org <id>', 'Call again with org_id set to one of the ids below');
|
|
241
268
|
return text({ needs_input: true, message, choices: err.choices, to_proceed: 'Ask the user which organization to use, then call again with org_id set to its id.' });
|
|
242
269
|
}
|
|
243
|
-
|
|
270
|
+
let message = err instanceof Error ? err.message : String(err);
|
|
271
|
+
if (opts.access && /^(Not allowed|Access denied)/.test(message)) {
|
|
272
|
+
const role = await opts.access.explain(orgId).catch(() => null);
|
|
273
|
+
if (role) message = `${message} ${role}`;
|
|
274
|
+
}
|
|
244
275
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
245
276
|
}
|
|
246
277
|
}) as never,
|
|
247
278
|
);
|
|
248
279
|
registered.push(name);
|
|
280
|
+
handles.set(name, handle);
|
|
249
281
|
},
|
|
250
282
|
names(): string[] {
|
|
251
283
|
return [...registered];
|
|
252
284
|
},
|
|
285
|
+
/** Show only the tools the person can use somewhere; the host is told when the list changes. */
|
|
286
|
+
applyVisibility(): void {
|
|
287
|
+
if (!opts.access) return;
|
|
288
|
+
for (const [name, handle] of handles) {
|
|
289
|
+
const show = opts.access.visible(name);
|
|
290
|
+
if (show && !handle.enabled) handle.enable();
|
|
291
|
+
else if (!show && handle.enabled) handle.disable();
|
|
292
|
+
}
|
|
293
|
+
},
|
|
253
294
|
};
|
|
254
295
|
}
|
|
255
296
|
|
package/src/mcp/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { IndemnClient } from '../sdk/client.js';
|
|
4
4
|
import { registerTools } from './tools.js';
|
|
5
|
+
import { refreshAccess } from '../sdk/access.js';
|
|
5
6
|
import { setAskers, type Askers } from '../core/scope.js';
|
|
6
7
|
import { cliVersion, PERSON_TIMEOUT_MS } from '../core/runtime.js';
|
|
7
8
|
|
|
@@ -75,8 +76,26 @@ export async function startServer(): Promise<void> {
|
|
|
75
76
|
}, { instructions: SERVER_INSTRUCTIONS });
|
|
76
77
|
|
|
77
78
|
setAskers(mcpAskers(server));
|
|
78
|
-
registerTools(server, client);
|
|
79
|
+
const registry = registerTools(server, client);
|
|
80
|
+
|
|
81
|
+
// A host session (INDEMN_TOKEN) may be a different identity than any saved login,
|
|
82
|
+
// so never hide tools for it — deny() still refuses with the reason when a call
|
|
83
|
+
// isn't allowed. A saved login hides from what it already knows, before the host
|
|
84
|
+
// lists tools, then keeps it fresh.
|
|
85
|
+
const ownLogin = client.getCredential()?.source === 'config';
|
|
86
|
+
if (ownLogin) registry.applyVisibility();
|
|
79
87
|
|
|
80
88
|
const transport = new StdioServerTransport();
|
|
81
89
|
await server.connect(transport);
|
|
90
|
+
|
|
91
|
+
const refresh = () =>
|
|
92
|
+
refreshAccess(client).then(
|
|
93
|
+
() => { if (ownLogin) registry.applyVisibility(); },
|
|
94
|
+
() => undefined,
|
|
95
|
+
);
|
|
96
|
+
void refresh();
|
|
97
|
+
setInterval(() => void refresh(), ACCESS_REFRESH_MS).unref();
|
|
82
98
|
}
|
|
99
|
+
|
|
100
|
+
/** How often a running MCP server asks again what the person may do. */
|
|
101
|
+
const ACCESS_REFRESH_MS = 15 * 60_000;
|
package/src/mcp/tool-meta.ts
CHANGED
|
@@ -114,7 +114,8 @@ export const TOOL_META: Record<string, ToolMeta> = {
|
|
|
114
114
|
delete_knowledge_base: { ...destroy('Delete knowledge base'), guard: true, describe: (a) => `This will delete knowledge base ${a.kb_id} with all its sources and vectors.` },
|
|
115
115
|
list_kb_data: read('List knowledge base entries'),
|
|
116
116
|
add_kb_data: { ...write('Add knowledge base entry'), guard: true, describe: (a) => `This will add an entry to knowledge base ${a.kb_id}; agents using it answer from it at once.` },
|
|
117
|
-
|
|
117
|
+
// The approval says which crawl it is: a whole-site crawl stops at kb-service's default of 100 pages.
|
|
118
|
+
scrape_url: { ...write('Scrape a website into a knowledge base', { openWorldHint: true }), guard: true, describe: (a) => `${a.crawl_mode === 'selected' ? `This will import only the page ${a.url}` : `This will crawl the whole site from ${a.url} (up to 100 pages)`} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
|
|
118
119
|
upload_kb_file: { ...write('Upload file to knowledge base'), guard: true, describe: (a) => `This will upload ${a.file_path} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
|
|
119
120
|
update_kb_data: {
|
|
120
121
|
...write('Update knowledge base entry', { idempotentHint: true }),
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod/v3';
|
|
2
|
+
import { currentAccess, orgDenial, refreshAccess, roleIn, usableSomewhere } from '../sdk/access.js';
|
|
2
3
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import type { IndemnClient } from '../sdk/client.js';
|
|
4
5
|
import { AgentInsightsSDK, AgentsSDK } from '../sdk/agents.js';
|
|
@@ -168,8 +169,22 @@ const skillDefinition = z.preprocess(parseJsonText, z.object({
|
|
|
168
169
|
}).passthrough());
|
|
169
170
|
|
|
170
171
|
export function registerTools(server: McpServer, client: IndemnClient): Registry {
|
|
172
|
+
// Role awareness: the folder's linked org (or INDEMN_ORG_ID) narrows what counts; otherwise any org.
|
|
173
|
+
const orgInView = () => process.env.INDEMN_ORG_ID || client.getLink()?.org_id || undefined;
|
|
171
174
|
const reg = createRegistry(server, {
|
|
172
175
|
context: () => client.getResultContext(),
|
|
176
|
+
access: {
|
|
177
|
+
visible: (name) => usableSomewhere(name, currentAccess(client), orgInView()),
|
|
178
|
+
// Refuse early only when the saved access still says no after asking again (it may be stale).
|
|
179
|
+
deny: async (name, orgId) => {
|
|
180
|
+
if (!orgDenial(name, currentAccess(client), orgId)) return null;
|
|
181
|
+
return orgDenial(name, await refreshAccess(client), orgId);
|
|
182
|
+
},
|
|
183
|
+
explain: async (orgId) => {
|
|
184
|
+
const role = roleIn((await refreshAccess(client)) ?? currentAccess(client), orgId);
|
|
185
|
+
return role ? `Your role: ${role}.` : null;
|
|
186
|
+
},
|
|
187
|
+
},
|
|
173
188
|
// Each call decides its own org; an answer given once is not reused for the next call.
|
|
174
189
|
resolveOrg: (explicit) => client.resolveOrg(explicit, { cache: false }),
|
|
175
190
|
// The person approves a change by names: look up the ids the call names.
|
|
@@ -1070,16 +1085,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
1070
1085
|
kb_id: z.string().describe('The knowledge base ID'),
|
|
1071
1086
|
question: z.string().optional().describe('FAQ question'),
|
|
1072
1087
|
answer: z.string().optional().describe('FAQ answer'),
|
|
1073
|
-
source_url: z.string().optional().describe('Source URL (initiates scraping via import endpoint)'),
|
|
1074
1088
|
content: z.string().optional().describe('Raw content'),
|
|
1075
1089
|
},
|
|
1076
1090
|
async (args) => {
|
|
1077
1091
|
try {
|
|
1092
|
+
// A website goes through scrape_url, where the person chooses the whole site or one page.
|
|
1078
1093
|
const { kb_id, ...input } = args;
|
|
1079
|
-
if (input.source_url) {
|
|
1080
|
-
const result = await kb.scrapeUrl(kb_id, input.source_url as string);
|
|
1081
|
-
return success(result);
|
|
1082
|
-
}
|
|
1083
1094
|
const result = await kb.addDataSource(kb_id, input);
|
|
1084
1095
|
return success(result);
|
|
1085
1096
|
} catch (err) { return error(err); }
|
|
@@ -1088,11 +1099,11 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
1088
1099
|
|
|
1089
1100
|
reg.tool(
|
|
1090
1101
|
'scrape_url',
|
|
1091
|
-
'Scrape a website URL and add it as a data source to a knowledge base. The crawl runs in the background; its pages appear in the knowledge base only when it ends.',
|
|
1102
|
+
'Scrape a website URL and add it as a data source to a knowledge base. The crawl runs in the background; its pages appear in the knowledge base only when it ends. Unless the person already said, ask them first whether to crawl the whole site (up to 100 pages) or import only this page.',
|
|
1092
1103
|
{
|
|
1093
1104
|
kb_id: z.string().describe('The knowledge base ID'),
|
|
1094
1105
|
url: z.string().describe('URL to scrape'),
|
|
1095
|
-
crawl_mode: z.
|
|
1106
|
+
crawl_mode: z.enum(['full', 'selected']).describe('What the person chose: "full" crawls the whole site from url (up to 100 pages); "selected" imports only this page (one call per page).'),
|
|
1096
1107
|
},
|
|
1097
1108
|
async (args) => {
|
|
1098
1109
|
try {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { IndemnClient } from './client.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the signed-in person may do, as copilot-server reports it
|
|
5
|
+
* (GET /auth/me/access). Display information only: copilot-server and
|
|
6
|
+
* darwin-service still decide every request. The CLI uses it to hide MCP
|
|
7
|
+
* tools the person can't use anywhere and to explain refusals by role.
|
|
8
|
+
* Unknown access (older server, offline) hides nothing.
|
|
9
|
+
*/
|
|
10
|
+
export interface AccessProject {
|
|
11
|
+
project_id: string;
|
|
12
|
+
name: string | null;
|
|
13
|
+
role: string;
|
|
14
|
+
capabilities: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AccessOrg {
|
|
18
|
+
org_id: string;
|
|
19
|
+
org_name: string;
|
|
20
|
+
role: string | null;
|
|
21
|
+
is_org_admin: boolean;
|
|
22
|
+
capabilities: string[];
|
|
23
|
+
projects: AccessProject[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Access {
|
|
27
|
+
user: { id: string; email: string; name: string | null };
|
|
28
|
+
is_indemn_staff: boolean;
|
|
29
|
+
capabilities: string[];
|
|
30
|
+
orgs: AccessOrg[];
|
|
31
|
+
fetched_at?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Scope = 'user' | 'org' | 'project';
|
|
35
|
+
|
|
36
|
+
/** MCP tools whose use depends on a role, staff email or org setting (copilot config/cliCapabilities.js). */
|
|
37
|
+
export const TOOL_REQUIRES: Readonly<Record<string, { scope: Scope; capability: string }>> = Object.freeze({
|
|
38
|
+
create_sandbox_org: { scope: 'user', capability: 'org:sandbox_create' },
|
|
39
|
+
set_org_admin_settings: { scope: 'user', capability: 'org:admin_settings' },
|
|
40
|
+
get_conversation: { scope: 'project', capability: 'conversations:read' },
|
|
41
|
+
chat_with_agent: { scope: 'project', capability: 'conversations:read' },
|
|
42
|
+
mark_conversation_test: { scope: 'project', capability: 'conversations:mark_test' },
|
|
43
|
+
list_tickets: { scope: 'org', capability: 'tickets' },
|
|
44
|
+
get_ticket: { scope: 'org', capability: 'tickets' },
|
|
45
|
+
create_ticket: { scope: 'org', capability: 'tickets' },
|
|
46
|
+
update_ticket: { scope: 'org', capability: 'tickets' },
|
|
47
|
+
archive_ticket: { scope: 'org', capability: 'tickets' },
|
|
48
|
+
list_ticket_rules: { scope: 'org', capability: 'tickets' },
|
|
49
|
+
save_ticket_rule: { scope: 'org', capability: 'tickets' },
|
|
50
|
+
delete_ticket_rule: { scope: 'org', capability: 'tickets' },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let memo: Access | null = null;
|
|
54
|
+
|
|
55
|
+
/** The last known access: this process's, else the one saved with the login (config credential only). */
|
|
56
|
+
export function currentAccess(client?: IndemnClient): Access | null {
|
|
57
|
+
if (memo) return memo;
|
|
58
|
+
// A host session (INDEMN_TOKEN) may be a different identity than the saved login; don't use its access.
|
|
59
|
+
const source = (client ?? new IndemnClient()).getCredential()?.source;
|
|
60
|
+
return source === 'config' ? IndemnClient.loadConfig()?.access ?? null : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Defend against a malformed payload: every array field defaults to empty, bad orgs are dropped. */
|
|
64
|
+
function normalize(raw: Access): Pick<Access, 'capabilities' | 'orgs'> {
|
|
65
|
+
const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
|
|
66
|
+
const orgs = (Array.isArray(raw.orgs) ? raw.orgs : [])
|
|
67
|
+
.filter((o): o is AccessOrg => Boolean(o) && typeof o.org_id === 'string')
|
|
68
|
+
.map((o) => ({
|
|
69
|
+
...o,
|
|
70
|
+
capabilities: strings(o.capabilities),
|
|
71
|
+
projects: (Array.isArray(o.projects) ? o.projects : [])
|
|
72
|
+
.filter((pr): pr is AccessProject => Boolean(pr) && typeof pr.project_id === 'string')
|
|
73
|
+
.map((pr) => ({ ...pr, capabilities: strings(pr.capabilities) })),
|
|
74
|
+
}));
|
|
75
|
+
return { capabilities: strings(raw.capabilities), orgs };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Forget the in-process copy (tests, logout). */
|
|
79
|
+
export function resetAccess(): void {
|
|
80
|
+
memo = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask copilot again. Saved with the login only when the credential is the saved
|
|
85
|
+
* login; a host's session (INDEMN_TOKEN) never writes to this machine's config.
|
|
86
|
+
* Returns null when it can't be read (older server, offline): callers then hide nothing.
|
|
87
|
+
*/
|
|
88
|
+
export async function refreshAccess(client: IndemnClient): Promise<Access | null> {
|
|
89
|
+
try {
|
|
90
|
+
const raw = await client.copilotGet<Access>('/auth/me/access');
|
|
91
|
+
if (!raw || !Array.isArray(raw.orgs)) return null;
|
|
92
|
+
const stamped: Access = { ...raw, ...normalize(raw), fetched_at: new Date().toISOString() };
|
|
93
|
+
memo = stamped;
|
|
94
|
+
if (client.getCredential()?.source === 'config') IndemnClient.saveConfig({ access: stamped });
|
|
95
|
+
return stamped;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function orgsInView(access: Access, onlyOrgId?: string): AccessOrg[] {
|
|
102
|
+
return onlyOrgId ? access.orgs.filter((o) => o.org_id === onlyOrgId) : access.orgs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True unless access is known and says the tool can't be used in any org/project in view. */
|
|
106
|
+
export function usableSomewhere(tool: string, access: Access | null, onlyOrgId?: string): boolean {
|
|
107
|
+
const rule = TOOL_REQUIRES[tool];
|
|
108
|
+
if (!rule || !access) return true;
|
|
109
|
+
if (rule.scope === 'user') return access.capabilities.includes(rule.capability);
|
|
110
|
+
const orgs = orgsInView(access, onlyOrgId);
|
|
111
|
+
if (rule.scope === 'org') return orgs.some((o) => o.capabilities.includes(rule.capability));
|
|
112
|
+
return orgs.some((o) => o.projects.some((p) => p.capabilities.includes(rule.capability)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The person's role in an org, for messages ("agent in Acme"). */
|
|
116
|
+
export function roleIn(access: Access | null, orgId: string | undefined): string | null {
|
|
117
|
+
if (!access || !orgId) return null;
|
|
118
|
+
const org = access.orgs.find((o) => o.org_id === orgId);
|
|
119
|
+
if (!org) return null;
|
|
120
|
+
return `${org.role ?? 'member'} in ${org.org_name}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* For org-scoped requirements: why this org can't be used, or null. Project-scoped
|
|
125
|
+
* requirements are decided by the server (the project comes from the agent).
|
|
126
|
+
*/
|
|
127
|
+
export function orgDenial(tool: string, access: Access | null, orgId: string | undefined): string | null {
|
|
128
|
+
const rule = TOOL_REQUIRES[tool];
|
|
129
|
+
if (!rule || !access || rule.scope === 'project') return null;
|
|
130
|
+
if (rule.scope === 'user') {
|
|
131
|
+
if (access.capabilities.includes(rule.capability)) return null;
|
|
132
|
+
return rule.capability === 'org:sandbox_create'
|
|
133
|
+
? 'Only the Indemn support account can create sandbox organizations.'
|
|
134
|
+
: 'This is for Indemn staff only.';
|
|
135
|
+
}
|
|
136
|
+
if (!orgId) return null;
|
|
137
|
+
const org = access.orgs.find((o) => o.org_id === orgId);
|
|
138
|
+
if (!org) return null; // not in the list: let the server answer
|
|
139
|
+
if (org.capabilities.includes(rule.capability)) return null;
|
|
140
|
+
return rule.capability === 'tickets'
|
|
141
|
+
? `Tickets are not turned on for ${org.org_name}.`
|
|
142
|
+
: `Your role (${org.role ?? 'member'} in ${org.org_name}) does not allow this.`;
|
|
143
|
+
}
|
package/src/sdk/auth.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { getAuth, signInWithEmailAndPassword } from '@firebase/auth';
|
|
|
3
3
|
import { DEFAULT_HOSTS, IndemnClient } from './client.js';
|
|
4
4
|
import { exchangeIdToken, toStoredSession } from './session.js';
|
|
5
5
|
import type { Credential } from './credentials.js';
|
|
6
|
+
import { browserSignIn, type BrowserLoginOptions } from './browser-login.js';
|
|
7
|
+
import { refreshAccess, resetAccess } from './access.js';
|
|
6
8
|
import type { Environment, EnvironmentHosts, OrgSummary } from './types.js';
|
|
7
9
|
|
|
8
10
|
// Firebase client configs per environment — public configuration (not secrets),
|
|
@@ -62,7 +64,21 @@ export async function login(email: string, password: string, env: Environment =
|
|
|
62
64
|
idToken,
|
|
63
65
|
opts?.mfaCodePrompt,
|
|
64
66
|
);
|
|
67
|
+
return saveLogin(env, { ...session, email: session.email ?? email });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Log in through the browser: the dashboard's sign-in (with the emailed code when
|
|
72
|
+
* the account uses it) approves this computer, and the CLI gets its own 24-hour
|
|
73
|
+
* session. Same storage and same rules as the password login.
|
|
74
|
+
*/
|
|
75
|
+
export async function loginWithBrowser(env: Environment = 'dev', opts?: BrowserLoginOptions): Promise<LoginResult> {
|
|
76
|
+
const session = await browserSignIn(env, opts);
|
|
77
|
+
return saveLogin(env, session);
|
|
78
|
+
}
|
|
65
79
|
|
|
80
|
+
/** Keep a fresh copilot session with the person's orgs, replacing any earlier login. */
|
|
81
|
+
async function saveLogin(env: Environment, session: { token: string; userId: string; email?: string }): Promise<LoginResult> {
|
|
66
82
|
// Which orgs this person belongs to, asked with the new session itself.
|
|
67
83
|
const authed = new IndemnClient({ environment: env, token: session.token });
|
|
68
84
|
const res = await authed.copilotGet<{ userOrganizations?: Array<{ _id: string; name: string }> }>('/organization');
|
|
@@ -73,13 +89,17 @@ export async function login(email: string, password: string, env: Environment =
|
|
|
73
89
|
IndemnClient.saveConfig({
|
|
74
90
|
environment: env,
|
|
75
91
|
user_id: session.userId,
|
|
76
|
-
email: session.email
|
|
92
|
+
email: session.email,
|
|
77
93
|
session: stored,
|
|
78
94
|
orgs,
|
|
79
95
|
hosts: DEFAULT_HOSTS,
|
|
80
96
|
});
|
|
81
97
|
|
|
82
|
-
|
|
98
|
+
// What the person may do, kept with the login (best effort: an older server has no such endpoint).
|
|
99
|
+
resetAccess();
|
|
100
|
+
await refreshAccess(new IndemnClient({ environment: env }));
|
|
101
|
+
|
|
102
|
+
return { userId: session.userId, email: session.email ?? '', environment: env, orgs, expiresAt: stored.expires_at };
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
/** Revoke the session on the server, then forget it locally. */
|
|
@@ -96,6 +116,7 @@ export async function logout(): Promise<{ revoked: boolean }> {
|
|
|
96
116
|
}
|
|
97
117
|
}
|
|
98
118
|
IndemnClient.clearConfig();
|
|
119
|
+
resetAccess();
|
|
99
120
|
return { revoked };
|
|
100
121
|
}
|
|
101
122
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer, type Server } from 'node:http';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import type { AddressInfo } from 'node:net';
|
|
5
|
+
import { IndemnClient } from './client.js';
|
|
6
|
+
import { CliError, indemnHost } from '../core/runtime.js';
|
|
7
|
+
import type { Environment } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `indemn login` through the browser (OAuth-style loopback + PKCE):
|
|
11
|
+
* 1. listen on 127.0.0.1 (random port) and open the dashboard's /cli-login page
|
|
12
|
+
* with a random `state` and the SHA-256 challenge of a secret verifier;
|
|
13
|
+
* 2. the person signs in there (with the emailed code if their account uses it)
|
|
14
|
+
* and approves; the browser comes back to 127.0.0.1 with a one-time code;
|
|
15
|
+
* 3. the CLI trades code + verifier for its own 24-hour session.
|
|
16
|
+
* No token ever travels in a URL, and the verifier never leaves this process.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
export interface Pkce {
|
|
22
|
+
verifier: string;
|
|
23
|
+
challenge: string;
|
|
24
|
+
state: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createPkce(): Pkce {
|
|
28
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
29
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
30
|
+
const state = randomBytes(32).toString('base64url');
|
|
31
|
+
return { verifier, challenge, state };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sameSecret(a: string, b: string): boolean {
|
|
35
|
+
const x = Buffer.from(a);
|
|
36
|
+
const y = Buffer.from(b);
|
|
37
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The dashboard that serves /cli-login: INDEMN_DASHBOARD_URL, else the copilot host without `/api`. */
|
|
41
|
+
export function dashboardUrl(copilotHost: string): string {
|
|
42
|
+
const explicit = process.env.INDEMN_DASHBOARD_URL;
|
|
43
|
+
if (explicit) return indemnHost(explicit, 'INDEMN_DASHBOARD_URL').replace(/\/+$/, '');
|
|
44
|
+
return copilotHost.replace(/\/+$/, '').replace(/\/api$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function approveUrl(dashboard: string, port: number, pkce: Pkce): string {
|
|
48
|
+
const query = new URLSearchParams({ port: String(port), state: pkce.state, challenge: pkce.challenge });
|
|
49
|
+
return `${dashboard}/#/cli-login?${query.toString()}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type CallbackResult = { code: string } | { error: string };
|
|
53
|
+
|
|
54
|
+
export interface Loopback {
|
|
55
|
+
port: number;
|
|
56
|
+
/** First callback whose `state` matches; others are refused and ignored. */
|
|
57
|
+
result: Promise<CallbackResult>;
|
|
58
|
+
close: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PAGE = (title: string, body: string) =>
|
|
62
|
+
`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>` +
|
|
63
|
+
`<body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem">` +
|
|
64
|
+
`<h1 style="font-size:1.1rem">${title}</h1><p>${body}</p></body></html>`;
|
|
65
|
+
|
|
66
|
+
/** Listen on 127.0.0.1 only, for one `GET /callback` carrying our `state`. */
|
|
67
|
+
export async function startLoopback(state: string, timeoutMs: number = LOGIN_TIMEOUT_MS): Promise<Loopback> {
|
|
68
|
+
let settle!: (r: CallbackResult) => void;
|
|
69
|
+
let fail!: (e: Error) => void;
|
|
70
|
+
const result = new Promise<CallbackResult>((resolve, reject) => {
|
|
71
|
+
settle = resolve;
|
|
72
|
+
fail = reject;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let done = false;
|
|
76
|
+
const server: Server = createServer((req, res) => {
|
|
77
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
78
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
79
|
+
// The page has no subresources and echoes nothing; these just harden it.
|
|
80
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
81
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
82
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'");
|
|
83
|
+
// A raw socket can send a target that URL() rejects; never let that crash login.
|
|
84
|
+
let url: URL;
|
|
85
|
+
try {
|
|
86
|
+
url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
87
|
+
} catch {
|
|
88
|
+
res.writeHead(400).end(PAGE('Bad request', 'Nothing to see here.'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// DNS-rebinding defence: only our own loopback Host is served.
|
|
92
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
93
|
+
if (host && !/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host)) {
|
|
94
|
+
res.writeHead(400).end(PAGE('Bad request', 'Nothing to see here.'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (req.method !== 'GET' || url.pathname !== '/callback' || done) {
|
|
98
|
+
res.writeHead(404).end(PAGE('Not found', 'Nothing to see here.'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const got = url.searchParams.get('state') ?? '';
|
|
102
|
+
if (!sameSecret(got, state)) {
|
|
103
|
+
res.writeHead(400).end(PAGE('Sign-in link mismatch', 'This sign-in did not come from your terminal. Run <code>indemn login</code> again.'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const code = url.searchParams.get('code');
|
|
107
|
+
const error = url.searchParams.get('error');
|
|
108
|
+
done = true;
|
|
109
|
+
if (code) {
|
|
110
|
+
res.writeHead(200).end(PAGE('Signed in to the Indemn CLI', 'You can close this tab and return to your terminal.'));
|
|
111
|
+
settle({ code });
|
|
112
|
+
} else {
|
|
113
|
+
res.writeHead(200).end(PAGE('Sign-in cancelled', 'The CLI was not signed in. You can close this tab.'));
|
|
114
|
+
settle({ error: error || 'access_denied' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await new Promise<void>((resolve, reject) => {
|
|
119
|
+
server.once('error', reject);
|
|
120
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
121
|
+
});
|
|
122
|
+
const { port } = server.address() as AddressInfo;
|
|
123
|
+
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
if (!done) {
|
|
126
|
+
done = true;
|
|
127
|
+
fail(new CliError('Timed out waiting for the browser (5 minutes). Run `indemn login` again.'));
|
|
128
|
+
}
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
timer.unref();
|
|
131
|
+
|
|
132
|
+
const close = () => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
server.closeAllConnections?.();
|
|
135
|
+
server.close();
|
|
136
|
+
};
|
|
137
|
+
result.then(close, close);
|
|
138
|
+
return { port, result, close };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Open a URL in the default browser without a shell. Resolves false if nothing could be started. */
|
|
142
|
+
export function openBrowser(url: string): Promise<boolean> {
|
|
143
|
+
const [cmd, args] =
|
|
144
|
+
process.platform === 'darwin' ? ['open', [url]]
|
|
145
|
+
: process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
146
|
+
: ['xdg-open', [url]];
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
try {
|
|
149
|
+
const child = spawn(cmd, args as string[], { stdio: 'ignore', detached: true });
|
|
150
|
+
child.once('error', () => resolve(false));
|
|
151
|
+
child.once('spawn', () => {
|
|
152
|
+
child.unref();
|
|
153
|
+
resolve(true);
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
resolve(false);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface BrowserLoginOptions {
|
|
162
|
+
/** Opens the approve page; defaults to the system browser. */
|
|
163
|
+
open?: (url: string) => Promise<boolean>;
|
|
164
|
+
/** Told the URL (always), so it can be opened by hand. */
|
|
165
|
+
showUrl?: (url: string, opened: boolean) => void;
|
|
166
|
+
timeoutMs?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Run the browser flow; returns the copilot session (without the "JWT " prefix). */
|
|
170
|
+
export async function browserSignIn(env: Environment, opts: BrowserLoginOptions = {}): Promise<{ token: string; userId: string; email?: string }> {
|
|
171
|
+
const client = new IndemnClient({ environment: env });
|
|
172
|
+
const pkce = createPkce();
|
|
173
|
+
const loop = await startLoopback(pkce.state, opts.timeoutMs);
|
|
174
|
+
try {
|
|
175
|
+
const url = approveUrl(dashboardUrl(client.getHosts().copilot), loop.port, pkce);
|
|
176
|
+
const opened = await (opts.open ?? openBrowser)(url);
|
|
177
|
+
opts.showUrl?.(url, opened);
|
|
178
|
+
|
|
179
|
+
const outcome = await loop.result;
|
|
180
|
+
if ('error' in outcome) throw new CliError('Sign-in was cancelled in the browser.');
|
|
181
|
+
|
|
182
|
+
let res: { token: string; user: { _id: string; email?: string } };
|
|
183
|
+
try {
|
|
184
|
+
res = await client.copilotPostUnauth('/auth/cli/token', { code: outcome.code, code_verifier: pkce.verifier });
|
|
185
|
+
} catch {
|
|
186
|
+
throw new CliError('The sign-in code was not accepted (it may have expired). Run `indemn login` again.');
|
|
187
|
+
}
|
|
188
|
+
if (!res?.token || !res.user?._id) throw new CliError('Sign-in returned no session. Run `indemn login` again.');
|
|
189
|
+
return { token: res.token.replace(/^JWT\s+/i, ''), userId: String(res.user._id), email: res.user.email };
|
|
190
|
+
} finally {
|
|
191
|
+
loop.close();
|
|
192
|
+
}
|
|
193
|
+
}
|