Unified · Split
| @@ -41,3 +41,19 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 40 unmodified lines | ||
| 41 | 41 | - Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`. |
| 42 | 42 | - `run_code` supports TypeScript typechecking and runtime transpilation before execution. |
| 43 | 43 | - `run_code` now attempts MCP form elicitation for pending tool approvals when the MCP client advertises `elicitation.form`; clients without elicitation support continue using the existing out-of-band approval flow. |
| 44 | + | |
| 45 | +## Credential Providers | |
| 46 | + | |
| 47 | +`sourceCredentials` now supports pluggable providers: | |
| 48 | + | |
| 49 | +- `managed` (default): stores `secretJson` in Convex. | |
| 50 | +- `workos-vault`: stores credential payload in encrypted external storage and keeps only a reference in Convex. | |
| 51 | + | |
| 52 | +`workos-vault` uses the standard `WORKOS_API_KEY` environment variable for Vault reads. | |
| 53 | + | |
| 54 | +Examples for `upsertCredential.secretJson`: | |
| 55 | + | |
| 56 | +- `managed`: `{ "token": "ghp_..." }` | |
| 57 | +- `workos-vault`: `{ "token": "ghp_..." }` (backend stores encrypted object) | |
| 58 | + | |
| 59 | +Compatibility note: `workos-vault` also accepts `{ "objectId": "secret_..." }` when importing existing references. | |
| @@ -12,11 +12,14 @@ import { · 11 unmodified lines | ||
| 12 | 12 | Search, |
| 13 | 13 | ChevronRight, |
| 14 | 14 | AlertTriangle, |
| 15 | + KeyRound, | |
| 16 | + Pencil, | |
| 15 | 17 | } from "lucide-react"; |
| 16 | 18 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 17 | 19 | import { Button } from "@/components/ui/button"; |
| 18 | 20 | import { Input } from "@/components/ui/input"; |
| 19 | 21 | import { Label } from "@/components/ui/label"; |
| 22 | +import { Textarea } from "@/components/ui/textarea"; | |
| 20 | 23 | import { Badge } from "@/components/ui/badge"; |
| 21 | 24 | import { |
| 22 | 25 | Select, |
| @@ -44,9 +47,9 @@ import { PageHeader } from "@/components/page-header"; · 21 unmodified lines | ||
| 44 | 47 | import { McpSetupCard } from "@/components/mcp-setup-card"; |
| 45 | 48 | import { useSession } from "@/lib/session-context"; |
| 46 | 49 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 47 | −import { useMutation, useQuery } from "convex/react"; | |
| 50 | +import { useAction, useMutation, useQuery } from "convex/react"; | |
| 48 | 51 | import { convexApi } from "@/lib/convex-api"; |
| 49 | −import type { ToolSourceRecord, ToolDescriptor } from "@/lib/types"; | |
| 52 | +import type { ToolSourceRecord, ToolDescriptor, CredentialRecord, CredentialScope } from "@/lib/types"; | |
| 50 | 53 | import { parse as parseDomain } from "tldts"; |
| 51 | 54 | import { toast } from "sonner"; |
| 52 | 55 | import { cn } from "@/lib/utils"; |
| @@ -219,6 +222,53 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { · 166 unmodified lines | ||
| 219 | 222 | return faviconForUrl(url); |
| 220 | 223 | } |
| 221 | 224 | |
| 225 | +function sourceKeyForSource(source: ToolSourceRecord): string | null { | |
| 226 | + if (source.type === "openapi") return `openapi:${source.name}`; | |
| 227 | + if (source.type === "graphql") return `graphql:${source.name}`; | |
| 228 | + return null; | |
| 229 | +} | |
| 230 | + | |
| 231 | +type SourceAuthType = "none" | "bearer" | "apiKey" | "basic"; | |
| 232 | +type SourceAuthMode = "workspace" | "actor"; | |
| 233 | + | |
| 234 | +function readSourceAuth(source: ToolSourceRecord): { | |
| 235 | + type: SourceAuthType; | |
| 236 | + mode?: SourceAuthMode; | |
| 237 | + header?: string; | |
| 238 | +} { | |
| 239 | + if (source.type !== "openapi" && source.type !== "graphql") { | |
| 240 | + return { type: "none" }; | |
| 241 | + } | |
| 242 | + | |
| 243 | + const auth = source.config.auth as Record<string, unknown> | undefined; | |
| 244 | + const type = | |
| 245 | + auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic"].includes(auth.type) | |
| 246 | + ? (auth.type as SourceAuthType) | |
| 247 | + : "none"; | |
| 248 | + | |
| 249 | + const mode = | |
| 250 | + auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") | |
| 251 | + ? (auth.mode as SourceAuthMode) | |
| 252 | + : undefined; | |
| 253 | + | |
| 254 | + const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 | |
| 255 | + ? auth.header.trim() | |
| 256 | + : undefined; | |
| 257 | + | |
| 258 | + return { | |
| 259 | + type, | |
| 260 | + ...(mode ? { mode } : {}), | |
| 261 | + ...(header ? { header } : {}), | |
| 262 | + }; | |
| 263 | +} | |
| 264 | + | |
| 265 | +function formatSourceAuthBadge(source: ToolSourceRecord): string | null { | |
| 266 | + const auth = readSourceAuth(source); | |
| 267 | + if (auth.type === "none") return null; | |
| 268 | + const mode = auth.mode ?? "workspace"; | |
| 269 | + return `${auth.type}:${mode}`; | |
| 270 | +} | |
| 271 | + | |
| 222 | 272 | // ── Add Source Dialog ── |
| 223 | 273 | |
| 224 | 274 | const RAW_HOSTS = new Set([ |
| @@ -562,6 +612,148 @@ function AddSourceDialog({ · 337 unmodified lines | ||
| 562 | 612 | |
| 563 | 613 | // ── Source Card ── |
| 564 | 614 | |
| 615 | +function ConfigureSourceAuthDialog({ | |
| 616 | + source, | |
| 617 | +}: { | |
| 618 | + source: ToolSourceRecord; | |
| 619 | +}) { | |
| 620 | + const { context } = useSession(); | |
| 621 | + const upsertToolSource = useMutation(convexApi.database.upsertToolSource); | |
| 622 | + const [open, setOpen] = useState(false); | |
| 623 | + const [saving, setSaving] = useState(false); | |
| 624 | + | |
| 625 | + const currentAuth = readSourceAuth(source); | |
| 626 | + const [authType, setAuthType] = useState<SourceAuthType>(currentAuth.type); | |
| 627 | + const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); | |
| 628 | + const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); | |
| 629 | + | |
| 630 | + const configurable = source.type === "openapi" || source.type === "graphql"; | |
| 631 | + | |
| 632 | + const resetFromSource = () => { | |
| 633 | + const auth = readSourceAuth(source); | |
| 634 | + setAuthType(auth.type); | |
| 635 | + setAuthMode(auth.mode ?? "workspace"); | |
| 636 | + setApiKeyHeader(auth.header ?? "x-api-key"); | |
| 637 | + }; | |
| 638 | + | |
| 639 | + const handleOpenChange = (isOpen: boolean) => { | |
| 640 | + setOpen(isOpen); | |
| 641 | + if (isOpen) { | |
| 642 | + resetFromSource(); | |
| 643 | + } | |
| 644 | + }; | |
| 645 | + | |
| 646 | + const handleSave = async () => { | |
| 647 | + if (!context || !configurable) return; | |
| 648 | + setSaving(true); | |
| 649 | + try { | |
| 650 | + const authConfig: Record<string, unknown> = | |
| 651 | + authType === "none" | |
| 652 | + ? { type: "none" } | |
| 653 | + : authType === "apiKey" | |
| 654 | + ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" } | |
| 655 | + : { type: authType, mode: authMode }; | |
| 656 | + | |
| 657 | + await upsertToolSource({ | |
| 658 | + id: source.id, | |
| 659 | + workspaceId: context.workspaceId, | |
| 660 | + name: source.name, | |
| 661 | + type: source.type, | |
| 662 | + config: { | |
| 663 | + ...source.config, | |
| 664 | + auth: authConfig, | |
| 665 | + }, | |
| 666 | + }); | |
| 667 | + | |
| 668 | + toast.success(`Updated auth for ${source.name}`); | |
| 669 | + setOpen(false); | |
| 670 | + } catch (err) { | |
| 671 | + toast.error(err instanceof Error ? err.message : "Failed to update auth"); | |
| 672 | + } finally { | |
| 673 | + setSaving(false); | |
| 674 | + } | |
| 675 | + }; | |
| 676 | + | |
| 677 | + if (!configurable) { | |
| 678 | + return null; | |
| 679 | + } | |
| 680 | + | |
| 681 | + return ( | |
| 682 | + <Dialog open={open} onOpenChange={handleOpenChange}> | |
| 683 | + <DialogTrigger asChild> | |
| 684 | + <Button variant="outline" size="sm" className="h-7 text-[11px]"> | |
| 685 | + <Pencil className="h-3 w-3 mr-1.5" /> | |
| 686 | + Auth | |
| 687 | + </Button> | |
| 688 | + </DialogTrigger> | |
| 689 | + <DialogContent className="bg-card border-border sm:max-w-md"> | |
| 690 | + <DialogHeader> | |
| 691 | + <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle> | |
| 692 | + </DialogHeader> | |
| 693 | + <div className="space-y-3"> | |
| 694 | + <div className="space-y-1.5"> | |
| 695 | + <Label className="text-xs text-muted-foreground">Source</Label> | |
| 696 | + <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" /> | |
| 697 | + </div> | |
| 698 | + | |
| 699 | + <div className="space-y-1.5"> | |
| 700 | + <Label className="text-xs text-muted-foreground">Auth Type</Label> | |
| 701 | + <Select value={authType} onValueChange={(value) => setAuthType(value as SourceAuthType)}> | |
| 702 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 703 | + <SelectValue /> | |
| 704 | + </SelectTrigger> | |
| 705 | + <SelectContent> | |
| 706 | + <SelectItem value="none" className="text-xs">None</SelectItem> | |
| 707 | + <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem> | |
| 708 | + <SelectItem value="apiKey" className="text-xs">API key header</SelectItem> | |
| 709 | + <SelectItem value="basic" className="text-xs">Basic auth</SelectItem> | |
| 710 | + </SelectContent> | |
| 711 | + </Select> | |
| 712 | + </div> | |
| 713 | + | |
| 714 | + {authType !== "none" && ( | |
| 715 | + <> | |
| 716 | + <div className="space-y-1.5"> | |
| 717 | + <Label className="text-xs text-muted-foreground">Credential Scope</Label> | |
| 718 | + <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}> | |
| 719 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 720 | + <SelectValue /> | |
| 721 | + </SelectTrigger> | |
| 722 | + <SelectContent> | |
| 723 | + <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | |
| 724 | + <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | |
| 725 | + </SelectContent> | |
| 726 | + </Select> | |
| 727 | + </div> | |
| 728 | + | |
| 729 | + {authType === "apiKey" && ( | |
| 730 | + <div className="space-y-1.5"> | |
| 731 | + <Label className="text-xs text-muted-foreground">Header Name</Label> | |
| 732 | + <Input | |
| 733 | + value={apiKeyHeader} | |
| 734 | + onChange={(e) => setApiKeyHeader(e.target.value)} | |
| 735 | + placeholder="x-api-key" | |
| 736 | + className="h-8 text-xs font-mono bg-background" | |
| 737 | + /> | |
| 738 | + </div> | |
| 739 | + )} | |
| 740 | + | |
| 741 | + <p className="text-[11px] text-muted-foreground"> | |
| 742 | + Save this first, then add credentials in the Credentials tab using source key | |
| 743 | + <code className="ml-1">{sourceKeyForSource(source)}</code>. | |
| 744 | + </p> | |
| 745 | + </> | |
| 746 | + )} | |
| 747 | + | |
| 748 | + <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | |
| 749 | + {saving ? "Saving..." : "Save Auth"} | |
| 750 | + </Button> | |
| 751 | + </div> | |
| 752 | + </DialogContent> | |
| 753 | + </Dialog> | |
| 754 | + ); | |
| 755 | +} | |
| 756 | + | |
| 565 | 757 | function SourceCard({ |
| 566 | 758 | source, |
| 567 | 759 | }: { |
| @@ -586,6 +778,7 @@ function SourceCard({ · 18 unmodified lines | ||
| 586 | 778 | |
| 587 | 779 | const TypeIcon = source.type === "mcp" ? Server : Globe; |
| 588 | 780 | const favicon = getSourceFavicon(source); |
| 781 | + const authBadge = formatSourceAuthBadge(source); | |
| 589 | 782 | |
| 590 | 783 | return ( |
| 591 | 784 | <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group"> |
| @@ -615,6 +808,14 @@ function SourceCard({ · 23 unmodified lines | ||
| 615 | 808 | disabled |
| 616 | 809 | </Badge> |
| 617 | 810 | )} |
| 811 | + {authBadge && ( | |
| 812 | + <Badge | |
| 813 | + variant="outline" | |
| 814 | + className="text-[9px] font-mono uppercase tracking-wider text-primary border-primary/30" | |
| 815 | + > | |
| 816 | + {authBadge} | |
| 817 | + </Badge> | |
| 818 | + )} | |
| 618 | 819 | </div> |
| 619 | 820 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> |
| 620 | 821 | {source.type === "mcp" |
| @@ -624,19 +825,505 @@ function SourceCard({ · 3 unmodified lines | ||
| 624 | 825 | : (source.config.spec as string)} |
| 625 | 826 | </span> |
| 626 | 827 | </div> |
| 627 | − <Button | |
| 628 | − variant="ghost" | |
| 629 | − size="icon" | |
| 630 | − className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-terminal-red shrink-0" | |
| 631 | − onClick={handleDelete} | |
| 632 | − disabled={deleting} | |
| 633 | − > | |
| 634 | − <Trash2 className="h-3.5 w-3.5" /> | |
| 635 | − </Button> | |
| 828 | + <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> | |
| 829 | + <ConfigureSourceAuthDialog source={source} /> | |
| 830 | + <Button | |
| 831 | + variant="ghost" | |
| 832 | + size="icon" | |
| 833 | + className="h-7 w-7 text-muted-foreground hover:text-terminal-red" | |
| 834 | + onClick={handleDelete} | |
| 835 | + disabled={deleting} | |
| 836 | + > | |
| 837 | + <Trash2 className="h-3.5 w-3.5" /> | |
| 838 | + </Button> | |
| 839 | + </div> | |
| 636 | 840 | </div> |
| 637 | 841 | ); |
| 638 | 842 | } |
| 639 | 843 | |
| 844 | +function formatCredentialSecret(secretJson: Record<string, unknown>): string { | |
| 845 | + try { | |
| 846 | + return JSON.stringify(secretJson, null, 2); | |
| 847 | + } catch { | |
| 848 | + return "{}"; | |
| 849 | + } | |
| 850 | +} | |
| 851 | + | |
| 852 | +type SourceOption = { source: ToolSourceRecord; key: string }; | |
| 853 | + | |
| 854 | +function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { | |
| 855 | + type: SourceAuthType; | |
| 856 | + mode?: SourceAuthMode; | |
| 857 | + header?: string; | |
| 858 | +} { | |
| 859 | + const match = sourceOptions.find((entry) => entry.key === key); | |
| 860 | + if (!match) { | |
| 861 | + return { type: "bearer" }; | |
| 862 | + } | |
| 863 | + return readSourceAuth(match.source); | |
| 864 | +} | |
| 865 | + | |
| 866 | +function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } { | |
| 867 | + try { | |
| 868 | + const parsed = JSON.parse(text); | |
| 869 | + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 870 | + return { error: "Credential JSON must be an object" }; | |
| 871 | + } | |
| 872 | + return { value: parsed as Record<string, unknown> }; | |
| 873 | + } catch (error) { | |
| 874 | + return { error: error instanceof Error ? error.message : "Invalid credential JSON" }; | |
| 875 | + } | |
| 876 | +} | |
| 877 | + | |
| 878 | +function asString(value: unknown): string { | |
| 879 | + return typeof value === "string" ? value : ""; | |
| 880 | +} | |
| 881 | + | |
| 882 | +function providerLabel(provider: "managed" | "workos-vault"): string { | |
| 883 | + return provider === "workos-vault" ? "encrypted" : "managed"; | |
| 884 | +} | |
| 885 | + | |
| 886 | +function CredentialsPanel({ | |
| 887 | + sources, | |
| 888 | + credentials, | |
| 889 | + loading, | |
| 890 | +}: { | |
| 891 | + sources: ToolSourceRecord[]; | |
| 892 | + credentials: CredentialRecord[]; | |
| 893 | + loading: boolean; | |
| 894 | +}) { | |
| 895 | + const { context } = useSession(); | |
| 896 | + const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | |
| 897 | + const [open, setOpen] = useState(false); | |
| 898 | + const [saving, setSaving] = useState(false); | |
| 899 | + const [editing, setEditing] = useState<CredentialRecord | null>(null); | |
| 900 | + const [sourceKey, setSourceKey] = useState(""); | |
| 901 | + const [scope, setScope] = useState<CredentialScope>("workspace"); | |
| 902 | + const [actorId, setActorId] = useState(""); | |
| 903 | + const [provider, setProvider] = useState<"managed" | "workos-vault">("managed"); | |
| 904 | + const [managedToken, setManagedToken] = useState(""); | |
| 905 | + const [apiKeyValue, setApiKeyValue] = useState(""); | |
| 906 | + const [basicUsername, setBasicUsername] = useState(""); | |
| 907 | + const [basicPassword, setBasicPassword] = useState(""); | |
| 908 | + const [advancedMode, setAdvancedMode] = useState(false); | |
| 909 | + const [secretJsonText, setSecretJsonText] = useState("{}"); | |
| 910 | + | |
| 911 | + const sourceOptions = sources | |
| 912 | + .map((source) => ({ source, key: sourceKeyForSource(source) })) | |
| 913 | + .filter((entry): entry is { source: ToolSourceRecord; key: string } => entry.key !== null); | |
| 914 | + | |
| 915 | + const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey); | |
| 916 | + const selectedAuthBadge = selectedAuth.type === "none" | |
| 917 | + ? "none" | |
| 918 | + : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; | |
| 919 | + | |
| 920 | + const buildDraftSecretFromInputs = (): Record<string, unknown> => { | |
| 921 | + if (selectedAuth.type === "apiKey") { | |
| 922 | + return { value: apiKeyValue.trim() }; | |
| 923 | + } | |
| 924 | + if (selectedAuth.type === "basic") { | |
| 925 | + return { | |
| 926 | + username: basicUsername, | |
| 927 | + password: basicPassword, | |
| 928 | + }; | |
| 929 | + } | |
| 930 | + return { token: managedToken.trim() }; | |
| 931 | + }; | |
| 932 | + | |
| 933 | + const setFormFromCredential = (credential: CredentialRecord) => { | |
| 934 | + const secret = credential.secretJson; | |
| 935 | + setManagedToken(asString(secret.token) || asString(secret.value)); | |
| 936 | + setApiKeyValue(asString(secret.value) || asString(secret.token)); | |
| 937 | + setBasicUsername(asString(secret.username)); | |
| 938 | + setBasicPassword(asString(secret.password)); | |
| 939 | + setSecretJsonText(formatCredentialSecret(secret)); | |
| 940 | + setAdvancedMode(false); | |
| 941 | + }; | |
| 942 | + | |
| 943 | + const resetForm = () => { | |
| 944 | + const defaultSourceKey = sourceOptions[0]?.key ?? ""; | |
| 945 | + setSourceKey(defaultSourceKey); | |
| 946 | + const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey); | |
| 947 | + setScope(defaultAuth.mode ?? "workspace"); | |
| 948 | + setActorId(context?.actorId ?? ""); | |
| 949 | + setProvider("managed"); | |
| 950 | + setManagedToken(""); | |
| 951 | + setApiKeyValue(""); | |
| 952 | + setBasicUsername(""); | |
| 953 | + setBasicPassword(""); | |
| 954 | + setAdvancedMode(false); | |
| 955 | + setSecretJsonText("{}"); | |
| 956 | + setEditing(null); | |
| 957 | + }; | |
| 958 | + | |
| 959 | + const openForCreate = () => { | |
| 960 | + resetForm(); | |
| 961 | + setOpen(true); | |
| 962 | + }; | |
| 963 | + | |
| 964 | + const openForEdit = (credential: CredentialRecord) => { | |
| 965 | + setEditing(credential); | |
| 966 | + setSourceKey(credential.sourceKey); | |
| 967 | + setScope(credential.scope); | |
| 968 | + setActorId(credential.actorId ?? context?.actorId ?? ""); | |
| 969 | + setProvider(credential.provider === "workos-vault" ? "workos-vault" : "managed"); | |
| 970 | + setFormFromCredential(credential); | |
| 971 | + setOpen(true); | |
| 972 | + }; | |
| 973 | + | |
| 974 | + const handleSourceKeyChange = (nextSourceKey: string) => { | |
| 975 | + setSourceKey(nextSourceKey); | |
| 976 | + const auth = sourceAuthForKey(sourceOptions, nextSourceKey); | |
| 977 | + if (!editing) { | |
| 978 | + setScope(auth.mode ?? "workspace"); | |
| 979 | + } | |
| 980 | + }; | |
| 981 | + | |
| 982 | + const handleProviderChange = (value: "managed" | "workos-vault") => { | |
| 983 | + setProvider(value); | |
| 984 | + }; | |
| 985 | + | |
| 986 | + const handleAdvancedModeChange = (next: boolean) => { | |
| 987 | + setAdvancedMode(next); | |
| 988 | + if (next) { | |
| 989 | + setSecretJsonText(formatCredentialSecret(buildDraftSecretFromInputs())); | |
| 990 | + } | |
| 991 | + }; | |
| 992 | + | |
| 993 | + const handleSave = async () => { | |
| 994 | + if (!context) return; | |
| 995 | + if (!sourceKey.trim()) { | |
| 996 | + toast.error("Source key is required"); | |
| 997 | + return; | |
| 998 | + } | |
| 999 | + if (scope === "actor" && !actorId.trim()) { | |
| 1000 | + toast.error("Actor ID is required for actor-scoped credentials"); | |
| 1001 | + return; | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + let secretJson: Record<string, unknown> = {}; | |
| 1005 | + const keepExistingEncryptedSecret = provider === "workos-vault" && Boolean(editing); | |
| 1006 | + | |
| 1007 | + if (advancedMode) { | |
| 1008 | + const parsed = parseJsonObject(secretJsonText); | |
| 1009 | + if (!parsed.value) { | |
| 1010 | + toast.error(parsed.error ?? "Invalid credential JSON"); | |
| 1011 | + return; | |
| 1012 | + } | |
| 1013 | + secretJson = parsed.value; | |
| 1014 | + } else { | |
| 1015 | + if (selectedAuth.type === "none") { | |
| 1016 | + toast.error("Configure source auth before saving credentials"); | |
| 1017 | + return; | |
| 1018 | + } | |
| 1019 | + if (selectedAuth.type === "basic") { | |
| 1020 | + const hasUsername = basicUsername.trim().length > 0; | |
| 1021 | + const hasPassword = basicPassword.trim().length > 0; | |
| 1022 | + if (!hasUsername && !hasPassword && keepExistingEncryptedSecret) { | |
| 1023 | + secretJson = {}; | |
| 1024 | + } else if (!hasUsername || !hasPassword) { | |
| 1025 | + toast.error("Username and password are required for basic auth"); | |
| 1026 | + return; | |
| 1027 | + } else { | |
| 1028 | + secretJson = { | |
| 1029 | + username: basicUsername, | |
| 1030 | + password: basicPassword, | |
| 1031 | + }; | |
| 1032 | + } | |
| 1033 | + } else if (selectedAuth.type === "apiKey") { | |
| 1034 | + if (!apiKeyValue.trim()) { | |
| 1035 | + if (keepExistingEncryptedSecret) { | |
| 1036 | + secretJson = {}; | |
| 1037 | + } else { | |
| 1038 | + toast.error("API key value is required"); | |
| 1039 | + return; | |
| 1040 | + } | |
| 1041 | + } else { | |
| 1042 | + secretJson = { value: apiKeyValue.trim() }; | |
| 1043 | + } | |
| 1044 | + } else { | |
| 1045 | + if (!managedToken.trim()) { | |
| 1046 | + if (keepExistingEncryptedSecret) { | |
| 1047 | + secretJson = {}; | |
| 1048 | + } else { | |
| 1049 | + toast.error("Token is required"); | |
| 1050 | + return; | |
| 1051 | + } | |
| 1052 | + } else { | |
| 1053 | + secretJson = { token: managedToken.trim() }; | |
| 1054 | + } | |
| 1055 | + } | |
| 1056 | + } | |
| 1057 | + | |
| 1058 | + if (provider === "workos-vault" && !editing && Object.keys(secretJson).length === 0) { | |
| 1059 | + if (selectedAuth.type === "basic") { | |
| 1060 | + toast.error("Username and password are required"); | |
| 1061 | + } else if (selectedAuth.type === "apiKey") { | |
| 1062 | + toast.error("API key value is required"); | |
| 1063 | + } else { | |
| 1064 | + toast.error("Token is required"); | |
| 1065 | + } | |
| 1066 | + return; | |
| 1067 | + } | |
| 1068 | + | |
| 1069 | + setSaving(true); | |
| 1070 | + try { | |
| 1071 | + await upsertCredential({ | |
| 1072 | + workspaceId: context.workspaceId, | |
| 1073 | + sourceKey: sourceKey.trim(), | |
| 1074 | + scope, | |
| 1075 | + ...(scope === "actor" ? { actorId: actorId.trim() } : {}), | |
| 1076 | + provider, | |
| 1077 | + secretJson, | |
| 1078 | + }); | |
| 1079 | + | |
| 1080 | + toast.success(editing ? "Credential updated" : "Credential saved"); | |
| 1081 | + setOpen(false); | |
| 1082 | + resetForm(); | |
| 1083 | + } catch (err) { | |
| 1084 | + toast.error(err instanceof Error ? err.message : "Failed to save credential"); | |
| 1085 | + } finally { | |
| 1086 | + setSaving(false); | |
| 1087 | + } | |
| 1088 | + }; | |
| 1089 | + | |
| 1090 | + return ( | |
| 1091 | + <Card className="bg-card border-border"> | |
| 1092 | + <CardHeader className="pb-3"> | |
| 1093 | + <div className="flex items-center justify-between"> | |
| 1094 | + <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| 1095 | + <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 1096 | + Credentials | |
| 1097 | + </CardTitle> | |
| 1098 | + <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> | |
| 1099 | + <Plus className="h-3.5 w-3.5 mr-1.5" /> | |
| 1100 | + Add Credential | |
| 1101 | + </Button> | |
| 1102 | + </div> | |
| 1103 | + </CardHeader> | |
| 1104 | + <CardContent className="pt-0 space-y-3"> | |
| 1105 | + {loading ? ( | |
| 1106 | + <div className="space-y-2"> | |
| 1107 | + {Array.from({ length: 3 }).map((_, i) => ( | |
| 1108 | + <Skeleton key={i} className="h-14" /> | |
| 1109 | + ))} | |
| 1110 | + </div> | |
| 1111 | + ) : credentials.length === 0 ? ( | |
| 1112 | + <div className="flex flex-col items-center justify-center py-10 gap-2"> | |
| 1113 | + <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> | |
| 1114 | + <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | |
| 1115 | + </div> | |
| 1116 | + <p className="text-sm text-muted-foreground">No credentials configured</p> | |
| 1117 | + <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> | |
| 1118 | + Configure source auth on an OpenAPI or GraphQL source, then add workspace or actor credentials. | |
| 1119 | + </p> | |
| 1120 | + </div> | |
| 1121 | + ) : ( | |
| 1122 | + <div className="space-y-2"> | |
| 1123 | + {credentials.map((credential) => ( | |
| 1124 | + <div | |
| 1125 | + key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`} | |
| 1126 | + className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" | |
| 1127 | + > | |
| 1128 | + <div className="flex-1 min-w-0"> | |
| 1129 | + <div className="flex items-center gap-2 flex-wrap"> | |
| 1130 | + <span className="text-sm font-mono font-medium">{credential.sourceKey}</span> | |
| 1131 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1132 | + {credential.scope} | |
| 1133 | + </Badge> | |
| 1134 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1135 | + {providerLabel(credential.provider === "workos-vault" ? "workos-vault" : "managed")} | |
| 1136 | + </Badge> | |
| 1137 | + {credential.scope === "actor" && credential.actorId && ( | |
| 1138 | + <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | |
| 1139 | + {credential.actorId} | |
| 1140 | + </span> | |
| 1141 | + )} | |
| 1142 | + </div> | |
| 1143 | + <p className="text-[11px] text-muted-foreground mt-0.5"> | |
| 1144 | + Updated {new Date(credential.updatedAt).toLocaleString()} | |
| 1145 | + </p> | |
| 1146 | + </div> | |
| 1147 | + <Button | |
| 1148 | + variant="outline" | |
| 1149 | + size="sm" | |
| 1150 | + className="h-7 text-[11px]" | |
| 1151 | + onClick={() => openForEdit(credential)} | |
| 1152 | + > | |
| 1153 | + Edit | |
| 1154 | + </Button> | |
| 1155 | + </div> | |
| 1156 | + ))} | |
| 1157 | + </div> | |
| 1158 | + )} | |
| 1159 | + </CardContent> | |
| 1160 | + | |
| 1161 | + <Dialog open={open} onOpenChange={setOpen}> | |
| 1162 | + <DialogContent className="bg-card border-border sm:max-w-lg"> | |
| 1163 | + <DialogHeader> | |
| 1164 | + <DialogTitle className="text-sm font-medium"> | |
| 1165 | + {editing ? "Edit Credential" : "Add Credential"} | |
| 1166 | + </DialogTitle> | |
| 1167 | + </DialogHeader> | |
| 1168 | + <div className="space-y-3"> | |
| 1169 | + <div className="space-y-1.5"> | |
| 1170 | + <Label className="text-xs text-muted-foreground">Source Key</Label> | |
| 1171 | + {sourceOptions.length > 0 ? ( | |
| 1172 | + <Select value={sourceKey} onValueChange={handleSourceKeyChange}> | |
| 1173 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1174 | + <SelectValue /> | |
| 1175 | + </SelectTrigger> | |
| 1176 | + <SelectContent> | |
| 1177 | + {sourceOptions.map((entry) => ( | |
| 1178 | + <SelectItem key={entry.key} value={entry.key} className="text-xs font-mono"> | |
| 1179 | + {entry.key} | |
| 1180 | + </SelectItem> | |
| 1181 | + ))} | |
| 1182 | + </SelectContent> | |
| 1183 | + </Select> | |
| 1184 | + ) : ( | |
| 1185 | + <Input | |
| 1186 | + value={sourceKey} | |
| 1187 | + onChange={(e) => handleSourceKeyChange(e.target.value)} | |
| 1188 | + placeholder="openapi:github" | |
| 1189 | + className="h-8 text-xs font-mono bg-background" | |
| 1190 | + /> | |
| 1191 | + )} | |
| 1192 | + </div> | |
| 1193 | + | |
| 1194 | + <div className="flex items-center gap-2 flex-wrap"> | |
| 1195 | + <span className="text-[11px] text-muted-foreground">Detected auth</span> | |
| 1196 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1197 | + {selectedAuthBadge} | |
| 1198 | + </Badge> | |
| 1199 | + {selectedAuth.type === "apiKey" && selectedAuth.header && ( | |
| 1200 | + <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | |
| 1201 | + header: {selectedAuth.header} | |
| 1202 | + </span> | |
| 1203 | + )} | |
| 1204 | + </div> | |
| 1205 | + | |
| 1206 | + <div className="grid grid-cols-2 gap-3"> | |
| 1207 | + <div className="space-y-1.5"> | |
| 1208 | + <Label className="text-xs text-muted-foreground">Scope</Label> | |
| 1209 | + <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}> | |
| 1210 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1211 | + <SelectValue /> | |
| 1212 | + </SelectTrigger> | |
| 1213 | + <SelectContent> | |
| 1214 | + <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | |
| 1215 | + <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | |
| 1216 | + </SelectContent> | |
| 1217 | + </Select> | |
| 1218 | + </div> | |
| 1219 | + | |
| 1220 | + <div className="space-y-1.5"> | |
| 1221 | + <Label className="text-xs text-muted-foreground">Provider</Label> | |
| 1222 | + <Select value={provider} onValueChange={(value) => handleProviderChange(value as "managed" | "workos-vault") }> | |
| 1223 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1224 | + <SelectValue /> | |
| 1225 | + </SelectTrigger> | |
| 1226 | + <SelectContent> | |
| 1227 | + <SelectItem value="managed" className="text-xs">Managed storage</SelectItem> | |
| 1228 | + <SelectItem value="workos-vault" className="text-xs">Encrypted storage</SelectItem> | |
| 1229 | + </SelectContent> | |
| 1230 | + </Select> | |
| 1231 | + </div> | |
| 1232 | + </div> | |
| 1233 | + | |
| 1234 | + {scope === "actor" && ( | |
| 1235 | + <div className="space-y-1.5"> | |
| 1236 | + <Label className="text-xs text-muted-foreground">Actor ID</Label> | |
| 1237 | + <Input | |
| 1238 | + value={actorId} | |
| 1239 | + onChange={(e) => setActorId(e.target.value)} | |
| 1240 | + placeholder="actor_123" | |
| 1241 | + className="h-8 text-xs font-mono bg-background" | |
| 1242 | + /> | |
| 1243 | + </div> | |
| 1244 | + )} | |
| 1245 | + | |
| 1246 | + {provider === "workos-vault" && editing && ( | |
| 1247 | + <p className="text-[11px] text-muted-foreground"> | |
| 1248 | + Stored secret is hidden. Enter a new value below to rotate, or leave fields blank to keep existing. | |
| 1249 | + </p> | |
| 1250 | + )} | |
| 1251 | + | |
| 1252 | + {selectedAuth.type === "none" ? ( | |
| 1253 | + <p className="text-[11px] text-terminal-amber"> | |
| 1254 | + This source has auth set to <code>none</code>. Configure source auth first. | |
| 1255 | + </p> | |
| 1256 | + ) : selectedAuth.type === "apiKey" ? ( | |
| 1257 | + <div className="space-y-1.5"> | |
| 1258 | + <Label className="text-xs text-muted-foreground">API Key Value</Label> | |
| 1259 | + <Input | |
| 1260 | + value={apiKeyValue} | |
| 1261 | + onChange={(e) => setApiKeyValue(e.target.value)} | |
| 1262 | + placeholder="sk_live_..." | |
| 1263 | + className="h-8 text-xs font-mono bg-background" | |
| 1264 | + /> | |
| 1265 | + </div> | |
| 1266 | + ) : selectedAuth.type === "basic" ? ( | |
| 1267 | + <div className="grid grid-cols-2 gap-3"> | |
| 1268 | + <div className="space-y-1.5"> | |
| 1269 | + <Label className="text-xs text-muted-foreground">Username</Label> | |
| 1270 | + <Input | |
| 1271 | + value={basicUsername} | |
| 1272 | + onChange={(e) => setBasicUsername(e.target.value)} | |
| 1273 | + className="h-8 text-xs font-mono bg-background" | |
| 1274 | + /> | |
| 1275 | + </div> | |
| 1276 | + <div className="space-y-1.5"> | |
| 1277 | + <Label className="text-xs text-muted-foreground">Password</Label> | |
| 1278 | + <Input | |
| 1279 | + type="password" | |
| 1280 | + value={basicPassword} | |
| 1281 | + onChange={(e) => setBasicPassword(e.target.value)} | |
| 1282 | + className="h-8 text-xs font-mono bg-background" | |
| 1283 | + /> | |
| 1284 | + </div> | |
| 1285 | + </div> | |
| 1286 | + ) : ( | |
| 1287 | + <div className="space-y-1.5"> | |
| 1288 | + <Label className="text-xs text-muted-foreground">Bearer Token</Label> | |
| 1289 | + <Input | |
| 1290 | + type="password" | |
| 1291 | + value={managedToken} | |
| 1292 | + onChange={(e) => setManagedToken(e.target.value)} | |
| 1293 | + placeholder="ghp_..." | |
| 1294 | + className="h-8 text-xs font-mono bg-background" | |
| 1295 | + /> | |
| 1296 | + </div> | |
| 1297 | + )} | |
| 1298 | + | |
| 1299 | + <Collapsible open={advancedMode} onOpenChange={handleAdvancedModeChange}> | |
| 1300 | + <CollapsibleTrigger asChild> | |
| 1301 | + <Button variant="outline" size="sm" className="h-7 text-[11px]"> | |
| 1302 | + Advanced JSON | |
| 1303 | + <ChevronRight className={cn("ml-1.5 h-3 w-3 transition-transform", advancedMode && "rotate-90")} /> | |
| 1304 | + </Button> | |
| 1305 | + </CollapsibleTrigger> | |
| 1306 | + <CollapsibleContent className="mt-2 space-y-1.5"> | |
| 1307 | + <Label className="text-xs text-muted-foreground">Override Secret JSON</Label> | |
| 1308 | + <Textarea | |
| 1309 | + value={secretJsonText} | |
| 1310 | + onChange={(e) => setSecretJsonText(e.target.value)} | |
| 1311 | + rows={6} | |
| 1312 | + className="text-xs font-mono bg-background" | |
| 1313 | + /> | |
| 1314 | + </CollapsibleContent> | |
| 1315 | + </Collapsible> | |
| 1316 | + | |
| 1317 | + <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | |
| 1318 | + {saving ? "Saving..." : editing ? "Update Credential" : "Save Credential"} | |
| 1319 | + </Button> | |
| 1320 | + </div> | |
| 1321 | + </DialogContent> | |
| 1322 | + </Dialog> | |
| 1323 | + </Card> | |
| 1324 | + ); | |
| 1325 | +} | |
| 1326 | + | |
| 640 | 1327 | // ── Tool Inventory ── |
| 641 | 1328 | |
| 642 | 1329 | function ToolInventory({ tools }: { tools: ToolDescriptor[] }) { |
| @@ -711,6 +1398,12 @@ export function ToolsView() { · 68 unmodified lines | ||
| 711 | 1398 | ); |
| 712 | 1399 | const sourcesLoading = !!context && sources === undefined; |
| 713 | 1400 | |
| 1401 | + const credentials = useQuery( | |
| 1402 | + convexApi.database.listCredentials, | |
| 1403 | + context ? { workspaceId: context.workspaceId } : "skip", | |
| 1404 | + ); | |
| 1405 | + const credentialsLoading = !!context && credentials === undefined; | |
| 1406 | + | |
| 714 | 1407 | const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null); |
| 715 | 1408 | |
| 716 | 1409 | if (sessionLoading) { |
| @@ -726,7 +1419,7 @@ export function ToolsView() { · 9 unmodified lines | ||
| 726 | 1419 | <div className="space-y-6"> |
| 727 | 1420 | <PageHeader |
| 728 | 1421 | title="Tools" |
| 729 | − description="Manage tool sources and view available tools" | |
| 1422 | + description="Manage sources, auth, credentials, and available tools" | |
| 730 | 1423 | /> |
| 731 | 1424 | |
| 732 | 1425 | <Tabs defaultValue="sources" className="w-full"> |
| @@ -739,6 +1432,14 @@ export function ToolsView() { · 6 unmodified lines | ||
| 739 | 1432 | </span> |
| 740 | 1433 | )} |
| 741 | 1434 | </TabsTrigger> |
| 1435 | + <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background"> | |
| 1436 | + Credentials | |
| 1437 | + {credentials && ( | |
| 1438 | + <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> | |
| 1439 | + {credentials.length} | |
| 1440 | + </span> | |
| 1441 | + )} | |
| 1442 | + </TabsTrigger> | |
| 742 | 1443 | <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> |
| 743 | 1444 | Inventory |
| 744 | 1445 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| @@ -811,6 +1512,14 @@ export function ToolsView() { · 66 unmodified lines | ||
| 811 | 1512 | </Card> |
| 812 | 1513 | </TabsContent> |
| 813 | 1514 | |
| 1515 | + <TabsContent value="credentials" className="mt-4"> | |
| 1516 | + <CredentialsPanel | |
| 1517 | + sources={sources ?? []} | |
| 1518 | + credentials={credentials ?? []} | |
| 1519 | + loading={credentialsLoading || sourcesLoading} | |
| 1520 | + /> | |
| 1521 | + </TabsContent> | |
| 1522 | + | |
| 814 | 1523 | <TabsContent value="inventory" className="mt-4"> |
| 815 | 1524 | <Card className="bg-card border-border"> |
| 816 | 1525 | <CardHeader className="pb-3"> |
| @@ -4,6 +4,7 @@ export type { · 3 unmodified lines | ||
| 4 | 4 | ApprovalStatus, |
| 5 | 5 | PolicyDecision, |
| 6 | 6 | CredentialScope, |
| 7 | + CredentialProvider, | |
| 7 | 8 | ToolApprovalMode, |
| 8 | 9 | ToolSourceType, |
| 9 | 10 | TaskRecord, |
| @@ -13,12 +13,14 @@ import type * as auth from "../auth.js"; · 12 unmodified lines | ||
| 13 | 13 | import type * as billing from "../billing.js"; |
| 14 | 14 | import type * as billingInternal from "../billingInternal.js"; |
| 15 | 15 | import type * as billingSync from "../billingSync.js"; |
| 16 | +import type * as credentialsNode from "../credentialsNode.js"; | |
| 16 | 17 | import type * as database from "../database.js"; |
| 17 | 18 | import type * as executor from "../executor.js"; |
| 18 | 19 | import type * as executorNode from "../executorNode.js"; |
| 19 | 20 | import type * as http from "../http.js"; |
| 20 | 21 | import type * as invites from "../invites.js"; |
| 21 | 22 | import type * as lib_adapters_in_process_execution_adapter from "../lib/adapters/in_process_execution_adapter.js"; |
| 23 | +import type * as lib_credential_providers from "../lib/credential_providers.js"; | |
| 22 | 24 | import type * as lib_events from "../lib/events.js"; |
| 23 | 25 | import type * as lib_execution_constants from "../lib/execution_constants.js"; |
| 24 | 26 | import type * as lib_functionBuilders from "../lib/functionBuilders.js"; |
| @@ -32,6 +34,7 @@ import type * as lib_tools from "../lib/tools.js"; · 7 unmodified lines | ||
| 32 | 34 | import type * as lib_typechecker from "../lib/typechecker.js"; |
| 33 | 35 | import type * as lib_types from "../lib/types.js"; |
| 34 | 36 | import type * as lib_utils from "../lib/utils.js"; |
| 37 | +import type * as openApiSpecCache from "../openApiSpecCache.js"; | |
| 35 | 38 | import type * as organizationMembers from "../organizationMembers.js"; |
| 36 | 39 | import type * as organizations from "../organizations.js"; |
| 37 | 40 | import type * as workspaces from "../workspaces.js"; |
| @@ -48,12 +51,14 @@ declare const fullApi: ApiFromModules<{ · 10 unmodified lines | ||
| 48 | 51 | billing: typeof billing; |
| 49 | 52 | billingInternal: typeof billingInternal; |
| 50 | 53 | billingSync: typeof billingSync; |
| 54 | + credentialsNode: typeof credentialsNode; | |
| 51 | 55 | database: typeof database; |
| 52 | 56 | executor: typeof executor; |
| 53 | 57 | executorNode: typeof executorNode; |
| 54 | 58 | http: typeof http; |
| 55 | 59 | invites: typeof invites; |
| 56 | 60 | "lib/adapters/in_process_execution_adapter": typeof lib_adapters_in_process_execution_adapter; |
| 61 | + "lib/credential_providers": typeof lib_credential_providers; | |
| 57 | 62 | "lib/events": typeof lib_events; |
| 58 | 63 | "lib/execution_constants": typeof lib_execution_constants; |
| 59 | 64 | "lib/functionBuilders": typeof lib_functionBuilders; |
| @@ -67,6 +72,7 @@ declare const fullApi: ApiFromModules<{ · 7 unmodified lines | ||
| 67 | 72 | "lib/typechecker": typeof lib_typechecker; |
| 68 | 73 | "lib/types": typeof lib_types; |
| 69 | 74 | "lib/utils": typeof lib_utils; |
| 75 | + openApiSpecCache: typeof openApiSpecCache; | |
| 70 | 76 | organizationMembers: typeof organizationMembers; |
| 71 | 77 | organizations: typeof organizations; |
| 72 | 78 | workspaces: typeof workspaces; |
| @@ -0,0 +1,212 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import { WorkOS } from "@workos-inc/node"; | |
| 4 | +import { v } from "convex/values"; | |
| 5 | +import { api } from "./_generated/api"; | |
| 6 | +import { action } from "./_generated/server"; | |
| 7 | + | |
| 8 | +const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | |
| 9 | +const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault")); | |
| 10 | + | |
| 11 | +function asRecord(value: unknown): Record<string, unknown> { | |
| 12 | + if (value && typeof value === "object" && !Array.isArray(value)) { | |
| 13 | + return value as Record<string, unknown>; | |
| 14 | + } | |
| 15 | + return {}; | |
| 16 | +} | |
| 17 | + | |
| 18 | +function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string { | |
| 19 | + if (scope !== "actor") return ""; | |
| 20 | + if (typeof actorId !== "string") return ""; | |
| 21 | + return actorId.trim(); | |
| 22 | +} | |
| 23 | + | |
| 24 | +function extractObjectId(secretJson: Record<string, unknown>): string | null { | |
| 25 | + const candidate = | |
| 26 | + (typeof secretJson.objectId === "string" ? secretJson.objectId : "") || | |
| 27 | + (typeof secretJson.id === "string" ? secretJson.id : ""); | |
| 28 | + const trimmed = candidate.trim(); | |
| 29 | + return trimmed.length > 0 ? trimmed : null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function stripObjectRef(secretJson: Record<string, unknown>): Record<string, unknown> { | |
| 33 | + const { objectId: _objectId, id: _id, ...rest } = secretJson; | |
| 34 | + return asRecord(rest); | |
| 35 | +} | |
| 36 | + | |
| 37 | +function hasPayload(secretJson: Record<string, unknown>): boolean { | |
| 38 | + return Object.keys(stripObjectRef(secretJson)).length > 0; | |
| 39 | +} | |
| 40 | + | |
| 41 | +function buildVaultObjectName(args: { | |
| 42 | + workspaceId: string; | |
| 43 | + sourceKey: string; | |
| 44 | + scope: "workspace" | "actor"; | |
| 45 | + actorId: string; | |
| 46 | +}): string { | |
| 47 | + const actorSegment = args.scope === "actor" ? args.actorId || "actor" : "workspace"; | |
| 48 | + const sourceSegment = args.sourceKey | |
| 49 | + .replace(/[^a-zA-Z0-9_-]+/g, "-") | |
| 50 | + .replace(/^-+|-+$/g, "") | |
| 51 | + .slice(0, 64); | |
| 52 | + | |
| 53 | + return `executor-cred-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function workosClient(): WorkOS { | |
| 57 | + const key = process.env.WORKOS_API_KEY?.trim(); | |
| 58 | + if (!key) { | |
| 59 | + throw new Error("Encrypted storage requires WORKOS_API_KEY"); | |
| 60 | + } | |
| 61 | + return new WorkOS(key); | |
| 62 | +} | |
| 63 | + | |
| 64 | +function isRetryableVaultError(error: unknown): boolean { | |
| 65 | + if (!(error instanceof Error)) { | |
| 66 | + return false; | |
| 67 | + } | |
| 68 | + | |
| 69 | + const message = error.message.toLowerCase(); | |
| 70 | + return ( | |
| 71 | + message.includes("not yet ready") || | |
| 72 | + message.includes("can be retried") || | |
| 73 | + (message.includes("kek") && message.includes("ready")) | |
| 74 | + ); | |
| 75 | +} | |
| 76 | + | |
| 77 | +async function sleep(ms: number): Promise<void> { | |
| 78 | + await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 79 | +} | |
| 80 | + | |
| 81 | +async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> { | |
| 82 | + const maxAttempts = 10; | |
| 83 | + const maxDelayMs = 10_000; | |
| 84 | + let delayMs = 500; | |
| 85 | + | |
| 86 | + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | |
| 87 | + try { | |
| 88 | + return await operation(); | |
| 89 | + } catch (error) { | |
| 90 | + if (!isRetryableVaultError(error)) { | |
| 91 | + throw error; | |
| 92 | + } | |
| 93 | + if (attempt === maxAttempts) { | |
| 94 | + throw new Error( | |
| 95 | + "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.", | |
| 96 | + ); | |
| 97 | + } | |
| 98 | + await sleep(delayMs); | |
| 99 | + delayMs = Math.min(delayMs * 2, maxDelayMs); | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + throw new Error("Unreachable retry state"); | |
| 104 | +} | |
| 105 | + | |
| 106 | +async function upsertVaultObject(args: { | |
| 107 | + workspaceId: string; | |
| 108 | + sourceKey: string; | |
| 109 | + scope: "workspace" | "actor"; | |
| 110 | + actorId: string; | |
| 111 | + existingObjectId: string | null; | |
| 112 | + payload: Record<string, unknown>; | |
| 113 | +}): Promise<string> { | |
| 114 | + const workos = workosClient(); | |
| 115 | + const value = JSON.stringify(args.payload); | |
| 116 | + | |
| 117 | + if (args.existingObjectId) { | |
| 118 | + const updated = await withVaultRetry(async () => { | |
| 119 | + return await workos.vault.updateObject({ | |
| 120 | + id: args.existingObjectId, | |
| 121 | + value, | |
| 122 | + }); | |
| 123 | + }); | |
| 124 | + return updated.id; | |
| 125 | + } | |
| 126 | + | |
| 127 | + const created = await withVaultRetry(async () => { | |
| 128 | + return await workos.vault.createObject({ | |
| 129 | + name: buildVaultObjectName(args), | |
| 130 | + value, | |
| 131 | + context: { | |
| 132 | + workspace_id: args.workspaceId, | |
| 133 | + }, | |
| 134 | + }); | |
| 135 | + }); | |
| 136 | + | |
| 137 | + return created.id; | |
| 138 | +} | |
| 139 | + | |
| 140 | +export const upsertCredential = action({ | |
| 141 | + args: { | |
| 142 | + id: v.optional(v.string()), | |
| 143 | + workspaceId: v.string(), | |
| 144 | + sourceKey: v.string(), | |
| 145 | + scope: credentialScopeValidator, | |
| 146 | + actorId: v.optional(v.string()), | |
| 147 | + provider: v.optional(credentialProviderValidator), | |
| 148 | + secretJson: v.any(), | |
| 149 | + }, | |
| 150 | + handler: async (ctx, args) => { | |
| 151 | + const provider = args.provider ?? "managed"; | |
| 152 | + const actorId = normalizedActorId(args.scope, args.actorId); | |
| 153 | + const submittedSecret = asRecord(args.secretJson); | |
| 154 | + | |
| 155 | + if (provider === "managed") { | |
| 156 | + return await ctx.runMutation(api.database.upsertCredential, { | |
| 157 | + ...args, | |
| 158 | + actorId, | |
| 159 | + provider, | |
| 160 | + secretJson: submittedSecret, | |
| 161 | + }); | |
| 162 | + } | |
| 163 | + | |
| 164 | + const submittedObjectId = extractObjectId(submittedSecret); | |
| 165 | + if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) { | |
| 166 | + throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field."); | |
| 167 | + } | |
| 168 | + | |
| 169 | + const existing = await ctx.runQuery(api.database.resolveCredential, { | |
| 170 | + workspaceId: args.workspaceId, | |
| 171 | + sourceKey: args.sourceKey, | |
| 172 | + scope: args.scope, | |
| 173 | + ...(args.scope === "actor" ? { actorId } : {}), | |
| 174 | + }); | |
| 175 | + const existingObjectId = existing ? extractObjectId(asRecord(existing.secretJson)) : null; | |
| 176 | + | |
| 177 | + const objectId = (() => { | |
| 178 | + if (submittedObjectId) return submittedObjectId; | |
| 179 | + return null; | |
| 180 | + })(); | |
| 181 | + | |
| 182 | + let finalObjectId = objectId; | |
| 183 | + if (!finalObjectId && hasPayload(submittedSecret)) { | |
| 184 | + finalObjectId = await upsertVaultObject({ | |
| 185 | + workspaceId: args.workspaceId, | |
| 186 | + sourceKey: args.sourceKey, | |
| 187 | + scope: args.scope, | |
| 188 | + actorId, | |
| 189 | + existingObjectId, | |
| 190 | + payload: stripObjectRef(submittedSecret), | |
| 191 | + }); | |
| 192 | + } | |
| 193 | + | |
| 194 | + if (!finalObjectId && existingObjectId) { | |
| 195 | + finalObjectId = existingObjectId; | |
| 196 | + } | |
| 197 | + | |
| 198 | + if (!finalObjectId) { | |
| 199 | + throw new Error("Encrypted storage requires credential values"); | |
| 200 | + } | |
| 201 | + | |
| 202 | + return await ctx.runMutation(api.database.upsertCredential, { | |
| 203 | + id: args.id, | |
| 204 | + workspaceId: args.workspaceId, | |
| 205 | + sourceKey: args.sourceKey, | |
| 206 | + scope: args.scope, | |
| 207 | + ...(args.scope === "actor" ? { actorId } : {}), | |
| 208 | + provider, | |
| 209 | + secretJson: { objectId: finalObjectId }, | |
| 210 | + }); | |
| 211 | + }, | |
| 212 | +}); | |
| @@ -120,3 +120,57 @@ test("bootstrap honors caller-provided session id", async () => { · 119 unmodified lines | ||
| 120 | 120 | expect(again.workspaceId).toBe(seeded.workspaceId); |
| 121 | 121 | expect(again.actorId).toBe(seeded.actorId); |
| 122 | 122 | }); |
| 123 | + | |
| 124 | +test("credentials persist provider and resolve by scope", async () => { | |
| 125 | + const t = setup(); | |
| 126 | + | |
| 127 | + const workspaceCredential = await t.mutation(api.database.upsertCredential, { | |
| 128 | + workspaceId: "ws_cred", | |
| 129 | + sourceKey: "openapi:github", | |
| 130 | + scope: "workspace", | |
| 131 | + provider: "managed", | |
| 132 | + secretJson: { token: "workspace-token" }, | |
| 133 | + }); | |
| 134 | + | |
| 135 | + expect(workspaceCredential.provider).toBe("managed"); | |
| 136 | + | |
| 137 | + const actorCredential = await t.mutation(api.database.upsertCredential, { | |
| 138 | + workspaceId: "ws_cred", | |
| 139 | + sourceKey: "openapi:github", | |
| 140 | + scope: "actor", | |
| 141 | + actorId: "actor_cred", | |
| 142 | + provider: "workos-vault", | |
| 143 | + secretJson: { objectId: "secret_actor_github" }, | |
| 144 | + }); | |
| 145 | + | |
| 146 | + expect(actorCredential.provider).toBe("workos-vault"); | |
| 147 | + expect(actorCredential.actorId).toBe("actor_cred"); | |
| 148 | + | |
| 149 | + const resolvedWorkspace = await t.query(api.database.resolveCredential, { | |
| 150 | + workspaceId: "ws_cred", | |
| 151 | + sourceKey: "openapi:github", | |
| 152 | + scope: "workspace", | |
| 153 | + }); | |
| 154 | + expect(resolvedWorkspace?.provider).toBe("managed"); | |
| 155 | + | |
| 156 | + const resolvedActor = await t.query(api.database.resolveCredential, { | |
| 157 | + workspaceId: "ws_cred", | |
| 158 | + sourceKey: "openapi:github", | |
| 159 | + scope: "actor", | |
| 160 | + actorId: "actor_cred", | |
| 161 | + }); | |
| 162 | + expect(resolvedActor?.provider).toBe("workos-vault"); | |
| 163 | +}); | |
| 164 | + | |
| 165 | +test("upsertCredential defaults provider to managed", async () => { | |
| 166 | + const t = setup(); | |
| 167 | + | |
| 168 | + const credential = await t.mutation(api.database.upsertCredential, { | |
| 169 | + workspaceId: "ws_default_provider", | |
| 170 | + sourceKey: "openapi:stripe", | |
| 171 | + scope: "workspace", | |
| 172 | + secretJson: { token: "sk_test_123" }, | |
| 173 | + }); | |
| 174 | + | |
| 175 | + expect(credential.provider).toBe("managed"); | |
| 176 | +}); | |
| @@ -17,6 +17,10 @@ const completedTaskStatusValidator = v.union( · 16 unmodified lines | ||
| 17 | 17 | const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); |
| 18 | 18 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 19 | 19 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 20 | +const credentialProviderValidator = v.union( | |
| 21 | + v.literal("managed"), | |
| 22 | + v.literal("workos-vault"), | |
| 23 | +); | |
| 20 | 24 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 21 | 25 | const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed")); |
| 22 | 26 | |
| @@ -159,6 +163,7 @@ function mapCredential(doc: Doc<"sourceCredentials">) { · 136 unmodified lines | ||
| 159 | 163 | sourceKey: doc.sourceKey, |
| 160 | 164 | scope: doc.scope, |
| 161 | 165 | actorId: optionalFromNormalized(doc.actorId), |
| 166 | + provider: doc.provider ?? "managed", | |
| 162 | 167 | secretJson: asRecord(doc.secretJson), |
| 163 | 168 | createdAt: doc.createdAt, |
| 164 | 169 | updatedAt: doc.updatedAt, |
| @@ -856,6 +861,7 @@ export const upsertCredential = mutation({ · 691 unmodified lines | ||
| 856 | 861 | sourceKey: v.string(), |
| 857 | 862 | scope: credentialScopeValidator, |
| 858 | 863 | actorId: v.optional(v.string()), |
| 864 | + provider: v.optional(credentialProviderValidator), | |
| 859 | 865 | secretJson: v.any(), |
| 860 | 866 | }, |
| 861 | 867 | handler: async (ctx, args) => { |
| @@ -873,8 +879,11 @@ export const upsertCredential = mutation({ · 11 unmodified lines | ||
| 873 | 879 | ) |
| 874 | 880 | .unique(); |
| 875 | 881 | |
| 882 | + const provider = args.provider ?? existing?.provider ?? "managed"; | |
| 883 | + | |
| 876 | 884 | if (existing) { |
| 877 | 885 | await ctx.db.patch(existing._id, { |
| 886 | + provider, | |
| 878 | 887 | secretJson: asRecord(args.secretJson), |
| 879 | 888 | updatedAt: now, |
| 880 | 889 | }); |
| @@ -885,6 +894,7 @@ export const upsertCredential = mutation({ · 4 unmodified lines | ||
| 885 | 894 | sourceKey: args.sourceKey, |
| 886 | 895 | scope: args.scope, |
| 887 | 896 | actorId, |
| 897 | + provider, | |
| 888 | 898 | secretJson: asRecord(args.secretJson), |
| 889 | 899 | createdAt: now, |
| 890 | 900 | updatedAt: now, |
| @@ -922,6 +932,24 @@ export const listCredentials = query({ · 31 unmodified lines | ||
| 922 | 932 | }, |
| 923 | 933 | }); |
| 924 | 934 | |
| 935 | +export const listCredentialProviders = query({ | |
| 936 | + args: {}, | |
| 937 | + handler: async () => { | |
| 938 | + return [ | |
| 939 | + { | |
| 940 | + id: "managed", | |
| 941 | + label: "Managed", | |
| 942 | + description: "Store credential payload in Executor's sourceCredentials table.", | |
| 943 | + }, | |
| 944 | + { | |
| 945 | + id: "workos-vault", | |
| 946 | + label: "Encrypted", | |
| 947 | + description: "Store credential payload in encrypted external storage.", | |
| 948 | + }, | |
| 949 | + ] as const; | |
| 950 | + }, | |
| 951 | +}); | |
| 952 | + | |
| 925 | 953 | export const resolveCredential = query({ |
| 926 | 954 | args: { |
| 927 | 955 | workspaceId: v.string(), |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | 3 | import { v } from "convex/values"; |
| 4 | −import { ActionCache } from "@convex-dev/action-cache"; | |
| 5 | −import { api, components, internal } from "./_generated/api"; | |
| 4 | +import { api, internal } from "./_generated/api"; | |
| 6 | 5 | import { action, internalAction } from "./_generated/server"; |
| 7 | 6 | import { InProcessExecutionAdapter } from "./lib/adapters/in_process_execution_adapter"; |
| 7 | +import { resolveCredentialPayload } from "./lib/credential_providers"; | |
| 8 | 8 | import { APPROVAL_DENIED_PREFIX } from "./lib/execution_constants"; |
| 9 | 9 | import { runCodeWithAdapter } from "./lib/runtimes/runtime_core"; |
| 10 | 10 | import { createDiscoverTool } from "./lib/tool_discovery"; |
| @@ -135,23 +135,8 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 124 unmodified lines | ||
| 135 | 135 | const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 136 | 136 | const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000; |
| 137 | 137 | |
| 138 | −const actionCacheComponent = components as unknown as { actionCache: unknown }; | |
| 139 | −const openApiSpecCache = new ActionCache(actionCacheComponent.actionCache as never, { | |
| 140 | − action: internal.executorNode.prepareOpenApiSpecForCache, | |
| 141 | − name: "openapi-spec-transform-v1", | |
| 142 | − ttl: OPENAPI_SPEC_CACHE_TTL_MS, | |
| 143 | −}); | |
| 144 | −const MAX_ACTION_CACHE_VALUE_BYTES = 900_000; | |
| 145 | −const OVERSIZED_OPENAPI_LOCAL_CACHE_TTL_MS = 5 * 60 * 60_000; | |
| 146 | −const oversizedOpenApiSpecCache = new Map< | |
| 147 | − string, | |
| 148 | − { loadedAt: number; prepared: PreparedOpenApiSpec } | |
| 149 | −>(); | |
| 150 | − | |
| 151 | −interface PreparedOpenApiSpecCacheValue { | |
| 152 | − preparedJson: string; | |
| 153 | − oversized?: boolean; | |
| 154 | −} | |
| 138 | +/** Cache version — bump when PreparedOpenApiSpec shape changes. */ | |
| 139 | +const OPENAPI_CACHE_VERSION = "v2"; | |
| 155 | 140 | |
| 156 | 141 | function isWorkspaceToolCacheFresh( |
| 157 | 142 | cached: { signature: string; loadedAt: number; warnings: string[] }, |
| @@ -200,92 +185,78 @@ async function waitForApproval(ctx: any, approvalId: string): Promise<"approved" · 42 unmodified lines | ||
| 200 | 185 | } |
| 201 | 186 | } |
| 202 | 187 | |
| 203 | −export const prepareOpenApiSpecForCache = internalAction({ | |
| 204 | − args: { | |
| 205 | − specUrl: v.string(), | |
| 206 | − }, | |
| 207 | − handler: async (_ctx, args): Promise<PreparedOpenApiSpecCacheValue> => { | |
| 208 | − const prepared = await prepareOpenApiSpec(args.specUrl, args.specUrl); | |
| 209 | − // Action cache values must be valid Convex values. Raw OpenAPI objects can | |
| 210 | − // contain keys like "$ref", which Convex object fields disallow, so we | |
| 211 | − // store the prepared payload as a JSON string. | |
| 212 | − let preparedJson = JSON.stringify(prepared); | |
| 213 | − | |
| 214 | − if (preparedJson.length > MAX_ACTION_CACHE_VALUE_BYTES) { | |
| 215 | − const reduced: PreparedOpenApiSpec = { | |
| 216 | − servers: prepared.servers, | |
| 217 | − paths: prepared.paths, | |
| 218 | − warnings: [ | |
| 219 | − ...(prepared.warnings ?? []), | |
| 220 | − "OpenAPI type metadata omitted due cache size limits.", | |
| 221 | − ], | |
| 222 | − }; | |
| 223 | − preparedJson = JSON.stringify(reduced); | |
| 224 | − } | |
| 188 | +/** | |
| 189 | + * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache. | |
| 190 | + * | |
| 191 | + * Flow: | |
| 192 | + * 1. Check the `openApiSpecCache` table for a valid entry → fetch blob from storage. | |
| 193 | + * 2. On miss: prepare the spec from scratch, store the JSON blob, write the metadata row. | |
| 194 | + * | |
| 195 | + * No size limits — Convex file storage handles multi-MB specs without issue. | |
| 196 | + */ | |
| 197 | +async function loadCachedOpenApiSpec( | |
| 198 | + ctx: any, | |
| 199 | + specUrl: string, | |
| 200 | + sourceName: string, | |
| 201 | +): Promise<PreparedOpenApiSpec> { | |
| 202 | + // 1. Persistent cache (Convex table + file storage) | |
| 203 | + try { | |
| 204 | + const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { | |
| 205 | + specUrl, | |
| 206 | + version: OPENAPI_CACHE_VERSION, | |
| 207 | + maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, | |
| 208 | + }); | |
| 225 | 209 | |
| 226 | − if (preparedJson.length > MAX_ACTION_CACHE_VALUE_BYTES) { | |
| 227 | − return { preparedJson: "", oversized: true }; | |
| 210 | + if (entry) { | |
| 211 | + const blob = await ctx.storage.get(entry.storageId); | |
| 212 | + if (blob) { | |
| 213 | + const json = await blob.text(); | |
| 214 | + return JSON.parse(json) as PreparedOpenApiSpec; | |
| 215 | + } | |
| 216 | + // Blob missing (deleted externally?) — fall through to re-prepare | |
| 228 | 217 | } |
| 218 | + } catch (error) { | |
| 219 | + // Cache read failed — log and fall through to fresh preparation | |
| 220 | + const message = error instanceof Error ? error.message : String(error); | |
| 221 | + console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); | |
| 222 | + } | |
| 229 | 223 | |
| 230 | − return { preparedJson }; | |
| 231 | − }, | |
| 232 | −}); | |
| 224 | + // 2. Cache miss — prepare from scratch | |
| 225 | + const prepared = await prepareOpenApiSpec(specUrl, sourceName); | |
| 226 | + | |
| 227 | + // Store in file storage + metadata table (best-effort, don't block on failure) | |
| 228 | + try { | |
| 229 | + const json = JSON.stringify(prepared); | |
| 230 | + const blob = new Blob([json], { type: "application/json" }); | |
| 231 | + const storageId = await ctx.storage.store(blob); | |
| 232 | + await ctx.runMutation(internal.openApiSpecCache.putEntry, { | |
| 233 | + specUrl, | |
| 234 | + version: OPENAPI_CACHE_VERSION, | |
| 235 | + storageId, | |
| 236 | + sizeBytes: json.length, | |
| 237 | + }); | |
| 238 | + } catch (error) { | |
| 239 | + const message = error instanceof Error ? error.message : String(error); | |
| 240 | + console.warn(`[executor] OpenAPI cache write failed for '${sourceName}': ${message}`); | |
| 241 | + } | |
| 242 | + | |
| 243 | + return prepared; | |
| 244 | +} | |
| 233 | 245 | |
| 234 | 246 | async function loadSourceTools( |
| 235 | 247 | ctx: any, |
| 236 | 248 | source: ExternalToolSourceConfig, |
| 237 | 249 | ): Promise<{ tools: ToolDefinition[]; warnings: string[] }> { |
| 238 | − const loadOpenApiDirect = async (extraWarnings: string[] = []) => { | |
| 239 | − const specUrl = source.spec; | |
| 240 | − const now = Date.now(); | |
| 241 | − const cached = oversizedOpenApiSpecCache.get(specUrl); | |
| 242 | − const prepared = (cached && (now - cached.loadedAt) < OVERSIZED_OPENAPI_LOCAL_CACHE_TTL_MS) | |
| 243 | − ? cached.prepared | |
| 244 | − : await prepareOpenApiSpec(specUrl, source.name); | |
| 245 | − | |
| 246 | − if (!cached || (now - cached.loadedAt) >= OVERSIZED_OPENAPI_LOCAL_CACHE_TTL_MS) { | |
| 247 | − oversizedOpenApiSpecCache.set(specUrl, { loadedAt: now, prepared }); | |
| 248 | − } | |
| 249 | − | |
| 250 | − const tools = buildOpenApiToolsFromPrepared(source, prepared); | |
| 251 | − return { | |
| 252 | − tools, | |
| 253 | − warnings: [ | |
| 254 | − ...extraWarnings, | |
| 255 | − ...(prepared.warnings ?? []).map((warning) => `Source '${source.name}': ${warning}`), | |
| 256 | − ], | |
| 257 | − }; | |
| 258 | − }; | |
| 259 | − | |
| 260 | 250 | if (source.type === "openapi" && typeof source.spec === "string") { |
| 261 | 251 | try { |
| 262 | − const cached = await openApiSpecCache.fetch(ctx, { specUrl: source.spec }) as PreparedOpenApiSpecCacheValue; | |
| 263 | − if (cached.oversized || !cached.preparedJson) { | |
| 264 | − return await loadOpenApiDirect([ | |
| 265 | − `Source '${source.name}': OpenAPI metadata is too large for global cache; using direct load.`, | |
| 266 | − ]); | |
| 267 | − } | |
| 268 | − const prepared = JSON.parse(cached.preparedJson) as PreparedOpenApiSpec; | |
| 252 | + const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name); | |
| 269 | 253 | const tools = buildOpenApiToolsFromPrepared(source, prepared); |
| 270 | − const warnings = (prepared.warnings ?? []).map((warning) => `Source '${source.name}': ${warning}`); | |
| 254 | + const warnings = (prepared.warnings ?? []).map( | |
| 255 | + (warning) => `Source '${source.name}': ${warning}`, | |
| 256 | + ); | |
| 271 | 257 | return { tools, warnings }; |
| 272 | 258 | } catch (error) { |
| 273 | 259 | const message = error instanceof Error ? error.message : String(error); |
| 274 | − | |
| 275 | − if (/Value is too large/i.test(message)) { | |
| 276 | − try { | |
| 277 | − return await loadOpenApiDirect([ | |
| 278 | − `Source '${source.name}': OpenAPI metadata exceeded global cache limits; using direct load.`, | |
| 279 | − ]); | |
| 280 | − } catch (fallbackError) { | |
| 281 | − const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); | |
| 282 | − return { | |
| 283 | − tools: [], | |
| 284 | − warnings: [`Failed to load openapi source '${source.name}': ${fallbackMessage}`], | |
| 285 | − }; | |
| 286 | − } | |
| 287 | − } | |
| 288 | − | |
| 289 | 260 | return { |
| 290 | 261 | tools: [], |
| 291 | 262 | warnings: [`Failed to load openapi source '${source.name}': ${message}`], |
| @@ -449,7 +420,9 @@ async function resolveCredentialHeaders( · 157 unmodified lines | ||
| 449 | 420 | actorId: task.actorId, |
| 450 | 421 | }); |
| 451 | 422 | |
| 452 | − const source = record?.secretJson ?? spec.staticSecretJson ?? null; | |
| 423 | + const source = record | |
| 424 | + ? await resolveCredentialPayload(record) | |
| 425 | + : spec.staticSecretJson ?? null; | |
| 453 | 426 | if (!source) { |
| 454 | 427 | return null; |
| 455 | 428 | } |
| @@ -0,0 +1,65 @@ | ||
| 1 | +import { expect, test } from "bun:test"; | |
| 2 | +import { resolveCredentialPayload } from "./credential_providers"; | |
| 3 | + | |
| 4 | +test("managed provider returns stored payload", async () => { | |
| 5 | + const payload = await resolveCredentialPayload({ | |
| 6 | + provider: "managed", | |
| 7 | + secretJson: { token: "managed-token" }, | |
| 8 | + }); | |
| 9 | + | |
| 10 | + expect(payload).toEqual({ token: "managed-token" }); | |
| 11 | +}); | |
| 12 | + | |
| 13 | +test("WorkOS Vault provider reads object and parses JSON", async () => { | |
| 14 | + let capturedObjectId = ""; | |
| 15 | + const payload = await resolveCredentialPayload( | |
| 16 | + { | |
| 17 | + provider: "workos-vault", | |
| 18 | + secretJson: { objectId: "secret_123" }, | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + readVaultObject: async ({ objectId }) => { | |
| 22 | + capturedObjectId = objectId; | |
| 23 | + return '{"token":"vault-token","headerName":"x-api-key"}'; | |
| 24 | + }, | |
| 25 | + }, | |
| 26 | + ); | |
| 27 | + | |
| 28 | + expect(capturedObjectId).toBe("secret_123"); | |
| 29 | + expect(payload).toEqual({ token: "vault-token", headerName: "x-api-key" }); | |
| 30 | +}); | |
| 31 | + | |
| 32 | +test("WorkOS Vault provider falls back to id key and raw token", async () => { | |
| 33 | + const payload = await resolveCredentialPayload( | |
| 34 | + { | |
| 35 | + provider: "workos-vault", | |
| 36 | + secretJson: { id: "secret_456" }, | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + readVaultObject: async ({ objectId }) => { | |
| 40 | + expect(objectId).toBe("secret_456"); | |
| 41 | + return "ghp_raw_token"; | |
| 42 | + }, | |
| 43 | + }, | |
| 44 | + ); | |
| 45 | + | |
| 46 | + expect(payload).toEqual({ token: "ghp_raw_token" }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +test("WorkOS Vault provider supports legacy inline payload", async () => { | |
| 50 | + const payload = await resolveCredentialPayload({ | |
| 51 | + provider: "workos-vault", | |
| 52 | + secretJson: { token: "legacy-token" }, | |
| 53 | + }); | |
| 54 | + | |
| 55 | + expect(payload).toEqual({ token: "legacy-token" }); | |
| 56 | +}); | |
| 57 | + | |
| 58 | +test("WorkOS Vault provider gives actionable error on missing reference", async () => { | |
| 59 | + await expect( | |
| 60 | + resolveCredentialPayload({ | |
| 61 | + provider: "workos-vault", | |
| 62 | + secretJson: {}, | |
| 63 | + }), | |
| 64 | + ).rejects.toThrow("Re-save this credential"); | |
| 65 | +}); | |
| @@ -0,0 +1,161 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import { WorkOS } from "@workos-inc/node"; | |
| 4 | +import type { CredentialProvider, CredentialRecord } from "./types"; | |
| 5 | +import { asRecord } from "./utils"; | |
| 6 | + | |
| 7 | +type CredentialPayload = Record<string, unknown>; | |
| 8 | + | |
| 9 | +interface VaultReadInput { | |
| 10 | + objectId: string; | |
| 11 | + apiKey?: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +type VaultObjectReader = (input: VaultReadInput) => Promise<string>; | |
| 15 | + | |
| 16 | +type ProviderResolver = ( | |
| 17 | + record: Pick<CredentialRecord, "provider" | "secretJson">, | |
| 18 | + readVaultObject: VaultObjectReader, | |
| 19 | +) => Promise<CredentialPayload | null>; | |
| 20 | + | |
| 21 | +function resolveWorkosApiKey(explicitApiKey?: string): string { | |
| 22 | + const candidate = | |
| 23 | + explicitApiKey?.trim() || | |
| 24 | + process.env.WORKOS_API_KEY?.trim() || | |
| 25 | + ""; | |
| 26 | + | |
| 27 | + if (!candidate) { | |
| 28 | + throw new Error("WorkOS Vault provider requires WORKOS_API_KEY"); | |
| 29 | + } | |
| 30 | + | |
| 31 | + return candidate; | |
| 32 | +} | |
| 33 | + | |
| 34 | +function isRetryableVaultError(error: unknown): boolean { | |
| 35 | + if (!(error instanceof Error)) { | |
| 36 | + return false; | |
| 37 | + } | |
| 38 | + const message = error.message.toLowerCase(); | |
| 39 | + return ( | |
| 40 | + message.includes("not yet ready") || | |
| 41 | + message.includes("can be retried") || | |
| 42 | + (message.includes("kek") && message.includes("ready")) | |
| 43 | + ); | |
| 44 | +} | |
| 45 | + | |
| 46 | +async function sleep(ms: number): Promise<void> { | |
| 47 | + await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 48 | +} | |
| 49 | + | |
| 50 | +async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> { | |
| 51 | + const maxAttempts = 5; | |
| 52 | + let delayMs = 250; | |
| 53 | + | |
| 54 | + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | |
| 55 | + try { | |
| 56 | + return await operation(); | |
| 57 | + } catch (error) { | |
| 58 | + if (!isRetryableVaultError(error) || attempt === maxAttempts) { | |
| 59 | + throw error; | |
| 60 | + } | |
| 61 | + await sleep(delayMs); | |
| 62 | + delayMs *= 2; | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + throw new Error("Unreachable retry state"); | |
| 67 | +} | |
| 68 | + | |
| 69 | +async function defaultReadVaultObject(input: VaultReadInput): Promise<string> { | |
| 70 | + const workos = new WorkOS(resolveWorkosApiKey(input.apiKey)); | |
| 71 | + const object = await withVaultRetry(async () => { | |
| 72 | + return await workos.vault.readObject({ id: input.objectId }); | |
| 73 | + }); | |
| 74 | + const value = typeof object.value === "string" ? object.value : ""; | |
| 75 | + if (!value.trim()) { | |
| 76 | + throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`); | |
| 77 | + } | |
| 78 | + return value; | |
| 79 | +} | |
| 80 | + | |
| 81 | +function parseSecretValue(raw: string): CredentialPayload { | |
| 82 | + const value = raw.trim(); | |
| 83 | + if (!value) { | |
| 84 | + return {}; | |
| 85 | + } | |
| 86 | + | |
| 87 | + try { | |
| 88 | + const parsed = JSON.parse(value); | |
| 89 | + const asObject = asRecord(parsed); | |
| 90 | + if (Object.keys(asObject).length > 0) { | |
| 91 | + return asObject; | |
| 92 | + } | |
| 93 | + } catch { | |
| 94 | + // not JSON; fall through | |
| 95 | + } | |
| 96 | + | |
| 97 | + return { token: value }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +function requireString(config: Record<string, unknown>, key: string, providerName: string): string { | |
| 101 | + const value = config[key]; | |
| 102 | + if (typeof value !== "string" || value.trim().length === 0) { | |
| 103 | + throw new Error(`Credential provider '${providerName}' requires secretJson.${key}`); | |
| 104 | + } | |
| 105 | + return value.trim(); | |
| 106 | +} | |
| 107 | + | |
| 108 | +async function resolveManaged( | |
| 109 | + record: Pick<CredentialRecord, "secretJson">, | |
| 110 | +): Promise<CredentialPayload | null> { | |
| 111 | + return asRecord(record.secretJson); | |
| 112 | +} | |
| 113 | + | |
| 114 | +async function resolveWorkosVault( | |
| 115 | + record: Pick<CredentialRecord, "secretJson">, | |
| 116 | + readVaultObject: VaultObjectReader, | |
| 117 | +): Promise<CredentialPayload | null> { | |
| 118 | + const config = asRecord(record.secretJson); | |
| 119 | + const objectId = | |
| 120 | + typeof config.objectId === "string" && config.objectId.trim() | |
| 121 | + ? config.objectId.trim() | |
| 122 | + : typeof config.id === "string" && config.id.trim() | |
| 123 | + ? config.id.trim() | |
| 124 | + : ""; | |
| 125 | + | |
| 126 | + if (!objectId) { | |
| 127 | + // Backward compatibility: older rows may have stored credential payload directly | |
| 128 | + // under the encrypted provider before object references were persisted. | |
| 129 | + const { objectId: _objectId, id: _id, apiKey: _apiKey, ...legacyPayload } = config; | |
| 130 | + const normalized = asRecord(legacyPayload); | |
| 131 | + if (Object.keys(normalized).length > 0) { | |
| 132 | + return normalized; | |
| 133 | + } | |
| 134 | + | |
| 135 | + throw new Error( | |
| 136 | + "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.", | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + const apiKey = typeof config.apiKey === "string" && config.apiKey.trim() ? config.apiKey.trim() : undefined; | |
| 140 | + | |
| 141 | + const raw = await readVaultObject({ objectId, apiKey }); | |
| 142 | + return parseSecretValue(raw); | |
| 143 | +} | |
| 144 | + | |
| 145 | +const providers: Record<CredentialProvider, ProviderResolver> = { | |
| 146 | + managed: resolveManaged, | |
| 147 | + "workos-vault": resolveWorkosVault, | |
| 148 | +}; | |
| 149 | + | |
| 150 | +export async function resolveCredentialPayload( | |
| 151 | + record: Pick<CredentialRecord, "provider" | "secretJson">, | |
| 152 | + options?: { readVaultObject?: VaultObjectReader }, | |
| 153 | +): Promise<CredentialPayload | null> { | |
| 154 | + const provider = (record.provider ?? "managed") as CredentialProvider; | |
| 155 | + const resolver = providers[provider]; | |
| 156 | + if (!resolver) { | |
| 157 | + throw new Error(`Unsupported credential provider: ${provider}`); | |
| 158 | + } | |
| 159 | + | |
| 160 | + return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject); | |
| 161 | +} | |
| @@ -4,6 +4,7 @@ export type { · 3 unmodified lines | ||
| 4 | 4 | ApprovalStatus, |
| 5 | 5 | PolicyDecision, |
| 6 | 6 | CredentialScope, |
| 7 | + CredentialProvider, | |
| 7 | 8 | ToolApprovalMode, |
| 8 | 9 | ToolSourceType, |
| 9 | 10 | TaskRecord, |
| @@ -37,6 +37,10 @@ const taskStatus = v.union( · 36 unmodified lines | ||
| 37 | 37 | const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); |
| 38 | 38 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 39 | 39 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); |
| 40 | +const credentialProvider = v.union( | |
| 41 | + v.literal("managed"), | |
| 42 | + v.literal("workos-vault"), | |
| 43 | +); | |
| 40 | 44 | const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 41 | 45 | const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed")); |
| 42 | 46 | |
| @@ -244,6 +248,7 @@ export default defineSchema({ · 201 unmodified lines | ||
| 244 | 248 | sourceKey: v.string(), |
| 245 | 249 | scope: credentialScope, |
| 246 | 250 | actorId: v.string(), |
| 251 | + provider: v.optional(credentialProvider), | |
| 247 | 252 | secretJson: v.any(), |
| 248 | 253 | createdAt: v.number(), |
| 249 | 254 | updatedAt: v.number(), |
| @@ -284,6 +289,15 @@ export default defineSchema({ · 34 unmodified lines | ||
| 284 | 289 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 285 | 290 | .index("by_requester_created", ["requesterId", "createdAt"]), |
| 286 | 291 | |
| 292 | + openApiSpecCache: defineTable({ | |
| 293 | + specUrl: v.string(), | |
| 294 | + storageId: v.id("_storage"), | |
| 295 | + version: v.string(), | |
| 296 | + sizeBytes: v.number(), | |
| 297 | + createdAt: v.number(), | |
| 298 | + }) | |
| 299 | + .index("by_spec_url_version", ["specUrl", "version"]), | |
| 300 | + | |
| 287 | 301 | anonymousSessions: defineTable({ |
| 288 | 302 | sessionId: v.string(), |
| 289 | 303 | workspaceId: v.id("workspaces"), |
| @@ -14,6 +14,8 @@ export type PolicyDecision = "allow" | "require_approval" | "deny"; · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | export type CredentialScope = "workspace" | "actor"; |
| 16 | 16 | |
| 17 | +export type CredentialProvider = "managed" | "workos-vault"; | |
| 18 | + | |
| 17 | 19 | export type ToolApprovalMode = "auto" | "required"; |
| 18 | 20 | |
| 19 | 21 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| @@ -83,6 +85,7 @@ export interface CredentialRecord { · 63 unmodified lines | ||
| 83 | 85 | sourceKey: string; |
| 84 | 86 | scope: CredentialScope; |
| 85 | 87 | actorId?: string; |
| 88 | + provider: CredentialProvider; | |
| 86 | 89 | secretJson: Record<string, unknown>; |
| 87 | 90 | createdAt: number; |
| 88 | 91 | updatedAt: number; |