History

Support multiple 1Password vaults (#1829)

* Support multiple 1Password vaults per configuration * Make multi-vault 1Password refs explicit and item picker searchable * Serve cached provider listings while revalidating in pickers * Ship pending changesets as a patch release

Rhys Sullivan committed 2 months ago

Commit 369fa0a1 parent 02b52cd13 files · +643 −169Browse files

Unified · Split

13 files
  1. .changeset/defer-irreversible-cleanup.md +1 −1
  2. .changeset/onepassword-multiple-vaults.md +11 −0
  3. packages/core/api/src/handlers/providers.ts +5 −1
  4. packages/core/api/src/providers/api.ts +1 −0
  5. packages/core/sdk/src/provider.ts +3 −0
  6. packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +93 −60
  7. packages/plugins/onepassword/src/react/atoms.ts +4 −1
  8. packages/plugins/onepassword/src/sdk/index.ts +6 −0
  9. packages/plugins/onepassword/src/sdk/plugin.test.ts +214 −17
  10. packages/plugins/onepassword/src/sdk/plugin.ts +171 −41
  11. packages/plugins/onepassword/src/sdk/types.ts +40 −16
  12. packages/react/src/api/atoms.tsx +4 −1
  13. packages/react/src/components/add-account-modal.tsx +90 −31
.changeset/defer-irreversible-cleanup.md+1−1
@@ -1,5 +1,5 @@
11 ---
2−"executor": minor
2+"executor": patch
33 ---
44
55 **Irreversible cleanup now waits for the transaction to commit, and plugins can do the same**
.changeset/onepassword-multiple-vaults.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"executor": patch
3+---
4+
5+**The 1Password provider can now be scoped to several vaults, with explicit per-vault addressing**
6+
7+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.
8+
9+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.
10+
11+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.
packages/core/api/src/handlers/providers.ts+5−1
@@ -21,7 +21,11 @@ export const ProvidersHandlers = HttpApiBuilder.group(ExecutorApi, "providers", · 20 unmodified lines
2121 Effect.gen(function* () {
2222 const executor = yield* ExecutorService;
2323 const entries = yield* executor.providers.items(path.key);
24− return entries.map((entry) => ({ id: entry.id, name: entry.name }));
24+ return entries.map((entry) => ({
25+ id: entry.id,
26+ name: entry.name,
27+ ...(entry.group !== undefined ? { group: entry.group } : {}),
28+ }));
2529 }),
2630 ),
2731 ),
packages/core/api/src/providers/api.ts+1−0
@@ -26,6 +26,7 @@ const ProviderParams = { key: ProviderKey }; · 25 unmodified lines
2626 const ProviderEntryResponse = Schema.Struct({
2727 id: ProviderItemId,
2828 name: Schema.String,
29+ group: Schema.optional(Schema.String),
2930 });
3031
3132 // ---------------------------------------------------------------------------
packages/core/sdk/src/provider.ts+3−0
@@ -16,6 +16,9 @@ export interface ProviderEntry { · 15 unmodified lines
1616 * a connection can reference it without core knowing its internal shape. */
1717 readonly id: ProviderItemId;
1818 readonly name: string;
19+ /** Optional provenance label for pickers when a provider spans several
20+ * containers (a 1Password vault name). Purely presentational. */
21+ readonly group?: string;
1922 }
2023
2124 export interface CredentialProvider {
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+93−60
@@ -1,8 +1,9 @@
1−import { useState } from "react";
2−import { useAtomSet, useAtomValue } from "@effect/atom-react";
1+import { useEffect, useRef, useState } from "react";
2+import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import { Button } from "@executor-js/react/components/button";
6+import { Checkbox } from "@executor-js/react/components/checkbox";
67 import { Input } from "@executor-js/react/components/input";
78 import { Label } from "@executor-js/react/components/label";
89 import {
@@ -35,10 +36,10 @@ import { · 26 unmodified lines
3536 removeOnePasswordConfig,
3637 onepasswordWriteKeys,
3738 } from "./atoms";
38−import type { RedactedOnePasswordConfig } from "../sdk/types";
39+import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";
3940
4041 // ---------------------------------------------------------------------------
41−// Vault picker
42+// Vault picker — multi-select
4243 // ---------------------------------------------------------------------------
4344
4445 const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults";
@@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => { · 7 unmodified lines
5253 function VaultPicker(props: {
5354 authKind: "desktop-app" | "service-account";
5455 accountName: string;
55− vaultId: string;
56− onVaultSelect: (id: string, name: string) => void;
56+ selected: ReadonlyArray<Vault>;
57+ onSelectedChange: (vaults: ReadonlyArray<Vault>) => void;
5758 }) {
5859 const account = props.accountName.trim();
59− const vaultsResult = useAtomValue(onepasswordVaultsAtom(props.authKind, account));
60+ const vaultsAtom = onepasswordVaultsAtom(props.authKind, account);
61+ const vaultsResult = useAtomValue(vaultsAtom);
62+ const refreshVaults = useAtomRefresh(vaultsAtom);
63+
64+ // Stale-while-revalidate: with a retained value the vault list renders
65+ // instantly and one background refresh per atom key picks up changes
66+ // (refreshing keeps the previous value, so nothing flashes). A cold key is
67+ // already fetching — refreshing it would only restart the request. The ref
68+ // carries the latest cached-ness into the effect without re-running it.
69+ const isCachedRef = useRef(false);
70+ isCachedRef.current = AsyncResult.isSuccess(vaultsResult);
71+ useEffect(() => {
72+ if (isCachedRef.current) refreshVaults();
73+ }, [refreshVaults]);
6074
6175 const { vaults, isLoading, error } = AsyncResult.matchWithError(
6276 vaultsResult as AsyncResult.AsyncResult<
@@ -81,12 +95,9 @@ function VaultPicker(props: { · 18 unmodified lines
8195 }),
8296 onSuccess: ({ value }) => {
8397 const v = value.vaults;
84− const defaultVault = v[0];
85− if (
86− defaultVault &&
87− (!props.vaultId || (v.length === 1 && props.vaultId !== defaultVault.id))
88− ) {
89− queueMicrotask(() => props.onVaultSelect(defaultVault.id, defaultVault.name));
98+ const onlyVault = v.length === 1 ? v[0] : undefined;
99+ if (onlyVault && props.selected.length === 0) {
100+ queueMicrotask(() => props.onSelectedChange([onlyVault]));
90101 }
91102 return { vaults: [...v], isLoading: false, error: null };
92103 },
@@ -101,34 +112,51 @@ function VaultPicker(props: { · 8 unmodified lines
101112 );
102113 }
103114
104− const singleVault = vaults.length === 1 ? vaults[0] : null;
115+ // Selected vaults missing from the loaded list (renamed, revoked, or the
116+ // list failed to load while editing) stay visible so they can be unchecked.
117+ const loadedIds = new Set(vaults.map((v) => v.id));
118+ const stale = props.selected.filter((v) => !loadedIds.has(v.id));
119+ const rows = [...vaults, ...stale];
120+
121+ const toggle = (vault: Vault, checked: boolean) => {
122+ if (checked) {
123+ if (!props.selected.some((v) => v.id === vault.id)) {
124+ props.onSelectedChange([...props.selected, vault]);
125+ }
126+ return;
127+ }
128+ props.onSelectedChange(props.selected.filter((v) => v.id !== vault.id));
129+ };
105130
106131 return (
107132 <div className="grid gap-2">
108− {singleVault ? (
109− <div className="flex h-9 items-center rounded-md border border-input bg-muted/30 px-3 text-[13px] text-foreground">
110− <span className="truncate">{singleVault.name}</span>
111− </div>
133+ {isLoading ? (
134+ <p className="text-[11px] text-muted-foreground/50 py-1">Loading vaults…</p>
135+ ) : rows.length === 0 ? (
136+ <p className="text-[11px] text-muted-foreground/50 py-1">No vaults found.</p>
112137 ) : (
113− <Select
114− disabled={isLoading || vaults.length === 0}
115− value={props.vaultId}
116− onValueChange={(id) => {
117− const v = vaults.find((vault) => vault.id === id);
118− if (v) props.onVaultSelect(v.id, v.name);
119− }}
120− >
121− <SelectTrigger className="h-9 text-[13px]">
122− <SelectValue placeholder={isLoading ? "Loading…" : "Select a vault"} />
123− </SelectTrigger>
124− <SelectContent>
125− {vaults.map((v) => (
126− <SelectItem key={v.id} value={v.id}>
127− {v.name}
128− </SelectItem>
129− ))}
130− </SelectContent>
131− </Select>
138+ <div className="grid max-h-44 gap-0.5 overflow-y-auto rounded-md border border-input p-1">
139+ {rows.map((vault) => {
140+ const checked = props.selected.some((v) => v.id === vault.id);
141+ return (
142+ <Label
143+ key={vault.id}
144+ className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 font-normal hover:bg-muted/40"
145+ >
146+ <Checkbox
147+ checked={checked}
148+ onCheckedChange={(value) => toggle(vault, value === true)}
149+ />
150+ <span className="truncate text-[13px] text-foreground">{vault.name}</span>
151+ {!loadedIds.has(vault.id) && (
152+ <span className="ml-auto shrink-0 text-[11px] text-muted-foreground/50">
153+ not found
154+ </span>
155+ )}
156+ </Label>
157+ );
158+ })}
159+ </div>
132160 )}
133161 {error && (
134162 <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5">
@@ -151,7 +179,7 @@ function ConfigDialog(props: { · 16 unmodified lines
151179 initial?: {
152180 authKind: string;
153181 accountName: string;
154− vaultId: string;
182+ vaults: ReadonlyArray<Vault>;
155183 name: string;
156184 };
157185 }) {
@@ -160,8 +188,10 @@ function ConfigDialog(props: { · 2 unmodified lines
160188 (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app",
161189 );
162190 const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com");
163− const [vaultId, setVaultId] = useState(props.initial?.vaultId ?? "");
164− const [vaultName, setVaultName] = useState(props.initial?.name ?? "");
191+ const [selectedVaults, setSelectedVaults] = useState<ReadonlyArray<Vault>>(
192+ props.initial?.vaults ?? [],
193+ );
194+ const [displayName, setDisplayName] = useState(props.initial?.name ?? "");
165195 const [saving, setSaving] = useState(false);
166196 const [error, setError] = useState<string | null>(null);
167197
@@ -171,15 +201,16 @@ function ConfigDialog(props: { · 3 unmodified lines
171201 if (!isEdit) {
172202 setAuthKind("desktop-app");
173203 setAccountName("my.1password.com");
174− setVaultId("");
175− setVaultName("");
204+ setSelectedVaults([]);
205+ setDisplayName("");
176206 }
177207 setError(null);
178208 setSaving(false);
179209 };
180210
181211 const handleSave = async () => {
182− if (!accountName.trim() || !vaultId.trim()) return;
212+ const [firstVault, ...restVaults] = selectedVaults;
213+ if (!accountName.trim() || firstVault === undefined) return;
183214 setSaving(true);
184215 setError(null);
185216
@@ -191,8 +222,8 @@ function ConfigDialog(props: { · 5 unmodified lines
191222 const exit = await doConfigure({
192223 payload: {
193224 auth,
194− vaultId: vaultId.trim(),
195− name: vaultName.trim() || "1Password",
225+ vaults: [firstVault, ...restVaults],
226+ name: displayName.trim() || "1Password",
196227 },
197228 reactivityKeys: onepasswordWriteKeys,
198229 });
@@ -220,7 +251,8 @@ function ConfigDialog(props: { · 21 unmodified lines
220251 {isEdit ? "Edit 1Password" : "Connect 1Password"}
221252 </DialogTitle>
222253 <DialogDescription className="text-[13px] leading-relaxed">
223− Link a vault to resolve secrets via the 1Password desktop app or a service account.
254+ Link one or more vaults to resolve secrets via the 1Password desktop app or a service
255+ account.
224256 </DialogDescription>
225257 </DialogHeader>
226258
@@ -262,19 +294,16 @@ function ConfigDialog(props: { · 35 unmodified lines
262294 </p>
263295 </div>
264296
265− {/* Vault */}
297+ {/* Vaults */}
266298 <div className="grid gap-1.5">
267299 <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
268− Vault
300+ Vaults
269301 </Label>
270302 <VaultPicker
271303 authKind={authKind}
272304 accountName={accountName}
273− vaultId={vaultId}
274− onVaultSelect={(id, name) => {
275− setVaultId(id);
276− setVaultName(name);
277− }}
305+ selected={selectedVaults}
306+ onSelectedChange={setSelectedVaults}
278307 />
279308 </div>
280309
@@ -285,8 +314,8 @@ function ConfigDialog(props: { · 4 unmodified lines
285314 </Label>
286315 <Input
287316 placeholder="1Password"
288− value={vaultName}
289− onChange={(e) => setVaultName((e.target as HTMLInputElement).value)}
317+ value={displayName}
318+ onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
290319 className="text-[13px] h-9"
291320 />
292321 </div>
@@ -307,7 +336,7 @@ function ConfigDialog(props: { · 14 unmodified lines
307336 <Button
308337 size="sm"
309338 onClick={handleSave}
310− disabled={!accountName.trim() || !vaultId.trim() || saving}
339+ disabled={!accountName.trim() || selectedVaults.length === 0 || saving}
311340 >
312341 {saving ? "Saving…" : isEdit ? "Update" : "Connect"}
313342 </Button>
@@ -371,14 +400,18 @@ export default function OnePasswordSettings() { · 57 unmodified lines
371400 <span className="font-mono text-foreground/80 truncate">
372401 {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
373402 </span>
374− <span className="text-muted-foreground/60">Vault</span>
403+ <span className="text-muted-foreground/60">
404+ {config.vaults.length === 1 ? "Vault" : "Vaults"}
405+ </span>
375406 <div className="flex items-center gap-2 min-w-0">
376− <span className="text-foreground/80 truncate">{config.name}</span>
407+ <span className="text-foreground/80 truncate">
408+ {config.vaults.map((vault) => vault.name).join(", ")}
409+ </span>
377410 </div>
378411 </div>
379412 ) : (
380413 <CardStackEntryDescription>
381− Resolve secrets from your 1Password vault.
414+ Resolve secrets from your 1Password vaults.
382415 </CardStackEntryDescription>
383416 )}
384417 </CardStackEntryContent>
@@ -429,7 +462,7 @@ export default function OnePasswordSettings() { · 44 unmodified lines
429462 // Service-account tokens are never surfaced (redacted); the
430463 // user re-enters the token when editing that auth method.
431464 accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",
432− vaultId: config.vaultId,
465+ vaults: config.vaults,
433466 name: config.name,
434467 }
435468 : undefined
packages/plugins/onepassword/src/react/atoms.ts+4−1
@@ -33,7 +33,10 @@ export const onepasswordVaultsAtom = ( · 32 unmodified lines
3333 ) =>
3434 OnePasswordClient.query("onepassword", "listVaults", {
3535 query: { authKind, account },
36− timeToLive: "30 seconds",
36+ // Long retention on purpose: vault listing goes through the op CLI/SDK and
37+ // is slow, so a reopened dialog renders the last-known vaults instantly
38+ // and revalidates in the background instead of flashing a loading state.
39+ timeToLive: "10 minutes",
3740 reactivityKeys: [ReactivityKey.providers],
3841 });
3942
packages/plugins/onepassword/src/sdk/index.ts+6−0
@@ -1,12 +1,18 @@
11 export {
22 onepasswordPlugin,
33 makeOnePasswordStore,
4+ resolveConfiguredRef,
5+ ambiguityMessage,
6+ type RefResolution,
47 type OnePasswordExtension,
58 type OnePasswordPluginOptions,
69 type OnePasswordStore,
710 } from "./plugin";
811 export {
912 OnePasswordConfig,
13+ LegacyOnePasswordConfig,
14+ StoredOnePasswordConfig,
15+ normalizeStoredConfig,
1016 RedactedOnePasswordConfig,
1117 RedactedOnePasswordAuth,
1218 redactConfig,
packages/plugins/onepassword/src/sdk/plugin.test.ts+214−17
@@ -2,9 +2,12 @@ import { describe, it, expect } from "@effect/vitest"; · 1 unmodified line
22 import { Effect } from "effect";
33
44 import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk";
5+import { makeInMemoryBlobStore, pluginBlobStore } from "@executor-js/sdk/core";
56 import { makeTestConfig } from "@executor-js/sdk/testing";
67
7−import { onepasswordPlugin } from "./plugin";
8+import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin";
9+import type { OnePasswordService } from "./service";
10+import { OnePasswordError } from "./errors";
811 import { OnePasswordConfig, DesktopAppAuth } from "./types";
912
1013 // removed: v1 routed configure/removeConfig through an explicit `ScopeId`
@@ -16,6 +19,18 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 5 unmodified lines
1619
1720 const ONEPASSWORD = ProviderKey.make("onepassword");
1821
22+const twoVaultConfig = OnePasswordConfig.make({
23+ auth: DesktopAppAuth.make({
24+ kind: "desktop-app",
25+ accountName: "my.1password.com",
26+ }),
27+ vaults: [
28+ { id: "vault-123", name: "Personal" },
29+ { id: "vault-456", name: "Work" },
30+ ],
31+ name: "1Password",
32+});
33+
1934 describe("onepassword plugin", () => {
2035 it.effect("registers onepassword as a credential provider", () =>
2136 Effect.gen(function* () {
@@ -36,20 +51,14 @@ describe("onepassword plugin", () => { · 14 unmodified lines
3651 const initial = yield* executor.onepassword.getConfig();
3752 expect(initial).toBeNull();
3853
39− const config = OnePasswordConfig.make({
40− auth: DesktopAppAuth.make({
41− kind: "desktop-app",
42− accountName: "my.1password.com",
43− }),
44− vaultId: "vault-123",
45− name: "Personal",
46− });
47−
48− yield* executor.onepassword.configure(config);
54+ yield* executor.onepassword.configure(twoVaultConfig);
4955
5056 const loaded = yield* executor.onepassword.getConfig();
51− expect(loaded?.vaultId).toBe("vault-123");
52− expect(loaded?.name).toBe("Personal");
57+ expect(loaded?.vaults).toEqual([
58+ { id: "vault-123", name: "Personal" },
59+ { id: "vault-456", name: "Work" },
60+ ]);
61+ expect(loaded?.name).toBe("1Password");
5362 expect(loaded?.auth.kind).toBe("desktop-app");
5463
5564 yield* executor.onepassword.removeConfig();
@@ -67,7 +76,7 @@ describe("onepassword plugin", () => { · 11 unmodified lines
6776 yield* executor.onepassword.configure(
6877 OnePasswordConfig.make({
6978 auth: { kind: "service-account", token: "super-secret-token" },
70− vaultId: "vault-123",
79+ vaults: [{ id: "vault-123", name: "CI" }],
7180 name: "CI",
7281 }),
7382 );
@@ -89,8 +98,11 @@ describe("onepassword plugin", () => { · 15 unmodified lines
8998 ToolAddress.make("executor.onepassword.configure"),
9099 {
91100 auth: { kind: "desktop-app", accountName: "my.1password.com" },
92− vaultId: "vault-123",
93− name: "Personal",
101+ vaults: [
102+ { id: "vault-123", name: "Personal" },
103+ { id: "vault-456", name: "Work" },
104+ ],
105+ name: "1Password",
94106 },
95107 { onElicitation: "accept-all" },
96108 );
@@ -100,7 +112,15 @@ describe("onepassword plugin", () => { · 3 unmodified lines
100112 yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}),
101113 ).toMatchObject({
102114 ok: true,
103− data: { config: { vaultId: "vault-123", name: "Personal" } },
115+ data: {
116+ config: {
117+ vaults: [
118+ { id: "vault-123", name: "Personal" },
119+ { id: "vault-456", name: "Work" },
120+ ],
121+ name: "1Password",
122+ },
123+ },
104124 });
105125
106126 const removed = yield* executor.execute(
@@ -125,3 +145,180 @@ describe("onepassword plugin", () => { · 18 unmodified lines
125145 }),
126146 );
127147 });
148+
149+// ---------------------------------------------------------------------------
150+// Stored-config compatibility — blobs written before multi-vault support hold
151+// `{ auth, vaultId, name }`. Reads normalize that to a one-element vaults
152+// array; the next save writes the current shape.
153+// ---------------------------------------------------------------------------
154+
155+describe("onepassword store", () => {
156+ const makeStore = () => {
157+ const blobs = pluginBlobStore(
158+ makeInMemoryBlobStore(),
159+ { org: "org_test", user: null },
160+ "onepassword",
161+ );
162+ return { blobs, store: makeOnePasswordStore(blobs) };
163+ };
164+
165+ it.effect("upgrades a legacy single-vault blob on read", () =>
166+ Effect.gen(function* () {
167+ const { blobs, store } = makeStore();
168+ yield* blobs.put(
169+ "config",
170+ JSON.stringify({
171+ auth: { kind: "desktop-app", accountName: "my.1password.com" },
172+ vaultId: "vault-123",
173+ name: "Personal",
174+ }),
175+ { owner: "org" },
176+ );
177+
178+ const config = yield* store.getConfig();
179+ expect(config).toEqual({
180+ auth: { kind: "desktop-app", accountName: "my.1password.com" },
181+ vaults: [{ id: "vault-123", name: "Personal" }],
182+ name: "Personal",
183+ });
184+ }),
185+ );
186+
187+ it.effect("persists and reads back the multi-vault shape", () =>
188+ Effect.gen(function* () {
189+ const { store } = makeStore();
190+ yield* store.saveConfig(twoVaultConfig, "org");
191+ const config = yield* store.getConfig();
192+ expect(config).toEqual(twoVaultConfig);
193+ }),
194+ );
195+});
196+
197+// ---------------------------------------------------------------------------
198+// Explicit ref resolution — vault-qualified refs resolve directly; bare refs
199+// must locate exactly one item, and a multi-vault match is an explicit
200+// ambiguity, never a precedence pick.
201+// ---------------------------------------------------------------------------
202+
203+const fakeService = (
204+ itemsByVault: Readonly<Record<string, readonly { id: string; title: string }[]>>,
205+ onResolve?: (uri: string) => void,
206+): OnePasswordService => ({
207+ resolveSecret: (uri) => {
208+ onResolve?.(uri);
209+ return Effect.succeed(`secret:${uri}`);
210+ },
211+ listVaults: () => Effect.succeed(Object.keys(itemsByVault).map((id) => ({ id, title: id }))),
212+ listItems: (vaultId) => {
213+ const items = itemsByVault[vaultId];
214+ return items === undefined
215+ ? Effect.fail(new OnePasswordError({ operation: "item listing", message: "no such vault" }))
216+ : Effect.succeed(items);
217+ },
218+});
219+
220+describe("resolveConfiguredRef", () => {
221+ it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () =>
222+ Effect.gen(function* () {
223+ const svc = fakeService({});
224+ const result = yield* resolveConfiguredRef(
225+ svc,
226+ twoVaultConfig,
227+ "op://vault-456/item-abc/password",
228+ );
229+ expect(result).toEqual({
230+ kind: "resolved",
231+ value: "secret:op://vault-456/item-abc/password",
232+ });
233+ }),
234+ );
235+
236+ it.effect("appends the credential field to a picker-shaped op://vault/item ref", () =>
237+ Effect.gen(function* () {
238+ const svc = fakeService({});
239+ const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc");
240+ expect(result).toEqual({
241+ kind: "resolved",
242+ value: "secret:op://vault-123/item-abc/credential",
243+ });
244+ }),
245+ );
246+
247+ it.effect("accepts an op:// URI addressed by vault name", () =>
248+ Effect.gen(function* () {
249+ const svc = fakeService({});
250+ const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://Work/item/password");
251+ expect(result).toEqual({ kind: "resolved", value: "secret:op://Work/item/password" });
252+ }),
253+ );
254+
255+ it.effect("reports an op:// URI outside the configured vaults", () =>
256+ Effect.gen(function* () {
257+ const svc = fakeService({});
258+ const result = yield* resolveConfiguredRef(
259+ svc,
260+ twoVaultConfig,
261+ "op://vault-999/item/password",
262+ );
263+ expect(result).toEqual({ kind: "outside-vaults" });
264+ }),
265+ );
266+
267+ it.effect("resolves a bare ref that matches exactly one item across vaults", () =>
268+ Effect.gen(function* () {
269+ const svc = fakeService({
270+ "vault-123": [{ id: "item-1", title: "GitHub Token" }],
271+ "vault-456": [{ id: "item-2", title: "Stripe Key" }],
272+ });
273+ const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");
274+ expect(result).toEqual({
275+ kind: "resolved",
276+ value: "secret:op://vault-123/item-1/credential",
277+ });
278+ }),
279+ );
280+
281+ it.effect("fails a bare ref that matches in two vaults with the vaults named", () =>
282+ Effect.gen(function* () {
283+ const svc = fakeService({
284+ "vault-123": [{ id: "item-1", title: "GitHub Token" }],
285+ "vault-456": [{ id: "item-2", title: "GitHub Token" }],
286+ });
287+ const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");
288+ expect(result).toEqual({
289+ kind: "ambiguous",
290+ matches: [
291+ {
292+ vaultId: "vault-123",
293+ vaultName: "Personal",
294+ itemId: "item-1",
295+ itemTitle: "GitHub Token",
296+ },
297+ { vaultId: "vault-456", vaultName: "Work", itemId: "item-2", itemTitle: "GitHub Token" },
298+ ],
299+ });
300+ }),
301+ );
302+
303+ it.effect("treats duplicate titles inside one vault as ambiguous too", () =>
304+ Effect.gen(function* () {
305+ const svc = fakeService({
306+ "vault-123": [
307+ { id: "item-1", title: "GitHub Token" },
308+ { id: "item-9", title: "GitHub Token" },
309+ ],
310+ "vault-456": [],
311+ });
312+ const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");
313+ expect(result.kind).toBe("ambiguous");
314+ }),
315+ );
316+
317+ it.effect("reports not-found for a bare ref matching nothing", () =>
318+ Effect.gen(function* () {
319+ const svc = fakeService({ "vault-123": [], "vault-456": [] });
320+ const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "missing");
321+ expect(result).toEqual({ kind: "not-found" });
322+ }),
323+ );
324+});
packages/plugins/onepassword/src/sdk/plugin.ts+171−41
@@ -20,8 +20,10 @@ import { · 19 unmodified lines
2020 OnePasswordAuth,
2121 OnePasswordConfig,
2222 RedactedOnePasswordConfig,
23+ StoredOnePasswordConfig,
2324 Vault,
2425 ConnectionStatus,
26+ normalizeStoredConfig,
2527 redactConfig,
2628 } from "./types";
2729 import { OnePasswordError } from "./errors";
@@ -42,11 +44,7 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 14 unmodified lines
4244 I
4345 >;
4446
45−const OnePasswordConfigureInput = Schema.Struct({
46− auth: OnePasswordAuth,
47− vaultId: Schema.String,
48− name: Schema.String,
49−});
47+const OnePasswordConfigureInput = OnePasswordConfig;
5048
5149 const OnePasswordConfigureOutput = Schema.Struct({
5250 configured: Schema.Boolean,
@@ -98,10 +96,12 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 45 unmodified lines
9896 // ---------------------------------------------------------------------------
9997 // Typed config store — single blob, JSON encoded, owner-partitioned. The
10098 // stored config carries the auth credential (desktop account name, or
101−// service-account token) plus the selected vault. v1 keyed this by executor
99+// service-account token) plus the selected vaults. v1 keyed this by executor
102100 // scope; v2 partitions by `owner` — the plugin-owned config row owns the
103−// partition, mirroring the connection model. Blob I/O failures surface as
104−// `StorageError`; decode failures stay `OnePasswordError`.
101+// partition, mirroring the connection model. Reads also accept the legacy
102+// single-`vaultId` shape and normalize it to the vaults array; saves always
103+// write the current shape. Blob I/O failures surface as `StorageError`;
104+// decode failures stay `OnePasswordError`.
105105 // ---------------------------------------------------------------------------
106106
107107 export interface OnePasswordStore {
@@ -116,7 +116,7 @@ export interface OnePasswordStore { · 8 unmodified lines
116116 readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>;
117117 }
118118
119−const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(OnePasswordConfig));
119+const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig));
120120
121121 const blobStorageError =
122122 (operation: string) =>
@@ -133,6 +133,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 10 unmodified lines
133133 Effect.flatMap((raw) => {
134134 if (raw === null) return Effect.succeed(null);
135135 return decodeConfig(raw).pipe(
136+ Effect.map(normalizeStoredConfig),
136137 Effect.mapError(
137138 () =>
138139 new OnePasswordError({
@@ -150,7 +151,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 11 unmodified lines
150151 CONFIG_KEY,
151152 JSON.stringify({
152153 auth: config.auth,
153− vaultId: config.vaultId,
154+ vaults: config.vaults,
154155 name: config.name,
155156 }),
156157 { owner },
@@ -177,22 +178,105 @@ const getServiceFromConfig = ( · 20 unmodified lines
177178 ): Effect.Effect<OnePasswordService, OnePasswordError> =>
178179 makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk });
179180
180−const configuredVaultUri = (config: OnePasswordConfig, itemId: string): string | null => {
181− if (!itemId.startsWith("op://")) {
182− return `op://${config.vaultId}/${itemId}/${CREDENTIAL_FIELD}`;
181+// ---------------------------------------------------------------------------
182+// Explicit ref resolution.
183+//
184+// A ref is one of:
185+// - `op://vault/item/field...` — fully qualified, resolved as-is.
186+// - `op://vault/item` — picker-shaped; the default credential field
187+// is appended. This is the id shape `list()`
188+// hands out, so every picked item permanently
189+// records which vault it came from.
190+// - a bare item id or title — located by listing the configured vaults.
191+// Exactly one match resolves; several matches
192+// are an explicit ambiguity failure naming the
193+// vaults — never a silent precedence pick.
194+// ---------------------------------------------------------------------------
195+
196+export type RefResolution =
197+ | { readonly kind: "resolved"; readonly value: string }
198+ | { readonly kind: "not-found" }
199+ | { readonly kind: "outside-vaults" }
200+ | {
201+ readonly kind: "ambiguous";
202+ readonly matches: readonly {
203+ readonly vaultId: string;
204+ readonly vaultName: string;
205+ readonly itemId: string;
206+ readonly itemTitle: string;
207+ }[];
208+ };
209+
210+export const ambiguityMessage = (
211+ ref: string,
212+ matches: Extract<RefResolution, { kind: "ambiguous" }>["matches"],
213+): string =>
214+ [
215+ `1Password ref "${ref}" is ambiguous: it matches`,
216+ matches.map((m) => `"${m.itemTitle}" in vault "${m.vaultName}"`).join(", "),
217+ `. Use op://<vaultId>/<itemId> to pick one.`,
218+ ].join(" ");
219+
220+const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean =>
221+ config.vaults.some((vault) => vault.id === segment || vault.name === segment);
222+
223+/** Resolve a ref against the configured vaults. Backend failures stay on the
224+ * error channel; every addressing outcome is an explicit `RefResolution`. */
225+export const resolveConfiguredRef = (
226+ svc: OnePasswordService,
227+ config: OnePasswordConfig,
228+ ref: string,
229+): Effect.Effect<RefResolution, OnePasswordError> => {
230+ if (ref.startsWith("op://")) {
231+ const segments = ref.slice("op://".length).split("/");
232+ const vaultSegment = segments[0];
233+ if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) {
234+ return Effect.succeed({ kind: "not-found" });
235+ }
236+ if (!isConfiguredVaultSegment(config, vaultSegment)) {
237+ return Effect.succeed({ kind: "outside-vaults" });
238+ }
239+ const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref;
240+ return svc
241+ .resolveSecret(uri)
242+ .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value })));
183243 }
184− const match = itemId.match(/^op:\/\/([^/]+)\/.+/);
185− if (!match || match[1] !== config.vaultId) return null;
186− return itemId;
244+
245+ return Effect.gen(function* () {
246+ const matches = (yield* Effect.forEach(config.vaults, (vault) =>
247+ svc.listItems(vault.id).pipe(
248+ Effect.map((items) =>
249+ items
250+ .filter((item) => item.id === ref || item.title === ref)
251+ .map((item) => ({
252+ vaultId: vault.id,
253+ vaultName: vault.name,
254+ itemId: item.id,
255+ itemTitle: item.title,
256+ })),
257+ ),
258+ ),
259+ )).flat();
260+
261+ const [only, ...extra] = matches;
262+ if (only === undefined) return { kind: "not-found" } as const;
263+ if (extra.length > 0) return { kind: "ambiguous", matches } as const;
264+
265+ const value = yield* svc.resolveSecret(
266+ `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`,
267+ );
268+ return { kind: "resolved", value } as const;
269+ });
187270 };
188271
189272 // ---------------------------------------------------------------------------
190−// CredentialProvider — read-only, resolves op:// URIs or vaultId-based lookups.
273+// CredentialProvider — read-only, resolves op:// URIs or vault-scoped lookups.
191274 //
192275 // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is
193−// either a fully-qualified `op://vault/item/field` URI or a bare item id that
194−// the stored config's vault scopes. The plugin's stored config supplies the
195−// auth + vault binding; the provider never writes (writable: false).
276+// a vault-qualified `op://` ref (what `list()` hands out) or a bare item
277+// id/title that must locate exactly one item across the configured vaults.
278+// The plugin's stored config supplies the auth + vault bindings; the provider
279+// never writes (writable: false).
196280 // ---------------------------------------------------------------------------
197281
198282 const makeProvider = (
@@ -205,19 +289,32 @@ const makeProvider = ( · 6 unmodified lines
205289
206290 get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>
207291 ctx.storage.getConfig().pipe(
292+ // An undecodable stored config reads as "not configured" here; the
293+ // settings surface reports the decode problem.
294+ Effect.catchTag("OnePasswordError", () => Effect.succeed(null)),
208295 Effect.flatMap((config) => {
209296 if (!config) return Effect.succeed(null as string | null);
210297
211− const uri = configuredVaultUri(config, id);
212− if (uri === null) return Effect.succeed(null as string | null);
213−
214298 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(
215− Effect.flatMap((svc) => svc.resolveSecret(uri)),
216− Effect.map((v): string | null => v),
217− Effect.orElseSucceed(() => null),
299+ Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)),
300+ // Backend unreachability degrades to "no value", matching the other
301+ // providers. Ambiguity does NOT: silently picking a vault (or
302+ // silently failing) hides a real conflict, so it surfaces as a
303+ // typed failure with the full explanation.
304+ Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)),
305+ Effect.flatMap(
306+ (resolution): Effect.Effect<string | null, StorageError> =>
307+ resolution.kind === "ambiguous"
308+ ? Effect.fail(
309+ new StorageError({
310+ message: ambiguityMessage(id, resolution.matches),
311+ cause: undefined,
312+ }),
313+ )
314+ : Effect.succeed(resolution.kind === "resolved" ? resolution.value : null),
315+ ),
218316 );
219317 }),
220− Effect.catch(() => Effect.succeed(null as string | null)),
221318 ),
222319
223320 list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>
@@ -225,10 +322,25 @@ const makeProvider = ( · 1 unmodified line
225322 Effect.flatMap((config) => {
226323 if (!config) return Effect.succeed([] as readonly ProviderEntry[]);
227324 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(
228− Effect.flatMap((svc) => svc.listItems(config.vaultId)),
229− Effect.map((items): readonly ProviderEntry[] =>
230− items.map((item) => ({ id: ProviderItemId.make(item.id), name: item.title })),
325+ Effect.flatMap((svc) =>
326+ Effect.forEach(config.vaults, (vault) =>
327+ svc.listItems(vault.id).pipe(
328+ Effect.map((items) =>
329+ items.map(
330+ // Vault-qualified ids: picking an entry permanently
331+ // records which vault it came from, so identically-titled
332+ // items in different vaults can never collide.
333+ (item): ProviderEntry => ({
334+ id: ProviderItemId.make(`op://${vault.id}/${item.id}`),
335+ name: item.title,
336+ group: vault.name,
337+ }),
338+ ),
339+ ),
340+ ),
341+ ),
231342 ),
343+ Effect.map((groups): readonly ProviderEntry[] => groups.flat()),
232344 );
233345 }),
234346 Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
@@ -270,11 +382,19 @@ const makeOnePasswordExtension = ( · 35 unmodified lines
270382 });
271383 }
272384 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
273− const vaults = yield* svc.listVaults();
274− const vault = vaults.find((v) => v.id === config.vaultId);
385+ const live = yield* svc.listVaults();
386+ const liveById = new Map(live.map((v) => [v.id, v.title]));
387+ const missing = config.vaults.filter((vault) => !liveById.has(vault.id));
275388 return ConnectionStatus.make({
276389 connected: true,
277− vaultName: vault?.title,
390+ vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name),
391+ ...(missing.length > 0
392+ ? {
393+ error: `Configured vaults not found: ${missing
394+ .map((vault) => vault.name)
395+ .join(", ")}`,
396+ }
397+ : {}),
278398 });
279399 }),
280400
@@ -299,15 +419,25 @@ const makeOnePasswordExtension = ( · 18 unmodified lines
299419 message: "1Password is not configured",
300420 });
301421 }
302− const scopedUri = configuredVaultUri(config, uri);
303− if (scopedUri === null) {
422+ const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
423+ const resolution = yield* resolveConfiguredRef(svc, config, uri);
424+ if (resolution.kind === "resolved") return resolution.value;
425+ if (resolution.kind === "outside-vaults") {
304426 return yield* new OnePasswordError({
305427 operation: "resolve",
306− message: "1Password secret URI is outside the configured vault",
428+ message: "1Password secret URI is outside the configured vaults",
307429 });
308430 }
309− const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
310− return yield* svc.resolveSecret(scopedUri);
431+ if (resolution.kind === "ambiguous") {
432+ return yield* new OnePasswordError({
433+ operation: "resolve",
434+ message: ambiguityMessage(uri, resolution.matches),
435+ });
436+ }
437+ return yield* new OnePasswordError({
438+ operation: "resolve",
439+ message: `1Password item "${uri}" was not found in the configured vaults`,
440+ });
311441 }),
312442 };
313443 };
@@ -345,7 +475,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 31 unmodified lines
345475 tool({
346476 name: "status",
347477 description:
348− "Check whether the 1Password credential provider is configured and can reach its selected vault. This returns status only, never secret values.",
478+ "Check whether the 1Password credential provider is configured and can reach its selected vaults. This returns status only, never secret values.",
349479 outputSchema: OnePasswordStatusOutputStd,
350480 execute: () => Effect.map(self.status(), ToolResult.ok),
351481 }),
@@ -368,7 +498,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines
368498 tool({
369499 name: "configure",
370500 description:
371− "Configure the 1Password credential provider for the acting owner. Use desktop-app auth for local biometric access, or service-account auth with the token. The token is stored in the plugin's owner-partitioned config and never surfaced again.",
501+ "Configure the 1Password credential provider for the acting owner with one or more vaults. Use desktop-app auth for local biometric access, or service-account auth with the token. The token is stored in the plugin's owner-partitioned config and never surfaced again.",
372502 annotations: {
373503 requiresApproval: true,
374504 approvalDescription: "Configure the 1Password credential provider",
@@ -377,7 +507,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 2 unmodified lines
377507 outputSchema: OnePasswordConfigureOutputStd,
378508 execute: (input) =>
379509 Effect.as(
380− self.configure({ auth: input.auth, vaultId: input.vaultId, name: input.name }),
510+ self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }),
381511 ToolResult.ok({ configured: true }),
382512 ),
383513 }),
packages/plugins/onepassword/src/sdk/types.ts+40−16
@@ -24,19 +24,53 @@ export type ServiceAccountAuth = typeof ServiceAccountAuth.Type; · 23 unmodified lines
2424 export const OnePasswordAuth = Schema.Union([DesktopAppAuth, ServiceAccountAuth]);
2525 export type OnePasswordAuth = typeof OnePasswordAuth.Type;
2626
27+// ---------------------------------------------------------------------------
28+// Vault
29+// ---------------------------------------------------------------------------
30+
31+export const Vault = Schema.Struct({
32+ id: Schema.String,
33+ name: Schema.String,
34+});
35+export type Vault = typeof Vault.Type;
36+
2737 // ---------------------------------------------------------------------------
2838 // Stored config — persisted via KV
2939 // ---------------------------------------------------------------------------
3040
3141 export const OnePasswordConfig = Schema.Struct({
3242 auth: OnePasswordAuth,
33− /** Vault to scope operations to */
34− vaultId: Schema.String,
35− /** Human label */
43+ /** Vaults to scope operations to. Order is presentational only: refs are
44+ * vault-qualified, and a bare ref that matches in more than one vault is an
45+ * explicit ambiguity failure, never a precedence pick. */
46+ vaults: Schema.NonEmptyArray(Vault),
47+ /** Human label for the whole connection */
3648 name: Schema.String,
3749 });
3850 export type OnePasswordConfig = typeof OnePasswordConfig.Type;
3951
52+/** Pre-multi-vault stored shape: a single vault id whose display name doubled
53+ * as the connection label. Still accepted on read; every save writes the
54+ * current shape, so a config row upgrades the first time it is re-saved. */
55+export const LegacyOnePasswordConfig = Schema.Struct({
56+ auth: OnePasswordAuth,
57+ vaultId: Schema.String,
58+ name: Schema.String,
59+});
60+export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type;
61+
62+export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]);
63+export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type;
64+
65+export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig =>
66+ "vaultId" in stored
67+ ? {
68+ auth: stored.auth,
69+ vaults: [{ id: stored.vaultId, name: stored.name }],
70+ name: stored.name,
71+ }
72+ : stored;
73+
4074 // ---------------------------------------------------------------------------
4175 // Redacted config — what `getConfig` returns to agents / the UI. The
4276 // service-account token is stripped; only the auth kind + account metadata is
@@ -56,7 +90,7 @@ export const RedactedOnePasswordAuth = Schema.Union([ · 13 unmodified lines
5690
5791 export const RedactedOnePasswordConfig = Schema.Struct({
5892 auth: RedactedOnePasswordAuth,
59− vaultId: Schema.String,
93+ vaults: Schema.NonEmptyArray(Vault),
6094 name: Schema.String,
6195 });
6296 export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type;
@@ -67,27 +101,17 @@ export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConf · 4 unmodified lines
67101 config.auth.kind === "desktop-app"
68102 ? { kind: "desktop-app", accountName: config.auth.accountName }
69103 : { kind: "service-account" },
70− vaultId: config.vaultId,
104+ vaults: config.vaults,
71105 name: config.name,
72106 });
73107
74−// ---------------------------------------------------------------------------
75−// Vault
76−// ---------------------------------------------------------------------------
77−
78−export const Vault = Schema.Struct({
79− id: Schema.String,
80− name: Schema.String,
81−});
82−export type Vault = typeof Vault.Type;
83−
84108 // ---------------------------------------------------------------------------
85109 // Connection status
86110 // ---------------------------------------------------------------------------
87111
88112 export const ConnectionStatus = Schema.Struct({
89113 connected: Schema.Boolean,
90− vaultName: Schema.optional(Schema.String),
114+ vaultNames: Schema.optional(Schema.Array(Schema.String)),
91115 error: Schema.optional(Schema.String),
92116 });
93117 export type ConnectionStatus = typeof ConnectionStatus.Type;
packages/react/src/api/atoms.tsx+4−1
@@ -132,7 +132,10 @@ export const providersAtom = ExecutorApiClient.query("providers", "list", { · 131 unmodified lines
132132 export const providerItemsAtom = (key: ProviderKey) =>
133133 ExecutorApiClient.query("providers", "items", {
134134 params: { key },
135− timeToLive: "30 seconds",
135+ // Long retention on purpose: external-provider listings (1Password) are
136+ // slow, so pickers render the last-known list instantly and revalidate in
137+ // the background on mount instead of flashing a loading state each open.
138+ timeToLive: "10 minutes",
136139 reactivityKeys: [ReactivityKey.providers],
137140 });
138141
packages/react/src/components/add-account-modal.tsx+90−31
@@ -1,5 +1,5 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2−import { useAtomSet, useAtomValue } from "@effect/atom-react";
2+import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import {
@@ -106,7 +106,14 @@ import { · 100 unmodified lines
106106 import { Input } from "./input";
107107 import { Label } from "./label";
108108 import { RadioGroup, RadioGroupItem } from "./radio-group";
109−import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
109+import {
110+ Combobox,
111+ ComboboxContent,
112+ ComboboxEmpty,
113+ ComboboxInput,
114+ ComboboxItem,
115+ ComboboxList,
116+} from "./combobox";
110117 import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
111118
112119 // ---------------------------------------------------------------------------
@@ -335,38 +342,45 @@ function PasteCredentialInputs(props: { · 222 unmodified lines
335342 );
336343 }
337344
345+type OnePasswordItem = {
346+ readonly id: ProviderItemId;
347+ readonly name: string;
348+ readonly group?: string;
349+};
350+
338351 function OnePasswordItemSelect(props: {
339352 readonly value: string;
340353 readonly onChange: (value: string) => void;
341354 }) {
342− const itemsResult = useAtomValue(providerItemsAtom(ONEPASSWORD_PROVIDER));
355+ const itemsAtom = providerItemsAtom(ONEPASSWORD_PROVIDER);
356+ const itemsResult = useAtomValue(itemsAtom);
357+ const refreshItems = useAtomRefresh(itemsAtom);
358+
359+ // Stale-while-revalidate: with a retained value the list renders instantly
360+ // and one background refresh per mount picks up vault changes (refreshing
361+ // keeps the previous value, so nothing flashes). A cold mount is already
362+ // fetching — refreshing it would only restart the request. The ref carries
363+ // the latest cached-ness into the effect without re-running it.
364+ const isCachedRef = useRef(false);
365+ isCachedRef.current = AsyncResult.isSuccess(itemsResult);
366+ useEffect(() => {
367+ if (isCachedRef.current) refreshItems();
368+ }, [refreshItems]);
343369 const state = AsyncResult.matchWithError(
344− itemsResult as AsyncResult.AsyncResult<
345− readonly { readonly id: ProviderItemId; readonly name: string }[],
346− Error
347− >,
370+ itemsResult as AsyncResult.AsyncResult<readonly OnePasswordItem[], Error>,
348371 {
349372 onInitial: () => ({
350− items: [] as readonly {
351− readonly id: ProviderItemId;
352− readonly name: string;
353− }[],
373+ items: [] as readonly OnePasswordItem[],
354374 loading: true,
355375 error: null as string | null,
356376 }),
357377 onError: () => ({
358− items: [] as readonly {
359− readonly id: ProviderItemId;
360− readonly name: string;
361− }[],
378+ items: [] as readonly OnePasswordItem[],
362379 loading: false,
363380 error: "Failed to load 1Password items",
364381 }),
365382 onDefect: () => ({
366− items: [] as readonly {
367− readonly id: ProviderItemId;
368− readonly name: string;
369− }[],
383+ items: [] as readonly OnePasswordItem[],
370384 loading: false,
371385 error: "Failed to load 1Password items",
372386 }),
@@ -374,6 +388,33 @@ function OnePasswordItemSelect(props: { · 1 unmodified line
374388 },
375389 );
376390
391+ const stateItems = state.items;
392+ const sorted = useMemo(
393+ () =>
394+ [...stateItems].sort(
395+ (a, b) => a.name.localeCompare(b.name) || (a.group ?? "").localeCompare(b.group ?? ""),
396+ ),
397+ [stateItems],
398+ );
399+ const byId = useMemo(() => {
400+ const map = new Map<string, OnePasswordItem>();
401+ for (const item of sorted) map.set(String(item.id), item);
402+ return map;
403+ }, [sorted]);
404+ const ids = useMemo(() => sorted.map((item) => String(item.id)), [sorted]);
405+
406+ const filter = useCallback(
407+ (id: string, query: string) => {
408+ const needle = query.trim().toLowerCase();
409+ if (needle === "") return true;
410+ const item = byId.get(id);
411+ return [item?.name ?? "", item?.group ?? ""].some((part) =>
412+ part.toLowerCase().includes(needle),
413+ );
414+ },
415+ [byId],
416+ );
417+
377418 if (state.loading) {
378419 return <p className="text-xs text-muted-foreground">Loading 1Password items…</p>;
379420 }
@@ -386,18 +427,36 @@ function OnePasswordItemSelect(props: { · 6 unmodified lines
386427
387428 return (
388429 <div className="space-y-1" data-ph-block>
389− <Select value={props.value} onValueChange={props.onChange}>
390− <SelectTrigger>
391− <SelectValue placeholder="Select secret" />
392− </SelectTrigger>
393− <SelectContent>
394− {state.items.map((item) => (
395− <SelectItem key={String(item.id)} value={String(item.id)}>
396− {item.name}
397− </SelectItem>
398− ))}
399− </SelectContent>
400− </Select>
430+ <Combobox
431+ items={ids}
432+ value={props.value.length > 0 ? props.value : null}
433+ filter={filter}
434+ limit={100}
435+ itemToStringLabel={(id: string) => byId.get(id)?.name ?? id}
436+ onValueChange={(id) => {
437+ if (id !== null) props.onChange(id);
438+ }}
439+ >
440+ <ComboboxInput placeholder="Search 1Password items…" className="w-full" />
441+ <ComboboxContent>
442+ <ComboboxEmpty>No items match.</ComboboxEmpty>
443+ <ComboboxList>
444+ {(id: string) => {
445+ const item = byId.get(id);
446+ return (
447+ <ComboboxItem key={id} value={id}>
448+ <span className="min-w-0 flex-1 truncate">{item?.name ?? id}</span>
449+ {item?.group && (
450+ <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
451+ {item.group}
452+ </span>
453+ )}
454+ </ComboboxItem>
455+ );
456+ }}
457+ </ComboboxList>
458+ </ComboboxContent>
459+ </Combobox>
401460 </div>
402461 );
403462 }