History

Add dashboard credential flows and encrypted provider handling

Rhys Sullivan committed 9 months ago

Commit f3fb3181 parent c7a024613 files · +1347 −104Browse files

Unified · Split

13 files
  1. executor/README.md +16 −0
  2. executor/apps/web/src/components/tools-view.tsx +721 −12
  3. executor/apps/web/src/lib/types.ts +1 −0
  4. executor/convex/_generated/api.d.ts +6 −0
  5. executor/convex/credentialsNode.ts +212 −0
  6. executor/convex/database.test.ts +54 −0
  7. executor/convex/database.ts +28 −0
  8. executor/convex/executorNode.ts +65 −92
  9. executor/convex/lib/credential-providers.test.ts +65 −0
  10. executor/convex/lib/credential_providers.ts +161 −0
  11. executor/convex/lib/types.ts +1 −0
  12. executor/convex/schema.ts +14 −0
  13. executor/packages/contracts/src/index.ts +3 −0
executor/README.md+16−0
@@ -41,3 +41,19 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 40 unmodified lines
4141 - Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`.
4242 - `run_code` supports TypeScript typechecking and runtime transpilation before execution.
4343 - `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.
executor/apps/web/src/components/tools-view.tsx+721−12
@@ -12,11 +12,14 @@ import { · 11 unmodified lines
1212 Search,
1313 ChevronRight,
1414 AlertTriangle,
15+ KeyRound,
16+ Pencil,
1517 } from "lucide-react";
1618 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1719 import { Button } from "@/components/ui/button";
1820 import { Input } from "@/components/ui/input";
1921 import { Label } from "@/components/ui/label";
22+import { Textarea } from "@/components/ui/textarea";
2023 import { Badge } from "@/components/ui/badge";
2124 import {
2225 Select,
@@ -44,9 +47,9 @@ import { PageHeader } from "@/components/page-header"; · 21 unmodified lines
4447 import { McpSetupCard } from "@/components/mcp-setup-card";
4548 import { useSession } from "@/lib/session-context";
4649 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
47−import { useMutation, useQuery } from "convex/react";
50+import { useAction, useMutation, useQuery } from "convex/react";
4851 import { convexApi } from "@/lib/convex-api";
49−import type { ToolSourceRecord, ToolDescriptor } from "@/lib/types";
52+import type { ToolSourceRecord, ToolDescriptor, CredentialRecord, CredentialScope } from "@/lib/types";
5053 import { parse as parseDomain } from "tldts";
5154 import { toast } from "sonner";
5255 import { cn } from "@/lib/utils";
@@ -219,6 +222,53 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { · 166 unmodified lines
219222 return faviconForUrl(url);
220223 }
221224
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+
222272 // ── Add Source Dialog ──
223273
224274 const RAW_HOSTS = new Set([
@@ -562,6 +612,148 @@ function AddSourceDialog({ · 337 unmodified lines
562612
563613 // ── Source Card ──
564614
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+
565757 function SourceCard({
566758 source,
567759 }: {
@@ -586,6 +778,7 @@ function SourceCard({ · 18 unmodified lines
586778
587779 const TypeIcon = source.type === "mcp" ? Server : Globe;
588780 const favicon = getSourceFavicon(source);
781+ const authBadge = formatSourceAuthBadge(source);
589782
590783 return (
591784 <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
615808 disabled
616809 </Badge>
617810 )}
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+ )}
618819 </div>
619820 <span className="text-[11px] text-muted-foreground font-mono truncate block">
620821 {source.type === "mcp"
@@ -624,19 +825,505 @@ function SourceCard({ · 3 unmodified lines
624825 : (source.config.spec as string)}
625826 </span>
626827 </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>
636840 </div>
637841 );
638842 }
639843
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+
6401327 // ── Tool Inventory ──
6411328
6421329 function ToolInventory({ tools }: { tools: ToolDescriptor[] }) {
@@ -711,6 +1398,12 @@ export function ToolsView() { · 68 unmodified lines
7111398 );
7121399 const sourcesLoading = !!context && sources === undefined;
7131400
1401+ const credentials = useQuery(
1402+ convexApi.database.listCredentials,
1403+ context ? { workspaceId: context.workspaceId } : "skip",
1404+ );
1405+ const credentialsLoading = !!context && credentials === undefined;
1406+
7141407 const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null);
7151408
7161409 if (sessionLoading) {
@@ -726,7 +1419,7 @@ export function ToolsView() { · 9 unmodified lines
7261419 <div className="space-y-6">
7271420 <PageHeader
7281421 title="Tools"
729− description="Manage tool sources and view available tools"
1422+ description="Manage sources, auth, credentials, and available tools"
7301423 />
7311424
7321425 <Tabs defaultValue="sources" className="w-full">
@@ -739,6 +1432,14 @@ export function ToolsView() { · 6 unmodified lines
7391432 </span>
7401433 )}
7411434 </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>
7421443 <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background">
7431444 Inventory
7441445 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
@@ -811,6 +1512,14 @@ export function ToolsView() { · 66 unmodified lines
8111512 </Card>
8121513 </TabsContent>
8131514
1515+ <TabsContent value="credentials" className="mt-4">
1516+ <CredentialsPanel
1517+ sources={sources ?? []}
1518+ credentials={credentials ?? []}
1519+ loading={credentialsLoading || sourcesLoading}
1520+ />
1521+ </TabsContent>
1522+
8141523 <TabsContent value="inventory" className="mt-4">
8151524 <Card className="bg-card border-border">
8161525 <CardHeader className="pb-3">
executor/apps/web/src/lib/types.ts+1−0
@@ -4,6 +4,7 @@ export type { · 3 unmodified lines
44 ApprovalStatus,
55 PolicyDecision,
66 CredentialScope,
7+ CredentialProvider,
78 ToolApprovalMode,
89 ToolSourceType,
910 TaskRecord,
executor/convex/_generated/api.d.ts+6−0
@@ -13,12 +13,14 @@ import type * as auth from "../auth.js"; · 12 unmodified lines
1313 import type * as billing from "../billing.js";
1414 import type * as billingInternal from "../billingInternal.js";
1515 import type * as billingSync from "../billingSync.js";
16+import type * as credentialsNode from "../credentialsNode.js";
1617 import type * as database from "../database.js";
1718 import type * as executor from "../executor.js";
1819 import type * as executorNode from "../executorNode.js";
1920 import type * as http from "../http.js";
2021 import type * as invites from "../invites.js";
2122 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";
2224 import type * as lib_events from "../lib/events.js";
2325 import type * as lib_execution_constants from "../lib/execution_constants.js";
2426 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
3234 import type * as lib_typechecker from "../lib/typechecker.js";
3335 import type * as lib_types from "../lib/types.js";
3436 import type * as lib_utils from "../lib/utils.js";
37+import type * as openApiSpecCache from "../openApiSpecCache.js";
3538 import type * as organizationMembers from "../organizationMembers.js";
3639 import type * as organizations from "../organizations.js";
3740 import type * as workspaces from "../workspaces.js";
@@ -48,12 +51,14 @@ declare const fullApi: ApiFromModules<{ · 10 unmodified lines
4851 billing: typeof billing;
4952 billingInternal: typeof billingInternal;
5053 billingSync: typeof billingSync;
54+ credentialsNode: typeof credentialsNode;
5155 database: typeof database;
5256 executor: typeof executor;
5357 executorNode: typeof executorNode;
5458 http: typeof http;
5559 invites: typeof invites;
5660 "lib/adapters/in_process_execution_adapter": typeof lib_adapters_in_process_execution_adapter;
61+ "lib/credential_providers": typeof lib_credential_providers;
5762 "lib/events": typeof lib_events;
5863 "lib/execution_constants": typeof lib_execution_constants;
5964 "lib/functionBuilders": typeof lib_functionBuilders;
@@ -67,6 +72,7 @@ declare const fullApi: ApiFromModules<{ · 7 unmodified lines
6772 "lib/typechecker": typeof lib_typechecker;
6873 "lib/types": typeof lib_types;
6974 "lib/utils": typeof lib_utils;
75+ openApiSpecCache: typeof openApiSpecCache;
7076 organizationMembers: typeof organizationMembers;
7177 organizations: typeof organizations;
7278 workspaces: typeof workspaces;
executor/convex/credentialsNode.ts+212−0
@@ -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+});
executor/convex/database.test.ts+54−0
@@ -120,3 +120,57 @@ test("bootstrap honors caller-provided session id", async () => { · 119 unmodified lines
120120 expect(again.workspaceId).toBe(seeded.workspaceId);
121121 expect(again.actorId).toBe(seeded.actorId);
122122 });
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+});
executor/convex/database.ts+28−0
@@ -17,6 +17,10 @@ const completedTaskStatusValidator = v.union( · 16 unmodified lines
1717 const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
1818 const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
1919 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+);
2024 const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
2125 const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
2226
@@ -159,6 +163,7 @@ function mapCredential(doc: Doc<"sourceCredentials">) { · 136 unmodified lines
159163 sourceKey: doc.sourceKey,
160164 scope: doc.scope,
161165 actorId: optionalFromNormalized(doc.actorId),
166+ provider: doc.provider ?? "managed",
162167 secretJson: asRecord(doc.secretJson),
163168 createdAt: doc.createdAt,
164169 updatedAt: doc.updatedAt,
@@ -856,6 +861,7 @@ export const upsertCredential = mutation({ · 691 unmodified lines
856861 sourceKey: v.string(),
857862 scope: credentialScopeValidator,
858863 actorId: v.optional(v.string()),
864+ provider: v.optional(credentialProviderValidator),
859865 secretJson: v.any(),
860866 },
861867 handler: async (ctx, args) => {
@@ -873,8 +879,11 @@ export const upsertCredential = mutation({ · 11 unmodified lines
873879 )
874880 .unique();
875881
882+ const provider = args.provider ?? existing?.provider ?? "managed";
883+
876884 if (existing) {
877885 await ctx.db.patch(existing._id, {
886+ provider,
878887 secretJson: asRecord(args.secretJson),
879888 updatedAt: now,
880889 });
@@ -885,6 +894,7 @@ export const upsertCredential = mutation({ · 4 unmodified lines
885894 sourceKey: args.sourceKey,
886895 scope: args.scope,
887896 actorId,
897+ provider,
888898 secretJson: asRecord(args.secretJson),
889899 createdAt: now,
890900 updatedAt: now,
@@ -922,6 +932,24 @@ export const listCredentials = query({ · 31 unmodified lines
922932 },
923933 });
924934
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+
925953 export const resolveCredential = query({
926954 args: {
927955 workspaceId: v.string(),
executor/convex/executorNode.ts+65−92
@@ -1,10 +1,10 @@
11 "use node";
22
33 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";
65 import { action, internalAction } from "./_generated/server";
76 import { InProcessExecutionAdapter } from "./lib/adapters/in_process_execution_adapter";
7+import { resolveCredentialPayload } from "./lib/credential_providers";
88 import { APPROVAL_DENIED_PREFIX } from "./lib/execution_constants";
99 import { runCodeWithAdapter } from "./lib/runtimes/runtime_core";
1010 import { createDiscoverTool } from "./lib/tool_discovery";
@@ -135,23 +135,8 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 124 unmodified lines
135135 const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000;
136136 const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000;
137137
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";
155140
156141 function isWorkspaceToolCacheFresh(
157142 cached: { signature: string; loadedAt: number; warnings: string[] },
@@ -200,92 +185,78 @@ async function waitForApproval(ctx: any, approvalId: string): Promise<"approved" · 42 unmodified lines
200185 }
201186 }
202187
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+ });
225209
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
228217 }
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+ }
229223
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+}
233245
234246 async function loadSourceTools(
235247 ctx: any,
236248 source: ExternalToolSourceConfig,
237249 ): 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−
260250 if (source.type === "openapi" && typeof source.spec === "string") {
261251 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);
269253 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+ );
271257 return { tools, warnings };
272258 } catch (error) {
273259 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−
289260 return {
290261 tools: [],
291262 warnings: [`Failed to load openapi source '${source.name}': ${message}`],
@@ -449,7 +420,9 @@ async function resolveCredentialHeaders( · 157 unmodified lines
449420 actorId: task.actorId,
450421 });
451422
452− const source = record?.secretJson ?? spec.staticSecretJson ?? null;
423+ const source = record
424+ ? await resolveCredentialPayload(record)
425+ : spec.staticSecretJson ?? null;
453426 if (!source) {
454427 return null;
455428 }
executor/convex/lib/credential-providers.test.ts+65−0
@@ -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+});
executor/convex/lib/credential_providers.ts+161−0
@@ -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+}
executor/convex/lib/types.ts+1−0
@@ -4,6 +4,7 @@ export type { · 3 unmodified lines
44 ApprovalStatus,
55 PolicyDecision,
66 CredentialScope,
7+ CredentialProvider,
78 ToolApprovalMode,
89 ToolSourceType,
910 TaskRecord,
executor/convex/schema.ts+14−0
@@ -37,6 +37,10 @@ const taskStatus = v.union( · 36 unmodified lines
3737 const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
3838 const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
3939 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+);
4044 const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
4145 const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
4246
@@ -244,6 +248,7 @@ export default defineSchema({ · 201 unmodified lines
244248 sourceKey: v.string(),
245249 scope: credentialScope,
246250 actorId: v.string(),
251+ provider: v.optional(credentialProvider),
247252 secretJson: v.any(),
248253 createdAt: v.number(),
249254 updatedAt: v.number(),
@@ -284,6 +289,15 @@ export default defineSchema({ · 34 unmodified lines
284289 .index("by_workspace_created", ["workspaceId", "createdAt"])
285290 .index("by_requester_created", ["requesterId", "createdAt"]),
286291
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+
287301 anonymousSessions: defineTable({
288302 sessionId: v.string(),
289303 workspaceId: v.id("workspaces"),
executor/packages/contracts/src/index.ts+3−0
@@ -14,6 +14,8 @@ export type PolicyDecision = "allow" | "require_approval" | "deny"; · 13 unmodified lines
1414
1515 export type CredentialScope = "workspace" | "actor";
1616
17+export type CredentialProvider = "managed" | "workos-vault";
18+
1719 export type ToolApprovalMode = "auto" | "required";
1820
1921 export type ToolSourceType = "mcp" | "openapi" | "graphql";
@@ -83,6 +85,7 @@ export interface CredentialRecord { · 63 unmodified lines
8385 sourceKey: string;
8486 scope: CredentialScope;
8587 actorId?: string;
88+ provider: CredentialProvider;
8689 secretJson: Record<string, unknown>;
8790 createdAt: number;
8891 updatedAt: number;