Unified · Split
| @@ -41,3 +41,19 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 40 unmodified lines | |||
| 41 | - Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`. | 41 | - Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`. |
| 42 | - `run_code` supports TypeScript typechecking and runtime transpilation before execution. | 42 | - `run_code` supports TypeScript typechecking and runtime transpilation before execution. |
| 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. | 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 | Search, | 12 | Search, |
| 13 | ChevronRight, | 13 | ChevronRight, |
| 14 | AlertTriangle, | 14 | AlertTriangle, |
| 15 | KeyRound, | ||
| 16 | Pencil, | ||
| 15 | } from "lucide-react"; | 17 | } from "lucide-react"; |
| 16 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | 18 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 17 | import { Button } from "@/components/ui/button"; | 19 | import { Button } from "@/components/ui/button"; |
| 18 | import { Input } from "@/components/ui/input"; | 20 | import { Input } from "@/components/ui/input"; |
| 19 | import { Label } from "@/components/ui/label"; | 21 | import { Label } from "@/components/ui/label"; |
| 22 | import { Textarea } from "@/components/ui/textarea"; | ||
| 20 | import { Badge } from "@/components/ui/badge"; | 23 | import { Badge } from "@/components/ui/badge"; |
| 21 | import { | 24 | import { |
| 22 | Select, | 25 | Select, |
| @@ -44,9 +47,9 @@ import { PageHeader } from "@/components/page-header"; · 21 unmodified lines | |||
| 44 | import { McpSetupCard } from "@/components/mcp-setup-card"; | 47 | import { McpSetupCard } from "@/components/mcp-setup-card"; |
| 45 | import { useSession } from "@/lib/session-context"; | 48 | import { useSession } from "@/lib/session-context"; |
| 46 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 49 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 47 | import { useMutation, useQuery } from "convex/react"; | 50 | import { useAction, useMutation, useQuery } from "convex/react"; |
| 48 | import { convexApi } from "@/lib/convex-api"; | 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 | import { parse as parseDomain } from "tldts"; | 53 | import { parse as parseDomain } from "tldts"; |
| 51 | import { toast } from "sonner"; | 54 | import { toast } from "sonner"; |
| 52 | import { cn } from "@/lib/utils"; | 55 | import { cn } from "@/lib/utils"; |
| @@ -219,6 +222,53 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { · 166 unmodified lines | |||
| 219 | return faviconForUrl(url); | 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 | // ── Add Source Dialog ── | 272 | // ── Add Source Dialog ── |
| 223 | 273 | ||
| 224 | const RAW_HOSTS = new Set([ | 274 | const RAW_HOSTS = new Set([ |
| @@ -562,6 +612,148 @@ function AddSourceDialog({ · 337 unmodified lines | |||
| 562 | 612 | ||
| 563 | // ── Source Card ── | 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 | function SourceCard({ | 757 | function SourceCard({ |
| 566 | source, | 758 | source, |
| 567 | }: { | 759 | }: { |
| @@ -586,6 +778,7 @@ function SourceCard({ · 18 unmodified lines | |||
| 586 | 778 | ||
| 587 | const TypeIcon = source.type === "mcp" ? Server : Globe; | 779 | const TypeIcon = source.type === "mcp" ? Server : Globe; |
| 588 | const favicon = getSourceFavicon(source); | 780 | const favicon = getSourceFavicon(source); |
| 781 | const authBadge = formatSourceAuthBadge(source); | ||
| 589 | 782 | ||
| 590 | return ( | 783 | return ( |
| 591 | <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group"> | 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 | disabled | 808 | disabled |
| 616 | </Badge> | 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 | </div> | 819 | </div> |
| 619 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> | 820 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> |
| 620 | {source.type === "mcp" | 821 | {source.type === "mcp" |
| @@ -624,19 +825,505 @@ function SourceCard({ · 3 unmodified lines | |||
| 624 | : (source.config.spec as string)} | 825 | : (source.config.spec as string)} |
| 625 | </span> | 826 | </span> |
| 626 | </div> | 827 | </div> |
| 627 | <Button | 828 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> |
| 628 | variant="ghost" | 829 | <ConfigureSourceAuthDialog source={source} /> |
| 629 | size="icon" | 830 | <Button |
| 630 | className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-terminal-red shrink-0" | 831 | variant="ghost" |
| 631 | onClick={handleDelete} | 832 | size="icon" |
| 632 | disabled={deleting} | 833 | className="h-7 w-7 text-muted-foreground hover:text-terminal-red" |
| 633 | > | 834 | onClick={handleDelete} |
| 634 | <Trash2 className="h-3.5 w-3.5" /> | 835 | disabled={deleting} |
| 635 | </Button> | 836 | > |
| 837 | <Trash2 className="h-3.5 w-3.5" /> | ||
| 838 | </Button> | ||
| 839 | </div> | ||
| 636 | </div> | 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 | // ── Tool Inventory ── | 1327 | // ── Tool Inventory ── |
| 641 | 1328 | ||
| 642 | function ToolInventory({ tools }: { tools: ToolDescriptor[] }) { | 1329 | function ToolInventory({ tools }: { tools: ToolDescriptor[] }) { |
| @@ -711,6 +1398,12 @@ export function ToolsView() { · 68 unmodified lines | |||
| 711 | ); | 1398 | ); |
| 712 | const sourcesLoading = !!context && sources === undefined; | 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 | const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null); | 1407 | const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null); |
| 715 | 1408 | ||
| 716 | if (sessionLoading) { | 1409 | if (sessionLoading) { |
| @@ -726,7 +1419,7 @@ export function ToolsView() { · 9 unmodified lines | |||
| 726 | <div className="space-y-6"> | 1419 | <div className="space-y-6"> |
| 727 | <PageHeader | 1420 | <PageHeader |
| 728 | title="Tools" | 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 | <Tabs defaultValue="sources" className="w-full"> | 1425 | <Tabs defaultValue="sources" className="w-full"> |
| @@ -739,6 +1432,14 @@ export function ToolsView() { · 6 unmodified lines | |||
| 739 | </span> | 1432 | </span> |
| 740 | )} | 1433 | )} |
| 741 | </TabsTrigger> | 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 | <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> | 1443 | <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> |
| 743 | Inventory | 1444 | Inventory |
| 744 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> | 1445 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| @@ -811,6 +1512,14 @@ export function ToolsView() { · 66 unmodified lines | |||
| 811 | </Card> | 1512 | </Card> |
| 812 | </TabsContent> | 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 | <TabsContent value="inventory" className="mt-4"> | 1523 | <TabsContent value="inventory" className="mt-4"> |
| 815 | <Card className="bg-card border-border"> | 1524 | <Card className="bg-card border-border"> |
| 816 | <CardHeader className="pb-3"> | 1525 | <CardHeader className="pb-3"> |
| @@ -4,6 +4,7 @@ export type { · 3 unmodified lines | |||
| 4 | ApprovalStatus, | 4 | ApprovalStatus, |
| 5 | PolicyDecision, | 5 | PolicyDecision, |
| 6 | CredentialScope, | 6 | CredentialScope, |
| 7 | CredentialProvider, | ||
| 7 | ToolApprovalMode, | 8 | ToolApprovalMode, |
| 8 | ToolSourceType, | 9 | ToolSourceType, |
| 9 | TaskRecord, | 10 | TaskRecord, |
| @@ -13,12 +13,14 @@ import type * as auth from "../auth.js"; · 12 unmodified lines | |||
| 13 | import type * as billing from "../billing.js"; | 13 | import type * as billing from "../billing.js"; |
| 14 | import type * as billingInternal from "../billingInternal.js"; | 14 | import type * as billingInternal from "../billingInternal.js"; |
| 15 | import type * as billingSync from "../billingSync.js"; | 15 | import type * as billingSync from "../billingSync.js"; |
| 16 | import type * as credentialsNode from "../credentialsNode.js"; | ||
| 16 | import type * as database from "../database.js"; | 17 | import type * as database from "../database.js"; |
| 17 | import type * as executor from "../executor.js"; | 18 | import type * as executor from "../executor.js"; |
| 18 | import type * as executorNode from "../executorNode.js"; | 19 | import type * as executorNode from "../executorNode.js"; |
| 19 | import type * as http from "../http.js"; | 20 | import type * as http from "../http.js"; |
| 20 | import type * as invites from "../invites.js"; | 21 | import type * as invites from "../invites.js"; |
| 21 | import type * as lib_adapters_in_process_execution_adapter from "../lib/adapters/in_process_execution_adapter.js"; | 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 | import type * as lib_events from "../lib/events.js"; | 24 | import type * as lib_events from "../lib/events.js"; |
| 23 | import type * as lib_execution_constants from "../lib/execution_constants.js"; | 25 | import type * as lib_execution_constants from "../lib/execution_constants.js"; |
| 24 | import type * as lib_functionBuilders from "../lib/functionBuilders.js"; | 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 | import type * as lib_typechecker from "../lib/typechecker.js"; | 34 | import type * as lib_typechecker from "../lib/typechecker.js"; |
| 33 | import type * as lib_types from "../lib/types.js"; | 35 | import type * as lib_types from "../lib/types.js"; |
| 34 | import type * as lib_utils from "../lib/utils.js"; | 36 | import type * as lib_utils from "../lib/utils.js"; |
| 37 | import type * as openApiSpecCache from "../openApiSpecCache.js"; | ||
| 35 | import type * as organizationMembers from "../organizationMembers.js"; | 38 | import type * as organizationMembers from "../organizationMembers.js"; |
| 36 | import type * as organizations from "../organizations.js"; | 39 | import type * as organizations from "../organizations.js"; |
| 37 | import type * as workspaces from "../workspaces.js"; | 40 | import type * as workspaces from "../workspaces.js"; |
| @@ -48,12 +51,14 @@ declare const fullApi: ApiFromModules<{ · 10 unmodified lines | |||
| 48 | billing: typeof billing; | 51 | billing: typeof billing; |
| 49 | billingInternal: typeof billingInternal; | 52 | billingInternal: typeof billingInternal; |
| 50 | billingSync: typeof billingSync; | 53 | billingSync: typeof billingSync; |
| 54 | credentialsNode: typeof credentialsNode; | ||
| 51 | database: typeof database; | 55 | database: typeof database; |
| 52 | executor: typeof executor; | 56 | executor: typeof executor; |
| 53 | executorNode: typeof executorNode; | 57 | executorNode: typeof executorNode; |
| 54 | http: typeof http; | 58 | http: typeof http; |
| 55 | invites: typeof invites; | 59 | invites: typeof invites; |
| 56 | "lib/adapters/in_process_execution_adapter": typeof lib_adapters_in_process_execution_adapter; | 60 | "lib/adapters/in_process_execution_adapter": typeof lib_adapters_in_process_execution_adapter; |
| 61 | "lib/credential_providers": typeof lib_credential_providers; | ||
| 57 | "lib/events": typeof lib_events; | 62 | "lib/events": typeof lib_events; |
| 58 | "lib/execution_constants": typeof lib_execution_constants; | 63 | "lib/execution_constants": typeof lib_execution_constants; |
| 59 | "lib/functionBuilders": typeof lib_functionBuilders; | 64 | "lib/functionBuilders": typeof lib_functionBuilders; |
| @@ -67,6 +72,7 @@ declare const fullApi: ApiFromModules<{ · 7 unmodified lines | |||
| 67 | "lib/typechecker": typeof lib_typechecker; | 72 | "lib/typechecker": typeof lib_typechecker; |
| 68 | "lib/types": typeof lib_types; | 73 | "lib/types": typeof lib_types; |
| 69 | "lib/utils": typeof lib_utils; | 74 | "lib/utils": typeof lib_utils; |
| 75 | openApiSpecCache: typeof openApiSpecCache; | ||
| 70 | organizationMembers: typeof organizationMembers; | 76 | organizationMembers: typeof organizationMembers; |
| 71 | organizations: typeof organizations; | 77 | organizations: typeof organizations; |
| 72 | workspaces: typeof workspaces; | 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 | expect(again.workspaceId).toBe(seeded.workspaceId); | 120 | expect(again.workspaceId).toBe(seeded.workspaceId); |
| 121 | expect(again.actorId).toBe(seeded.actorId); | 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 | const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | 17 | const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); |
| 18 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 18 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 19 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 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 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | 24 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 21 | const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed")); | 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 | sourceKey: doc.sourceKey, | 163 | sourceKey: doc.sourceKey, |
| 160 | scope: doc.scope, | 164 | scope: doc.scope, |
| 161 | actorId: optionalFromNormalized(doc.actorId), | 165 | actorId: optionalFromNormalized(doc.actorId), |
| 166 | provider: doc.provider ?? "managed", | ||
| 162 | secretJson: asRecord(doc.secretJson), | 167 | secretJson: asRecord(doc.secretJson), |
| 163 | createdAt: doc.createdAt, | 168 | createdAt: doc.createdAt, |
| 164 | updatedAt: doc.updatedAt, | 169 | updatedAt: doc.updatedAt, |
| @@ -856,6 +861,7 @@ export const upsertCredential = mutation({ · 691 unmodified lines | |||
| 856 | sourceKey: v.string(), | 861 | sourceKey: v.string(), |
| 857 | scope: credentialScopeValidator, | 862 | scope: credentialScopeValidator, |
| 858 | actorId: v.optional(v.string()), | 863 | actorId: v.optional(v.string()), |
| 864 | provider: v.optional(credentialProviderValidator), | ||
| 859 | secretJson: v.any(), | 865 | secretJson: v.any(), |
| 860 | }, | 866 | }, |
| 861 | handler: async (ctx, args) => { | 867 | handler: async (ctx, args) => { |
| @@ -873,8 +879,11 @@ export const upsertCredential = mutation({ · 11 unmodified lines | |||
| 873 | ) | 879 | ) |
| 874 | .unique(); | 880 | .unique(); |
| 875 | 881 | ||
| 882 | const provider = args.provider ?? existing?.provider ?? "managed"; | ||
| 883 | |||
| 876 | if (existing) { | 884 | if (existing) { |
| 877 | await ctx.db.patch(existing._id, { | 885 | await ctx.db.patch(existing._id, { |
| 886 | provider, | ||
| 878 | secretJson: asRecord(args.secretJson), | 887 | secretJson: asRecord(args.secretJson), |
| 879 | updatedAt: now, | 888 | updatedAt: now, |
| 880 | }); | 889 | }); |
| @@ -885,6 +894,7 @@ export const upsertCredential = mutation({ · 4 unmodified lines | |||
| 885 | sourceKey: args.sourceKey, | 894 | sourceKey: args.sourceKey, |
| 886 | scope: args.scope, | 895 | scope: args.scope, |
| 887 | actorId, | 896 | actorId, |
| 897 | provider, | ||
| 888 | secretJson: asRecord(args.secretJson), | 898 | secretJson: asRecord(args.secretJson), |
| 889 | createdAt: now, | 899 | createdAt: now, |
| 890 | updatedAt: now, | 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 | export const resolveCredential = query({ | 953 | export const resolveCredential = query({ |
| 926 | args: { | 954 | args: { |
| 927 | workspaceId: v.string(), | 955 | workspaceId: v.string(), |
| @@ -1,10 +1,10 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { v } from "convex/values"; | 3 | import { v } from "convex/values"; |
| 4 | import { ActionCache } from "@convex-dev/action-cache"; | 4 | import { api, internal } from "./_generated/api"; |
| 5 | import { api, components, internal } from "./_generated/api"; | ||
| 6 | import { action, internalAction } from "./_generated/server"; | 5 | import { action, internalAction } from "./_generated/server"; |
| 7 | import { InProcessExecutionAdapter } from "./lib/adapters/in_process_execution_adapter"; | 6 | import { InProcessExecutionAdapter } from "./lib/adapters/in_process_execution_adapter"; |
| 7 | import { resolveCredentialPayload } from "./lib/credential_providers"; | ||
| 8 | import { APPROVAL_DENIED_PREFIX } from "./lib/execution_constants"; | 8 | import { APPROVAL_DENIED_PREFIX } from "./lib/execution_constants"; |
| 9 | import { runCodeWithAdapter } from "./lib/runtimes/runtime_core"; | 9 | import { runCodeWithAdapter } from "./lib/runtimes/runtime_core"; |
| 10 | import { createDiscoverTool } from "./lib/tool_discovery"; | 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 | const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000; | 135 | const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 136 | const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000; | 136 | const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000; |
| 137 | 137 | ||
| 138 | const actionCacheComponent = components as unknown as { actionCache: unknown }; | 138 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ |
| 139 | const openApiSpecCache = new ActionCache(actionCacheComponent.actionCache as never, { | 139 | const OPENAPI_CACHE_VERSION = "v2"; |
| 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 | } | ||
| 155 | 140 | ||
| 156 | function isWorkspaceToolCacheFresh( | 141 | function isWorkspaceToolCacheFresh( |
| 157 | cached: { signature: string; loadedAt: number; warnings: string[] }, | 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({ | 188 | /** |
| 204 | args: { | 189 | * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache. |
| 205 | specUrl: v.string(), | 190 | * |
| 206 | }, | 191 | * Flow: |
| 207 | handler: async (_ctx, args): Promise<PreparedOpenApiSpecCacheValue> => { | 192 | * 1. Check the `openApiSpecCache` table for a valid entry → fetch blob from storage. |
| 208 | const prepared = await prepareOpenApiSpec(args.specUrl, args.specUrl); | 193 | * 2. On miss: prepare the spec from scratch, store the JSON blob, write the metadata row. |
| 209 | // Action cache values must be valid Convex values. Raw OpenAPI objects can | 194 | * |
| 210 | // contain keys like "$ref", which Convex object fields disallow, so we | 195 | * No size limits — Convex file storage handles multi-MB specs without issue. |
| 211 | // store the prepared payload as a JSON string. | 196 | */ |
| 212 | let preparedJson = JSON.stringify(prepared); | 197 | async function loadCachedOpenApiSpec( |
| 213 | 198 | ctx: any, | |
| 214 | if (preparedJson.length > MAX_ACTION_CACHE_VALUE_BYTES) { | 199 | specUrl: string, |
| 215 | const reduced: PreparedOpenApiSpec = { | 200 | sourceName: string, |
| 216 | servers: prepared.servers, | 201 | ): Promise<PreparedOpenApiSpec> { |
| 217 | paths: prepared.paths, | 202 | // 1. Persistent cache (Convex table + file storage) |
| 218 | warnings: [ | 203 | try { |
| 219 | ...(prepared.warnings ?? []), | 204 | const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { |
| 220 | "OpenAPI type metadata omitted due cache size limits.", | 205 | specUrl, |
| 221 | ], | 206 | version: OPENAPI_CACHE_VERSION, |
| 222 | }; | 207 | maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, |
| 223 | preparedJson = JSON.stringify(reduced); | 208 | }); |
| 224 | } | ||
| 225 | 209 | ||
| 226 | if (preparedJson.length > MAX_ACTION_CACHE_VALUE_BYTES) { | 210 | if (entry) { |
| 227 | return { preparedJson: "", oversized: true }; | 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 }; | 224 | // 2. Cache miss — prepare from scratch |
| 231 | }, | 225 | const prepared = await prepareOpenApiSpec(specUrl, sourceName); |
| 232 | }); | 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 | async function loadSourceTools( | 246 | async function loadSourceTools( |
| 235 | ctx: any, | 247 | ctx: any, |
| 236 | source: ExternalToolSourceConfig, | 248 | source: ExternalToolSourceConfig, |
| 237 | ): Promise<{ tools: ToolDefinition[]; warnings: string[] }> { | 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 | if (source.type === "openapi" && typeof source.spec === "string") { | 250 | if (source.type === "openapi" && typeof source.spec === "string") { |
| 261 | try { | 251 | try { |
| 262 | const cached = await openApiSpecCache.fetch(ctx, { specUrl: source.spec }) as PreparedOpenApiSpecCacheValue; | 252 | const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name); |
| 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; | ||
| 269 | const tools = buildOpenApiToolsFromPrepared(source, prepared); | 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 | return { tools, warnings }; | 257 | return { tools, warnings }; |
| 272 | } catch (error) { | 258 | } catch (error) { |
| 273 | const message = error instanceof Error ? error.message : String(error); | 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 | return { | 260 | return { |
| 290 | tools: [], | 261 | tools: [], |
| 291 | warnings: [`Failed to load openapi source '${source.name}': ${message}`], | 262 | warnings: [`Failed to load openapi source '${source.name}': ${message}`], |
| @@ -449,7 +420,9 @@ async function resolveCredentialHeaders( · 157 unmodified lines | |||
| 449 | actorId: task.actorId, | 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 | if (!source) { | 426 | if (!source) { |
| 454 | return null; | 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 | ApprovalStatus, | 4 | ApprovalStatus, |
| 5 | PolicyDecision, | 5 | PolicyDecision, |
| 6 | CredentialScope, | 6 | CredentialScope, |
| 7 | CredentialProvider, | ||
| 7 | ToolApprovalMode, | 8 | ToolApprovalMode, |
| 8 | ToolSourceType, | 9 | ToolSourceType, |
| 9 | TaskRecord, | 10 | TaskRecord, |
| @@ -37,6 +37,10 @@ const taskStatus = v.union( · 36 unmodified lines | |||
| 37 | const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | 37 | const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); |
| 38 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 38 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 39 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); | 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 | const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | 44 | const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 41 | const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed")); | 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 | sourceKey: v.string(), | 248 | sourceKey: v.string(), |
| 245 | scope: credentialScope, | 249 | scope: credentialScope, |
| 246 | actorId: v.string(), | 250 | actorId: v.string(), |
| 251 | provider: v.optional(credentialProvider), | ||
| 247 | secretJson: v.any(), | 252 | secretJson: v.any(), |
| 248 | createdAt: v.number(), | 253 | createdAt: v.number(), |
| 249 | updatedAt: v.number(), | 254 | updatedAt: v.number(), |
| @@ -284,6 +289,15 @@ export default defineSchema({ · 34 unmodified lines | |||
| 284 | .index("by_workspace_created", ["workspaceId", "createdAt"]) | 289 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 285 | .index("by_requester_created", ["requesterId", "createdAt"]), | 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 | anonymousSessions: defineTable({ | 301 | anonymousSessions: defineTable({ |
| 288 | sessionId: v.string(), | 302 | sessionId: v.string(), |
| 289 | workspaceId: v.id("workspaces"), | 303 | workspaceId: v.id("workspaces"), |
| @@ -14,6 +14,8 @@ export type PolicyDecision = "allow" | "require_approval" | "deny"; · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | export type CredentialScope = "workspace" | "actor"; | 15 | export type CredentialScope = "workspace" | "actor"; |
| 16 | 16 | ||
| 17 | export type CredentialProvider = "managed" | "workos-vault"; | ||
| 18 | |||
| 17 | export type ToolApprovalMode = "auto" | "required"; | 19 | export type ToolApprovalMode = "auto" | "required"; |
| 18 | 20 | ||
| 19 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | 21 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| @@ -83,6 +85,7 @@ export interface CredentialRecord { · 63 unmodified lines | |||
| 83 | sourceKey: string; | 85 | sourceKey: string; |
| 84 | scope: CredentialScope; | 86 | scope: CredentialScope; |
| 85 | actorId?: string; | 87 | actorId?: string; |
| 88 | provider: CredentialProvider; | ||
| 86 | secretJson: Record<string, unknown>; | 89 | secretJson: Record<string, unknown>; |
| 87 | createdAt: number; | 90 | createdAt: number; |
| 88 | updatedAt: number; | 91 | updatedAt: number; |