History

Refactor secret storage to use providers

Rhys Sullivan committed 7 months ago

Commit 22e370c1 parent 6e204f010 files · +1066 −263Browse files

Unified · Split

10 files
  1. apps/web/src/views/add-source.tsx +58 −5
  2. apps/web/src/views/secrets.tsx +68 −7
  3. apps/web/src/views/source-editor.tsx +50 −7
  4. packages/control-plane/src/api/local/api.ts +3 −0
  5. packages/control-plane/src/api/local/http.ts +89 −48
  6. packages/control-plane/src/runtime/control-plane-runtime.test.ts +88 −0
  7. packages/control-plane/src/runtime/local-control-plane-store.test.ts +48 −2
  8. packages/control-plane/src/runtime/secret-material-providers.ts +621 −193
  9. packages/control-plane/src/runtime/store.ts +1 −0
  10. packages/control-plane/src/schema/models/secret-material.ts +40 −1
apps/web/src/views/add-source.tsx+58−5
@@ -1,10 +1,11 @@
1−import { useState, type ReactNode } from "react";
1+import { useEffect, useState, type ReactNode } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import {
44 type CompleteSourceOAuthResult,
55 type ConnectSourcePayload,
66 type ConnectSourceResult,
77 type DiscoverSourcePayload,
8+ type InstanceConfig,
89 type Loadable,
910 type SecretListItem,
1011 type Source,
@@ -12,6 +13,7 @@ import { · 1 unmodified line
1213 useConnectSource,
1314 useCreateSecret,
1415 useDiscoverSource,
16+ useInstanceConfig,
1517 useRefreshSecrets,
1618 useSecrets,
1719 useStartSourceOAuth,
@@ -590,6 +592,7 @@ export function AddSourcePage() { · 572 unmodified lines
590592 const navigate = useNavigate();
591593 const discoverSource = useDiscoverSource();
592594 const connectSource = useConnectSource();
595+ const instanceConfig = useInstanceConfig();
593596 const secrets = useSecrets();
594597 const refreshSecrets = useRefreshSecrets();
595598 const startSourceOAuth = useStartSourceOAuth();
@@ -1209,6 +1212,7 @@ export function AddSourcePage() { · 613 unmodified lines
12091212 {connectForm.authKind === "bearer" && (
12101213 <Field label="Token" className="sm:col-span-2">
12111214 <SecretOrTokenInput
1215+ instanceConfig={instanceConfig}
12121216 secrets={secrets}
12131217 providerId={connectForm.bearerProviderId}
12141218 handle={connectForm.bearerHandle}
@@ -1408,6 +1412,7 @@ const CREATE_NEW_VALUE = "__create_new__"; · 193 unmodified lines
14081412 const INLINE_TOKEN_VALUE = "__inline_token__";
14091413
14101414 function SecretOrTokenInput(props: {
1415+ instanceConfig: Loadable<InstanceConfig>;
14111416 secrets: Loadable<ReadonlyArray<SecretListItem>>;
14121417 providerId: string;
14131418 handle: string;
@@ -1415,13 +1420,35 @@ function SecretOrTokenInput(props: { · 1 unmodified line
14151420 onSelectSecret: (providerId: string, handle: string) => void;
14161421 onChangeToken: (token: string) => void;
14171422 }) {
1418− const { secrets, providerId, handle, inlineToken, onSelectSecret, onChangeToken } = props;
1423+ const {
1424+ instanceConfig,
1425+ secrets,
1426+ providerId,
1427+ handle,
1428+ inlineToken,
1429+ onSelectSecret,
1430+ onChangeToken,
1431+ } = props;
14191432 const createSecret = useCreateSecret();
14201433 const [showCreate, setShowCreate] = useState(false);
14211434 const [useInline, setUseInline] = useState(false);
14221435 const [newName, setNewName] = useState("");
14231436 const [newValue, setNewValue] = useState("");
1437+ const [newProviderId, setNewProviderId] = useState("");
14241438 const [createError, setCreateError] = useState<string | null>(null);
1439+ const storableProviders =
1440+ instanceConfig.status === "ready"
1441+ ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)
1442+ : [];
1443+
1444+ useEffect(() => {
1445+ if (newProviderId.length > 0) {
1446+ return;
1447+ }
1448+ if (instanceConfig.status === "ready") {
1449+ setNewProviderId(instanceConfig.data.defaultSecretStoreProvider);
1450+ }
1451+ }, [instanceConfig, newProviderId]);
14251452
14261453 const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || "");
14271454
@@ -1431,6 +1458,11 @@ function SecretOrTokenInput(props: { · 3 unmodified lines
14311458 setUseInline(false);
14321459 setNewName("");
14331460 setNewValue("");
1461+ setNewProviderId(
1462+ instanceConfig.status === "ready"
1463+ ? instanceConfig.data.defaultSecretStoreProvider
1464+ : "",
1465+ );
14341466 setCreateError(null);
14351467 return;
14361468 }
@@ -1448,7 +1480,10 @@ function SecretOrTokenInput(props: { · 11 unmodified lines
14481480 }
14491481 setUseInline(false);
14501482 setShowCreate(false);
1451− onSelectSecret("postgres", value);
1483+ const matchedSecret = secrets.status === "ready"
1484+ ? secrets.data.find((secret) => secret.id === value)
1485+ : null;
1486+ onSelectSecret(matchedSecret?.providerId ?? "local", value);
14521487 };
14531488
14541489 const handleCreate = async () => {
@@ -1464,7 +1499,11 @@ function SecretOrTokenInput(props: { · 9 unmodified lines
14641499 }
14651500
14661501 try {
1467− const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue });
1502+ const result = await createSecret.mutateAsync({
1503+ name: trimmedName,
1504+ value: newValue,
1505+ ...(newProviderId ? { providerId: newProviderId } : {}),
1506+ });
14681507 onSelectSecret(result.providerId, result.id);
14691508 setShowCreate(false);
14701509 } catch (err) {
@@ -1495,7 +1534,7 @@ function SecretOrTokenInput(props: { · 24 unmodified lines
14951534 <option value="">Select a secret...</option>
14961535 {items.map((secret) => (
14971536 <option key={secret.id} value={secret.id}>
1498− {secret.name || secret.id}
1537+ {secret.name || secret.id} ({secret.providerId})
14991538 </option>
15001539 ))}
15011540 <option value={INLINE_TOKEN_VALUE}>Paste token directly</option>
@@ -1541,6 +1580,20 @@ function SecretOrTokenInput(props: { · 39 unmodified lines
15411580 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
15421581 />
15431582 </label>
1583+ <label className="block space-y-1">
1584+ <span className="text-[11px] font-medium text-muted-foreground">Store in</span>
1585+ <select
1586+ value={newProviderId}
1587+ onChange={(e) => setNewProviderId(e.target.value)}
1588+ className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1589+ >
1590+ {storableProviders.map((provider) => (
1591+ <option key={provider.id} value={provider.id}>
1592+ {provider.name}
1593+ </option>
1594+ ))}
1595+ </select>
1596+ </label>
15441597 </div>
15451598 <div className="flex items-center justify-end gap-2">
15461599 <button
apps/web/src/views/secrets.tsx+68−7
@@ -1,13 +1,14 @@
1−import { useState, type ReactNode } from "react";
1+import { useEffect, useState, type ReactNode } from "react";
22 import { Link } from "@tanstack/react-router";
33 import {
4+ type InstanceConfig,
45 type SecretListItem,
56 type Loadable,
67 useSecrets,
78 useCreateSecret,
89 useUpdateSecret,
910 useDeleteSecret,
10− useRefreshSecrets,
11+ useInstanceConfig,
1112 } from "@executor/react";
1213 import { LoadableBlock } from "../components/loadable";
1314 import { Button } from "../components/ui/button";
@@ -19,10 +20,26 @@ import { cn } from "../lib/utils"; · 5 unmodified lines
1920 // Page
2021 // ---------------------------------------------------------------------------
2122
23+const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) =>
24+ config.status === "ready"
25+ ? config.data.secretProviders.filter((provider) => provider.canStore)
26+ : [];
27+
28+const providerLabelForId = (config: Loadable<InstanceConfig>, providerId: string): string =>
29+ config.status === "ready"
30+ ? config.data.secretProviders.find((provider) => provider.id === providerId)?.name ?? providerId
31+ : providerId;
32+
2233 export function SecretsPage() {
34+ const instanceConfig = useInstanceConfig();
2335 const secrets = useSecrets();
2436 const [showCreate, setShowCreate] = useState(false);
2537 const [editingId, setEditingId] = useState<string | null>(null);
38+ const storableProviders = storableProvidersFromConfig(instanceConfig);
39+ const defaultProviderLabel =
40+ instanceConfig.status === "ready"
41+ ? providerLabelForId(instanceConfig, instanceConfig.data.defaultSecretStoreProvider)
42+ : null;
2643
2744 return (
2845 <div className="flex-1 overflow-y-auto">
@@ -34,7 +51,8 @@ export function SecretsPage() { · 5 unmodified lines
3451 Secrets
3552 </h1>
3653 <p className="mt-1.5 text-[14px] text-muted-foreground">
37− Tokens and credentials stored in this instance.
54+ Tokens and credentials stored by executor.
55+ {defaultProviderLabel ? ` New secrets default to ${defaultProviderLabel}.` : ""}
3856 </p>
3957 </div>
4058 <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}>
@@ -48,6 +66,12 @@ export function SecretsPage() { · 7 unmodified lines
4866 <CreateSecretForm
4967 onClose={() => setShowCreate(false)}
5068 className="mb-6"
69+ providerOptions={storableProviders}
70+ defaultProviderId={
71+ instanceConfig.status === "ready"
72+ ? instanceConfig.data.defaultSecretStoreProvider
73+ : null
74+ }
5175 />
5276 )}
5377
@@ -62,6 +86,7 @@ export function SecretsPage() { · 8 unmodified lines
6286 <SecretRow
6387 key={secret.id}
6488 secret={secret}
89+ providerLabel={providerLabelForId(instanceConfig, secret.providerId)}
6590 isEditing={editingId === secret.id}
6691 onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)}
6792 onCancelEdit={() => setEditingId(null)}
@@ -95,7 +120,7 @@ function EmptyState(props: { onAdd: () => void }) { · 27 unmodified lines
95120 No secrets stored
96121 </p>
97122 <p className="text-[13px] text-muted-foreground/60 mb-5">
98− Secrets let you securely provide tokens for source authentication.
123+ Secrets let you securely provide tokens for source authentication without putting them in workspace config.
99124 </p>
100125 <Button size="sm" onClick={props.onAdd}>
101126 <IconPlus className="size-3.5" />
@@ -109,12 +134,27 @@ function EmptyState(props: { onAdd: () => void }) { · 7 unmodified lines
109134 // Create form
110135 // ---------------------------------------------------------------------------
111136
112−function CreateSecretForm(props: { onClose: () => void; className?: string }) {
137+function CreateSecretForm(props: {
138+ onClose: () => void;
139+ className?: string;
140+ providerOptions: ReadonlyArray<{ id: string; name: string }>;
141+ defaultProviderId: string | null;
142+}) {
113143 const createSecret = useCreateSecret();
114144 const [name, setName] = useState("");
115145 const [value, setValue] = useState("");
146+ const [providerId, setProviderId] = useState(props.defaultProviderId ?? "");
116147 const [error, setError] = useState<string | null>(null);
117148
149+ useEffect(() => {
150+ if (providerId.length > 0) {
151+ return;
152+ }
153+ if (props.defaultProviderId) {
154+ setProviderId(props.defaultProviderId);
155+ }
156+ }, [props.defaultProviderId, providerId]);
157+
118158 const handleSubmit = async () => {
119159 setError(null);
120160 const trimmedName = name.trim();
@@ -128,7 +168,11 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 7 unmodified lines
128168 }
129169
130170 try {
131− await createSecret.mutateAsync({ name: trimmedName, value });
171+ await createSecret.mutateAsync({
172+ name: trimmedName,
173+ value,
174+ ...(providerId ? { providerId } : {}),
175+ });
132176 props.onClose();
133177 } catch (err) {
134178 setError(err instanceof Error ? err.message : "Failed creating secret.");
@@ -172,6 +216,19 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 37 unmodified lines
172216 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
173217 />
174218 </FieldLabel>
219+ <FieldLabel label="Store in">
220+ <select
221+ value={providerId}
222+ onChange={(e) => setProviderId(e.target.value)}
223+ className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
224+ >
225+ {props.providerOptions.map((provider) => (
226+ <option key={provider.id} value={provider.id}>
227+ {provider.name}
228+ </option>
229+ ))}
230+ </select>
231+ </FieldLabel>
175232 </div>
176233 <div className="flex justify-end">
177234 <Button
@@ -194,11 +251,12 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 16 unmodified lines
194251
195252 function SecretRow(props: {
196253 secret: SecretListItem;
254+ providerLabel: string;
197255 isEditing: boolean;
198256 onEdit: () => void;
199257 onCancelEdit: () => void;
200258 }) {
201− const { secret, isEditing } = props;
259+ const { secret, providerLabel, isEditing } = props;
202260 const deleteSecret = useDeleteSecret();
203261 const [isDeleting, setIsDeleting] = useState(false);
204262
@@ -239,6 +297,9 @@ function SecretRow(props: { · 34 unmodified lines
239297 <Badge variant="outline" className="text-[9px] shrink-0">
240298 {secret.purpose.replace(/_/g, " ")}
241299 </Badge>
300+ <Badge variant="outline" className="text-[9px] shrink-0">
301+ {providerLabel}
302+ </Badge>
242303 </div>
243304 <div className="flex items-center gap-2 mt-0.5">
244305 <span className="text-[11px] font-mono text-muted-foreground/50 truncate">
apps/web/src/views/source-editor.tsx+50−7
@@ -1,4 +1,4 @@
1−import { useState, type ReactNode } from "react";
1+import { useEffect, useState, type ReactNode } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import {
44 type CompleteSourceOAuthResult,
@@ -1012,6 +1012,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 1007 unmodified lines
10121012 {formState.authKind === "bearer" && (
10131013 <Field label="Token" className="sm:col-span-2">
10141014 <SecretPicker
1015+ instanceConfig={instanceConfig}
10151016 secrets={secrets}
10161017 providerId={formState.bearerProviderId}
10171018 handle={formState.bearerHandle}
@@ -1030,6 +1031,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines
10301031 <>
10311032 <Field label="Access token" className="sm:col-span-2">
10321033 <SecretPicker
1034+ instanceConfig={instanceConfig}
10331035 secrets={secrets}
10341036 providerId={formState.oauthAccessProviderId}
10351037 handle={formState.oauthAccessHandle}
@@ -1041,6 +1043,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines
10411043 </Field>
10421044 <Field label="Refresh token (optional)" className="sm:col-span-2">
10431045 <SecretPicker
1046+ instanceConfig={instanceConfig}
10441047 secrets={secrets}
10451048 providerId={formState.oauthRefreshProviderId}
10461049 handle={formState.oauthRefreshHandle}
@@ -1218,18 +1221,33 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 171 unmodified lines
12181221 const CREATE_NEW_VALUE = "__create_new__";
12191222
12201223 function SecretPicker(props: {
1224+ instanceConfig: Loadable<InstanceConfig>;
12211225 secrets: Loadable<ReadonlyArray<SecretListItem>>;
12221226 providerId: string;
12231227 handle: string;
12241228 onSelect: (providerId: string, handle: string) => void;
12251229 allowEmpty?: boolean;
12261230 }) {
1227− const { secrets, providerId, handle, onSelect, allowEmpty } = props;
1231+ const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = props;
12281232 const createSecret = useCreateSecret();
12291233 const [showCreate, setShowCreate] = useState(false);
12301234 const [newName, setNewName] = useState("");
12311235 const [newValue, setNewValue] = useState("");
1236+ const [newProviderId, setNewProviderId] = useState("");
12321237 const [createError, setCreateError] = useState<string | null>(null);
1238+ const storableProviders =
1239+ instanceConfig.status === "ready"
1240+ ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)
1241+ : [];
1242+
1243+ useEffect(() => {
1244+ if (newProviderId.length > 0) {
1245+ return;
1246+ }
1247+ if (instanceConfig.status === "ready") {
1248+ setNewProviderId(instanceConfig.data.defaultSecretStoreProvider);
1249+ }
1250+ }, [instanceConfig, newProviderId]);
12331251
12341252 // Build the selected value key: if handle is set, use it (it's the secret ID)
12351253 const selectedValue = handle || "";
@@ -1239,6 +1257,11 @@ function SecretPicker(props: { · 3 unmodified lines
12391257 setShowCreate(true);
12401258 setNewName("");
12411259 setNewValue("");
1260+ setNewProviderId(
1261+ instanceConfig.status === "ready"
1262+ ? instanceConfig.data.defaultSecretStoreProvider
1263+ : "",
1264+ );
12421265 setCreateError(null);
12431266 return;
12441267 }
@@ -1246,8 +1269,10 @@ function SecretPicker(props: { · 1 unmodified line
12461269 onSelect("", "");
12471270 return;
12481271 }
1249− // Selecting an existing secret: providerId is always "postgres"
1250− onSelect("postgres", value);
1272+ const matchedSecret = secrets.status === "ready"
1273+ ? secrets.data.find((secret) => secret.id === value)
1274+ : null;
1275+ onSelect(matchedSecret?.providerId ?? "local", value);
12511276 };
12521277
12531278 const handleCreate = async () => {
@@ -1263,7 +1288,11 @@ function SecretPicker(props: { · 9 unmodified lines
12631288 }
12641289
12651290 try {
1266− const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue });
1291+ const result = await createSecret.mutateAsync({
1292+ name: trimmedName,
1293+ value: newValue,
1294+ ...(newProviderId ? { providerId: newProviderId } : {}),
1295+ });
12671296 onSelect(result.providerId, result.id);
12681297 setShowCreate(false);
12691298 } catch (err) {
@@ -1286,7 +1315,7 @@ function SecretPicker(props: { · 16 unmodified lines
12861315
12871316 // Check if the current handle matches a known secret
12881317 const matchedSecret = items.find((s) => s.id === handle);
1289− const isExternalRef = handle && !matchedSecret && providerId !== "postgres";
1318+ const isExternalRef = handle && !matchedSecret && providerId !== "local";
12901319
12911320 return (
12921321 <div className="space-y-2">
@@ -1299,7 +1328,7 @@ function SecretPicker(props: { · 6 unmodified lines
12991328 {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>}
13001329 {items.map((secret) => (
13011330 <option key={secret.id} value={secret.id}>
1302− {secret.name || secret.id}
1331+ {secret.name || secret.id} ({secret.providerId})
13031332 </option>
13041333 ))}
13051334 {isExternalRef && (
@@ -1338,6 +1367,20 @@ function SecretPicker(props: { · 32 unmodified lines
13381367 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
13391368 />
13401369 </label>
1370+ <label className="block space-y-1">
1371+ <span className="text-[11px] font-medium text-muted-foreground">Store in</span>
1372+ <select
1373+ value={newProviderId}
1374+ onChange={(e) => setNewProviderId(e.target.value)}
1375+ className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1376+ >
1377+ {storableProviders.map((provider) => (
1378+ <option key={provider.id} value={provider.id}>
1379+ {provider.name}
1380+ </option>
1381+ ))}
1382+ </select>
1383+ </label>
13411384 </div>
13421385 <div className="flex items-center justify-end gap-2">
13431386 <button
packages/control-plane/src/api/local/api.ts+3−0
@@ -34,6 +34,7 @@ export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; · 33 unmodified lines
3434
3535 export const SecretListItemSchema = Schema.Struct({
3636 id: Schema.String,
37+ providerId: Schema.String,
3738 name: Schema.NullOr(Schema.String),
3839 purpose: Schema.String,
3940 createdAt: Schema.Number,
@@ -47,6 +48,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines
4748 name: Schema.String,
4849 value: Schema.String,
4950 purpose: Schema.optional(SecretMaterialPurposeSchema),
51+ providerId: Schema.optional(Schema.String),
5052 });
5153
5254 export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;
@@ -71,6 +73,7 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 18 unmodified lines
7173
7274 export const UpdateSecretResultSchema = Schema.Struct({
7375 id: Schema.String,
76+ providerId: Schema.String,
7477 name: Schema.NullOr(Schema.String),
7578 purpose: Schema.String,
7679 createdAt: Schema.Number,
packages/control-plane/src/api/local/http.ts+89−48
@@ -3,7 +3,6 @@ import { · 2 unmodified lines
33 } from "@effect/platform";
44 import * as Effect from "effect/Effect";
55 import * as Option from "effect/Option";
6−import { randomUUID } from "node:crypto";
76
87 import { SecretMaterialIdSchema } from "#schema";
98 import {
@@ -11,10 +10,14 @@ import { · 1 unmodified line
1110 } from "../../runtime/local-operations";
1211 import { requireRuntimeLocalWorkspace } from "../../runtime/local-runtime-context";
1312 import {
13+ createDefaultSecretMaterialDeleter,
14+ createDefaultSecretMaterialStorer,
15+ createDefaultSecretMaterialUpdater,
1416 ENV_SECRET_PROVIDER_ID,
1517 KEYCHAIN_SECRET_PROVIDER_ID,
1618 LOCAL_SECRET_PROVIDER_ID,
1719 parseSecretStoreProviderId,
20+ resolveDefaultSecretStoreProviderId,
1821 } from "../../runtime/secret-material-providers";
1922 import { RuntimeSourceStoreService } from "../../runtime/source-store";
2023 import { ControlPlaneStore } from "../../runtime/store";
@@ -35,6 +38,8 @@ import { · 14 unmodified lines
3538 const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
3639
3740 const getInstanceConfig = (): Effect.Effect<InstanceConfig> => {
41+ const explicitDefaultStoreProvider =
42+ parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]);
3843 const providers: SecretProvider[] = [
3944 {
4045 id: LOCAL_SECRET_PROVIDER_ID,
@@ -47,7 +52,9 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 6 unmodified lines
4752 providers.push({
4853 id: KEYCHAIN_SECRET_PROVIDER_ID,
4954 name: process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring",
50− canStore: true,
55+ canStore:
56+ process.platform === "darwin"
57+ || explicitDefaultStoreProvider === KEYCHAIN_SECRET_PROVIDER_ID,
5158 });
5259 }
5360
@@ -57,15 +64,15 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 3 unmodified lines
5764 canStore: false,
5865 });
5966
60− const defaultStoreProvider =
61− parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV])
62− ?? LOCAL_SECRET_PROVIDER_ID;
63−
64− return Effect.succeed({
65− platform: process.platform,
66− secretProviders: providers,
67− defaultSecretStoreProvider: defaultStoreProvider,
68− });
67+ return resolveDefaultSecretStoreProviderId({
68+ storeProviderId: explicitDefaultStoreProvider ?? undefined,
69+ }).pipe(
70+ Effect.map((resolvedDefaultStoreProvider) => ({
71+ platform: process.platform,
72+ secretProviders: providers,
73+ defaultSecretStoreProvider: resolvedDefaultStoreProvider,
74+ })),
75+ );
6976 };
7077
7178 const storageError = (message: string) =>
@@ -105,16 +112,19 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 33 unmodified lines
105112 Effect.mapError(() => storageError("Failed loading linked sources.")),
106113 );
107114 return rows.map((row) => ({
108− ...row,
109− linkedSources: linkedSourcesMap.get(row.id) ?? [],
110− }));
111− }),
115+ ...row,
116+ linkedSources: linkedSourcesMap.get(row.id) ?? [],
117+ }));
118+ }),
112119 )
113120 .handle("createSecret", ({ payload }) =>
114121 Effect.gen(function* () {
115122 const name = payload.name.trim();
116123 const value = payload.value;
117124 const purpose = payload.purpose ?? "auth_material";
125+ const requestedProviderId = payload.providerId === undefined
126+ ? null
127+ : parseSecretStoreProviderId(payload.providerId);
118128
119129 if (name.length === 0) {
120130 return yield* Effect.fail(
@@ -125,29 +135,46 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 4 unmodified lines
125135 }),
126136 );
127137 }
138+ if (payload.providerId !== undefined && requestedProviderId === null) {
139+ return yield* Effect.fail(
140+ new ControlPlaneBadRequestError({
141+ operation: "secrets.create",
142+ message: `Unsupported secret provider: ${payload.providerId}`,
143+ details: `Unsupported secret provider: ${payload.providerId}`,
144+ }),
145+ );
146+ }
128147
129148 const store = yield* ControlPlaneStore;
130− const now = Date.now();
131− const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
132−
133− yield* store.secretMaterials.upsert({
134− id,
149+ const storeSecretMaterial = createDefaultSecretMaterialStorer({
150+ rows: store,
151+ ...(requestedProviderId ? { storeProviderId: requestedProviderId } : {}),
152+ });
153+ const ref = yield* storeSecretMaterial({
135154 name,
136155 purpose,
137156 value,
138− createdAt: now,
139− updatedAt: now,
140157 }).pipe(
141− Effect.mapError(() => storageError("Failed creating secret.")),
158+ Effect.mapError((cause) => storageError(
159+ cause instanceof Error ? cause.message : "Failed creating secret.",
160+ )),
161+ );
162+ const secretId = SecretMaterialIdSchema.make(ref.handle);
163+ const created = yield* store.secretMaterials.getById(secretId).pipe(
164+ Effect.mapError(() => storageError("Failed loading created secret.")),
142165 );
143166
167+ if (Option.isNone(created)) {
168+ return yield* Effect.fail(storageError(`Created secret not found: ${ref.handle}`));
169+ }
170+
144171 return {
145− id,
146− name,
147− providerId: LOCAL_SECRET_PROVIDER_ID,
148− purpose,
149− createdAt: now,
150− updatedAt: now,
172+ id: created.value.id,
173+ name: created.value.name,
174+ providerId: created.value.providerId,
175+ purpose: created.value.purpose,
176+ createdAt: created.value.createdAt,
177+ updatedAt: created.value.updatedAt,
151178 } satisfies CreateSecretResult;
152179 }),
153180 )
@@ -174,26 +201,26 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 20 unmodified lines
174201 if (payload.name !== undefined) update.name = payload.name.trim() || null;
175202 if (payload.value !== undefined) update.value = payload.value;
176203
177− const updated = yield* store.secretMaterials.updateById(secretId, update).pipe(
204+ const updateSecretMaterial = createDefaultSecretMaterialUpdater({
205+ rows: store,
206+ });
207+ const updated = yield* updateSecretMaterial({
208+ ref: {
209+ providerId: existing.value.providerId,
210+ handle: existing.value.id,
211+ },
212+ ...update,
213+ }).pipe(
178214 Effect.mapError(() => storageError("Failed updating secret.")),
179215 );
180216
181− if (Option.isNone(updated)) {
182− return yield* Effect.fail(
183− new ControlPlaneNotFoundError({
184− operation: "secrets.update",
185− message: `Secret not found after update: ${path.secretId}`,
186− details: `Secret not found after update: ${path.secretId}`,
187− }),
188− );
189− }
190−
191217 return {
192− id: updated.value.id,
193− name: updated.value.name,
194− purpose: updated.value.purpose,
195− createdAt: updated.value.createdAt,
196− updatedAt: updated.value.updatedAt,
218+ id: updated.id,
219+ providerId: updated.providerId,
220+ name: updated.name,
221+ purpose: updated.purpose,
222+ createdAt: updated.createdAt,
223+ updatedAt: updated.updatedAt,
197224 } satisfies UpdateSecretResult;
198225 }),
199226 )
@@ -202,11 +229,11 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 2 unmodified lines
202229 const secretId = SecretMaterialIdSchema.make(path.secretId);
203230 const store = yield* ControlPlaneStore;
204231
205− const removed = yield* store.secretMaterials.removeById(secretId).pipe(
206− Effect.mapError(() => storageError("Failed removing secret.")),
232+ const existing = yield* store.secretMaterials.getById(secretId).pipe(
233+ Effect.mapError(() => storageError("Failed looking up secret.")),
207234 );
208235
209− if (!removed) {
236+ if (Option.isNone(existing)) {
210237 return yield* Effect.fail(
211238 new ControlPlaneNotFoundError({
212239 operation: "secrets.delete",
@@ -216,6 +243,20 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 3 unmodified lines
216243 );
217244 }
218245
246+ const deleteSecretMaterial = createDefaultSecretMaterialDeleter({
247+ rows: store,
248+ });
249+ const removed = yield* deleteSecretMaterial({
250+ providerId: existing.value.providerId,
251+ handle: existing.value.id,
252+ }).pipe(
253+ Effect.mapError(() => storageError("Failed removing secret.")),
254+ );
255+
256+ if (!removed) {
257+ return yield* Effect.fail(storageError(`Failed removing secret: ${path.secretId}`));
258+ }
259+
219260 return { removed: true };
220261 }),
221262 ),
packages/control-plane/src/runtime/control-plane-runtime.test.ts+88−0
@@ -197,6 +197,94 @@ describe("control-plane-runtime", () => { · 196 unmodified lines
197197 60_000,
198198 );
199199
200+ it.scoped("manages local secrets through provider-backed metadata rows", () =>
201+ Effect.gen(function* () {
202+ const runtime = yield* makeRuntime;
203+
204+ const created = yield* withControlPlaneClient(
205+ { runtime },
206+ (client) =>
207+ client.local.createSecret({
208+ payload: {
209+ name: "GitHub PAT",
210+ value: "ghp_test_token",
211+ providerId: "local",
212+ },
213+ }),
214+ );
215+
216+ expect(created.providerId).toBe("local");
217+
218+ const listed = yield* withControlPlaneClient(
219+ { runtime },
220+ (client) => client.local.listSecrets({}),
221+ );
222+ expect(listed).toEqual([
223+ expect.objectContaining({
224+ id: created.id,
225+ providerId: "local",
226+ name: "GitHub PAT",
227+ }),
228+ ]);
229+
230+ const updated = yield* withControlPlaneClient(
231+ { runtime },
232+ (client) =>
233+ client.local.updateSecret({
234+ path: { secretId: created.id },
235+ payload: {
236+ name: "GitHub PAT Updated",
237+ value: "ghp_test_token_v2",
238+ },
239+ }),
240+ );
241+ expect(updated.providerId).toBe("local");
242+ expect(updated.name).toBe("GitHub PAT Updated");
243+
244+ const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById(
245+ SecretMaterialIdSchema.make(created.id),
246+ );
247+ assertTrue(Option.isSome(storedSecret));
248+ expect(storedSecret.value.providerId).toBe("local");
249+ expect(storedSecret.value.value).toBe("ghp_test_token_v2");
250+
251+ const removed = yield* withControlPlaneClient(
252+ { runtime },
253+ (client) =>
254+ client.local.deleteSecret({
255+ path: { secretId: created.id },
256+ }),
257+ );
258+ expect(removed.removed).toBe(true);
259+ }),
260+ );
261+
262+ it.scoped("reports safe secret storage options for the current platform", () =>
263+ Effect.gen(function* () {
264+ const runtime = yield* makeRuntime;
265+
266+ const config = yield* withControlPlaneClient(
267+ { runtime },
268+ (client) => client.local.config({}),
269+ );
270+
271+ const keychainProvider =
272+ config.secretProviders.find((provider) => provider.id === "keychain") ?? null;
273+
274+ if (process.platform === "linux") {
275+ expect(config.defaultSecretStoreProvider).toBe("local");
276+ expect(keychainProvider?.canStore).toBe(false);
277+ return;
278+ }
279+
280+ if (process.platform === "darwin") {
281+ expect(keychainProvider?.canStore).toBe(true);
282+ return;
283+ }
284+
285+ expect(keychainProvider).toBeNull();
286+ }),
287+ );
200288
201289 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>
202290 Effect.gen(function* () {
packages/control-plane/src/runtime/local-control-plane-store.test.ts+48−2
@@ -1,6 +1,6 @@
1−import { existsSync, mkdtempSync, statSync } from "node:fs";
1+import { existsSync, mkdtempSync, mkdirSync, statSync, writeFileSync } from "node:fs";
22 import { tmpdir } from "node:os";
3−import { join } from "node:path";
3+import { dirname, join } from "node:path";
44 import { describe, expect, it } from "@effect/vitest";
55 import * as Effect from "effect/Effect";
66
@@ -62,4 +62,50 @@ describe("local-control-plane-store", () => { · 55 unmodified lines
6262 }
6363 }),
6464 );
65+
66+ it.effect("decodes legacy local secret rows without provider metadata", () =>
67+ Effect.gen(function* () {
68+ const context = makeContext();
69+ const expectedPath = localControlPlaneStatePath(context);
70+ mkdirSync(dirname(expectedPath), { recursive: true });
71+
72+ writeFileSync(
73+ expectedPath,
74+ JSON.stringify({
75+ version: 1,
76+ authArtifacts: [],
77+ authLeases: [],
78+ sourceOauthClients: [],
79+ sourceAuthSessions: [],
80+ secretMaterials: [
81+ {
82+ id: "sec_legacy",
83+ name: "Legacy token",
84+ purpose: "auth_material",
85+ value: "secret-value",
86+ createdAt: 1,
87+ updatedAt: 2,
88+ },
89+ ],
90+ executions: [],
91+ executionInteractions: [],
92+ executionSteps: [],
93+ }),
94+ );
95+
96+ const loaded = yield* loadLocalControlPlaneState(context);
97+ expect(loaded.secretMaterials).toEqual([
98+ {
99+ id: "sec_legacy",
100+ providerId: "local",
101+ handle: "sec_legacy",
102+ name: "Legacy token",
103+ purpose: "auth_material",
104+ value: "secret-value",
105+ createdAt: 1,
106+ updatedAt: 2,
107+ },
108+ ]);
109+ }),
110+ );
65111 });
packages/control-plane/src/runtime/secret-material-providers.ts+621−193
@@ -7,6 +7,7 @@ import { isAbsolute } from "node:path"; · 6 unmodified lines
77 import {
88 type LocalConfigSecretProvider,
99 type LocalExecutorConfig,
10+ type SecretMaterial,
1011 type SecretMaterialPurpose,
1112 SecretMaterialIdSchema,
1213 type SecretRef,
@@ -50,6 +51,19 @@ export type DeleteSecretMaterial = ( · 37 unmodified lines
5051 ref: SecretRef,
5152 ) => Effect.Effect<boolean, Error, never>;
5253
54+export type UpdateSecretMaterial = (input: {
55+ ref: SecretRef;
56+ name?: string | null;
57+ value?: string;
58+}) => Effect.Effect<{
59+ id: string;
60+ providerId: string;
61+ name: string | null;
62+ purpose: string;
63+ createdAt: number;
64+ updatedAt: number;
65+}, Error, never>;
66+
5367 export class SecretMaterialResolverService extends Context.Tag(
5468 "#runtime/SecretMaterialResolverService",
5569 )<SecretMaterialResolverService, ResolveSecretMaterial>() {}
@@ -62,6 +76,10 @@ export class SecretMaterialDeleterService extends Context.Tag( · 6 unmodified lines
6276 "#runtime/SecretMaterialDeleterService",
6377 )<SecretMaterialDeleterService, DeleteSecretMaterial>() {}
6478
79+export class SecretMaterialUpdaterService extends Context.Tag(
80+ "#runtime/SecretMaterialUpdaterService",
81+)<SecretMaterialUpdaterService, UpdateSecretMaterial>() {}
82+
6583 type SecretMaterialProviderRuntime = {
6684 rows: ControlPlaneStoreShape;
6785 env: NodeJS.ProcessEnv;
@@ -83,6 +101,19 @@ type SecretMaterialProvider = { · 15 unmodified lines
83101 name?: string | null;
84102 runtime: SecretMaterialProviderRuntime;
85103 }) => Effect.Effect<SecretRef, Error, never>;
104+ update?: (input: {
105+ ref: SecretRef;
106+ name?: string | null;
107+ value?: string;
108+ runtime: SecretMaterialProviderRuntime;
109+ }) => Effect.Effect<{
110+ id: string;
111+ providerId: string;
112+ name: string | null;
113+ purpose: string;
114+ createdAt: number;
115+ updatedAt: number;
116+ }, Error, never>;
86117 remove?: (input: {
87118 ref: SecretRef;
88119 runtime: SecretMaterialProviderRuntime;
@@ -98,10 +129,20 @@ type SpawnResult = { · 9 unmodified lines
98129 };
99130
100131 const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor";
132+const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000;
101133 const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS";
102134 const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
103135 const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";
104136
137+type SecretMaterialSummary = {
138+ id: string;
139+ providerId: string;
140+ name: string | null;
141+ purpose: string;
142+ createdAt: number;
143+ updatedAt: number;
144+};
145+
105146 const trimOrNull = (value: string | null | undefined): string | null => {
106147 if (value === null || value === undefined) {
107148 return null;
@@ -134,11 +175,6 @@ export const parseSecretStoreProviderId = (value: string | undefined): SecretSto · 26 unmodified lines
134175 const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean =>
135176 value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]);
136177
137−const resolveSecretStoreProviderId = (value: SecretStoreProviderId | undefined): SecretStoreProviderId =>
138− value
139− ?? parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV])
140− ?? LOCAL_SECRET_PROVIDER_ID;
141−
142178 const resolveKeychainServiceName = (value: string | undefined): string =>
143179 trimOrNull(value)
144180 ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])
@@ -149,12 +185,38 @@ const ensureNonEmptyString = (value: string | undefined): string | null => { · 4 unmodified lines
149185 return trimmed && trimmed.length > 0 ? trimmed : null;
150186 };
151187
188+const toSecretMaterialSummary = (
189+ material: Pick<SecretMaterial, "id" | "providerId" | "name" | "purpose" | "createdAt" | "updatedAt">,
190+): SecretMaterialSummary => ({
191+ id: material.id,
192+ providerId: material.providerId,
193+ name: material.name,
194+ purpose: material.purpose,
195+ createdAt: material.createdAt,
196+ updatedAt: material.updatedAt,
197+});
198+
199+const keychainCommandForPlatform = (
200+ platform: NodeJS.Platform = process.platform,
201+): string | null => {
202+ if (platform === "darwin") {
203+ return "security";
204+ }
205+
206+ if (platform === "linux") {
207+ return "secret-tool";
208+ }
209+
210+ return null;
211+};
212+
152213 const runCommand = (input: {
153214 command: string;
154215 args: ReadonlyArray<string>;
155216 stdin?: string;
156217 env?: NodeJS.ProcessEnv;
157218 operation: string;
219+ timeoutMs?: number;
158220 }): Effect.Effect<SpawnResult, Error, never> =>
159221 Effect.tryPromise({
160222 try: () =>
@@ -166,6 +228,22 @@ const runCommand = (input: { · 5 unmodified lines
166228
167229 let stdout = "";
168230 let stderr = "";
231+ let settled = false;
232+ const timeout = input.timeoutMs === undefined
233+ ? null
234+ : setTimeout(() => {
235+ if (settled) {
236+ return;
237+ }
238+
239+ settled = true;
240+ child.kill("SIGKILL");
241+ reject(
242+ new Error(
243+ `${input.operation}: '${input.command}' timed out after ${input.timeoutMs}ms`,
244+ ),
245+ );
246+ }, input.timeoutMs);
169247
170248 child.stdout.on("data", (chunk) => {
171249 stdout += chunk.toString("utf8");
@@ -176,10 +254,24 @@ const runCommand = (input: { · 4 unmodified lines
176254 });
177255
178256 child.on("error", (error) => {
257+ if (settled) {
258+ return;
259+ }
260+ settled = true;
261+ if (timeout) {
262+ clearTimeout(timeout);
263+ }
179264 reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`));
180265 });
181266
182267 child.on("close", (code) => {
268+ if (settled) {
269+ return;
270+ }
271+ settled = true;
272+ if (timeout) {
273+ clearTimeout(timeout);
274+ }
183275 resolve({
184276 exitCode: code ?? 0,
185277 stdout,
@@ -215,6 +307,79 @@ const ensureCommandSuccess = (input: { · 29 unmodified lines
215307 return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`));
216308 };
217309
310+const commandAvailabilityCache = new Map<string, Promise<boolean>>();
311+
312+const commandExists = (command: string): Effect.Effect<boolean, Error, never> =>
313+ Effect.tryPromise({
314+ try: async () => {
315+ const cached = commandAvailabilityCache.get(command);
316+ if (cached) {
317+ return cached;
318+ }
319+
320+ const probe = new Promise<boolean>((resolve) => {
321+ const child = spawn(command, ["--help"], {
322+ stdio: "ignore",
323+ });
324+ const timeout = setTimeout(() => {
325+ child.kill("SIGKILL");
326+ resolve(false);
327+ }, 2_000);
328+
329+ child.on("error", () => {
330+ clearTimeout(timeout);
331+ resolve(false);
332+ });
333+ child.on("close", () => {
334+ clearTimeout(timeout);
335+ resolve(true);
336+ });
337+ });
338+
339+ commandAvailabilityCache.set(command, probe);
340+ const available = await probe;
341+ if (!available) {
342+ commandAvailabilityCache.delete(command);
343+ }
344+ return available;
345+ },
346+ catch: (cause) =>
347+ cause instanceof Error
348+ ? cause
349+ : new Error(`command.exists: failed checking '${command}': ${String(cause)}`),
350+ });
351+
352+const isKeychainProviderAvailable = (
353+ platform: NodeJS.Platform = process.platform,
354+): Effect.Effect<boolean, Error, never> => {
355+ const command = keychainCommandForPlatform(platform);
356+ return command === null ? Effect.succeed(false) : commandExists(command);
357+};
358+
359+export const resolveDefaultSecretStoreProviderId = (input: {
360+ storeProviderId?: SecretStoreProviderId;
361+ env?: NodeJS.ProcessEnv;
362+ platform?: NodeJS.Platform;
363+} = {}): Effect.Effect<SecretStoreProviderId, never, never> => {
364+ const localProviderId = LOCAL_SECRET_PROVIDER_ID as SecretStoreProviderId;
365+ const explicit =
366+ input.storeProviderId
367+ ?? parseSecretStoreProviderId((input.env ?? process.env)[SECRET_STORE_PROVIDER_ENV]);
368+ if (explicit) {
369+ return Effect.succeed(explicit);
370+ }
371+
372+ if ((input.platform ?? process.platform) !== "darwin") {
373+ return Effect.succeed(localProviderId);
374+ }
375+
376+ return isKeychainProviderAvailable(input.platform).pipe(
377+ Effect.map((available): SecretStoreProviderId =>
378+ available ? KEYCHAIN_SECRET_PROVIDER_ID : localProviderId),
379+ Effect.catchAll(() => Effect.succeed(localProviderId)),
380+ ) as Effect.Effect<SecretStoreProviderId, never, never>;
381+};
382+
218383 const parseKeychainHandle = (handle: string): string | null => {
219384 if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) {
220385 return null;
@@ -223,6 +388,248 @@ const parseKeychainHandle = (handle: string): string | null => { · 2 unmodified lines
223388 return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length));
224389 };
225390
391+const loadStoredSecretMaterial = (input: {
392+ id: string;
393+ runtime: SecretMaterialProviderRuntime;
394+ operation: string;
395+}) =>
396+ Effect.gen(function* () {
397+ const materialId = SecretMaterialIdSchema.make(input.id);
398+ const stored = yield* input.runtime.rows.secretMaterials.getById(materialId);
399+ if (Option.isNone(stored)) {
400+ return yield* Effect.fail(
401+ new Error(`${input.operation}: secret material not found: ${input.id}`),
402+ );
403+ }
404+
405+ return stored.value;
406+ });
407+
408+const createSecretMaterialMetadata = (input: {
409+ providerId: SecretStoreProviderId;
410+ providerHandle: string;
411+ purpose: SecretMaterialPurpose;
412+ value: string | null;
413+ name?: string | null;
414+ runtime: SecretMaterialProviderRuntime;
415+}) =>
416+ Effect.gen(function* () {
417+ const now = Date.now();
418+ const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
419+ yield* input.runtime.rows.secretMaterials.upsert({
420+ id,
421+ providerId: input.providerId,
422+ handle: input.providerHandle,
423+ name: trimOrNull(input.name),
424+ purpose: input.purpose,
425+ value: input.value,
426+ createdAt: now,
427+ updatedAt: now,
428+ });
429+
430+ return {
431+ providerId: input.providerId,
432+ handle: id,
433+ } satisfies SecretRef;
434+ });
435+
436+const loadManagedKeychainRef = (input: {
437+ ref: SecretRef;
438+ runtime: SecretMaterialProviderRuntime;
439+ operation: string;
440+}) =>
441+ Effect.gen(function* () {
442+ const legacyHandle = parseKeychainHandle(input.ref.handle);
443+ if (legacyHandle !== null) {
444+ return {
445+ providerHandle: legacyHandle,
446+ material: null,
447+ };
448+ }
449+
450+ const material = yield* loadStoredSecretMaterial({
451+ id: input.ref.handle,
452+ runtime: input.runtime,
453+ operation: input.operation,
454+ });
455+ if (material.providerId !== KEYCHAIN_SECRET_PROVIDER_ID) {
456+ return yield* Effect.fail(
457+ new Error(
458+ `${input.operation}: secret ${material.id} is stored in provider '${material.providerId}', not '${KEYCHAIN_SECRET_PROVIDER_ID}'`,
459+ ),
460+ );
461+ }
462+
463+ return {
464+ providerHandle: material.handle,
465+ material,
466+ };
467+ });
468+
469+const readKeychainSecretValue = (input: {
470+ providerHandle: string;
471+ runtime: SecretMaterialProviderRuntime;
472+}) => {
473+ switch (process.platform) {
474+ case "darwin":
475+ return runCommand({
476+ command: "security",
477+ args: [
478+ "find-generic-password",
479+ "-a",
480+ input.providerHandle,
481+ "-s",
482+ input.runtime.keychainServiceName,
483+ "-w",
484+ ],
485+ operation: "keychain.get",
486+ timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
487+ }).pipe(
488+ Effect.flatMap((result) =>
489+ ensureCommandSuccess({
490+ result,
491+ operation: "keychain.get",
492+ message: "Failed loading secret from macOS keychain",
493+ }),
494+ ),
495+ Effect.map((result) => result.stdout.trimEnd()),
496+ );
497+ case "linux":
498+ return runCommand({
499+ command: "secret-tool",
500+ args: [
501+ "lookup",
502+ "service",
503+ input.runtime.keychainServiceName,
504+ "account",
505+ input.providerHandle,
506+ ],
507+ operation: "keychain.get",
508+ timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
509+ }).pipe(
510+ Effect.flatMap((result) =>
511+ ensureCommandSuccess({
512+ result,
513+ operation: "keychain.get",
514+ message: "Failed loading secret from desktop keyring",
515+ }),
516+ ),
517+ Effect.map((result) => result.stdout.trimEnd()),
518+ );
519+ default:
520+ return Effect.fail(
521+ new Error(`keychain.get: keychain provider is unsupported on platform '${process.platform}'`),
522+ );
523+ }
524+};
525+
526+const writeKeychainSecretValue = (input: {
527+ providerHandle: string;
528+ name?: string | null;
529+ value: string;
530+ runtime: SecretMaterialProviderRuntime;
531+}) => {
532+ const secretName = trimOrNull(input.name);
533+
534+ switch (process.platform) {
535+ case "darwin":
536+ return runCommand({
537+ command: "security",
538+ args: [
539+ "add-generic-password",
540+ "-a",
541+ input.providerHandle,
542+ "-s",
543+ input.runtime.keychainServiceName,
544+ "-w",
545+ input.value,
546+ ...(secretName ? ["-l", secretName] : []),
547+ "-U",
548+ ],
549+ operation: "keychain.put",
550+ timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
551+ }).pipe(
552+ Effect.flatMap((result) =>
553+ ensureCommandSuccess({
554+ result,
555+ operation: "keychain.put",
556+ message: "Failed storing secret in macOS keychain",
557+ }),
558+ ),
559+ );
560+ case "linux":
561+ return runCommand({
562+ command: "secret-tool",
563+ args: [
564+ "store",
565+ "--label",
566+ secretName ?? input.runtime.keychainServiceName,
567+ "service",
568+ input.runtime.keychainServiceName,
569+ "account",
570+ input.providerHandle,
571+ ],
572+ stdin: input.value,
573+ operation: "keychain.put",
574+ timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
575+ }).pipe(
576+ Effect.flatMap((result) =>
577+ ensureCommandSuccess({
578+ result,
579+ operation: "keychain.put",
580+ message: "Failed storing secret in desktop keyring",
581+ }),
582+ ),
583+ );
584+ default:
585+ return Effect.fail(
586+ new Error(`keychain.put: keychain provider is unsupported on platform '${process.platform}'`),
587+ );
588+ }
589+};
590+
591+const deleteKeychainSecretValue = (input: {
592+ providerHandle: string;
593+ runtime: SecretMaterialProviderRuntime;
594+}) => {
595+ switch (process.platform) {
596+ case "darwin":
597+ return runCommand({
598+ command: "security",
599+ args: [
600+ "delete-generic-password",
601+ "-a",
602+ input.providerHandle,
603+ "-s",
604+ input.runtime.keychainServiceName,
605+ ],
606+ operation: "keychain.delete",
607+ timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
608+ }).pipe(
609+ Effect.map((result) => result.exitCode === 0),
610+ );
611+ case "linux":
612+ return runCommand({
613+ command: "secret-tool",
614+ args: [
615+ "clear",
616+ "service",
617+ input.runtime.keychainServiceName,
618+ "account",
619+ input.providerHandle,
620+ ],
621+ operation: "keychain.delete",
622+ timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
623+ }).pipe(
624+ Effect.map((result) => result.exitCode === 0),
625+ );
626+ default:
627+ return Effect.fail(
628+ new Error(`keychain.delete: keychain provider is unsupported on platform '${process.platform}'`),
629+ );
630+ }
631+};
632+
226633 const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({
227634 resolve: ({ ref, context }) => {
228635 const value = ensureNonEmptyString(context.params?.[ref.handle]);
@@ -260,32 +667,75 @@ const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({ · 31 unmodified lines
260667 const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({
261668 resolve: ({ ref, runtime }) =>
262669 Effect.gen(function* () {
263− const materialId = SecretMaterialIdSchema.make(ref.handle);
264− const stored = yield* runtime.rows.secretMaterials.getById(materialId);
265− if (Option.isNone(stored)) {
266− return yield* Effect.fail(new Error(`Secret material not found: ${ref.handle}`));
670+ const stored = yield* loadStoredSecretMaterial({
671+ id: ref.handle,
672+ runtime,
673+ operation: "local.get",
674+ });
675+ if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) {
676+ return yield* Effect.fail(
677+ new Error(
678+ `local.get: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`,
679+ ),
680+ );
681+ }
682+ if (stored.value === null) {
683+ return yield* Effect.fail(
684+ new Error(`local.get: secret ${stored.id} does not have a local value`),
685+ );
267686 }
268687
269− return stored.value.value;
688+ return stored.value;
270689 }),
271690
272691 store: ({ purpose, value, name, runtime }) =>
692+ createSecretMaterialMetadata({
693+ providerId: LOCAL_SECRET_PROVIDER_ID,
694+ providerHandle: `local:${randomUUID()}`,
695+ purpose,
696+ value,
697+ name,
698+ runtime,
699+ }),
700+
701+ update: ({ ref, name, value, runtime }) =>
273702 Effect.gen(function* () {
274− const now = Date.now();
275− const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
276− yield* runtime.rows.secretMaterials.upsert({
277− id,
278− name: trimOrNull(name),
279− purpose,
280− value,
281− createdAt: now,
282− updatedAt: now,
703+ const stored = yield* loadStoredSecretMaterial({
704+ id: ref.handle,
705+ runtime,
706+ operation: "local.update",
283707 });
708+ if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) {
709+ return yield* Effect.fail(
710+ new Error(
711+ `local.update: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`,
712+ ),
713+ );
714+ }
715+
716+ if (name === undefined && value === undefined) {
717+ return toSecretMaterialSummary(stored);
718+ }
719+
720+ const updated = yield* runtime.rows.secretMaterials.updateById(
721+ stored.id,
722+ {
723+ ...(name !== undefined ? { name } : {}),
724+ ...(value !== undefined ? { value } : {}),
725+ },
726+ );
727+ if (Option.isNone(updated)) {
728+ return yield* Effect.fail(new Error(`local.update: secret material not found: ${stored.id}`));
729+ }
284730
285731 return {
286− providerId: LOCAL_SECRET_PROVIDER_ID,
287− handle: id,
288− } satisfies SecretRef;
732+ id: updated.value.id,
733+ providerId: updated.value.providerId,
734+ name: updated.value.name,
735+ purpose: updated.value.purpose,
736+ createdAt: updated.value.createdAt,
737+ updatedAt: updated.value.updatedAt,
738+ } satisfies SecretMaterialSummary;
289739 }),
290740
291741 remove: ({ ref, runtime }) =>
@@ -295,190 +745,117 @@ const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ · 3 unmodified lines
295745 }),
296746 });
297747
298−const keychainStoreWithSecurityCli = (): SecretMaterialProvider => ({
299− resolve: ({ ref, runtime }) => {
300− const id = parseKeychainHandle(ref.handle);
301− if (id === null) {
302− return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
303− }
748+const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => ({
749+ resolve: ({ ref, runtime }) =>
750+ Effect.gen(function* () {
751+ const loaded = yield* loadManagedKeychainRef({
752+ ref,
753+ runtime,
754+ operation: "keychain.get",
755+ });
304756
305− return runCommand({
306− command: "security",
307− args: [
308− "find-generic-password",
309− "-a",
310− id,
311− "-s",
312− runtime.keychainServiceName,
313− "-w",
314− ],
315− operation: "keychain.get",
316− }).pipe(
317− Effect.flatMap((result) =>
318− ensureCommandSuccess({
319− result,
320− operation: "keychain.get",
321− message: "Failed loading secret from macOS keychain",
322− }),
323− ),
324− Effect.map((result) => result.stdout.trimEnd()),
325− );
326− },
757+ return yield* readKeychainSecretValue({
758+ providerHandle: loaded.providerHandle,
759+ runtime,
760+ });
761+ }),
327762
328− store: ({ name, value, runtime }) => {
329− const id = randomUUID();
330−
331− return runCommand({
332− command: "security",
333− args: [
334− "add-generic-password",
335− "-a",
336− id,
337− "-s",
338− runtime.keychainServiceName,
339− "-w",
763+ store: ({ purpose, value, name, runtime }) =>
764+ Effect.gen(function* () {
765+ const providerHandle = randomUUID();
766+ yield* writeKeychainSecretValue({
767+ providerHandle,
768+ name,
340769 value,
341− ...(trimOrNull(name) ? ["-l", trimOrNull(name)!] : []),
342− "-U",
343− ],
344− operation: "keychain.put",
345− }).pipe(
346− Effect.flatMap((result) =>
347− ensureCommandSuccess({
348− result,
349− operation: "keychain.put",
350− message: "Failed storing secret in macOS keychain",
351− }),
352− ),
353− Effect.as({
354− providerId: KEYCHAIN_SECRET_PROVIDER_ID,
355− handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
356− } satisfies SecretRef),
357− );
358− },
770+ runtime,
771+ });
359772
360− remove: ({ ref, runtime }) => {
361− const id = parseKeychainHandle(ref.handle);
362− if (id === null) {
363− return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
364− }
773+ return yield* createSecretMaterialMetadata({
774+ providerId: KEYCHAIN_SECRET_PROVIDER_ID,
775+ providerHandle,
776+ purpose,
777+ value: null,
778+ name,
779+ runtime,
780+ });
781+ }),
365782
366− return runCommand({
367− command: "security",
368− args: [
369− "delete-generic-password",
370− "-a",
371− id,
372− "-s",
373− runtime.keychainServiceName,
374− ],
375− operation: "keychain.delete",
376− }).pipe(
377− Effect.map((result) => result.exitCode === 0),
378− );
379− },
380−});
783+ update: ({ ref, name, value, runtime }) =>
784+ Effect.gen(function* () {
785+ const loaded = yield* loadManagedKeychainRef({
786+ ref,
787+ runtime,
788+ operation: "keychain.update",
789+ });
790+ if (loaded.material === null) {
791+ return yield* Effect.fail(
792+ new Error(`keychain.update: legacy keychain refs cannot be updated: ${ref.handle}`),
793+ );
794+ }
381795
382−const keychainStoreWithSecretTool = (): SecretMaterialProvider => ({
383− resolve: ({ ref, runtime }) => {
384− const id = parseKeychainHandle(ref.handle);
385− if (id === null) {
386− return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
387− }
796+ if (name === undefined && value === undefined) {
797+ return toSecretMaterialSummary(loaded.material);
798+ }
388799
389− return runCommand({
390− command: "secret-tool",
391− args: [
392− "lookup",
393− "service",
394− runtime.keychainServiceName,
395− "account",
396− id,
397− ],
398− operation: "keychain.get",
399− }).pipe(
400− Effect.flatMap((result) =>
401− ensureCommandSuccess({
402− result,
403− operation: "keychain.get",
404− message: "Failed loading secret from desktop keyring",
405− }),
406− ),
407− Effect.map((result) => result.stdout.trimEnd()),
408− );
409− },
800+ const nextName = name ?? loaded.material.name;
801+ const nextValue = value
802+ ?? (yield* readKeychainSecretValue({
803+ providerHandle: loaded.providerHandle,
804+ runtime,
805+ }));
410806
411− store: ({ name, value, runtime }) => {
412− const id = randomUUID();
413−
414− return runCommand({
415− command: "secret-tool",
416− args: [
417− "store",
418− "--label",
419− trimOrNull(name) ?? runtime.keychainServiceName,
420− "service",
421− runtime.keychainServiceName,
422− "account",
423− id,
424− ],
425− stdin: value,
426− operation: "keychain.put",
427− }).pipe(
428− Effect.flatMap((result) =>
429− ensureCommandSuccess({
430− result,
431− operation: "keychain.put",
432− message: "Failed storing secret in desktop keyring",
433− }),
434− ),
435− Effect.as({
436− providerId: KEYCHAIN_SECRET_PROVIDER_ID,
437− handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
438− } satisfies SecretRef),
439− );
440− },
807+ yield* writeKeychainSecretValue({
808+ providerHandle: loaded.providerHandle,
809+ name: nextName,
810+ value: nextValue,
811+ runtime,
812+ });
441813
442− remove: ({ ref, runtime }) => {
443− const id = parseKeychainHandle(ref.handle);
444− if (id === null) {
445− return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
446− }
814+ const updated = yield* runtime.rows.secretMaterials.updateById(
815+ loaded.material.id,
816+ {
817+ name: nextName,
818+ },
819+ );
820+ if (Option.isNone(updated)) {
821+ return yield* Effect.fail(
822+ new Error(`keychain.update: secret material not found: ${loaded.material.id}`),
823+ );
824+ }
447825
448− return runCommand({
449− command: "secret-tool",
450− args: [
451− "clear",
452− "service",
453− runtime.keychainServiceName,
454− "account",
455− id,
456− ],
457− operation: "keychain.delete",
458− }).pipe(
459− Effect.map((result) => result.exitCode === 0),
460− );
461− },
462−});
826+ return {
827+ id: updated.value.id,
828+ providerId: updated.value.providerId,
829+ name: updated.value.name,
830+ purpose: updated.value.purpose,
831+ createdAt: updated.value.createdAt,
832+ updatedAt: updated.value.updatedAt,
833+ } satisfies SecretMaterialSummary;
834+ }),
463835
464−const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => {
465− if (process.platform === "darwin") {
466− return keychainStoreWithSecurityCli();
467− }
836+ remove: ({ ref, runtime }) =>
837+ Effect.gen(function* () {
838+ const loaded = yield* loadManagedKeychainRef({
839+ ref,
840+ runtime,
841+ operation: "keychain.delete",
842+ });
843+ const deleted = yield* deleteKeychainSecretValue({
844+ providerHandle: loaded.providerHandle,
845+ runtime,
846+ });
468847
469− if (process.platform === "linux") {
470− return keychainStoreWithSecretTool();
471− }
848+ if (loaded.material === null) {
849+ return deleted;
850+ }
472851
473− const unsupported = (operation: string) =>
474− Effect.fail(new Error(`${operation}: keychain provider is unsupported on platform '${process.platform}'`));
852+ if (!deleted) {
853+ return false;
854+ }
475855
476− return {
477− resolve: () => unsupported("keychain.get"),
478− store: () => unsupported("keychain.put"),
479− remove: () => Effect.succeed(false),
480− } satisfies SecretMaterialProvider;
481−};
856+ return yield* runtime.rows.secretMaterials.removeById(loaded.material.id);
857+ }),
858+});
482859
483860 const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry =>
484861 new Map([
@@ -707,10 +1084,13 @@ export const createDefaultSecretMaterialStorer = (input: { · 222 unmodified lines
7071084 }): StoreSecretMaterial => {
7081085 const providers = createSecretMaterialProviderRegistry();
7091086 const runtime = createSecretMaterialProviderRuntime(input);
710− const defaultStoreProviderId = resolveSecretStoreProviderId(input.storeProviderId);
7111087
7121088 return ({ purpose, value, name }) =>
7131089 Effect.gen(function* () {
1090+ const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({
1091+ storeProviderId: input.storeProviderId,
1092+ env: runtime.env,
1093+ });
7141094 const provider = yield* getSecretMaterialProvider({
7151095 providers,
7161096 providerId: defaultStoreProviderId,
@@ -731,6 +1111,36 @@ export const createDefaultSecretMaterialStorer = (input: { · 14 unmodified lines
7311111 });
7321112 };
7331113
1114+export const createDefaultSecretMaterialUpdater = (input: {
1115+ rows: ControlPlaneStoreShape;
1116+ dangerouslyAllowEnvSecrets?: boolean;
1117+ keychainServiceName?: string;
1118+}): UpdateSecretMaterial => {
1119+ const providers = createSecretMaterialProviderRegistry();
1120+ const runtime = createSecretMaterialProviderRuntime(input);
1121+
1122+ return ({ ref, name, value }) =>
1123+ Effect.gen(function* () {
1124+ const provider = yield* getSecretMaterialProvider({
1125+ providers,
1126+ providerId: ref.providerId,
1127+ });
1128+
1129+ if (!provider.update) {
1130+ return yield* Effect.fail(
1131+ new Error(`Secret provider ${ref.providerId} does not support updating secret material`),
1132+ );
1133+ }
1134+
1135+ return yield* provider.update({
1136+ ref,
1137+ name,
1138+ value,
1139+ runtime,
1140+ });
1141+ });
1142+};
1143+
7341144 export const createDefaultSecretMaterialDeleter = (input: {
7351145 rows: ControlPlaneStoreShape;
7361146 dangerouslyAllowEnvSecrets?: boolean;
@@ -835,6 +1245,23 @@ export const SecretMaterialDeleterLive = (input: { · 98 unmodified lines
8351245 }),
8361246 );
8371247
1248+export const SecretMaterialUpdaterLive = (input: {
1249+ dangerouslyAllowEnvSecrets?: boolean;
1250+ keychainServiceName?: string;
1251+} = {}) =>
1252+ Layer.effect(
1253+ SecretMaterialUpdaterService,
1254+ Effect.gen(function* () {
1255+ const rows = yield* ControlPlaneStore;
1256+
1257+ return createDefaultSecretMaterialUpdater({
1258+ rows,
1259+ dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets,
1260+ keychainServiceName: input.keychainServiceName,
1261+ });
1262+ }),
1263+ );
1264+
8381265 export const SecretMaterialLive = (input: {
8391266 resolveSecretMaterial?: ResolveSecretMaterial;
8401267 storeProviderId?: SecretStoreProviderId;
@@ -847,4 +1274,5 @@ export const SecretMaterialLive = (input: { · 6 unmodified lines
8471274 SecretMaterialResolverLive(input),
8481275 SecretMaterialStorerLive(input),
8491276 SecretMaterialDeleterLive(input),
1277+ SecretMaterialUpdaterLive(input),
8501278 );
packages/control-plane/src/runtime/store.ts+1−0
@@ -14,6 +14,7 @@ import type { · 13 unmodified lines
1414
1515 type SecretMaterialSummary = {
1616 id: string;
17+ providerId: string;
1718 name: string | null;
1819 purpose: string;
1920 createdAt: number;
packages/control-plane/src/schema/models/secret-material.ts+40−1
@@ -10,7 +10,7 @@ export const SecretMaterialPurposeSchema = Schema.Literal( · 9 unmodified lines
1010 "oauth_client_info",
1111 );
1212
13−export const SecretMaterialSchema = Schema.Struct({
13+const LegacySecretMaterialSchema = Schema.Struct({
1414 id: SecretMaterialIdSchema,
1515 name: Schema.NullOr(Schema.String),
1616 purpose: SecretMaterialPurposeSchema,
@@ -19,5 +19,44 @@ export const SecretMaterialSchema = Schema.Struct({ · 2 unmodified lines
1919 updatedAt: TimestampMsSchema,
2020 });
2121
22+const SecretMaterialStorageSchema = Schema.Struct({
23+ id: SecretMaterialIdSchema,
24+ name: Schema.NullOr(Schema.String),
25+ purpose: SecretMaterialPurposeSchema,
26+ providerId: Schema.String,
27+ handle: Schema.String,
28+ value: Schema.NullOr(Schema.String),
29+ createdAt: TimestampMsSchema,
30+ updatedAt: TimestampMsSchema,
31+});
32+
33+export const SecretMaterialSchema = Schema.transform(
34+ Schema.Union(LegacySecretMaterialSchema, SecretMaterialStorageSchema),
35+ SecretMaterialStorageSchema,
36+ {
37+ strict: false,
38+ decode: (row) => ({
39+ id: row.id,
40+ name: row.name,
41+ purpose: row.purpose,
42+ providerId: "providerId" in row ? row.providerId : "local",
43+ handle: "handle" in row ? row.handle : row.id,
44+ value: row.value,
45+ createdAt: row.createdAt,
46+ updatedAt: row.updatedAt,
47+ }),
48+ encode: (material) => ({
49+ id: material.id,
50+ name: material.name,
51+ purpose: material.purpose,
52+ providerId: material.providerId,
53+ handle: material.handle,
54+ value: material.value,
55+ createdAt: material.createdAt,
56+ updatedAt: material.updatedAt,
57+ }),
58+ },
59+);
60+
2261 export type SecretMaterialPurpose = typeof SecretMaterialPurposeSchema.Type;
2362 export type SecretMaterial = typeof SecretMaterialSchema.Type;