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 @@
1---1---
2"executor": minor2"executor": patch
3---3---
4 4
5**Irreversible cleanup now waits for the transaction to commit, and plugins can do the same**5**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
7The 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
9Reopening 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
11Configurations 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
21 Effect.gen(function* () {21 Effect.gen(function* () {
22 const executor = yield* ExecutorService;22 const executor = yield* ExecutorService;
23 const entries = yield* executor.providers.items(path.key);23 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 }));
25 }),29 }),
26 ),30 ),
27 ),31 ),
packages/core/api/src/providers/api.ts+1−0
@@ -26,6 +26,7 @@ const ProviderParams = { key: ProviderKey }; · 25 unmodified lines
26const ProviderEntryResponse = Schema.Struct({26const ProviderEntryResponse = Schema.Struct({
27 id: ProviderItemId,27 id: ProviderItemId,
28 name: Schema.String,28 name: Schema.String,
29 group: Schema.optional(Schema.String),
29});30});
30 31
31// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
packages/core/sdk/src/provider.ts+3−0
@@ -16,6 +16,9 @@ export interface ProviderEntry { · 15 unmodified lines
16 * a connection can reference it without core knowing its internal shape. */16 * a connection can reference it without core knowing its internal shape. */
17 readonly id: ProviderItemId;17 readonly id: ProviderItemId;
18 readonly name: string;18 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;
19}22}
20 23
21export interface CredentialProvider {24export interface CredentialProvider {
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+93−60
@@ -1,8 +1,9 @@
1import { useState } from "react";1import { useEffect, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
6import { Checkbox } from "@executor-js/react/components/checkbox";
6import { Input } from "@executor-js/react/components/input";7import { Input } from "@executor-js/react/components/input";
7import { Label } from "@executor-js/react/components/label";8import { Label } from "@executor-js/react/components/label";
8import {9import {
@@ -35,10 +36,10 @@ import { · 26 unmodified lines
35 removeOnePasswordConfig,36 removeOnePasswordConfig,
36 onepasswordWriteKeys,37 onepasswordWriteKeys,
37} from "./atoms";38} from "./atoms";
38import type { RedactedOnePasswordConfig } from "../sdk/types";39import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";
39 40
40// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
41// Vault picker42// Vault picker — multi-select
42// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
43 44
44const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults";45const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults";
@@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => { · 7 unmodified lines
52function VaultPicker(props: {53function VaultPicker(props: {
53 authKind: "desktop-app" | "service-account";54 authKind: "desktop-app" | "service-account";
54 accountName: string;55 accountName: string;
55 vaultId: string;56 selected: ReadonlyArray<Vault>;
56 onVaultSelect: (id: string, name: string) => void;57 onSelectedChange: (vaults: ReadonlyArray<Vault>) => void;
57}) {58}) {
58 const account = props.accountName.trim();59 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]);
60 74
61 const { vaults, isLoading, error } = AsyncResult.matchWithError(75 const { vaults, isLoading, error } = AsyncResult.matchWithError(
62 vaultsResult as AsyncResult.AsyncResult<76 vaultsResult as AsyncResult.AsyncResult<
@@ -81,12 +95,9 @@ function VaultPicker(props: { · 18 unmodified lines
81 }),95 }),
82 onSuccess: ({ value }) => {96 onSuccess: ({ value }) => {
83 const v = value.vaults;97 const v = value.vaults;
84 const defaultVault = v[0];98 const onlyVault = v.length === 1 ? v[0] : undefined;
85 if (99 if (onlyVault && props.selected.length === 0) {
86 defaultVault &&100 queueMicrotask(() => props.onSelectedChange([onlyVault]));
87 (!props.vaultId || (v.length === 1 && props.vaultId !== defaultVault.id))
88 ) {
89 queueMicrotask(() => props.onVaultSelect(defaultVault.id, defaultVault.name));
90 }101 }
91 return { vaults: [...v], isLoading: false, error: null };102 return { vaults: [...v], isLoading: false, error: null };
92 },103 },
@@ -101,34 +112,51 @@ function VaultPicker(props: { · 8 unmodified lines
101 );112 );
102 }113 }
103 114
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 };
105 130
106 return (131 return (
107 <div className="grid gap-2">132 <div className="grid gap-2">
108 {singleVault ? (133 {isLoading ? (
109 <div className="flex h-9 items-center rounded-md border border-input bg-muted/30 px-3 text-[13px] text-foreground">134 <p className="text-[11px] text-muted-foreground/50 py-1">Loading vaults…</p>
110 <span className="truncate">{singleVault.name}</span>135 ) : rows.length === 0 ? (
111 </div>136 <p className="text-[11px] text-muted-foreground/50 py-1">No vaults found.</p>
112 ) : (137 ) : (
113 <Select138 <div className="grid max-h-44 gap-0.5 overflow-y-auto rounded-md border border-input p-1">
114 disabled={isLoading || vaults.length === 0}139 {rows.map((vault) => {
115 value={props.vaultId}140 const checked = props.selected.some((v) => v.id === vault.id);
116 onValueChange={(id) => {141 return (
117 const v = vaults.find((vault) => vault.id === id);142 <Label
118 if (v) props.onVaultSelect(v.id, v.name);143 key={vault.id}
119 }}144 className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 font-normal hover:bg-muted/40"
120 >145 >
121 <SelectTrigger className="h-9 text-[13px]">146 <Checkbox
122 <SelectValue placeholder={isLoading ? "Loading…" : "Select a vault"} />147 checked={checked}
123 </SelectTrigger>148 onCheckedChange={(value) => toggle(vault, value === true)}
124 <SelectContent>149 />
125 {vaults.map((v) => (150 <span className="truncate text-[13px] text-foreground">{vault.name}</span>
126 <SelectItem key={v.id} value={v.id}>151 {!loadedIds.has(vault.id) && (
127 {v.name}152 <span className="ml-auto shrink-0 text-[11px] text-muted-foreground/50">
128 </SelectItem>153 not found
129 ))}154 </span>
130 </SelectContent>155 )}
131 </Select>156 </Label>
157 );
158 })}
159 </div>
132 )}160 )}
133 {error && (161 {error && (
134 <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5">162 <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
151 initial?: {179 initial?: {
152 authKind: string;180 authKind: string;
153 accountName: string;181 accountName: string;
154 vaultId: string;182 vaults: ReadonlyArray<Vault>;
155 name: string;183 name: string;
156 };184 };
157}) {185}) {
@@ -160,8 +188,10 @@ function ConfigDialog(props: { · 2 unmodified lines
160 (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app",188 (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app",
161 );189 );
162 const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com");190 const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com");
163 const [vaultId, setVaultId] = useState(props.initial?.vaultId ?? "");191 const [selectedVaults, setSelectedVaults] = useState<ReadonlyArray<Vault>>(
164 const [vaultName, setVaultName] = useState(props.initial?.name ?? "");192 props.initial?.vaults ?? [],
193 );
194 const [displayName, setDisplayName] = useState(props.initial?.name ?? "");
165 const [saving, setSaving] = useState(false);195 const [saving, setSaving] = useState(false);
166 const [error, setError] = useState<string | null>(null);196 const [error, setError] = useState<string | null>(null);
167 197
@@ -171,15 +201,16 @@ function ConfigDialog(props: { · 3 unmodified lines
171 if (!isEdit) {201 if (!isEdit) {
172 setAuthKind("desktop-app");202 setAuthKind("desktop-app");
173 setAccountName("my.1password.com");203 setAccountName("my.1password.com");
174 setVaultId("");204 setSelectedVaults([]);
175 setVaultName("");205 setDisplayName("");
176 }206 }
177 setError(null);207 setError(null);
178 setSaving(false);208 setSaving(false);
179 };209 };
180 210
181 const handleSave = async () => {211 const handleSave = async () => {
182 if (!accountName.trim() || !vaultId.trim()) return;212 const [firstVault, ...restVaults] = selectedVaults;
213 if (!accountName.trim() || firstVault === undefined) return;
183 setSaving(true);214 setSaving(true);
184 setError(null);215 setError(null);
185 216
@@ -191,8 +222,8 @@ function ConfigDialog(props: { · 5 unmodified lines
191 const exit = await doConfigure({222 const exit = await doConfigure({
192 payload: {223 payload: {
193 auth,224 auth,
194 vaultId: vaultId.trim(),225 vaults: [firstVault, ...restVaults],
195 name: vaultName.trim() || "1Password",226 name: displayName.trim() || "1Password",
196 },227 },
197 reactivityKeys: onepasswordWriteKeys,228 reactivityKeys: onepasswordWriteKeys,
198 });229 });
@@ -220,7 +251,8 @@ function ConfigDialog(props: { · 21 unmodified lines
220 {isEdit ? "Edit 1Password" : "Connect 1Password"}251 {isEdit ? "Edit 1Password" : "Connect 1Password"}
221 </DialogTitle>252 </DialogTitle>
222 <DialogDescription className="text-[13px] leading-relaxed">253 <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.
224 </DialogDescription>256 </DialogDescription>
225 </DialogHeader>257 </DialogHeader>
226 258
@@ -262,19 +294,16 @@ function ConfigDialog(props: { · 35 unmodified lines
262 </p>294 </p>
263 </div>295 </div>
264 296
265 {/* Vault */}297 {/* Vaults */}
266 <div className="grid gap-1.5">298 <div className="grid gap-1.5">
267 <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">299 <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
268 Vault300 Vaults
269 </Label>301 </Label>
270 <VaultPicker302 <VaultPicker
271 authKind={authKind}303 authKind={authKind}
272 accountName={accountName}304 accountName={accountName}
273 vaultId={vaultId}305 selected={selectedVaults}
274 onVaultSelect={(id, name) => {306 onSelectedChange={setSelectedVaults}
275 setVaultId(id);
276 setVaultName(name);
277 }}
278 />307 />
279 </div>308 </div>
280 309
@@ -285,8 +314,8 @@ function ConfigDialog(props: { · 4 unmodified lines
285 </Label>314 </Label>
286 <Input315 <Input
287 placeholder="1Password"316 placeholder="1Password"
288 value={vaultName}317 value={displayName}
289 onChange={(e) => setVaultName((e.target as HTMLInputElement).value)}318 onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
290 className="text-[13px] h-9"319 className="text-[13px] h-9"
291 />320 />
292 </div>321 </div>
@@ -307,7 +336,7 @@ function ConfigDialog(props: { · 14 unmodified lines
307 <Button336 <Button
308 size="sm"337 size="sm"
309 onClick={handleSave}338 onClick={handleSave}
310 disabled={!accountName.trim() || !vaultId.trim() || saving}339 disabled={!accountName.trim() || selectedVaults.length === 0 || saving}
311 >340 >
312 {saving ? "Saving…" : isEdit ? "Update" : "Connect"}341 {saving ? "Saving…" : isEdit ? "Update" : "Connect"}
313 </Button>342 </Button>
@@ -371,14 +400,18 @@ export default function OnePasswordSettings() { · 57 unmodified lines
371 <span className="font-mono text-foreground/80 truncate">400 <span className="font-mono text-foreground/80 truncate">
372 {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}401 {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
373 </span>402 </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>
375 <div className="flex items-center gap-2 min-w-0">406 <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>
377 </div>410 </div>
378 </div>411 </div>
379 ) : (412 ) : (
380 <CardStackEntryDescription>413 <CardStackEntryDescription>
381 Resolve secrets from your 1Password vault.414 Resolve secrets from your 1Password vaults.
382 </CardStackEntryDescription>415 </CardStackEntryDescription>
383 )}416 )}
384 </CardStackEntryContent>417 </CardStackEntryContent>
@@ -429,7 +462,7 @@ export default function OnePasswordSettings() { · 44 unmodified lines
429 // Service-account tokens are never surfaced (redacted); the462 // Service-account tokens are never surfaced (redacted); the
430 // user re-enters the token when editing that auth method.463 // user re-enters the token when editing that auth method.
431 accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",464 accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",
432 vaultId: config.vaultId,465 vaults: config.vaults,
433 name: config.name,466 name: config.name,
434 }467 }
435 : undefined468 : undefined
packages/plugins/onepassword/src/react/atoms.ts+4−1
@@ -33,7 +33,10 @@ export const onepasswordVaultsAtom = ( · 32 unmodified lines
33) =>33) =>
34 OnePasswordClient.query("onepassword", "listVaults", {34 OnePasswordClient.query("onepassword", "listVaults", {
35 query: { authKind, account },35 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",
37 reactivityKeys: [ReactivityKey.providers],40 reactivityKeys: [ReactivityKey.providers],
38 });41 });
39 42
packages/plugins/onepassword/src/sdk/index.ts+6−0
@@ -1,12 +1,18 @@
1export {1export {
2 onepasswordPlugin,2 onepasswordPlugin,
3 makeOnePasswordStore,3 makeOnePasswordStore,
4 resolveConfiguredRef,
5 ambiguityMessage,
6 type RefResolution,
4 type OnePasswordExtension,7 type OnePasswordExtension,
5 type OnePasswordPluginOptions,8 type OnePasswordPluginOptions,
6 type OnePasswordStore,9 type OnePasswordStore,
7} from "./plugin";10} from "./plugin";
8export {11export {
9 OnePasswordConfig,12 OnePasswordConfig,
13 LegacyOnePasswordConfig,
14 StoredOnePasswordConfig,
15 normalizeStoredConfig,
10 RedactedOnePasswordConfig,16 RedactedOnePasswordConfig,
11 RedactedOnePasswordAuth,17 RedactedOnePasswordAuth,
12 redactConfig,18 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
2import { Effect } from "effect";2import { Effect } from "effect";
3 3
4import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk";4import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk";
5import { makeInMemoryBlobStore, pluginBlobStore } from "@executor-js/sdk/core";
5import { makeTestConfig } from "@executor-js/sdk/testing";6import { makeTestConfig } from "@executor-js/sdk/testing";
6 7
7import { onepasswordPlugin } from "./plugin";8import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin";
9import type { OnePasswordService } from "./service";
10import { OnePasswordError } from "./errors";
8import { OnePasswordConfig, DesktopAppAuth } from "./types";11import { OnePasswordConfig, DesktopAppAuth } from "./types";
9 12
10// removed: v1 routed configure/removeConfig through an explicit `ScopeId`13// removed: v1 routed configure/removeConfig through an explicit `ScopeId`
@@ -16,6 +19,18 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 5 unmodified lines
16 19
17const ONEPASSWORD = ProviderKey.make("onepassword");20const ONEPASSWORD = ProviderKey.make("onepassword");
18 21
22const 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
19describe("onepassword plugin", () => {34describe("onepassword plugin", () => {
20 it.effect("registers onepassword as a credential provider", () =>35 it.effect("registers onepassword as a credential provider", () =>
21 Effect.gen(function* () {36 Effect.gen(function* () {
@@ -36,20 +51,14 @@ describe("onepassword plugin", () => { · 14 unmodified lines
36 const initial = yield* executor.onepassword.getConfig();51 const initial = yield* executor.onepassword.getConfig();
37 expect(initial).toBeNull();52 expect(initial).toBeNull();
38 53
39 const config = OnePasswordConfig.make({54 yield* executor.onepassword.configure(twoVaultConfig);
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);
49 55
50 const loaded = yield* executor.onepassword.getConfig();56 const loaded = yield* executor.onepassword.getConfig();
51 expect(loaded?.vaultId).toBe("vault-123");57 expect(loaded?.vaults).toEqual([
52 expect(loaded?.name).toBe("Personal");58 { id: "vault-123", name: "Personal" },
59 { id: "vault-456", name: "Work" },
60 ]);
61 expect(loaded?.name).toBe("1Password");
53 expect(loaded?.auth.kind).toBe("desktop-app");62 expect(loaded?.auth.kind).toBe("desktop-app");
54 63
55 yield* executor.onepassword.removeConfig();64 yield* executor.onepassword.removeConfig();
@@ -67,7 +76,7 @@ describe("onepassword plugin", () => { · 11 unmodified lines
67 yield* executor.onepassword.configure(76 yield* executor.onepassword.configure(
68 OnePasswordConfig.make({77 OnePasswordConfig.make({
69 auth: { kind: "service-account", token: "super-secret-token" },78 auth: { kind: "service-account", token: "super-secret-token" },
70 vaultId: "vault-123",79 vaults: [{ id: "vault-123", name: "CI" }],
71 name: "CI",80 name: "CI",
72 }),81 }),
73 );82 );
@@ -89,8 +98,11 @@ describe("onepassword plugin", () => { · 15 unmodified lines
89 ToolAddress.make("executor.onepassword.configure"),98 ToolAddress.make("executor.onepassword.configure"),
90 {99 {
91 auth: { kind: "desktop-app", accountName: "my.1password.com" },100 auth: { kind: "desktop-app", accountName: "my.1password.com" },
92 vaultId: "vault-123",101 vaults: [
93 name: "Personal",102 { id: "vault-123", name: "Personal" },
103 { id: "vault-456", name: "Work" },
104 ],
105 name: "1Password",
94 },106 },
95 { onElicitation: "accept-all" },107 { onElicitation: "accept-all" },
96 );108 );
@@ -100,7 +112,15 @@ describe("onepassword plugin", () => { · 3 unmodified lines
100 yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}),112 yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}),
101 ).toMatchObject({113 ).toMatchObject({
102 ok: true,114 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 },
104 });124 });
105 125
106 const removed = yield* executor.execute(126 const removed = yield* executor.execute(
@@ -125,3 +145,180 @@ describe("onepassword plugin", () => { · 18 unmodified lines
125 }),145 }),
126 );146 );
127});147});
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
155describe("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
203const 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
220describe("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
20 OnePasswordAuth,20 OnePasswordAuth,
21 OnePasswordConfig,21 OnePasswordConfig,
22 RedactedOnePasswordConfig,22 RedactedOnePasswordConfig,
23 StoredOnePasswordConfig,
23 Vault,24 Vault,
24 ConnectionStatus,25 ConnectionStatus,
26 normalizeStoredConfig,
25 redactConfig,27 redactConfig,
26} from "./types";28} from "./types";
27import { OnePasswordError } from "./errors";29import { OnePasswordError } from "./errors";
@@ -42,11 +44,7 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 14 unmodified lines
42 I44 I
43 >;45 >;
44 46
45const OnePasswordConfigureInput = Schema.Struct({47const OnePasswordConfigureInput = OnePasswordConfig;
46 auth: OnePasswordAuth,
47 vaultId: Schema.String,
48 name: Schema.String,
49});
50 48
51const OnePasswordConfigureOutput = Schema.Struct({49const OnePasswordConfigureOutput = Schema.Struct({
52 configured: Schema.Boolean,50 configured: Schema.Boolean,
@@ -98,10 +96,12 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 45 unmodified lines
98// ---------------------------------------------------------------------------96// ---------------------------------------------------------------------------
99// Typed config store — single blob, JSON encoded, owner-partitioned. The97// Typed config store — single blob, JSON encoded, owner-partitioned. The
100// stored config carries the auth credential (desktop account name, or98// stored config carries the auth credential (desktop account name, or
101// service-account token) plus the selected vault. v1 keyed this by executor99// service-account token) plus the selected vaults. v1 keyed this by executor
102// scope; v2 partitions by `owner` — the plugin-owned config row owns the100// scope; v2 partitions by `owner` — the plugin-owned config row owns the
103// partition, mirroring the connection model. Blob I/O failures surface as101// partition, mirroring the connection model. Reads also accept the legacy
104// `StorageError`; decode failures stay `OnePasswordError`.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`.
105// ---------------------------------------------------------------------------105// ---------------------------------------------------------------------------
106 106
107export interface OnePasswordStore {107export interface OnePasswordStore {
@@ -116,7 +116,7 @@ export interface OnePasswordStore { · 8 unmodified lines
116 readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>;116 readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>;
117}117}
118 118
119const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(OnePasswordConfig));119const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig));
120 120
121const blobStorageError =121const blobStorageError =
122 (operation: string) =>122 (operation: string) =>
@@ -133,6 +133,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 10 unmodified lines
133 Effect.flatMap((raw) => {133 Effect.flatMap((raw) => {
134 if (raw === null) return Effect.succeed(null);134 if (raw === null) return Effect.succeed(null);
135 return decodeConfig(raw).pipe(135 return decodeConfig(raw).pipe(
136 Effect.map(normalizeStoredConfig),
136 Effect.mapError(137 Effect.mapError(
137 () =>138 () =>
138 new OnePasswordError({139 new OnePasswordError({
@@ -150,7 +151,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 11 unmodified lines
150 CONFIG_KEY,151 CONFIG_KEY,
151 JSON.stringify({152 JSON.stringify({
152 auth: config.auth,153 auth: config.auth,
153 vaultId: config.vaultId,154 vaults: config.vaults,
154 name: config.name,155 name: config.name,
155 }),156 }),
156 { owner },157 { owner },
@@ -177,22 +178,105 @@ const getServiceFromConfig = ( · 20 unmodified lines
177): Effect.Effect<OnePasswordService, OnePasswordError> =>178): Effect.Effect<OnePasswordService, OnePasswordError> =>
178 makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk });179 makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk });
179 180
180const configuredVaultUri = (config: OnePasswordConfig, itemId: string): string | null => {181// ---------------------------------------------------------------------------
181 if (!itemId.startsWith("op://")) {182// Explicit ref resolution.
182 return `op://${config.vaultId}/${itemId}/${CREDENTIAL_FIELD}`;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
196export 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
210export 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
220const 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`. */
225export 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 })));
183 }243 }
184 const match = itemId.match(/^op:\/\/([^/]+)\/.+/);244
185 if (!match || match[1] !== config.vaultId) return null;245 return Effect.gen(function* () {
186 return itemId;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 });
187};270};
188 271
189// ---------------------------------------------------------------------------272// ---------------------------------------------------------------------------
190// CredentialProvider — read-only, resolves op:// URIs or vaultId-based lookups.273// CredentialProvider — read-only, resolves op:// URIs or vault-scoped lookups.
191//274//
192// v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is275// 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 that276// a vault-qualified `op://` ref (what `list()` hands out) or a bare item
194// the stored config's vault scopes. The plugin's stored config supplies the277// id/title that must locate exactly one item across the configured vaults.
195// auth + vault binding; the provider never writes (writable: false).278// The plugin's stored config supplies the auth + vault bindings; the provider
279// never writes (writable: false).
196// ---------------------------------------------------------------------------280// ---------------------------------------------------------------------------
197 281
198const makeProvider = (282const makeProvider = (
@@ -205,19 +289,32 @@ const makeProvider = ( · 6 unmodified lines
205 289
206 get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>290 get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>
207 ctx.storage.getConfig().pipe(291 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)),
208 Effect.flatMap((config) => {295 Effect.flatMap((config) => {
209 if (!config) return Effect.succeed(null as string | null);296 if (!config) return Effect.succeed(null as string | null);
210 297
211 const uri = configuredVaultUri(config, id);
212 if (uri === null) return Effect.succeed(null as string | null);
213
214 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(298 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(
215 Effect.flatMap((svc) => svc.resolveSecret(uri)),299 Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)),
216 Effect.map((v): string | null => v),300 // Backend unreachability degrades to "no value", matching the other
217 Effect.orElseSucceed(() => null),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 ),
218 );316 );
219 }),317 }),
220 Effect.catch(() => Effect.succeed(null as string | null)),
221 ),318 ),
222 319
223 list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>320 list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>
@@ -225,10 +322,25 @@ const makeProvider = ( · 1 unmodified line
225 Effect.flatMap((config) => {322 Effect.flatMap((config) => {
226 if (!config) return Effect.succeed([] as readonly ProviderEntry[]);323 if (!config) return Effect.succeed([] as readonly ProviderEntry[]);
227 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(324 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(
228 Effect.flatMap((svc) => svc.listItems(config.vaultId)),325 Effect.flatMap((svc) =>
229 Effect.map((items): readonly ProviderEntry[] =>326 Effect.forEach(config.vaults, (vault) =>
230 items.map((item) => ({ id: ProviderItemId.make(item.id), name: item.title })),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 ),
231 ),342 ),
343 Effect.map((groups): readonly ProviderEntry[] => groups.flat()),
232 );344 );
233 }),345 }),
234 Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),346 Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
@@ -270,11 +382,19 @@ const makeOnePasswordExtension = ( · 35 unmodified lines
270 });382 });
271 }383 }
272 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);384 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
273 const vaults = yield* svc.listVaults();385 const live = yield* svc.listVaults();
274 const vault = vaults.find((v) => v.id === config.vaultId);386 const liveById = new Map(live.map((v) => [v.id, v.title]));
387 const missing = config.vaults.filter((vault) => !liveById.has(vault.id));
275 return ConnectionStatus.make({388 return ConnectionStatus.make({
276 connected: true,389 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 : {}),
278 });398 });
279 }),399 }),
280 400
@@ -299,15 +419,25 @@ const makeOnePasswordExtension = ( · 18 unmodified lines
299 message: "1Password is not configured",419 message: "1Password is not configured",
300 });420 });
301 }421 }
302 const scopedUri = configuredVaultUri(config, uri);422 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
303 if (scopedUri === null) {423 const resolution = yield* resolveConfiguredRef(svc, config, uri);
424 if (resolution.kind === "resolved") return resolution.value;
425 if (resolution.kind === "outside-vaults") {
304 return yield* new OnePasswordError({426 return yield* new OnePasswordError({
305 operation: "resolve",427 operation: "resolve",
306 message: "1Password secret URI is outside the configured vault",428 message: "1Password secret URI is outside the configured vaults",
307 });429 });
308 }430 }
309 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);431 if (resolution.kind === "ambiguous") {
310 return yield* svc.resolveSecret(scopedUri);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 });
311 }),441 }),
312 };442 };
313};443};
@@ -345,7 +475,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 31 unmodified lines
345 tool({475 tool({
346 name: "status",476 name: "status",
347 description:477 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.",
349 outputSchema: OnePasswordStatusOutputStd,479 outputSchema: OnePasswordStatusOutputStd,
350 execute: () => Effect.map(self.status(), ToolResult.ok),480 execute: () => Effect.map(self.status(), ToolResult.ok),
351 }),481 }),
@@ -368,7 +498,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines
368 tool({498 tool({
369 name: "configure",499 name: "configure",
370 description:500 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.",
372 annotations: {502 annotations: {
373 requiresApproval: true,503 requiresApproval: true,
374 approvalDescription: "Configure the 1Password credential provider",504 approvalDescription: "Configure the 1Password credential provider",
@@ -377,7 +507,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 2 unmodified lines
377 outputSchema: OnePasswordConfigureOutputStd,507 outputSchema: OnePasswordConfigureOutputStd,
378 execute: (input) =>508 execute: (input) =>
379 Effect.as(509 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 }),
381 ToolResult.ok({ configured: true }),511 ToolResult.ok({ configured: true }),
382 ),512 ),
383 }),513 }),
packages/plugins/onepassword/src/sdk/types.ts+40−16
@@ -24,19 +24,53 @@ export type ServiceAccountAuth = typeof ServiceAccountAuth.Type; · 23 unmodified lines
24export const OnePasswordAuth = Schema.Union([DesktopAppAuth, ServiceAccountAuth]);24export const OnePasswordAuth = Schema.Union([DesktopAppAuth, ServiceAccountAuth]);
25export type OnePasswordAuth = typeof OnePasswordAuth.Type;25export type OnePasswordAuth = typeof OnePasswordAuth.Type;
26 26
27// ---------------------------------------------------------------------------
28// Vault
29// ---------------------------------------------------------------------------
30
31export const Vault = Schema.Struct({
32 id: Schema.String,
33 name: Schema.String,
34});
35export type Vault = typeof Vault.Type;
36
27// ---------------------------------------------------------------------------37// ---------------------------------------------------------------------------
28// Stored config — persisted via KV38// Stored config — persisted via KV
29// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
30 40
31export const OnePasswordConfig = Schema.Struct({41export const OnePasswordConfig = Schema.Struct({
32 auth: OnePasswordAuth,42 auth: OnePasswordAuth,
33 /** Vault to scope operations to */43 /** Vaults to scope operations to. Order is presentational only: refs are
34 vaultId: Schema.String,44 * vault-qualified, and a bare ref that matches in more than one vault is an
35 /** Human label */45 * explicit ambiguity failure, never a precedence pick. */
46 vaults: Schema.NonEmptyArray(Vault),
47 /** Human label for the whole connection */
36 name: Schema.String,48 name: Schema.String,
37});49});
38export type OnePasswordConfig = typeof OnePasswordConfig.Type;50export type OnePasswordConfig = typeof OnePasswordConfig.Type;
39 51
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. */
55export const LegacyOnePasswordConfig = Schema.Struct({
56 auth: OnePasswordAuth,
57 vaultId: Schema.String,
58 name: Schema.String,
59});
60export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type;
61
62export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]);
63export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type;
64
65export 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
40// ---------------------------------------------------------------------------74// ---------------------------------------------------------------------------
41// Redacted config — what `getConfig` returns to agents / the UI. The75// Redacted config — what `getConfig` returns to agents / the UI. The
42// service-account token is stripped; only the auth kind + account metadata is76// service-account token is stripped; only the auth kind + account metadata is
@@ -56,7 +90,7 @@ export const RedactedOnePasswordAuth = Schema.Union([ · 13 unmodified lines
56 90
57export const RedactedOnePasswordConfig = Schema.Struct({91export const RedactedOnePasswordConfig = Schema.Struct({
58 auth: RedactedOnePasswordAuth,92 auth: RedactedOnePasswordAuth,
59 vaultId: Schema.String,93 vaults: Schema.NonEmptyArray(Vault),
60 name: Schema.String,94 name: Schema.String,
61});95});
62export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type;96export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type;
@@ -67,27 +101,17 @@ export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConf · 4 unmodified lines
67 config.auth.kind === "desktop-app"101 config.auth.kind === "desktop-app"
68 ? { kind: "desktop-app", accountName: config.auth.accountName }102 ? { kind: "desktop-app", accountName: config.auth.accountName }
69 : { kind: "service-account" },103 : { kind: "service-account" },
70 vaultId: config.vaultId,104 vaults: config.vaults,
71 name: config.name,105 name: config.name,
72});106});
73 107
74// ---------------------------------------------------------------------------
75// Vault
76// ---------------------------------------------------------------------------
77
78export const Vault = Schema.Struct({
79 id: Schema.String,
80 name: Schema.String,
81});
82export type Vault = typeof Vault.Type;
83
84// ---------------------------------------------------------------------------108// ---------------------------------------------------------------------------
85// Connection status109// Connection status
86// ---------------------------------------------------------------------------110// ---------------------------------------------------------------------------
87 111
88export const ConnectionStatus = Schema.Struct({112export const ConnectionStatus = Schema.Struct({
89 connected: Schema.Boolean,113 connected: Schema.Boolean,
90 vaultName: Schema.optional(Schema.String),114 vaultNames: Schema.optional(Schema.Array(Schema.String)),
91 error: Schema.optional(Schema.String),115 error: Schema.optional(Schema.String),
92});116});
93export type ConnectionStatus = typeof ConnectionStatus.Type;117export 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
132export const providerItemsAtom = (key: ProviderKey) =>132export const providerItemsAtom = (key: ProviderKey) =>
133 ExecutorApiClient.query("providers", "items", {133 ExecutorApiClient.query("providers", "items", {
134 params: { key },134 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",
136 reactivityKeys: [ReactivityKey.providers],139 reactivityKeys: [ReactivityKey.providers],
137 });140 });
138 141
packages/react/src/components/add-account-modal.tsx+90−31
@@ -1,5 +1,5 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import {5import {
@@ -106,7 +106,14 @@ import { · 100 unmodified lines
106import { Input } from "./input";106import { Input } from "./input";
107import { Label } from "./label";107import { Label } from "./label";
108import { RadioGroup, RadioGroupItem } from "./radio-group";108import { RadioGroup, RadioGroupItem } from "./radio-group";
109import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";109import {
110 Combobox,
111 ComboboxContent,
112 ComboboxEmpty,
113 ComboboxInput,
114 ComboboxItem,
115 ComboboxList,
116} from "./combobox";
110import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";117import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
111 118
112// ---------------------------------------------------------------------------119// ---------------------------------------------------------------------------
@@ -335,38 +342,45 @@ function PasteCredentialInputs(props: { · 222 unmodified lines
335 );342 );
336}343}
337 344
345type OnePasswordItem = {
346 readonly id: ProviderItemId;
347 readonly name: string;
348 readonly group?: string;
349};
350
338function OnePasswordItemSelect(props: {351function OnePasswordItemSelect(props: {
339 readonly value: string;352 readonly value: string;
340 readonly onChange: (value: string) => void;353 readonly onChange: (value: string) => void;
341}) {354}) {
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]);
343 const state = AsyncResult.matchWithError(369 const state = AsyncResult.matchWithError(
344 itemsResult as AsyncResult.AsyncResult<370 itemsResult as AsyncResult.AsyncResult<readonly OnePasswordItem[], Error>,
345 readonly { readonly id: ProviderItemId; readonly name: string }[],
346 Error
347 >,
348 {371 {
349 onInitial: () => ({372 onInitial: () => ({
350 items: [] as readonly {373 items: [] as readonly OnePasswordItem[],
351 readonly id: ProviderItemId;
352 readonly name: string;
353 }[],
354 loading: true,374 loading: true,
355 error: null as string | null,375 error: null as string | null,
356 }),376 }),
357 onError: () => ({377 onError: () => ({
358 items: [] as readonly {378 items: [] as readonly OnePasswordItem[],
359 readonly id: ProviderItemId;
360 readonly name: string;
361 }[],
362 loading: false,379 loading: false,
363 error: "Failed to load 1Password items",380 error: "Failed to load 1Password items",
364 }),381 }),
365 onDefect: () => ({382 onDefect: () => ({
366 items: [] as readonly {383 items: [] as readonly OnePasswordItem[],
367 readonly id: ProviderItemId;
368 readonly name: string;
369 }[],
370 loading: false,384 loading: false,
371 error: "Failed to load 1Password items",385 error: "Failed to load 1Password items",
372 }),386 }),
@@ -374,6 +388,33 @@ function OnePasswordItemSelect(props: { · 1 unmodified line
374 },388 },
375 );389 );
376 390
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
377 if (state.loading) {418 if (state.loading) {
378 return <p className="text-xs text-muted-foreground">Loading 1Password items…</p>;419 return <p className="text-xs text-muted-foreground">Loading 1Password items…</p>;
379 }420 }
@@ -386,18 +427,36 @@ function OnePasswordItemSelect(props: { · 6 unmodified lines
386 427
387 return (428 return (
388 <div className="space-y-1" data-ph-block>429 <div className="space-y-1" data-ph-block>
389 <Select value={props.value} onValueChange={props.onChange}>430 <Combobox
390 <SelectTrigger>431 items={ids}
391 <SelectValue placeholder="Select secret" />432 value={props.value.length > 0 ? props.value : null}
392 </SelectTrigger>433 filter={filter}
393 <SelectContent>434 limit={100}
394 {state.items.map((item) => (435 itemToStringLabel={(id: string) => byId.get(id)?.name ?? id}
395 <SelectItem key={String(item.id)} value={String(item.id)}>436 onValueChange={(id) => {
396 {item.name}437 if (id !== null) props.onChange(id);
397 </SelectItem>438 }}
398 ))}439 >
399 </SelectContent>440 <ComboboxInput placeholder="Search 1Password items…" className="w-full" />
400 </Select>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>
401 </div>460 </div>
402 );461 );
403}462}