* 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
Unified · Split
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | --- |
| 2 | −"executor": minor | |
| 2 | +"executor": patch | |
| 3 | 3 | --- |
| 4 | 4 | |
| 5 | 5 | **Irreversible cleanup now waits for the transaction to commit, and plugins can do the same** |
| @@ -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. | |
| @@ -21,7 +21,11 @@ export const ProvidersHandlers = HttpApiBuilder.group(ExecutorApi, "providers", · 20 unmodified lines | ||
| 21 | 21 | Effect.gen(function* () { |
| 22 | 22 | const executor = yield* ExecutorService; |
| 23 | 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 | ), |
| @@ -26,6 +26,7 @@ const ProviderParams = { key: ProviderKey }; · 25 unmodified lines | ||
| 26 | 26 | const ProviderEntryResponse = Schema.Struct({ |
| 27 | 27 | id: ProviderItemId, |
| 28 | 28 | name: Schema.String, |
| 29 | + group: Schema.optional(Schema.String), | |
| 29 | 30 | }); |
| 30 | 31 | |
| 31 | 32 | // --------------------------------------------------------------------------- |
| @@ -16,6 +16,9 @@ export interface ProviderEntry { · 15 unmodified lines | ||
| 16 | 16 | * a connection can reference it without core knowing its internal shape. */ |
| 17 | 17 | readonly id: ProviderItemId; |
| 18 | 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 | |
| 21 | 24 | export interface CredentialProvider { |
| @@ -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"; | |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 5 | import { Button } from "@executor-js/react/components/button"; |
| 6 | +import { Checkbox } from "@executor-js/react/components/checkbox"; | |
| 6 | 7 | import { Input } from "@executor-js/react/components/input"; |
| 7 | 8 | import { Label } from "@executor-js/react/components/label"; |
| 8 | 9 | import { |
| @@ -35,10 +36,10 @@ import { · 26 unmodified lines | ||
| 35 | 36 | removeOnePasswordConfig, |
| 36 | 37 | onepasswordWriteKeys, |
| 37 | 38 | } from "./atoms"; |
| 38 | −import type { RedactedOnePasswordConfig } from "../sdk/types"; | |
| 39 | +import type { RedactedOnePasswordConfig, Vault } from "../sdk/types"; | |
| 39 | 40 | |
| 40 | 41 | // --------------------------------------------------------------------------- |
| 41 | −// Vault picker | |
| 42 | +// Vault picker — multi-select | |
| 42 | 43 | // --------------------------------------------------------------------------- |
| 43 | 44 | |
| 44 | 45 | const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults"; |
| @@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => { · 7 unmodified lines | ||
| 52 | 53 | function VaultPicker(props: { |
| 53 | 54 | authKind: "desktop-app" | "service-account"; |
| 54 | 55 | accountName: string; |
| 55 | − vaultId: string; | |
| 56 | − onVaultSelect: (id: string, name: string) => void; | |
| 56 | + selected: ReadonlyArray<Vault>; | |
| 57 | + onSelectedChange: (vaults: ReadonlyArray<Vault>) => void; | |
| 57 | 58 | }) { |
| 58 | 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 | 75 | const { vaults, isLoading, error } = AsyncResult.matchWithError( |
| 62 | 76 | vaultsResult as AsyncResult.AsyncResult< |
| @@ -81,12 +95,9 @@ function VaultPicker(props: { · 18 unmodified lines | ||
| 81 | 95 | }), |
| 82 | 96 | onSuccess: ({ value }) => { |
| 83 | 97 | 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])); | |
| 90 | 101 | } |
| 91 | 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 | 131 | return ( |
| 107 | 132 | <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> | |
| 112 | 137 | ) : ( |
| 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> | |
| 132 | 160 | )} |
| 133 | 161 | {error && ( |
| 134 | 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 | 179 | initial?: { |
| 152 | 180 | authKind: string; |
| 153 | 181 | accountName: string; |
| 154 | − vaultId: string; | |
| 182 | + vaults: ReadonlyArray<Vault>; | |
| 155 | 183 | name: string; |
| 156 | 184 | }; |
| 157 | 185 | }) { |
| @@ -160,8 +188,10 @@ function ConfigDialog(props: { · 2 unmodified lines | ||
| 160 | 188 | (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app", |
| 161 | 189 | ); |
| 162 | 190 | 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 ?? ""); | |
| 165 | 195 | const [saving, setSaving] = useState(false); |
| 166 | 196 | const [error, setError] = useState<string | null>(null); |
| 167 | 197 | |
| @@ -171,15 +201,16 @@ function ConfigDialog(props: { · 3 unmodified lines | ||
| 171 | 201 | if (!isEdit) { |
| 172 | 202 | setAuthKind("desktop-app"); |
| 173 | 203 | setAccountName("my.1password.com"); |
| 174 | − setVaultId(""); | |
| 175 | − setVaultName(""); | |
| 204 | + setSelectedVaults([]); | |
| 205 | + setDisplayName(""); | |
| 176 | 206 | } |
| 177 | 207 | setError(null); |
| 178 | 208 | setSaving(false); |
| 179 | 209 | }; |
| 180 | 210 | |
| 181 | 211 | const handleSave = async () => { |
| 182 | − if (!accountName.trim() || !vaultId.trim()) return; | |
| 212 | + const [firstVault, ...restVaults] = selectedVaults; | |
| 213 | + if (!accountName.trim() || firstVault === undefined) return; | |
| 183 | 214 | setSaving(true); |
| 184 | 215 | setError(null); |
| 185 | 216 | |
| @@ -191,8 +222,8 @@ function ConfigDialog(props: { · 5 unmodified lines | ||
| 191 | 222 | const exit = await doConfigure({ |
| 192 | 223 | payload: { |
| 193 | 224 | auth, |
| 194 | − vaultId: vaultId.trim(), | |
| 195 | − name: vaultName.trim() || "1Password", | |
| 225 | + vaults: [firstVault, ...restVaults], | |
| 226 | + name: displayName.trim() || "1Password", | |
| 196 | 227 | }, |
| 197 | 228 | reactivityKeys: onepasswordWriteKeys, |
| 198 | 229 | }); |
| @@ -220,7 +251,8 @@ function ConfigDialog(props: { · 21 unmodified lines | ||
| 220 | 251 | {isEdit ? "Edit 1Password" : "Connect 1Password"} |
| 221 | 252 | </DialogTitle> |
| 222 | 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 | 256 | </DialogDescription> |
| 225 | 257 | </DialogHeader> |
| 226 | 258 | |
| @@ -262,19 +294,16 @@ function ConfigDialog(props: { · 35 unmodified lines | ||
| 262 | 294 | </p> |
| 263 | 295 | </div> |
| 264 | 296 | |
| 265 | − {/* Vault */} | |
| 297 | + {/* Vaults */} | |
| 266 | 298 | <div className="grid gap-1.5"> |
| 267 | 299 | <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> |
| 268 | − Vault | |
| 300 | + Vaults | |
| 269 | 301 | </Label> |
| 270 | 302 | <VaultPicker |
| 271 | 303 | authKind={authKind} |
| 272 | 304 | accountName={accountName} |
| 273 | − vaultId={vaultId} | |
| 274 | − onVaultSelect={(id, name) => { | |
| 275 | − setVaultId(id); | |
| 276 | − setVaultName(name); | |
| 277 | − }} | |
| 305 | + selected={selectedVaults} | |
| 306 | + onSelectedChange={setSelectedVaults} | |
| 278 | 307 | /> |
| 279 | 308 | </div> |
| 280 | 309 | |
| @@ -285,8 +314,8 @@ function ConfigDialog(props: { · 4 unmodified lines | ||
| 285 | 314 | </Label> |
| 286 | 315 | <Input |
| 287 | 316 | 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)} | |
| 290 | 319 | className="text-[13px] h-9" |
| 291 | 320 | /> |
| 292 | 321 | </div> |
| @@ -307,7 +336,7 @@ function ConfigDialog(props: { · 14 unmodified lines | ||
| 307 | 336 | <Button |
| 308 | 337 | size="sm" |
| 309 | 338 | onClick={handleSave} |
| 310 | − disabled={!accountName.trim() || !vaultId.trim() || saving} | |
| 339 | + disabled={!accountName.trim() || selectedVaults.length === 0 || saving} | |
| 311 | 340 | > |
| 312 | 341 | {saving ? "Saving…" : isEdit ? "Update" : "Connect"} |
| 313 | 342 | </Button> |
| @@ -371,14 +400,18 @@ export default function OnePasswordSettings() { · 57 unmodified lines | ||
| 371 | 400 | <span className="font-mono text-foreground/80 truncate"> |
| 372 | 401 | {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} |
| 373 | 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 | 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 | 410 | </div> |
| 378 | 411 | </div> |
| 379 | 412 | ) : ( |
| 380 | 413 | <CardStackEntryDescription> |
| 381 | − Resolve secrets from your 1Password vault. | |
| 414 | + Resolve secrets from your 1Password vaults. | |
| 382 | 415 | </CardStackEntryDescription> |
| 383 | 416 | )} |
| 384 | 417 | </CardStackEntryContent> |
| @@ -429,7 +462,7 @@ export default function OnePasswordSettings() { · 44 unmodified lines | ||
| 429 | 462 | // Service-account tokens are never surfaced (redacted); the |
| 430 | 463 | // user re-enters the token when editing that auth method. |
| 431 | 464 | accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "", |
| 432 | − vaultId: config.vaultId, | |
| 465 | + vaults: config.vaults, | |
| 433 | 466 | name: config.name, |
| 434 | 467 | } |
| 435 | 468 | : undefined |
| @@ -33,7 +33,10 @@ export const onepasswordVaultsAtom = ( · 32 unmodified lines | ||
| 33 | 33 | ) => |
| 34 | 34 | OnePasswordClient.query("onepassword", "listVaults", { |
| 35 | 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 | 40 | reactivityKeys: [ReactivityKey.providers], |
| 38 | 41 | }); |
| 39 | 42 | |
| @@ -1,12 +1,18 @@ | ||
| 1 | 1 | export { |
| 2 | 2 | onepasswordPlugin, |
| 3 | 3 | makeOnePasswordStore, |
| 4 | + resolveConfiguredRef, | |
| 5 | + ambiguityMessage, | |
| 6 | + type RefResolution, | |
| 4 | 7 | type OnePasswordExtension, |
| 5 | 8 | type OnePasswordPluginOptions, |
| 6 | 9 | type OnePasswordStore, |
| 7 | 10 | } from "./plugin"; |
| 8 | 11 | export { |
| 9 | 12 | OnePasswordConfig, |
| 13 | + LegacyOnePasswordConfig, | |
| 14 | + StoredOnePasswordConfig, | |
| 15 | + normalizeStoredConfig, | |
| 10 | 16 | RedactedOnePasswordConfig, |
| 11 | 17 | RedactedOnePasswordAuth, |
| 12 | 18 | redactConfig, |
| @@ -2,9 +2,12 @@ import { describe, it, expect } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import { Effect } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk"; |
| 5 | +import { makeInMemoryBlobStore, pluginBlobStore } from "@executor-js/sdk/core"; | |
| 5 | 6 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 6 | 7 | |
| 7 | −import { onepasswordPlugin } from "./plugin"; | |
| 8 | +import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin"; | |
| 9 | +import type { OnePasswordService } from "./service"; | |
| 10 | +import { OnePasswordError } from "./errors"; | |
| 8 | 11 | import { OnePasswordConfig, DesktopAppAuth } from "./types"; |
| 9 | 12 | |
| 10 | 13 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` |
| @@ -16,6 +19,18 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 5 unmodified lines | ||
| 16 | 19 | |
| 17 | 20 | const ONEPASSWORD = ProviderKey.make("onepassword"); |
| 18 | 21 | |
| 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 | + | |
| 19 | 34 | describe("onepassword plugin", () => { |
| 20 | 35 | it.effect("registers onepassword as a credential provider", () => |
| 21 | 36 | Effect.gen(function* () { |
| @@ -36,20 +51,14 @@ describe("onepassword plugin", () => { · 14 unmodified lines | ||
| 36 | 51 | const initial = yield* executor.onepassword.getConfig(); |
| 37 | 52 | expect(initial).toBeNull(); |
| 38 | 53 | |
| 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); | |
| 49 | 55 | |
| 50 | 56 | 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"); | |
| 53 | 62 | expect(loaded?.auth.kind).toBe("desktop-app"); |
| 54 | 63 | |
| 55 | 64 | yield* executor.onepassword.removeConfig(); |
| @@ -67,7 +76,7 @@ describe("onepassword plugin", () => { · 11 unmodified lines | ||
| 67 | 76 | yield* executor.onepassword.configure( |
| 68 | 77 | OnePasswordConfig.make({ |
| 69 | 78 | auth: { kind: "service-account", token: "super-secret-token" }, |
| 70 | − vaultId: "vault-123", | |
| 79 | + vaults: [{ id: "vault-123", name: "CI" }], | |
| 71 | 80 | name: "CI", |
| 72 | 81 | }), |
| 73 | 82 | ); |
| @@ -89,8 +98,11 @@ describe("onepassword plugin", () => { · 15 unmodified lines | ||
| 89 | 98 | ToolAddress.make("executor.onepassword.configure"), |
| 90 | 99 | { |
| 91 | 100 | 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", | |
| 94 | 106 | }, |
| 95 | 107 | { onElicitation: "accept-all" }, |
| 96 | 108 | ); |
| @@ -100,7 +112,15 @@ describe("onepassword plugin", () => { · 3 unmodified lines | ||
| 100 | 112 | yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), |
| 101 | 113 | ).toMatchObject({ |
| 102 | 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 | 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 | + | |
| 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 | +}); | |
| @@ -20,8 +20,10 @@ import { · 19 unmodified lines | ||
| 20 | 20 | OnePasswordAuth, |
| 21 | 21 | OnePasswordConfig, |
| 22 | 22 | RedactedOnePasswordConfig, |
| 23 | + StoredOnePasswordConfig, | |
| 23 | 24 | Vault, |
| 24 | 25 | ConnectionStatus, |
| 26 | + normalizeStoredConfig, | |
| 25 | 27 | redactConfig, |
| 26 | 28 | } from "./types"; |
| 27 | 29 | import { OnePasswordError } from "./errors"; |
| @@ -42,11 +44,7 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 14 unmodified lines | ||
| 42 | 44 | I |
| 43 | 45 | >; |
| 44 | 46 | |
| 45 | −const OnePasswordConfigureInput = Schema.Struct({ | |
| 46 | − auth: OnePasswordAuth, | |
| 47 | − vaultId: Schema.String, | |
| 48 | − name: Schema.String, | |
| 49 | −}); | |
| 47 | +const OnePasswordConfigureInput = OnePasswordConfig; | |
| 50 | 48 | |
| 51 | 49 | const OnePasswordConfigureOutput = Schema.Struct({ |
| 52 | 50 | configured: Schema.Boolean, |
| @@ -98,10 +96,12 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 45 unmodified lines | ||
| 98 | 96 | // --------------------------------------------------------------------------- |
| 99 | 97 | // Typed config store — single blob, JSON encoded, owner-partitioned. The |
| 100 | 98 | // 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 | |
| 102 | 100 | // 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`. | |
| 105 | 105 | // --------------------------------------------------------------------------- |
| 106 | 106 | |
| 107 | 107 | export interface OnePasswordStore { |
| @@ -116,7 +116,7 @@ export interface OnePasswordStore { · 8 unmodified lines | ||
| 116 | 116 | readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>; |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | −const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(OnePasswordConfig)); | |
| 119 | +const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig)); | |
| 120 | 120 | |
| 121 | 121 | const blobStorageError = |
| 122 | 122 | (operation: string) => |
| @@ -133,6 +133,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 10 unmodified lines | ||
| 133 | 133 | Effect.flatMap((raw) => { |
| 134 | 134 | if (raw === null) return Effect.succeed(null); |
| 135 | 135 | return decodeConfig(raw).pipe( |
| 136 | + Effect.map(normalizeStoredConfig), | |
| 136 | 137 | Effect.mapError( |
| 137 | 138 | () => |
| 138 | 139 | new OnePasswordError({ |
| @@ -150,7 +151,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 11 unmodified lines | ||
| 150 | 151 | CONFIG_KEY, |
| 151 | 152 | JSON.stringify({ |
| 152 | 153 | auth: config.auth, |
| 153 | − vaultId: config.vaultId, | |
| 154 | + vaults: config.vaults, | |
| 154 | 155 | name: config.name, |
| 155 | 156 | }), |
| 156 | 157 | { owner }, |
| @@ -177,22 +178,105 @@ const getServiceFromConfig = ( · 20 unmodified lines | ||
| 177 | 178 | ): Effect.Effect<OnePasswordService, OnePasswordError> => |
| 178 | 179 | makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk }); |
| 179 | 180 | |
| 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 }))); | |
| 183 | 243 | } |
| 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 | + }); | |
| 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 | 275 | // 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). | |
| 196 | 280 | // --------------------------------------------------------------------------- |
| 197 | 281 | |
| 198 | 282 | const makeProvider = ( |
| @@ -205,19 +289,32 @@ const makeProvider = ( · 6 unmodified lines | ||
| 205 | 289 | |
| 206 | 290 | get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => |
| 207 | 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 | 295 | Effect.flatMap((config) => { |
| 209 | 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 | 298 | 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 | + ), | |
| 218 | 316 | ); |
| 219 | 317 | }), |
| 220 | − Effect.catch(() => Effect.succeed(null as string | null)), | |
| 221 | 318 | ), |
| 222 | 319 | |
| 223 | 320 | list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> => |
| @@ -225,10 +322,25 @@ const makeProvider = ( · 1 unmodified line | ||
| 225 | 322 | Effect.flatMap((config) => { |
| 226 | 323 | if (!config) return Effect.succeed([] as readonly ProviderEntry[]); |
| 227 | 324 | 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 | + ), | |
| 231 | 342 | ), |
| 343 | + Effect.map((groups): readonly ProviderEntry[] => groups.flat()), | |
| 232 | 344 | ); |
| 233 | 345 | }), |
| 234 | 346 | Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), |
| @@ -270,11 +382,19 @@ const makeOnePasswordExtension = ( · 35 unmodified lines | ||
| 270 | 382 | }); |
| 271 | 383 | } |
| 272 | 384 | 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)); | |
| 275 | 388 | return ConnectionStatus.make({ |
| 276 | 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 | 419 | message: "1Password is not configured", |
| 300 | 420 | }); |
| 301 | 421 | } |
| 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") { | |
| 304 | 426 | return yield* new OnePasswordError({ |
| 305 | 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); | |
| 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 | + }); | |
| 311 | 441 | }), |
| 312 | 442 | }; |
| 313 | 443 | }; |
| @@ -345,7 +475,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 31 unmodified lines | ||
| 345 | 475 | tool({ |
| 346 | 476 | name: "status", |
| 347 | 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 | 479 | outputSchema: OnePasswordStatusOutputStd, |
| 350 | 480 | execute: () => Effect.map(self.status(), ToolResult.ok), |
| 351 | 481 | }), |
| @@ -368,7 +498,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines | ||
| 368 | 498 | tool({ |
| 369 | 499 | name: "configure", |
| 370 | 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 | 502 | annotations: { |
| 373 | 503 | requiresApproval: true, |
| 374 | 504 | approvalDescription: "Configure the 1Password credential provider", |
| @@ -377,7 +507,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 2 unmodified lines | ||
| 377 | 507 | outputSchema: OnePasswordConfigureOutputStd, |
| 378 | 508 | execute: (input) => |
| 379 | 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 | 511 | ToolResult.ok({ configured: true }), |
| 382 | 512 | ), |
| 383 | 513 | }), |
| @@ -24,19 +24,53 @@ export type ServiceAccountAuth = typeof ServiceAccountAuth.Type; · 23 unmodified lines | ||
| 24 | 24 | export const OnePasswordAuth = Schema.Union([DesktopAppAuth, ServiceAccountAuth]); |
| 25 | 25 | export type OnePasswordAuth = typeof OnePasswordAuth.Type; |
| 26 | 26 | |
| 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 | + | |
| 27 | 37 | // --------------------------------------------------------------------------- |
| 28 | 38 | // Stored config — persisted via KV |
| 29 | 39 | // --------------------------------------------------------------------------- |
| 30 | 40 | |
| 31 | 41 | export const OnePasswordConfig = Schema.Struct({ |
| 32 | 42 | 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 */ | |
| 36 | 48 | name: Schema.String, |
| 37 | 49 | }); |
| 38 | 50 | export 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. */ | |
| 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 | + | |
| 40 | 74 | // --------------------------------------------------------------------------- |
| 41 | 75 | // Redacted config — what `getConfig` returns to agents / the UI. The |
| 42 | 76 | // 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 | |
| 57 | 91 | export const RedactedOnePasswordConfig = Schema.Struct({ |
| 58 | 92 | auth: RedactedOnePasswordAuth, |
| 59 | − vaultId: Schema.String, | |
| 93 | + vaults: Schema.NonEmptyArray(Vault), | |
| 60 | 94 | name: Schema.String, |
| 61 | 95 | }); |
| 62 | 96 | export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; |
| @@ -67,27 +101,17 @@ export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConf · 4 unmodified lines | ||
| 67 | 101 | config.auth.kind === "desktop-app" |
| 68 | 102 | ? { kind: "desktop-app", accountName: config.auth.accountName } |
| 69 | 103 | : { kind: "service-account" }, |
| 70 | − vaultId: config.vaultId, | |
| 104 | + vaults: config.vaults, | |
| 71 | 105 | name: config.name, |
| 72 | 106 | }); |
| 73 | 107 | |
| 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 | − | |
| 84 | 108 | // --------------------------------------------------------------------------- |
| 85 | 109 | // Connection status |
| 86 | 110 | // --------------------------------------------------------------------------- |
| 87 | 111 | |
| 88 | 112 | export const ConnectionStatus = Schema.Struct({ |
| 89 | 113 | connected: Schema.Boolean, |
| 90 | − vaultName: Schema.optional(Schema.String), | |
| 114 | + vaultNames: Schema.optional(Schema.Array(Schema.String)), | |
| 91 | 115 | error: Schema.optional(Schema.String), |
| 92 | 116 | }); |
| 93 | 117 | export type ConnectionStatus = typeof ConnectionStatus.Type; |
| @@ -132,7 +132,10 @@ export const providersAtom = ExecutorApiClient.query("providers", "list", { · 131 unmodified lines | ||
| 132 | 132 | export const providerItemsAtom = (key: ProviderKey) => |
| 133 | 133 | ExecutorApiClient.query("providers", "items", { |
| 134 | 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 | 139 | reactivityKeys: [ReactivityKey.providers], |
| 137 | 140 | }); |
| 138 | 141 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 5 | import { |
| @@ -106,7 +106,14 @@ import { · 100 unmodified lines | ||
| 106 | 106 | import { Input } from "./input"; |
| 107 | 107 | import { Label } from "./label"; |
| 108 | 108 | 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"; | |
| 110 | 117 | import { 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 | |
| 345 | +type OnePasswordItem = { | |
| 346 | + readonly id: ProviderItemId; | |
| 347 | + readonly name: string; | |
| 348 | + readonly group?: string; | |
| 349 | +}; | |
| 350 | + | |
| 338 | 351 | function OnePasswordItemSelect(props: { |
| 339 | 352 | readonly value: string; |
| 340 | 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 | 369 | 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>, | |
| 348 | 371 | { |
| 349 | 372 | onInitial: () => ({ |
| 350 | − items: [] as readonly { | |
| 351 | − readonly id: ProviderItemId; | |
| 352 | − readonly name: string; | |
| 353 | − }[], | |
| 373 | + items: [] as readonly OnePasswordItem[], | |
| 354 | 374 | loading: true, |
| 355 | 375 | error: null as string | null, |
| 356 | 376 | }), |
| 357 | 377 | onError: () => ({ |
| 358 | − items: [] as readonly { | |
| 359 | − readonly id: ProviderItemId; | |
| 360 | − readonly name: string; | |
| 361 | − }[], | |
| 378 | + items: [] as readonly OnePasswordItem[], | |
| 362 | 379 | loading: false, |
| 363 | 380 | error: "Failed to load 1Password items", |
| 364 | 381 | }), |
| 365 | 382 | onDefect: () => ({ |
| 366 | − items: [] as readonly { | |
| 367 | − readonly id: ProviderItemId; | |
| 368 | − readonly name: string; | |
| 369 | − }[], | |
| 383 | + items: [] as readonly OnePasswordItem[], | |
| 370 | 384 | loading: false, |
| 371 | 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 | 418 | if (state.loading) { |
| 378 | 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 | 428 | return ( |
| 388 | 429 | <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> | |
| 401 | 460 | </div> |
| 402 | 461 | ); |
| 403 | 462 | } |