- 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 | 56 | *.har |
| 57 | 57 | .vercel |
| 58 | 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 | 44 | const LINK_FIELD_WORKSPACE = "workspace_id"; |
| 45 | 45 | const LINK_FIELD_TOKEN = "access_token"; |
| 46 | 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 | 111 | const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ |
| 49 | 112 | new SlashCommandBuilder() |
| @@ -64,10 +127,7 @@ const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ · 14 unmodified lines | ||
| 64 | 127 | .toJSON(), |
| 65 | 128 | new SlashCommandBuilder() |
| 66 | 129 | .setName("link-anon") |
| 67 | − .setDescription("Link to an anonymous MCP session") | |
| 68 | − .addStringOption((option) => option | |
| 69 | − .setName("session_id") | |
| 70 | − .setDescription("Optional session id to reuse")) | |
| 130 | + .setDescription("Link to an anonymous MCP API key") | |
| 71 | 131 | .toJSON(), |
| 72 | 132 | new SlashCommandBuilder() |
| 73 | 133 | .setName("unlink") |
| @@ -167,13 +227,14 @@ async function linkWorkos(identity: ChatIdentity, workspaceId: string, accessTok · 93 unmodified lines | ||
| 167 | 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 | 231 | const data = await unwrap( |
| 172 | 232 | api.api.context.link.post({ |
| 173 | 233 | platform: identity.platform, |
| 174 | 234 | userId: identity.userId, |
| 175 | 235 | provider: "anonymous", |
| 176 | − sessionId, | |
| 236 | + workspaceId, | |
| 237 | + apiKey, | |
| 177 | 238 | }), |
| 178 | 239 | ); |
| 179 | 240 | return data.context; |
| @@ -189,30 +250,624 @@ async function unlink(identity: ChatIdentity) { · 9 unmodified lines | ||
| 189 | 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 | 254 | const data = await unwrap( |
| 194 | 255 | api.api.chat.run.post({ |
| 195 | 256 | platform: identity.platform, |
| 196 | 257 | userId: identity.userId, |
| 197 | 258 | prompt, |
| 259 | + requestId, | |
| 198 | 260 | }), |
| 199 | 261 | ); |
| 200 | 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 | 856 | function contextLabel(context: { |
| 204 | 857 | source: string; |
| 205 | 858 | workspaceId: string; |
| 206 | 859 | accountId?: string; |
| 207 | 860 | sessionId?: string; |
| 208 | 861 | hasAccessToken: boolean; |
| 862 | + hasApiKey?: boolean; | |
| 209 | 863 | }) { |
| 864 | + const authMode = context.hasApiKey ? "api-key" : context.hasAccessToken ? "token" : "session"; | |
| 210 | 865 | return [ |
| 211 | 866 | `source=${context.source}`, |
| 212 | 867 | `workspace=${context.workspaceId}`, |
| 213 | 868 | context.accountId ? `account=${context.accountId}` : undefined, |
| 214 | 869 | context.sessionId ? `session=${context.sessionId}` : undefined, |
| 215 | − context.hasAccessToken ? "auth=token" : "auth=session", | |
| 870 | + `auth=${authMode}`, | |
| 216 | 871 | ] |
| 217 | 872 | .filter(Boolean) |
| 218 | 873 | .join(" | "); |
| @@ -253,6 +908,33 @@ function createWorkosLinkModal() { · 34 unmodified lines | ||
| 253 | 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 | 938 | function buildExecutorLink(identity: ChatIdentity): string | null { |
| 257 | 939 | try { |
| 258 | 940 | const url = new URL(EXECUTOR_WEB_URL); |
| @@ -329,9 +1011,9 @@ async function handlePrefixCommand(message: Message<boolean>, rawText: string) { · 70 unmodified lines | ||
| 329 | 1011 | "- /ask <prompt>", |
| 330 | 1012 | "- /whoami", |
| 331 | 1013 | "- /link-workos (opens secure modal)", |
| 332 | − "- /link-anon [session_id]", | |
| 1014 | + "- /link-anon (paste workspace + API key)", | |
| 333 | 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 | 1017 | ].join("\n")); |
| 336 | 1018 | return; |
| 337 | 1019 | } |
| @@ -349,8 +1031,12 @@ async function handlePrefixCommand(message: Message<boolean>, rawText: string) { · 11 unmodified lines | ||
| 349 | 1031 | } |
| 350 | 1032 | |
| 351 | 1033 | if (command === "link-anon") { |
| 352 | − const [sessionId] = args; | |
| 353 | − const context = await linkAnonymous(identity, sessionId); | |
| 1034 | + const [workspaceId, apiKey] = args; | |
| 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 | 1040 | await reply(message, `Linked anonymous context: ${contextLabel(context)}`); |
| 355 | 1041 | return; |
| 356 | 1042 | } |
| @@ -381,7 +1067,13 @@ bot.on("interactionCreate", async (interaction) => { · 24 unmodified lines | ||
| 381 | 1067 | if (interaction.commandName === "ask") { |
| 382 | 1068 | const prompt = interaction.options.getString("prompt", true); |
| 383 | 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 | 1077 | const chunks = chunkMessage(result.text || "Done."); |
| 386 | 1078 | await interaction.editReply(chunks[0] ?? "Done."); |
| 387 | 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 | 1103 | if (interaction.commandName === "link-anon") { |
| 412 | − const sessionId = interaction.options.getString("session_id") ?? undefined; | |
| 413 | − const context = await linkAnonymous(identity, sessionId); | |
| 414 | − await interaction.reply({ | |
| 415 | − content: `Linked anonymous context: ${contextLabel(context)}`, | |
| 416 | − ephemeral: true, | |
| 417 | − }); | |
| 1104 | + await interaction.showModal(createAnonymousLinkModal()); | |
| 418 | 1105 | return; |
| 419 | 1106 | } |
| 420 | 1107 | |
| @@ -452,6 +1139,141 @@ bot.on("interactionCreate", async (interaction) => { · 31 unmodified lines | ||
| 452 | 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 | 1277 | if (interaction.isButton() && interaction.customId === LINK_OPEN_MODAL_BUTTON_ID) { |
| 456 | 1278 | await interaction.showModal(createWorkosLinkModal()); |
| 457 | 1279 | return; |
| @@ -504,7 +1326,13 @@ bot.on("messageCreate", async (message) => { · 46 unmodified lines | ||
| 504 | 1326 | } |
| 505 | 1327 | |
| 506 | 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 | 1336 | await reply(message, result.text || "Done."); |
| 509 | 1337 | } catch (error) { |
| 510 | 1338 | const messageText = error instanceof Error ? error.message : String(error); |
| @@ -4,6 +4,10 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | +import { | |
| 8 | + ElicitRequestSchema, | |
| 9 | + ElicitationCompleteNotificationSchema, | |
| 10 | +} from "@modelcontextprotocol/sdk/types.js"; | |
| 7 | 11 | import { |
| 8 | 12 | completeSimple, |
| 9 | 13 | getModel, |
| @@ -51,6 +55,23 @@ interface Message { · 41 unmodified lines | ||
| 51 | 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 | 75 | interface McpTool { |
| 55 | 76 | name: string; |
| 56 | 77 | description?: string; |
| @@ -81,10 +102,14 @@ export interface AgentOptions { · 24 unmodified lines | ||
| 81 | 102 | readonly clientId?: string; |
| 82 | 103 | readonly sessionId?: string; |
| 83 | 104 | readonly mcpAccessToken?: string; |
| 105 | + readonly mcpApiKey?: string; | |
| 106 | + readonly useAnonymousMcp?: boolean; | |
| 84 | 107 | readonly apiKey?: string; |
| 85 | 108 | readonly modelId?: string; |
| 86 | 109 | readonly context?: string; |
| 87 | 110 | readonly maxToolCalls?: number; |
| 111 | + readonly onElicitation?: (request: AgentElicitationRequest) => Promise<AgentElicitationResponse>; | |
| 112 | + readonly onElicitationComplete?: (notification: AgentElicitationCompleteNotification) => void; | |
| 88 | 113 | } |
| 89 | 114 | |
| 90 | 115 | export interface AgentResult { |
| @@ -156,10 +181,14 @@ export function createAgent(options: AgentOptions) { · 65 unmodified lines | ||
| 156 | 181 | clientId, |
| 157 | 182 | sessionId, |
| 158 | 183 | mcpAccessToken, |
| 184 | + mcpApiKey, | |
| 185 | + useAnonymousMcp = false, | |
| 159 | 186 | apiKey, |
| 160 | 187 | modelId = "claude-sonnet-4-5", |
| 161 | 188 | context, |
| 162 | 189 | maxToolCalls = 20, |
| 190 | + onElicitation, | |
| 191 | + onElicitationComplete, | |
| 163 | 192 | } = options; |
| 164 | 193 | |
| 165 | 194 | const resolvedApiKey = resolveApiKey(apiKey); |
| @@ -194,17 +223,23 @@ export function createAgent(options: AgentOptions) { · 28 unmodified lines | ||
| 194 | 223 | |
| 195 | 224 | // Connect to executor MCP |
| 196 | 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 | 228 | mcpUrl.searchParams.set("workspaceId", workspaceId); |
| 199 | − if (accountId) mcpUrl.searchParams.set("accountId", accountId); | |
| 229 | + if (!useAnonymousMcp && accountId) mcpUrl.searchParams.set("accountId", accountId); | |
| 200 | 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 | 234 | ? new StreamableHTTPClientTransport(mcpUrl, { |
| 205 | 235 | fetch: async (input, init) => { |
| 206 | 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 | 243 | return await fetch(input, { |
| 209 | 244 | ...init, |
| 210 | 245 | headers, |
| @@ -212,7 +247,58 @@ export function createAgent(options: AgentOptions) { · 1 unmodified line | ||
| 212 | 247 | }, |
| 213 | 248 | }) |
| 214 | 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 | 303 | let mcpConnected = false; |
| 218 | 304 | try { |
| @@ -1,3 +1,9 @@ | ||
| 1 | 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 | 9 | export type { TaskEvent, TaskStatusEvent, TaskCodeGeneratedEvent, TaskCodeResultEvent, TaskAgentMessageEvent, TaskErrorEvent, TaskCompletedEvent } from "./events"; |
| @@ -10,6 +10,7 @@ export interface LinkedMcpContext { · 9 unmodified lines | ||
| 10 | 10 | readonly accountId?: string; |
| 11 | 11 | readonly sessionId?: string; |
| 12 | 12 | readonly accessToken?: string; |
| 13 | + readonly mcpApiKey?: string; | |
| 13 | 14 | readonly clientId?: string; |
| 14 | 15 | readonly linkedAt: number; |
| 15 | 16 | } |
| @@ -1,5 +1,9 @@ | ||
| 1 | 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 | 7 | import { ConvexHttpClient } from "convex/browser"; |
| 4 | 8 | import { api } from "@executor/database/convex/_generated/api"; |
| 5 | 9 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| @@ -24,10 +28,37 @@ interface ResolvedExecutorContext { · 18 unmodified lines | ||
| 24 | 28 | readonly sessionId?: string; |
| 25 | 29 | readonly clientId?: string; |
| 26 | 30 | readonly mcpAccessToken?: string; |
| 31 | + readonly mcpApiKey?: string; | |
| 32 | + readonly mcpApiKeyError?: string; | |
| 33 | + readonly useAnonymousMcp: boolean; | |
| 27 | 34 | readonly linked: boolean; |
| 28 | 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 | 62 | function normalizeIdentityPart(value: string): string { |
| 32 | 63 | return value |
| 33 | 64 | .toLowerCase() |
| @@ -51,6 +82,142 @@ export function createApp(options: ServerOptions) { · 17 unmodified lines | ||
| 51 | 82 | const convex = new ConvexHttpClient(options.convexUrl); |
| 52 | 83 | const linkStore = createFileLinkStore(options.linksFile); |
| 53 | 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 | 222 | async function resolveContext(identity: ChatIdentity): Promise<ResolvedExecutorContext> { |
| 56 | 223 | const key = identityKey(identity); |
| @@ -63,17 +230,40 @@ export function createApp(options: ServerOptions) { · 6 unmodified lines | ||
| 63 | 230 | sessionId: linked.sessionId, |
| 64 | 231 | clientId: linked.clientId ?? defaultClientId, |
| 65 | 232 | mcpAccessToken: linked.accessToken, |
| 233 | + useAnonymousMcp: false, | |
| 66 | 234 | linked: true, |
| 67 | 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 | 252 | const requestedSessionId = linked?.sessionId ?? defaultSessionId(identity); |
| 72 | 253 | const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, { |
| 73 | 254 | sessionId: requestedSessionId, |
| 74 | 255 | }); |
| 75 | 256 | |
| 76 | 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 | 268 | if (linked?.provider === "anonymous") { |
| 79 | 269 | await linkStore.set(key, { |
| @@ -81,6 +271,7 @@ export function createApp(options: ServerOptions) { · 1 unmodified line | ||
| 81 | 271 | workspaceId: anonymous.workspaceId, |
| 82 | 272 | accountId: anonymous.accountId, |
| 83 | 273 | sessionId: anonymous.sessionId, |
| 274 | + mcpApiKey, | |
| 84 | 275 | clientId, |
| 85 | 276 | linkedAt: linked.linkedAt, |
| 86 | 277 | }); |
| @@ -91,6 +282,9 @@ export function createApp(options: ServerOptions) { · 4 unmodified lines | ||
| 91 | 282 | accountId: anonymous.accountId, |
| 92 | 283 | sessionId: anonymous.sessionId, |
| 93 | 284 | clientId, |
| 285 | + mcpApiKey, | |
| 286 | + mcpApiKeyError, | |
| 287 | + useAnonymousMcp: true, | |
| 94 | 288 | linked: linked?.provider === "anonymous", |
| 95 | 289 | source: linked?.provider === "anonymous" ? "linked_anonymous" : "anonymous", |
| 96 | 290 | }; |
| @@ -105,6 +299,7 @@ export function createApp(options: ServerOptions) { · 8 unmodified lines | ||
| 105 | 299 | linked: context.linked, |
| 106 | 300 | source: context.source, |
| 107 | 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 | 350 | return { linked: true, context: toPublicContext(context) }; |
| 156 | 351 | } |
| 157 | 352 | |
| 158 | − const requestedSessionId = body.sessionId?.trim() || defaultSessionId(identity); | |
| 159 | − const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, { | |
| 160 | − sessionId: requestedSessionId, | |
| 161 | − }); | |
| 353 | + if (!body.workspaceId?.trim()) { | |
| 354 | + throw new Error("workspaceId is required for anonymous links"); | |
| 355 | + } | |
| 356 | + if (!body.apiKey?.trim()) { | |
| 357 | + throw new Error("apiKey is required for anonymous links"); | |
| 358 | + } | |
| 162 | 359 | |
| 163 | 360 | const link: LinkedMcpContext = { |
| 164 | 361 | provider: "anonymous", |
| 165 | − workspaceId: anonymous.workspaceId, | |
| 166 | − accountId: anonymous.accountId, | |
| 167 | − sessionId: anonymous.sessionId, | |
| 362 | + workspaceId: body.workspaceId as Id<"workspaces">, | |
| 363 | + accountId: body.accountId?.trim() || undefined, | |
| 364 | + sessionId: body.sessionId?.trim() || undefined, | |
| 365 | + mcpApiKey: body.apiKey.trim(), | |
| 168 | 366 | clientId, |
| 169 | 367 | linkedAt, |
| 170 | 368 | }; |
| @@ -181,6 +379,7 @@ export function createApp(options: ServerOptions) { · 10 unmodified lines | ||
| 181 | 379 | accountId: t.Optional(t.String({ minLength: 1 })), |
| 182 | 380 | sessionId: t.Optional(t.String({ minLength: 1 })), |
| 183 | 381 | accessToken: t.Optional(t.String({ minLength: 1 })), |
| 382 | + apiKey: t.Optional(t.String({ minLength: 1 })), | |
| 184 | 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 | 466 | .post( |
| 204 | 467 | "/api/chat/run", |
| 205 | 468 | async ({ body }) => { |
| 206 | − const context = await resolveContext({ | |
| 469 | + const identity = { | |
| 207 | 470 | platform: body.platform, |
| 208 | 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 | 483 | const agent = createAgent({ |
| 212 | 484 | executorUrl: options.executorUrl, |
| @@ -215,11 +487,17 @@ export function createApp(options: ServerOptions) { · 2 unmodified lines | ||
| 215 | 487 | sessionId: context.sessionId, |
| 216 | 488 | clientId: context.clientId, |
| 217 | 489 | mcpAccessToken: context.mcpAccessToken, |
| 490 | + mcpApiKey: context.mcpApiKey, | |
| 491 | + useAnonymousMcp: context.useAnonymousMcp, | |
| 218 | 492 | context: options.context, |
| 493 | + onElicitation: async (request) => { | |
| 494 | + return await waitForElicitationResponse(identity, requestId, request); | |
| 495 | + }, | |
| 219 | 496 | }); |
| 220 | 497 | |
| 221 | 498 | const result = await agent.run(body.prompt); |
| 222 | 499 | return { |
| 500 | + requestId, | |
| 223 | 501 | text: result.text, |
| 224 | 502 | toolCalls: result.toolCalls, |
| 225 | 503 | context: toPublicContext(context), |
| @@ -230,6 +508,7 @@ export function createApp(options: ServerOptions) { · 4 unmodified lines | ||
| 230 | 508 | platform: t.String({ minLength: 1 }), |
| 231 | 509 | userId: t.String({ minLength: 1 }), |
| 232 | 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 | 20 | import { convexApi } from "@/lib/convex-api"; |
| 21 | 21 | import { useSession } from "@/lib/session-context"; |
| 22 | 22 | import type { RuntimeTargetDescriptor } from "@/lib/types"; |
| 23 | −import { listRuntimeTargets } from "@/lib/runtime-targets"; | |
| 23 | +import { listRuntimeTargetsWithOptions } from "@/lib/runtime-targets"; | |
| 24 | 24 | import { useWorkspaceTools } from "@/hooks/use/workspace-tools"; |
| 25 | 25 | |
| 26 | 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 | 109 | export function TaskComposer() { |
| 110 | − const { context } = useSession(); | |
| 110 | + const { context, clientConfig } = useSession(); | |
| 111 | 111 | const [code, setCode] = useState(() => { |
| 112 | 112 | return readCodeDraftForWorkspace(context?.workspaceId) ?? DEFAULT_CODE; |
| 113 | 113 | }); |
| @@ -131,7 +131,10 @@ export function TaskComposer() { · 17 unmodified lines | ||
| 131 | 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 | 138 | const createTask = useAction(convexApi.executor.createTask); |
| 136 | 139 | const { tools, typesUrl, loadingTools } = useWorkspaceTools(context ?? null); |
| 137 | 140 | const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId) |
| @@ -186,7 +189,6 @@ export function TaskComposer() { · 48 unmodified lines | ||
| 186 | 189 | timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS, |
| 187 | 190 | workspaceId: context.workspaceId, |
| 188 | 191 | sessionId: context.sessionId, |
| 189 | − clientId: context.clientId, | |
| 190 | 192 | waitForResult: true, |
| 191 | 193 | }); |
| 192 | 194 | |
| @@ -221,7 +223,7 @@ export function TaskComposer() { · 28 unmodified lines | ||
| 221 | 223 | <CardContent className="space-y-4"> |
| 222 | 224 | <div className="grid grid-cols-2 gap-3"> |
| 223 | 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 | 227 | {showRuntimeSelector ? ( |
| 226 | 228 | <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}> |
| 227 | 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 | 49 | import { convexApi } from "@/lib/convex-api"; |
| 50 | 50 | import { cn } from "@/lib/utils"; |
| 51 | 51 | import type { |
| 52 | − AccessPolicyRecord, | |
| 52 | + ToolPolicyRecord, | |
| 53 | 53 | ArgumentCondition, |
| 54 | 54 | ArgumentConditionOperator, |
| 55 | 55 | ToolDescriptor, |
| 56 | + ToolRoleBindingRecord, | |
| 57 | + ToolRoleRuleRecord, | |
| 58 | + ToolRoleRecord, | |
| 56 | 59 | } from "@/lib/types"; |
| 57 | 60 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 58 | 61 | import { workspaceQueryArgs } from "@/lib/workspace/query-args"; |
| @@ -61,6 +64,7 @@ import { sourceLabel } from "@/lib/tool/source-utils"; · 2 unmodified lines | ||
| 61 | 64 | // ── Types ──────────────────────────────────────────────────────────────────── |
| 62 | 65 | |
| 63 | 66 | type PolicyDecisionType = "allow" | "require_approval" | "deny"; |
| 67 | +type PolicyResourceType = ToolPolicyRecord["resourceType"]; | |
| 64 | 68 | |
| 65 | 69 | interface ToolNamespace { |
| 66 | 70 | prefix: string; |
| @@ -74,28 +78,92 @@ type PolicyScope = "personal" | "workspace" | "organization"; · 7 unmodified lines | ||
| 74 | 78 | interface FormState { |
| 75 | 79 | scope: PolicyScope; |
| 76 | 80 | decision: PolicyDecisionType; |
| 81 | + resourceType: PolicyResourceType; | |
| 77 | 82 | selectedToolPaths: string[]; |
| 78 | 83 | resourcePattern: string; |
| 84 | + sourcePattern: string; | |
| 85 | + namespacePattern: string; | |
| 79 | 86 | argumentConditions: ArgumentCondition[]; |
| 80 | 87 | clientId: string; |
| 81 | 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 | 121 | function defaultFormState(): FormState { |
| 85 | 122 | return { |
| 86 | 123 | scope: "personal", |
| 87 | 124 | decision: "require_approval", |
| 125 | + resourceType: "tool_path", | |
| 88 | 126 | selectedToolPaths: [], |
| 89 | 127 | resourcePattern: "", |
| 128 | + sourcePattern: "", | |
| 129 | + namespacePattern: "", | |
| 90 | 130 | argumentConditions: [], |
| 91 | 131 | clientId: "", |
| 92 | 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 | 164 | // ── Helpers ────────────────────────────────────────────────────────────────── |
| 97 | 165 | |
| 98 | −function getDecisionFromPolicy(policy: AccessPolicyRecord): PolicyDecisionType { | |
| 166 | +function getDecisionFromPolicy(policy: ToolPolicyRecord): PolicyDecisionType { | |
| 99 | 167 | if (policy.effect === "deny") return "deny"; |
| 100 | 168 | return policy.approvalMode === "required" ? "require_approval" : "allow"; |
| 101 | 169 | } |
| @@ -106,7 +174,7 @@ function getDecisionPayload(decision: PolicyDecisionType) { · 4 unmodified lines | ||
| 106 | 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 | 178 | if (policy.targetAccountId) { |
| 111 | 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 | 228 | const OPERATOR_LABELS: Record<ArgumentConditionOperator, string> = { |
| 139 | 229 | equals: "equals", |
| 140 | 230 | not_equals: "not equals", |
| @@ -159,13 +249,6 @@ function buildNamespaces(tools: ToolDescriptor[]): ToolNamespace[] { · 18 unmodified lines | ||
| 159 | 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 | 252 | function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[]): string { |
| 170 | 253 | if (paths.length === 0) return "*"; |
| 171 | 254 | if (paths.length === 1) return paths[0]!; |
| @@ -196,6 +279,77 @@ function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[] · 24 unmodified lines | ||
| 196 | 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 | 353 | // ── Tool Picker (virtualized) ──────────────────────────────────────────────── |
| 200 | 354 | |
| 201 | 355 | type VirtualRow = |
| @@ -573,12 +727,14 @@ function PolicyCard({ · 371 unmodified lines | ||
| 573 | 727 | tools, |
| 574 | 728 | currentAccountId, |
| 575 | 729 | onDelete, |
| 730 | + canDelete, | |
| 576 | 731 | deleting, |
| 577 | 732 | }: { |
| 578 | − policy: AccessPolicyRecord; | |
| 733 | + policy: ToolPolicyRecord; | |
| 579 | 734 | tools: ToolDescriptor[]; |
| 580 | 735 | currentAccountId?: string; |
| 581 | − onDelete: (id: string) => void; | |
| 736 | + onDelete: (policy: ToolPolicyRecord) => void; | |
| 737 | + canDelete: boolean; | |
| 582 | 738 | deleting: boolean; |
| 583 | 739 | }) { |
| 584 | 740 | const decision = getDecisionFromPolicy(policy); |
| @@ -586,9 +742,8 @@ function PolicyCard({ · 1 unmodified line | ||
| 586 | 742 | const Icon = config.icon; |
| 587 | 743 | |
| 588 | 744 | const matchingTools = useMemo(() => { |
| 589 | − if (policy.resourcePattern === "*") return tools; | |
| 590 | − return tools.filter((t) => patternMatchesToolPath(policy.resourcePattern, t.path)); | |
| 591 | − }, [policy.resourcePattern, tools]); | |
| 745 | + return tools.filter((tool) => policyMatchesTool(policy, tool)); | |
| 746 | + }, [policy, tools]); | |
| 592 | 747 | |
| 593 | 748 | const [showTools, setShowTools] = useState(false); |
| 594 | 749 | |
| @@ -616,6 +771,12 @@ function PolicyCard({ · 21 unmodified lines | ||
| 616 | 771 | > |
| 617 | 772 | p{policy.priority} |
| 618 | 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 | 780 | </div> |
| 620 | 781 | {/* Pattern display */} |
| 621 | 782 | <div className="mt-1"> |
| @@ -663,14 +824,21 @@ function PolicyCard({ · 41 unmodified lines | ||
| 663 | 824 | <TooltipTrigger asChild> |
| 664 | 825 | <button |
| 665 | 826 | type="button" |
| 666 | − onClick={() => onDelete(policy.id)} | |
| 667 | − disabled={deleting} | |
| 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" | |
| 827 | + onClick={() => onDelete(policy)} | |
| 828 | + disabled={deleting || !canDelete} | |
| 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 | 836 | <Trash2 className="h-3.5 w-3.5" /> |
| 671 | 837 | </button> |
| 672 | 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 | 842 | </Tooltip> |
| 675 | 843 | </TooltipProvider> |
| 676 | 844 | </div> |
| @@ -688,33 +856,144 @@ export function PoliciesPanel({ · 11 unmodified lines | ||
| 688 | 856 | tools?: ToolDescriptor[]; |
| 689 | 857 | loadingTools?: boolean; |
| 690 | 858 | }) { |
| 691 | − const { context } = useSession(); | |
| 859 | + const { context, workspaces } = useSession(); | |
| 692 | 860 | const [dialogOpen, setDialogOpen] = useState(false); |
| 693 | 861 | const [submitting, setSubmitting] = useState(false); |
| 694 | 862 | const [deletingId, setDeletingId] = useState<string | null>(null); |
| 695 | 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 | 870 | const listArgs = workspaceQueryArgs(context); |
| 698 | − const policiesQuery = useQuery(convexApi.workspace.listAccessPolicies, listArgs); | |
| 699 | − const upsertAccessPolicy = useMutation(convexApi.workspace.upsertAccessPolicy); | |
| 700 | − const deleteAccessPolicy = useMutation(convexApi.workspace.deleteAccessPolicy); | |
| 871 | + const policiesQuery = useQuery(convexApi.workspace.listToolPolicies, listArgs); | |
| 872 | + const upsertToolRole = useMutation(convexApi.workspace.upsertToolRole); | |
| 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 | 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 | 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 | 967 | // ── Handlers ── |
| 708 | 968 | |
| 709 | 969 | const handleSave = async () => { |
| 710 | 970 | if (!context) return; |
| 711 | 971 | |
| 712 | − const pattern = form.resourcePattern.trim() || (form.selectedToolPaths.length > 0 | |
| 713 | − ? derivePatternFromSelection(form.selectedToolPaths, namespaces) | |
| 714 | − : "*"); | |
| 715 | − if (!pattern) { | |
| 716 | − toast.error("Tool path pattern is required"); | |
| 717 | − return; | |
| 972 | + let resourceType: PolicyResourceType = form.resourceType; | |
| 973 | + let pattern = ""; | |
| 974 | + if (resourceType === "all_tools") { | |
| 975 | + pattern = "*"; | |
| 976 | + } else if (resourceType === "source") { | |
| 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 | 999 | const priority = Number(form.priority.trim() || "100"); |
| @@ -725,8 +1004,19 @@ export function PoliciesPanel({ · 4 unmodified lines | ||
| 725 | 1004 | |
| 726 | 1005 | setSubmitting(true); |
| 727 | 1006 | try { |
| 1007 | + const policyId = createToolPolicyId(); | |
| 1008 | + const roleId = toolPolicyRoleId(policyId); | |
| 1009 | + const ruleId = toolPolicyRuleId(policyId); | |
| 1010 | + const bindingId = toolPolicyBindingId(policyId); | |
| 728 | 1011 | const { effect, approvalMode } = getDecisionPayload(form.decision); |
| 729 | 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 | 1021 | // Map UI scope to backend scopeType + targetAccountId. |
| 732 | 1022 | const scopeType = form.scope === "personal" ? "account" as const |
| @@ -734,20 +1024,53 @@ export function PoliciesPanel({ · 1 unmodified line | ||
| 734 | 1024 | : "organization" as const; |
| 735 | 1025 | const targetAccountId = form.scope === "personal" ? context.accountId : undefined; |
| 736 | 1026 | |
| 737 | − await upsertAccessPolicy({ | |
| 1027 | + await upsertToolRole({ | |
| 738 | 1028 | workspaceId: context.workspaceId, |
| 739 | 1029 | sessionId: context.sessionId, |
| 740 | − scopeType, | |
| 741 | − resourcePattern: pattern, | |
| 742 | − effect, | |
| 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, | |
| 1030 | + id: roleId, | |
| 1031 | + name: `tool-policy:${policyId}`, | |
| 1032 | + description: "Tool policy", | |
| 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 | 1074 | toast.success("Policy created"); |
| 752 | 1075 | setForm(defaultFormState()); |
| 753 | 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 | 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 | 1097 | try { |
| 765 | − await deleteAccessPolicy({ | |
| 1098 | + await deleteToolRole({ | |
| 766 | 1099 | workspaceId: context.workspaceId, |
| 767 | 1100 | sessionId: context.sessionId, |
| 768 | − policyId, | |
| 1101 | + roleId: policy.roleId, | |
| 769 | 1102 | }); |
| 770 | 1103 | toast.success("Policy deleted"); |
| 771 | 1104 | } catch (error) { |
| @@ -773,12 +1106,172 @@ export function PoliciesPanel({ · 1 unmodified line | ||
| 773 | 1106 | } finally { |
| 774 | 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 | 1271 | // ── Group policies by decision for display ── |
| 779 | 1272 | |
| 780 | 1273 | const groupedPolicies = useMemo(() => { |
| 781 | − const groups: Record<PolicyDecisionType, AccessPolicyRecord[]> = { | |
| 1274 | + const groups: Record<PolicyDecisionType, ToolPolicyRecord[]> = { | |
| 782 | 1275 | allow: [], |
| 783 | 1276 | require_approval: [], |
| 784 | 1277 | deny: [], |
| @@ -799,9 +1292,9 @@ export function PoliciesPanel({ · 14 unmodified lines | ||
| 799 | 1292 | <div className="flex items-center gap-2.5"> |
| 800 | 1293 | <Shield className="h-4 w-4 text-muted-foreground" /> |
| 801 | 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 | 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 | 1298 | </p> |
| 806 | 1299 | </div> |
| 807 | 1300 | </div> |
| @@ -829,9 +1322,9 @@ export function PoliciesPanel({ · 21 unmodified lines | ||
| 829 | 1322 | <div className="rounded-lg border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5"> |
| 830 | 1323 | <Shield className="h-8 w-8 text-muted-foreground/30" /> |
| 831 | 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 | 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 | 1328 | </p> |
| 836 | 1329 | </div> |
| 837 | 1330 | <Button |
| @@ -869,6 +1362,7 @@ export function PoliciesPanel({ · 31 unmodified lines | ||
| 869 | 1362 | tools={tools} |
| 870 | 1363 | currentAccountId={context?.accountId} |
| 871 | 1364 | onDelete={handleDelete} |
| 1365 | + canDelete={isDirectToolPolicy(policy)} | |
| 872 | 1366 | deleting={deletingId === policy.id} |
| 873 | 1367 | /> |
| 874 | 1368 | ))} |
| @@ -879,13 +1373,368 @@ export function PoliciesPanel({ · 4 unmodified lines | ||
| 879 | 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 | 1731 | {/* Create Policy Dialog */} |
| 883 | 1732 | <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> |
| 884 | 1733 | <DialogContent className="sm:max-w-[540px]"> |
| 885 | 1734 | <DialogHeader> |
| 886 | − <DialogTitle className="text-base">New Access Policy</DialogTitle> | |
| 1735 | + <DialogTitle className="text-base">New Tool Policy</DialogTitle> | |
| 887 | 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 | 1738 | </DialogDescription> |
| 890 | 1739 | </DialogHeader> |
| 891 | 1740 | |
| @@ -919,38 +1768,130 @@ export function PoliciesPanel({ · 27 unmodified lines | ||
| 919 | 1768 | </div> |
| 920 | 1769 | </div> |
| 921 | 1770 | |
| 922 | − {/* Tools selection */} | |
| 1771 | + {/* Resource target */} | |
| 923 | 1772 | <div className="space-y-2"> |
| 924 | − <Label className="text-xs text-muted-foreground">Apply to tools</Label> | |
| 925 | − {tools.length > 0 ? ( | |
| 926 | − <ToolPicker | |
| 927 | − tools={tools} | |
| 928 | − selectedPaths={form.selectedToolPaths} | |
| 929 | − onSelectionChange={(paths) => setForm((s) => ({ ...s, selectedToolPaths: paths }))} | |
| 930 | − onPatternChange={(pattern) => setForm((s) => ({ ...s, resourcePattern: pattern }))} | |
| 931 | − /> | |
| 932 | − ) : ( | |
| 933 | − <Input | |
| 934 | − value={form.resourcePattern} | |
| 935 | − onChange={(e) => setForm((s) => ({ ...s, resourcePattern: e.target.value }))} | |
| 936 | − placeholder="github.repos.* or * for all tools" | |
| 937 | − className="h-9 text-xs font-mono bg-background" | |
| 938 | − /> | |
| 1773 | + <Label className="text-xs text-muted-foreground">Apply to</Label> | |
| 1774 | + <Select | |
| 1775 | + value={form.resourceType} | |
| 1776 | + onValueChange={(value) => { | |
| 1777 | + const nextType = value as PolicyResourceType; | |
| 1778 | + setForm((state) => ({ | |
| 1779 | + ...state, | |
| 1780 | + resourceType: nextType, | |
| 1781 | + resourcePattern: nextType === "all_tools" ? "*" : state.resourcePattern, | |
| 1782 | + selectedToolPaths: nextType === "tool_path" ? state.selectedToolPaths : [], | |
| 1783 | + })); | |
| 1784 | + }} | |
| 1785 | + > | |
| 1786 | + <SelectTrigger className="h-9 text-xs bg-background"> | |
| 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 */} | |
| 941 | − {form.selectedToolPaths.length > 0 && ( | |
| 942 | − <div className="flex items-center gap-2"> | |
| 943 | − <span className="text-[10px] text-muted-foreground shrink-0">Pattern:</span> | |
| 1835 | + | |
| 1836 | + {form.resourceType === "namespace" && ( | |
| 1837 | + <> | |
| 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 | 1855 | <Input |
| 945 | − value={form.resourcePattern || derivePatternFromSelection(form.selectedToolPaths, namespaces)} | |
| 946 | − onChange={(e) => setForm((s) => ({ ...s, resourcePattern: e.target.value }))} | |
| 947 | − className="h-6 text-[10px] font-mono bg-muted/30 border-border/30" | |
| 1856 | + value={form.namespacePattern} | |
| 1857 | + onChange={(event) => setForm((state) => ({ ...state, namespacePattern: event.target.value }))} | |
| 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 | 1893 | </div> |
| 952 | 1894 | |
| 953 | − {/* Scope */} | |
| 954 | 1895 | {/* Scope */} |
| 955 | 1896 | <div className="space-y-2"> |
| 956 | 1897 | <Label className="text-xs text-muted-foreground">Scope</Label> |
| @@ -982,6 +1923,27 @@ export function PoliciesPanel({ · 25 unmodified lines | ||
| 982 | 1923 | className="h-9 text-xs font-mono bg-background w-24" |
| 983 | 1924 | /> |
| 984 | 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 | 1947 | </div> |
| 986 | 1948 | |
| 987 | 1949 | <DialogFooter> |
| @@ -47,14 +47,12 @@ type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "displ · 46 unmodified lines | ||
| 47 | 47 | type GetToolDetailsMutation = (args: { |
| 48 | 48 | workspaceId: Id<"workspaces">; |
| 49 | 49 | sessionId?: string; |
| 50 | − clientId?: string; | |
| 51 | 50 | toolPaths: string[]; |
| 52 | 51 | }) => Promise<Record<string, ToolDetailDescriptor>>; |
| 53 | 52 | |
| 54 | 53 | type ListToolsWithWarningsAction = (args: { |
| 55 | 54 | workspaceId: Id<"workspaces">; |
| 56 | 55 | accountId?: string; |
| 57 | − clientId?: string; | |
| 58 | 56 | sessionId?: string; |
| 59 | 57 | includeDetails?: boolean; |
| 60 | 58 | includeSourceMeta?: boolean; |
| @@ -122,7 +120,6 @@ export function useWorkspaceTools( · 61 unmodified lines | ||
| 122 | 120 | return await listToolsWithWarnings({ |
| 123 | 121 | workspaceId: context.workspaceId, |
| 124 | 122 | ...(context.accountId && { accountId: context.accountId }), |
| 125 | − ...(context.clientId && { clientId: context.clientId }), | |
| 126 | 123 | ...(context.sessionId && { sessionId: context.sessionId }), |
| 127 | 124 | includeDetails, |
| 128 | 125 | fetchAll: true, |
| @@ -152,7 +149,6 @@ export function useWorkspaceTools( · 23 unmodified lines | ||
| 152 | 149 | const detailsByPath = await listToolDetails({ |
| 153 | 150 | workspaceId: context.workspaceId, |
| 154 | 151 | sessionId: context.sessionId, |
| 155 | − ...(context.clientId && { clientId: context.clientId }), | |
| 156 | 152 | toolPaths: missing, |
| 157 | 153 | }); |
| 158 | 154 | |
| @@ -206,7 +202,6 @@ export function useWorkspaceTools( · 47 unmodified lines | ||
| 206 | 202 | await listToolsWithWarnings({ |
| 207 | 203 | workspaceId: context.workspaceId, |
| 208 | 204 | ...(context.accountId && { accountId: context.accountId }), |
| 209 | − ...(context.clientId && { clientId: context.clientId }), | |
| 210 | 205 | ...(context.sessionId && { sessionId: context.sessionId }), |
| 211 | 206 | includeDetails: false, |
| 212 | 207 | includeSourceMeta: false, |
| @@ -4,6 +4,7 @@ export type ExecutorRuntimeConfig = { · 3 unmodified lines | ||
| 4 | 4 | workosClientId?: string; |
| 5 | 5 | stripePriceId?: string; |
| 6 | 6 | executorHttpUrl?: string; |
| 7 | + allowLocalVm?: boolean; | |
| 7 | 8 | }; |
| 8 | 9 | |
| 9 | 10 | declare global { |
| @@ -17,6 +18,16 @@ function trim(value: string | null | undefined): string | undefined { · 7 unmodified lines | ||
| 17 | 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 | 31 | function normalizeDeploymentSlug(value: string | undefined): string | undefined { |
| 21 | 32 | const candidate = trim(value); |
| 22 | 33 | if (!candidate) { |
| @@ -53,6 +64,7 @@ export function readRuntimeConfig(): ExecutorRuntimeConfig { · 30 unmodified lines | ||
| 53 | 64 | workosClientId: trim(import.meta.env.VITE_WORKOS_CLIENT_ID), |
| 54 | 65 | stripePriceId: trim(import.meta.env.VITE_STRIPE_PRICE_ID), |
| 55 | 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 | 106 | ?? process.env.EXECUTOR_HTTP_URL |
| 95 | 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 | 1 | import type { RuntimeTargetDescriptor } from "@/lib/types"; |
| 2 | +import { readRuntimeConfig } from "@/lib/runtime-config"; | |
| 2 | 3 | |
| 3 | 4 | const LOCAL_BUN_RUNTIME_ID = "local-bun"; |
| 4 | 5 | const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader"; |
| @@ -27,16 +28,26 @@ function isTruthyEnvValue(value: string | undefined): boolean { · 22 unmodified lines | ||
| 27 | 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 | 47 | if (runtimeId !== LOCAL_BUN_RUNTIME_ID && runtimeId !== CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) { |
| 32 | 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 | 51 | if (localVmAllowed) { |
| 41 | 52 | return true; |
| 42 | 53 | } |
| @@ -45,5 +56,10 @@ function isRuntimeEnabled(runtimeId: string): boolean { · 2 unmodified lines | ||
| 45 | 56 | } |
| 46 | 57 | |
| 47 | 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 | 49 | clientConfig: { |
| 50 | 50 | authProviderMode: string; |
| 51 | 51 | invitesProvider: string; |
| 52 | + runtime?: { | |
| 53 | + allowLocalVm: boolean; | |
| 54 | + defaultRuntimeId: string; | |
| 55 | + enabledRuntimeIds: string[]; | |
| 56 | + }; | |
| 52 | 57 | features: { |
| 53 | 58 | organizations: boolean; |
| 54 | 59 | billing: boolean; |
| @@ -1,4 +1,10 @@ | ||
| 1 | 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 | 9 | // ── Shared types (inlined from @executor/contracts) ────────────────────────── |
| 4 | 10 | |
| @@ -9,6 +15,8 @@ export type PolicyScopeType = "account" | "organization" | "workspace"; · 4 unmodified lines | ||
| 9 | 15 | export type PolicyMatchType = "glob" | "exact"; |
| 10 | 16 | export type PolicyEffect = "allow" | "deny"; |
| 11 | 17 | export type PolicyApprovalMode = "inherit" | "auto" | "required"; |
| 18 | +export type ToolRoleSelectorType = "all" | "source" | "namespace" | "tool_path"; | |
| 19 | +export type ToolRoleBindingStatus = "active" | "disabled"; | |
| 12 | 20 | export type ArgumentConditionOperator = "equals" | "contains" | "starts_with" | "not_equals"; |
| 13 | 21 | |
| 14 | 22 | export interface ArgumentCondition { |
| @@ -76,23 +84,10 @@ export interface TaskEventRecord { · 61 unmodified lines | ||
| 76 | 84 | createdAt: number; |
| 77 | 85 | } |
| 78 | 86 | |
| 79 | −export interface AccessPolicyRecord { | |
| 80 | − id: string; | |
| 81 | − scopeType: PolicyScopeType; | |
| 82 | − organizationId?: string; | |
| 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 | −} | |
| 87 | +export type ToolPolicyRecord = CoreToolPolicyRecord; | |
| 88 | +export type ToolRoleRecord = CoreToolRoleRecord; | |
| 89 | +export type ToolRoleRuleRecord = CoreToolRoleRuleRecord; | |
| 90 | +export type ToolRoleBindingRecord = CoreToolRoleBindingRecord; | |
| 96 | 91 | |
| 97 | 92 | export interface CredentialRecord { |
| 98 | 93 | id: string; |
| @@ -146,6 +146,11 @@ export const organizationQuery = convexCustomQuery(query, { · 145 unmodified lines | ||
| 146 | 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 | 154 | ensureOrganizationRole(accountMembership.role, options); |
| 150 | 155 | |
| 151 | 156 | return { |
| @@ -172,6 +177,11 @@ export const organizationMutation = convexCustomMutation(mutation, { · 20 unmodified lines | ||
| 172 | 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 | 185 | ensureOrganizationRole(accountMembership.role, options); |
| 176 | 186 | |
| 177 | 187 | return { |
| @@ -238,6 +248,11 @@ export const internalOrganizationQuery = convexCustomQuery(internalQuery, { · 60 unmodified lines | ||
| 238 | 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 | 256 | ensureOrganizationRole(accountMembership.role, options); |
| 242 | 257 | |
| 243 | 258 | return { |
| @@ -38,7 +38,6 @@ function createRunCodeTool( · 37 unmodified lines | ||
| 38 | 38 | timeoutMs?: number; |
| 39 | 39 | runtimeId?: string; |
| 40 | 40 | metadata?: Record<string, unknown>; |
| 41 | − clientId?: string; | |
| 42 | 41 | sessionId?: string; |
| 43 | 42 | waitForResult?: boolean; |
| 44 | 43 | resultTimeoutMs?: number; |
| @@ -62,7 +61,7 @@ function createRunCodeTool( · 17 unmodified lines | ||
| 62 | 61 | context = { |
| 63 | 62 | workspaceId: anonymous.workspaceId, |
| 64 | 63 | accountId: anonymous.accountId, |
| 65 | − clientId: input.clientId ?? anonymous.clientId, | |
| 64 | + clientId: anonymous.clientId, | |
| 66 | 65 | sessionId: anonymous.sessionId, |
| 67 | 66 | }; |
| 68 | 67 | } |
| @@ -178,7 +177,6 @@ const FULL_INPUT = z.object({ · 109 unmodified lines | ||
| 178 | 177 | timeoutMs: z.number().int().min(1).max(600_000).optional(), |
| 179 | 178 | runtimeId: z.string().optional(), |
| 180 | 179 | metadata: z.record(z.unknown()).optional(), |
| 181 | − clientId: z.string().optional(), | |
| 182 | 180 | sessionId: z.string().optional(), |
| 183 | 181 | waitForResult: z.boolean().optional(), |
| 184 | 182 | resultTimeoutMs: z.number().int().min(100).max(900_000).optional(), |
| @@ -125,11 +125,20 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 124 unmodified lines | ||
| 125 | 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 | 137 | return { |
| 129 | 138 | ok: true, |
| 130 | 139 | status: body.value.status, |
| 131 | 140 | result: body.value.result, |
| 132 | − error: body.value.error, | |
| 141 | + error, | |
| 133 | 142 | exitCode: body.value.exitCode, |
| 134 | 143 | durationMs: Date.now() - startedAt, |
| 135 | 144 | }; |
| @@ -100,7 +100,7 @@ export interface ArgumentCondition { · 99 unmodified lines | ||
| 100 | 100 | value: string; |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | −export interface AccessPolicyRecord { | |
| 103 | +export interface ToolPolicyRecord { | |
| 104 | 104 | id: string; |
| 105 | 105 | scopeType: PolicyScopeType; |
| 106 | 106 | organizationId: Id<"organizations">; |
| @@ -104,7 +104,6 @@ export declare const api: { · 103 unmodified lines | ||
| 104 | 104 | "public", |
| 105 | 105 | { |
| 106 | 106 | accountId?: Id<"accounts">; |
| 107 | − clientId?: string; | |
| 108 | 107 | code: string; |
| 109 | 108 | metadata?: Record<string, any>; |
| 110 | 109 | runtimeId?: string; |
| @@ -136,7 +135,6 @@ export declare const api: { · 25 unmodified lines | ||
| 136 | 135 | { |
| 137 | 136 | accountId?: Id<"accounts">; |
| 138 | 137 | buildId?: string; |
| 139 | − clientId?: string; | |
| 140 | 138 | cursor?: string; |
| 141 | 139 | fetchAll?: boolean; |
| 142 | 140 | includeDetails?: boolean; |
| @@ -156,7 +154,6 @@ export declare const api: { · 13 unmodified lines | ||
| 156 | 154 | "public", |
| 157 | 155 | { |
| 158 | 156 | accountId?: Id<"accounts">; |
| 159 | − clientId?: string; | |
| 160 | 157 | config: Record<string, any>; |
| 161 | 158 | name: string; |
| 162 | 159 | sessionId?: string; |
| @@ -176,7 +173,6 @@ export declare const api: { · 13 unmodified lines | ||
| 176 | 173 | organizationId: Id<"organizations">; |
| 177 | 174 | role: "owner" | "admin" | "member" | "billing_admin"; |
| 178 | 175 | sessionId?: string; |
| 179 | − workspaceId?: Id<"workspaces">; | |
| 180 | 176 | }, |
| 181 | 177 | any |
| 182 | 178 | >; |
| @@ -307,13 +303,7 @@ export declare const api: { · 124 unmodified lines | ||
| 307 | 303 | bootstrapAnonymousSession: FunctionReference< |
| 308 | 304 | "mutation", |
| 309 | 305 | "public", |
| 310 | − { accountId?: string; clientId?: string; sessionId?: string }, | |
| 311 | − any | |
| 312 | − >; | |
| 313 | − deleteAccessPolicy: FunctionReference< | |
| 314 | − "mutation", | |
| 315 | − "public", | |
| 316 | − { policyId: string; sessionId?: string; workspaceId: Id<"workspaces"> }, | |
| 306 | + { accountId?: string; sessionId?: string }, | |
| 317 | 307 | any |
| 318 | 308 | >; |
| 319 | 309 | deleteToolRole: FunctionReference< |
| @@ -355,7 +345,6 @@ export declare const api: { · 35 unmodified lines | ||
| 355 | 345 | "mutation", |
| 356 | 346 | "public", |
| 357 | 347 | { |
| 358 | − clientId?: string; | |
| 359 | 348 | sessionId?: string; |
| 360 | 349 | toolPaths: Array<string>; |
| 361 | 350 | workspaceId: Id<"workspaces">; |
| @@ -368,25 +357,25 @@ export declare const api: { · 6 unmodified lines | ||
| 368 | 357 | { sessionId?: string; workspaceId: Id<"workspaces"> }, |
| 369 | 358 | any |
| 370 | 359 | >; |
| 371 | − listAccessPolicies: FunctionReference< | |
| 360 | + listCredentials: FunctionReference< | |
| 372 | 361 | "query", |
| 373 | 362 | "public", |
| 374 | 363 | { sessionId?: string; workspaceId: Id<"workspaces"> }, |
| 375 | 364 | any |
| 376 | 365 | >; |
| 377 | − listCredentials: FunctionReference< | |
| 366 | + listPendingApprovals: FunctionReference< | |
| 378 | 367 | "query", |
| 379 | 368 | "public", |
| 380 | 369 | { sessionId?: string; workspaceId: Id<"workspaces"> }, |
| 381 | 370 | any |
| 382 | 371 | >; |
| 383 | − listPendingApprovals: FunctionReference< | |
| 372 | + listTasks: FunctionReference< | |
| 384 | 373 | "query", |
| 385 | 374 | "public", |
| 386 | 375 | { sessionId?: string; workspaceId: Id<"workspaces"> }, |
| 387 | 376 | any |
| 388 | 377 | >; |
| 389 | − listTasks: FunctionReference< | |
| 378 | + listToolPolicies: FunctionReference< | |
| 390 | 379 | "query", |
| 391 | 380 | "public", |
| 392 | 381 | { sessionId?: string; workspaceId: Id<"workspaces"> }, |
| @@ -434,30 +423,6 @@ export declare const api: { · 41 unmodified lines | ||
| 434 | 423 | }, |
| 435 | 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 | 426 | upsertCredential: FunctionReference< |
| 462 | 427 | "mutation", |
| 463 | 428 | "public", |
| @@ -777,12 +742,6 @@ export declare const internal: { · 313 unmodified lines | ||
| 777 | 742 | any |
| 778 | 743 | >; |
| 779 | 744 | }; |
| 780 | − deleteAccessPolicy: FunctionReference< | |
| 781 | − "mutation", | |
| 782 | − "internal", | |
| 783 | − { policyId: string; workspaceId: Id<"workspaces"> }, | |
| 784 | − any | |
| 785 | − >; | |
| 786 | 745 | deleteToolRole: FunctionReference< |
| 787 | 746 | "mutation", |
| 788 | 747 | "internal", |
| @@ -843,12 +802,6 @@ export declare const internal: { · 54 unmodified lines | ||
| 843 | 802 | { callId: string; taskId: string }, |
| 844 | 803 | any |
| 845 | 804 | >; |
| 846 | − listAccessPolicies: FunctionReference< | |
| 847 | − "query", | |
| 848 | − "internal", | |
| 849 | − { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> }, | |
| 850 | − any | |
| 851 | − >; | |
| 852 | 805 | listApprovals: FunctionReference< |
| 853 | 806 | "query", |
| 854 | 807 | "internal", |
| @@ -896,6 +849,12 @@ export declare const internal: { · 41 unmodified lines | ||
| 896 | 849 | { taskId: string }, |
| 897 | 850 | any |
| 898 | 851 | >; |
| 852 | + listToolPolicies: FunctionReference< | |
| 853 | + "query", | |
| 854 | + "internal", | |
| 855 | + { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> }, | |
| 856 | + any | |
| 857 | + >; | |
| 899 | 858 | listToolRoleBindings: FunctionReference< |
| 900 | 859 | "query", |
| 901 | 860 | "internal", |
| @@ -938,12 +897,6 @@ export declare const internal: { · 36 unmodified lines | ||
| 938 | 897 | any |
| 939 | 898 | >; |
| 940 | 899 | policies: { |
| 941 | − deleteAccessPolicy: FunctionReference< | |
| 942 | − "mutation", | |
| 943 | − "internal", | |
| 944 | − { policyId: string; workspaceId: Id<"workspaces"> }, | |
| 945 | − any | |
| 946 | − >; | |
| 947 | 900 | deleteToolRole: FunctionReference< |
| 948 | 901 | "mutation", |
| 949 | 902 | "internal", |
| @@ -962,13 +915,13 @@ export declare const internal: { · 12 unmodified lines | ||
| 962 | 915 | { roleId: string; ruleId: string; workspaceId: Id<"workspaces"> }, |
| 963 | 916 | any |
| 964 | 917 | >; |
| 965 | − listAccessPolicies: FunctionReference< | |
| 918 | + listRuntimeTargets: FunctionReference<"query", "internal", {}, any>; | |
| 919 | + listToolPolicies: FunctionReference< | |
| 966 | 920 | "query", |
| 967 | 921 | "internal", |
| 968 | 922 | { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> }, |
| 969 | 923 | any |
| 970 | 924 | >; |
| 971 | − listRuntimeTargets: FunctionReference<"query", "internal", {}, any>; | |
| 972 | 925 | listToolRoleBindings: FunctionReference< |
| 973 | 926 | "query", |
| 974 | 927 | "internal", |
| @@ -987,29 +940,6 @@ export declare const internal: { · 12 unmodified lines | ||
| 987 | 940 | { workspaceId: Id<"workspaces"> }, |
| 988 | 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 | 943 | upsertToolRole: FunctionReference< |
| 1014 | 944 | "mutation", |
| 1015 | 945 | "internal", |
| @@ -1230,29 +1160,6 @@ export declare const internal: { · 214 unmodified lines | ||
| 1230 | 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 | 1163 | upsertCredential: FunctionReference< |
| 1257 | 1164 | "mutation", |
| 1258 | 1165 | "internal", |
| @@ -217,10 +217,9 @@ describe("authentication", () => { · 216 unmodified lines | ||
| 217 | 217 | const owner = await seedUser(t, { subject: "owner-2" }); |
| 218 | 218 | |
| 219 | 219 | await expect( |
| 220 | − t.mutation(api.workspace.upsertAccessPolicy, { | |
| 220 | + t.mutation(api.workspace.upsertToolRole, { | |
| 221 | 221 | workspaceId: owner.workspaceId, |
| 222 | − resourcePattern: "*", | |
| 223 | − effect: "allow", | |
| 222 | + name: "anonymous-role", | |
| 224 | 223 | }), |
| 225 | 224 | ).rejects.toThrow("Must be signed in"); |
| 226 | 225 | }); |
| @@ -421,7 +420,7 @@ describe("workspace access controls", () => { · 194 unmodified lines | ||
| 421 | 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 | 424 | const t = setup(); |
| 426 | 425 | const owner = await seedUser(t, { subject: "ws-admin-owner" }); |
| 427 | 426 | const member = await seedUser(t, { subject: "ws-plain-member" }); |
| @@ -440,15 +439,14 @@ describe("workspace access controls", () => { · 12 unmodified lines | ||
| 440 | 439 | const authedMember = t.withIdentity({ subject: "ws-plain-member" }); |
| 441 | 440 | |
| 442 | 441 | await expect( |
| 443 | − authedMember.mutation(api.workspace.upsertAccessPolicy, { | |
| 442 | + authedMember.mutation(api.workspace.upsertToolRole, { | |
| 444 | 443 | workspaceId: owner.workspaceId, |
| 445 | − resourcePattern: "*", | |
| 446 | − effect: "allow", | |
| 444 | + name: "member-managed-role", | |
| 447 | 445 | }), |
| 448 | 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 | 450 | const t = setup(); |
| 453 | 451 | const owner = await seedUser(t, { subject: "ws-policy-owner" }); |
| 454 | 452 | const admin = await seedUser(t, { subject: "ws-policy-admin" }); |
| @@ -466,16 +464,37 @@ describe("workspace access controls", () => { · 11 unmodified lines | ||
| 466 | 464 | |
| 467 | 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 | 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 | 477 | resourcePattern: "*.delete", |
| 472 | 478 | effect: "allow", |
| 473 | 479 | approvalMode: "required", |
| 474 | 480 | }); |
| 475 | 481 | |
| 476 | − expect(policy.effect).toBe("allow"); | |
| 477 | − expect(policy.approvalMode).toBe("required"); | |
| 478 | − expect(policy.resourcePattern).toBe("*.delete"); | |
| 482 | + await authedAdmin.mutation(api.workspace.upsertToolRoleBinding, { | |
| 483 | + workspaceId: owner.workspaceId, | |
| 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 | 500 | test("account-scoped policy target must be active org member", async () => { |
| @@ -484,29 +503,88 @@ describe("workspace access controls", () => { · 2 unmodified lines | ||
| 484 | 503 | const outsider = await seedUser(t, { subject: "policy-target-outsider" }); |
| 485 | 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 | 511 | await expect( |
| 488 | − authedOwner.mutation(api.workspace.upsertAccessPolicy, { | |
| 512 | + authedOwner.mutation(api.workspace.upsertToolRoleBinding, { | |
| 489 | 513 | workspaceId: owner.workspaceId, |
| 514 | + roleId: role.id, | |
| 490 | 515 | scopeType: "account", |
| 491 | 516 | targetAccountId: outsider.accountId, |
| 492 | − resourcePattern: "*", | |
| 493 | − effect: "allow", | |
| 517 | + status: "active", | |
| 494 | 518 | }), |
| 495 | 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 | 523 | const t = setup(); |
| 500 | 524 | const owner = await seedUser(t, { subject: "ws-owner-policy" }); |
| 501 | 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 | 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 | 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 | 590 | test("regular member cannot upsert credentials (admin-only)", async () => { |
| @@ -565,6 +643,60 @@ describe("workspace access controls", () => { · 52 unmodified lines | ||
| 565 | 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 | 700 | test("account-scoped credential target must be active org member", async () => { |
| 569 | 701 | const t = setup(); |
| 570 | 702 | const owner = await seedUser(t, { subject: "cred-target-owner" }); |
| @@ -656,15 +788,15 @@ describe("workspace access controls", () => { · 85 unmodified lines | ||
| 656 | 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 | 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 | 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 | 800 | const t = setup(); |
| 669 | 801 | const owner = await seedUser(t, { subject: "read-policy-owner" }); |
| 670 | 802 | const member = await seedUser(t, { subject: "read-policy-member" }); |
| @@ -682,7 +814,7 @@ describe("workspace access controls", () => { · 11 unmodified lines | ||
| 682 | 814 | |
| 683 | 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 | 818 | workspaceId: owner.workspaceId, |
| 687 | 819 | }); |
| 688 | 820 | |
| @@ -764,6 +896,55 @@ describe("organization access controls", () => { · 75 unmodified lines | ||
| 764 | 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 | 948 | test("regular member cannot update roles (admin-only)", async () => { |
| 768 | 949 | const t = setup(); |
| 769 | 950 | const owner = await seedUser(t, { subject: "role-owner" }); |
| @@ -931,7 +1112,7 @@ describe("cross-workspace isolation", () => { · 161 unmodified lines | ||
| 931 | 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 | 1116 | const t = setup(); |
| 936 | 1117 | await seedUser(t, { subject: "policy-user-a" }); |
| 937 | 1118 | const userB = await seedUser(t, { subject: "policy-user-b" }); |
| @@ -939,10 +1120,9 @@ describe("cross-workspace isolation", () => { · 1 unmodified line | ||
| 939 | 1120 | const authedA = t.withIdentity({ subject: "policy-user-a" }); |
| 940 | 1121 | |
| 941 | 1122 | await expect( |
| 942 | − authedA.mutation(api.workspace.upsertAccessPolicy, { | |
| 1123 | + authedA.mutation(api.workspace.upsertToolRole, { | |
| 943 | 1124 | workspaceId: userB.workspaceId, |
| 944 | − resourcePattern: "*", | |
| 945 | − effect: "allow", | |
| 1125 | + name: "cross-workspace-role", | |
| 946 | 1126 | }), |
| 947 | 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 | 1628 | const authed = t.withIdentity({ subject: "admin-hier-admin" }); |
| 1449 | 1629 | |
| 1450 | − // Admin can upsert access policies | |
| 1451 | − const policy = await authed.mutation(api.workspace.upsertAccessPolicy, { | |
| 1630 | + // Admin can manage tool-policy roles/rules/bindings | |
| 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 | 1636 | workspaceId: org.workspaceId, |
| 1637 | + roleId: role.id, | |
| 1638 | + selectorType: "tool_path", | |
| 1453 | 1639 | resourcePattern: "admin.*", |
| 1454 | 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 | 1654 | // Admin can upsert tool sources |
| 1459 | 1655 | const source = await authed.mutation(api.workspace.upsertToolSource, { |
| @@ -1,4 +1,10 @@ | ||
| 1 | 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 | 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 | 12 | method: "GET", |
| 7 | 13 | args: {}, |
| 8 | 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 | 21 | return { |
| 10 | 22 | authProviderMode: workosEnabled ? "workos" : "local", |
| 11 | 23 | invitesProvider: workosEnabled ? "workos" : "disabled", |
| 12 | 24 | anonymousAuthIssuer: process.env.ANONYMOUS_AUTH_ISSUER ?? null, |
| 25 | + runtime: { | |
| 26 | + allowLocalVm, | |
| 27 | + defaultRuntimeId: defaultRuntimeId(), | |
| 28 | + enabledRuntimeIds, | |
| 29 | + }, | |
| 13 | 30 | features: { |
| 14 | 31 | organizations: true, |
| 15 | 32 | billing: true, |
| @@ -34,9 +34,7 @@ export { · 33 unmodified lines | ||
| 34 | 34 | upsertToolRoleBinding, |
| 35 | 35 | listToolRoleBindings, |
| 36 | 36 | deleteToolRoleBinding, |
| 37 | − upsertAccessPolicy, | |
| 38 | − deleteAccessPolicy, | |
| 39 | − listAccessPolicies, | |
| 37 | + listToolPolicies, | |
| 40 | 38 | } from "./database/policies"; |
| 41 | 39 | export { |
| 42 | 40 | upsertCredential, |
| @@ -4,6 +4,59 @@ import { internalMutation } from "../_generated/server"; · 3 unmodified lines | ||
| 4 | 4 | import { ensureAnonymousIdentity } from "../../src/database/anonymous"; |
| 5 | 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 | 60 | export const bootstrapAnonymousSession = internalMutation({ |
| 8 | 61 | args: { |
| 9 | 62 | sessionId: v.optional(v.string()), |
| @@ -14,7 +67,7 @@ export const bootstrapAnonymousSession = internalMutation({ · 4 unmodified lines | ||
| 14 | 67 | const now = Date.now(); |
| 15 | 68 | const requestedSessionId = args.sessionId?.trim() || ""; |
| 16 | 69 | const requestedAccountId = args.accountId?.trim() || ""; |
| 17 | − const clientId = args.clientId?.trim() || "web"; | |
| 70 | + const requestedClientId = parseTrustedClientId(args.clientId); | |
| 18 | 71 | |
| 19 | 72 | const allowRequestedSessionId = requestedSessionId.startsWith("mcp_") |
| 20 | 73 | || requestedSessionId.startsWith("anon_session_"); |
| @@ -26,6 +79,11 @@ export const bootstrapAnonymousSession = internalMutation({ · 5 unmodified lines | ||
| 26 | 79 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) |
| 27 | 80 | .unique(); |
| 28 | 81 | if (existing) { |
| 82 | + const clientId = resolveTrustedClientId({ | |
| 83 | + sessionId, | |
| 84 | + requestedClientId, | |
| 85 | + existingClientId: existing.clientId, | |
| 86 | + }); | |
| 29 | 87 | const identity = await ensureAnonymousIdentity(ctx, { |
| 30 | 88 | sessionId, |
| 31 | 89 | workspaceId: existing.workspaceId, |
| @@ -57,6 +115,10 @@ export const bootstrapAnonymousSession = internalMutation({ · 25 unmodified lines | ||
| 57 | 115 | const sessionId = allowRequestedSessionId |
| 58 | 116 | ? requestedSessionId as string |
| 59 | 117 | : generatedSessionId; |
| 118 | + const clientId = resolveTrustedClientId({ | |
| 119 | + sessionId, | |
| 120 | + requestedClientId, | |
| 121 | + }); | |
| 60 | 122 | |
| 61 | 123 | const identity = await ensureAnonymousIdentity(ctx, { |
| 62 | 124 | sessionId, |
| @@ -4,7 +4,7 @@ import type { Id } from "../_generated/dataModel"; · 3 unmodified lines | ||
| 4 | 4 | import type { MutationCtx, QueryCtx } from "../_generated/server"; |
| 5 | 5 | import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog"; |
| 6 | 6 | import type { |
| 7 | − AccessPolicyRecord, | |
| 7 | + ToolPolicyRecord, | |
| 8 | 8 | ToolRoleBindingRecord, |
| 9 | 9 | ToolRoleRecord, |
| 10 | 10 | ToolRoleRuleRecord, |
| @@ -15,18 +15,17 @@ import { · 4 unmodified lines | ||
| 15 | 15 | policyApprovalModeValidator, |
| 16 | 16 | policyEffectValidator, |
| 17 | 17 | policyMatchTypeValidator, |
| 18 | − policyResourceTypeValidator, | |
| 19 | 18 | policyScopeTypeValidator, |
| 20 | 19 | toolRoleBindingStatusValidator, |
| 21 | 20 | toolRoleSelectorTypeValidator, |
| 22 | 21 | } from "../../src/database/validators"; |
| 23 | 22 | |
| 24 | −type PolicyResourceType = AccessPolicyRecord["resourceType"]; | |
| 23 | +type PolicyResourceType = ToolPolicyRecord["resourceType"]; | |
| 25 | 24 | type DbContext = Pick<MutationCtx, "db"> | Pick<QueryCtx, "db">; |
| 26 | 25 | |
| 27 | 26 | function normalizeArgumentConditions( |
| 28 | − argumentConditions: AccessPolicyRecord["argumentConditions"] | undefined, | |
| 29 | −): AccessPolicyRecord["argumentConditions"] | undefined { | |
| 27 | + argumentConditions: ToolPolicyRecord["argumentConditions"] | undefined, | |
| 28 | +): ToolPolicyRecord["argumentConditions"] | undefined { | |
| 30 | 29 | if (!argumentConditions || argumentConditions.length === 0) { |
| 31 | 30 | return undefined; |
| 32 | 31 | } |
| @@ -42,22 +41,6 @@ function normalizeArgumentConditions( · 9 unmodified lines | ||
| 42 | 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 | 44 | function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): PolicyResourceType { |
| 62 | 45 | if (selectorType === "all") { |
| 63 | 46 | return "all_tools"; |
| @@ -74,69 +57,6 @@ function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): Polic · 10 unmodified lines | ||
| 74 | 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 | 60 | function resourcePatternFromRule(rule: { |
| 141 | 61 | selectorType: ToolRoleSelectorType; |
| 142 | 62 | sourceKey?: string; |
| @@ -183,10 +103,10 @@ function mapToolRoleRule(doc: { · 40 unmodified lines | ||
| 183 | 103 | sourceKey?: string; |
| 184 | 104 | namespacePattern?: string; |
| 185 | 105 | toolPathPattern?: string; |
| 186 | − matchType: AccessPolicyRecord["matchType"]; | |
| 187 | − effect: AccessPolicyRecord["effect"]; | |
| 188 | − approvalMode: AccessPolicyRecord["approvalMode"]; | |
| 189 | − argumentConditions?: AccessPolicyRecord["argumentConditions"]; | |
| 106 | + matchType: ToolPolicyRecord["matchType"]; | |
| 107 | + effect: ToolPolicyRecord["effect"]; | |
| 108 | + approvalMode: ToolPolicyRecord["approvalMode"]; | |
| 109 | + argumentConditions?: ToolPolicyRecord["argumentConditions"]; | |
| 190 | 110 | priority: number; |
| 191 | 111 | createdAt: number; |
| 192 | 112 | updatedAt: number; |
| @@ -213,7 +133,7 @@ function mapToolRoleBinding(doc: { · 20 unmodified lines | ||
| 213 | 133 | bindingId: string; |
| 214 | 134 | roleId: string; |
| 215 | 135 | organizationId: Id<"organizations">; |
| 216 | − scopeType: AccessPolicyRecord["scopeType"]; | |
| 136 | + scopeType: ToolPolicyRecord["scopeType"]; | |
| 217 | 137 | workspaceId?: Id<"workspaces">; |
| 218 | 138 | targetAccountId?: Id<"accounts">; |
| 219 | 139 | clientId?: string; |
| @@ -238,9 +158,9 @@ function mapToolRoleBinding(doc: { · 18 unmodified lines | ||
| 238 | 158 | } |
| 239 | 159 | |
| 240 | 160 | function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): string { |
| 241 | − const rolePrefix = "policy_role_"; | |
| 242 | − const rulePrefix = "policy_rule_"; | |
| 243 | − const bindingPrefix = "policy_binding_"; | |
| 161 | + const rolePrefix = "tool_policy_role_"; | |
| 162 | + const rulePrefix = "tool_policy_rule_"; | |
| 163 | + const bindingPrefix = "tool_policy_binding_"; | |
| 244 | 164 | if ( |
| 245 | 165 | roleId.startsWith(rolePrefix) |
| 246 | 166 | && ruleId.startsWith(rulePrefix) |
| @@ -259,24 +179,24 @@ function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): str · 12 unmodified lines | ||
| 259 | 179 | |
| 260 | 180 | function mapFlattenedPolicy(doc: { |
| 261 | 181 | policyId: string; |
| 262 | − scopeType: AccessPolicyRecord["scopeType"]; | |
| 182 | + scopeType: ToolPolicyRecord["scopeType"]; | |
| 263 | 183 | organizationId: Id<"organizations">; |
| 264 | 184 | workspaceId?: Id<"workspaces">; |
| 265 | 185 | targetAccountId?: Id<"accounts">; |
| 266 | 186 | clientId?: string; |
| 267 | 187 | resourceType: PolicyResourceType; |
| 268 | 188 | resourcePattern: string; |
| 269 | − matchType: AccessPolicyRecord["matchType"]; | |
| 270 | − effect: AccessPolicyRecord["effect"]; | |
| 271 | − approvalMode: AccessPolicyRecord["approvalMode"]; | |
| 272 | − argumentConditions?: AccessPolicyRecord["argumentConditions"]; | |
| 189 | + matchType: ToolPolicyRecord["matchType"]; | |
| 190 | + effect: ToolPolicyRecord["effect"]; | |
| 191 | + approvalMode: ToolPolicyRecord["approvalMode"]; | |
| 192 | + argumentConditions?: ToolPolicyRecord["argumentConditions"]; | |
| 273 | 193 | priority: number; |
| 274 | 194 | roleId: string; |
| 275 | 195 | ruleId: string; |
| 276 | 196 | bindingId: string; |
| 277 | 197 | createdAt: number; |
| 278 | 198 | updatedAt: number; |
| 279 | −}): AccessPolicyRecord { | |
| 199 | +}): ToolPolicyRecord { | |
| 280 | 200 | return { |
| 281 | 201 | id: doc.policyId, |
| 282 | 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({ | |
| 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({ | |
| 728 | +export const listToolPolicies = internalQuery({ | |
| 1002 | 729 | args: { |
| 1003 | 730 | workspaceId: v.id("workspaces"), |
| 1004 | 731 | accountId: v.optional(v.id("accounts")), |
| @@ -1020,7 +747,7 @@ export const listAccessPolicies = internalQuery({ · 15 unmodified lines | ||
| 1020 | 747 | })); |
| 1021 | 748 | const rulesByRole = new Map(roleIds.map((roleId, index) => [roleId, roleRules[index] ?? []])); |
| 1022 | 749 | |
| 1023 | − const policies: AccessPolicyRecord[] = []; | |
| 750 | + const policies: ToolPolicyRecord[] = []; | |
| 1024 | 751 | for (const binding of bindings) { |
| 1025 | 752 | const rules = rulesByRole.get(binding.roleId) ?? []; |
| 1026 | 753 | for (const rule of rules) { |
| @@ -61,6 +61,18 @@ export const upsertToolSource = internalMutation({ · 60 unmodified lines | ||
| 61 | 61 | } |
| 62 | 62 | |
| 63 | 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 | 76 | await ctx.db.patch(existing._id, { |
| 65 | 77 | scopeType, |
| 66 | 78 | organizationId, |
| @@ -21,7 +21,6 @@ export const createTask = customAction({ · 20 unmodified lines | ||
| 21 | 21 | workspaceId: v.id("workspaces"), |
| 22 | 22 | sessionId: v.optional(v.string()), |
| 23 | 23 | accountId: v.optional(v.id("accounts")), |
| 24 | − clientId: v.optional(v.string()), | |
| 25 | 24 | waitForResult: v.optional(v.boolean()), |
| 26 | 25 | }, |
| 27 | 26 | handler: async (ctx, args) => { |
| @@ -28,7 +28,6 @@ export const listToolsWithWarnings = customAction({ · 27 unmodified lines | ||
| 28 | 28 | args: { |
| 29 | 29 | workspaceId: v.id("workspaces"), |
| 30 | 30 | accountId: v.optional(v.id("accounts")), |
| 31 | − clientId: v.optional(v.string()), | |
| 32 | 31 | sessionId: v.optional(v.string()), |
| 33 | 32 | includeDetails: v.optional(v.boolean()), |
| 34 | 33 | includeSourceMeta: v.optional(v.boolean()), |
| @@ -73,14 +72,14 @@ export const listToolsWithWarnings = customAction({ · 38 unmodified lines | ||
| 73 | 72 | await rebuildWorkspaceToolInventoryForContext(ctx, { |
| 74 | 73 | workspaceId: args.workspaceId, |
| 75 | 74 | accountId: access.accountId, |
| 76 | − clientId: args.clientId, | |
| 75 | + clientId: access.clientId, | |
| 77 | 76 | }); |
| 78 | 77 | } |
| 79 | 78 | |
| 80 | 79 | const inventory = await listToolsWithWarningsForContext(ctx, { |
| 81 | 80 | workspaceId: args.workspaceId, |
| 82 | 81 | accountId: access.accountId, |
| 83 | − clientId: args.clientId, | |
| 82 | + clientId: access.clientId, | |
| 84 | 83 | }, { |
| 85 | 84 | includeDetails: args.includeDetails ?? false, |
| 86 | 85 | includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true), |
| @@ -150,7 +149,6 @@ export const previewOpenApiSourceUpgrade = customAction({ · 63 unmodified lines | ||
| 150 | 149 | args: { |
| 151 | 150 | workspaceId: v.id("workspaces"), |
| 152 | 151 | accountId: v.optional(v.id("accounts")), |
| 153 | − clientId: v.optional(v.string()), | |
| 154 | 152 | sessionId: v.optional(v.string()), |
| 155 | 153 | sourceId: v.string(), |
| 156 | 154 | name: v.string(), |
| @@ -165,11 +163,11 @@ export const previewOpenApiSourceUpgrade = customAction({ · 8 unmodified lines | ||
| 165 | 163 | |
| 166 | 164 | return await previewOpenApiSourceUpgradeForContext( |
| 167 | 165 | ctx, |
| 168 | − { | |
| 169 | − workspaceId: args.workspaceId, | |
| 170 | − accountId: access.accountId, | |
| 171 | − clientId: args.clientId, | |
| 172 | − }, | |
| 166 | + { | |
| 167 | + workspaceId: args.workspaceId, | |
| 168 | + accountId: access.accountId, | |
| 169 | + clientId: access.clientId, | |
| 170 | + }, | |
| 173 | 171 | { |
| 174 | 172 | sourceId: args.sourceId, |
| 175 | 173 | name: args.name, |
| @@ -16,7 +16,6 @@ type VerifiedMcpToken = { provider: "workos"; subject: string }; · 15 unmodified lines | ||
| 16 | 16 | type ParsedMcpContext = { |
| 17 | 17 | workspaceId?: Id<"workspaces">; |
| 18 | 18 | accountId?: string; |
| 19 | − clientId?: string; | |
| 20 | 19 | sessionId?: string; |
| 21 | 20 | }; |
| 22 | 21 | |
| @@ -148,10 +147,9 @@ export function parseMcpContext(url: URL): ParsedMcpContext | undefined { · 125 unmodified lines | ||
| 148 | 147 | const raw = url.searchParams.get("workspaceId"); |
| 149 | 148 | const workspaceId = raw ? parseWorkspaceId(raw) : undefined; |
| 150 | 149 | const accountId = url.searchParams.get("accountId") ?? undefined; |
| 151 | − const clientId = url.searchParams.get("clientId") ?? undefined; | |
| 152 | 150 | const sessionId = url.searchParams.get("sessionId") ?? undefined; |
| 153 | − if (!workspaceId && !accountId && !clientId && !sessionId) { | |
| 151 | + if (!workspaceId && !accountId && !sessionId) { | |
| 154 | 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 | 103 | context = { |
| 104 | 104 | workspaceId, |
| 105 | 105 | accountId: access.accountId, |
| 106 | − clientId: requestedContext?.clientId, | |
| 106 | + clientId: "mcp", | |
| 107 | 107 | }; |
| 108 | 108 | } catch (error) { |
| 109 | 109 | return Response.json( |
| @@ -144,7 +144,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 34 unmodified lines | ||
| 144 | 144 | context = { |
| 145 | 145 | workspaceId, |
| 146 | 146 | accountId: access.accountId, |
| 147 | − clientId: requestedContext?.clientId, | |
| 147 | + clientId: "mcp", | |
| 148 | 148 | }; |
| 149 | 149 | } else { |
| 150 | 150 | if (mcpAuthConfig.enabled && !requestedContext?.sessionId) { |
| @@ -166,7 +166,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 15 unmodified lines | ||
| 166 | 166 | context = { |
| 167 | 167 | workspaceId, |
| 168 | 168 | accountId: access.accountId, |
| 169 | − clientId: requestedContext?.clientId, | |
| 169 | + clientId: "mcp", | |
| 170 | 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 | 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 | 86 | listTools: async ( |
| 84 | 87 | toolContext?: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string }, |
| @@ -53,7 +53,6 @@ export const create = organizationMutation({ · 52 unmodified lines | ||
| 53 | 53 | args: { |
| 54 | 54 | email: v.string(), |
| 55 | 55 | role: organizationRoleValidator, |
| 56 | − workspaceId: v.optional(v.id("workspaces")), | |
| 57 | 56 | expiresInDays: v.optional(v.number()), |
| 58 | 57 | }, |
| 59 | 58 | handler: async (ctx, args) => { |
| @@ -78,13 +77,6 @@ export const create = organizationMutation({ · 18 unmodified lines | ||
| 78 | 77 | const expiresAt = now + (args.expiresInDays ?? 7) * 24 * 60 * 60 * 1000; |
| 79 | 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 | 80 | if (ctx.account.provider !== "workos") { |
| 89 | 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 | 85 | const inviteId = await ctx.db.insert("invites", { |
| 94 | 86 | organizationId: ctx.organizationId, |
| 95 | − ...(args.workspaceId ? { workspaceId: args.workspaceId } : {}), | |
| 96 | 87 | email: normalizedEmail, |
| 97 | 88 | role: args.role, |
| 98 | 89 | status: "pending", |
| @@ -9,7 +9,6 @@ import { · 8 unmodified lines | ||
| 9 | 9 | policyApprovalModeValidator, |
| 10 | 10 | policyEffectValidator, |
| 11 | 11 | policyMatchTypeValidator, |
| 12 | − policyResourceTypeValidator, | |
| 13 | 12 | policyScopeTypeValidator, |
| 14 | 13 | toolRoleBindingStatusValidator, |
| 15 | 14 | toolRoleSelectorTypeValidator, |
| @@ -56,7 +55,6 @@ export const bootstrapAnonymousSession = customMutation({ · 40 unmodified lines | ||
| 56 | 55 | args: { |
| 57 | 56 | sessionId: v.optional(v.string()), |
| 58 | 57 | accountId: v.optional(v.string()), |
| 59 | − clientId: v.optional(v.string()), | |
| 60 | 58 | }, |
| 61 | 59 | handler: async (ctx, args) => { |
| 62 | 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({ | |
| 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({ | |
| 128 | +export const listToolPolicies = workspaceQuery({ | |
| 195 | 129 | method: "GET", |
| 196 | 130 | args: {}, |
| 197 | 131 | handler: async (ctx) => { |
| 198 | − return await ctx.runQuery(internal.database.listAccessPolicies, { | |
| 132 | + return await ctx.runQuery(internal.database.listToolPolicies, { | |
| 199 | 133 | workspaceId: ctx.workspaceId, |
| 200 | 134 | accountId: ctx.account._id, |
| 201 | 135 | }); |
| @@ -480,7 +414,6 @@ export const getToolDetails = workspaceMutation({ · 278 unmodified lines | ||
| 480 | 414 | method: "POST", |
| 481 | 415 | args: { |
| 482 | 416 | toolPaths: v.array(v.string()), |
| 483 | − clientId: v.optional(v.string()), | |
| 484 | 417 | }, |
| 485 | 418 | handler: async (ctx, args) => { |
| 486 | 419 | return await listToolDetailsForContext( |
| @@ -488,7 +421,7 @@ export const getToolDetails = workspaceMutation({ · 1 unmodified line | ||
| 488 | 421 | { |
| 489 | 422 | workspaceId: ctx.workspaceId, |
| 490 | 423 | accountId: ctx.account._id, |
| 491 | − clientId: args.clientId, | |
| 424 | + clientId: "web", | |
| 492 | 425 | }, |
| 493 | 426 | { |
| 494 | 427 | toolPaths: args.toolPaths, |
| @@ -17,3 +17,21 @@ export function assertMatchesCanonicalAccountId( · 16 unmodified lines | ||
| 17 | 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 | 270 | status: "deleted", |
| 271 | 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 | 292 | "organization.created": async (ctx, event) => { |
| @@ -5,6 +5,7 @@ import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../ · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | assertMatchesCanonicalAccountId, |
| 7 | 7 | canonicalAccountIdForWorkspaceAccess, |
| 8 | + canonicalClientIdForWorkspaceAccess, | |
| 8 | 9 | } from "../auth/account_identity"; |
| 9 | 10 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 10 | 11 | import { createTaskEvent } from "../task/events"; |
| @@ -227,7 +228,6 @@ export async function createTaskHandler( · 216 unmodified lines | ||
| 227 | 228 | workspaceId: Id<"workspaces">; |
| 228 | 229 | sessionId?: string; |
| 229 | 230 | accountId?: Id<"accounts">; |
| 230 | − clientId?: string; | |
| 231 | 231 | waitForResult?: boolean; |
| 232 | 232 | }, |
| 233 | 233 | ): Promise<TaskExecutionOutcome> { |
| @@ -237,6 +237,10 @@ export async function createTaskHandler( · 3 unmodified lines | ||
| 237 | 237 | }); |
| 238 | 238 | |
| 239 | 239 | const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access); |
| 240 | + const canonicalClientId = canonicalClientIdForWorkspaceAccess({ | |
| 241 | + provider: access.provider, | |
| 242 | + sessionId: args.sessionId, | |
| 243 | + }); | |
| 240 | 244 | assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId); |
| 241 | 245 | |
| 242 | 246 | const waitForResult = args.waitForResult ?? false; |
| @@ -247,7 +251,7 @@ export async function createTaskHandler( · 4 unmodified lines | ||
| 247 | 251 | metadata: toMetadata(args.metadata), |
| 248 | 252 | workspaceId: args.workspaceId, |
| 249 | 253 | accountId: canonicalAccountId, |
| 250 | − clientId: args.clientId, | |
| 254 | + clientId: canonicalClientId, | |
| 251 | 255 | scheduleAfterCreate: !waitForResult, |
| 252 | 256 | }); |
| 253 | 257 | |
| @@ -4,6 +4,7 @@ import { internal } from "../../convex/_generated/api"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | assertMatchesCanonicalAccountId, |
| 6 | 6 | canonicalAccountIdForWorkspaceAccess, |
| 7 | + canonicalClientIdForWorkspaceAccess, | |
| 7 | 8 | } from "../auth/account_identity"; |
| 8 | 9 | |
| 9 | 10 | export async function requireCanonicalAccount( |
| @@ -13,14 +14,19 @@ export async function requireCanonicalAccount( · 3 unmodified lines | ||
| 13 | 14 | sessionId?: string; |
| 14 | 15 | accountId?: Id<"accounts">; |
| 15 | 16 | }, |
| 16 | −): Promise<{ accountId: Id<"accounts"> }> { | |
| 17 | +): Promise<{ accountId: Id<"accounts">; clientId: "web" | "mcp" }> { | |
| 17 | 18 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { |
| 18 | 19 | workspaceId: args.workspaceId, |
| 19 | 20 | sessionId: args.sessionId, |
| 20 | 21 | }); |
| 21 | 22 | const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access); |
| 23 | + const canonicalClientId = canonicalClientIdForWorkspaceAccess({ | |
| 24 | + provider: access.provider, | |
| 25 | + sessionId: args.sessionId, | |
| 26 | + }); | |
| 22 | 27 | assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId); |
| 23 | 28 | return { |
| 24 | 29 | accountId: canonicalAccountId, |
| 30 | + clientId: canonicalClientId, | |
| 25 | 31 | }; |
| 26 | 32 | } |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | −import type { AccessPolicyRecord } from "../../../core/src/types"; | |
| 2 | +import type { ToolPolicyRecord } from "../../../core/src/types"; | |
| 3 | 3 | import { getDecisionForContext } from "./policy"; |
| 4 | 4 | |
| 5 | −const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePattern"> = { | |
| 5 | +const basePolicy: Omit<ToolPolicyRecord, "id" | "resourceType" | "resourcePattern"> = { | |
| 6 | 6 | scopeType: "organization", |
| 7 | 7 | organizationId: "org_1" as any, |
| 8 | 8 | matchType: "glob", |
| @@ -14,7 +14,7 @@ const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePatt · 5 unmodified lines | ||
| 14 | 14 | }; |
| 15 | 15 | |
| 16 | 16 | test("source-scoped policy can deny all tools from a source", () => { |
| 17 | − const policies: AccessPolicyRecord[] = [ | |
| 17 | + const policies: ToolPolicyRecord[] = [ | |
| 18 | 18 | { |
| 19 | 19 | id: "p1", |
| 20 | 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 | 44 | test("tool-path policy can override source policy by specificity", () => { |
| 45 | − const policies: AccessPolicyRecord[] = [ | |
| 45 | + const policies: ToolPolicyRecord[] = [ | |
| 46 | 46 | { |
| 47 | 47 | id: "p-source", |
| 48 | 48 | ...basePolicy, |
| @@ -81,7 +81,7 @@ test("tool-path policy can override source policy by specificity", () => { · 32 unmodified lines | ||
| 81 | 81 | }); |
| 82 | 82 | |
| 83 | 83 | test("all-tools policy applies without source context", () => { |
| 84 | − const policies: AccessPolicyRecord[] = [ | |
| 84 | + const policies: ToolPolicyRecord[] = [ | |
| 85 | 85 | { |
| 86 | 86 | id: "p-all", |
| 87 | 87 | ...basePolicy, |
| @@ -106,3 +106,31 @@ test("all-tools policy applies without source context", () => { · 18 unmodified lines | ||
| 106 | 106 | |
| 107 | 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 | 1 | import type { |
| 2 | − AccessPolicyRecord, | |
| 2 | + ToolPolicyRecord, | |
| 3 | 3 | ArgumentCondition, |
| 4 | 4 | PolicyDecision, |
| 5 | 5 | TaskRecord, |
| @@ -46,7 +46,7 @@ function matchesArgumentConditions( · 40 unmodified lines | ||
| 46 | 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 | 50 | if (policy.resourceType === "all_tools") { |
| 51 | 51 | return true; |
| 52 | 52 | } |
| @@ -66,7 +66,7 @@ function matchesPolicyResource(policy: AccessPolicyRecord, tool: PolicyTool): bo · 13 unmodified lines | ||
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | function policySpecificity( |
| 69 | − policy: AccessPolicyRecord, | |
| 69 | + policy: ToolPolicyRecord, | |
| 70 | 70 | context: { workspaceId: string; accountId?: string; clientId?: string }, |
| 71 | 71 | ): number { |
| 72 | 72 | const scopeType = policy.scopeType; |
| @@ -91,7 +91,7 @@ function policySpecificity( · 18 unmodified lines | ||
| 91 | 91 | return score; |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | −function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision { | |
| 94 | +function resolvePolicyDecision(policy: ToolPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision { | |
| 95 | 95 | const effect = policy.effect; |
| 96 | 96 | const approvalMode = policy.approvalMode; |
| 97 | 97 | |
| @@ -113,13 +113,9 @@ function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: Poli · 15 unmodified lines | ||
| 113 | 113 | export function getDecisionForContext( |
| 114 | 114 | tool: PolicyTool, |
| 115 | 115 | context: { workspaceId: string; accountId?: string; clientId?: string }, |
| 116 | − policies: AccessPolicyRecord[], | |
| 116 | + policies: ToolPolicyRecord[], | |
| 117 | 117 | input?: Record<string, unknown>, |
| 118 | 118 | ): PolicyDecision { |
| 119 | − if (tool.path === "discover") { | |
| 120 | − return "allow"; | |
| 121 | − } | |
| 122 | − | |
| 123 | 119 | const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; |
| 124 | 120 | const candidates = policies |
| 125 | 121 | .filter((policy) => { |
| @@ -151,7 +147,7 @@ export function getDecisionForContext( · 25 unmodified lines | ||
| 151 | 147 | export function getToolDecision( |
| 152 | 148 | task: TaskRecord, |
| 153 | 149 | tool: PolicyTool, |
| 154 | − policies: AccessPolicyRecord[], | |
| 150 | + policies: ToolPolicyRecord[], | |
| 155 | 151 | input?: Record<string, unknown>, |
| 156 | 152 | ): PolicyDecision { |
| 157 | 153 | return getDecisionForContext( |
| @@ -170,7 +166,7 @@ export function isToolAllowedForTask( · 12 unmodified lines | ||
| 170 | 166 | task: TaskRecord, |
| 171 | 167 | toolPath: string, |
| 172 | 168 | workspaceTools: ReadonlyMap<string, PolicyTool>, |
| 173 | − policies: AccessPolicyRecord[], | |
| 169 | + policies: ToolPolicyRecord[], | |
| 174 | 170 | ): boolean { |
| 175 | 171 | const tool = workspaceTools.get(toolPath); |
| 176 | 172 | if (!tool) return false; |
| @@ -3,7 +3,7 @@ import { z } from "zod"; · 2 unmodified lines | ||
| 3 | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | 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 | 7 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 8 | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | 9 | import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; |
| @@ -79,7 +79,7 @@ export function getGraphqlDecision( · 69 unmodified lines | ||
| 79 | 79 | tool: { path: string; approval: "auto" | "required"; source?: string; _graphqlSource?: string }, |
| 80 | 80 | input: unknown, |
| 81 | 81 | workspaceTools: Map<string, { path: string; approval: "auto" | "required"; source?: string }> | undefined, |
| 82 | − policies: AccessPolicyRecord[], | |
| 82 | + policies: ToolPolicyRecord[], | |
| 83 | 83 | ): { decision: PolicyDecision; effectivePaths: string[] } { |
| 84 | 84 | const sourceName = tool._graphqlSource; |
| 85 | 85 | if (!sourceName) { |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | − AccessPolicyRecord, | |
| 2 | + ToolPolicyRecord, | |
| 3 | 3 | OpenApiSourceQuality, |
| 4 | 4 | ToolDefinition, |
| 5 | 5 | ToolDescriptor, |
| @@ -135,7 +135,7 @@ export function computeOpenApiSourceQuality( · 129 unmodified lines | ||
| 135 | 135 | export function listVisibleToolDescriptors( |
| 136 | 136 | workspaceTools: Map<string, ToolDefinition>, |
| 137 | 137 | context: { workspaceId: string; accountId?: string; clientId?: string }, |
| 138 | − policies: AccessPolicyRecord[], | |
| 138 | + policies: ToolPolicyRecord[], | |
| 139 | 139 | options: { includeDetails?: boolean; toolPaths?: string[] } = {}, |
| 140 | 140 | ): ToolDescriptor[] { |
| 141 | 141 | const requestedPaths = options.toolPaths ?? []; |
| @@ -2,7 +2,7 @@ import { z } from "zod"; · 1 unmodified line | ||
| 2 | 2 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 3 | 3 | import { internal } from "../../convex/_generated/api"; |
| 4 | 4 | import type { |
| 5 | − AccessPolicyRecord, | |
| 5 | + ToolPolicyRecord, | |
| 6 | 6 | PolicyDecision, |
| 7 | 7 | ResolvedToolCredential, |
| 8 | 8 | TaskRecord, |
| @@ -218,15 +218,15 @@ async function upsertRequestedToolCall( · 209 unmodified lines | ||
| 218 | 218 | return persistedCall; |
| 219 | 219 | } |
| 220 | 220 | |
| 221 | −async function listWorkspaceAccessPolicies( | |
| 221 | +async function listWorkspaceToolPolicies( | |
| 222 | 222 | ctx: ActionCtx, |
| 223 | 223 | task: Pick<TaskRecord, "workspaceId" | "accountId">, |
| 224 | −): Promise<AccessPolicyRecord[]> { | |
| 225 | − const policies = await ctx.runQuery(internal.database.listAccessPolicies, { | |
| 224 | +): Promise<ToolPolicyRecord[]> { | |
| 225 | + const policies = await ctx.runQuery(internal.database.listToolPolicies, { | |
| 226 | 226 | workspaceId: task.workspaceId, |
| 227 | 227 | accountId: task.accountId, |
| 228 | 228 | }); |
| 229 | − return policies as AccessPolicyRecord[]; | |
| 229 | + return policies as ToolPolicyRecord[]; | |
| 230 | 230 | } |
| 231 | 231 | |
| 232 | 232 | async function listRegistryNamespaces( |
| @@ -348,7 +348,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 115 unmodified lines | ||
| 348 | 348 | |
| 349 | 349 | let effectiveToolPath = toolPath; |
| 350 | 350 | try { |
| 351 | − const typedPolicies = await listWorkspaceAccessPolicies(ctx, task); | |
| 351 | + const typedPolicies = await listWorkspaceToolPolicies(ctx, task); | |
| 352 | 352 | const finalizeImmediateTool = async (value: unknown): Promise<unknown> => { |
| 353 | 353 | if (persistedCall.status === "requested") { |
| 354 | 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 | 369 | // Fast system tools are handled server-side from the registry. |
| 370 | 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 | 394 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { |
| 372 | 395 | workspaceId: task.workspaceId, |
| 373 | 396 | accountId: task.accountId, |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 19 | 19 | import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types"; |
| 20 | 20 | import type { |
| 21 | − AccessPolicyRecord, | |
| 21 | + ToolPolicyRecord, | |
| 22 | 22 | JsonSchema, |
| 23 | 23 | OpenApiSourceQuality, |
| 24 | 24 | SourceAuthProfile, |
| @@ -66,12 +66,12 @@ async function listWorkspaceToolSources( · 41 unmodified lines | ||
| 66 | 66 | return sources; |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | −async function listWorkspaceAccessPolicies( | |
| 69 | +async function listWorkspaceToolPolicies( | |
| 70 | 70 | ctx: QueryRunnerCtx, |
| 71 | 71 | workspaceId: Id<"workspaces">, |
| 72 | 72 | accountId?: Id<"accounts">, |
| 73 | −): Promise<AccessPolicyRecord[]> { | |
| 74 | − const policies: AccessPolicyRecord[] = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId, accountId }); | |
| 73 | +): Promise<ToolPolicyRecord[]> { | |
| 74 | + const policies: ToolPolicyRecord[] = await ctx.runQuery(internal.database.listToolPolicies, { workspaceId, accountId }); | |
| 75 | 75 | return policies; |
| 76 | 76 | } |
| 77 | 77 | |
| @@ -378,7 +378,7 @@ function toToolDetailDescriptor(tool: ToolDescriptor): ToolDetailDescriptor { · 300 unmodified lines | ||
| 378 | 378 | function listVisibleRegistryToolDescriptors( |
| 379 | 379 | entries: RegistryToolEntry[], |
| 380 | 380 | context: { workspaceId: string; accountId?: string; clientId?: string }, |
| 381 | − policies: AccessPolicyRecord[], | |
| 381 | + policies: ToolPolicyRecord[], | |
| 382 | 382 | options: { |
| 383 | 383 | includeDetails?: boolean; |
| 384 | 384 | toolPaths?: string[]; |
| @@ -1151,7 +1151,7 @@ export async function getWorkspaceInventoryProgressForContext( · 766 unmodified lines | ||
| 1151 | 1151 | // Read policies so Convex tracks this reactive dependency — any policy |
| 1152 | 1152 | // change will invalidate the query and produce a new reactiveKey, which |
| 1153 | 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 | 1157 | const progress = computeWorkspaceInventoryProgress( |
| @@ -1368,7 +1368,7 @@ async function loadWorkspaceToolInventoryForContext( · 210 unmodified lines | ||
| 1368 | 1368 | buildId: options.buildId, |
| 1369 | 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 | 1374 | const warnings = [...result.warnings]; |
| @@ -1468,7 +1468,7 @@ export async function listToolDetailsForContext( · 93 unmodified lines | ||
| 1468 | 1468 | ctx.runQuery(internal.toolRegistry.getState, { |
| 1469 | 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 | 1474 | const result: Record<string, ToolDetailDescriptor> = {}; |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Result } from "better-result"; |
| 2 | 2 | import { api } from "@executor/database/convex/_generated/api"; |
| 3 | 3 | import { decodeToolCallResultFromTransport } from "../../core/src/tool-call-result-transport"; |
| 4 | −import { ConvexClient, ConvexHttpClient } from "convex/browser"; | |
| 4 | +import { ConvexHttpClient } from "convex/browser"; | |
| 5 | 5 | import { z } from "zod"; |
| 6 | 6 | import type { |
| 7 | 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 { | |
| 78 | − return new ConvexClient(callbackConvexUrl, { | |
| 79 | − skipConvexDeploymentUrlCheck: true, | |
| 80 | − }); | |
| 77 | +function sleep(ms: number): Promise<void> { | |
| 78 | + return new Promise((resolve) => setTimeout(resolve, ms)); | |
| 81 | 79 | } |
| 82 | 80 | |
| 83 | 81 | async function waitForApprovalUpdate(props: BridgeProps, approvalId: string): Promise<void> { |
| 84 | − const client = createRealtimeClient(props.callbackConvexUrl); | |
| 85 | − | |
| 86 | − await new Promise<void>((resolve, reject) => { | |
| 87 | − let settled = false; | |
| 88 | − | |
| 89 | − const timer = setTimeout(() => { | |
| 90 | − if (settled) return; | |
| 91 | − settled = true; | |
| 92 | − unsubscribe(); | |
| 93 | − client.close(); | |
| 94 | − reject(new Error(`Timed out waiting for approval update: ${approvalId}`)); | |
| 95 | − }, APPROVAL_SUBSCRIPTION_TIMEOUT_MS); | |
| 96 | − | |
| 97 | − const unsubscribe = client.onUpdate( | |
| 98 | − api.runtimeCallbacks.getApprovalStatus, | |
| 99 | − { | |
| 100 | − internalSecret: props.callbackInternalSecret, | |
| 101 | − runId: props.taskId, | |
| 102 | − approvalId, | |
| 103 | − }, | |
| 104 | − (value: { status?: "pending" | "approved" | "denied" | "missing" } | null | undefined) => { | |
| 105 | − const status = value?.status; | |
| 106 | − if (!status || status === "pending") { | |
| 107 | − return; | |
| 108 | − } | |
| 109 | − if (status === "missing") { | |
| 110 | − if (settled) return; | |
| 111 | − settled = true; | |
| 112 | − clearTimeout(timer); | |
| 113 | − unsubscribe(); | |
| 114 | − client.close(); | |
| 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 | − }); | |
| 82 | + const client = createConvexClient(props.callbackConvexUrl); | |
| 83 | + const startedAt = Date.now(); | |
| 84 | + const pollIntervalMs = 1000; | |
| 85 | + | |
| 86 | + while (Date.now() - startedAt < APPROVAL_SUBSCRIPTION_TIMEOUT_MS) { | |
| 87 | + const statusResult = await Result.tryPromise(() => client.query(api.runtimeCallbacks.getApprovalStatus, { | |
| 88 | + internalSecret: props.callbackInternalSecret, | |
| 89 | + runId: props.taskId, | |
| 90 | + approvalId, | |
| 91 | + })); | |
| 92 | + | |
| 93 | + if (statusResult.isErr()) { | |
| 94 | + const cause = statusResult.error.cause; | |
| 95 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 96 | + throw new Error(`Approval status poll failed: ${message}`); | |
| 97 | + } | |
| 98 | + | |
| 99 | + const status = statusResult.value?.status; | |
| 100 | + if (!status || status === "pending") { | |
| 101 | + await sleep(pollIntervalMs); | |
| 102 | + continue; | |
| 103 | + } | |
| 104 | + | |
| 105 | + if (status === "missing") { | |
| 106 | + throw new Error(`Approval not found: ${approvalId}`); | |
| 107 | + } | |
| 108 | + | |
| 109 | + return; | |
| 110 | + } | |
| 111 | + | |
| 112 | + throw new Error(`Timed out waiting for approval update: ${approvalId}`); | |
| 127 | 113 | } |
| 128 | 114 | |
| 129 | 115 | export async function callToolWithBridge( |
| @@ -35,6 +35,7 @@ · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | import { Result } from "better-result"; |
| 37 | 37 | import { WorkerEntrypoint } from "cloudflare:workers"; |
| 38 | +import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport"; | |
| 38 | 39 | import GLOBALS_MODULE from "./isolate/globals.isolate.js"; |
| 39 | 40 | import HARNESS_CODE from "./isolate/harness.isolate.js"; |
| 40 | 41 | import { authorizeRunRequest } from "./auth"; |
| @@ -63,8 +64,9 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 22 unmodified lines | ||
| 63 | 64 | } |
| 64 | 65 | |
| 65 | 66 | /** Forward a tool call to the Convex callback RPC action. */ |
| 66 | − async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> { | |
| 67 | − return callToolWithBridge(this.props, toolPath, input, callId); | |
| 67 | + async callTool(toolPath: string, input: unknown, callId?: string): Promise<string> { | |
| 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 | 2 | import { run } from "./user-code.js"; |
| 3 | 3 | |
| 4 | 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 | 111 | function createToolsProxy(bridge, path = []) { |
| 7 | 112 | const callable = () => {}; |
| @@ -17,10 +122,16 @@ function createToolsProxy(bridge, path = []) { · 9 unmodified lines | ||
| 17 | 122 | const input = args.length > 0 ? args[0] : {}; |
| 18 | 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 | 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 | 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 | 166 | exitCode: 0, |
| 56 | 167 | }); |
| 57 | 168 | } catch (error) { |
| 58 | − const message = | |
| 59 | − error instanceof Error ? error.message : String(error); | |
| 169 | + const message = describeExecutionError(error); | |
| 60 | 170 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { |
| 61 | 171 | const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); |
| 62 | 172 | return _ResponseJson({ |
| @@ -45,7 +45,7 @@ export interface RunResult { · 44 unmodified lines | ||
| 45 | 45 | export type ToolCallResult = |
| 46 | 46 | | { |
| 47 | 47 | ok: true; |
| 48 | − value?: unknown; | |
| 48 | + value: unknown; | |
| 49 | 49 | } |
| 50 | 50 | | { |
| 51 | 51 | ok: false; |
| @@ -57,7 +57,7 @@ export type ToolCallResult = · 5 unmodified lines | ||
| 57 | 57 | | { |
| 58 | 58 | ok: false; |
| 59 | 59 | kind: "denied" | "failed"; |
| 60 | − error?: string; | |
| 60 | + error: string; | |
| 61 | 61 | approvalId?: string; |
| 62 | 62 | retryAfterMs?: number; |
| 63 | 63 | }; |