- 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.
Unified · Split
| @@ -56,3 +56,4 @@ executor/.env.executor-push · 55 unmodified lines | |||
| 56 | *.har | 56 | *.har |
| 57 | .vercel | 57 | .vercel |
| 58 | .env*.local | 58 | .env*.local |
| 59 | .chat-links.json | ||
| @@ -44,6 +44,69 @@ const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal"; · 43 unmodified lines | |||
| 44 | const LINK_FIELD_WORKSPACE = "workspace_id"; | 44 | const LINK_FIELD_WORKSPACE = "workspace_id"; |
| 45 | const LINK_FIELD_TOKEN = "access_token"; | 45 | const LINK_FIELD_TOKEN = "access_token"; |
| 46 | const LINK_FIELD_ACCOUNT = "account_id"; | 46 | const LINK_FIELD_ACCOUNT = "account_id"; |
| 47 | const ANON_LINK_MODAL_ID = "assistant_link_anonymous_modal"; | ||
| 48 | const ANON_LINK_FIELD_WORKSPACE = "workspace_id"; | ||
| 49 | const ANON_LINK_FIELD_API_KEY = "api_key"; | ||
| 50 | const ELICITATION_BUTTON_PREFIX = "assistant_elicitation"; | ||
| 51 | const ELICITATION_FORM_MODAL_PREFIX = "assistant_elicitation_form"; | ||
| 52 | const ELICITATION_POLL_INTERVAL_MS = 1000; | ||
| 53 | const ELICITATION_FORM_SESSION_TIMEOUT_MS = 10 * 60 * 1000; | ||
| 54 | const MAX_DISCORD_MODAL_FIELDS = 5; | ||
| 55 | |||
| 56 | type ElicitationAction = "accept" | "decline" | "cancel"; | ||
| 57 | type ElicitationButtonAction = ElicitationAction | "form"; | ||
| 58 | |||
| 59 | type FormFieldKind = "string" | "number" | "integer" | "boolean" | "enum_single" | "enum_multi"; | ||
| 60 | |||
| 61 | interface EnumOption { | ||
| 62 | readonly value: string | number | boolean; | ||
| 63 | readonly title?: string; | ||
| 64 | } | ||
| 65 | |||
| 66 | interface 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 | |||
| 83 | interface FormSchemaSpec { | ||
| 84 | readonly fields: readonly FormFieldSpec[]; | ||
| 85 | readonly error?: string; | ||
| 86 | } | ||
| 87 | |||
| 88 | interface 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 | |||
| 96 | interface 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 | |||
| 107 | const knownElicitations = new Map<string, PendingElicitation>(); | ||
| 108 | const knownElicitationIdsByRequest = new Map<string, Set<string>>(); | ||
| 109 | const formModalSessions = new Map<string, FormModalSession>(); | ||
| 47 | 110 | ||
| 48 | const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ | 111 | const 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 | ||
| 170 | async function linkAnonymous(identity: ChatIdentity, sessionId?: string) { | 230 | async 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 | ||
| 192 | async function runPrompt(identity: ChatIdentity, prompt: string) { | 253 | async 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 | ||
| 265 | async 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 | |||
| 276 | async 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 | |||
| 295 | function randomRequestId(): string { | ||
| 296 | return crypto.randomUUID(); | ||
| 297 | } | ||
| 298 | |||
| 299 | function elicitationKey(requestId: string, elicitationId: string): string { | ||
| 300 | return `${requestId}:${elicitationId}`; | ||
| 301 | } | ||
| 302 | |||
| 303 | function 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 | |||
| 310 | function 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 | |||
| 322 | function 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 | |||
| 333 | function getKnownElicitation(requestId: string, elicitationId: string): PendingElicitation | null { | ||
| 334 | return knownElicitations.get(elicitationKey(requestId, elicitationId)) ?? null; | ||
| 335 | } | ||
| 336 | |||
| 337 | function buildElicitationButtonId(action: ElicitationButtonAction, requestId: string, elicitationId: string): string { | ||
| 338 | return `${ELICITATION_BUTTON_PREFIX}:${action}:${requestId}:${elicitationId}`; | ||
| 339 | } | ||
| 340 | |||
| 341 | function 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 | |||
| 363 | function 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 | |||
| 370 | function 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 | |||
| 405 | function 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 | |||
| 469 | function 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 | |||
| 518 | function 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 | |||
| 525 | function 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 | |||
| 535 | function 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 | |||
| 552 | function 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 | |||
| 610 | function 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 | |||
| 620 | function 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 | |||
| 631 | function 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 | |||
| 653 | function 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 | |||
| 735 | function 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 | |||
| 755 | function 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 | |||
| 803 | function 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 | |||
| 820 | async 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 | |||
| 203 | function contextLabel(context: { | 856 | function 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 | ||
| 911 | function 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 | |||
| 256 | function buildExecutorLink(identity: ChatIdentity): string | null { | 938 | function 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); |
| @@ -4,6 +4,10 @@ · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | import { | ||
| 8 | ElicitRequestSchema, | ||
| 9 | ElicitationCompleteNotificationSchema, | ||
| 10 | } from "@modelcontextprotocol/sdk/types.js"; | ||
| 7 | import { | 11 | import { |
| 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 | ||
| 58 | export 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 | |||
| 66 | export interface AgentElicitationResponse { | ||
| 67 | readonly action: "accept" | "decline" | "cancel"; | ||
| 68 | readonly content?: Record<string, unknown>; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface AgentElicitationCompleteNotification { | ||
| 72 | readonly elicitationId: string; | ||
| 73 | } | ||
| 74 | |||
| 54 | interface McpTool { | 75 | interface 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 | ||
| 90 | export interface AgentResult { | 115 | export 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 MCP | 224 | // 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 = mcpAccessToken | 233 | 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 { |
| @@ -1,3 +1,9 @@ | |||
| 1 | export { createAgent } from "./agent"; | 1 | export { createAgent } from "./agent"; |
| 2 | export type { AgentOptions, AgentResult } from "./agent"; | 2 | export type { |
| 3 | AgentOptions, | ||
| 4 | AgentResult, | ||
| 5 | AgentElicitationRequest, | ||
| 6 | AgentElicitationResponse, | ||
| 7 | AgentElicitationCompleteNotification, | ||
| 8 | } from "./agent"; | ||
| 3 | export type { TaskEvent, TaskStatusEvent, TaskCodeGeneratedEvent, TaskCodeResultEvent, TaskAgentMessageEvent, TaskErrorEvent, TaskCompletedEvent } from "./events"; | 9 | export type { TaskEvent, TaskStatusEvent, TaskCodeGeneratedEvent, TaskCodeResultEvent, TaskAgentMessageEvent, TaskErrorEvent, TaskCompletedEvent } from "./events"; |
| @@ -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 | } |
| @@ -1,5 +1,9 @@ | |||
| 1 | import { Elysia, t } from "elysia"; | 1 | import { Elysia, t } from "elysia"; |
| 2 | import { createAgent } from "@assistant/core"; | 2 | import { |
| 3 | createAgent, | ||
| 4 | type AgentElicitationRequest, | ||
| 5 | type AgentElicitationResponse, | ||
| 6 | } from "@assistant/core"; | ||
| 3 | import { ConvexHttpClient } from "convex/browser"; | 7 | import { ConvexHttpClient } from "convex/browser"; |
| 4 | import { api } from "@executor/database/convex/_generated/api"; | 8 | import { api } from "@executor/database/convex/_generated/api"; |
| 5 | import type { Id } from "@executor/database/convex/_generated/dataModel"; | 9 | import 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 | ||
| 38 | interface 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 | |||
| 50 | interface PendingElicitationEntry extends PendingElicitation { | ||
| 51 | readonly resolve: (response: AgentElicitationResponse) => void; | ||
| 52 | readonly timeout: ReturnType<typeof setTimeout>; | ||
| 53 | } | ||
| 54 | |||
| 55 | interface ResolvePendingElicitationResult { | ||
| 56 | readonly ok: boolean; | ||
| 57 | readonly error?: string; | ||
| 58 | } | ||
| 59 | |||
| 60 | const ELICITATION_TIMEOUT_MS = 10 * 60 * 1000; | ||
| 61 | |||
| 31 | function normalizeIdentityPart(value: string): string { | 62 | function normalizeIdentityPart(value: string): string { |
| 32 | return value | 63 | 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 | ); |
| @@ -20,7 +20,7 @@ import { FormattedCodeBlock } from "@/components/formatted/code-block"; · 19 unmodified lines | |||
| 20 | import { convexApi } from "@/lib/convex-api"; | 20 | import { convexApi } from "@/lib/convex-api"; |
| 21 | import { useSession } from "@/lib/session-context"; | 21 | import { useSession } from "@/lib/session-context"; |
| 22 | import type { RuntimeTargetDescriptor } from "@/lib/types"; | 22 | import type { RuntimeTargetDescriptor } from "@/lib/types"; |
| 23 | import { listRuntimeTargets } from "@/lib/runtime-targets"; | 23 | import { listRuntimeTargetsWithOptions } from "@/lib/runtime-targets"; |
| 24 | import { useWorkspaceTools } from "@/hooks/use/workspace-tools"; | 24 | import { useWorkspaceTools } from "@/hooks/use/workspace-tools"; |
| 25 | 25 | ||
| 26 | const DEFAULT_CODE = `// Example: discover tools and return matching tool names | 26 | const 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 | ||
| 109 | export function TaskComposer() { | 109 | export 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"> |
| @@ -49,10 +49,13 @@ import { useSession } from "@/lib/session-context"; · 48 unmodified lines | |||
| 49 | import { convexApi } from "@/lib/convex-api"; | 49 | import { convexApi } from "@/lib/convex-api"; |
| 50 | import { cn } from "@/lib/utils"; | 50 | import { cn } from "@/lib/utils"; |
| 51 | import type { | 51 | import 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"; |
| 57 | import type { Id } from "@executor/database/convex/_generated/dataModel"; | 60 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 58 | import { workspaceQueryArgs } from "@/lib/workspace/query-args"; | 61 | import { 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 | ||
| 63 | type PolicyDecisionType = "allow" | "require_approval" | "deny"; | 66 | type PolicyDecisionType = "allow" | "require_approval" | "deny"; |
| 67 | type PolicyResourceType = ToolPolicyRecord["resourceType"]; | ||
| 64 | 68 | ||
| 65 | interface ToolNamespace { | 69 | interface ToolNamespace { |
| 66 | prefix: string; | 70 | prefix: string; |
| @@ -74,28 +78,92 @@ type PolicyScope = "personal" | "workspace" | "organization"; · 7 unmodified lines | |||
| 74 | interface FormState { | 78 | interface 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 | ||
| 91 | interface RoleFormState { | ||
| 92 | name: string; | ||
| 93 | description: string; | ||
| 94 | } | ||
| 95 | |||
| 96 | interface 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 | |||
| 106 | interface RoleBindingFormState { | ||
| 107 | scopeType: ToolRoleBindingRecord["scopeType"]; | ||
| 108 | targetAccountId: string; | ||
| 109 | clientId: string; | ||
| 110 | status: ToolRoleBindingRecord["status"]; | ||
| 111 | } | ||
| 112 | |||
| 113 | interface 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 | |||
| 84 | function defaultFormState(): FormState { | 121 | function 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 | ||
| 136 | function defaultRoleFormState(): RoleFormState { | ||
| 137 | return { | ||
| 138 | name: "", | ||
| 139 | description: "", | ||
| 140 | }; | ||
| 141 | } | ||
| 142 | |||
| 143 | function 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 | |||
| 155 | function defaultRoleBindingFormState(): RoleBindingFormState { | ||
| 156 | return { | ||
| 157 | scopeType: "workspace", | ||
| 158 | targetAccountId: "", | ||
| 159 | clientId: "", | ||
| 160 | status: "active", | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 96 | // ── Helpers ────────────────────────────────────────────────────────────────── | 164 | // ── Helpers ────────────────────────────────────────────────────────────────── |
| 97 | 165 | ||
| 98 | function getDecisionFromPolicy(policy: AccessPolicyRecord): PolicyDecisionType { | 166 | function 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 | ||
| 109 | function scopeLabel(policy: AccessPolicyRecord, currentAccountId?: string): string { | 177 | function 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 | ||
| 206 | const 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 | |||
| 138 | const OPERATOR_LABELS: Record<ArgumentConditionOperator, string> = { | 228 | const 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 | ||
| 162 | function 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 | |||
| 169 | function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[]): string { | 252 | function 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 | ||
| 282 | function 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 | |||
| 293 | function 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 | |||
| 307 | function 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 | |||
| 314 | function createToolPolicyId(): string { | ||
| 315 | return `tool_policy_${crypto.randomUUID()}`; | ||
| 316 | } | ||
| 317 | |||
| 318 | function toolPolicyRoleId(policyId: string): string { | ||
| 319 | return `tool_policy_role_${policyId}`; | ||
| 320 | } | ||
| 321 | |||
| 322 | function toolPolicyRuleId(policyId: string): string { | ||
| 323 | return `tool_policy_rule_${policyId}`; | ||
| 324 | } | ||
| 325 | |||
| 326 | function toolPolicyBindingId(policyId: string): string { | ||
| 327 | return `tool_policy_binding_${policyId}`; | ||
| 328 | } | ||
| 329 | |||
| 330 | function 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 | |||
| 346 | function 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 | ||
| 201 | type VirtualRow = | 355 | type 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 | <button | 825 | <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 > 0 | 972 | 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 const | 1022 | 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 blocked | 1297 | 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 | <Button | 1330 | <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 | <ToolPicker | 1775 | 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 | <Input | 1782 | 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 | <Input | 1855 | <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> |
| @@ -47,14 +47,12 @@ type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "displ · 46 unmodified lines | |||
| 47 | type GetToolDetailsMutation = (args: { | 47 | type 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 | ||
| 54 | type ListToolsWithWarningsAction = (args: { | 53 | type 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, |
| @@ -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 | ||
| 9 | declare global { | 10 | declare 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 | ||
| 21 | const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]); | ||
| 22 | |||
| 23 | function 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 | |||
| 20 | function normalizeDeploymentSlug(value: string | undefined): string | undefined { | 31 | function 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_URL | 106 | ?? 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 | } |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { RuntimeTargetDescriptor } from "@/lib/types"; | 1 | import type { RuntimeTargetDescriptor } from "@/lib/types"; |
| 2 | import { readRuntimeConfig } from "@/lib/runtime-config"; | ||
| 2 | 3 | ||
| 3 | const LOCAL_BUN_RUNTIME_ID = "local-bun"; | 4 | const LOCAL_BUN_RUNTIME_ID = "local-bun"; |
| 4 | const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader"; | 5 | const 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 | ||
| 30 | function isRuntimeEnabled(runtimeId: string): boolean { | 31 | function 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 | |||
| 46 | function 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 | ||
| 47 | export function listRuntimeTargets(): RuntimeTargetDescriptor[] { | 58 | export function listRuntimeTargets(): RuntimeTargetDescriptor[] { |
| 48 | return RUNTIME_TARGETS.filter((target) => isRuntimeEnabled(target.id)); | 59 | return listRuntimeTargetsWithOptions({}); |
| 60 | } | ||
| 61 | |||
| 62 | export function listRuntimeTargetsWithOptions(options: { allowLocalVm?: boolean }): RuntimeTargetDescriptor[] { | ||
| 63 | const localVmAllowed = resolveLocalVmAllowed(options.allowLocalVm); | ||
| 64 | return RUNTIME_TARGETS.filter((target) => isRuntimeEnabled(target.id, localVmAllowed)); | ||
| 49 | } | 65 | } |
| @@ -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; |
| @@ -1,4 +1,10 @@ | |||
| 1 | import type { Id } from "@executor/database/convex/_generated/dataModel"; | 1 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 2 | import 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 | |||
| 9 | export type PolicyMatchType = "glob" | "exact"; | 15 | export type PolicyMatchType = "glob" | "exact"; |
| 10 | export type PolicyEffect = "allow" | "deny"; | 16 | export type PolicyEffect = "allow" | "deny"; |
| 11 | export type PolicyApprovalMode = "inherit" | "auto" | "required"; | 17 | export type PolicyApprovalMode = "inherit" | "auto" | "required"; |
| 18 | export type ToolRoleSelectorType = "all" | "source" | "namespace" | "tool_path"; | ||
| 19 | export type ToolRoleBindingStatus = "active" | "disabled"; | ||
| 12 | export type ArgumentConditionOperator = "equals" | "contains" | "starts_with" | "not_equals"; | 20 | export type ArgumentConditionOperator = "equals" | "contains" | "starts_with" | "not_equals"; |
| 13 | 21 | ||
| 14 | export interface ArgumentCondition { | 22 | export interface ArgumentCondition { |
| @@ -76,23 +84,10 @@ export interface TaskEventRecord { · 61 unmodified lines | |||
| 76 | createdAt: number; | 84 | createdAt: number; |
| 77 | } | 85 | } |
| 78 | 86 | ||
| 79 | export interface AccessPolicyRecord { | 87 | export type ToolPolicyRecord = CoreToolPolicyRecord; |
| 80 | id: string; | 88 | export type ToolRoleRecord = CoreToolRoleRecord; |
| 81 | scopeType: PolicyScopeType; | 89 | export type ToolRoleRuleRecord = CoreToolRoleRuleRecord; |
| 82 | organizationId?: string; | 90 | export 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 | ||
| 97 | export interface CredentialRecord { | 92 | export interface CredentialRecord { |
| 98 | id: string; | 93 | id: string; |
| @@ -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 { |
| @@ -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(), |
| @@ -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 | }; |
| @@ -100,7 +100,7 @@ export interface ArgumentCondition { · 99 unmodified lines | |||
| 100 | value: string; | 100 | value: string; |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | export interface AccessPolicyRecord { | 103 | export interface ToolPolicyRecord { |
| 104 | id: string; | 104 | id: string; |
| 105 | scopeType: PolicyScopeType; | 105 | scopeType: PolicyScopeType; |
| 106 | organizationId: Id<"organizations">; | 106 | organizationId: Id<"organizations">; |
| @@ -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 | any | 177 | 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 | any | 307 | 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 | any | 358 | 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 | any | 364 | 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 | any | 370 | 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 | any | 376 | 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 | any | 424 | 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 | any | 742 | 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 | any | 803 | 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 | any | 850 | 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 | any | 897 | 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 | any | 916 | 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 | any | 923 | 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 | any | 941 | 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 | any | 1160 | 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", |
| @@ -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 policies | 1630 | // 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 sources | 1654 | // Admin can upsert tool sources |
| 1459 | const source = await authed.mutation(api.workspace.upsertToolSource, { | 1655 | const source = await authed.mutation(api.workspace.upsertToolSource, { |
| @@ -1,4 +1,10 @@ | |||
| 1 | import { customQuery, optionalAccountQuery } from "../../core/src/function-builders"; | 1 | import { customQuery, optionalAccountQuery } from "../../core/src/function-builders"; |
| 2 | import { | ||
| 3 | CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | ||
| 4 | LOCAL_BUN_RUNTIME_ID, | ||
| 5 | defaultRuntimeId, | ||
| 6 | isRuntimeEnabled, | ||
| 7 | } from "../../core/src/runtimes/runtime-catalog"; | ||
| 2 | 8 | ||
| 3 | const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY); | 9 | const 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, |
| @@ -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"; |
| 41 | export { | 39 | export { |
| 42 | upsertCredential, | 40 | upsertCredential, |
| @@ -4,6 +4,59 @@ import { internalMutation } from "../_generated/server"; · 3 unmodified lines | |||
| 4 | import { ensureAnonymousIdentity } from "../../src/database/anonymous"; | 4 | import { ensureAnonymousIdentity } from "../../src/database/anonymous"; |
| 5 | import { mapAnonymousContext } from "../../src/database/readers"; | 5 | import { mapAnonymousContext } from "../../src/database/readers"; |
| 6 | 6 | ||
| 7 | type TrustedClientId = "web" | "mcp"; | ||
| 8 | |||
| 9 | function 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 | |||
| 22 | function 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 | |||
| 35 | function defaultClientIdForSessionId(sessionId: string): TrustedClientId { | ||
| 36 | if (sessionId.startsWith("mcp_")) { | ||
| 37 | return "mcp"; | ||
| 38 | } | ||
| 39 | |||
| 40 | return "web"; | ||
| 41 | } | ||
| 42 | |||
| 43 | function 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 | |||
| 7 | export const bootstrapAnonymousSession = internalMutation({ | 60 | export 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 = allowRequestedSessionId | 115 | const sessionId = allowRequestedSessionId |
| 58 | ? requestedSessionId as string | 116 | ? 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, |
| @@ -4,7 +4,7 @@ import type { Id } from "../_generated/dataModel"; · 3 unmodified lines | |||
| 4 | import type { MutationCtx, QueryCtx } from "../_generated/server"; | 4 | import type { MutationCtx, QueryCtx } from "../_generated/server"; |
| 5 | import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog"; | 5 | import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog"; |
| 6 | import type { | 6 | import 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 | ||
| 24 | type PolicyResourceType = AccessPolicyRecord["resourceType"]; | 23 | type PolicyResourceType = ToolPolicyRecord["resourceType"]; |
| 25 | type DbContext = Pick<MutationCtx, "db"> | Pick<QueryCtx, "db">; | 24 | type DbContext = Pick<MutationCtx, "db"> | Pick<QueryCtx, "db">; |
| 26 | 25 | ||
| 27 | function normalizeArgumentConditions( | 26 | function 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 | ||
| 45 | function 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 | |||
| 61 | function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): PolicyResourceType { | 44 | function 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 | ||
| 77 | function 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 | |||
| 105 | function 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 | |||
| 140 | function resourcePatternFromRule(rule: { | 60 | function 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 | ||
| 240 | function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): string { | 160 | function 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 | ||
| 260 | function mapFlattenedPolicy(doc: { | 180 | function 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 | ||
| 808 | export const upsertAccessPolicy = internalMutation({ | 728 | export 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 | |||
| 951 | export 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 | |||
| 1001 | export 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) { |
| @@ -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, |
| @@ -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) => { |
| @@ -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, |
| @@ -16,7 +16,6 @@ type VerifiedMcpToken = { provider: "workos"; subject: string }; · 15 unmodified lines | |||
| 16 | type ParsedMcpContext = { | 16 | type 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 | } |
| @@ -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 | } |
| @@ -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 }, |
| @@ -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", |
| @@ -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 | ||
| 130 | export const upsertAccessPolicy = workspaceMutation({ | 128 | export 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 | |||
| 180 | export 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 | |||
| 194 | export 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, |
| @@ -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 | |||
| 21 | type WorkspaceProvider = "workos" | "anonymous"; | ||
| 22 | |||
| 23 | export 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 | } | ||
| @@ -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) => { |
| @@ -5,6 +5,7 @@ import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../ · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | assertMatchesCanonicalAccountId, | 6 | assertMatchesCanonicalAccountId, |
| 7 | canonicalAccountIdForWorkspaceAccess, | 7 | canonicalAccountIdForWorkspaceAccess, |
| 8 | canonicalClientIdForWorkspaceAccess, | ||
| 8 | } from "../auth/account_identity"; | 9 | } from "../auth/account_identity"; |
| 9 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; | 10 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 10 | import { createTaskEvent } from "../task/events"; | 11 | import { 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 | ||
| @@ -4,6 +4,7 @@ import { internal } from "../../convex/_generated/api"; · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 5 | assertMatchesCanonicalAccountId, | 5 | assertMatchesCanonicalAccountId, |
| 6 | canonicalAccountIdForWorkspaceAccess, | 6 | canonicalAccountIdForWorkspaceAccess, |
| 7 | canonicalClientIdForWorkspaceAccess, | ||
| 7 | } from "../auth/account_identity"; | 8 | } from "../auth/account_identity"; |
| 8 | 9 | ||
| 9 | export async function requireCanonicalAccount( | 10 | export 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 | } |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { expect, test } from "bun:test"; | 1 | import { expect, test } from "bun:test"; |
| 2 | import type { AccessPolicyRecord } from "../../../core/src/types"; | 2 | import type { ToolPolicyRecord } from "../../../core/src/types"; |
| 3 | import { getDecisionForContext } from "./policy"; | 3 | import { getDecisionForContext } from "./policy"; |
| 4 | 4 | ||
| 5 | const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePattern"> = { | 5 | const 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 | ||
| 16 | test("source-scoped policy can deny all tools from a source", () => { | 16 | test("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 | ||
| 44 | test("tool-path policy can override source policy by specificity", () => { | 44 | test("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 | ||
| 83 | test("all-tools policy applies without source context", () => { | 83 | test("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 | |||
| 110 | test("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 | }); | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | import type { | 1 | import 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 | ||
| 49 | function matchesPolicyResource(policy: AccessPolicyRecord, tool: PolicyTool): boolean { | 49 | function 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 | ||
| 68 | function policySpecificity( | 68 | function 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 | ||
| 94 | function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision { | 94 | function 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 | |||
| 113 | export function getDecisionForContext( | 113 | export 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 = policies | 120 | const candidates = policies |
| 125 | .filter((policy) => { | 121 | .filter((policy) => { |
| @@ -151,7 +147,7 @@ export function getDecisionForContext( · 25 unmodified lines | |||
| 151 | export function getToolDecision( | 147 | export 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; |
| @@ -3,7 +3,7 @@ import { z } from "zod"; · 2 unmodified lines | |||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; | 5 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; |
| 6 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; | 6 | import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; | 7 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 8 | import { getDecisionForContext, getToolDecision } from "./policy"; | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; | 9 | import { 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) { |
| @@ -1,5 +1,5 @@ | |||
| 1 | import type { | 1 | import 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 | |||
| 135 | export function listVisibleToolDescriptors( | 135 | export 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 ?? []; |
| @@ -2,7 +2,7 @@ import { z } from "zod"; · 1 unmodified line | |||
| 2 | import type { ActionCtx } from "../../convex/_generated/server"; | 2 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 3 | import { internal } from "../../convex/_generated/api"; | 3 | import { internal } from "../../convex/_generated/api"; |
| 4 | import type { | 4 | import 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 | ||
| 221 | async function listWorkspaceAccessPolicies( | 221 | async 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 | ||
| 232 | async function listRegistryNamespaces( | 232 | async 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, |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; | 18 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 19 | import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types"; | 19 | import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types"; |
| 20 | import type { | 20 | import 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 | ||
| 69 | async function listWorkspaceAccessPolicies( | 69 | async 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 | |||
| 378 | function listVisibleRegistryToolDescriptors( | 378 | function 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 policy | 1151 | // Read policies so Convex tracks this reactive dependency — any policy |
| 1152 | // change will invalidate the query and produce a new reactiveKey, which | 1152 | // 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> = {}; |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Result } from "better-result"; | 1 | import { Result } from "better-result"; |
| 2 | import { api } from "@executor/database/convex/_generated/api"; | 2 | import { api } from "@executor/database/convex/_generated/api"; |
| 3 | import { decodeToolCallResultFromTransport } from "../../core/src/tool-call-result-transport"; | 3 | import { decodeToolCallResultFromTransport } from "../../core/src/tool-call-result-transport"; |
| 4 | import { ConvexClient, ConvexHttpClient } from "convex/browser"; | 4 | import { ConvexHttpClient } from "convex/browser"; |
| 5 | import { z } from "zod"; | 5 | import { z } from "zod"; |
| 6 | import type { | 6 | import type { |
| 7 | BridgeEntrypointContext, | 7 | BridgeEntrypointContext, |
| @@ -74,56 +74,42 @@ function createConvexClient(callbackConvexUrl: string): ConvexHttpClient { · 66 unmodified lines | |||
| 74 | }); | 74 | }); |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | function createRealtimeClient(callbackConvexUrl: string): ConvexClient { | 77 | function 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 | ||
| 83 | async function waitForApprovalUpdate(props: BridgeProps, approvalId: string): Promise<void> { | 81 | async 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 | ||
| 129 | export async function callToolWithBridge( | 115 | export async function callToolWithBridge( |
| @@ -35,6 +35,7 @@ · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | import { Result } from "better-result"; | 36 | import { Result } from "better-result"; |
| 37 | import { WorkerEntrypoint } from "cloudflare:workers"; | 37 | import { WorkerEntrypoint } from "cloudflare:workers"; |
| 38 | import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport"; | ||
| 38 | import GLOBALS_MODULE from "./isolate/globals.isolate.js"; | 39 | import GLOBALS_MODULE from "./isolate/globals.isolate.js"; |
| 39 | import HARNESS_CODE from "./isolate/harness.isolate.js"; | 40 | import HARNESS_CODE from "./isolate/harness.isolate.js"; |
| 40 | import { authorizeRunRequest } from "./auth"; | 41 | import { 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 | ||
| @@ -2,6 +2,111 @@ import { ResponseJson as _ResponseJson } from "./globals.js"; · 1 unmodified line | |||
| 2 | import { run } from "./user-code.js"; | 2 | import { run } from "./user-code.js"; |
| 3 | 3 | ||
| 4 | const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; | 4 | const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; |
| 5 | const TOOL_RESULT_UNDEFINED_SENTINEL = "__executor_tool_result_undefined__"; | ||
| 6 | |||
| 7 | function 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 | |||
| 83 | function 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 | ||
| 6 | function createToolsProxy(bridge, path = []) { | 111 | function 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({ |
| @@ -45,7 +45,7 @@ export interface RunResult { · 44 unmodified lines | |||
| 45 | export type ToolCallResult = | 45 | export 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 | }; |