History

Enhance elicitation handling and introduce tool role management

- Added support for anonymous MCP API key linking and elicitation requests in the bot. - Introduced new interfaces for elicitation requests and responses in the agent. - Updated the server to manage pending elicitation responses and integrate tool role policies. - Refactored access policy handling to utilize tool roles, improving role-based policy evaluation. - Enhanced runtime configuration to allow local VM execution based on environment settings.

Rhys Sullivan committed 8 months ago

Commit cffff331 parent a63e82345 files · +3020 −775Browse files

Unified · Split

45 files
  1. .gitignore +1 −0
  2. assistant/packages/bot/src/index.ts +848 −20
  3. assistant/packages/core/src/agent.ts +92 −6
  4. assistant/packages/core/src/index.ts +7 −1
  5. assistant/packages/server/src/link-store.ts +1 −0
  6. assistant/packages/server/src/routes.ts +288 −9
  7. executor/apps/web/src/components/tasks/task-composer.tsx +7 −5
  8. executor/apps/web/src/components/tools/policies.tsx +1039 −77
  9. executor/apps/web/src/hooks/use/workspace-tools.ts +0 −5
  10. executor/apps/web/src/lib/runtime-config.ts +16 −0
  11. executor/apps/web/src/lib/runtime-targets.ts +23 −7
  12. executor/apps/web/src/lib/session-context.tsx +5 −0
  13. executor/apps/web/src/lib/types.ts +12 −17
  14. executor/packages/core/src/function-builders.ts +15 −0
  15. executor/packages/core/src/mcp-server.ts +1 −3
  16. executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.ts +10 −1
  17. executor/packages/core/src/types.ts +1 −1
  18. executor/packages/database/convex/_generated/api.d.ts +13 −106
  19. executor/packages/database/convex/access-controls.test.ts +226 −30
  20. executor/packages/database/convex/app.ts +17 −0
  21. executor/packages/database/convex/database.ts +1 −3
  22. executor/packages/database/convex/database/anonymous_session.ts +63 −1
  23. executor/packages/database/convex/database/policies.ts +20 −293
  24. executor/packages/database/convex/database/tool_sources.ts +12 −0
  25. executor/packages/database/convex/executor.ts +0 −1
  26. executor/packages/database/convex/executorNode.ts +7 −9
  27. executor/packages/database/convex/http/mcp_auth.ts +2 −4
  28. executor/packages/database/convex/http/mcp_handler.ts +3 −3
  29. executor/packages/database/convex/http/mcp_service.ts +4 −1
  30. executor/packages/database/convex/invites.ts +0 −9
  31. executor/packages/database/convex/workspace.ts +3 −70
  32. executor/packages/database/src/auth/account_identity.ts +18 −0
  33. executor/packages/database/src/auth/event_handlers.ts +17 −0
  34. executor/packages/database/src/executor/handlers.ts +6 −2
  35. executor/packages/database/src/runtime/account_auth.ts +7 −1
  36. executor/packages/database/src/runtime/policy.test.ts +33 −5
  37. executor/packages/database/src/runtime/policy.ts +7 −11
  38. executor/packages/database/src/runtime/tool_call_resolution.ts +2 −2
  39. executor/packages/database/src/runtime/tool_descriptors.ts +2 −2
  40. executor/packages/database/src/runtime/tool_invocation.ts +29 −6
  41. executor/packages/database/src/runtime/workspace_tools.ts +8 −8
  42. executor/packages/runner-sandbox-host/src/bridge.ts +34 −48
  43. executor/packages/runner-sandbox-host/src/index.ts +4 −2
  44. executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js +114 −4
  45. executor/packages/runner-sandbox-host/src/types.ts +2 −2
.gitignore+1−0
@@ -56,3 +56,4 @@ executor/.env.executor-push · 55 unmodified lines
56*.har56*.har
57.vercel57.vercel
58.env*.local58.env*.local
59.chat-links.json
assistant/packages/bot/src/index.ts+848−20
@@ -44,6 +44,69 @@ const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal"; · 43 unmodified lines
44const LINK_FIELD_WORKSPACE = "workspace_id";44const LINK_FIELD_WORKSPACE = "workspace_id";
45const LINK_FIELD_TOKEN = "access_token";45const LINK_FIELD_TOKEN = "access_token";
46const LINK_FIELD_ACCOUNT = "account_id";46const LINK_FIELD_ACCOUNT = "account_id";
47const ANON_LINK_MODAL_ID = "assistant_link_anonymous_modal";
48const ANON_LINK_FIELD_WORKSPACE = "workspace_id";
49const ANON_LINK_FIELD_API_KEY = "api_key";
50const ELICITATION_BUTTON_PREFIX = "assistant_elicitation";
51const ELICITATION_FORM_MODAL_PREFIX = "assistant_elicitation_form";
52const ELICITATION_POLL_INTERVAL_MS = 1000;
53const ELICITATION_FORM_SESSION_TIMEOUT_MS = 10 * 60 * 1000;
54const MAX_DISCORD_MODAL_FIELDS = 5;
55
56type ElicitationAction = "accept" | "decline" | "cancel";
57type ElicitationButtonAction = ElicitationAction | "form";
58
59type FormFieldKind = "string" | "number" | "integer" | "boolean" | "enum_single" | "enum_multi";
60
61interface EnumOption {
62 readonly value: string | number | boolean;
63 readonly title?: string;
64}
65
66interface FormFieldSpec {
67 readonly key: string;
68 readonly label: string;
69 readonly description?: string;
70 readonly required: boolean;
71 readonly kind: FormFieldKind;
72 readonly minLength?: number;
73 readonly maxLength?: number;
74 readonly pattern?: string;
75 readonly minimum?: number;
76 readonly maximum?: number;
77 readonly minItems?: number;
78 readonly maxItems?: number;
79 readonly options?: readonly EnumOption[];
80 readonly defaultValue?: string | number | boolean | readonly (string | number | boolean)[];
81}
82
83interface FormSchemaSpec {
84 readonly fields: readonly FormFieldSpec[];
85 readonly error?: string;
86}
87
88interface FormModalSession {
89 readonly requestId: string;
90 readonly elicitationRequestId: string;
91 readonly userId: string;
92 readonly fields: readonly FormFieldSpec[];
93 readonly timeout: ReturnType<typeof setTimeout>;
94}
95
96interface PendingElicitation {
97 readonly id: string;
98 readonly requestId: string;
99 readonly mode: "form" | "url";
100 readonly message: string;
101 readonly requestedSchema?: Record<string, unknown>;
102 readonly url?: string;
103 readonly elicitationId?: string;
104 readonly createdAt: number;
105}
106
107const knownElicitations = new Map<string, PendingElicitation>();
108const knownElicitationIdsByRequest = new Map<string, Set<string>>();
109const formModalSessions = new Map<string, FormModalSession>();
47 110
48const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [111const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [
49 new SlashCommandBuilder()112 new SlashCommandBuilder()
@@ -64,10 +127,7 @@ const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ · 14 unmodified lines
64 .toJSON(),127 .toJSON(),
65 new SlashCommandBuilder()128 new SlashCommandBuilder()
66 .setName("link-anon")129 .setName("link-anon")
67 .setDescription("Link to an anonymous MCP session")130 .setDescription("Link to an anonymous MCP API key")
68 .addStringOption((option) => option
69 .setName("session_id")
70 .setDescription("Optional session id to reuse"))
71 .toJSON(),131 .toJSON(),
72 new SlashCommandBuilder()132 new SlashCommandBuilder()
73 .setName("unlink")133 .setName("unlink")
@@ -167,13 +227,14 @@ async function linkWorkos(identity: ChatIdentity, workspaceId: string, accessTok · 93 unmodified lines
167 return data.context;227 return data.context;
168}228}
169 229
170async function linkAnonymous(identity: ChatIdentity, sessionId?: string) {230async function linkAnonymous(identity: ChatIdentity, workspaceId: string, apiKey: string) {
171 const data = await unwrap(231 const data = await unwrap(
172 api.api.context.link.post({232 api.api.context.link.post({
173 platform: identity.platform,233 platform: identity.platform,
174 userId: identity.userId,234 userId: identity.userId,
175 provider: "anonymous",235 provider: "anonymous",
176 sessionId,236 workspaceId,
237 apiKey,
177 }),238 }),
178 );239 );
179 return data.context;240 return data.context;
@@ -189,30 +250,624 @@ async function unlink(identity: ChatIdentity) { · 9 unmodified lines
189 return data.removed;250 return data.removed;
190}251}
191 252
192async function runPrompt(identity: ChatIdentity, prompt: string) {253async function runPrompt(identity: ChatIdentity, prompt: string, requestId?: string) {
193 const data = await unwrap(254 const data = await unwrap(
194 api.api.chat.run.post({255 api.api.chat.run.post({
195 platform: identity.platform,256 platform: identity.platform,
196 userId: identity.userId,257 userId: identity.userId,
197 prompt,258 prompt,
259 requestId,
198 }),260 }),
199 );261 );
200 return data;262 return data;
201}263}
202 264
265async function getPendingElicitation(identity: ChatIdentity, requestId: string): Promise<PendingElicitation | null> {
266 const data = await unwrap(
267 api.api.chat.elicitation.pending.post({
268 platform: identity.platform,
269 userId: identity.userId,
270 requestId,
271 }),
272 );
273 return data.elicitation;
274}
275
276async function respondToElicitation(
277 identity: ChatIdentity,
278 requestId: string,
279 elicitationRequestId: string,
280 action: ElicitationAction,
281 content?: Record<string, unknown>,
282) {
283 return await unwrap(
284 api.api.chat.elicitation.respond.post({
285 platform: identity.platform,
286 userId: identity.userId,
287 requestId,
288 elicitationRequestId,
289 action,
290 content,
291 }),
292 );
293}
294
295function randomRequestId(): string {
296 return crypto.randomUUID();
297}
298
299function elicitationKey(requestId: string, elicitationId: string): string {
300 return `${requestId}:${elicitationId}`;
301}
302
303function rememberElicitation(elicitation: PendingElicitation) {
304 knownElicitations.set(elicitationKey(elicitation.requestId, elicitation.id), elicitation);
305 const existing = knownElicitationIdsByRequest.get(elicitation.requestId) ?? new Set<string>();
306 existing.add(elicitation.id);
307 knownElicitationIdsByRequest.set(elicitation.requestId, existing);
308}
309
310function forgetElicitation(requestId: string, elicitationId: string) {
311 knownElicitations.delete(elicitationKey(requestId, elicitationId));
312 const ids = knownElicitationIdsByRequest.get(requestId);
313 if (!ids) {
314 return;
315 }
316 ids.delete(elicitationId);
317 if (ids.size === 0) {
318 knownElicitationIdsByRequest.delete(requestId);
319 }
320}
321
322function forgetRequestElicitations(requestId: string) {
323 const ids = knownElicitationIdsByRequest.get(requestId);
324 if (!ids) {
325 return;
326 }
327 for (const id of ids) {
328 knownElicitations.delete(elicitationKey(requestId, id));
329 }
330 knownElicitationIdsByRequest.delete(requestId);
331}
332
333function getKnownElicitation(requestId: string, elicitationId: string): PendingElicitation | null {
334 return knownElicitations.get(elicitationKey(requestId, elicitationId)) ?? null;
335}
336
337function buildElicitationButtonId(action: ElicitationButtonAction, requestId: string, elicitationId: string): string {
338 return `${ELICITATION_BUTTON_PREFIX}:${action}:${requestId}:${elicitationId}`;
339}
340
341function parseElicitationButtonId(customId: string): {
342 action: ElicitationButtonAction;
343 requestId: string;
344 elicitationRequestId: string;
345} | null {
346 const [prefix, action, requestId, elicitationRequestId] = customId.split(":");
347 if (prefix !== ELICITATION_BUTTON_PREFIX) {
348 return null;
349 }
350 if (action !== "accept" && action !== "decline" && action !== "cancel" && action !== "form") {
351 return null;
352 }
353 if (!requestId || !elicitationRequestId) {
354 return null;
355 }
356 return {
357 action,
358 requestId,
359 elicitationRequestId,
360 };
361}
362
363function parseObject(value: unknown): Record<string, unknown> | null {
364 if (!value || typeof value !== "object" || Array.isArray(value)) {
365 return null;
366 }
367 return value as Record<string, unknown>;
368}
369
370function parseEnumOptions(property: Record<string, unknown>): EnumOption[] | null {
371 const enumValues = Array.isArray(property.enum)
372 ? property.enum.filter((value) => ["string", "number", "boolean"].includes(typeof value)) as Array<string | number | boolean>
373 : null;
374 if (enumValues && enumValues.length > 0) {
375 return enumValues.map((value) => ({ value }));
376 }
377
378 const variantsRaw = Array.isArray(property.oneOf)
379 ? property.oneOf
380 : Array.isArray(property.anyOf)
381 ? property.anyOf
382 : null;
383
384 if (!variantsRaw || variantsRaw.length === 0) {
385 return null;
386 }
387
388 const variants: EnumOption[] = [];
389 for (const variant of variantsRaw) {
390 const parsed = parseObject(variant);
391 if (!parsed || !("const" in parsed)) {
392 return null;
393 }
394 const value = parsed.const;
395 if (!["string", "number", "boolean"].includes(typeof value)) {
396 return null;
397 }
398 const title = typeof parsed.title === "string" ? parsed.title : undefined;
399 variants.push({ value: value as string | number | boolean, title });
400 }
401
402 return variants;
403}
404
405function toFieldSpec(
406 key: string,
407 property: Record<string, unknown>,
408 requiredSet: ReadonlySet<string>,
409): FormFieldSpec | null {
410 const type = typeof property.type === "string" ? property.type : undefined;
411 const options = parseEnumOptions(property);
412
413 if (type === "array") {
414 const items = parseObject(property.items);
415 const itemOptions = items ? parseEnumOptions(items) : null;
416 if (!itemOptions || itemOptions.length === 0) {
417 return null;
418 }
419 return {
420 key,
421 label: typeof property.title === "string" ? property.title.slice(0, 45) : key.slice(0, 45),
422 description: typeof property.description === "string" ? property.description : undefined,
423 required: requiredSet.has(key),
424 kind: "enum_multi",
425 options: itemOptions,
426 minItems: typeof property.minItems === "number" ? property.minItems : undefined,
427 maxItems: typeof property.maxItems === "number" ? property.maxItems : undefined,
428 defaultValue: Array.isArray(property.default)
429 ? property.default.filter((value) => ["string", "number", "boolean"].includes(typeof value)) as Array<string | number | boolean>
430 : undefined,
431 };
432 }
433
434 const kind: FormFieldKind | null = (() => {
435 if (options && options.length > 0) {
436 return "enum_single";
437 }
438 if (type === "string") return "string";
439 if (type === "number") return "number";
440 if (type === "integer") return "integer";
441 if (type === "boolean") return "boolean";
442 return null;
443 })();
444
445 if (!kind) {
446 return null;
447 }
448
449 const defaultValue = ["string", "number", "boolean"].includes(typeof property.default)
450 ? property.default as string | number | boolean
451 : undefined;
452
453 return {
454 key,
455 label: typeof property.title === "string" ? property.title.slice(0, 45) : key.slice(0, 45),
456 description: typeof property.description === "string" ? property.description : undefined,
457 required: requiredSet.has(key),
458 kind,
459 minLength: typeof property.minLength === "number" ? property.minLength : undefined,
460 maxLength: typeof property.maxLength === "number" ? property.maxLength : undefined,
461 pattern: typeof property.pattern === "string" ? property.pattern : undefined,
462 minimum: typeof property.minimum === "number" ? property.minimum : undefined,
463 maximum: typeof property.maximum === "number" ? property.maximum : undefined,
464 options: options ?? undefined,
465 defaultValue,
466 };
467}
468
469function parseFormSchema(requestedSchema?: Record<string, unknown>): FormSchemaSpec {
470 const schema = parseObject(requestedSchema);
471 if (!schema) {
472 return { fields: [], error: "Missing or invalid form schema." };
473 }
474
475 if (schema.type !== "object") {
476 return { fields: [], error: "Form schema must be an object." };
477 }
478
479 const properties = parseObject(schema.properties);
480 if (!properties) {
481 return { fields: [], error: "Form schema is missing properties." };
482 }
483
484 const requiredSet = new Set(
485 Array.isArray(schema.required)
486 ? schema.required.filter((value): value is string => typeof value === "string")
487 : [],
488 );
489
490 const keys = Object.keys(properties);
491 if (keys.length === 0) {
492 return { fields: [], error: "Form schema has no fields." };
493 }
494 if (keys.length > MAX_DISCORD_MODAL_FIELDS) {
495 return {
496 fields: [],
497 error: `Discord modals support up to ${MAX_DISCORD_MODAL_FIELDS} fields, but schema requested ${keys.length}.`,
498 };
499 }
500
501 const fields: FormFieldSpec[] = [];
502 for (const key of keys) {
503 const property = parseObject(properties[key]);
504 if (!property) {
505 return { fields: [], error: `Field '${key}' has an invalid schema.` };
506 }
507
508 const spec = toFieldSpec(key, property, requiredSet);
509 if (!spec) {
510 return { fields: [], error: `Field '${key}' uses an unsupported schema shape for Discord input.` };
511 }
512 fields.push(spec);
513 }
514
515 return { fields };
516}
517
518function enumOptionLabel(option: EnumOption): string {
519 if (option.title && option.title.trim().length > 0) {
520 return `${option.title} (${String(option.value)})`;
521 }
522 return String(option.value);
523}
524
525function toTextInputDefault(field: FormFieldSpec): string | undefined {
526 if (field.defaultValue === undefined) {
527 return undefined;
528 }
529 if (Array.isArray(field.defaultValue)) {
530 return field.defaultValue.map((value) => String(value)).join(", ");
531 }
532 return String(field.defaultValue);
533}
534
535function toTextInputPlaceholder(field: FormFieldSpec): string | undefined {
536 const optionsHint = field.options && field.options.length > 0
537 ? `Options: ${field.options.map(enumOptionLabel).join(", ")}`
538 : undefined;
539 const boolHint = field.kind === "boolean" ? "Enter true/false" : undefined;
540
541 const description = field.description?.trim();
542 const joined = [description, optionsHint, boolHint]
543 .filter((value): value is string => Boolean(value && value.length > 0))
544 .join(" | ");
545
546 if (!joined) {
547 return undefined;
548 }
549 return joined.slice(0, 100);
550}
551
552function createFormModalSession(elicitation: PendingElicitation, userId: string): {
553 modal?: ModalBuilder;
554 error?: string;
555} {
556 const parsed = parseFormSchema(elicitation.requestedSchema);
557 if (parsed.error) {
558 return { error: parsed.error };
559 }
560
561 const fields = parsed.fields;
562 if (fields.length === 0) {
563 return { error: "No supported fields were found in this form." };
564 }
565
566 const modalId = `${ELICITATION_FORM_MODAL_PREFIX}:${crypto.randomUUID()}`;
567 const modal = new ModalBuilder()
568 .setCustomId(modalId)
569 .setTitle("MCP Elicitation Form");
570
571 fields.forEach((field, index) => {
572 const input = new TextInputBuilder()
573 .setCustomId(`f_${index}`)
574 .setLabel(field.label)
575 .setStyle(TextInputStyle.Short)
576 .setRequired(field.required);
577
578 const defaultValue = toTextInputDefault(field);
579 if (defaultValue) {
580 input.setValue(defaultValue.slice(0, 4000));
581 }
582
583 const placeholder = toTextInputPlaceholder(field);
584 if (placeholder) {
585 input.setPlaceholder(placeholder);
586 }
587
588 if (field.kind === "string" && (field.maxLength ?? 0) > 120) {
589 input.setStyle(TextInputStyle.Paragraph);
590 }
591
592 modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
593 });
594
595 const timeout = setTimeout(() => {
596 formModalSessions.delete(modalId);
597 }, ELICITATION_FORM_SESSION_TIMEOUT_MS);
598
599 formModalSessions.set(modalId, {
600 requestId: elicitation.requestId,
601 elicitationRequestId: elicitation.id,
602 userId,
603 fields,
604 timeout,
605 });
606
607 return { modal };
608}
609
610function consumeFormModalSession(modalId: string): FormModalSession | null {
611 const session = formModalSessions.get(modalId);
612 if (!session) {
613 return null;
614 }
615 clearTimeout(session.timeout);
616 formModalSessions.delete(modalId);
617 return session;
618}
619
620function parseBooleanInput(raw: string): boolean | null {
621 const normalized = raw.trim().toLowerCase();
622 if (["true", "yes", "y", "1", "on"].includes(normalized)) {
623 return true;
624 }
625 if (["false", "no", "n", "0", "off"].includes(normalized)) {
626 return false;
627 }
628 return null;
629}
630
631function parseEnumValue(input: string, field: FormFieldSpec): { value?: string | number | boolean; error?: string } {
632 const options = field.options ?? [];
633 const trimmed = input.trim();
634 if (trimmed.length === 0) {
635 return { error: `Field '${field.key}' cannot be empty.` };
636 }
637
638 const matching = options.find((option) => {
639 const valueText = String(option.value);
640 const titleText = option.title ?? "";
641 return valueText === trimmed || titleText.toLowerCase() === trimmed.toLowerCase();
642 });
643
644 if (!matching) {
645 return {
646 error: `Field '${field.key}' must match one of: ${options.map(enumOptionLabel).join(", ")}`,
647 };
648 }
649
650 return { value: matching.value };
651}
652
653function parseFormFieldValue(field: FormFieldSpec, rawInput: string): { value?: unknown; error?: string; skip?: true } {
654 const raw = rawInput.trim();
655 if (raw.length === 0) {
656 if (field.required) {
657 return { error: `Field '${field.key}' is required.` };
658 }
659 return { skip: true };
660 }
661
662 switch (field.kind) {
663 case "string": {
664 if (field.minLength !== undefined && raw.length < field.minLength) {
665 return { error: `Field '${field.key}' must be at least ${field.minLength} characters.` };
666 }
667 if (field.maxLength !== undefined && raw.length > field.maxLength) {
668 return { error: `Field '${field.key}' must be at most ${field.maxLength} characters.` };
669 }
670 if (field.pattern) {
671 const regex = new RegExp(field.pattern);
672 if (!regex.test(raw)) {
673 return { error: `Field '${field.key}' does not match required format.` };
674 }
675 }
676 return { value: raw };
677 }
678
679 case "number":
680 case "integer": {
681 const parsed = Number(raw);
682 if (!Number.isFinite(parsed)) {
683 return { error: `Field '${field.key}' must be a number.` };
684 }
685 if (field.kind === "integer" && !Number.isInteger(parsed)) {
686 return { error: `Field '${field.key}' must be an integer.` };
687 }
688 if (field.minimum !== undefined && parsed < field.minimum) {
689 return { error: `Field '${field.key}' must be >= ${field.minimum}.` };
690 }
691 if (field.maximum !== undefined && parsed > field.maximum) {
692 return { error: `Field '${field.key}' must be <= ${field.maximum}.` };
693 }
694 return { value: parsed };
695 }
696
697 case "boolean": {
698 const parsed = parseBooleanInput(raw);
699 if (parsed === null) {
700 return { error: `Field '${field.key}' must be true/false.` };
701 }
702 return { value: parsed };
703 }
704
705 case "enum_single": {
706 return parseEnumValue(raw, field);
707 }
708
709 case "enum_multi": {
710 const valuesRaw = raw
711 .split(",")
712 .map((part) => part.trim())
713 .filter((part) => part.length > 0);
714
715 if (field.minItems !== undefined && valuesRaw.length < field.minItems) {
716 return { error: `Field '${field.key}' must include at least ${field.minItems} value(s).` };
717 }
718 if (field.maxItems !== undefined && valuesRaw.length > field.maxItems) {
719 return { error: `Field '${field.key}' must include at most ${field.maxItems} value(s).` };
720 }
721
722 const parsed: Array<string | number | boolean> = [];
723 for (const valueRaw of valuesRaw) {
724 const value = parseEnumValue(valueRaw, field);
725 if (value.error) {
726 return value;
727 }
728 parsed.push(value.value!);
729 }
730 return { value: parsed };
731 }
732 }
733}
734
735function parseFormModalValues(
736 fields: readonly FormFieldSpec[],
737 getFieldValue: (fieldId: string) => string,
738): { content?: Record<string, unknown>; error?: string } {
739 const content: Record<string, unknown> = {};
740
741 for (const [index, field] of fields.entries()) {
742 const rawInput = getFieldValue(`f_${index}`);
743 const parsed = parseFormFieldValue(field, rawInput);
744 if (parsed.error) {
745 return { error: parsed.error };
746 }
747 if (!parsed.skip) {
748 content[field.key] = parsed.value;
749 }
750 }
751
752 return { content };
753}
754
755function createElicitationButtons(elicitation: PendingElicitation) {
756 const row = new ActionRowBuilder<ButtonBuilder>();
757
758 if (elicitation.mode === "url") {
759 if (elicitation.url) {
760 try {
761 row.addComponents(
762 new ButtonBuilder()
763 .setLabel("Open URL")
764 .setStyle(ButtonStyle.Link)
765 .setURL(elicitation.url),
766 );
767 } catch {
768 // Ignore malformed URLs and keep decision buttons available.
769 }
770 }
771
772 row.addComponents(
773 new ButtonBuilder()
774 .setCustomId(buildElicitationButtonId("accept", elicitation.requestId, elicitation.id))
775 .setLabel("Approve")
776 .setStyle(ButtonStyle.Success),
777 );
778 }
779
780 if (elicitation.mode === "form") {
781 row.addComponents(
782 new ButtonBuilder()
783 .setCustomId(buildElicitationButtonId("form", elicitation.requestId, elicitation.id))
784 .setLabel("Fill Form")
785 .setStyle(ButtonStyle.Primary),
786 );
787 }
788
789 row.addComponents(
790 new ButtonBuilder()
791 .setCustomId(buildElicitationButtonId("decline", elicitation.requestId, elicitation.id))
792 .setLabel("Deny")
793 .setStyle(ButtonStyle.Danger),
794 new ButtonBuilder()
795 .setCustomId(buildElicitationButtonId("cancel", elicitation.requestId, elicitation.id))
796 .setLabel("Cancel")
797 .setStyle(ButtonStyle.Secondary),
798 );
799
800 return [row];
801}
802
803function formatElicitationMessage(elicitation: PendingElicitation): string {
804 const lines = [
805 `MCP elicitation request (${elicitation.mode})`,
806 elicitation.message,
807 ];
808
809 if (elicitation.mode === "url" && elicitation.url) {
810 lines.push(`Open URL if approved: ${elicitation.url}`);
811 }
812
813 if (elicitation.mode === "form") {
814 lines.push("Click Fill Form to provide values, or deny/cancel.");
815 }
816
817 return lines.join("\n");
818}
819
820async function runPromptWithElicitations(
821 identity: ChatIdentity,
822 prompt: string,
823 onElicitation: (elicitation: PendingElicitation) => Promise<void>,
824) {
825 const requestId = randomRequestId();
826 let done = false;
827 const seen = new Set<string>();
828
829 const runPromise = runPrompt(identity, prompt, requestId);
830 const pollPromise = (async () => {
831 while (!done) {
832 try {
833 const pending = await getPendingElicitation(identity, requestId);
834 if (pending && !seen.has(pending.id)) {
835 seen.add(pending.id);
836 rememberElicitation(pending);
837 await onElicitation(pending);
838 }
839 } catch {
840 // Best effort polling: do not interrupt active prompt run.
841 }
842
843 await Bun.sleep(ELICITATION_POLL_INTERVAL_MS);
844 }
845 })();
846
847 try {
848 return await runPromise;
849 } finally {
850 done = true;
851 await pollPromise.catch(() => {});
852 forgetRequestElicitations(requestId);
853 }
854}
855
203function contextLabel(context: {856function contextLabel(context: {
204 source: string;857 source: string;
205 workspaceId: string;858 workspaceId: string;
206 accountId?: string;859 accountId?: string;
207 sessionId?: string;860 sessionId?: string;
208 hasAccessToken: boolean;861 hasAccessToken: boolean;
862 hasApiKey?: boolean;
209}) {863}) {
864 const authMode = context.hasApiKey ? "api-key" : context.hasAccessToken ? "token" : "session";
210 return [865 return [
211 `source=${context.source}`,866 `source=${context.source}`,
212 `workspace=${context.workspaceId}`,867 `workspace=${context.workspaceId}`,
213 context.accountId ? `account=${context.accountId}` : undefined,868 context.accountId ? `account=${context.accountId}` : undefined,
214 context.sessionId ? `session=${context.sessionId}` : undefined,869 context.sessionId ? `session=${context.sessionId}` : undefined,
215 context.hasAccessToken ? "auth=token" : "auth=session",870 `auth=${authMode}`,
216 ]871 ]
217 .filter(Boolean)872 .filter(Boolean)
218 .join(" | ");873 .join(" | ");
@@ -253,6 +908,33 @@ function createWorkosLinkModal() { · 34 unmodified lines
253 return modal;908 return modal;
254}909}
255 910
911function createAnonymousLinkModal() {
912 const modal = new ModalBuilder()
913 .setCustomId(ANON_LINK_MODAL_ID)
914 .setTitle("Link Anonymous MCP Context");
915
916 const workspaceInput = new TextInputBuilder()
917 .setCustomId(ANON_LINK_FIELD_WORKSPACE)
918 .setLabel("Workspace ID")
919 .setStyle(TextInputStyle.Short)
920 .setRequired(true)
921 .setPlaceholder("Paste workspace id from dashboard");
922
923 const apiKeyInput = new TextInputBuilder()
924 .setCustomId(ANON_LINK_FIELD_API_KEY)
925 .setLabel("MCP API Key")
926 .setStyle(TextInputStyle.Paragraph)
927 .setRequired(true)
928 .setPlaceholder("Paste API key from dashboard");
929
930 modal.addComponents(
931 new ActionRowBuilder<TextInputBuilder>().addComponents(workspaceInput),
932 new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
933 );
934
935 return modal;
936}
937
256function buildExecutorLink(identity: ChatIdentity): string | null {938function buildExecutorLink(identity: ChatIdentity): string | null {
257 try {939 try {
258 const url = new URL(EXECUTOR_WEB_URL);940 const url = new URL(EXECUTOR_WEB_URL);
@@ -329,9 +1011,9 @@ async function handlePrefixCommand(message: Message<boolean>, rawText: string) { · 70 unmodified lines
329 "- /ask <prompt>",1011 "- /ask <prompt>",
330 "- /whoami",1012 "- /whoami",
331 "- /link-workos (opens secure modal)",1013 "- /link-workos (opens secure modal)",
332 "- /link-anon [session_id]",1014 "- /link-anon (paste workspace + API key)",
333 "- /unlink",1015 "- /unlink",
334 `Prefix fallback: ${COMMAND_PREFIX}whoami, ${COMMAND_PREFIX}link-anon [sessionId], ${COMMAND_PREFIX}unlink`,1016 `Prefix fallback: ${COMMAND_PREFIX}whoami, ${COMMAND_PREFIX}link-anon <workspaceId> <apiKey>, ${COMMAND_PREFIX}unlink`,
335 ].join("\n"));1017 ].join("\n"));
336 return;1018 return;
337 }1019 }
@@ -349,8 +1031,12 @@ async function handlePrefixCommand(message: Message<boolean>, rawText: string) { · 11 unmodified lines
349 }1031 }
350 1032
351 if (command === "link-anon") {1033 if (command === "link-anon") {
352 const [sessionId] = args;1034 const [workspaceId, apiKey] = args;
353 const context = await linkAnonymous(identity, sessionId);1035 if (!workspaceId || !apiKey) {
1036 await reply(message, `Usage: ${COMMAND_PREFIX}link-anon <workspaceId> <apiKey>`);
1037 return;
1038 }
1039 const context = await linkAnonymous(identity, workspaceId, apiKey);
354 await reply(message, `Linked anonymous context: ${contextLabel(context)}`);1040 await reply(message, `Linked anonymous context: ${contextLabel(context)}`);
355 return;1041 return;
356 }1042 }
@@ -381,7 +1067,13 @@ bot.on("interactionCreate", async (interaction) => { · 24 unmodified lines
381 if (interaction.commandName === "ask") {1067 if (interaction.commandName === "ask") {
382 const prompt = interaction.options.getString("prompt", true);1068 const prompt = interaction.options.getString("prompt", true);
383 await interaction.deferReply();1069 await interaction.deferReply();
384 const result = await runPrompt(identity, prompt);1070 const result = await runPromptWithElicitations(identity, prompt, async (elicitation) => {
1071 await interaction.followUp({
1072 content: formatElicitationMessage(elicitation),
1073 components: createElicitationButtons(elicitation),
1074 ephemeral: true,
1075 });
1076 });
385 const chunks = chunkMessage(result.text || "Done.");1077 const chunks = chunkMessage(result.text || "Done.");
386 await interaction.editReply(chunks[0] ?? "Done.");1078 await interaction.editReply(chunks[0] ?? "Done.");
387 for (let i = 1; i < chunks.length; i += 1) {1079 for (let i = 1; i < chunks.length; i += 1) {
@@ -409,12 +1101,7 @@ bot.on("interactionCreate", async (interaction) => { · 21 unmodified lines
409 }1101 }
410 1102
411 if (interaction.commandName === "link-anon") {1103 if (interaction.commandName === "link-anon") {
412 const sessionId = interaction.options.getString("session_id") ?? undefined;1104 await interaction.showModal(createAnonymousLinkModal());
413 const context = await linkAnonymous(identity, sessionId);
414 await interaction.reply({
415 content: `Linked anonymous context: ${contextLabel(context)}`,
416 ephemeral: true,
417 });
418 return;1105 return;
419 }1106 }
420 1107
@@ -452,6 +1139,141 @@ bot.on("interactionCreate", async (interaction) => { · 31 unmodified lines
452 return;1139 return;
453 }1140 }
454 1141
1142 if (interaction.isModalSubmit() && interaction.customId === ANON_LINK_MODAL_ID) {
1143 const identity = identityFromUser(interaction.user.id);
1144 const workspaceId = interaction.fields.getTextInputValue(ANON_LINK_FIELD_WORKSPACE).trim();
1145 const apiKey = interaction.fields.getTextInputValue(ANON_LINK_FIELD_API_KEY).trim();
1146
1147 if (!workspaceId || !apiKey) {
1148 await interaction.reply({
1149 content: "Workspace ID and API key are required.",
1150 ephemeral: true,
1151 });
1152 return;
1153 }
1154
1155 await interaction.deferReply({ ephemeral: true });
1156 const context = await linkAnonymous(identity, workspaceId, apiKey);
1157 await interaction.editReply(`Linked anonymous context: ${contextLabel(context)}`);
1158 return;
1159 }
1160
1161 if (interaction.isModalSubmit() && interaction.customId.startsWith(`${ELICITATION_FORM_MODAL_PREFIX}:`)) {
1162 const session = consumeFormModalSession(interaction.customId);
1163 if (!session) {
1164 await interaction.reply({
1165 content: "This elicitation form has expired. Click Fill Form again.",
1166 ephemeral: true,
1167 });
1168 return;
1169 }
1170
1171 if (session.userId !== interaction.user.id) {
1172 await interaction.reply({
1173 content: "This form belongs to a different user.",
1174 ephemeral: true,
1175 });
1176 return;
1177 }
1178
1179 const parsed = parseFormModalValues(
1180 session.fields,
1181 (fieldId) => interaction.fields.getTextInputValue(fieldId),
1182 );
1183 if (parsed.error || !parsed.content) {
1184 await interaction.reply({
1185 content: parsed.error ?? "Invalid form data. Click Fill Form and try again.",
1186 ephemeral: true,
1187 });
1188 return;
1189 }
1190
1191 await interaction.deferReply({ ephemeral: true });
1192 const identity = identityFromUser(interaction.user.id);
1193 const resolution = await respondToElicitation(
1194 identity,
1195 session.requestId,
1196 session.elicitationRequestId,
1197 "accept",
1198 parsed.content,
1199 );
1200
1201 if (!resolution.ok) {
1202 await interaction.editReply(resolution.error ?? "Unable to resolve elicitation.");
1203 return;
1204 }
1205
1206 forgetElicitation(session.requestId, session.elicitationRequestId);
1207 await interaction.editReply("Elicitation accepted.");
1208 return;
1209 }
1210
1211 if (interaction.isButton() && interaction.customId.startsWith(`${ELICITATION_BUTTON_PREFIX}:`)) {
1212 const parsed = parseElicitationButtonId(interaction.customId);
1213 if (!parsed) {
1214 await interaction.reply({
1215 content: "Invalid elicitation action.",
1216 ephemeral: true,
1217 });
1218 return;
1219 }
1220
1221 if (parsed.action === "form") {
1222 const elicitation = getKnownElicitation(parsed.requestId, parsed.elicitationRequestId);
1223 if (!elicitation) {
1224 await interaction.reply({
1225 content: "This elicitation request is no longer available.",
1226 ephemeral: true,
1227 });
1228 return;
1229 }
1230
1231 if (elicitation.mode !== "form") {
1232 await interaction.reply({
1233 content: "This elicitation is not a form request.",
1234 ephemeral: true,
1235 });
1236 return;
1237 }
1238
1239 const modal = createFormModalSession(elicitation, interaction.user.id);
1240 if (!modal.modal) {
1241 await interaction.reply({
1242 content: modal.error ?? "Unable to render this form in Discord.",
1243 ephemeral: true,
1244 });
1245 return;
1246 }
1247
1248 await interaction.showModal(modal.modal);
1249 return;
1250 }
1251
1252 const identity = identityFromUser(interaction.user.id);
1253 const resolution = await respondToElicitation(
1254 identity,
1255 parsed.requestId,
1256 parsed.elicitationRequestId,
1257 parsed.action,
1258 );
1259
1260 if (!resolution.ok) {
1261 await interaction.reply({
1262 content: resolution.error ?? "Unable to resolve elicitation.",
1263 ephemeral: true,
1264 });
1265 return;
1266 }
1267
1268 forgetElicitation(parsed.requestId, parsed.elicitationRequestId);
1269
1270 await interaction.update({
1271 content: `Elicitation ${parsed.action}.`,
1272 components: [],
1273 });
1274 return;
1275 }
1276
455 if (interaction.isButton() && interaction.customId === LINK_OPEN_MODAL_BUTTON_ID) {1277 if (interaction.isButton() && interaction.customId === LINK_OPEN_MODAL_BUTTON_ID) {
456 await interaction.showModal(createWorkosLinkModal());1278 await interaction.showModal(createWorkosLinkModal());
457 return;1279 return;
@@ -504,7 +1326,13 @@ bot.on("messageCreate", async (message) => { · 46 unmodified lines
504 }1326 }
505 1327
506 await message.channel.sendTyping();1328 await message.channel.sendTyping();
507 const result = await runPrompt(identityFromUser(message.author.id), prompt);1329 const identity = identityFromUser(message.author.id);
1330 const result = await runPromptWithElicitations(identity, prompt, async (elicitation) => {
1331 await message.reply({
1332 content: formatElicitationMessage(elicitation),
1333 components: createElicitationButtons(elicitation),
1334 });
1335 });
508 await reply(message, result.text || "Done.");1336 await reply(message, result.text || "Done.");
509 } catch (error) {1337 } catch (error) {
510 const messageText = error instanceof Error ? error.message : String(error);1338 const messageText = error instanceof Error ? error.message : String(error);
assistant/packages/core/src/agent.ts+92−6
@@ -4,6 +4,10 @@ · 3 unmodified lines
4 4
5import { Client } from "@modelcontextprotocol/sdk/client/index.js";5import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7import {
8 ElicitRequestSchema,
9 ElicitationCompleteNotificationSchema,
10} from "@modelcontextprotocol/sdk/types.js";
7import {11import {
8 completeSimple,12 completeSimple,
9 getModel,13 getModel,
@@ -51,6 +55,23 @@ interface Message { · 41 unmodified lines
51 toolCallId?: string;55 toolCallId?: string;
52}56}
53 57
58export interface AgentElicitationRequest {
59 readonly mode: "form" | "url";
60 readonly message: string;
61 readonly requestedSchema?: Record<string, unknown>;
62 readonly url?: string;
63 readonly elicitationId?: string;
64}
65
66export interface AgentElicitationResponse {
67 readonly action: "accept" | "decline" | "cancel";
68 readonly content?: Record<string, unknown>;
69}
70
71export interface AgentElicitationCompleteNotification {
72 readonly elicitationId: string;
73}
74
54interface McpTool {75interface McpTool {
55 name: string;76 name: string;
56 description?: string;77 description?: string;
@@ -81,10 +102,14 @@ export interface AgentOptions { · 24 unmodified lines
81 readonly clientId?: string;102 readonly clientId?: string;
82 readonly sessionId?: string;103 readonly sessionId?: string;
83 readonly mcpAccessToken?: string;104 readonly mcpAccessToken?: string;
105 readonly mcpApiKey?: string;
106 readonly useAnonymousMcp?: boolean;
84 readonly apiKey?: string;107 readonly apiKey?: string;
85 readonly modelId?: string;108 readonly modelId?: string;
86 readonly context?: string;109 readonly context?: string;
87 readonly maxToolCalls?: number;110 readonly maxToolCalls?: number;
111 readonly onElicitation?: (request: AgentElicitationRequest) => Promise<AgentElicitationResponse>;
112 readonly onElicitationComplete?: (notification: AgentElicitationCompleteNotification) => void;
88}113}
89 114
90export interface AgentResult {115export interface AgentResult {
@@ -156,10 +181,14 @@ export function createAgent(options: AgentOptions) { · 65 unmodified lines
156 clientId,181 clientId,
157 sessionId,182 sessionId,
158 mcpAccessToken,183 mcpAccessToken,
184 mcpApiKey,
185 useAnonymousMcp = false,
159 apiKey,186 apiKey,
160 modelId = "claude-sonnet-4-5",187 modelId = "claude-sonnet-4-5",
161 context,188 context,
162 maxToolCalls = 20,189 maxToolCalls = 20,
190 onElicitation,
191 onElicitationComplete,
163 } = options;192 } = options;
164 193
165 const resolvedApiKey = resolveApiKey(apiKey);194 const resolvedApiKey = resolveApiKey(apiKey);
@@ -194,17 +223,23 @@ export function createAgent(options: AgentOptions) { · 28 unmodified lines
194 223
195 // Connect to executor MCP224 // Connect to executor MCP
196 emit({ type: "status", message: "Connecting..." });225 emit({ type: "status", message: "Connecting..." });
197 const mcpUrl = new URL(`${executorBaseUrl}/mcp`);226 const mcpPath = useAnonymousMcp ? "/mcp/anonymous" : "/mcp";
227 const mcpUrl = new URL(`${executorBaseUrl}${mcpPath}`);
198 mcpUrl.searchParams.set("workspaceId", workspaceId);228 mcpUrl.searchParams.set("workspaceId", workspaceId);
199 if (accountId) mcpUrl.searchParams.set("accountId", accountId);229 if (!useAnonymousMcp && accountId) mcpUrl.searchParams.set("accountId", accountId);
200 if (clientId) mcpUrl.searchParams.set("clientId", clientId);230 if (clientId) mcpUrl.searchParams.set("clientId", clientId);
201 if (sessionId) mcpUrl.searchParams.set("sessionId", sessionId);231 if (!useAnonymousMcp && sessionId) mcpUrl.searchParams.set("sessionId", sessionId);
202 232
203 const transport = mcpAccessToken233 const transport = (mcpAccessToken || mcpApiKey)
204 ? new StreamableHTTPClientTransport(mcpUrl, {234 ? new StreamableHTTPClientTransport(mcpUrl, {
205 fetch: async (input, init) => {235 fetch: async (input, init) => {
206 const headers = new Headers(init?.headers);236 const headers = new Headers(init?.headers);
207 headers.set("authorization", `Bearer ${mcpAccessToken}`);237 if (mcpAccessToken) {
238 headers.set("authorization", `Bearer ${mcpAccessToken}`);
239 }
240 if (mcpApiKey) {
241 headers.set("x-api-key", mcpApiKey);
242 }
208 return await fetch(input, {243 return await fetch(input, {
209 ...init,244 ...init,
210 headers,245 headers,
@@ -212,7 +247,58 @@ export function createAgent(options: AgentOptions) { · 1 unmodified line
212 },247 },
213 })248 })
214 : new StreamableHTTPClientTransport(mcpUrl);249 : new StreamableHTTPClientTransport(mcpUrl);
215 const mcp = new Client({ name: "assistant-agent", version: "0.1.0" });250 const mcp = new Client(
251 { name: "assistant-agent", version: "0.1.0" },
252 {
253 capabilities: {
254 elicitation: {
255 form: {},
256 url: {},
257 },
258 },
259 },
260 );
261
262 mcp.setRequestHandler(ElicitRequestSchema, async (request) => {
263 const params = request.params as {
264 mode?: "form" | "url";
265 message: string;
266 requestedSchema?: Record<string, unknown>;
267 url?: string;
268 elicitationId?: string;
269 };
270 const mode = params.mode ?? "form";
271
272 emit({ type: "status", message: `Awaiting ${mode} elicitation...` });
273
274 if (!onElicitation) {
275 return { action: "decline" as const };
276 }
277
278 const response = await onElicitation({
279 mode,
280 message: params.message,
281 requestedSchema: params.requestedSchema,
282 url: params.url,
283 elicitationId: params.elicitationId,
284 });
285
286 if (response.content === undefined) {
287 return { action: response.action };
288 }
289
290 return {
291 action: response.action,
292 content: response.content,
293 };
294 });
295
296 mcp.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) => {
297 emit({ type: "status", message: "Elicitation completed on server." });
298 onElicitationComplete?.({
299 elicitationId: notification.params.elicitationId,
300 });
301 });
216 302
217 let mcpConnected = false;303 let mcpConnected = false;
218 try {304 try {
assistant/packages/core/src/index.ts+7−1
@@ -1,3 +1,9 @@
1export { createAgent } from "./agent";1export { createAgent } from "./agent";
2export type { AgentOptions, AgentResult } from "./agent";2export type {
3 AgentOptions,
4 AgentResult,
5 AgentElicitationRequest,
6 AgentElicitationResponse,
7 AgentElicitationCompleteNotification,
8} from "./agent";
3export type { TaskEvent, TaskStatusEvent, TaskCodeGeneratedEvent, TaskCodeResultEvent, TaskAgentMessageEvent, TaskErrorEvent, TaskCompletedEvent } from "./events";9export type { TaskEvent, TaskStatusEvent, TaskCodeGeneratedEvent, TaskCodeResultEvent, TaskAgentMessageEvent, TaskErrorEvent, TaskCompletedEvent } from "./events";
assistant/packages/server/src/link-store.ts+1−0
@@ -10,6 +10,7 @@ export interface LinkedMcpContext { · 9 unmodified lines
10 readonly accountId?: string;10 readonly accountId?: string;
11 readonly sessionId?: string;11 readonly sessionId?: string;
12 readonly accessToken?: string;12 readonly accessToken?: string;
13 readonly mcpApiKey?: string;
13 readonly clientId?: string;14 readonly clientId?: string;
14 readonly linkedAt: number;15 readonly linkedAt: number;
15}16}
assistant/packages/server/src/routes.ts+288−9
@@ -1,5 +1,9 @@
1import { Elysia, t } from "elysia";1import { Elysia, t } from "elysia";
2import { createAgent } from "@assistant/core";2import {
3 createAgent,
4 type AgentElicitationRequest,
5 type AgentElicitationResponse,
6} from "@assistant/core";
3import { ConvexHttpClient } from "convex/browser";7import { ConvexHttpClient } from "convex/browser";
4import { api } from "@executor/database/convex/_generated/api";8import { api } from "@executor/database/convex/_generated/api";
5import type { Id } from "@executor/database/convex/_generated/dataModel";9import type { Id } from "@executor/database/convex/_generated/dataModel";
@@ -24,10 +28,37 @@ interface ResolvedExecutorContext { · 18 unmodified lines
24 readonly sessionId?: string;28 readonly sessionId?: string;
25 readonly clientId?: string;29 readonly clientId?: string;
26 readonly mcpAccessToken?: string;30 readonly mcpAccessToken?: string;
31 readonly mcpApiKey?: string;
32 readonly mcpApiKeyError?: string;
33 readonly useAnonymousMcp: boolean;
27 readonly linked: boolean;34 readonly linked: boolean;
28 readonly source: "anonymous" | "linked_anonymous" | "linked_workos";35 readonly source: "anonymous" | "linked_anonymous" | "linked_workos";
29}36}
30 37
38interface PendingElicitation {
39 readonly id: string;
40 readonly identityKey: string;
41 readonly requestId: string;
42 readonly mode: "form" | "url";
43 readonly message: string;
44 readonly requestedSchema?: Record<string, unknown>;
45 readonly url?: string;
46 readonly elicitationId?: string;
47 readonly createdAt: number;
48}
49
50interface PendingElicitationEntry extends PendingElicitation {
51 readonly resolve: (response: AgentElicitationResponse) => void;
52 readonly timeout: ReturnType<typeof setTimeout>;
53}
54
55interface ResolvePendingElicitationResult {
56 readonly ok: boolean;
57 readonly error?: string;
58}
59
60const ELICITATION_TIMEOUT_MS = 10 * 60 * 1000;
61
31function normalizeIdentityPart(value: string): string {62function normalizeIdentityPart(value: string): string {
32 return value63 return value
33 .toLowerCase()64 .toLowerCase()
@@ -51,6 +82,142 @@ export function createApp(options: ServerOptions) { · 17 unmodified lines
51 const convex = new ConvexHttpClient(options.convexUrl);82 const convex = new ConvexHttpClient(options.convexUrl);
52 const linkStore = createFileLinkStore(options.linksFile);83 const linkStore = createFileLinkStore(options.linksFile);
53 const defaultClientId = options.defaultClientId?.trim() || "assistant-chat";84 const defaultClientId = options.defaultClientId?.trim() || "assistant-chat";
85 const pendingElicitations = new Map<string, PendingElicitationEntry>();
86 const pendingByIdentity = new Map<string, string[]>();
87
88 function removePendingFromQueue(identity: string, id: string) {
89 const existing = pendingByIdentity.get(identity);
90 if (!existing || existing.length === 0) {
91 return;
92 }
93
94 const next = existing.filter((candidate) => candidate !== id);
95 if (next.length === 0) {
96 pendingByIdentity.delete(identity);
97 return;
98 }
99 pendingByIdentity.set(identity, next);
100 }
101
102 function nextPending(identity: ChatIdentity, requestId?: string): PendingElicitation | null {
103 const key = identityKey(identity);
104 const queue = pendingByIdentity.get(key);
105 if (!queue || queue.length === 0) {
106 return null;
107 }
108
109 while (queue.length > 0) {
110 const id = queue[0]!;
111 const entry = pendingElicitations.get(id);
112 if (!entry) {
113 queue.shift();
114 continue;
115 }
116
117 if (requestId && entry.requestId !== requestId) {
118 const index = queue.findIndex((candidate) => {
119 const candidateEntry = pendingElicitations.get(candidate);
120 return Boolean(candidateEntry) && candidateEntry?.requestId === requestId;
121 });
122 if (index === -1) {
123 return null;
124 }
125 const candidateId = queue[index]!;
126 const candidateEntry = pendingElicitations.get(candidateId);
127 return candidateEntry ?? null;
128 }
129
130 return entry;
131 }
132
133 pendingByIdentity.delete(key);
134 return null;
135 }
136
137 async function waitForElicitationResponse(
138 identity: ChatIdentity,
139 requestId: string,
140 request: AgentElicitationRequest,
141 ): Promise<AgentElicitationResponse> {
142 const key = identityKey(identity);
143 const id = crypto.randomUUID();
144 const createdAt = Date.now();
145
146 return await new Promise<AgentElicitationResponse>((resolve) => {
147 const resolveAndCleanup = (response: AgentElicitationResponse) => {
148 const entry = pendingElicitations.get(id);
149 if (!entry) {
150 return;
151 }
152 clearTimeout(entry.timeout);
153 pendingElicitations.delete(id);
154 removePendingFromQueue(key, id);
155 resolve(response);
156 };
157
158 const timeout = setTimeout(() => {
159 resolveAndCleanup({ action: "cancel" });
160 }, ELICITATION_TIMEOUT_MS);
161
162 const entry: PendingElicitationEntry = {
163 id,
164 identityKey: key,
165 requestId,
166 mode: request.mode,
167 message: request.message,
168 requestedSchema: request.requestedSchema,
169 url: request.url,
170 elicitationId: request.elicitationId,
171 createdAt,
172 resolve: resolveAndCleanup,
173 timeout,
174 };
175
176 pendingElicitations.set(id, entry);
177 const queue = pendingByIdentity.get(key) ?? [];
178 queue.push(id);
179 pendingByIdentity.set(key, queue);
180 });
181 }
182
183 function resolvePendingElicitation(
184 identity: ChatIdentity,
185 args: {
186 requestId?: string;
187 elicitationRequestId: string;
188 action: AgentElicitationResponse["action"];
189 content?: Record<string, unknown>;
190 },
191 ): ResolvePendingElicitationResult {
192 const entry = pendingElicitations.get(args.elicitationRequestId);
193 if (!entry) {
194 return { ok: false, error: "Elicitation request not found or already resolved." };
195 }
196
197 const key = identityKey(identity);
198 if (entry.identityKey !== key) {
199 return { ok: false, error: "Elicitation request does not belong to this user." };
200 }
201 if (args.requestId && entry.requestId !== args.requestId) {
202 return { ok: false, error: "Elicitation request id does not match the active prompt." };
203 }
204
205 if (entry.mode === "url" && args.action === "accept") {
206 entry.resolve({ action: "accept" });
207 return { ok: true };
208 }
209
210 if (entry.mode === "form" && args.action === "accept") {
211 if (!args.content) {
212 return { ok: false, error: "Form elicitation requires content." };
213 }
214 entry.resolve({ action: "accept", content: args.content });
215 return { ok: true };
216 }
217
218 entry.resolve({ action: args.action });
219 return { ok: true };
220 }
54 221
55 async function resolveContext(identity: ChatIdentity): Promise<ResolvedExecutorContext> {222 async function resolveContext(identity: ChatIdentity): Promise<ResolvedExecutorContext> {
56 const key = identityKey(identity);223 const key = identityKey(identity);
@@ -63,17 +230,40 @@ export function createApp(options: ServerOptions) { · 6 unmodified lines
63 sessionId: linked.sessionId,230 sessionId: linked.sessionId,
64 clientId: linked.clientId ?? defaultClientId,231 clientId: linked.clientId ?? defaultClientId,
65 mcpAccessToken: linked.accessToken,232 mcpAccessToken: linked.accessToken,
233 useAnonymousMcp: false,
66 linked: true,234 linked: true,
67 source: "linked_workos",235 source: "linked_workos",
68 };236 };
69 }237 }
70 238
239 if (linked?.provider === "anonymous" && linked.mcpApiKey) {
240 return {
241 workspaceId: linked.workspaceId,
242 accountId: linked.accountId,
243 sessionId: linked.sessionId,
244 clientId: linked.clientId ?? defaultClientId,
245 mcpApiKey: linked.mcpApiKey,
246 useAnonymousMcp: true,
247 linked: true,
248 source: "linked_anonymous",
249 };
250 }
251
71 const requestedSessionId = linked?.sessionId ?? defaultSessionId(identity);252 const requestedSessionId = linked?.sessionId ?? defaultSessionId(identity);
72 const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {253 const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
73 sessionId: requestedSessionId,254 sessionId: requestedSessionId,
74 });255 });
75 256
76 const clientId = linked?.clientId ?? defaultClientId;257 const clientId = linked?.clientId ?? defaultClientId;
258 const anonymousMcpApiKey = await convex.query(api.workspace.getMcpApiKey, {
259 workspaceId: anonymous.workspaceId,
260 sessionId: anonymous.sessionId,
261 });
262
263 const mcpApiKey = anonymousMcpApiKey?.enabled ? anonymousMcpApiKey.apiKey ?? undefined : undefined;
264 const mcpApiKeyError = anonymousMcpApiKey?.enabled
265 ? undefined
266 : anonymousMcpApiKey?.error ?? "Anonymous MCP API key is unavailable";
77 267
78 if (linked?.provider === "anonymous") {268 if (linked?.provider === "anonymous") {
79 await linkStore.set(key, {269 await linkStore.set(key, {
@@ -81,6 +271,7 @@ export function createApp(options: ServerOptions) { · 1 unmodified line
81 workspaceId: anonymous.workspaceId,271 workspaceId: anonymous.workspaceId,
82 accountId: anonymous.accountId,272 accountId: anonymous.accountId,
83 sessionId: anonymous.sessionId,273 sessionId: anonymous.sessionId,
274 mcpApiKey,
84 clientId,275 clientId,
85 linkedAt: linked.linkedAt,276 linkedAt: linked.linkedAt,
86 });277 });
@@ -91,6 +282,9 @@ export function createApp(options: ServerOptions) { · 4 unmodified lines
91 accountId: anonymous.accountId,282 accountId: anonymous.accountId,
92 sessionId: anonymous.sessionId,283 sessionId: anonymous.sessionId,
93 clientId,284 clientId,
285 mcpApiKey,
286 mcpApiKeyError,
287 useAnonymousMcp: true,
94 linked: linked?.provider === "anonymous",288 linked: linked?.provider === "anonymous",
95 source: linked?.provider === "anonymous" ? "linked_anonymous" : "anonymous",289 source: linked?.provider === "anonymous" ? "linked_anonymous" : "anonymous",
96 };290 };
@@ -105,6 +299,7 @@ export function createApp(options: ServerOptions) { · 8 unmodified lines
105 linked: context.linked,299 linked: context.linked,
106 source: context.source,300 source: context.source,
107 hasAccessToken: Boolean(context.mcpAccessToken),301 hasAccessToken: Boolean(context.mcpAccessToken),
302 hasApiKey: Boolean(context.mcpApiKey),
108 };303 };
109 }304 }
110 305
@@ -155,16 +350,19 @@ export function createApp(options: ServerOptions) { · 44 unmodified lines
155 return { linked: true, context: toPublicContext(context) };350 return { linked: true, context: toPublicContext(context) };
156 }351 }
157 352
158 const requestedSessionId = body.sessionId?.trim() || defaultSessionId(identity);353 if (!body.workspaceId?.trim()) {
159 const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {354 throw new Error("workspaceId is required for anonymous links");
160 sessionId: requestedSessionId,355 }
161 });356 if (!body.apiKey?.trim()) {
357 throw new Error("apiKey is required for anonymous links");
358 }
162 359
163 const link: LinkedMcpContext = {360 const link: LinkedMcpContext = {
164 provider: "anonymous",361 provider: "anonymous",
165 workspaceId: anonymous.workspaceId,362 workspaceId: body.workspaceId as Id<"workspaces">,
166 accountId: anonymous.accountId,363 accountId: body.accountId?.trim() || undefined,
167 sessionId: anonymous.sessionId,364 sessionId: body.sessionId?.trim() || undefined,
365 mcpApiKey: body.apiKey.trim(),
168 clientId,366 clientId,
169 linkedAt,367 linkedAt,
170 };368 };
@@ -181,6 +379,7 @@ export function createApp(options: ServerOptions) { · 10 unmodified lines
181 accountId: t.Optional(t.String({ minLength: 1 })),379 accountId: t.Optional(t.String({ minLength: 1 })),
182 sessionId: t.Optional(t.String({ minLength: 1 })),380 sessionId: t.Optional(t.String({ minLength: 1 })),
183 accessToken: t.Optional(t.String({ minLength: 1 })),381 accessToken: t.Optional(t.String({ minLength: 1 })),
382 apiKey: t.Optional(t.String({ minLength: 1 })),
184 clientId: t.Optional(t.String({ minLength: 1 })),383 clientId: t.Optional(t.String({ minLength: 1 })),
185 }),384 }),
186 },385 },
@@ -200,13 +399,86 @@ export function createApp(options: ServerOptions) { · 13 unmodified lines
200 },399 },
201 )400 )
202 401
402 .post(
403 "/api/chat/elicitation/pending",
404 async ({ body }) => {
405 const identity = {
406 platform: body.platform,
407 userId: body.userId,
408 } satisfies ChatIdentity;
409
410 const pending = nextPending(identity, body.requestId?.trim() || undefined);
411 if (!pending) {
412 return { elicitation: null };
413 }
414
415 return {
416 elicitation: {
417 id: pending.id,
418 requestId: pending.requestId,
419 mode: pending.mode,
420 message: pending.message,
421 requestedSchema: pending.requestedSchema,
422 url: pending.url,
423 elicitationId: pending.elicitationId,
424 createdAt: pending.createdAt,
425 },
426 };
427 },
428 {
429 body: t.Object({
430 platform: t.String({ minLength: 1 }),
431 userId: t.String({ minLength: 1 }),
432 requestId: t.Optional(t.String({ minLength: 1 })),
433 }),
434 },
435 )
436
437 .post(
438 "/api/chat/elicitation/respond",
439 async ({ body }) => {
440 const identity = {
441 platform: body.platform,
442 userId: body.userId,
443 } satisfies ChatIdentity;
444
445 const resolution = resolvePendingElicitation(identity, {
446 requestId: body.requestId?.trim() || undefined,
447 elicitationRequestId: body.elicitationRequestId,
448 action: body.action,
449 content: body.content,
450 });
451
452 return resolution;
453 },
454 {
455 body: t.Object({
456 platform: t.String({ minLength: 1 }),
457 userId: t.String({ minLength: 1 }),
458 requestId: t.Optional(t.String({ minLength: 1 })),
459 elicitationRequestId: t.String({ minLength: 1 }),
460 action: t.Union([t.Literal("accept"), t.Literal("decline"), t.Literal("cancel")]),
461 content: t.Optional(t.Record(t.String(), t.Unknown())),
462 }),
463 },
464 )
465
203 .post(466 .post(
204 "/api/chat/run",467 "/api/chat/run",
205 async ({ body }) => {468 async ({ body }) => {
206 const context = await resolveContext({469 const identity = {
207 platform: body.platform,470 platform: body.platform,
208 userId: body.userId,471 userId: body.userId,
472 } satisfies ChatIdentity;
473 const context = await resolveContext({
474 platform: identity.platform,
475 userId: identity.userId,
209 });476 });
477 const requestId = body.requestId?.trim() || crypto.randomUUID();
478
479 if (context.useAnonymousMcp && !context.mcpApiKey) {
480 throw new Error(context.mcpApiKeyError ?? "Anonymous MCP API key is unavailable");
481 }
210 482
211 const agent = createAgent({483 const agent = createAgent({
212 executorUrl: options.executorUrl,484 executorUrl: options.executorUrl,
@@ -215,11 +487,17 @@ export function createApp(options: ServerOptions) { · 2 unmodified lines
215 sessionId: context.sessionId,487 sessionId: context.sessionId,
216 clientId: context.clientId,488 clientId: context.clientId,
217 mcpAccessToken: context.mcpAccessToken,489 mcpAccessToken: context.mcpAccessToken,
490 mcpApiKey: context.mcpApiKey,
491 useAnonymousMcp: context.useAnonymousMcp,
218 context: options.context,492 context: options.context,
493 onElicitation: async (request) => {
494 return await waitForElicitationResponse(identity, requestId, request);
495 },
219 });496 });
220 497
221 const result = await agent.run(body.prompt);498 const result = await agent.run(body.prompt);
222 return {499 return {
500 requestId,
223 text: result.text,501 text: result.text,
224 toolCalls: result.toolCalls,502 toolCalls: result.toolCalls,
225 context: toPublicContext(context),503 context: toPublicContext(context),
@@ -230,6 +508,7 @@ export function createApp(options: ServerOptions) { · 4 unmodified lines
230 platform: t.String({ minLength: 1 }),508 platform: t.String({ minLength: 1 }),
231 userId: t.String({ minLength: 1 }),509 userId: t.String({ minLength: 1 }),
232 prompt: t.String({ minLength: 1 }),510 prompt: t.String({ minLength: 1 }),
511 requestId: t.Optional(t.String({ minLength: 1 })),
233 }),512 }),
234 },513 },
235 );514 );
executor/apps/web/src/components/tasks/task-composer.tsx+7−5
@@ -20,7 +20,7 @@ import { FormattedCodeBlock } from "@/components/formatted/code-block"; · 19 unmodified lines
20import { convexApi } from "@/lib/convex-api";20import { convexApi } from "@/lib/convex-api";
21import { useSession } from "@/lib/session-context";21import { useSession } from "@/lib/session-context";
22import type { RuntimeTargetDescriptor } from "@/lib/types";22import type { RuntimeTargetDescriptor } from "@/lib/types";
23import { listRuntimeTargets } from "@/lib/runtime-targets";23import { listRuntimeTargetsWithOptions } from "@/lib/runtime-targets";
24import { useWorkspaceTools } from "@/hooks/use/workspace-tools";24import { useWorkspaceTools } from "@/hooks/use/workspace-tools";
25 25
26const DEFAULT_CODE = `// Example: discover tools and return matching tool names26const DEFAULT_CODE = `// Example: discover tools and return matching tool names
@@ -107,7 +107,7 @@ function formatExecutionValue(value: unknown): string { · 80 unmodified lines
107}107}
108 108
109export function TaskComposer() {109export function TaskComposer() {
110 const { context } = useSession();110 const { context, clientConfig } = useSession();
111 const [code, setCode] = useState(() => {111 const [code, setCode] = useState(() => {
112 return readCodeDraftForWorkspace(context?.workspaceId) ?? DEFAULT_CODE;112 return readCodeDraftForWorkspace(context?.workspaceId) ?? DEFAULT_CODE;
113 });113 });
@@ -131,7 +131,10 @@ export function TaskComposer() { · 17 unmodified lines
131 [storageWorkspaceId],131 [storageWorkspaceId],
132 );132 );
133 133
134 const runtimeTargets = useMemo(() => listRuntimeTargets(), []);134 const runtimeTargets = useMemo(
135 () => listRuntimeTargetsWithOptions({ allowLocalVm: clientConfig?.runtime?.allowLocalVm }),
136 [clientConfig?.runtime?.allowLocalVm],
137 );
135 const createTask = useAction(convexApi.executor.createTask);138 const createTask = useAction(convexApi.executor.createTask);
136 const { tools, typesUrl, loadingTools } = useWorkspaceTools(context ?? null);139 const { tools, typesUrl, loadingTools } = useWorkspaceTools(context ?? null);
137 const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId)140 const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId)
@@ -186,7 +189,6 @@ export function TaskComposer() { · 48 unmodified lines
186 timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS,189 timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS,
187 workspaceId: context.workspaceId,190 workspaceId: context.workspaceId,
188 sessionId: context.sessionId,191 sessionId: context.sessionId,
189 clientId: context.clientId,
190 waitForResult: true,192 waitForResult: true,
191 });193 });
192 194
@@ -221,7 +223,7 @@ export function TaskComposer() { · 28 unmodified lines
221 <CardContent className="space-y-4">223 <CardContent className="space-y-4">
222 <div className="grid grid-cols-2 gap-3">224 <div className="grid grid-cols-2 gap-3">
223 <div className="space-y-1.5">225 <div className="space-y-1.5">
224 <Label className="text-xs text-muted-foreground">Runtime</Label>226 {showRuntimeSelector ? <Label className="text-xs text-muted-foreground">Runtime</Label> : null}
225 {showRuntimeSelector ? (227 {showRuntimeSelector ? (
226 <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>228 <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>
227 <SelectTrigger className="h-8 text-xs font-mono bg-background">229 <SelectTrigger className="h-8 text-xs font-mono bg-background">
executor/apps/web/src/components/tools/policies.tsx+1039−77
@@ -49,10 +49,13 @@ import { useSession } from "@/lib/session-context"; · 48 unmodified lines
49import { convexApi } from "@/lib/convex-api";49import { convexApi } from "@/lib/convex-api";
50import { cn } from "@/lib/utils";50import { cn } from "@/lib/utils";
51import type {51import type {
52 AccessPolicyRecord,52 ToolPolicyRecord,
53 ArgumentCondition,53 ArgumentCondition,
54 ArgumentConditionOperator,54 ArgumentConditionOperator,
55 ToolDescriptor,55 ToolDescriptor,
56 ToolRoleBindingRecord,
57 ToolRoleRuleRecord,
58 ToolRoleRecord,
56} from "@/lib/types";59} from "@/lib/types";
57import type { Id } from "@executor/database/convex/_generated/dataModel";60import type { Id } from "@executor/database/convex/_generated/dataModel";
58import { workspaceQueryArgs } from "@/lib/workspace/query-args";61import { workspaceQueryArgs } from "@/lib/workspace/query-args";
@@ -61,6 +64,7 @@ import { sourceLabel } from "@/lib/tool/source-utils"; · 2 unmodified lines
61// ── Types ────────────────────────────────────────────────────────────────────64// ── Types ────────────────────────────────────────────────────────────────────
62 65
63type PolicyDecisionType = "allow" | "require_approval" | "deny";66type PolicyDecisionType = "allow" | "require_approval" | "deny";
67type PolicyResourceType = ToolPolicyRecord["resourceType"];
64 68
65interface ToolNamespace {69interface ToolNamespace {
66 prefix: string;70 prefix: string;
@@ -74,28 +78,92 @@ type PolicyScope = "personal" | "workspace" | "organization"; · 7 unmodified lines
74interface FormState {78interface FormState {
75 scope: PolicyScope;79 scope: PolicyScope;
76 decision: PolicyDecisionType;80 decision: PolicyDecisionType;
81 resourceType: PolicyResourceType;
77 selectedToolPaths: string[];82 selectedToolPaths: string[];
78 resourcePattern: string;83 resourcePattern: string;
84 sourcePattern: string;
85 namespacePattern: string;
79 argumentConditions: ArgumentCondition[];86 argumentConditions: ArgumentCondition[];
80 clientId: string;87 clientId: string;
81 priority: string;88 priority: string;
82}89}
83 90
91interface RoleFormState {
92 name: string;
93 description: string;
94}
95
96interface RoleRuleFormState {
97 selectorType: ToolRoleRuleRecord["selectorType"];
98 sourceKey: string;
99 resourcePattern: string;
100 matchType: ToolRoleRuleRecord["matchType"];
101 effect: ToolRoleRuleRecord["effect"];
102 approvalMode: ToolRoleRuleRecord["approvalMode"];
103 priority: string;
104}
105
106interface RoleBindingFormState {
107 scopeType: ToolRoleBindingRecord["scopeType"];
108 targetAccountId: string;
109 clientId: string;
110 status: ToolRoleBindingRecord["status"];
111}
112
113interface OrganizationMemberListItem {
114 accountId: string;
115 displayName: string;
116 email: string | null;
117 role: "owner" | "admin" | "member" | "billing_admin";
118 status: "active" | "pending" | "removed";
119}
120
84function defaultFormState(): FormState {121function defaultFormState(): FormState {
85 return {122 return {
86 scope: "personal",123 scope: "personal",
87 decision: "require_approval",124 decision: "require_approval",
125 resourceType: "tool_path",
88 selectedToolPaths: [],126 selectedToolPaths: [],
89 resourcePattern: "",127 resourcePattern: "",
128 sourcePattern: "",
129 namespacePattern: "",
90 argumentConditions: [],130 argumentConditions: [],
91 clientId: "",131 clientId: "",
92 priority: "100",132 priority: "100",
93 };133 };
94}134}
95 135
136function defaultRoleFormState(): RoleFormState {
137 return {
138 name: "",
139 description: "",
140 };
141}
142
143function defaultRoleRuleFormState(): RoleRuleFormState {
144 return {
145 selectorType: "tool_path",
146 sourceKey: "",
147 resourcePattern: "",
148 matchType: "glob",
149 effect: "allow",
150 approvalMode: "required",
151 priority: "100",
152 };
153}
154
155function defaultRoleBindingFormState(): RoleBindingFormState {
156 return {
157 scopeType: "workspace",
158 targetAccountId: "",
159 clientId: "",
160 status: "active",
161 };
162}
163
96// ── Helpers ──────────────────────────────────────────────────────────────────164// ── Helpers ──────────────────────────────────────────────────────────────────
97 165
98function getDecisionFromPolicy(policy: AccessPolicyRecord): PolicyDecisionType {166function getDecisionFromPolicy(policy: ToolPolicyRecord): PolicyDecisionType {
99 if (policy.effect === "deny") return "deny";167 if (policy.effect === "deny") return "deny";
100 return policy.approvalMode === "required" ? "require_approval" : "allow";168 return policy.approvalMode === "required" ? "require_approval" : "allow";
101}169}
@@ -106,7 +174,7 @@ function getDecisionPayload(decision: PolicyDecisionType) { · 4 unmodified lines
106 return { effect: "allow" as const, approvalMode: "auto" as const };174 return { effect: "allow" as const, approvalMode: "auto" as const };
107}175}
108 176
109function scopeLabel(policy: AccessPolicyRecord, currentAccountId?: string): string {177function scopeLabel(policy: ToolPolicyRecord, currentAccountId?: string): string {
110 if (policy.targetAccountId) {178 if (policy.targetAccountId) {
111 return policy.targetAccountId === currentAccountId ? "personal" : "user";179 return policy.targetAccountId === currentAccountId ? "personal" : "user";
112 }180 }
@@ -135,6 +203,28 @@ const DECISION_CONFIG: Record<PolicyDecisionType, { label: string; color: string · 22 unmodified lines
135 },203 },
136};204};
137 205
206const RESOURCE_TYPE_CONFIG: Record<
207 PolicyResourceType,
208 { label: string; description: string }
209> = {
210 all_tools: {
211 label: "All tools",
212 description: "Apply to every tool in this workspace",
213 },
214 source: {
215 label: "Source",
216 description: "Apply to all tools from one source",
217 },
218 namespace: {
219 label: "Namespace",
220 description: "Apply to a namespace prefix pattern",
221 },
222 tool_path: {
223 label: "Tool path",
224 description: "Apply to specific tools or path patterns",
225 },
226};
227
138const OPERATOR_LABELS: Record<ArgumentConditionOperator, string> = {228const OPERATOR_LABELS: Record<ArgumentConditionOperator, string> = {
139 equals: "equals",229 equals: "equals",
140 not_equals: "not equals",230 not_equals: "not equals",
@@ -159,13 +249,6 @@ function buildNamespaces(tools: ToolDescriptor[]): ToolNamespace[] { · 18 unmodified lines
159 return Array.from(nsMap.values()).sort((a, b) => a.prefix.localeCompare(b.prefix));249 return Array.from(nsMap.values()).sort((a, b) => a.prefix.localeCompare(b.prefix));
160}250}
161 251
162function patternMatchesToolPath(pattern: string, toolPath: string): boolean {
163 if (pattern === "*") return true;
164 if (!pattern.includes("*")) return pattern === toolPath;
165 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
166 return new RegExp(`^${escaped}$`).test(toolPath);
167}
168
169function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[]): string {252function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[]): string {
170 if (paths.length === 0) return "*";253 if (paths.length === 0) return "*";
171 if (paths.length === 1) return paths[0]!;254 if (paths.length === 1) return paths[0]!;
@@ -196,6 +279,77 @@ function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[] · 24 unmodified lines
196 return paths.join(", ");279 return paths.join(", ");
197}280}
198 281
282function matchesResourcePattern(pattern: string, candidate: string, matchType: "glob" | "exact"): boolean {
283 if (matchType === "exact") {
284 return pattern === candidate;
285 }
286 if (pattern === "*") {
287 return true;
288 }
289 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
290 return new RegExp(`^${escaped}$`).test(candidate);
291}
292
293function policyMatchesTool(policy: ToolPolicyRecord, tool: ToolDescriptor): boolean {
294 if (policy.resourceType === "all_tools") {
295 return true;
296 }
297 if (policy.resourceType === "source") {
298 if (!tool.source) {
299 return false;
300 }
301 return matchesResourcePattern(policy.resourcePattern, tool.source, policy.matchType);
302 }
303
304 return matchesResourcePattern(policy.resourcePattern, tool.path, policy.matchType);
305}
306
307function resourceTypeLabel(resourceType: ToolPolicyRecord["resourceType"]): string {
308 if (resourceType === "all_tools") return "all tools";
309 if (resourceType === "source") return "source";
310 if (resourceType === "namespace") return "namespace";
311 return "tool path";
312}
313
314function createToolPolicyId(): string {
315 return `tool_policy_${crypto.randomUUID()}`;
316}
317
318function toolPolicyRoleId(policyId: string): string {
319 return `tool_policy_role_${policyId}`;
320}
321
322function toolPolicyRuleId(policyId: string): string {
323 return `tool_policy_rule_${policyId}`;
324}
325
326function toolPolicyBindingId(policyId: string): string {
327 return `tool_policy_binding_${policyId}`;
328}
329
330function isDirectToolPolicy(policy: ToolPolicyRecord): boolean {
331 if (!policy.id.startsWith("tool_policy_")) {
332 return false;
333 }
334
335 if (!policy.roleId || !policy.ruleId || !policy.bindingId) {
336 return false;
337 }
338
339 return (
340 policy.roleId === toolPolicyRoleId(policy.id)
341 && policy.ruleId === toolPolicyRuleId(policy.id)
342 && policy.bindingId === toolPolicyBindingId(policy.id)
343 );
344}
345
346function roleRulePattern(rule: ToolRoleRuleRecord): string {
347 if (rule.selectorType === "all") return "*";
348 if (rule.selectorType === "source") return rule.sourceKey ?? "";
349 if (rule.selectorType === "namespace") return rule.namespacePattern ?? "";
350 return rule.toolPathPattern ?? "";
351}
352
199// ── Tool Picker (virtualized) ────────────────────────────────────────────────353// ── Tool Picker (virtualized) ────────────────────────────────────────────────
200 354
201type VirtualRow =355type VirtualRow =
@@ -573,12 +727,14 @@ function PolicyCard({ · 371 unmodified lines
573 tools,727 tools,
574 currentAccountId,728 currentAccountId,
575 onDelete,729 onDelete,
730 canDelete,
576 deleting,731 deleting,
577}: {732}: {
578 policy: AccessPolicyRecord;733 policy: ToolPolicyRecord;
579 tools: ToolDescriptor[];734 tools: ToolDescriptor[];
580 currentAccountId?: string;735 currentAccountId?: string;
581 onDelete: (id: string) => void;736 onDelete: (policy: ToolPolicyRecord) => void;
737 canDelete: boolean;
582 deleting: boolean;738 deleting: boolean;
583}) {739}) {
584 const decision = getDecisionFromPolicy(policy);740 const decision = getDecisionFromPolicy(policy);
@@ -586,9 +742,8 @@ function PolicyCard({ · 1 unmodified line
586 const Icon = config.icon;742 const Icon = config.icon;
587 743
588 const matchingTools = useMemo(() => {744 const matchingTools = useMemo(() => {
589 if (policy.resourcePattern === "*") return tools;745 return tools.filter((tool) => policyMatchesTool(policy, tool));
590 return tools.filter((t) => patternMatchesToolPath(policy.resourcePattern, t.path));746 }, [policy, tools]);
591 }, [policy.resourcePattern, tools]);
592 747
593 const [showTools, setShowTools] = useState(false);748 const [showTools, setShowTools] = useState(false);
594 749
@@ -616,6 +771,12 @@ function PolicyCard({ · 21 unmodified lines
616 >771 >
617 p{policy.priority}772 p{policy.priority}
618 </Badge>773 </Badge>
774 <Badge
775 variant="outline"
776 className="text-[9px] px-1.5 py-0 font-mono uppercase tracking-wider border-border/50"
777 >
778 {resourceTypeLabel(policy.resourceType)}
779 </Badge>
619 </div>780 </div>
620 {/* Pattern display */}781 {/* Pattern display */}
621 <div className="mt-1">782 <div className="mt-1">
@@ -663,14 +824,21 @@ function PolicyCard({ · 41 unmodified lines
663 <TooltipTrigger asChild>824 <TooltipTrigger asChild>
664 <button825 <button
665 type="button"826 type="button"
666 onClick={() => onDelete(policy.id)}827 onClick={() => onDelete(policy)}
667 disabled={deleting}828 disabled={deleting || !canDelete}
668 className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive shrink-0"829 className={cn(
830 "opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded shrink-0",
831 canDelete
832 ? "hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
833 : "text-muted-foreground/40 cursor-not-allowed",
834 )}
669 >835 >
670 <Trash2 className="h-3.5 w-3.5" />836 <Trash2 className="h-3.5 w-3.5" />
671 </button>837 </button>
672 </TooltipTrigger>838 </TooltipTrigger>
673 <TooltipContent side="left" className="text-xs">Delete policy</TooltipContent>839 <TooltipContent side="left" className="text-xs">
840 {canDelete ? "Delete policy" : "Role-managed policy (delete from roles)"}
841 </TooltipContent>
674 </Tooltip>842 </Tooltip>
675 </TooltipProvider>843 </TooltipProvider>
676 </div>844 </div>
@@ -688,33 +856,144 @@ export function PoliciesPanel({ · 11 unmodified lines
688 tools?: ToolDescriptor[];856 tools?: ToolDescriptor[];
689 loadingTools?: boolean;857 loadingTools?: boolean;
690}) {858}) {
691 const { context } = useSession();859 const { context, workspaces } = useSession();
692 const [dialogOpen, setDialogOpen] = useState(false);860 const [dialogOpen, setDialogOpen] = useState(false);
693 const [submitting, setSubmitting] = useState(false);861 const [submitting, setSubmitting] = useState(false);
694 const [deletingId, setDeletingId] = useState<string | null>(null);862 const [deletingId, setDeletingId] = useState<string | null>(null);
695 const [form, setForm] = useState<FormState>(defaultFormState);863 const [form, setForm] = useState<FormState>(defaultFormState);
864 const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
865 const [roleForm, setRoleForm] = useState<RoleFormState>(defaultRoleFormState);
866 const [roleRuleForm, setRoleRuleForm] = useState<RoleRuleFormState>(defaultRoleRuleFormState);
867 const [roleBindingForm, setRoleBindingForm] = useState<RoleBindingFormState>(defaultRoleBindingFormState);
868 const [busyRoleAction, setBusyRoleAction] = useState<string | null>(null);
696 869
697 const listArgs = workspaceQueryArgs(context);870 const listArgs = workspaceQueryArgs(context);
698 const policiesQuery = useQuery(convexApi.workspace.listAccessPolicies, listArgs);871 const policiesQuery = useQuery(convexApi.workspace.listToolPolicies, listArgs);
699 const upsertAccessPolicy = useMutation(convexApi.workspace.upsertAccessPolicy);872 const upsertToolRole = useMutation(convexApi.workspace.upsertToolRole);
700 const deleteAccessPolicy = useMutation(convexApi.workspace.deleteAccessPolicy);873 const upsertToolRoleRule = useMutation(convexApi.workspace.upsertToolRoleRule);
874 const upsertToolRoleBinding = useMutation(convexApi.workspace.upsertToolRoleBinding);
875 const deleteToolRole = useMutation(convexApi.workspace.deleteToolRole);
876 const deleteToolRoleRule = useMutation(convexApi.workspace.deleteToolRoleRule);
877 const deleteToolRoleBinding = useMutation(convexApi.workspace.deleteToolRoleBinding);
701 878
702 const loading = Boolean(context) && policiesQuery === undefined;879 const loading = Boolean(context) && policiesQuery === undefined;
703 const policies = useMemo(() => (policiesQuery ?? []) as AccessPolicyRecord[], [policiesQuery]);880 const policies = useMemo(() => (policiesQuery ?? []) as ToolPolicyRecord[], [policiesQuery]);
881
882 const currentWorkspace = useMemo(() => {
883 if (!context) return null;
884 return workspaces.find((workspace) => workspace.id === context.workspaceId) ?? null;
885 }, [context, workspaces]);
886 const currentOrganizationId = currentWorkspace?.organizationId ?? null;
887
888 const membersQuery = useQuery(
889 convexApi.organizationMembers.list,
890 context && currentOrganizationId
891 ? { organizationId: currentOrganizationId, sessionId: context.sessionId }
892 : "skip",
893 );
894 const memberItems = useMemo(
895 () => ((membersQuery?.items ?? []) as OrganizationMemberListItem[]),
896 [membersQuery],
897 );
898 const selfMembership = useMemo(() => {
899 if (!context) return null;
900 return memberItems.find((member) => member.accountId === context.accountId) ?? null;
901 }, [context, memberItems]);
902 const canManageRoles = selfMembership?.role === "owner" || selfMembership?.role === "admin";
903
904 const rolesQuery = useQuery(
905 convexApi.workspace.listToolRoles,
906 context && canManageRoles ? listArgs : "skip",
907 );
908 const roleItems = useMemo(() => ((rolesQuery ?? []) as ToolRoleRecord[]), [rolesQuery]);
909 const activeRoleId = useMemo(() => {
910 if (!canManageRoles) {
911 return null;
912 }
913
914 if (selectedRoleId && roleItems.some((role) => role.id === selectedRoleId)) {
915 return selectedRoleId;
916 }
917
918 return roleItems[0]?.id ?? null;
919 }, [canManageRoles, roleItems, selectedRoleId]);
920
921 const bindingsQuery = useQuery(
922 convexApi.workspace.listToolRoleBindings,
923 context && canManageRoles ? listArgs : "skip",
924 );
925 const bindingItems = useMemo(() => ((bindingsQuery ?? []) as ToolRoleBindingRecord[]), [bindingsQuery]);
926
927 const roleRulesQuery = useQuery(
928 convexApi.workspace.listToolRoleRules,
929 context && canManageRoles && activeRoleId
930 ? {
931 workspaceId: context.workspaceId,
932 sessionId: context.sessionId,
933 roleId: activeRoleId,
934 }
935 : "skip",
936 );
937 const selectedRoleRules = useMemo(
938 () => ((roleRulesQuery ?? []) as ToolRoleRuleRecord[]),
939 [roleRulesQuery],
940 );
704 941
705 const namespaces = useMemo(() => buildNamespaces(tools), [tools]);942 const namespaces = useMemo(() => buildNamespaces(tools), [tools]);
943 const sourceOptions = useMemo(() => {
944 return [...new Set(tools
945 .map((tool) => tool.source)
946 .filter((source): source is string => typeof source === "string" && source.trim().length > 0))]
947 .sort((a, b) => a.localeCompare(b));
948 }, [tools]);
949 const namespaceOptions = useMemo(() => {
950 return [...new Set(namespaces.map((namespace) => namespace.prefix))]
951 .sort((a, b) => a.localeCompare(b));
952 }, [namespaces]);
953
954 const selectedRole = useMemo(
955 () => roleItems.find((role) => role.id === activeRoleId) ?? null,
956 [activeRoleId, roleItems],
957 );
958 const selectedRoleBindings = useMemo(
959 () => bindingItems.filter((binding) => binding.roleId === activeRoleId),
960 [activeRoleId, bindingItems],
961 );
962 const activeMemberOptions = useMemo(
963 () => memberItems.filter((member) => member.status === "active"),
964 [memberItems],
965 );
706 966
707 // ── Handlers ──967 // ── Handlers ──
708 968
709 const handleSave = async () => {969 const handleSave = async () => {
710 if (!context) return;970 if (!context) return;
711 971
712 const pattern = form.resourcePattern.trim() || (form.selectedToolPaths.length > 0972 let resourceType: PolicyResourceType = form.resourceType;
713 ? derivePatternFromSelection(form.selectedToolPaths, namespaces)973 let pattern = "";
714 : "*");974 if (resourceType === "all_tools") {
715 if (!pattern) {975 pattern = "*";
716 toast.error("Tool path pattern is required");976 } else if (resourceType === "source") {
717 return;977 pattern = form.sourcePattern.trim();
978 if (!pattern) {
979 toast.error("Select a source for source-scoped policies");
980 return;
981 }
982 } else if (resourceType === "namespace") {
983 pattern = form.namespacePattern.trim();
984 if (!pattern) {
985 toast.error("Namespace pattern is required");
986 return;
987 }
988 } else {
989 pattern = form.resourcePattern.trim() || (form.selectedToolPaths.length > 0
990 ? derivePatternFromSelection(form.selectedToolPaths, namespaces)
991 : "*");
992 if (!pattern) {
993 toast.error("Tool path pattern is required");
994 return;
995 }
996 resourceType = pattern === "*" ? "all_tools" : "tool_path";
718 }997 }
719 998
720 const priority = Number(form.priority.trim() || "100");999 const priority = Number(form.priority.trim() || "100");
@@ -725,8 +1004,19 @@ export function PoliciesPanel({ · 4 unmodified lines
725 1004
726 setSubmitting(true);1005 setSubmitting(true);
727 try {1006 try {
1007 const policyId = createToolPolicyId();
1008 const roleId = toolPolicyRoleId(policyId);
1009 const ruleId = toolPolicyRuleId(policyId);
1010 const bindingId = toolPolicyBindingId(policyId);
728 const { effect, approvalMode } = getDecisionPayload(form.decision);1011 const { effect, approvalMode } = getDecisionPayload(form.decision);
729 const argumentConditions = form.argumentConditions.filter((c) => c.key.trim().length > 0);1012 const argumentConditions = form.argumentConditions.filter((c) => c.key.trim().length > 0);
1013 const selectorType = resourceType === "all_tools"
1014 ? "all" as const
1015 : resourceType === "source"
1016 ? "source" as const
1017 : resourceType === "namespace"
1018 ? "namespace" as const
1019 : "tool_path" as const;
730 1020
731 // Map UI scope to backend scopeType + targetAccountId.1021 // Map UI scope to backend scopeType + targetAccountId.
732 const scopeType = form.scope === "personal" ? "account" as const1022 const scopeType = form.scope === "personal" ? "account" as const
@@ -734,20 +1024,53 @@ export function PoliciesPanel({ · 1 unmodified line
734 : "organization" as const;1024 : "organization" as const;
735 const targetAccountId = form.scope === "personal" ? context.accountId : undefined;1025 const targetAccountId = form.scope === "personal" ? context.accountId : undefined;
736 1026
737 await upsertAccessPolicy({1027 await upsertToolRole({
738 workspaceId: context.workspaceId,1028 workspaceId: context.workspaceId,
739 sessionId: context.sessionId,1029 sessionId: context.sessionId,
740 scopeType,1030 id: roleId,
741 resourcePattern: pattern,1031 name: `tool-policy:${policyId}`,
742 effect,1032 description: "Tool policy",
743 approvalMode,
744 matchType: pattern.includes("*") ? "glob" : "exact",
745 targetAccountId,
746 clientId: form.clientId.trim() || undefined,
747 argumentConditions: argumentConditions.length > 0 ? argumentConditions : undefined,
748 priority,
749 });1033 });
750 1034
1035 try {
1036 await upsertToolRoleRule({
1037 workspaceId: context.workspaceId,
1038 sessionId: context.sessionId,
1039 id: ruleId,
1040 roleId,
1041 selectorType,
1042 sourceKey: resourceType === "source" ? pattern : undefined,
1043 resourcePattern: resourceType === "source" || resourceType === "all_tools" ? undefined : pattern,
1044 matchType: pattern.includes("*") ? "glob" : "exact",
1045 effect,
1046 approvalMode,
1047 argumentConditions: argumentConditions.length > 0 ? argumentConditions : undefined,
1048 priority,
1049 });
1050
1051 await upsertToolRoleBinding({
1052 workspaceId: context.workspaceId,
1053 sessionId: context.sessionId,
1054 id: bindingId,
1055 roleId,
1056 scopeType,
1057 targetAccountId,
1058 clientId: form.clientId.trim() || undefined,
1059 status: "active",
1060 });
1061 } catch (error) {
1062 try {
1063 await deleteToolRole({
1064 workspaceId: context.workspaceId,
1065 sessionId: context.sessionId,
1066 roleId,
1067 });
1068 } catch {
1069 // Best effort cleanup.
1070 }
1071 throw error;
1072 }
1073
751 toast.success("Policy created");1074 toast.success("Policy created");
752 setForm(defaultFormState());1075 setForm(defaultFormState());
753 setDialogOpen(false);1076 setDialogOpen(false);
@@ -758,14 +1081,24 @@ export function PoliciesPanel({ · 4 unmodified lines
758 }1081 }
759 };1082 };
760 1083
761 const handleDelete = useCallback(async (policyId: string) => {1084 const handleDelete = useCallback(async (policy: ToolPolicyRecord) => {
762 if (!context) return;1085 if (!context) return;
763 setDeletingId(policyId);1086 if (!isDirectToolPolicy(policy)) {
1087 toast.error("This policy is managed by role bindings. Use role management to remove it.");
1088 return;
1089 }
1090
1091 if (!policy.roleId) {
1092 toast.error("Cannot delete policy without role metadata");
1093 return;
1094 }
1095
1096 setDeletingId(policy.id);
764 try {1097 try {
765 await deleteAccessPolicy({1098 await deleteToolRole({
766 workspaceId: context.workspaceId,1099 workspaceId: context.workspaceId,
767 sessionId: context.sessionId,1100 sessionId: context.sessionId,
768 policyId,1101 roleId: policy.roleId,
769 });1102 });
770 toast.success("Policy deleted");1103 toast.success("Policy deleted");
771 } catch (error) {1104 } catch (error) {
@@ -773,12 +1106,172 @@ export function PoliciesPanel({ · 1 unmodified line
773 } finally {1106 } finally {
774 setDeletingId(null);1107 setDeletingId(null);
775 }1108 }
776 }, [context, deleteAccessPolicy]);1109 }, [context, deleteToolRole]);
1110
1111 const handleCreateRole = useCallback(async () => {
1112 if (!context) return;
1113 const name = roleForm.name.trim();
1114 if (!name) {
1115 toast.error("Role name is required");
1116 return;
1117 }
1118
1119 setBusyRoleAction("create-role");
1120 try {
1121 const role = await upsertToolRole({
1122 workspaceId: context.workspaceId,
1123 sessionId: context.sessionId,
1124 name,
1125 description: roleForm.description.trim() || undefined,
1126 });
1127 setRoleForm(defaultRoleFormState());
1128 setSelectedRoleId(role.id);
1129 toast.success("Role created");
1130 } catch (error) {
1131 toast.error(error instanceof Error ? error.message : "Failed to create role");
1132 } finally {
1133 setBusyRoleAction(null);
1134 }
1135 }, [context, roleForm, upsertToolRole]);
1136
1137 const handleCreateRoleRule = useCallback(async () => {
1138 if (!context || !activeRoleId) return;
1139
1140 const priority = Number(roleRuleForm.priority.trim() || "100");
1141 if (!Number.isFinite(priority)) {
1142 toast.error("Rule priority must be a number");
1143 return;
1144 }
1145
1146 const selectorType = roleRuleForm.selectorType;
1147 const sourceKey = roleRuleForm.sourceKey.trim();
1148 const resourcePattern = roleRuleForm.resourcePattern.trim();
1149 if (selectorType === "source" && !sourceKey) {
1150 toast.error("Source is required for source-scoped rules");
1151 return;
1152 }
1153 if ((selectorType === "namespace" || selectorType === "tool_path") && !resourcePattern) {
1154 toast.error("Pattern is required for this selector");
1155 return;
1156 }
1157
1158 setBusyRoleAction("create-rule");
1159 try {
1160 await upsertToolRoleRule({
1161 workspaceId: context.workspaceId,
1162 sessionId: context.sessionId,
1163 roleId: activeRoleId,
1164 selectorType,
1165 sourceKey: selectorType === "source" ? sourceKey : undefined,
1166 resourcePattern: selectorType === "source" || selectorType === "all" ? undefined : resourcePattern,
1167 matchType: roleRuleForm.matchType,
1168 effect: roleRuleForm.effect,
1169 approvalMode: roleRuleForm.approvalMode,
1170 priority,
1171 });
1172 setRoleRuleForm(defaultRoleRuleFormState());
1173 toast.success("Rule added");
1174 } catch (error) {
1175 toast.error(error instanceof Error ? error.message : "Failed to add rule");
1176 } finally {
1177 setBusyRoleAction(null);
1178 }
1179 }, [activeRoleId, context, roleRuleForm, upsertToolRoleRule]);
1180
1181 const handleCreateRoleBinding = useCallback(async () => {
1182 if (!context || !activeRoleId) return;
1183
1184 const scopeType = roleBindingForm.scopeType;
1185 const targetAccountId = roleBindingForm.targetAccountId.trim();
1186 if (scopeType === "account" && !targetAccountId) {
1187 toast.error("Select an account for account-scoped bindings");
1188 return;
1189 }
1190
1191 setBusyRoleAction("create-binding");
1192 try {
1193 await upsertToolRoleBinding({
1194 workspaceId: context.workspaceId,
1195 sessionId: context.sessionId,
1196 roleId: activeRoleId,
1197 scopeType,
1198 targetAccountId: scopeType === "account"
1199 ? (targetAccountId as Id<"accounts">)
1200 : undefined,
1201 clientId: roleBindingForm.clientId.trim() || undefined,
1202 status: roleBindingForm.status,
1203 });
1204 setRoleBindingForm(defaultRoleBindingFormState());
1205 toast.success("Binding added");
1206 } catch (error) {
1207 toast.error(error instanceof Error ? error.message : "Failed to add binding");
1208 } finally {
1209 setBusyRoleAction(null);
1210 }
1211 }, [activeRoleId, context, roleBindingForm, upsertToolRoleBinding]);
1212
1213 const handleDeleteRole = useCallback(async (roleId: string) => {
1214 if (!context) return;
1215
1216 setBusyRoleAction(`delete-role:${roleId}`);
1217 try {
1218 await deleteToolRole({
1219 workspaceId: context.workspaceId,
1220 sessionId: context.sessionId,
1221 roleId,
1222 });
1223 if (activeRoleId === roleId) {
1224 setSelectedRoleId(null);
1225 }
1226 toast.success("Role deleted");
1227 } catch (error) {
1228 toast.error(error instanceof Error ? error.message : "Failed to delete role");
1229 } finally {
1230 setBusyRoleAction(null);
1231 }
1232 }, [activeRoleId, context, deleteToolRole]);
1233
1234 const handleDeleteRule = useCallback(async (ruleId: string) => {
1235 if (!context || !activeRoleId) return;
1236
1237 setBusyRoleAction(`delete-rule:${ruleId}`);
1238 try {
1239 await deleteToolRoleRule({
1240 workspaceId: context.workspaceId,
1241 sessionId: context.sessionId,
1242 roleId: activeRoleId,
1243 ruleId,
1244 });
1245 toast.success("Rule deleted");
1246 } catch (error) {
1247 toast.error(error instanceof Error ? error.message : "Failed to delete rule");
1248 } finally {
1249 setBusyRoleAction(null);
1250 }
1251 }, [activeRoleId, context, deleteToolRoleRule]);
1252
1253 const handleDeleteBinding = useCallback(async (bindingId: string) => {
1254 if (!context) return;
1255
1256 setBusyRoleAction(`delete-binding:${bindingId}`);
1257 try {
1258 await deleteToolRoleBinding({
1259 workspaceId: context.workspaceId,
1260 sessionId: context.sessionId,
1261 bindingId,
1262 });
1263 toast.success("Binding deleted");
1264 } catch (error) {
1265 toast.error(error instanceof Error ? error.message : "Failed to delete binding");
1266 } finally {
1267 setBusyRoleAction(null);
1268 }
1269 }, [context, deleteToolRoleBinding]);
777 1270
778 // ── Group policies by decision for display ──1271 // ── Group policies by decision for display ──
779 1272
780 const groupedPolicies = useMemo(() => {1273 const groupedPolicies = useMemo(() => {
781 const groups: Record<PolicyDecisionType, AccessPolicyRecord[]> = {1274 const groups: Record<PolicyDecisionType, ToolPolicyRecord[]> = {
782 allow: [],1275 allow: [],
783 require_approval: [],1276 require_approval: [],
784 deny: [],1277 deny: [],
@@ -799,9 +1292,9 @@ export function PoliciesPanel({ · 14 unmodified lines
799 <div className="flex items-center gap-2.5">1292 <div className="flex items-center gap-2.5">
800 <Shield className="h-4 w-4 text-muted-foreground" />1293 <Shield className="h-4 w-4 text-muted-foreground" />
801 <div>1294 <div>
802 <h3 className="text-sm font-medium leading-none">Access Policies</h3>1295 <h3 className="text-sm font-medium leading-none">Tool Policies</h3>
803 <p className="text-[11px] text-muted-foreground mt-1">1296 <p className="text-[11px] text-muted-foreground mt-1">
804 Control which tools require approval, auto-approve, or are blocked1297 Control which tools or sources are approved, gated, or blocked
805 </p>1298 </p>
806 </div>1299 </div>
807 </div>1300 </div>
@@ -829,9 +1322,9 @@ export function PoliciesPanel({ · 21 unmodified lines
829 <div className="rounded-lg border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5">1322 <div className="rounded-lg border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5">
830 <Shield className="h-8 w-8 text-muted-foreground/30" />1323 <Shield className="h-8 w-8 text-muted-foreground/30" />
831 <div className="text-center">1324 <div className="text-center">
832 <p className="text-xs text-muted-foreground">No policies configured</p>1325 <p className="text-xs text-muted-foreground">No tool policies configured</p>
833 <p className="text-[11px] text-muted-foreground/60 mt-0.5">1326 <p className="text-[11px] text-muted-foreground/60 mt-0.5">
834 Tools use their default approval settings. Create a policy to customize behavior.1327 Tools use default approval behavior. Create a tool policy to customize it.
835 </p>1328 </p>
836 </div>1329 </div>
837 <Button1330 <Button
@@ -869,6 +1362,7 @@ export function PoliciesPanel({ · 31 unmodified lines
869 tools={tools}1362 tools={tools}
870 currentAccountId={context?.accountId}1363 currentAccountId={context?.accountId}
871 onDelete={handleDelete}1364 onDelete={handleDelete}
1365 canDelete={isDirectToolPolicy(policy)}
872 deleting={deletingId === policy.id}1366 deleting={deletingId === policy.id}
873 />1367 />
874 ))}1368 ))}
@@ -879,13 +1373,368 @@ export function PoliciesPanel({ · 4 unmodified lines
879 </div>1373 </div>
880 )}1374 )}
881 1375
1376 <Separator className="bg-border/40" />
1377
1378 <div className="space-y-3">
1379 <div className="flex items-center justify-between">
1380 <div>
1381 <h4 className="text-sm font-medium">Role Manager</h4>
1382 <p className="text-[11px] text-muted-foreground mt-1">
1383 Manage reusable tool roles, rule selectors, and scoped bindings.
1384 </p>
1385 </div>
1386 </div>
1387
1388 {!canManageRoles ? (
1389 <div className="rounded-lg border border-dashed border-border/50 p-4 text-xs text-muted-foreground">
1390 Organization owner/admin role required to manage tool roles.
1391 </div>
1392 ) : (
1393 <div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
1394 <div className="space-y-3 rounded-lg border border-border/60 bg-card p-3">
1395 <Label className="text-xs text-muted-foreground">Create role</Label>
1396 <Input
1397 value={roleForm.name}
1398 onChange={(event) => setRoleForm((state) => ({ ...state, name: event.target.value }))}
1399 placeholder="Role name"
1400 className="h-8 text-xs"
1401 />
1402 <Input
1403 value={roleForm.description}
1404 onChange={(event) => setRoleForm((state) => ({ ...state, description: event.target.value }))}
1405 placeholder="Description (optional)"
1406 className="h-8 text-xs"
1407 />
1408 <Button
1409 size="sm"
1410 className="h-8 text-xs"
1411 onClick={handleCreateRole}
1412 disabled={busyRoleAction === "create-role"}
1413 >
1414 {busyRoleAction === "create-role" ? "Creating..." : "Create role"}
1415 </Button>
1416
1417 <Separator className="bg-border/40" />
1418
1419 <div className="space-y-1.5 max-h-[320px] overflow-y-auto">
1420 {roleItems.length === 0 ? (
1421 <p className="text-[11px] text-muted-foreground">No roles yet.</p>
1422 ) : (
1423 roleItems.map((role) => {
1424 const bindingCount = bindingItems.filter((binding) => binding.roleId === role.id).length;
1425 return (
1426 <button
1427 key={role.id}
1428 type="button"
1429 onClick={() => setSelectedRoleId(role.id)}
1430 className={cn(
1431 "w-full rounded-md border px-2 py-1.5 text-left",
1432 selectedRoleId === role.id
1433 ? "border-primary/40 bg-primary/5"
1434 : "border-border/40 hover:bg-muted/30",
1435 )}
1436 >
1437 <div className="flex items-center justify-between gap-2">
1438 <span className="truncate text-xs font-medium">{role.name}</span>
1439 <Badge variant="outline" className="text-[9px] px-1.5 py-0">
1440 {bindingCount} bind
1441 </Badge>
1442 </div>
1443 {role.description ? (
1444 <p className="mt-1 truncate text-[10px] text-muted-foreground">{role.description}</p>
1445 ) : null}
1446 </button>
1447 );
1448 })
1449 )}
1450 </div>
1451 </div>
1452
1453 <div className="space-y-4 rounded-lg border border-border/60 bg-card p-4">
1454 {!selectedRole ? (
1455 <p className="text-xs text-muted-foreground">Select a role to manage its rules and bindings.</p>
1456 ) : (
1457 <>
1458 <div className="flex items-start justify-between gap-3">
1459 <div>
1460 <h5 className="text-sm font-medium">{selectedRole.name}</h5>
1461 {selectedRole.description ? (
1462 <p className="text-[11px] text-muted-foreground mt-1">{selectedRole.description}</p>
1463 ) : null}
1464 <p className="text-[10px] text-muted-foreground mt-1 font-mono">{selectedRole.id}</p>
1465 </div>
1466 <Button
1467 variant="destructive"
1468 size="sm"
1469 className="h-8 text-xs"
1470 onClick={() => handleDeleteRole(selectedRole.id)}
1471 disabled={busyRoleAction === `delete-role:${selectedRole.id}`}
1472 >
1473 {busyRoleAction === `delete-role:${selectedRole.id}` ? "Deleting..." : "Delete role"}
1474 </Button>
1475 </div>
1476
1477 <Separator className="bg-border/40" />
1478
1479 <div className="space-y-2">
1480 <div className="flex items-center justify-between">
1481 <Label className="text-xs text-muted-foreground">Rules</Label>
1482 <span className="text-[10px] text-muted-foreground">{selectedRoleRules.length}</span>
1483 </div>
1484 <div className="space-y-1.5 max-h-[200px] overflow-y-auto">
1485 {selectedRoleRules.length === 0 ? (
1486 <p className="text-[11px] text-muted-foreground">No rules.</p>
1487 ) : (
1488 selectedRoleRules.map((rule) => (
1489 <div key={rule.id} className="rounded border border-border/40 px-2 py-1.5">
1490 <div className="flex items-center justify-between gap-2">
1491 <div className="flex items-center gap-1.5 min-w-0">
1492 <Badge variant="outline" className="text-[9px] px-1.5 py-0 uppercase">
1493 {rule.selectorType}
1494 </Badge>
1495 <span className="text-[10px] font-mono truncate">{roleRulePattern(rule) || "*"}</span>
1496 </div>
1497 <button
1498 type="button"
1499 onClick={() => handleDeleteRule(rule.id)}
1500 disabled={busyRoleAction === `delete-rule:${rule.id}`}
1501 className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
1502 >
1503 <Trash2 className="h-3 w-3" />
1504 </button>
1505 </div>
1506 <div className="mt-1 flex items-center gap-1.5 text-[10px] text-muted-foreground">
1507 <span>{rule.effect}</span>
1508 <span>|</span>
1509 <span>{rule.approvalMode}</span>
1510 <span>|</span>
1511 <span>p{rule.priority}</span>
1512 </div>
1513 </div>
1514 ))
1515 )}
1516 </div>
1517
1518 <div className="grid grid-cols-1 gap-2 rounded-md border border-border/40 bg-muted/10 p-2 md:grid-cols-2">
1519 <Select
1520 value={roleRuleForm.selectorType}
1521 onValueChange={(value) => setRoleRuleForm((state) => ({
1522 ...state,
1523 selectorType: value as ToolRoleRuleRecord["selectorType"],
1524 }))}
1525 >
1526 <SelectTrigger className="h-8 text-xs">
1527 <SelectValue />
1528 </SelectTrigger>
1529 <SelectContent>
1530 <SelectItem value="all" className="text-xs">All tools</SelectItem>
1531 <SelectItem value="source" className="text-xs">Source</SelectItem>
1532 <SelectItem value="namespace" className="text-xs">Namespace</SelectItem>
1533 <SelectItem value="tool_path" className="text-xs">Tool path</SelectItem>
1534 </SelectContent>
1535 </Select>
1536
1537 {roleRuleForm.selectorType === "source" ? (
1538 <Select
1539 value={roleRuleForm.sourceKey}
1540 onValueChange={(value) => setRoleRuleForm((state) => ({ ...state, sourceKey: value }))}
1541 >
1542 <SelectTrigger className="h-8 text-xs font-mono">
1543 <SelectValue placeholder="Source" />
1544 </SelectTrigger>
1545 <SelectContent>
1546 {sourceOptions.map((source) => (
1547 <SelectItem key={source} value={source} className="text-xs font-mono">
1548 {source}
1549 </SelectItem>
1550 ))}
1551 </SelectContent>
1552 </Select>
1553 ) : (
1554 <Input
1555 value={roleRuleForm.resourcePattern}
1556 onChange={(event) => setRoleRuleForm((state) => ({ ...state, resourcePattern: event.target.value }))}
1557 placeholder={roleRuleForm.selectorType === "namespace" ? "github.repos.*" : "github.repos.delete"}
1558 className="h-8 text-xs font-mono"
1559 disabled={roleRuleForm.selectorType === "all"}
1560 />
1561 )}
1562
1563 <Select
1564 value={roleRuleForm.effect}
1565 onValueChange={(value) => setRoleRuleForm((state) => ({
1566 ...state,
1567 effect: value as ToolRoleRuleRecord["effect"],
1568 }))}
1569 >
1570 <SelectTrigger className="h-8 text-xs">
1571 <SelectValue />
1572 </SelectTrigger>
1573 <SelectContent>
1574 <SelectItem value="allow" className="text-xs">Allow</SelectItem>
1575 <SelectItem value="deny" className="text-xs">Deny</SelectItem>
1576 </SelectContent>
1577 </Select>
1578
1579 <Select
1580 value={roleRuleForm.approvalMode}
1581 onValueChange={(value) => setRoleRuleForm((state) => ({
1582 ...state,
1583 approvalMode: value as ToolRoleRuleRecord["approvalMode"],
1584 }))}
1585 >
1586 <SelectTrigger className="h-8 text-xs">
1587 <SelectValue />
1588 </SelectTrigger>
1589 <SelectContent>
1590 <SelectItem value="inherit" className="text-xs">Inherit</SelectItem>
1591 <SelectItem value="required" className="text-xs">Require approval</SelectItem>
1592 <SelectItem value="auto" className="text-xs">Auto approve</SelectItem>
1593 </SelectContent>
1594 </Select>
1595
1596 <Input
1597 value={roleRuleForm.priority}
1598 onChange={(event) => setRoleRuleForm((state) => ({ ...state, priority: event.target.value }))}
1599 placeholder="priority"
1600 className="h-8 text-xs font-mono"
1601 />
1602
1603 <Button
1604 size="sm"
1605 className="h-8 text-xs md:col-span-2"
1606 onClick={handleCreateRoleRule}
1607 disabled={busyRoleAction === "create-rule"}
1608 >
1609 {busyRoleAction === "create-rule" ? "Adding rule..." : "Add rule"}
1610 </Button>
1611 </div>
1612 </div>
1613
1614 <Separator className="bg-border/40" />
1615
1616 <div className="space-y-2">
1617 <div className="flex items-center justify-between">
1618 <Label className="text-xs text-muted-foreground">Bindings</Label>
1619 <span className="text-[10px] text-muted-foreground">{selectedRoleBindings.length}</span>
1620 </div>
1621 <div className="space-y-1.5 max-h-[200px] overflow-y-auto">
1622 {selectedRoleBindings.length === 0 ? (
1623 <p className="text-[11px] text-muted-foreground">No bindings.</p>
1624 ) : (
1625 selectedRoleBindings.map((binding) => (
1626 <div key={binding.id} className="rounded border border-border/40 px-2 py-1.5">
1627 <div className="flex items-center justify-between gap-2">
1628 <div className="flex items-center gap-1.5">
1629 <Badge variant="outline" className="text-[9px] px-1.5 py-0 uppercase">
1630 {binding.scopeType}
1631 </Badge>
1632 <Badge variant="outline" className="text-[9px] px-1.5 py-0 uppercase">
1633 {binding.status}
1634 </Badge>
1635 </div>
1636 <button
1637 type="button"
1638 onClick={() => handleDeleteBinding(binding.id)}
1639 disabled={busyRoleAction === `delete-binding:${binding.id}`}
1640 className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
1641 >
1642 <Trash2 className="h-3 w-3" />
1643 </button>
1644 </div>
1645 {binding.targetAccountId || binding.clientId ? (
1646 <div className="mt-1 text-[10px] text-muted-foreground font-mono truncate">
1647 {[binding.targetAccountId, binding.clientId].filter(Boolean).join(" | ")}
1648 </div>
1649 ) : null}
1650 </div>
1651 ))
1652 )}
1653 </div>
1654
1655 <div className="grid grid-cols-1 gap-2 rounded-md border border-border/40 bg-muted/10 p-2 md:grid-cols-2">
1656 <Select
1657 value={roleBindingForm.scopeType}
1658 onValueChange={(value) => setRoleBindingForm((state) => ({
1659 ...state,
1660 scopeType: value as ToolRoleBindingRecord["scopeType"],
1661 }))}
1662 >
1663 <SelectTrigger className="h-8 text-xs">
1664 <SelectValue />
1665 </SelectTrigger>
1666 <SelectContent>
1667 <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
1668 <SelectItem value="organization" className="text-xs">Organization</SelectItem>
1669 <SelectItem value="account" className="text-xs">Account</SelectItem>
1670 </SelectContent>
1671 </Select>
1672
1673 {roleBindingForm.scopeType === "account" ? (
1674 <Select
1675 value={roleBindingForm.targetAccountId}
1676 onValueChange={(value) => setRoleBindingForm((state) => ({ ...state, targetAccountId: value }))}
1677 >
1678 <SelectTrigger className="h-8 text-xs">
1679 <SelectValue placeholder="Select account" />
1680 </SelectTrigger>
1681 <SelectContent>
1682 {activeMemberOptions.map((member) => (
1683 <SelectItem key={member.accountId} value={member.accountId} className="text-xs">
1684 {member.displayName}{member.email ? ` (${member.email})` : ""}
1685 </SelectItem>
1686 ))}
1687 </SelectContent>
1688 </Select>
1689 ) : (
1690 <Input
1691 value={roleBindingForm.clientId}
1692 onChange={(event) => setRoleBindingForm((state) => ({ ...state, clientId: event.target.value }))}
1693 placeholder="client id (optional)"
1694 className="h-8 text-xs font-mono"
1695 />
1696 )}
1697
1698 <Select
1699 value={roleBindingForm.status}
1700 onValueChange={(value) => setRoleBindingForm((state) => ({
1701 ...state,
1702 status: value as ToolRoleBindingRecord["status"],
1703 }))}
1704 >
1705 <SelectTrigger className="h-8 text-xs">
1706 <SelectValue />
1707 </SelectTrigger>
1708 <SelectContent>
1709 <SelectItem value="active" className="text-xs">Active</SelectItem>
1710 <SelectItem value="disabled" className="text-xs">Disabled</SelectItem>
1711 </SelectContent>
1712 </Select>
1713
1714 <Button
1715 size="sm"
1716 className="h-8 text-xs"
1717 onClick={handleCreateRoleBinding}
1718 disabled={busyRoleAction === "create-binding"}
1719 >
1720 {busyRoleAction === "create-binding" ? "Adding binding..." : "Add binding"}
1721 </Button>
1722 </div>
1723 </div>
1724 </>
1725 )}
1726 </div>
1727 </div>
1728 )}
1729 </div>
1730
882 {/* Create Policy Dialog */}1731 {/* Create Policy Dialog */}
883 <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>1732 <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
884 <DialogContent className="sm:max-w-[540px]">1733 <DialogContent className="sm:max-w-[540px]">
885 <DialogHeader>1734 <DialogHeader>
886 <DialogTitle className="text-base">New Access Policy</DialogTitle>1735 <DialogTitle className="text-base">New Tool Policy</DialogTitle>
887 <DialogDescription className="text-xs">1736 <DialogDescription className="text-xs">
888 Define which tools are auto-approved, require manual approval, or blocked.1737 Define which tools, namespaces, or sources are auto-approved, gated, or blocked.
889 </DialogDescription>1738 </DialogDescription>
890 </DialogHeader>1739 </DialogHeader>
891 1740
@@ -919,38 +1768,130 @@ export function PoliciesPanel({ · 27 unmodified lines
919 </div>1768 </div>
920 </div>1769 </div>
921 1770
922 {/* Tools selection */}1771 {/* Resource target */}
923 <div className="space-y-2">1772 <div className="space-y-2">
924 <Label className="text-xs text-muted-foreground">Apply to tools</Label>1773 <Label className="text-xs text-muted-foreground">Apply to</Label>
925 {tools.length > 0 ? (1774 <Select
926 <ToolPicker1775 value={form.resourceType}
927 tools={tools}1776 onValueChange={(value) => {
928 selectedPaths={form.selectedToolPaths}1777 const nextType = value as PolicyResourceType;
929 onSelectionChange={(paths) => setForm((s) => ({ ...s, selectedToolPaths: paths }))}1778 setForm((state) => ({
930 onPatternChange={(pattern) => setForm((s) => ({ ...s, resourcePattern: pattern }))}1779 ...state,
931 />1780 resourceType: nextType,
932 ) : (1781 resourcePattern: nextType === "all_tools" ? "*" : state.resourcePattern,
933 <Input1782 selectedToolPaths: nextType === "tool_path" ? state.selectedToolPaths : [],
934 value={form.resourcePattern}1783 }));
935 onChange={(e) => setForm((s) => ({ ...s, resourcePattern: e.target.value }))}1784 }}
936 placeholder="github.repos.* or * for all tools"1785 >
937 className="h-9 text-xs font-mono bg-background"1786 <SelectTrigger className="h-9 text-xs bg-background">
938 />1787 <SelectValue />
1788 </SelectTrigger>
1789 <SelectContent>
1790 {(Object.keys(RESOURCE_TYPE_CONFIG) as PolicyResourceType[]).map((resourceType) => (
1791 <SelectItem key={resourceType} value={resourceType} className="text-xs">
1792 {RESOURCE_TYPE_CONFIG[resourceType].label}
1793 </SelectItem>
1794 ))}
1795 </SelectContent>
1796 </Select>
1797 <p className="text-[10px] text-muted-foreground/70 leading-tight">
1798 {RESOURCE_TYPE_CONFIG[form.resourceType].description}
1799 </p>
1800
1801 {form.resourceType === "all_tools" && (
1802 <div className="h-9 rounded-md border border-border/40 bg-muted/20 px-3 flex items-center text-[11px] font-mono text-muted-foreground">
1803 *
1804 </div>
1805 )}
1806
1807 {form.resourceType === "source" && (
1808 <>
1809 {sourceOptions.length > 0 ? (
1810 <Select
1811 value={form.sourcePattern}
1812 onValueChange={(sourcePattern) => setForm((state) => ({ ...state, sourcePattern }))}
1813 >
1814 <SelectTrigger className="h-9 text-xs font-mono bg-background">
1815 <SelectValue placeholder="Select source" />
1816 </SelectTrigger>
1817 <SelectContent>
1818 {sourceOptions.map((source) => (
1819 <SelectItem key={source} value={source} className="text-xs font-mono">
1820 {source}
1821 </SelectItem>
1822 ))}
1823 </SelectContent>
1824 </Select>
1825 ) : (
1826 <Input
1827 value={form.sourcePattern}
1828 onChange={(event) => setForm((state) => ({ ...state, sourcePattern: event.target.value }))}
1829 placeholder="source:github"
1830 className="h-9 text-xs font-mono bg-background"
1831 />
1832 )}
1833 </>
939 )}1834 )}
940 {/* Editable pattern override */}1835
941 {form.selectedToolPaths.length > 0 && (1836 {form.resourceType === "namespace" && (
942 <div className="flex items-center gap-2">1837 <>
943 <span className="text-[10px] text-muted-foreground shrink-0">Pattern:</span>1838 {namespaceOptions.length > 0 ? (
1839 <Select
1840 value={form.namespacePattern}
1841 onValueChange={(namespace) => setForm((state) => ({ ...state, namespacePattern: `${namespace}.*` }))}
1842 >
1843 <SelectTrigger className="h-9 text-xs font-mono bg-background">
1844 <SelectValue placeholder="Select namespace" />
1845 </SelectTrigger>
1846 <SelectContent>
1847 {namespaceOptions.map((namespace) => (
1848 <SelectItem key={namespace} value={namespace} className="text-xs font-mono">
1849 {namespace}.*
1850 </SelectItem>
1851 ))}
1852 </SelectContent>
1853 </Select>
1854 ) : null}
944 <Input1855 <Input
945 value={form.resourcePattern || derivePatternFromSelection(form.selectedToolPaths, namespaces)}1856 value={form.namespacePattern}
946 onChange={(e) => setForm((s) => ({ ...s, resourcePattern: e.target.value }))}1857 onChange={(event) => setForm((state) => ({ ...state, namespacePattern: event.target.value }))}
947 className="h-6 text-[10px] font-mono bg-muted/30 border-border/30"1858 placeholder="github.repos.*"
1859 className="h-9 text-xs font-mono bg-background"
948 />1860 />
949 </div>1861 </>
1862 )}
1863
1864 {form.resourceType === "tool_path" && (
1865 <>
1866 {tools.length > 0 ? (
1867 <ToolPicker
1868 tools={tools}
1869 selectedPaths={form.selectedToolPaths}
1870 onSelectionChange={(paths) => setForm((state) => ({ ...state, selectedToolPaths: paths }))}
1871 onPatternChange={(pattern) => setForm((state) => ({ ...state, resourcePattern: pattern }))}
1872 />
1873 ) : (
1874 <Input
1875 value={form.resourcePattern}
1876 onChange={(event) => setForm((state) => ({ ...state, resourcePattern: event.target.value }))}
1877 placeholder="github.repos.* or github.repos.list"
1878 className="h-9 text-xs font-mono bg-background"
1879 />
1880 )}
1881 {form.selectedToolPaths.length > 0 && (
1882 <div className="flex items-center gap-2">
1883 <span className="text-[10px] text-muted-foreground shrink-0">Pattern:</span>
1884 <Input
1885 value={form.resourcePattern || derivePatternFromSelection(form.selectedToolPaths, namespaces)}
1886 onChange={(event) => setForm((state) => ({ ...state, resourcePattern: event.target.value }))}
1887 className="h-6 text-[10px] font-mono bg-muted/30 border-border/30"
1888 />
1889 </div>
1890 )}
1891 </>
950 )}1892 )}
951 </div>1893 </div>
952 1894
953 {/* Scope */}
954 {/* Scope */}1895 {/* Scope */}
955 <div className="space-y-2">1896 <div className="space-y-2">
956 <Label className="text-xs text-muted-foreground">Scope</Label>1897 <Label className="text-xs text-muted-foreground">Scope</Label>
@@ -982,6 +1923,27 @@ export function PoliciesPanel({ · 25 unmodified lines
982 className="h-9 text-xs font-mono bg-background w-24"1923 className="h-9 text-xs font-mono bg-background w-24"
983 />1924 />
984 </div>1925 </div>
1926
1927 {/* Advanced filters */}
1928 <div className="space-y-3 rounded-md border border-border/40 bg-muted/10 p-3">
1929 <div className="space-y-1">
1930 <Label className="text-xs text-muted-foreground">Client ID</Label>
1931 <Input
1932 value={form.clientId}
1933 onChange={(event) => setForm((state) => ({ ...state, clientId: event.target.value }))}
1934 placeholder="optional client identifier"
1935 className="h-8 text-xs font-mono bg-background"
1936 />
1937 <p className="text-[10px] text-muted-foreground/60 leading-tight">
1938 When set, this policy only applies to tool calls from this client ID.
1939 </p>
1940 </div>
1941
1942 <ArgumentConditionsEditor
1943 conditions={form.argumentConditions}
1944 onChange={(argumentConditions) => setForm((state) => ({ ...state, argumentConditions }))}
1945 />
1946 </div>
985 </div>1947 </div>
986 1948
987 <DialogFooter>1949 <DialogFooter>
executor/apps/web/src/hooks/use/workspace-tools.ts+0−5
@@ -47,14 +47,12 @@ type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "displ · 46 unmodified lines
47type GetToolDetailsMutation = (args: {47type GetToolDetailsMutation = (args: {
48 workspaceId: Id<"workspaces">;48 workspaceId: Id<"workspaces">;
49 sessionId?: string;49 sessionId?: string;
50 clientId?: string;
51 toolPaths: string[];50 toolPaths: string[];
52}) => Promise<Record<string, ToolDetailDescriptor>>;51}) => Promise<Record<string, ToolDetailDescriptor>>;
53 52
54type ListToolsWithWarningsAction = (args: {53type ListToolsWithWarningsAction = (args: {
55 workspaceId: Id<"workspaces">;54 workspaceId: Id<"workspaces">;
56 accountId?: string;55 accountId?: string;
57 clientId?: string;
58 sessionId?: string;56 sessionId?: string;
59 includeDetails?: boolean;57 includeDetails?: boolean;
60 includeSourceMeta?: boolean;58 includeSourceMeta?: boolean;
@@ -122,7 +120,6 @@ export function useWorkspaceTools( · 61 unmodified lines
122 return await listToolsWithWarnings({120 return await listToolsWithWarnings({
123 workspaceId: context.workspaceId,121 workspaceId: context.workspaceId,
124 ...(context.accountId && { accountId: context.accountId }),122 ...(context.accountId && { accountId: context.accountId }),
125 ...(context.clientId && { clientId: context.clientId }),
126 ...(context.sessionId && { sessionId: context.sessionId }),123 ...(context.sessionId && { sessionId: context.sessionId }),
127 includeDetails,124 includeDetails,
128 fetchAll: true,125 fetchAll: true,
@@ -152,7 +149,6 @@ export function useWorkspaceTools( · 23 unmodified lines
152 const detailsByPath = await listToolDetails({149 const detailsByPath = await listToolDetails({
153 workspaceId: context.workspaceId,150 workspaceId: context.workspaceId,
154 sessionId: context.sessionId,151 sessionId: context.sessionId,
155 ...(context.clientId && { clientId: context.clientId }),
156 toolPaths: missing,152 toolPaths: missing,
157 });153 });
158 154
@@ -206,7 +202,6 @@ export function useWorkspaceTools( · 47 unmodified lines
206 await listToolsWithWarnings({202 await listToolsWithWarnings({
207 workspaceId: context.workspaceId,203 workspaceId: context.workspaceId,
208 ...(context.accountId && { accountId: context.accountId }),204 ...(context.accountId && { accountId: context.accountId }),
209 ...(context.clientId && { clientId: context.clientId }),
210 ...(context.sessionId && { sessionId: context.sessionId }),205 ...(context.sessionId && { sessionId: context.sessionId }),
211 includeDetails: false,206 includeDetails: false,
212 includeSourceMeta: false,207 includeSourceMeta: false,
executor/apps/web/src/lib/runtime-config.ts+16−0
@@ -4,6 +4,7 @@ export type ExecutorRuntimeConfig = { · 3 unmodified lines
4 workosClientId?: string;4 workosClientId?: string;
5 stripePriceId?: string;5 stripePriceId?: string;
6 executorHttpUrl?: string;6 executorHttpUrl?: string;
7 allowLocalVm?: boolean;
7};8};
8 9
9declare global {10declare global {
@@ -17,6 +18,16 @@ function trim(value: string | null | undefined): string | undefined { · 7 unmodified lines
17 return candidate && candidate.length > 0 ? candidate : undefined;18 return candidate && candidate.length > 0 ? candidate : undefined;
18}19}
19 20
21const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
22
23function parseBooleanEnv(value: string | null | undefined): boolean | undefined {
24 const candidate = trim(value);
25 if (!candidate) {
26 return undefined;
27 }
28 return TRUTHY_ENV_VALUES.has(candidate.toLowerCase());
29}
30
20function normalizeDeploymentSlug(value: string | undefined): string | undefined {31function normalizeDeploymentSlug(value: string | undefined): string | undefined {
21 const candidate = trim(value);32 const candidate = trim(value);
22 if (!candidate) {33 if (!candidate) {
@@ -53,6 +64,7 @@ export function readRuntimeConfig(): ExecutorRuntimeConfig { · 30 unmodified lines
53 workosClientId: trim(import.meta.env.VITE_WORKOS_CLIENT_ID),64 workosClientId: trim(import.meta.env.VITE_WORKOS_CLIENT_ID),
54 stripePriceId: trim(import.meta.env.VITE_STRIPE_PRICE_ID),65 stripePriceId: trim(import.meta.env.VITE_STRIPE_PRICE_ID),
55 executorHttpUrl: trim(import.meta.env.VITE_EXECUTOR_HTTP_URL),66 executorHttpUrl: trim(import.meta.env.VITE_EXECUTOR_HTTP_URL),
67 allowLocalVm: parseBooleanEnv(import.meta.env.VITE_DANGEROUSLY_ALLOW_LOCAL_VM),
56 };68 };
57}69}
58 70
@@ -94,5 +106,9 @@ export function runtimeConfigFromEnv(): ExecutorRuntimeConfig { · 35 unmodified lines
94 ?? process.env.EXECUTOR_HTTP_URL106 ?? process.env.EXECUTOR_HTTP_URL
95 ?? process.env.VITE_EXECUTOR_HTTP_URL,107 ?? process.env.VITE_EXECUTOR_HTTP_URL,
96 ),108 ),
109 allowLocalVm: parseBooleanEnv(
110 process.env.DANGEROUSLY_ALLOW_LOCAL_VM
111 ?? process.env.VITE_DANGEROUSLY_ALLOW_LOCAL_VM,
112 ),
97 };113 };
98}114}
executor/apps/web/src/lib/runtime-targets.ts+23−7
@@ -1,4 +1,5 @@
1import type { RuntimeTargetDescriptor } from "@/lib/types";1import type { RuntimeTargetDescriptor } from "@/lib/types";
2import { readRuntimeConfig } from "@/lib/runtime-config";
2 3
3const LOCAL_BUN_RUNTIME_ID = "local-bun";4const LOCAL_BUN_RUNTIME_ID = "local-bun";
4const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader";5const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader";
@@ -27,16 +28,26 @@ function isTruthyEnvValue(value: string | undefined): boolean { · 22 unmodified lines
27 return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase());28 return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase());
28}29}
29 30
30function isRuntimeEnabled(runtimeId: string): boolean {31function resolveLocalVmAllowed(override?: boolean): boolean {
32 if (typeof override === "boolean") {
33 return override;
34 }
35
36 const fromRuntimeConfig = readRuntimeConfig().allowLocalVm;
37 if (typeof fromRuntimeConfig === "boolean") {
38 return fromRuntimeConfig;
39 }
40
41 return typeof process !== "undefined"
42 ? isTruthyEnvValue(process.env[DANGEROUSLY_ALLOW_LOCAL_VM_ENV_KEY])
43 : false;
44}
45
46function isRuntimeEnabled(runtimeId: string, localVmAllowed: boolean): boolean {
31 if (runtimeId !== LOCAL_BUN_RUNTIME_ID && runtimeId !== CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) {47 if (runtimeId !== LOCAL_BUN_RUNTIME_ID && runtimeId !== CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) {
32 return false;48 return false;
33 }49 }
34 50
35 const localVmAllowed =
36 typeof process !== "undefined"
37 ? isTruthyEnvValue(process.env[DANGEROUSLY_ALLOW_LOCAL_VM_ENV_KEY])
38 : false;
39
40 if (localVmAllowed) {51 if (localVmAllowed) {
41 return true;52 return true;
42 }53 }
@@ -45,5 +56,10 @@ function isRuntimeEnabled(runtimeId: string): boolean { · 2 unmodified lines
45}56}
46 57
47export function listRuntimeTargets(): RuntimeTargetDescriptor[] {58export function listRuntimeTargets(): RuntimeTargetDescriptor[] {
48 return RUNTIME_TARGETS.filter((target) => isRuntimeEnabled(target.id));59 return listRuntimeTargetsWithOptions({});
60}
61
62export function listRuntimeTargetsWithOptions(options: { allowLocalVm?: boolean }): RuntimeTargetDescriptor[] {
63 const localVmAllowed = resolveLocalVmAllowed(options.allowLocalVm);
64 return RUNTIME_TARGETS.filter((target) => isRuntimeEnabled(target.id, localVmAllowed));
49}65}
executor/apps/web/src/lib/session-context.tsx+5−0
@@ -49,6 +49,11 @@ interface SessionState { · 48 unmodified lines
49 clientConfig: {49 clientConfig: {
50 authProviderMode: string;50 authProviderMode: string;
51 invitesProvider: string;51 invitesProvider: string;
52 runtime?: {
53 allowLocalVm: boolean;
54 defaultRuntimeId: string;
55 enabledRuntimeIds: string[];
56 };
52 features: {57 features: {
53 organizations: boolean;58 organizations: boolean;
54 billing: boolean;59 billing: boolean;
executor/apps/web/src/lib/types.ts+12−17
@@ -1,4 +1,10 @@
1import type { Id } from "@executor/database/convex/_generated/dataModel";1import type { Id } from "@executor/database/convex/_generated/dataModel";
2import type {
3 ToolPolicyRecord as CoreToolPolicyRecord,
4 ToolRoleBindingRecord as CoreToolRoleBindingRecord,
5 ToolRoleRecord as CoreToolRoleRecord,
6 ToolRoleRuleRecord as CoreToolRoleRuleRecord,
7} from "@executor/core/types";
2 8
3// ── Shared types (inlined from @executor/contracts) ──────────────────────────9// ── Shared types (inlined from @executor/contracts) ──────────────────────────
4 10
@@ -9,6 +15,8 @@ export type PolicyScopeType = "account" | "organization" | "workspace"; · 4 unmodified lines
9export type PolicyMatchType = "glob" | "exact";15export type PolicyMatchType = "glob" | "exact";
10export type PolicyEffect = "allow" | "deny";16export type PolicyEffect = "allow" | "deny";
11export type PolicyApprovalMode = "inherit" | "auto" | "required";17export type PolicyApprovalMode = "inherit" | "auto" | "required";
18export type ToolRoleSelectorType = "all" | "source" | "namespace" | "tool_path";
19export type ToolRoleBindingStatus = "active" | "disabled";
12export type ArgumentConditionOperator = "equals" | "contains" | "starts_with" | "not_equals";20export type ArgumentConditionOperator = "equals" | "contains" | "starts_with" | "not_equals";
13 21
14export interface ArgumentCondition {22export interface ArgumentCondition {
@@ -76,23 +84,10 @@ export interface TaskEventRecord { · 61 unmodified lines
76 createdAt: number;84 createdAt: number;
77}85}
78 86
79export interface AccessPolicyRecord {87export type ToolPolicyRecord = CoreToolPolicyRecord;
80 id: string;88export type ToolRoleRecord = CoreToolRoleRecord;
81 scopeType: PolicyScopeType;89export type ToolRoleRuleRecord = CoreToolRoleRuleRecord;
82 organizationId?: string;90export type ToolRoleBindingRecord = CoreToolRoleBindingRecord;
83 workspaceId?: string;
84 targetAccountId?: string;
85 clientId?: string;
86 resourceType: "tool_path";
87 resourcePattern: string;
88 matchType: PolicyMatchType;
89 effect: PolicyEffect;
90 approvalMode: PolicyApprovalMode;
91 argumentConditions?: ArgumentCondition[];
92 priority: number;
93 createdAt: number;
94 updatedAt: number;
95}
96 91
97export interface CredentialRecord {92export interface CredentialRecord {
98 id: string;93 id: string;
executor/packages/core/src/function-builders.ts+15−0
@@ -146,6 +146,11 @@ export const organizationQuery = convexCustomQuery(query, { · 145 unmodified lines
146 throw new Error("You are not a member of this organization");146 throw new Error("You are not a member of this organization");
147 }147 }
148 148
149 const organization = await ctx.db.get(args.organizationId);
150 if (!organization || organization.status !== "active") {
151 throw new Error("Organization is inactive");
152 }
153
149 ensureOrganizationRole(accountMembership.role, options);154 ensureOrganizationRole(accountMembership.role, options);
150 155
151 return {156 return {
@@ -172,6 +177,11 @@ export const organizationMutation = convexCustomMutation(mutation, { · 20 unmodified lines
172 throw new Error("You are not a member of this organization");177 throw new Error("You are not a member of this organization");
173 }178 }
174 179
180 const organization = await ctx.db.get(args.organizationId);
181 if (!organization || organization.status !== "active") {
182 throw new Error("Organization is inactive");
183 }
184
175 ensureOrganizationRole(accountMembership.role, options);185 ensureOrganizationRole(accountMembership.role, options);
176 186
177 return {187 return {
@@ -238,6 +248,11 @@ export const internalOrganizationQuery = convexCustomQuery(internalQuery, { · 60 unmodified lines
238 throw new Error("You are not a member of this organization");248 throw new Error("You are not a member of this organization");
239 }249 }
240 250
251 const organization = await ctx.db.get(args.organizationId);
252 if (!organization || organization.status !== "active") {
253 throw new Error("Organization is inactive");
254 }
255
241 ensureOrganizationRole(accountMembership.role, options);256 ensureOrganizationRole(accountMembership.role, options);
242 257
243 return {258 return {
executor/packages/core/src/mcp-server.ts+1−3
@@ -38,7 +38,6 @@ function createRunCodeTool( · 37 unmodified lines
38 timeoutMs?: number;38 timeoutMs?: number;
39 runtimeId?: string;39 runtimeId?: string;
40 metadata?: Record<string, unknown>;40 metadata?: Record<string, unknown>;
41 clientId?: string;
42 sessionId?: string;41 sessionId?: string;
43 waitForResult?: boolean;42 waitForResult?: boolean;
44 resultTimeoutMs?: number;43 resultTimeoutMs?: number;
@@ -62,7 +61,7 @@ function createRunCodeTool( · 17 unmodified lines
62 context = {61 context = {
63 workspaceId: anonymous.workspaceId,62 workspaceId: anonymous.workspaceId,
64 accountId: anonymous.accountId,63 accountId: anonymous.accountId,
65 clientId: input.clientId ?? anonymous.clientId,64 clientId: anonymous.clientId,
66 sessionId: anonymous.sessionId,65 sessionId: anonymous.sessionId,
67 };66 };
68 }67 }
@@ -178,7 +177,6 @@ const FULL_INPUT = z.object({ · 109 unmodified lines
178 timeoutMs: z.number().int().min(1).max(600_000).optional(),177 timeoutMs: z.number().int().min(1).max(600_000).optional(),
179 runtimeId: z.string().optional(),178 runtimeId: z.string().optional(),
180 metadata: z.record(z.unknown()).optional(),179 metadata: z.record(z.unknown()).optional(),
181 clientId: z.string().optional(),
182 sessionId: z.string().optional(),180 sessionId: z.string().optional(),
183 waitForResult: z.boolean().optional(),181 waitForResult: z.boolean().optional(),
184 resultTimeoutMs: z.number().int().min(100).max(900_000).optional(),182 resultTimeoutMs: z.number().int().min(100).max(900_000).optional(),
executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.ts+10−1
@@ -125,11 +125,20 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 124 unmodified lines
125 return mkError("Cloudflare sandbox execution response missing status");125 return mkError("Cloudflare sandbox execution response missing status");
126 }126 }
127 127
128 const normalizedError = typeof body.value.error === "string"
129 ? body.value.error.trim()
130 : "";
131 const error = normalizedError.length > 0
132 ? normalizedError
133 : body.value.status === "failed"
134 ? "Cloudflare sandbox execution failed without an error message"
135 : undefined;
136
128 return {137 return {
129 ok: true,138 ok: true,
130 status: body.value.status,139 status: body.value.status,
131 result: body.value.result,140 result: body.value.result,
132 error: body.value.error,141 error,
133 exitCode: body.value.exitCode,142 exitCode: body.value.exitCode,
134 durationMs: Date.now() - startedAt,143 durationMs: Date.now() - startedAt,
135 };144 };
executor/packages/core/src/types.ts+1−1
@@ -100,7 +100,7 @@ export interface ArgumentCondition { · 99 unmodified lines
100 value: string;100 value: string;
101}101}
102 102
103export interface AccessPolicyRecord {103export interface ToolPolicyRecord {
104 id: string;104 id: string;
105 scopeType: PolicyScopeType;105 scopeType: PolicyScopeType;
106 organizationId: Id<"organizations">;106 organizationId: Id<"organizations">;
executor/packages/database/convex/_generated/api.d.ts+13−106
@@ -104,7 +104,6 @@ export declare const api: { · 103 unmodified lines
104 "public",104 "public",
105 {105 {
106 accountId?: Id<"accounts">;106 accountId?: Id<"accounts">;
107 clientId?: string;
108 code: string;107 code: string;
109 metadata?: Record<string, any>;108 metadata?: Record<string, any>;
110 runtimeId?: string;109 runtimeId?: string;
@@ -136,7 +135,6 @@ export declare const api: { · 25 unmodified lines
136 {135 {
137 accountId?: Id<"accounts">;136 accountId?: Id<"accounts">;
138 buildId?: string;137 buildId?: string;
139 clientId?: string;
140 cursor?: string;138 cursor?: string;
141 fetchAll?: boolean;139 fetchAll?: boolean;
142 includeDetails?: boolean;140 includeDetails?: boolean;
@@ -156,7 +154,6 @@ export declare const api: { · 13 unmodified lines
156 "public",154 "public",
157 {155 {
158 accountId?: Id<"accounts">;156 accountId?: Id<"accounts">;
159 clientId?: string;
160 config: Record<string, any>;157 config: Record<string, any>;
161 name: string;158 name: string;
162 sessionId?: string;159 sessionId?: string;
@@ -176,7 +173,6 @@ export declare const api: { · 13 unmodified lines
176 organizationId: Id<"organizations">;173 organizationId: Id<"organizations">;
177 role: "owner" | "admin" | "member" | "billing_admin";174 role: "owner" | "admin" | "member" | "billing_admin";
178 sessionId?: string;175 sessionId?: string;
179 workspaceId?: Id<"workspaces">;
180 },176 },
181 any177 any
182 >;178 >;
@@ -307,13 +303,7 @@ export declare const api: { · 124 unmodified lines
307 bootstrapAnonymousSession: FunctionReference<303 bootstrapAnonymousSession: FunctionReference<
308 "mutation",304 "mutation",
309 "public",305 "public",
310 { accountId?: string; clientId?: string; sessionId?: string },306 { accountId?: string; sessionId?: string },
311 any
312 >;
313 deleteAccessPolicy: FunctionReference<
314 "mutation",
315 "public",
316 { policyId: string; sessionId?: string; workspaceId: Id<"workspaces"> },
317 any307 any
318 >;308 >;
319 deleteToolRole: FunctionReference<309 deleteToolRole: FunctionReference<
@@ -355,7 +345,6 @@ export declare const api: { · 35 unmodified lines
355 "mutation",345 "mutation",
356 "public",346 "public",
357 {347 {
358 clientId?: string;
359 sessionId?: string;348 sessionId?: string;
360 toolPaths: Array<string>;349 toolPaths: Array<string>;
361 workspaceId: Id<"workspaces">;350 workspaceId: Id<"workspaces">;
@@ -368,25 +357,25 @@ export declare const api: { · 6 unmodified lines
368 { sessionId?: string; workspaceId: Id<"workspaces"> },357 { sessionId?: string; workspaceId: Id<"workspaces"> },
369 any358 any
370 >;359 >;
371 listAccessPolicies: FunctionReference<360 listCredentials: FunctionReference<
372 "query",361 "query",
373 "public",362 "public",
374 { sessionId?: string; workspaceId: Id<"workspaces"> },363 { sessionId?: string; workspaceId: Id<"workspaces"> },
375 any364 any
376 >;365 >;
377 listCredentials: FunctionReference<366 listPendingApprovals: FunctionReference<
378 "query",367 "query",
379 "public",368 "public",
380 { sessionId?: string; workspaceId: Id<"workspaces"> },369 { sessionId?: string; workspaceId: Id<"workspaces"> },
381 any370 any
382 >;371 >;
383 listPendingApprovals: FunctionReference<372 listTasks: FunctionReference<
384 "query",373 "query",
385 "public",374 "public",
386 { sessionId?: string; workspaceId: Id<"workspaces"> },375 { sessionId?: string; workspaceId: Id<"workspaces"> },
387 any376 any
388 >;377 >;
389 listTasks: FunctionReference<378 listToolPolicies: FunctionReference<
390 "query",379 "query",
391 "public",380 "public",
392 { sessionId?: string; workspaceId: Id<"workspaces"> },381 { sessionId?: string; workspaceId: Id<"workspaces"> },
@@ -434,30 +423,6 @@ export declare const api: { · 41 unmodified lines
434 },423 },
435 any424 any
436 >;425 >;
437 upsertAccessPolicy: FunctionReference<
438 "mutation",
439 "public",
440 {
441 approvalMode?: "inherit" | "auto" | "required";
442 argumentConditions?: Array<{
443 key: string;
444 operator: "equals" | "contains" | "starts_with" | "not_equals";
445 value: string;
446 }>;
447 clientId?: string;
448 effect?: "allow" | "deny";
449 id?: string;
450 matchType?: "glob" | "exact";
451 priority?: number;
452 resourcePattern: string;
453 resourceType?: "all_tools" | "source" | "namespace" | "tool_path";
454 scopeType?: "account" | "organization" | "workspace";
455 sessionId?: string;
456 targetAccountId?: Id<"accounts">;
457 workspaceId: Id<"workspaces">;
458 },
459 any
460 >;
461 upsertCredential: FunctionReference<426 upsertCredential: FunctionReference<
462 "mutation",427 "mutation",
463 "public",428 "public",
@@ -777,12 +742,6 @@ export declare const internal: { · 313 unmodified lines
777 any742 any
778 >;743 >;
779 };744 };
780 deleteAccessPolicy: FunctionReference<
781 "mutation",
782 "internal",
783 { policyId: string; workspaceId: Id<"workspaces"> },
784 any
785 >;
786 deleteToolRole: FunctionReference<745 deleteToolRole: FunctionReference<
787 "mutation",746 "mutation",
788 "internal",747 "internal",
@@ -843,12 +802,6 @@ export declare const internal: { · 54 unmodified lines
843 { callId: string; taskId: string },802 { callId: string; taskId: string },
844 any803 any
845 >;804 >;
846 listAccessPolicies: FunctionReference<
847 "query",
848 "internal",
849 { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },
850 any
851 >;
852 listApprovals: FunctionReference<805 listApprovals: FunctionReference<
853 "query",806 "query",
854 "internal",807 "internal",
@@ -896,6 +849,12 @@ export declare const internal: { · 41 unmodified lines
896 { taskId: string },849 { taskId: string },
897 any850 any
898 >;851 >;
852 listToolPolicies: FunctionReference<
853 "query",
854 "internal",
855 { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },
856 any
857 >;
899 listToolRoleBindings: FunctionReference<858 listToolRoleBindings: FunctionReference<
900 "query",859 "query",
901 "internal",860 "internal",
@@ -938,12 +897,6 @@ export declare const internal: { · 36 unmodified lines
938 any897 any
939 >;898 >;
940 policies: {899 policies: {
941 deleteAccessPolicy: FunctionReference<
942 "mutation",
943 "internal",
944 { policyId: string; workspaceId: Id<"workspaces"> },
945 any
946 >;
947 deleteToolRole: FunctionReference<900 deleteToolRole: FunctionReference<
948 "mutation",901 "mutation",
949 "internal",902 "internal",
@@ -962,13 +915,13 @@ export declare const internal: { · 12 unmodified lines
962 { roleId: string; ruleId: string; workspaceId: Id<"workspaces"> },915 { roleId: string; ruleId: string; workspaceId: Id<"workspaces"> },
963 any916 any
964 >;917 >;
965 listAccessPolicies: FunctionReference<918 listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
919 listToolPolicies: FunctionReference<
966 "query",920 "query",
967 "internal",921 "internal",
968 { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },922 { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },
969 any923 any
970 >;924 >;
971 listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
972 listToolRoleBindings: FunctionReference<925 listToolRoleBindings: FunctionReference<
973 "query",926 "query",
974 "internal",927 "internal",
@@ -987,29 +940,6 @@ export declare const internal: { · 12 unmodified lines
987 { workspaceId: Id<"workspaces"> },940 { workspaceId: Id<"workspaces"> },
988 any941 any
989 >;942 >;
990 upsertAccessPolicy: FunctionReference<
991 "mutation",
992 "internal",
993 {
994 approvalMode?: "inherit" | "auto" | "required";
995 argumentConditions?: Array<{
996 key: string;
997 operator: "equals" | "contains" | "starts_with" | "not_equals";
998 value: string;
999 }>;
1000 clientId?: string;
1001 effect?: "allow" | "deny";
1002 id?: string;
1003 matchType?: "glob" | "exact";
1004 priority?: number;
1005 resourcePattern: string;
1006 resourceType?: "all_tools" | "source" | "namespace" | "tool_path";
1007 scopeType?: "account" | "organization" | "workspace";
1008 targetAccountId?: Id<"accounts">;
1009 workspaceId: Id<"workspaces">;
1010 },
1011 any
1012 >;
1013 upsertToolRole: FunctionReference<943 upsertToolRole: FunctionReference<
1014 "mutation",944 "mutation",
1015 "internal",945 "internal",
@@ -1230,29 +1160,6 @@ export declare const internal: { · 214 unmodified lines
1230 any1160 any
1231 >;1161 >;
1232 };1162 };
1233 upsertAccessPolicy: FunctionReference<
1234 "mutation",
1235 "internal",
1236 {
1237 approvalMode?: "inherit" | "auto" | "required";
1238 argumentConditions?: Array<{
1239 key: string;
1240 operator: "equals" | "contains" | "starts_with" | "not_equals";
1241 value: string;
1242 }>;
1243 clientId?: string;
1244 effect?: "allow" | "deny";
1245 id?: string;
1246 matchType?: "glob" | "exact";
1247 priority?: number;
1248 resourcePattern: string;
1249 resourceType?: "all_tools" | "source" | "namespace" | "tool_path";
1250 scopeType?: "account" | "organization" | "workspace";
1251 targetAccountId?: Id<"accounts">;
1252 workspaceId: Id<"workspaces">;
1253 },
1254 any
1255 >;
1256 upsertCredential: FunctionReference<1163 upsertCredential: FunctionReference<
1257 "mutation",1164 "mutation",
1258 "internal",1165 "internal",
executor/packages/database/convex/access-controls.test.ts+226−30
@@ -217,10 +217,9 @@ describe("authentication", () => { · 216 unmodified lines
217 const owner = await seedUser(t, { subject: "owner-2" });217 const owner = await seedUser(t, { subject: "owner-2" });
218 218
219 await expect(219 await expect(
220 t.mutation(api.workspace.upsertAccessPolicy, {220 t.mutation(api.workspace.upsertToolRole, {
221 workspaceId: owner.workspaceId,221 workspaceId: owner.workspaceId,
222 resourcePattern: "*",222 name: "anonymous-role",
223 effect: "allow",
224 }),223 }),
225 ).rejects.toThrow("Must be signed in");224 ).rejects.toThrow("Must be signed in");
226 });225 });
@@ -421,7 +420,7 @@ describe("workspace access controls", () => { · 194 unmodified lines
421 ).rejects.toThrow("You are not a member of this workspace");420 ).rejects.toThrow("You are not a member of this workspace");
422 });421 });
423 422
424 test("regular member cannot upsert access policies (admin-only)", async () => {423 test("regular member cannot upsert tool roles (admin-only)", async () => {
425 const t = setup();424 const t = setup();
426 const owner = await seedUser(t, { subject: "ws-admin-owner" });425 const owner = await seedUser(t, { subject: "ws-admin-owner" });
427 const member = await seedUser(t, { subject: "ws-plain-member" });426 const member = await seedUser(t, { subject: "ws-plain-member" });
@@ -440,15 +439,14 @@ describe("workspace access controls", () => { · 12 unmodified lines
440 const authedMember = t.withIdentity({ subject: "ws-plain-member" });439 const authedMember = t.withIdentity({ subject: "ws-plain-member" });
441 440
442 await expect(441 await expect(
443 authedMember.mutation(api.workspace.upsertAccessPolicy, {442 authedMember.mutation(api.workspace.upsertToolRole, {
444 workspaceId: owner.workspaceId,443 workspaceId: owner.workspaceId,
445 resourcePattern: "*",444 name: "member-managed-role",
446 effect: "allow",
447 }),445 }),
448 ).rejects.toThrow("Only organization admins can perform this action");446 ).rejects.toThrow("Only organization admins can perform this action");
449 });447 });
450 448
451 test("workspace admin can upsert access policies", async () => {449 test("workspace admin can configure tool policies via roles", async () => {
452 const t = setup();450 const t = setup();
453 const owner = await seedUser(t, { subject: "ws-policy-owner" });451 const owner = await seedUser(t, { subject: "ws-policy-owner" });
454 const admin = await seedUser(t, { subject: "ws-policy-admin" });452 const admin = await seedUser(t, { subject: "ws-policy-admin" });
@@ -466,16 +464,37 @@ describe("workspace access controls", () => { · 11 unmodified lines
466 464
467 const authedAdmin = t.withIdentity({ subject: "ws-policy-admin" });465 const authedAdmin = t.withIdentity({ subject: "ws-policy-admin" });
468 466
469 const policy = await authedAdmin.mutation(api.workspace.upsertAccessPolicy, {467 const role = await authedAdmin.mutation(api.workspace.upsertToolRole, {
470 workspaceId: owner.workspaceId,468 workspaceId: owner.workspaceId,
469 name: "policy-admin-role",
470 description: "workspace admin policy role",
471 });
472
473 await authedAdmin.mutation(api.workspace.upsertToolRoleRule, {
474 workspaceId: owner.workspaceId,
475 roleId: role.id,
476 selectorType: "tool_path",
471 resourcePattern: "*.delete",477 resourcePattern: "*.delete",
472 effect: "allow",478 effect: "allow",
473 approvalMode: "required",479 approvalMode: "required",
474 });480 });
475 481
476 expect(policy.effect).toBe("allow");482 await authedAdmin.mutation(api.workspace.upsertToolRoleBinding, {
477 expect(policy.approvalMode).toBe("required");483 workspaceId: owner.workspaceId,
478 expect(policy.resourcePattern).toBe("*.delete");484 roleId: role.id,
485 scopeType: "workspace",
486 status: "active",
487 });
488
489 const policies = await authedAdmin.query(api.workspace.listToolPolicies, {
490 workspaceId: owner.workspaceId,
491 });
492
493 expect(policies.some((policy: { resourcePattern?: string; effect?: string; approvalMode?: string }) => (
494 policy.resourcePattern === "*.delete"
495 && policy.effect === "allow"
496 && policy.approvalMode === "required"
497 ))).toBe(true);
479 });498 });
480 499
481 test("account-scoped policy target must be active org member", async () => {500 test("account-scoped policy target must be active org member", async () => {
@@ -484,29 +503,88 @@ describe("workspace access controls", () => { · 2 unmodified lines
484 const outsider = await seedUser(t, { subject: "policy-target-outsider" });503 const outsider = await seedUser(t, { subject: "policy-target-outsider" });
485 const authedOwner = t.withIdentity({ subject: "policy-target-owner" });504 const authedOwner = t.withIdentity({ subject: "policy-target-owner" });
486 505
506 const role = await authedOwner.mutation(api.workspace.upsertToolRole, {
507 workspaceId: owner.workspaceId,
508 name: "policy-target-role",
509 });
510
487 await expect(511 await expect(
488 authedOwner.mutation(api.workspace.upsertAccessPolicy, {512 authedOwner.mutation(api.workspace.upsertToolRoleBinding, {
489 workspaceId: owner.workspaceId,513 workspaceId: owner.workspaceId,
514 roleId: role.id,
490 scopeType: "account",515 scopeType: "account",
491 targetAccountId: outsider.accountId,516 targetAccountId: outsider.accountId,
492 resourcePattern: "*",517 status: "active",
493 effect: "allow",
494 }),518 }),
495 ).rejects.toThrow("targetAccountId must be an active member of this organization");519 ).rejects.toThrow("targetAccountId must be an active member of this organization");
496 });520 });
497 521
498 test("workspace owner can upsert access policies", async () => {522 test("workspace owner can configure tool policies", async () => {
499 const t = setup();523 const t = setup();
500 const owner = await seedUser(t, { subject: "ws-owner-policy" });524 const owner = await seedUser(t, { subject: "ws-owner-policy" });
501 const authedOwner = t.withIdentity({ subject: "ws-owner-policy" });525 const authedOwner = t.withIdentity({ subject: "ws-owner-policy" });
502 526
503 const policy = await authedOwner.mutation(api.workspace.upsertAccessPolicy, {527 const role = await authedOwner.mutation(api.workspace.upsertToolRole, {
504 workspaceId: owner.workspaceId,528 workspaceId: owner.workspaceId,
505 resourcePattern: "*",529 name: "owner-policy-role",
530 });
531
532 await authedOwner.mutation(api.workspace.upsertToolRoleRule, {
533 workspaceId: owner.workspaceId,
534 roleId: role.id,
535 selectorType: "all",
506 effect: "allow",536 effect: "allow",
537 approvalMode: "required",
538 });
539
540 await authedOwner.mutation(api.workspace.upsertToolRoleBinding, {
541 workspaceId: owner.workspaceId,
542 roleId: role.id,
543 scopeType: "workspace",
544 status: "active",
545 });
546
547 const policies = await authedOwner.query(api.workspace.listToolPolicies, {
548 workspaceId: owner.workspaceId,
549 });
550 expect(policies.some((policy: { roleId?: string }) => policy.roleId === role.id)).toBe(true);
551 });
552
553 test("workspace owner can delete role-managed tool policies", async () => {
554 const t = setup();
555 const owner = await seedUser(t, { subject: "ws-owner-policy-delete" });
556 const authedOwner = t.withIdentity({ subject: "ws-owner-policy-delete" });
557
558 const role = await authedOwner.mutation(api.workspace.upsertToolRole, {
559 workspaceId: owner.workspaceId,
560 name: "owner-policy-delete-role",
561 });
562
563 await authedOwner.mutation(api.workspace.upsertToolRoleRule, {
564 workspaceId: owner.workspaceId,
565 roleId: role.id,
566 selectorType: "tool_path",
567 resourcePattern: "github.repos.delete",
568 effect: "deny",
569 matchType: "exact",
570 });
571
572 await authedOwner.mutation(api.workspace.upsertToolRoleBinding, {
573 workspaceId: owner.workspaceId,
574 roleId: role.id,
575 scopeType: "workspace",
576 status: "active",
507 });577 });
508 578
509 expect(policy.effect).toBe("allow");579 await authedOwner.mutation(api.workspace.deleteToolRole, {
580 workspaceId: owner.workspaceId,
581 roleId: role.id,
582 });
583
584 const remaining = await authedOwner.query(api.workspace.listToolPolicies, {
585 workspaceId: owner.workspaceId,
586 });
587 expect(remaining.some((policy: { roleId?: string }) => policy.roleId === role.id)).toBe(false);
510 });588 });
511 589
512 test("regular member cannot upsert credentials (admin-only)", async () => {590 test("regular member cannot upsert credentials (admin-only)", async () => {
@@ -565,6 +643,60 @@ describe("workspace access controls", () => { · 52 unmodified lines
565 ).rejects.toThrow("Only organization admins can perform this action");643 ).rejects.toThrow("Only organization admins can perform this action");
566 });644 });
567 645
646 test("cannot hijack tool source id from another organization", async () => {
647 const t = setup();
648 const orgAOwner = await seedUser(t, { subject: "tool-source-org-a-owner" });
649 const orgBOwner = await seedUser(t, { subject: "tool-source-org-b-owner" });
650
651 const authedA = t.withIdentity({ subject: "tool-source-org-a-owner" });
652 const authedB = t.withIdentity({ subject: "tool-source-org-b-owner" });
653
654 const sourceB = await authedB.mutation(api.workspace.upsertToolSource, {
655 workspaceId: orgBOwner.workspaceId,
656 name: "org-b-source",
657 type: "mcp",
658 config: { url: "https://example.com/org-b" },
659 });
660
661 await expect(
662 authedA.mutation(api.workspace.upsertToolSource, {
663 workspaceId: orgAOwner.workspaceId,
664 id: sourceB.id,
665 name: "org-a-source",
666 type: "mcp",
667 config: { url: "https://example.com/org-a" },
668 }),
669 ).rejects.toThrow("Tool source id already exists in another organization");
670 });
671
672 test("cannot reuse workspace-scoped tool source id across workspaces", async () => {
673 const t = setup();
674 const owner = await seedUser(t, { subject: "tool-source-workspace-owner" });
675 const authedOwner = t.withIdentity({ subject: "tool-source-workspace-owner" });
676
677 const secondWorkspace = await authedOwner.mutation(api.workspaces.create, {
678 name: "Second Workspace",
679 organizationId: owner.organizationId,
680 });
681
682 const source = await authedOwner.mutation(api.workspace.upsertToolSource, {
683 workspaceId: owner.workspaceId,
684 name: "workspace-one-source",
685 type: "mcp",
686 config: { url: "https://example.com/ws1" },
687 });
688
689 await expect(
690 authedOwner.mutation(api.workspace.upsertToolSource, {
691 workspaceId: secondWorkspace.id,
692 id: source.id,
693 name: "workspace-two-source",
694 type: "mcp",
695 config: { url: "https://example.com/ws2" },
696 }),
697 ).rejects.toThrow("Tool source id belongs to a different workspace");
698 });
699
568 test("account-scoped credential target must be active org member", async () => {700 test("account-scoped credential target must be active org member", async () => {
569 const t = setup();701 const t = setup();
570 const owner = await seedUser(t, { subject: "cred-target-owner" });702 const owner = await seedUser(t, { subject: "cred-target-owner" });
@@ -656,15 +788,15 @@ describe("workspace access controls", () => { · 85 unmodified lines
656 status: "active",788 status: "active",
657 });789 });
658 790
659 const policies = await authedOwner.query(api.workspace.listAccessPolicies, {791 const policies = await authedOwner.query(api.workspace.listToolPolicies, {
660 workspaceId: owner.workspaceId,792 workspaceId: owner.workspaceId,
661 });793 });
662 794
663 expect(policies.some((policy) => policy.resourceType === "source" && policy.resourcePattern === "source:github"))795 expect(policies.some((policy: { resourceType?: string; resourcePattern?: string }) => policy.resourceType === "source" && policy.resourcePattern === "source:github"))
664 .toBe(true);796 .toBe(true);
665 });797 });
666 798
667 test("regular member can read access policies (no admin required)", async () => {799 test("regular member can read tool policies (no admin required)", async () => {
668 const t = setup();800 const t = setup();
669 const owner = await seedUser(t, { subject: "read-policy-owner" });801 const owner = await seedUser(t, { subject: "read-policy-owner" });
670 const member = await seedUser(t, { subject: "read-policy-member" });802 const member = await seedUser(t, { subject: "read-policy-member" });
@@ -682,7 +814,7 @@ describe("workspace access controls", () => { · 11 unmodified lines
682 814
683 const authedMember = t.withIdentity({ subject: "read-policy-member" });815 const authedMember = t.withIdentity({ subject: "read-policy-member" });
684 816
685 const policies = await authedMember.query(api.workspace.listAccessPolicies, {817 const policies = await authedMember.query(api.workspace.listToolPolicies, {
686 workspaceId: owner.workspaceId,818 workspaceId: owner.workspaceId,
687 });819 });
688 820
@@ -764,6 +896,55 @@ describe("organization access controls", () => { · 75 unmodified lines
764 ).rejects.toThrow("You are not a member of this organization");896 ).rejects.toThrow("You are not a member of this organization");
765 });897 });
766 898
899 test("inactive organization blocks organization queries", async () => {
900 const t = setup();
901 const owner = await seedUser(t, { subject: "inactive-org-query-owner" });
902
903 await t.run(async (ctx) => {
904 await ctx.db.patch(owner.organizationId, {
905 status: "deleted",
906 updatedAt: Date.now(),
907 });
908 });
909
910 const authedOwner = t.withIdentity({ subject: "inactive-org-query-owner" });
911
912 await expect(
913 authedOwner.query(api.organizationMembers.list, {
914 organizationId: owner.organizationId,
915 }),
916 ).rejects.toThrow("Organization is inactive");
917 });
918
919 test("inactive organization blocks organization mutations", async () => {
920 const t = setup();
921 const owner = await seedUser(t, { subject: "inactive-org-mutation-owner" });
922 const member = await seedUser(t, { subject: "inactive-org-mutation-member" });
923
924 await addOrgMember(t, {
925 organizationId: owner.organizationId,
926 accountId: member.accountId,
927 role: "member",
928 });
929
930 await t.run(async (ctx) => {
931 await ctx.db.patch(owner.organizationId, {
932 status: "deleted",
933 updatedAt: Date.now(),
934 });
935 });
936
937 const authedOwner = t.withIdentity({ subject: "inactive-org-mutation-owner" });
938
939 await expect(
940 authedOwner.mutation(api.organizationMembers.updateRole, {
941 organizationId: owner.organizationId,
942 accountId: member.accountId,
943 role: "admin",
944 }),
945 ).rejects.toThrow("Organization is inactive");
946 });
947
767 test("regular member cannot update roles (admin-only)", async () => {948 test("regular member cannot update roles (admin-only)", async () => {
768 const t = setup();949 const t = setup();
769 const owner = await seedUser(t, { subject: "role-owner" });950 const owner = await seedUser(t, { subject: "role-owner" });
@@ -931,7 +1112,7 @@ describe("cross-workspace isolation", () => { · 161 unmodified lines
931 ).rejects.toThrow("You are not a member of this workspace");1112 ).rejects.toThrow("You are not a member of this workspace");
932 });1113 });
933 1114
934 test("user in workspace A cannot manage policies in workspace B", async () => {1115 test("user in workspace A cannot manage tool policies in workspace B", async () => {
935 const t = setup();1116 const t = setup();
936 await seedUser(t, { subject: "policy-user-a" });1117 await seedUser(t, { subject: "policy-user-a" });
937 const userB = await seedUser(t, { subject: "policy-user-b" });1118 const userB = await seedUser(t, { subject: "policy-user-b" });
@@ -939,10 +1120,9 @@ describe("cross-workspace isolation", () => { · 1 unmodified line
939 const authedA = t.withIdentity({ subject: "policy-user-a" });1120 const authedA = t.withIdentity({ subject: "policy-user-a" });
940 1121
941 await expect(1122 await expect(
942 authedA.mutation(api.workspace.upsertAccessPolicy, {1123 authedA.mutation(api.workspace.upsertToolRole, {
943 workspaceId: userB.workspaceId,1124 workspaceId: userB.workspaceId,
944 resourcePattern: "*",1125 name: "cross-workspace-role",
945 effect: "allow",
946 }),1126 }),
947 ).rejects.toThrow("You are not a member of this workspace");1127 ).rejects.toThrow("You are not a member of this workspace");
948 });1128 });
@@ -1447,13 +1627,29 @@ describe("role hierarchy validation", () => { · 498 unmodified lines
1447 1627
1448 const authed = t.withIdentity({ subject: "admin-hier-admin" });1628 const authed = t.withIdentity({ subject: "admin-hier-admin" });
1449 1629
1450 // Admin can upsert access policies1630 // Admin can manage tool-policy roles/rules/bindings
1451 const policy = await authed.mutation(api.workspace.upsertAccessPolicy, {1631 const role = await authed.mutation(api.workspace.upsertToolRole, {
1632 workspaceId: org.workspaceId,
1633 name: "admin-hierarchy-role",
1634 });
1635 await authed.mutation(api.workspace.upsertToolRoleRule, {
1452 workspaceId: org.workspaceId,1636 workspaceId: org.workspaceId,
1637 roleId: role.id,
1638 selectorType: "tool_path",
1453 resourcePattern: "admin.*",1639 resourcePattern: "admin.*",
1454 effect: "deny",1640 effect: "deny",
1455 });1641 });
1456 expect(policy.effect).toBe("deny");1642 await authed.mutation(api.workspace.upsertToolRoleBinding, {
1643 workspaceId: org.workspaceId,
1644 roleId: role.id,
1645 scopeType: "workspace",
1646 status: "active",
1647 });
1648
1649 const policies = await authed.query(api.workspace.listToolPolicies, {
1650 workspaceId: org.workspaceId,
1651 });
1652 expect(policies.some((policy: { roleId?: string; effect?: string }) => policy.roleId === role.id && policy.effect === "deny")).toBe(true);
1457 1653
1458 // Admin can upsert tool sources1654 // Admin can upsert tool sources
1459 const source = await authed.mutation(api.workspace.upsertToolSource, {1655 const source = await authed.mutation(api.workspace.upsertToolSource, {
executor/packages/database/convex/app.ts+17−0
@@ -1,4 +1,10 @@
1import { customQuery, optionalAccountQuery } from "../../core/src/function-builders";1import { customQuery, optionalAccountQuery } from "../../core/src/function-builders";
2import {
3 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
4 LOCAL_BUN_RUNTIME_ID,
5 defaultRuntimeId,
6 isRuntimeEnabled,
7} from "../../core/src/runtimes/runtime-catalog";
2 8
3const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);9const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
4 10
@@ -6,10 +12,21 @@ export const getClientConfig = customQuery({ · 1 unmodified line
6 method: "GET",12 method: "GET",
7 args: {},13 args: {},
8 handler: async () => {14 handler: async () => {
15 const allowLocalVm = isRuntimeEnabled(LOCAL_BUN_RUNTIME_ID);
16 const enabledRuntimeIds = [
17 ...(allowLocalVm ? [LOCAL_BUN_RUNTIME_ID] : []),
18 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
19 ];
20
9 return {21 return {
10 authProviderMode: workosEnabled ? "workos" : "local",22 authProviderMode: workosEnabled ? "workos" : "local",
11 invitesProvider: workosEnabled ? "workos" : "disabled",23 invitesProvider: workosEnabled ? "workos" : "disabled",
12 anonymousAuthIssuer: process.env.ANONYMOUS_AUTH_ISSUER ?? null,24 anonymousAuthIssuer: process.env.ANONYMOUS_AUTH_ISSUER ?? null,
25 runtime: {
26 allowLocalVm,
27 defaultRuntimeId: defaultRuntimeId(),
28 enabledRuntimeIds,
29 },
13 features: {30 features: {
14 organizations: true,31 organizations: true,
15 billing: true,32 billing: true,
executor/packages/database/convex/database.ts+1−3
@@ -34,9 +34,7 @@ export { · 33 unmodified lines
34 upsertToolRoleBinding,34 upsertToolRoleBinding,
35 listToolRoleBindings,35 listToolRoleBindings,
36 deleteToolRoleBinding,36 deleteToolRoleBinding,
37 upsertAccessPolicy,37 listToolPolicies,
38 deleteAccessPolicy,
39 listAccessPolicies,
40} from "./database/policies";38} from "./database/policies";
41export {39export {
42 upsertCredential,40 upsertCredential,
executor/packages/database/convex/database/anonymous_session.ts+63−1
@@ -4,6 +4,59 @@ import { internalMutation } from "../_generated/server"; · 3 unmodified lines
4import { ensureAnonymousIdentity } from "../../src/database/anonymous";4import { ensureAnonymousIdentity } from "../../src/database/anonymous";
5import { mapAnonymousContext } from "../../src/database/readers";5import { mapAnonymousContext } from "../../src/database/readers";
6 6
7type TrustedClientId = "web" | "mcp";
8
9function parseTrustedClientId(value: string | undefined): TrustedClientId | undefined {
10 const candidate = value?.trim();
11 if (!candidate) {
12 return undefined;
13 }
14
15 if (candidate === "web" || candidate === "mcp") {
16 return candidate;
17 }
18
19 throw new Error("clientId must be one of: web, mcp");
20}
21
22function parseStoredTrustedClientId(value: string | undefined): TrustedClientId | undefined {
23 const candidate = value?.trim();
24 if (!candidate) {
25 return undefined;
26 }
27
28 if (candidate === "web" || candidate === "mcp") {
29 return candidate;
30 }
31
32 return undefined;
33}
34
35function defaultClientIdForSessionId(sessionId: string): TrustedClientId {
36 if (sessionId.startsWith("mcp_")) {
37 return "mcp";
38 }
39
40 return "web";
41}
42
43function resolveTrustedClientId(args: {
44 sessionId: string;
45 requestedClientId?: TrustedClientId;
46 existingClientId?: string;
47}): TrustedClientId {
48 if (args.requestedClientId) {
49 return args.requestedClientId;
50 }
51
52 const existingClientId = parseStoredTrustedClientId(args.existingClientId);
53 if (existingClientId) {
54 return existingClientId;
55 }
56
57 return defaultClientIdForSessionId(args.sessionId);
58}
59
7export const bootstrapAnonymousSession = internalMutation({60export const bootstrapAnonymousSession = internalMutation({
8 args: {61 args: {
9 sessionId: v.optional(v.string()),62 sessionId: v.optional(v.string()),
@@ -14,7 +67,7 @@ export const bootstrapAnonymousSession = internalMutation({ · 4 unmodified lines
14 const now = Date.now();67 const now = Date.now();
15 const requestedSessionId = args.sessionId?.trim() || "";68 const requestedSessionId = args.sessionId?.trim() || "";
16 const requestedAccountId = args.accountId?.trim() || "";69 const requestedAccountId = args.accountId?.trim() || "";
17 const clientId = args.clientId?.trim() || "web";70 const requestedClientId = parseTrustedClientId(args.clientId);
18 71
19 const allowRequestedSessionId = requestedSessionId.startsWith("mcp_")72 const allowRequestedSessionId = requestedSessionId.startsWith("mcp_")
20 || requestedSessionId.startsWith("anon_session_");73 || requestedSessionId.startsWith("anon_session_");
@@ -26,6 +79,11 @@ export const bootstrapAnonymousSession = internalMutation({ · 5 unmodified lines
26 .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))79 .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
27 .unique();80 .unique();
28 if (existing) {81 if (existing) {
82 const clientId = resolveTrustedClientId({
83 sessionId,
84 requestedClientId,
85 existingClientId: existing.clientId,
86 });
29 const identity = await ensureAnonymousIdentity(ctx, {87 const identity = await ensureAnonymousIdentity(ctx, {
30 sessionId,88 sessionId,
31 workspaceId: existing.workspaceId,89 workspaceId: existing.workspaceId,
@@ -57,6 +115,10 @@ export const bootstrapAnonymousSession = internalMutation({ · 25 unmodified lines
57 const sessionId = allowRequestedSessionId115 const sessionId = allowRequestedSessionId
58 ? requestedSessionId as string116 ? requestedSessionId as string
59 : generatedSessionId;117 : generatedSessionId;
118 const clientId = resolveTrustedClientId({
119 sessionId,
120 requestedClientId,
121 });
60 122
61 const identity = await ensureAnonymousIdentity(ctx, {123 const identity = await ensureAnonymousIdentity(ctx, {
62 sessionId,124 sessionId,
executor/packages/database/convex/database/policies.ts+20−293
@@ -4,7 +4,7 @@ import type { Id } from "../_generated/dataModel"; · 3 unmodified lines
4import type { MutationCtx, QueryCtx } from "../_generated/server";4import type { MutationCtx, QueryCtx } from "../_generated/server";
5import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog";5import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog";
6import type {6import type {
7 AccessPolicyRecord,7 ToolPolicyRecord,
8 ToolRoleBindingRecord,8 ToolRoleBindingRecord,
9 ToolRoleRecord,9 ToolRoleRecord,
10 ToolRoleRuleRecord,10 ToolRoleRuleRecord,
@@ -15,18 +15,17 @@ import { · 4 unmodified lines
15 policyApprovalModeValidator,15 policyApprovalModeValidator,
16 policyEffectValidator,16 policyEffectValidator,
17 policyMatchTypeValidator,17 policyMatchTypeValidator,
18 policyResourceTypeValidator,
19 policyScopeTypeValidator,18 policyScopeTypeValidator,
20 toolRoleBindingStatusValidator,19 toolRoleBindingStatusValidator,
21 toolRoleSelectorTypeValidator,20 toolRoleSelectorTypeValidator,
22} from "../../src/database/validators";21} from "../../src/database/validators";
23 22
24type PolicyResourceType = AccessPolicyRecord["resourceType"];23type PolicyResourceType = ToolPolicyRecord["resourceType"];
25type DbContext = Pick<MutationCtx, "db"> | Pick<QueryCtx, "db">;24type DbContext = Pick<MutationCtx, "db"> | Pick<QueryCtx, "db">;
26 25
27function normalizeArgumentConditions(26function normalizeArgumentConditions(
28 argumentConditions: AccessPolicyRecord["argumentConditions"] | undefined,27 argumentConditions: ToolPolicyRecord["argumentConditions"] | undefined,
29): AccessPolicyRecord["argumentConditions"] | undefined {28): ToolPolicyRecord["argumentConditions"] | undefined {
30 if (!argumentConditions || argumentConditions.length === 0) {29 if (!argumentConditions || argumentConditions.length === 0) {
31 return undefined;30 return undefined;
32 }31 }
@@ -42,22 +41,6 @@ function normalizeArgumentConditions( · 9 unmodified lines
42 return normalized.length > 0 ? normalized : undefined;41 return normalized.length > 0 ? normalized : undefined;
43}42}
44 43
45function selectorTypeFromResourceType(resourceType: PolicyResourceType): ToolRoleSelectorType {
46 if (resourceType === "all_tools") {
47 return "all";
48 }
49
50 if (resourceType === "source") {
51 return "source";
52 }
53
54 if (resourceType === "namespace") {
55 return "namespace";
56 }
57
58 return "tool_path";
59}
60
61function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): PolicyResourceType {44function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): PolicyResourceType {
62 if (selectorType === "all") {45 if (selectorType === "all") {
63 return "all_tools";46 return "all_tools";
@@ -74,69 +57,6 @@ function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): Polic · 10 unmodified lines
74 return "tool_path";57 return "tool_path";
75}58}
76 59
77function normalizePolicyResourceInput(args: {
78 resourceType?: PolicyResourceType;
79 resourcePattern: string;
80}): {
81 resourceType: PolicyResourceType;
82 resourcePattern: string;
83} {
84 const trimmedPattern = args.resourcePattern.trim();
85 const inferredType = trimmedPattern === "*" ? "all_tools" : "tool_path";
86 const resourceType = args.resourceType ?? inferredType;
87
88 if (resourceType === "all_tools") {
89 return {
90 resourceType,
91 resourcePattern: "*",
92 };
93 }
94
95 if (trimmedPattern.length === 0) {
96 throw new Error("resourcePattern is required");
97 }
98
99 return {
100 resourceType,
101 resourcePattern: trimmedPattern,
102 };
103}
104
105function selectorFieldsFromResource(
106 resourceType: PolicyResourceType,
107 resourcePattern: string,
108): {
109 selectorType: ToolRoleSelectorType;
110 sourceKey?: string;
111 namespacePattern?: string;
112 toolPathPattern?: string;
113} {
114 if (resourceType === "all_tools") {
115 return {
116 selectorType: "all",
117 };
118 }
119
120 if (resourceType === "source") {
121 return {
122 selectorType: "source",
123 sourceKey: resourcePattern,
124 };
125 }
126
127 if (resourceType === "namespace") {
128 return {
129 selectorType: "namespace",
130 namespacePattern: resourcePattern,
131 };
132 }
133
134 return {
135 selectorType: "tool_path",
136 toolPathPattern: resourcePattern,
137 };
138}
139
140function resourcePatternFromRule(rule: {60function resourcePatternFromRule(rule: {
141 selectorType: ToolRoleSelectorType;61 selectorType: ToolRoleSelectorType;
142 sourceKey?: string;62 sourceKey?: string;
@@ -183,10 +103,10 @@ function mapToolRoleRule(doc: { · 40 unmodified lines
183 sourceKey?: string;103 sourceKey?: string;
184 namespacePattern?: string;104 namespacePattern?: string;
185 toolPathPattern?: string;105 toolPathPattern?: string;
186 matchType: AccessPolicyRecord["matchType"];106 matchType: ToolPolicyRecord["matchType"];
187 effect: AccessPolicyRecord["effect"];107 effect: ToolPolicyRecord["effect"];
188 approvalMode: AccessPolicyRecord["approvalMode"];108 approvalMode: ToolPolicyRecord["approvalMode"];
189 argumentConditions?: AccessPolicyRecord["argumentConditions"];109 argumentConditions?: ToolPolicyRecord["argumentConditions"];
190 priority: number;110 priority: number;
191 createdAt: number;111 createdAt: number;
192 updatedAt: number;112 updatedAt: number;
@@ -213,7 +133,7 @@ function mapToolRoleBinding(doc: { · 20 unmodified lines
213 bindingId: string;133 bindingId: string;
214 roleId: string;134 roleId: string;
215 organizationId: Id<"organizations">;135 organizationId: Id<"organizations">;
216 scopeType: AccessPolicyRecord["scopeType"];136 scopeType: ToolPolicyRecord["scopeType"];
217 workspaceId?: Id<"workspaces">;137 workspaceId?: Id<"workspaces">;
218 targetAccountId?: Id<"accounts">;138 targetAccountId?: Id<"accounts">;
219 clientId?: string;139 clientId?: string;
@@ -238,9 +158,9 @@ function mapToolRoleBinding(doc: { · 18 unmodified lines
238}158}
239 159
240function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): string {160function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): string {
241 const rolePrefix = "policy_role_";161 const rolePrefix = "tool_policy_role_";
242 const rulePrefix = "policy_rule_";162 const rulePrefix = "tool_policy_rule_";
243 const bindingPrefix = "policy_binding_";163 const bindingPrefix = "tool_policy_binding_";
244 if (164 if (
245 roleId.startsWith(rolePrefix)165 roleId.startsWith(rolePrefix)
246 && ruleId.startsWith(rulePrefix)166 && ruleId.startsWith(rulePrefix)
@@ -259,24 +179,24 @@ function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): str · 12 unmodified lines
259 179
260function mapFlattenedPolicy(doc: {180function mapFlattenedPolicy(doc: {
261 policyId: string;181 policyId: string;
262 scopeType: AccessPolicyRecord["scopeType"];182 scopeType: ToolPolicyRecord["scopeType"];
263 organizationId: Id<"organizations">;183 organizationId: Id<"organizations">;
264 workspaceId?: Id<"workspaces">;184 workspaceId?: Id<"workspaces">;
265 targetAccountId?: Id<"accounts">;185 targetAccountId?: Id<"accounts">;
266 clientId?: string;186 clientId?: string;
267 resourceType: PolicyResourceType;187 resourceType: PolicyResourceType;
268 resourcePattern: string;188 resourcePattern: string;
269 matchType: AccessPolicyRecord["matchType"];189 matchType: ToolPolicyRecord["matchType"];
270 effect: AccessPolicyRecord["effect"];190 effect: ToolPolicyRecord["effect"];
271 approvalMode: AccessPolicyRecord["approvalMode"];191 approvalMode: ToolPolicyRecord["approvalMode"];
272 argumentConditions?: AccessPolicyRecord["argumentConditions"];192 argumentConditions?: ToolPolicyRecord["argumentConditions"];
273 priority: number;193 priority: number;
274 roleId: string;194 roleId: string;
275 ruleId: string;195 ruleId: string;
276 bindingId: string;196 bindingId: string;
277 createdAt: number;197 createdAt: number;
278 updatedAt: number;198 updatedAt: number;
279}): AccessPolicyRecord {199}): ToolPolicyRecord {
280 return {200 return {
281 id: doc.policyId,201 id: doc.policyId,
282 scopeType: doc.scopeType,202 scopeType: doc.scopeType,
@@ -805,200 +725,7 @@ export const deleteToolRoleBinding = internalMutation({ · 522 unmodified lines
805 },725 },
806});726});
807 727
808export const upsertAccessPolicy = internalMutation({728export const listToolPolicies = internalQuery({
809 args: {
810 id: v.optional(v.string()),
811 workspaceId: v.id("workspaces"),
812 scopeType: v.optional(policyScopeTypeValidator),
813 targetAccountId: v.optional(v.id("accounts")),
814 clientId: v.optional(v.string()),
815 resourceType: v.optional(policyResourceTypeValidator),
816 resourcePattern: v.string(),
817 matchType: v.optional(policyMatchTypeValidator),
818 effect: v.optional(policyEffectValidator),
819 approvalMode: v.optional(policyApprovalModeValidator),
820 argumentConditions: v.optional(v.array(argumentConditionValidator)),
821 priority: v.optional(v.number()),
822 },
823 handler: async (ctx, args) => {
824 const now = Date.now();
825 const workspace = await requireWorkspaceWithOrganization(ctx, args.workspaceId);
826 const policyId = args.id ?? `policy_${crypto.randomUUID()}`;
827 const scopeType = args.scopeType ?? "workspace";
828 const targetAccountId = scopeType === "account" ? args.targetAccountId : undefined;
829 if (scopeType === "account" && !targetAccountId) {
830 throw new Error("targetAccountId is required for account-scoped policies");
831 }
832 if (targetAccountId) {
833 await assertActiveOrgMember(ctx, {
834 organizationId: workspace.organizationId,
835 accountId: targetAccountId,
836 fieldLabel: "targetAccountId",
837 });
838 }
839
840 const resourceInput = normalizePolicyResourceInput({
841 resourceType: args.resourceType,
842 resourcePattern: args.resourcePattern,
843 });
844 const selectorFields = selectorFieldsFromResource(resourceInput.resourceType, resourceInput.resourcePattern);
845
846 const roleId = `policy_role_${policyId}`;
847 const ruleId = `policy_rule_${policyId}`;
848 const bindingId = `policy_binding_${policyId}`;
849
850 const existingRole = await ctx.db
851 .query("toolRoles")
852 .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
853 .unique();
854 if (existingRole) {
855 if (existingRole.organizationId !== workspace.organizationId) {
856 throw new Error("Policy role does not belong to this organization");
857 }
858 await ctx.db.patch(existingRole._id, {
859 name: `policy:${policyId}`,
860 description: "Direct access policy",
861 updatedAt: now,
862 });
863 } else {
864 await ctx.db.insert("toolRoles", {
865 roleId,
866 organizationId: workspace.organizationId,
867 name: `policy:${policyId}`,
868 description: "Direct access policy",
869 createdAt: now,
870 updatedAt: now,
871 });
872 }
873
874 const normalizedConditions = normalizeArgumentConditions(args.argumentConditions);
875 const existingRule = await ctx.db
876 .query("toolRoleRules")
877 .withIndex("by_rule_id", (q) => q.eq("ruleId", ruleId))
878 .unique();
879 const rulePayload = {
880 roleId,
881 organizationId: workspace.organizationId,
882 selectorType: selectorFields.selectorType,
883 sourceKey: selectorFields.sourceKey,
884 namespacePattern: selectorFields.namespacePattern,
885 toolPathPattern: selectorFields.toolPathPattern,
886 matchType: args.matchType ?? "glob",
887 effect: args.effect ?? "allow",
888 approvalMode: args.approvalMode ?? "required",
889 argumentConditions: normalizedConditions,
890 priority: args.priority ?? 100,
891 updatedAt: now,
892 };
893 if (existingRule) {
894 await ctx.db.patch(existingRule._id, rulePayload);
895 } else {
896 await ctx.db.insert("toolRoleRules", {
897 ruleId,
898 ...rulePayload,
899 createdAt: now,
900 });
901 }
902
903 const existingBinding = await ctx.db
904 .query("toolRoleBindings")
905 .withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
906 .unique();
907 const bindingPayload = {
908 roleId,
909 organizationId: workspace.organizationId,
910 scopeType,
911 workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
912 targetAccountId,
913 clientId: args.clientId?.trim() || undefined,
914 status: "active" as const,
915 expiresAt: undefined,
916 updatedAt: now,
917 };
918 if (existingBinding) {
919 await ctx.db.patch(existingBinding._id, bindingPayload);
920 } else {
921 await ctx.db.insert("toolRoleBindings", {
922 bindingId,
923 ...bindingPayload,
924 createdAt: now,
925 });
926 }
927
928 return mapFlattenedPolicy({
929 policyId,
930 scopeType,
931 organizationId: workspace.organizationId,
932 workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
933 targetAccountId,
934 clientId: args.clientId?.trim() || undefined,
935 resourceType: resourceInput.resourceType,
936 resourcePattern: resourceInput.resourcePattern,
937 matchType: args.matchType ?? "glob",
938 effect: args.effect ?? "allow",
939 approvalMode: args.approvalMode ?? "required",
940 argumentConditions: normalizedConditions,
941 priority: args.priority ?? 100,
942 roleId,
943 ruleId,
944 bindingId,
945 createdAt: now,
946 updatedAt: now,
947 });
948 },
949});
950
951export const deleteAccessPolicy = internalMutation({
952 args: {
953 policyId: v.string(),
954 workspaceId: v.id("workspaces"),
955 },
956 handler: async (ctx, args) => {
957 const workspace = await requireWorkspaceWithOrganization(ctx, args.workspaceId);
958 const roleId = `policy_role_${args.policyId}`;
959 const ruleId = `policy_rule_${args.policyId}`;
960 const bindingId = `policy_binding_${args.policyId}`;
961
962 const [role, rule, binding] = await Promise.all([
963 ctx.db
964 .query("toolRoles")
965 .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
966 .unique(),
967 ctx.db
968 .query("toolRoleRules")
969 .withIndex("by_rule_id", (q) => q.eq("ruleId", ruleId))
970 .unique(),
971 ctx.db
972 .query("toolRoleBindings")
973 .withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
974 .unique(),
975 ]);
976
977 if (
978 !role
979 || role.organizationId !== workspace.organizationId
980 || !rule
981 || rule.organizationId !== workspace.organizationId
982 || !binding
983 || binding.organizationId !== workspace.organizationId
984 ) {
985 throw new Error(`Policy not found: ${args.policyId}`);
986 }
987
988 if (binding.scopeType === "workspace" && binding.workspaceId !== args.workspaceId) {
989 throw new Error("Policy does not belong to this workspace");
990 }
991
992 await Promise.all([
993 ctx.db.delete(rule._id),
994 ctx.db.delete(binding._id),
995 ctx.db.delete(role._id),
996 ]);
997 return { ok: true as const };
998 },
999});
1000
1001export const listAccessPolicies = internalQuery({
1002 args: {729 args: {
1003 workspaceId: v.id("workspaces"),730 workspaceId: v.id("workspaces"),
1004 accountId: v.optional(v.id("accounts")),731 accountId: v.optional(v.id("accounts")),
@@ -1020,7 +747,7 @@ export const listAccessPolicies = internalQuery({ · 15 unmodified lines
1020 }));747 }));
1021 const rulesByRole = new Map(roleIds.map((roleId, index) => [roleId, roleRules[index] ?? []]));748 const rulesByRole = new Map(roleIds.map((roleId, index) => [roleId, roleRules[index] ?? []]));
1022 749
1023 const policies: AccessPolicyRecord[] = [];750 const policies: ToolPolicyRecord[] = [];
1024 for (const binding of bindings) {751 for (const binding of bindings) {
1025 const rules = rulesByRole.get(binding.roleId) ?? [];752 const rules = rulesByRole.get(binding.roleId) ?? [];
1026 for (const rule of rules) {753 for (const rule of rules) {
executor/packages/database/convex/database/tool_sources.ts+12−0
@@ -61,6 +61,18 @@ export const upsertToolSource = internalMutation({ · 60 unmodified lines
61 }61 }
62 62
63 if (existing) {63 if (existing) {
64 if (existing.organizationId !== organizationId) {
65 throw new Error("Tool source id already exists in another organization");
66 }
67
68 if (existing.scopeType !== scopeType) {
69 throw new Error("Tool source scope cannot be changed for an existing id");
70 }
71
72 if (scopeType === "workspace" && existing.workspaceId !== args.workspaceId) {
73 throw new Error("Tool source id belongs to a different workspace");
74 }
75
64 await ctx.db.patch(existing._id, {76 await ctx.db.patch(existing._id, {
65 scopeType,77 scopeType,
66 organizationId,78 organizationId,
executor/packages/database/convex/executor.ts+0−1
@@ -21,7 +21,6 @@ export const createTask = customAction({ · 20 unmodified lines
21 workspaceId: v.id("workspaces"),21 workspaceId: v.id("workspaces"),
22 sessionId: v.optional(v.string()),22 sessionId: v.optional(v.string()),
23 accountId: v.optional(v.id("accounts")),23 accountId: v.optional(v.id("accounts")),
24 clientId: v.optional(v.string()),
25 waitForResult: v.optional(v.boolean()),24 waitForResult: v.optional(v.boolean()),
26 },25 },
27 handler: async (ctx, args) => {26 handler: async (ctx, args) => {
executor/packages/database/convex/executorNode.ts+7−9
@@ -28,7 +28,6 @@ export const listToolsWithWarnings = customAction({ · 27 unmodified lines
28 args: {28 args: {
29 workspaceId: v.id("workspaces"),29 workspaceId: v.id("workspaces"),
30 accountId: v.optional(v.id("accounts")),30 accountId: v.optional(v.id("accounts")),
31 clientId: v.optional(v.string()),
32 sessionId: v.optional(v.string()),31 sessionId: v.optional(v.string()),
33 includeDetails: v.optional(v.boolean()),32 includeDetails: v.optional(v.boolean()),
34 includeSourceMeta: v.optional(v.boolean()),33 includeSourceMeta: v.optional(v.boolean()),
@@ -73,14 +72,14 @@ export const listToolsWithWarnings = customAction({ · 38 unmodified lines
73 await rebuildWorkspaceToolInventoryForContext(ctx, {72 await rebuildWorkspaceToolInventoryForContext(ctx, {
74 workspaceId: args.workspaceId,73 workspaceId: args.workspaceId,
75 accountId: access.accountId,74 accountId: access.accountId,
76 clientId: args.clientId,75 clientId: access.clientId,
77 });76 });
78 }77 }
79 78
80 const inventory = await listToolsWithWarningsForContext(ctx, {79 const inventory = await listToolsWithWarningsForContext(ctx, {
81 workspaceId: args.workspaceId,80 workspaceId: args.workspaceId,
82 accountId: access.accountId,81 accountId: access.accountId,
83 clientId: args.clientId,82 clientId: access.clientId,
84 }, {83 }, {
85 includeDetails: args.includeDetails ?? false,84 includeDetails: args.includeDetails ?? false,
86 includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true),85 includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true),
@@ -150,7 +149,6 @@ export const previewOpenApiSourceUpgrade = customAction({ · 63 unmodified lines
150 args: {149 args: {
151 workspaceId: v.id("workspaces"),150 workspaceId: v.id("workspaces"),
152 accountId: v.optional(v.id("accounts")),151 accountId: v.optional(v.id("accounts")),
153 clientId: v.optional(v.string()),
154 sessionId: v.optional(v.string()),152 sessionId: v.optional(v.string()),
155 sourceId: v.string(),153 sourceId: v.string(),
156 name: v.string(),154 name: v.string(),
@@ -165,11 +163,11 @@ export const previewOpenApiSourceUpgrade = customAction({ · 8 unmodified lines
165 163
166 return await previewOpenApiSourceUpgradeForContext(164 return await previewOpenApiSourceUpgradeForContext(
167 ctx,165 ctx,
168 {166 {
169 workspaceId: args.workspaceId,167 workspaceId: args.workspaceId,
170 accountId: access.accountId,168 accountId: access.accountId,
171 clientId: args.clientId,169 clientId: access.clientId,
172 },170 },
173 {171 {
174 sourceId: args.sourceId,172 sourceId: args.sourceId,
175 name: args.name,173 name: args.name,
executor/packages/database/convex/http/mcp_auth.ts+2−4
@@ -16,7 +16,6 @@ type VerifiedMcpToken = { provider: "workos"; subject: string }; · 15 unmodified lines
16type ParsedMcpContext = {16type ParsedMcpContext = {
17 workspaceId?: Id<"workspaces">;17 workspaceId?: Id<"workspaces">;
18 accountId?: string;18 accountId?: string;
19 clientId?: string;
20 sessionId?: string;19 sessionId?: string;
21};20};
22 21
@@ -148,10 +147,9 @@ export function parseMcpContext(url: URL): ParsedMcpContext | undefined { · 125 unmodified lines
148 const raw = url.searchParams.get("workspaceId");147 const raw = url.searchParams.get("workspaceId");
149 const workspaceId = raw ? parseWorkspaceId(raw) : undefined;148 const workspaceId = raw ? parseWorkspaceId(raw) : undefined;
150 const accountId = url.searchParams.get("accountId") ?? undefined;149 const accountId = url.searchParams.get("accountId") ?? undefined;
151 const clientId = url.searchParams.get("clientId") ?? undefined;
152 const sessionId = url.searchParams.get("sessionId") ?? undefined;150 const sessionId = url.searchParams.get("sessionId") ?? undefined;
153 if (!workspaceId && !accountId && !clientId && !sessionId) {151 if (!workspaceId && !accountId && !sessionId) {
154 return undefined;152 return undefined;
155 }153 }
156 return { workspaceId, accountId, clientId, sessionId };154 return { workspaceId, accountId, sessionId };
157}155}
executor/packages/database/convex/http/mcp_handler.ts+3−3
@@ -103,7 +103,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 102 unmodified lines
103 context = {103 context = {
104 workspaceId,104 workspaceId,
105 accountId: access.accountId,105 accountId: access.accountId,
106 clientId: requestedContext?.clientId,106 clientId: "mcp",
107 };107 };
108 } catch (error) {108 } catch (error) {
109 return Response.json(109 return Response.json(
@@ -144,7 +144,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 34 unmodified lines
144 context = {144 context = {
145 workspaceId,145 workspaceId,
146 accountId: access.accountId,146 accountId: access.accountId,
147 clientId: requestedContext?.clientId,147 clientId: "mcp",
148 };148 };
149 } else {149 } else {
150 if (mcpAuthConfig.enabled && !requestedContext?.sessionId) {150 if (mcpAuthConfig.enabled && !requestedContext?.sessionId) {
@@ -166,7 +166,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 15 unmodified lines
166 context = {166 context = {
167 workspaceId,167 workspaceId,
168 accountId: access.accountId,168 accountId: access.accountId,
169 clientId: requestedContext?.clientId,169 clientId: "mcp",
170 sessionId: requestedContext?.sessionId,170 sessionId: requestedContext?.sessionId,
171 };171 };
172 }172 }
executor/packages/database/convex/http/mcp_service.ts+4−1
@@ -78,7 +78,10 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 77 unmodified lines
78 };78 };
79 },79 },
80 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {80 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {
81 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId });81 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
82 sessionId,
83 clientId: "mcp",
84 });
82 },85 },
83 listTools: async (86 listTools: async (
84 toolContext?: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string },87 toolContext?: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string },
executor/packages/database/convex/invites.ts+0−9
@@ -53,7 +53,6 @@ export const create = organizationMutation({ · 52 unmodified lines
53 args: {53 args: {
54 email: v.string(),54 email: v.string(),
55 role: organizationRoleValidator,55 role: organizationRoleValidator,
56 workspaceId: v.optional(v.id("workspaces")),
57 expiresInDays: v.optional(v.number()),56 expiresInDays: v.optional(v.number()),
58 },57 },
59 handler: async (ctx, args) => {58 handler: async (ctx, args) => {
@@ -78,13 +77,6 @@ export const create = organizationMutation({ · 18 unmodified lines
78 const expiresAt = now + (args.expiresInDays ?? 7) * 24 * 60 * 60 * 1000;77 const expiresAt = now + (args.expiresInDays ?? 7) * 24 * 60 * 60 * 1000;
79 const normalizedEmail = args.email.toLowerCase().trim();78 const normalizedEmail = args.email.toLowerCase().trim();
80 79
81 if (args.workspaceId) {
82 const workspace = await ctx.db.get(args.workspaceId);
83 if (workspace?.organizationId !== ctx.organizationId) {
84 throw new Error("Workspace does not belong to this organization");
85 }
86 }
87
88 if (ctx.account.provider !== "workos") {80 if (ctx.account.provider !== "workos") {
89 throw new Error("Inviter is not linked to WorkOS");81 throw new Error("Inviter is not linked to WorkOS");
90 }82 }
@@ -92,7 +84,6 @@ export const create = organizationMutation({ · 1 unmodified line
92 84
93 const inviteId = await ctx.db.insert("invites", {85 const inviteId = await ctx.db.insert("invites", {
94 organizationId: ctx.organizationId,86 organizationId: ctx.organizationId,
95 ...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
96 email: normalizedEmail,87 email: normalizedEmail,
97 role: args.role,88 role: args.role,
98 status: "pending",89 status: "pending",
executor/packages/database/convex/workspace.ts+3−70
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
9 policyApprovalModeValidator,9 policyApprovalModeValidator,
10 policyEffectValidator,10 policyEffectValidator,
11 policyMatchTypeValidator,11 policyMatchTypeValidator,
12 policyResourceTypeValidator,
13 policyScopeTypeValidator,12 policyScopeTypeValidator,
14 toolRoleBindingStatusValidator,13 toolRoleBindingStatusValidator,
15 toolRoleSelectorTypeValidator,14 toolRoleSelectorTypeValidator,
@@ -56,7 +55,6 @@ export const bootstrapAnonymousSession = customMutation({ · 40 unmodified lines
56 args: {55 args: {
57 sessionId: v.optional(v.string()),56 sessionId: v.optional(v.string()),
58 accountId: v.optional(v.string()),57 accountId: v.optional(v.string()),
59 clientId: v.optional(v.string()),
60 },58 },
61 handler: async (ctx, args) => {59 handler: async (ctx, args) => {
62 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, args);60 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, args);
@@ -127,75 +125,11 @@ export const listPendingApprovals = workspaceQuery({ · 64 unmodified lines
127 },125 },
128});126});
129 127
130export const upsertAccessPolicy = workspaceMutation({128export const listToolPolicies = workspaceQuery({
131 method: "POST",
132 requireAdmin: true,
133 args: {
134 id: v.optional(v.string()),
135 scopeType: v.optional(policyScopeTypeValidator),
136 targetAccountId: v.optional(v.id("accounts")),
137 clientId: v.optional(v.string()),
138 resourceType: v.optional(policyResourceTypeValidator),
139 resourcePattern: v.string(),
140 matchType: v.optional(policyMatchTypeValidator),
141 effect: v.optional(policyEffectValidator),
142 approvalMode: v.optional(policyApprovalModeValidator),
143 argumentConditions: v.optional(v.array(v.object({
144 key: v.string(),
145 operator: v.union(v.literal("equals"), v.literal("contains"), v.literal("starts_with"), v.literal("not_equals")),
146 value: v.string(),
147 }))),
148 priority: v.optional(v.number()),
149 },
150 handler: async (ctx, args) => {
151 if (args.scopeType === "organization") {
152 const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
153 if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
154 throw new Error("Only organization admins can create organization-level policies");
155 }
156 }
157
158 if (args.scopeType === "account") {
159 if (!args.targetAccountId) {
160 throw new Error("targetAccountId is required for account-scoped policies");
161 }
162
163 const targetMembership = await getOrganizationMembership(
164 ctx,
165 ctx.workspace.organizationId,
166 args.targetAccountId,
167 );
168 if (!targetMembership || targetMembership.status !== "active") {
169 throw new Error("targetAccountId must be an active member of this organization");
170 }
171 }
172
173 return await ctx.runMutation(internal.database.upsertAccessPolicy, {
174 ...args,
175 workspaceId: ctx.workspaceId,
176 });
177 },
178});
179
180export const deleteAccessPolicy = workspaceMutation({
181 method: "POST",
182 requireAdmin: true,
183 args: {
184 policyId: v.string(),
185 },
186 handler: async (ctx, args) => {
187 return await ctx.runMutation(internal.database.deleteAccessPolicy, {
188 policyId: args.policyId,
189 workspaceId: ctx.workspaceId,
190 });
191 },
192});
193
194export const listAccessPolicies = workspaceQuery({
195 method: "GET",129 method: "GET",
196 args: {},130 args: {},
197 handler: async (ctx) => {131 handler: async (ctx) => {
198 return await ctx.runQuery(internal.database.listAccessPolicies, {132 return await ctx.runQuery(internal.database.listToolPolicies, {
199 workspaceId: ctx.workspaceId,133 workspaceId: ctx.workspaceId,
200 accountId: ctx.account._id,134 accountId: ctx.account._id,
201 });135 });
@@ -480,7 +414,6 @@ export const getToolDetails = workspaceMutation({ · 278 unmodified lines
480 method: "POST",414 method: "POST",
481 args: {415 args: {
482 toolPaths: v.array(v.string()),416 toolPaths: v.array(v.string()),
483 clientId: v.optional(v.string()),
484 },417 },
485 handler: async (ctx, args) => {418 handler: async (ctx, args) => {
486 return await listToolDetailsForContext(419 return await listToolDetailsForContext(
@@ -488,7 +421,7 @@ export const getToolDetails = workspaceMutation({ · 1 unmodified line
488 {421 {
489 workspaceId: ctx.workspaceId,422 workspaceId: ctx.workspaceId,
490 accountId: ctx.account._id,423 accountId: ctx.account._id,
491 clientId: args.clientId,424 clientId: "web",
492 },425 },
493 {426 {
494 toolPaths: args.toolPaths,427 toolPaths: args.toolPaths,
executor/packages/database/src/auth/account_identity.ts+18−0
@@ -17,3 +17,21 @@ export function assertMatchesCanonicalAccountId( · 16 unmodified lines
17 throw new Error(`${fieldName} must match the authenticated workspace account`);17 throw new Error(`${fieldName} must match the authenticated workspace account`);
18 }18 }
19}19}
20
21type WorkspaceProvider = "workos" | "anonymous";
22
23export function canonicalClientIdForWorkspaceAccess(args: {
24 provider: WorkspaceProvider;
25 sessionId?: string;
26}): "web" | "mcp" {
27 const sessionId = args.sessionId?.trim() || "";
28 if (sessionId.startsWith("mcp_")) {
29 return "mcp";
30 }
31
32 if (args.provider === "anonymous" && sessionId.startsWith("anon_session_")) {
33 return "web";
34 }
35
36 return "web";
37}
executor/packages/database/src/auth/event_handlers.ts+17−0
@@ -270,6 +270,23 @@ export const workosEventHandlers = { · 269 unmodified lines
270 status: "deleted",270 status: "deleted",
271 updatedAt: now,271 updatedAt: now,
272 });272 });
273
274 const memberships = await ctx.db
275 .query("organizationMembers")
276 .withIndex("by_account", (q) => q.eq("accountId", account._id))
277 .collect();
278
279 await Promise.all(memberships.map(async (membership) => {
280 if (membership.status === "removed" && !membership.billable) {
281 return;
282 }
283
284 await ctx.db.patch(membership._id, {
285 status: "removed",
286 billable: false,
287 updatedAt: now,
288 });
289 }));
273 },290 },
274 291
275 "organization.created": async (ctx, event) => {292 "organization.created": async (ctx, event) => {
executor/packages/database/src/executor/handlers.ts+6−2
@@ -5,6 +5,7 @@ import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../ · 4 unmodified lines
5import {5import {
6 assertMatchesCanonicalAccountId,6 assertMatchesCanonicalAccountId,
7 canonicalAccountIdForWorkspaceAccess,7 canonicalAccountIdForWorkspaceAccess,
8 canonicalClientIdForWorkspaceAccess,
8} from "../auth/account_identity";9} from "../auth/account_identity";
9import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";10import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
10import { createTaskEvent } from "../task/events";11import { createTaskEvent } from "../task/events";
@@ -227,7 +228,6 @@ export async function createTaskHandler( · 216 unmodified lines
227 workspaceId: Id<"workspaces">;228 workspaceId: Id<"workspaces">;
228 sessionId?: string;229 sessionId?: string;
229 accountId?: Id<"accounts">;230 accountId?: Id<"accounts">;
230 clientId?: string;
231 waitForResult?: boolean;231 waitForResult?: boolean;
232 },232 },
233): Promise<TaskExecutionOutcome> {233): Promise<TaskExecutionOutcome> {
@@ -237,6 +237,10 @@ export async function createTaskHandler( · 3 unmodified lines
237 });237 });
238 238
239 const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access);239 const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access);
240 const canonicalClientId = canonicalClientIdForWorkspaceAccess({
241 provider: access.provider,
242 sessionId: args.sessionId,
243 });
240 assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId);244 assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId);
241 245
242 const waitForResult = args.waitForResult ?? false;246 const waitForResult = args.waitForResult ?? false;
@@ -247,7 +251,7 @@ export async function createTaskHandler( · 4 unmodified lines
247 metadata: toMetadata(args.metadata),251 metadata: toMetadata(args.metadata),
248 workspaceId: args.workspaceId,252 workspaceId: args.workspaceId,
249 accountId: canonicalAccountId,253 accountId: canonicalAccountId,
250 clientId: args.clientId,254 clientId: canonicalClientId,
251 scheduleAfterCreate: !waitForResult,255 scheduleAfterCreate: !waitForResult,
252 });256 });
253 257
executor/packages/database/src/runtime/account_auth.ts+7−1
@@ -4,6 +4,7 @@ import { internal } from "../../convex/_generated/api"; · 3 unmodified lines
4import {4import {
5 assertMatchesCanonicalAccountId,5 assertMatchesCanonicalAccountId,
6 canonicalAccountIdForWorkspaceAccess,6 canonicalAccountIdForWorkspaceAccess,
7 canonicalClientIdForWorkspaceAccess,
7} from "../auth/account_identity";8} from "../auth/account_identity";
8 9
9export async function requireCanonicalAccount(10export async function requireCanonicalAccount(
@@ -13,14 +14,19 @@ export async function requireCanonicalAccount( · 3 unmodified lines
13 sessionId?: string;14 sessionId?: string;
14 accountId?: Id<"accounts">;15 accountId?: Id<"accounts">;
15 },16 },
16): Promise<{ accountId: Id<"accounts"> }> {17): Promise<{ accountId: Id<"accounts">; clientId: "web" | "mcp" }> {
17 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {18 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
18 workspaceId: args.workspaceId,19 workspaceId: args.workspaceId,
19 sessionId: args.sessionId,20 sessionId: args.sessionId,
20 });21 });
21 const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access);22 const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access);
23 const canonicalClientId = canonicalClientIdForWorkspaceAccess({
24 provider: access.provider,
25 sessionId: args.sessionId,
26 });
22 assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId);27 assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId);
23 return {28 return {
24 accountId: canonicalAccountId,29 accountId: canonicalAccountId,
30 clientId: canonicalClientId,
25 };31 };
26}32}
executor/packages/database/src/runtime/policy.test.ts+33−5
@@ -1,8 +1,8 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import type { AccessPolicyRecord } from "../../../core/src/types";2import type { ToolPolicyRecord } from "../../../core/src/types";
3import { getDecisionForContext } from "./policy";3import { getDecisionForContext } from "./policy";
4 4
5const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePattern"> = {5const basePolicy: Omit<ToolPolicyRecord, "id" | "resourceType" | "resourcePattern"> = {
6 scopeType: "organization",6 scopeType: "organization",
7 organizationId: "org_1" as any,7 organizationId: "org_1" as any,
8 matchType: "glob",8 matchType: "glob",
@@ -14,7 +14,7 @@ const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePatt · 5 unmodified lines
14};14};
15 15
16test("source-scoped policy can deny all tools from a source", () => {16test("source-scoped policy can deny all tools from a source", () => {
17 const policies: AccessPolicyRecord[] = [17 const policies: ToolPolicyRecord[] = [
18 {18 {
19 id: "p1",19 id: "p1",
20 ...basePolicy,20 ...basePolicy,
@@ -42,7 +42,7 @@ test("source-scoped policy can deny all tools from a source", () => { · 21 unmodified lines
42});42});
43 43
44test("tool-path policy can override source policy by specificity", () => {44test("tool-path policy can override source policy by specificity", () => {
45 const policies: AccessPolicyRecord[] = [45 const policies: ToolPolicyRecord[] = [
46 {46 {
47 id: "p-source",47 id: "p-source",
48 ...basePolicy,48 ...basePolicy,
@@ -81,7 +81,7 @@ test("tool-path policy can override source policy by specificity", () => { · 32 unmodified lines
81});81});
82 82
83test("all-tools policy applies without source context", () => {83test("all-tools policy applies without source context", () => {
84 const policies: AccessPolicyRecord[] = [84 const policies: ToolPolicyRecord[] = [
85 {85 {
86 id: "p-all",86 id: "p-all",
87 ...basePolicy,87 ...basePolicy,
@@ -106,3 +106,31 @@ test("all-tools policy applies without source context", () => { · 18 unmodified lines
106 106
107 expect(decision).toBe("require_approval");107 expect(decision).toBe("require_approval");
108});108});
109
110test("discover can be denied by policy", () => {
111 const policies: ToolPolicyRecord[] = [
112 {
113 id: "p-discover-deny",
114 ...basePolicy,
115 resourceType: "tool_path",
116 resourcePattern: "discover",
117 matchType: "exact",
118 effect: "deny",
119 approvalMode: "required",
120 },
121 ];
122
123 const decision = getDecisionForContext(
124 {
125 path: "discover",
126 approval: "auto",
127 },
128 {
129 workspaceId: "ws_1",
130 accountId: "acct_1",
131 },
132 policies,
133 );
134
135 expect(decision).toBe("deny");
136});
executor/packages/database/src/runtime/policy.ts+7−11
@@ -1,5 +1,5 @@
1import type {1import type {
2 AccessPolicyRecord,2 ToolPolicyRecord,
3 ArgumentCondition,3 ArgumentCondition,
4 PolicyDecision,4 PolicyDecision,
5 TaskRecord,5 TaskRecord,
@@ -46,7 +46,7 @@ function matchesArgumentConditions( · 40 unmodified lines
46 return conditions.every((condition) => matchesArgumentCondition(condition, input[condition.key]));46 return conditions.every((condition) => matchesArgumentCondition(condition, input[condition.key]));
47}47}
48 48
49function matchesPolicyResource(policy: AccessPolicyRecord, tool: PolicyTool): boolean {49function matchesPolicyResource(policy: ToolPolicyRecord, tool: PolicyTool): boolean {
50 if (policy.resourceType === "all_tools") {50 if (policy.resourceType === "all_tools") {
51 return true;51 return true;
52 }52 }
@@ -66,7 +66,7 @@ function matchesPolicyResource(policy: AccessPolicyRecord, tool: PolicyTool): bo · 13 unmodified lines
66}66}
67 67
68function policySpecificity(68function policySpecificity(
69 policy: AccessPolicyRecord,69 policy: ToolPolicyRecord,
70 context: { workspaceId: string; accountId?: string; clientId?: string },70 context: { workspaceId: string; accountId?: string; clientId?: string },
71): number {71): number {
72 const scopeType = policy.scopeType;72 const scopeType = policy.scopeType;
@@ -91,7 +91,7 @@ function policySpecificity( · 18 unmodified lines
91 return score;91 return score;
92}92}
93 93
94function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision {94function resolvePolicyDecision(policy: ToolPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision {
95 const effect = policy.effect;95 const effect = policy.effect;
96 const approvalMode = policy.approvalMode;96 const approvalMode = policy.approvalMode;
97 97
@@ -113,13 +113,9 @@ function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: Poli · 15 unmodified lines
113export function getDecisionForContext(113export function getDecisionForContext(
114 tool: PolicyTool,114 tool: PolicyTool,
115 context: { workspaceId: string; accountId?: string; clientId?: string },115 context: { workspaceId: string; accountId?: string; clientId?: string },
116 policies: AccessPolicyRecord[],116 policies: ToolPolicyRecord[],
117 input?: Record<string, unknown>,117 input?: Record<string, unknown>,
118): PolicyDecision {118): PolicyDecision {
119 if (tool.path === "discover") {
120 return "allow";
121 }
122
123 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";119 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
124 const candidates = policies120 const candidates = policies
125 .filter((policy) => {121 .filter((policy) => {
@@ -151,7 +147,7 @@ export function getDecisionForContext( · 25 unmodified lines
151export function getToolDecision(147export function getToolDecision(
152 task: TaskRecord,148 task: TaskRecord,
153 tool: PolicyTool,149 tool: PolicyTool,
154 policies: AccessPolicyRecord[],150 policies: ToolPolicyRecord[],
155 input?: Record<string, unknown>,151 input?: Record<string, unknown>,
156): PolicyDecision {152): PolicyDecision {
157 return getDecisionForContext(153 return getDecisionForContext(
@@ -170,7 +166,7 @@ export function isToolAllowedForTask( · 12 unmodified lines
170 task: TaskRecord,166 task: TaskRecord,
171 toolPath: string,167 toolPath: string,
172 workspaceTools: ReadonlyMap<string, PolicyTool>,168 workspaceTools: ReadonlyMap<string, PolicyTool>,
173 policies: AccessPolicyRecord[],169 policies: ToolPolicyRecord[],
174): boolean {170): boolean {
175 const tool = workspaceTools.get(toolPath);171 const tool = workspaceTools.get(toolPath);
176 if (!tool) return false;172 if (!tool) return false;
executor/packages/database/src/runtime/tool_call_resolution.ts+2−2
@@ -3,7 +3,7 @@ import { z } from "zod"; · 2 unmodified lines
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";5import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
6import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";6import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
7import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";7import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
8import { getDecisionForContext, getToolDecision } from "./policy";8import { getDecisionForContext, getToolDecision } from "./policy";
9import { getReadyRegistryBuildIdResult } from "./tool_registry_state";9import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
@@ -79,7 +79,7 @@ export function getGraphqlDecision( · 69 unmodified lines
79 tool: { path: string; approval: "auto" | "required"; source?: string; _graphqlSource?: string },79 tool: { path: string; approval: "auto" | "required"; source?: string; _graphqlSource?: string },
80 input: unknown,80 input: unknown,
81 workspaceTools: Map<string, { path: string; approval: "auto" | "required"; source?: string }> | undefined,81 workspaceTools: Map<string, { path: string; approval: "auto" | "required"; source?: string }> | undefined,
82 policies: AccessPolicyRecord[],82 policies: ToolPolicyRecord[],
83): { decision: PolicyDecision; effectivePaths: string[] } {83): { decision: PolicyDecision; effectivePaths: string[] } {
84 const sourceName = tool._graphqlSource;84 const sourceName = tool._graphqlSource;
85 if (!sourceName) {85 if (!sourceName) {
executor/packages/database/src/runtime/tool_descriptors.ts+2−2
@@ -1,5 +1,5 @@
1import type {1import type {
2 AccessPolicyRecord,2 ToolPolicyRecord,
3 OpenApiSourceQuality,3 OpenApiSourceQuality,
4 ToolDefinition,4 ToolDefinition,
5 ToolDescriptor,5 ToolDescriptor,
@@ -135,7 +135,7 @@ export function computeOpenApiSourceQuality( · 129 unmodified lines
135export function listVisibleToolDescriptors(135export function listVisibleToolDescriptors(
136 workspaceTools: Map<string, ToolDefinition>,136 workspaceTools: Map<string, ToolDefinition>,
137 context: { workspaceId: string; accountId?: string; clientId?: string },137 context: { workspaceId: string; accountId?: string; clientId?: string },
138 policies: AccessPolicyRecord[],138 policies: ToolPolicyRecord[],
139 options: { includeDetails?: boolean; toolPaths?: string[] } = {},139 options: { includeDetails?: boolean; toolPaths?: string[] } = {},
140): ToolDescriptor[] {140): ToolDescriptor[] {
141 const requestedPaths = options.toolPaths ?? [];141 const requestedPaths = options.toolPaths ?? [];
executor/packages/database/src/runtime/tool_invocation.ts+29−6
@@ -2,7 +2,7 @@ import { z } from "zod"; · 1 unmodified line
2import type { ActionCtx } from "../../convex/_generated/server";2import type { ActionCtx } from "../../convex/_generated/server";
3import { internal } from "../../convex/_generated/api";3import { internal } from "../../convex/_generated/api";
4import type {4import type {
5 AccessPolicyRecord,5 ToolPolicyRecord,
6 PolicyDecision,6 PolicyDecision,
7 ResolvedToolCredential,7 ResolvedToolCredential,
8 TaskRecord,8 TaskRecord,
@@ -218,15 +218,15 @@ async function upsertRequestedToolCall( · 209 unmodified lines
218 return persistedCall;218 return persistedCall;
219}219}
220 220
221async function listWorkspaceAccessPolicies(221async function listWorkspaceToolPolicies(
222 ctx: ActionCtx,222 ctx: ActionCtx,
223 task: Pick<TaskRecord, "workspaceId" | "accountId">,223 task: Pick<TaskRecord, "workspaceId" | "accountId">,
224): Promise<AccessPolicyRecord[]> {224): Promise<ToolPolicyRecord[]> {
225 const policies = await ctx.runQuery(internal.database.listAccessPolicies, {225 const policies = await ctx.runQuery(internal.database.listToolPolicies, {
226 workspaceId: task.workspaceId,226 workspaceId: task.workspaceId,
227 accountId: task.accountId,227 accountId: task.accountId,
228 });228 });
229 return policies as AccessPolicyRecord[];229 return policies as ToolPolicyRecord[];
230}230}
231 231
232async function listRegistryNamespaces(232async function listRegistryNamespaces(
@@ -348,7 +348,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 115 unmodified lines
348 348
349 let effectiveToolPath = toolPath;349 let effectiveToolPath = toolPath;
350 try {350 try {
351 const typedPolicies = await listWorkspaceAccessPolicies(ctx, task);351 const typedPolicies = await listWorkspaceToolPolicies(ctx, task);
352 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {352 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {
353 if (persistedCall.status === "requested") {353 if (persistedCall.status === "requested") {
354 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {354 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
@@ -368,6 +368,29 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 13 unmodified lines
368 368
369 // Fast system tools are handled server-side from the registry.369 // Fast system tools are handled server-side from the registry.
370 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {370 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
371 const systemToolDecision = getDecisionForContext(
372 {
373 path: toolPath,
374 approval: "auto",
375 },
376 {
377 workspaceId: task.workspaceId,
378 accountId: task.accountId,
379 clientId: task.clientId,
380 },
381 typedPolicies,
382 );
383 if (systemToolDecision === "deny") {
384 const deniedMessage = `${toolPath} (policy denied)`;
385 await denyToolCall(ctx, {
386 task,
387 callId,
388 toolPath,
389 deniedMessage,
390 reason: "policy_deny",
391 });
392 }
393
371 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {394 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
372 workspaceId: task.workspaceId,395 workspaceId: task.workspaceId,
373 accountId: task.accountId,396 accountId: task.accountId,
executor/packages/database/src/runtime/workspace_tools.ts+8−8
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
18import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";18import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
19import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types";19import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types";
20import type {20import type {
21 AccessPolicyRecord,21 ToolPolicyRecord,
22 JsonSchema,22 JsonSchema,
23 OpenApiSourceQuality,23 OpenApiSourceQuality,
24 SourceAuthProfile,24 SourceAuthProfile,
@@ -66,12 +66,12 @@ async function listWorkspaceToolSources( · 41 unmodified lines
66 return sources;66 return sources;
67}67}
68 68
69async function listWorkspaceAccessPolicies(69async function listWorkspaceToolPolicies(
70 ctx: QueryRunnerCtx,70 ctx: QueryRunnerCtx,
71 workspaceId: Id<"workspaces">,71 workspaceId: Id<"workspaces">,
72 accountId?: Id<"accounts">,72 accountId?: Id<"accounts">,
73): Promise<AccessPolicyRecord[]> {73): Promise<ToolPolicyRecord[]> {
74 const policies: AccessPolicyRecord[] = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId, accountId });74 const policies: ToolPolicyRecord[] = await ctx.runQuery(internal.database.listToolPolicies, { workspaceId, accountId });
75 return policies;75 return policies;
76}76}
77 77
@@ -378,7 +378,7 @@ function toToolDetailDescriptor(tool: ToolDescriptor): ToolDetailDescriptor { · 300 unmodified lines
378function listVisibleRegistryToolDescriptors(378function listVisibleRegistryToolDescriptors(
379 entries: RegistryToolEntry[],379 entries: RegistryToolEntry[],
380 context: { workspaceId: string; accountId?: string; clientId?: string },380 context: { workspaceId: string; accountId?: string; clientId?: string },
381 policies: AccessPolicyRecord[],381 policies: ToolPolicyRecord[],
382 options: {382 options: {
383 includeDetails?: boolean;383 includeDetails?: boolean;
384 toolPaths?: string[];384 toolPaths?: string[];
@@ -1151,7 +1151,7 @@ export async function getWorkspaceInventoryProgressForContext( · 766 unmodified lines
1151 // Read policies so Convex tracks this reactive dependency — any policy1151 // Read policies so Convex tracks this reactive dependency — any policy
1152 // change will invalidate the query and produce a new reactiveKey, which1152 // change will invalidate the query and produce a new reactiveKey, which
1153 // in turn causes the client-side TanStack query to re-fetch tool data.1153 // in turn causes the client-side TanStack query to re-fetch tool data.
1154 listWorkspaceAccessPolicies(ctx, workspaceId),1154 listWorkspaceToolPolicies(ctx, workspaceId),
1155 ]);1155 ]);
1156 1156
1157 const progress = computeWorkspaceInventoryProgress(1157 const progress = computeWorkspaceInventoryProgress(
@@ -1368,7 +1368,7 @@ async function loadWorkspaceToolInventoryForContext( · 210 unmodified lines
1368 buildId: options.buildId,1368 buildId: options.buildId,
1369 fetchAll: options.fetchAll,1369 fetchAll: options.fetchAll,
1370 }),1370 }),
1371 listWorkspaceAccessPolicies(ctx, context.workspaceId, context.accountId),1371 listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId),
1372 ]);1372 ]);
1373 1373
1374 const warnings = [...result.warnings];1374 const warnings = [...result.warnings];
@@ -1468,7 +1468,7 @@ export async function listToolDetailsForContext( · 93 unmodified lines
1468 ctx.runQuery(internal.toolRegistry.getState, {1468 ctx.runQuery(internal.toolRegistry.getState, {
1469 workspaceId: context.workspaceId,1469 workspaceId: context.workspaceId,
1470 }),1470 }),
1471 listWorkspaceAccessPolicies(ctx, context.workspaceId, context.accountId),1471 listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId),
1472 ]);1472 ]);
1473 1473
1474 const result: Record<string, ToolDetailDescriptor> = {};1474 const result: Record<string, ToolDetailDescriptor> = {};
executor/packages/runner-sandbox-host/src/bridge.ts+34−48
@@ -1,7 +1,7 @@
1import { Result } from "better-result";1import { Result } from "better-result";
2import { api } from "@executor/database/convex/_generated/api";2import { api } from "@executor/database/convex/_generated/api";
3import { decodeToolCallResultFromTransport } from "../../core/src/tool-call-result-transport";3import { decodeToolCallResultFromTransport } from "../../core/src/tool-call-result-transport";
4import { ConvexClient, ConvexHttpClient } from "convex/browser";4import { ConvexHttpClient } from "convex/browser";
5import { z } from "zod";5import { z } from "zod";
6import type {6import type {
7 BridgeEntrypointContext,7 BridgeEntrypointContext,
@@ -74,56 +74,42 @@ function createConvexClient(callbackConvexUrl: string): ConvexHttpClient { · 66 unmodified lines
74 });74 });
75}75}
76 76
77function createRealtimeClient(callbackConvexUrl: string): ConvexClient {77function sleep(ms: number): Promise<void> {
78 return new ConvexClient(callbackConvexUrl, {78 return new Promise((resolve) => setTimeout(resolve, ms));
79 skipConvexDeploymentUrlCheck: true,
80 });
81}79}
82 80
83async function waitForApprovalUpdate(props: BridgeProps, approvalId: string): Promise<void> {81async function waitForApprovalUpdate(props: BridgeProps, approvalId: string): Promise<void> {
84 const client = createRealtimeClient(props.callbackConvexUrl);82 const client = createConvexClient(props.callbackConvexUrl);
85 83 const startedAt = Date.now();
86 await new Promise<void>((resolve, reject) => {84 const pollIntervalMs = 1000;
87 let settled = false;85
88 86 while (Date.now() - startedAt < APPROVAL_SUBSCRIPTION_TIMEOUT_MS) {
89 const timer = setTimeout(() => {87 const statusResult = await Result.tryPromise(() => client.query(api.runtimeCallbacks.getApprovalStatus, {
90 if (settled) return;88 internalSecret: props.callbackInternalSecret,
91 settled = true;89 runId: props.taskId,
92 unsubscribe();90 approvalId,
93 client.close();91 }));
94 reject(new Error(`Timed out waiting for approval update: ${approvalId}`));92
95 }, APPROVAL_SUBSCRIPTION_TIMEOUT_MS);93 if (statusResult.isErr()) {
96 94 const cause = statusResult.error.cause;
97 const unsubscribe = client.onUpdate(95 const message = cause instanceof Error ? cause.message : String(cause);
98 api.runtimeCallbacks.getApprovalStatus,96 throw new Error(`Approval status poll failed: ${message}`);
99 {97 }
100 internalSecret: props.callbackInternalSecret,98
101 runId: props.taskId,99 const status = statusResult.value?.status;
102 approvalId,100 if (!status || status === "pending") {
103 },101 await sleep(pollIntervalMs);
104 (value: { status?: "pending" | "approved" | "denied" | "missing" } | null | undefined) => {102 continue;
105 const status = value?.status;103 }
106 if (!status || status === "pending") {104
107 return;105 if (status === "missing") {
108 }106 throw new Error(`Approval not found: ${approvalId}`);
109 if (status === "missing") {107 }
110 if (settled) return;108
111 settled = true;109 return;
112 clearTimeout(timer);110 }
113 unsubscribe();111
114 client.close();112 throw new Error(`Timed out waiting for approval update: ${approvalId}`);
115 reject(new Error(`Approval not found: ${approvalId}`));
116 return;
117 }
118 if (settled) return;
119 settled = true;
120 clearTimeout(timer);
121 unsubscribe();
122 client.close();
123 resolve();
124 },
125 );
126 });
127}113}
128 114
129export async function callToolWithBridge(115export async function callToolWithBridge(
executor/packages/runner-sandbox-host/src/index.ts+4−2
@@ -35,6 +35,7 @@ · 34 unmodified lines
35 35
36import { Result } from "better-result";36import { Result } from "better-result";
37import { WorkerEntrypoint } from "cloudflare:workers";37import { WorkerEntrypoint } from "cloudflare:workers";
38import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";
38import GLOBALS_MODULE from "./isolate/globals.isolate.js";39import GLOBALS_MODULE from "./isolate/globals.isolate.js";
39import HARNESS_CODE from "./isolate/harness.isolate.js";40import HARNESS_CODE from "./isolate/harness.isolate.js";
40import { authorizeRunRequest } from "./auth";41import { authorizeRunRequest } from "./auth";
@@ -63,8 +64,9 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 22 unmodified lines
63 }64 }
64 65
65 /** Forward a tool call to the Convex callback RPC action. */66 /** Forward a tool call to the Convex callback RPC action. */
66 async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> {67 async callTool(toolPath: string, input: unknown, callId?: string): Promise<string> {
67 return callToolWithBridge(this.props, toolPath, input, callId);68 const result = await callToolWithBridge(this.props, toolPath, input, callId);
69 return encodeToolCallResultForTransport(result as ToolCallResult);
68 }70 }
69}71}
70 72
executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js+114−4
@@ -2,6 +2,111 @@ import { ResponseJson as _ResponseJson } from "./globals.js"; · 1 unmodified line
2import { run } from "./user-code.js";2import { run } from "./user-code.js";
3 3
4const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";4const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
5const TOOL_RESULT_UNDEFINED_SENTINEL = "__executor_tool_result_undefined__";
6
7function decodeToolBridgeResult(rawResult) {
8 const parsed = (() => {
9 if (typeof rawResult === "string") {
10 try {
11 return JSON.parse(rawResult);
12 } catch {
13 return null;
14 }
15 }
16 if (rawResult && typeof rawResult === "object") {
17 return rawResult;
18 }
19 return null;
20 })();
21
22 if (!parsed || typeof parsed !== "object") {
23 return {
24 ok: false,
25 kind: "failed",
26 error: "Tool bridge returned an invalid payload",
27 };
28 }
29
30 if (parsed.ok === true) {
31 if (typeof parsed.valueJson !== "string") {
32 return {
33 ok: false,
34 kind: "failed",
35 error: "Tool bridge success payload is missing valueJson",
36 };
37 }
38
39 if (parsed.valueJson === TOOL_RESULT_UNDEFINED_SENTINEL) {
40 return { ok: true, value: undefined };
41 }
42
43 try {
44 return { ok: true, value: JSON.parse(parsed.valueJson) };
45 } catch {
46 return { ok: true, value: parsed.valueJson };
47 }
48 }
49
50 if (parsed.ok === false && parsed.kind === "pending" && typeof parsed.approvalId === "string") {
51 return {
52 ok: false,
53 kind: "pending",
54 approvalId: parsed.approvalId,
55 ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
56 ...(typeof parsed.retryAfterMs === "number" ? { retryAfterMs: parsed.retryAfterMs } : {}),
57 };
58 }
59
60 if (parsed.ok === false && parsed.kind === "denied" && typeof parsed.error === "string") {
61 return {
62 ok: false,
63 kind: "denied",
64 error: parsed.error,
65 };
66 }
67
68 if (parsed.ok === false && parsed.kind === "failed" && typeof parsed.error === "string") {
69 return {
70 ok: false,
71 kind: "failed",
72 error: parsed.error,
73 };
74 }
75
76 return {
77 ok: false,
78 kind: "failed",
79 error: "Tool bridge returned a malformed payload",
80 };
81}
82
83function describeExecutionError(error) {
84 if (error instanceof Error) {
85 const message = typeof error.message === "string" ? error.message.trim() : "";
86 if (message.length > 0) {
87 return message;
88 }
89
90 const stack = typeof error.stack === "string" ? error.stack.trim() : "";
91 if (stack.length > 0) {
92 return stack;
93 }
94
95 const name = typeof error.name === "string" ? error.name.trim() : "";
96 if (name.length > 0) {
97 return name;
98 }
99
100 return "Execution failed with an empty Error object";
101 }
102
103 const text = String(error).trim();
104 if (text.length > 0) {
105 return text;
106 }
107
108 return "Execution failed with an empty non-Error throw value";
109}
5 110
6function createToolsProxy(bridge, path = []) {111function createToolsProxy(bridge, path = []) {
7 const callable = () => {};112 const callable = () => {};
@@ -17,10 +122,16 @@ function createToolsProxy(bridge, path = []) { · 9 unmodified lines
17 const input = args.length > 0 ? args[0] : {};122 const input = args.length > 0 ? args[0] : {};
18 const callId = "call_" + crypto.randomUUID();123 const callId = "call_" + crypto.randomUUID();
19 124
20 const result = await bridge.callTool(toolPath, input, callId);125 const result = decodeToolBridgeResult(await bridge.callTool(toolPath, input, callId));
126
21 if (result.ok) return result.value;127 if (result.ok) return result.value;
128 if (result.kind === "pending") {
129 throw new Error(`Tool call is unexpectedly pending approval (${result.approvalId})`);
130 }
22 if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error);131 if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error);
23 throw new Error(result.error);132
133 const errorText = typeof result.error === "string" ? result.error.trim() : "";
134 throw new Error(errorText || `Tool call failed without an error message (${toolPath})`);
24 },135 },
25 });136 });
26}137}
@@ -55,8 +166,7 @@ export default { · 28 unmodified lines
55 exitCode: 0,166 exitCode: 0,
56 });167 });
57 } catch (error) {168 } catch (error) {
58 const message =169 const message = describeExecutionError(error);
59 error instanceof Error ? error.message : String(error);
60 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {170 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
61 const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim();171 const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
62 return _ResponseJson({172 return _ResponseJson({
executor/packages/runner-sandbox-host/src/types.ts+2−2
@@ -45,7 +45,7 @@ export interface RunResult { · 44 unmodified lines
45export type ToolCallResult =45export type ToolCallResult =
46 | {46 | {
47 ok: true;47 ok: true;
48 value?: unknown;48 value: unknown;
49 }49 }
50 | {50 | {
51 ok: false;51 ok: false;
@@ -57,7 +57,7 @@ export type ToolCallResult = · 5 unmodified lines
57 | {57 | {
58 ok: false;58 ok: false;
59 kind: "denied" | "failed";59 kind: "denied" | "failed";
60 error?: string;60 error: string;
61 approvalId?: string;61 approvalId?: string;
62 retryAfterMs?: number;62 retryAfterMs?: number;
63 };63 };