Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/defer-irreversible-cleanup.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
"executor": minor
"executor": patch
---

**Irreversible cleanup now waits for the transaction to commit, and plugins can do the same**
Expand Down
11 changes: 11 additions & 0 deletions .changeset/onepassword-multiple-vaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"executor": patch
---

**The 1Password provider can now be scoped to several vaults, with explicit per-vault addressing**

The provider previously bound exactly one vault. The configuration now holds a set of vaults selected with checkboxes, and every reference is explicit about which vault it means: the item picker is a searchable list that shows each item's vault and stores a vault-qualified `op://` reference, so identically-titled items in different vaults can never collide. A bare item name is accepted only when it matches exactly one item across the selected vaults — a name that exists in more than one place fails with an error naming the matching vaults instead of silently picking one.

Reopening the vault or item pickers no longer flashes a loading state: listings are retained and re-validated in the background, so the last-known list renders instantly.

Configurations saved before this change keep working: the stored single-vault shape is read as a one-vault list and upgrades to the new shape the next time it is saved. The `status` tool reports `vaultNames` for all configured vaults and flags any configured vault the account can no longer see. Provider entries also gained an optional `group` label, which pickers use to show where an item lives.
6 changes: 5 additions & 1 deletion packages/core/api/src/handlers/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export const ProvidersHandlers = HttpApiBuilder.group(ExecutorApi, "providers",
Effect.gen(function* () {
const executor = yield* ExecutorService;
const entries = yield* executor.providers.items(path.key);
return entries.map((entry) => ({ id: entry.id, name: entry.name }));
return entries.map((entry) => ({
id: entry.id,
name: entry.name,
...(entry.group !== undefined ? { group: entry.group } : {}),
}));
}),
),
),
Expand Down
1 change: 1 addition & 0 deletions packages/core/api/src/providers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const ProviderParams = { key: ProviderKey };
const ProviderEntryResponse = Schema.Struct({
id: ProviderItemId,
name: Schema.String,
group: Schema.optional(Schema.String),
});

// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions packages/core/sdk/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export interface ProviderEntry {
* a connection can reference it without core knowing its internal shape. */
readonly id: ProviderItemId;
readonly name: string;
/** Optional provenance label for pickers when a provider spans several
* containers (a 1Password vault name). Purely presentational. */
readonly group?: string;
}

export interface CredentialProvider {
Expand Down
153 changes: 93 additions & 60 deletions packages/plugins/onepassword/src/react/OnePasswordSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { useEffect, useRef, useState } from "react";
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import * as Exit from "effect/Exit";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { Button } from "@executor-js/react/components/button";
import { Checkbox } from "@executor-js/react/components/checkbox";
import { Input } from "@executor-js/react/components/input";
import { Label } from "@executor-js/react/components/label";
import {
Expand Down Expand Up @@ -35,10 +36,10 @@ import {
removeOnePasswordConfig,
onepasswordWriteKeys,
} from "./atoms";
import type { RedactedOnePasswordConfig } from "../sdk/types";
import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";

// ---------------------------------------------------------------------------
// Vault picker
// Vault picker — multi-select
// ---------------------------------------------------------------------------

const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults";
Expand All @@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => {
function VaultPicker(props: {
authKind: "desktop-app" | "service-account";
accountName: string;
vaultId: string;
onVaultSelect: (id: string, name: string) => void;
selected: ReadonlyArray<Vault>;
onSelectedChange: (vaults: ReadonlyArray<Vault>) => void;
}) {
const account = props.accountName.trim();
const vaultsResult = useAtomValue(onepasswordVaultsAtom(props.authKind, account));
const vaultsAtom = onepasswordVaultsAtom(props.authKind, account);
const vaultsResult = useAtomValue(vaultsAtom);
const refreshVaults = useAtomRefresh(vaultsAtom);

// Stale-while-revalidate: with a retained value the vault list renders
// instantly and one background refresh per atom key picks up changes
// (refreshing keeps the previous value, so nothing flashes). A cold key is
// already fetching — refreshing it would only restart the request. The ref
// carries the latest cached-ness into the effect without re-running it.
const isCachedRef = useRef(false);
isCachedRef.current = AsyncResult.isSuccess(vaultsResult);
useEffect(() => {
if (isCachedRef.current) refreshVaults();
}, [refreshVaults]);

const { vaults, isLoading, error } = AsyncResult.matchWithError(
vaultsResult as AsyncResult.AsyncResult<
Expand All @@ -81,12 +95,9 @@ function VaultPicker(props: {
}),
onSuccess: ({ value }) => {
const v = value.vaults;
const defaultVault = v[0];
if (
defaultVault &&
(!props.vaultId || (v.length === 1 && props.vaultId !== defaultVault.id))
) {
queueMicrotask(() => props.onVaultSelect(defaultVault.id, defaultVault.name));
const onlyVault = v.length === 1 ? v[0] : undefined;
if (onlyVault && props.selected.length === 0) {
queueMicrotask(() => props.onSelectedChange([onlyVault]));
}
return { vaults: [...v], isLoading: false, error: null };
},
Expand All @@ -101,34 +112,51 @@ function VaultPicker(props: {
);
}

const singleVault = vaults.length === 1 ? vaults[0] : null;
// Selected vaults missing from the loaded list (renamed, revoked, or the
// list failed to load while editing) stay visible so they can be unchecked.
const loadedIds = new Set(vaults.map((v) => v.id));
const stale = props.selected.filter((v) => !loadedIds.has(v.id));
const rows = [...vaults, ...stale];

const toggle = (vault: Vault, checked: boolean) => {
if (checked) {
if (!props.selected.some((v) => v.id === vault.id)) {
props.onSelectedChange([...props.selected, vault]);
}
return;
}
props.onSelectedChange(props.selected.filter((v) => v.id !== vault.id));
};

return (
<div className="grid gap-2">
{singleVault ? (
<div className="flex h-9 items-center rounded-md border border-input bg-muted/30 px-3 text-[13px] text-foreground">
<span className="truncate">{singleVault.name}</span>
</div>
{isLoading ? (
<p className="text-[11px] text-muted-foreground/50 py-1">Loading vaults…</p>
) : rows.length === 0 ? (
<p className="text-[11px] text-muted-foreground/50 py-1">No vaults found.</p>
) : (
<Select
disabled={isLoading || vaults.length === 0}
value={props.vaultId}
onValueChange={(id) => {
const v = vaults.find((vault) => vault.id === id);
if (v) props.onVaultSelect(v.id, v.name);
}}
>
<SelectTrigger className="h-9 text-[13px]">
<SelectValue placeholder={isLoading ? "Loading…" : "Select a vault"} />
</SelectTrigger>
<SelectContent>
{vaults.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid max-h-44 gap-0.5 overflow-y-auto rounded-md border border-input p-1">
{rows.map((vault) => {
const checked = props.selected.some((v) => v.id === vault.id);
return (
<Label
key={vault.id}
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 font-normal hover:bg-muted/40"
>
<Checkbox
checked={checked}
onCheckedChange={(value) => toggle(vault, value === true)}
/>
<span className="truncate text-[13px] text-foreground">{vault.name}</span>
{!loadedIds.has(vault.id) && (
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground/50">
not found
</span>
)}
</Label>
);
})}
</div>
)}
{error && (
<div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5">
Expand All @@ -151,7 +179,7 @@ function ConfigDialog(props: {
initial?: {
authKind: string;
accountName: string;
vaultId: string;
vaults: ReadonlyArray<Vault>;
name: string;
};
}) {
Expand All @@ -160,8 +188,10 @@ function ConfigDialog(props: {
(props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app",
);
const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com");
const [vaultId, setVaultId] = useState(props.initial?.vaultId ?? "");
const [vaultName, setVaultName] = useState(props.initial?.name ?? "");
const [selectedVaults, setSelectedVaults] = useState<ReadonlyArray<Vault>>(
props.initial?.vaults ?? [],
);
const [displayName, setDisplayName] = useState(props.initial?.name ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -171,15 +201,16 @@ function ConfigDialog(props: {
if (!isEdit) {
setAuthKind("desktop-app");
setAccountName("my.1password.com");
setVaultId("");
setVaultName("");
setSelectedVaults([]);
setDisplayName("");
}
setError(null);
setSaving(false);
};

const handleSave = async () => {
if (!accountName.trim() || !vaultId.trim()) return;
const [firstVault, ...restVaults] = selectedVaults;
if (!accountName.trim() || firstVault === undefined) return;
setSaving(true);
setError(null);

Expand All @@ -191,8 +222,8 @@ function ConfigDialog(props: {
const exit = await doConfigure({
payload: {
auth,
vaultId: vaultId.trim(),
name: vaultName.trim() || "1Password",
vaults: [firstVault, ...restVaults],
name: displayName.trim() || "1Password",
},
reactivityKeys: onepasswordWriteKeys,
});
Expand Down Expand Up @@ -220,7 +251,8 @@ function ConfigDialog(props: {
{isEdit ? "Edit 1Password" : "Connect 1Password"}
</DialogTitle>
<DialogDescription className="text-[13px] leading-relaxed">
Link a vault to resolve secrets via the 1Password desktop app or a service account.
Link one or more vaults to resolve secrets via the 1Password desktop app or a service
account.
</DialogDescription>
</DialogHeader>

Expand Down Expand Up @@ -262,19 +294,16 @@ function ConfigDialog(props: {
</p>
</div>

{/* Vault */}
{/* Vaults */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Vault
Vaults
</Label>
<VaultPicker
authKind={authKind}
accountName={accountName}
vaultId={vaultId}
onVaultSelect={(id, name) => {
setVaultId(id);
setVaultName(name);
}}
selected={selectedVaults}
onSelectedChange={setSelectedVaults}
/>
</div>

Expand All @@ -285,8 +314,8 @@ function ConfigDialog(props: {
</Label>
<Input
placeholder="1Password"
value={vaultName}
onChange={(e) => setVaultName((e.target as HTMLInputElement).value)}
value={displayName}
onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
className="text-[13px] h-9"
/>
</div>
Expand All @@ -307,7 +336,7 @@ function ConfigDialog(props: {
<Button
size="sm"
onClick={handleSave}
disabled={!accountName.trim() || !vaultId.trim() || saving}
disabled={!accountName.trim() || selectedVaults.length === 0 || saving}
>
{saving ? "Saving…" : isEdit ? "Update" : "Connect"}
</Button>
Expand Down Expand Up @@ -371,14 +400,18 @@ export default function OnePasswordSettings() {
<span className="font-mono text-foreground/80 truncate">
{config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
</span>
<span className="text-muted-foreground/60">Vault</span>
<span className="text-muted-foreground/60">
{config.vaults.length === 1 ? "Vault" : "Vaults"}
</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-foreground/80 truncate">{config.name}</span>
<span className="text-foreground/80 truncate">
{config.vaults.map((vault) => vault.name).join(", ")}
</span>
</div>
</div>
) : (
<CardStackEntryDescription>
Resolve secrets from your 1Password vault.
Resolve secrets from your 1Password vaults.
</CardStackEntryDescription>
)}
</CardStackEntryContent>
Expand Down Expand Up @@ -429,7 +462,7 @@ export default function OnePasswordSettings() {
// Service-account tokens are never surfaced (redacted); the
// user re-enters the token when editing that auth method.
accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",
vaultId: config.vaultId,
vaults: config.vaults,
name: config.name,
}
: undefined
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/onepassword/src/react/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export const onepasswordVaultsAtom = (
) =>
OnePasswordClient.query("onepassword", "listVaults", {
query: { authKind, account },
timeToLive: "30 seconds",
// Long retention on purpose: vault listing goes through the op CLI/SDK and
// is slow, so a reopened dialog renders the last-known vaults instantly
// and revalidates in the background instead of flashing a loading state.
timeToLive: "10 minutes",
reactivityKeys: [ReactivityKey.providers],
});

Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/onepassword/src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
export {
onepasswordPlugin,
makeOnePasswordStore,
resolveConfiguredRef,
ambiguityMessage,
type RefResolution,
type OnePasswordExtension,
type OnePasswordPluginOptions,
type OnePasswordStore,
} from "./plugin";
export {
OnePasswordConfig,
LegacyOnePasswordConfig,
StoredOnePasswordConfig,
normalizeStoredConfig,
RedactedOnePasswordConfig,
RedactedOnePasswordAuth,
redactConfig,
Expand Down
Loading
Loading