From 2a016ae5c7e1af492a3c914068471cdf1799f766 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:49:10 +0530 Subject: [PATCH] feat(plugin-onepassword): support multiple named vault configurations per owner (#1791) --- packages/plugins/onepassword/src/api/group.ts | 9 + .../plugins/onepassword/src/api/handlers.ts | 21 +- .../src/react/OnePasswordSettings.tsx | 320 +++++++----- .../plugins/onepassword/src/react/atoms.ts | 23 +- packages/plugins/onepassword/src/sdk/index.ts | 5 +- .../onepassword/src/sdk/plugin.test.ts | 400 ++++++++------- .../plugins/onepassword/src/sdk/plugin.ts | 477 ++++++++++++------ packages/plugins/onepassword/src/sdk/types.ts | 119 +++-- 8 files changed, 865 insertions(+), 509 deletions(-) diff --git a/packages/plugins/onepassword/src/api/group.ts b/packages/plugins/onepassword/src/api/group.ts index e93c4ac79b..da0a86276f 100644 --- a/packages/plugins/onepassword/src/api/group.ts +++ b/packages/plugins/onepassword/src/api/group.ts @@ -51,8 +51,15 @@ const GetConfigResponse = Schema.NullOr(RedactedOnePasswordConfig); // --------------------------------------------------------------------------- export const OnePasswordGroup = HttpApiGroup.make("onepassword") + .add( + HttpApiEndpoint.get("listConfigs", "/onepassword/configs", { + success: Schema.Struct({ configs: Schema.Array(RedactedOnePasswordConfig) }), + error: [InternalError, OnePasswordError], + }), + ) .add( HttpApiEndpoint.get("getConfig", "/onepassword/config", { + query: Schema.Struct({ id: Schema.optional(Schema.String) }), success: GetConfigResponse, error: [InternalError, OnePasswordError], }), @@ -66,12 +73,14 @@ export const OnePasswordGroup = HttpApiGroup.make("onepassword") ) .add( HttpApiEndpoint.delete("removeConfig", "/onepassword/config", { + query: Schema.Struct({ id: Schema.optional(Schema.String) }), success: Schema.Void, error: [InternalError, OnePasswordError], }), ) .add( HttpApiEndpoint.get("status", "/onepassword/status", { + query: Schema.Struct({ id: Schema.optional(Schema.String) }), success: ConnectionStatus, error: [InternalError, OnePasswordError], }), diff --git a/packages/plugins/onepassword/src/api/handlers.ts b/packages/plugins/onepassword/src/api/handlers.ts index b3747c3c0b..c69bbbdab8 100644 --- a/packages/plugins/onepassword/src/api/handlers.ts +++ b/packages/plugins/onepassword/src/api/handlers.ts @@ -42,11 +42,20 @@ export const OnePasswordHandlers = HttpApiBuilder.group( "onepassword", (handlers) => handlers - .handle("getConfig", () => + .handle("listConfigs", () => capture( Effect.gen(function* () { const ext = yield* OnePasswordExtensionService; - return yield* ext.getConfig(); + const configs = yield* ext.listConfigs(); + return { configs: [...configs] }; + }), + ), + ) + .handle("getConfig", ({ query }) => + capture( + Effect.gen(function* () { + const ext = yield* OnePasswordExtensionService; + return yield* ext.getConfig(query.id); }), ), ) @@ -58,19 +67,19 @@ export const OnePasswordHandlers = HttpApiBuilder.group( }), ), ) - .handle("removeConfig", () => + .handle("removeConfig", ({ query }) => capture( Effect.gen(function* () { const ext = yield* OnePasswordExtensionService; - yield* ext.removeConfig(); + yield* ext.removeConfig(query.id); }), ), ) - .handle("status", () => + .handle("status", ({ query }) => capture( Effect.gen(function* () { const ext = yield* OnePasswordExtensionService; - return yield* ext.status(); + return yield* ext.status(query.id); }), ), ) diff --git a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx index 220b2ddbc3..4edca20d5a 100644 --- a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +++ b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx @@ -3,7 +3,6 @@ import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Button } from "@executor-js/react/components/button"; -import { Checkbox } from "@executor-js/react/components/checkbox"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { @@ -30,7 +29,7 @@ import { } from "@executor-js/react/components/card-stack"; import { - onepasswordConfigAtom, + onepasswordConfigsAtom, onepasswordVaultsAtom, configureOnePassword, removeOnePasswordConfig, @@ -39,7 +38,7 @@ import { import type { RedactedOnePasswordConfig, Vault } from "../sdk/types"; // --------------------------------------------------------------------------- -// Vault picker — multi-select +// Vault picker — single select // --------------------------------------------------------------------------- const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults"; @@ -53,19 +52,14 @@ const formatVaultListError = (error: Error): string => { function VaultPicker(props: { authKind: "desktop-app" | "service-account"; accountName: string; - selected: ReadonlyArray; - onSelectedChange: (vaults: ReadonlyArray) => void; + selectedVault: Vault | null; + onSelectedChange: (vault: Vault | null) => void; }) { const account = props.accountName.trim(); const vaultsAtom = onepasswordVaultsAtom(props.authKind, account); const vaultsResult = useAtomValue(vaultsAtom); const refreshVaults = useAtomRefresh(vaultsAtom); - // Stale-while-revalidate: with a retained value the vault list renders - // instantly and one background refresh per atom key picks up changes - // (refreshing keeps the previous value, so nothing flashes). A cold key is - // already fetching — refreshing it would only restart the request. The ref - // carries the latest cached-ness into the effect without re-running it. const isCachedRef = useRef(false); isCachedRef.current = AsyncResult.isSuccess(vaultsResult); useEffect(() => { @@ -96,8 +90,8 @@ function VaultPicker(props: { onSuccess: ({ value }) => { const v = value.vaults; const onlyVault = v.length === 1 ? v[0] : undefined; - if (onlyVault && props.selected.length === 0) { - queueMicrotask(() => props.onSelectedChange([onlyVault])); + if (onlyVault && !props.selectedVault) { + queueMicrotask(() => props.onSelectedChange(onlyVault)); } return { vaults: [...v], isLoading: false, error: null }; }, @@ -112,22 +106,11 @@ function VaultPicker(props: { ); } - // Selected vaults missing from the loaded list (renamed, revoked, or the - // list failed to load while editing) stay visible so they can be unchecked. const loadedIds = new Set(vaults.map((v) => v.id)); - const stale = props.selected.filter((v) => !loadedIds.has(v.id)); + const stale = + props.selectedVault && !loadedIds.has(props.selectedVault.id) ? [props.selectedVault] : []; const rows = [...vaults, ...stale]; - const toggle = (vault: Vault, checked: boolean) => { - if (checked) { - if (!props.selected.some((v) => v.id === vault.id)) { - props.onSelectedChange([...props.selected, vault]); - } - return; - } - props.onSelectedChange(props.selected.filter((v) => v.id !== vault.id)); - }; - return (
{isLoading ? ( @@ -137,23 +120,30 @@ function VaultPicker(props: { ) : (
{rows.map((vault) => { - const checked = props.selected.some((v) => v.id === vault.id); + const isSelected = props.selectedVault?.id === vault.id; return ( - +
); })}
@@ -177,9 +167,11 @@ function ConfigDialog(props: { open: boolean; onOpenChange: (v: boolean) => void; initial?: { + id: string; authKind: string; accountName: string; - vaults: ReadonlyArray; + vaultId: string; + vaultName?: string; name: string; }; }) { @@ -188,8 +180,10 @@ function ConfigDialog(props: { (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app", ); const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com"); - const [selectedVaults, setSelectedVaults] = useState>( - props.initial?.vaults ?? [], + const [selectedVault, setSelectedVault] = useState( + props.initial?.vaultId + ? { id: props.initial.vaultId, name: props.initial.vaultName ?? props.initial.vaultId } + : null, ); const [displayName, setDisplayName] = useState(props.initial?.name ?? ""); const [saving, setSaving] = useState(false); @@ -201,7 +195,7 @@ function ConfigDialog(props: { if (!isEdit) { setAuthKind("desktop-app"); setAccountName("my.1password.com"); - setSelectedVaults([]); + setSelectedVault(null); setDisplayName(""); } setError(null); @@ -209,8 +203,7 @@ function ConfigDialog(props: { }; const handleSave = async () => { - const [firstVault, ...restVaults] = selectedVaults; - if (!accountName.trim() || firstVault === undefined) return; + if (!accountName.trim() || !selectedVault) return; setSaving(true); setError(null); @@ -219,11 +212,22 @@ function ConfigDialog(props: { ? { kind: "desktop-app" as const, accountName: accountName.trim() } : { kind: "service-account" as const, token: accountName.trim() }; + const name = displayName.trim() || selectedVault.name || "1Password"; + const id = + props.initial?.id || + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || + `vault-${Date.now()}`; + const exit = await doConfigure({ payload: { + id, + name, auth, - vaults: [firstVault, ...restVaults], - name: displayName.trim() || "1Password", + vaultId: selectedVault.id, + vaultName: selectedVault.name, }, reactivityKeys: onepasswordWriteKeys, }); @@ -248,11 +252,10 @@ function ConfigDialog(props: { - {isEdit ? "Edit 1Password" : "Connect 1Password"} + {isEdit ? "Edit 1Password vault" : "Connect 1Password vault"} - Link one or more vaults to resolve secrets via the 1Password desktop app or a service - account. + Link a 1Password vault to resolve secrets via the desktop app or a service account. @@ -294,16 +297,16 @@ function ConfigDialog(props: {

- {/* Vaults */} + {/* Vault */}
@@ -336,7 +339,7 @@ function ConfigDialog(props: { @@ -351,24 +354,44 @@ function ConfigDialog(props: { // --------------------------------------------------------------------------- export default function OnePasswordSettings() { - const [configOpen, setConfigOpen] = useState(false); - const configResult = useAtomValue(onepasswordConfigAtom); + const [dialogState, setDialogState] = useState<{ + open: boolean; + initial?: { + id: string; + authKind: string; + accountName: string; + vaultId: string; + vaultName?: string; + name: string; + }; + }>({ open: false }); + + const configsResult = useAtomValue(onepasswordConfigsAtom); const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" }); - const handleRemove = async () => { - await doRemove({ reactivityKeys: onepasswordWriteKeys }); + const handleRemove = async (id: string) => { + await doRemove({ + query: { id }, + reactivityKeys: onepasswordWriteKeys, + }); }; - const config: RedactedOnePasswordConfig | null = AsyncResult.match( - configResult as AsyncResult.AsyncResult, + const configs: readonly RedactedOnePasswordConfig[] = AsyncResult.match( + configsResult as AsyncResult.AsyncResult< + { configs: readonly RedactedOnePasswordConfig[] }, + unknown + >, { - onInitial: () => null, - onFailure: () => null, - onSuccess: ({ value }) => value, + onInitial: () => [], + onFailure: () => [], + onSuccess: ({ value }) => value.configs, }, ); const isLoading = AsyncResult.match( - configResult as AsyncResult.AsyncResult, + configsResult as AsyncResult.AsyncResult< + { configs: readonly RedactedOnePasswordConfig[] }, + unknown + >, { onInitial: () => true, onFailure: () => false, @@ -376,7 +399,10 @@ export default function OnePasswordSettings() { }, ); const isError = AsyncResult.match( - configResult as AsyncResult.AsyncResult, + configsResult as AsyncResult.AsyncResult< + { configs: readonly RedactedOnePasswordConfig[] }, + unknown + >, { onInitial: () => false, onFailure: () => true, @@ -386,87 +412,109 @@ export default function OnePasswordSettings() { return ( <> - - - {isLoading ? ( + {isLoading ? ( + + Loading… - ) : isError ? ( + + + ) : isError ? ( + + Failed to load configuration - ) : config ? ( -
- Auth - - {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} - - - {config.vaults.length === 1 ? "Vault" : "Vaults"} - -
- - {config.vaults.map((vault) => vault.name).join(", ")} - -
-
- ) : ( +
+
+ ) : configs.length === 0 ? ( + + Resolve secrets from your 1Password vaults. - )} - - - {config ? ( - <> - - - - ) : ( - !isLoading && - !isError && ( - - ) - )} - - +
+ + + +
+ ) : ( + <> + {configs.map((config) => ( + + +
+ Name + {config.name} + Auth + + {config.auth.kind === "desktop-app" + ? config.auth.accountName + : "service-account"} + + Vault + + {config.vaultName ?? config.vaultId} + +
+
+ + + + +
+ ))} +
+ +
+ + )} - {configOpen && ( + {dialogState.open && ( setDialogState((prev) => ({ ...prev, open }))} + initial={dialogState.initial} /> )} diff --git a/packages/plugins/onepassword/src/react/atoms.ts b/packages/plugins/onepassword/src/react/atoms.ts index d6cec111bb..90d43eb5b2 100644 --- a/packages/plugins/onepassword/src/react/atoms.ts +++ b/packages/plugins/onepassword/src/react/atoms.ts @@ -7,21 +7,26 @@ export const onepasswordWriteKeys = [ReactivityKey.providers] as const; // --------------------------------------------------------------------------- // Query atoms -// -// v2: the 1Password config is a single owner-partitioned binding the server -// derives from the executor's owner — there are no owner path params here; the -// server reads the acting owner from the executor binding. // --------------------------------------------------------------------------- -export const onepasswordConfigAtom = OnePasswordClient.query("onepassword", "getConfig", { +export const onepasswordConfigsAtom = OnePasswordClient.query("onepassword", "listConfigs", { timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.providers], }); -export const onepasswordStatusAtom = OnePasswordClient.query("onepassword", "status", { - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.providers], -}); +export const onepasswordConfigAtom = (id?: string) => + OnePasswordClient.query("onepassword", "getConfig", { + query: { id }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.providers], + }); + +export const onepasswordStatusAtom = (id?: string) => + OnePasswordClient.query("onepassword", "status", { + query: { id }, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.providers], + }); // --------------------------------------------------------------------------- // Query atoms — vaults diff --git a/packages/plugins/onepassword/src/sdk/index.ts b/packages/plugins/onepassword/src/sdk/index.ts index 751108ebda..880517e8eb 100644 --- a/packages/plugins/onepassword/src/sdk/index.ts +++ b/packages/plugins/onepassword/src/sdk/index.ts @@ -10,13 +10,16 @@ export { } from "./plugin"; export { OnePasswordConfig, - LegacyOnePasswordConfig, + LegacySingleVaultConfig, + LegacyMultiVaultConfig, StoredOnePasswordConfig, + normalizeStoredConfigs, normalizeStoredConfig, RedactedOnePasswordConfig, RedactedOnePasswordAuth, redactConfig, Vault, + VaultStatus, ConnectionStatus, OnePasswordAuth, DesktopAppAuth, diff --git a/packages/plugins/onepassword/src/sdk/plugin.test.ts b/packages/plugins/onepassword/src/sdk/plugin.test.ts index 4d9f1ed916..5c62a4dfab 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.test.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.test.ts @@ -8,30 +8,50 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin"; import type { OnePasswordService } from "./service"; import { OnePasswordError } from "./errors"; -import { OnePasswordConfig, DesktopAppAuth } from "./types"; - -// removed: v1 routed configure/removeConfig through an explicit `ScopeId` -// (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and -// asserted provider registration via `executor.secrets.providers()`. v2 deletes -// the scope stack and the secrets table: config is a single owner-partitioned -// blob the extension derives from the executor's owner binding, and credential -// providers are discovered through `executor.providers.list()`. +import { OnePasswordConfig, DesktopAppAuth, ServiceAccountAuth } from "./types"; const ONEPASSWORD = ProviderKey.make("onepassword"); -const twoVaultConfig = OnePasswordConfig.make({ +const personalConfig = OnePasswordConfig.make({ + id: "personal", + name: "Personal", auth: DesktopAppAuth.make({ kind: "desktop-app", accountName: "my.1password.com", }), - vaults: [ - { id: "vault-123", name: "Personal" }, - { id: "vault-456", name: "Work" }, - ], - name: "1Password", + vaultId: "vault-personal", + vaultName: "Personal Vault", +}); + +const workConfig = OnePasswordConfig.make({ + id: "work", + name: "Work", + auth: ServiceAccountAuth.make({ + kind: "service-account", + token: "ops_work_token", + }), + vaultId: "vault-work", + vaultName: "Work Vault", +}); + +const fakeService = ( + itemsByVault: Readonly>, + onResolve?: (uri: string) => void, +): OnePasswordService => ({ + resolveSecret: (uri) => { + onResolve?.(uri); + return Effect.succeed(`secret:${uri}`); + }, + listVaults: () => Effect.succeed(Object.keys(itemsByVault).map((id) => ({ id, title: id }))), + listItems: (vaultId) => { + const items = itemsByVault[vaultId]; + return items === undefined + ? Effect.fail(new OnePasswordError({ operation: "item listing", message: "no such vault" })) + : Effect.succeed(items); + }, }); -describe("onepassword plugin", () => { +describe("onepassword plugin — multiple named configurations", () => { it.effect("registers onepassword as a credential provider", () => Effect.gen(function* () { const executor = yield* createExecutor( @@ -42,117 +62,136 @@ describe("onepassword plugin", () => { }), ); - it.effect("configure / getConfig / removeConfig round-trip via blob store", () => + it.effect("supports adding, listing, and removing multiple named configurations", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [onepasswordPlugin()] as const }), ); - const initial = yield* executor.onepassword.getConfig(); - expect(initial).toBeNull(); + const initial = yield* executor.onepassword.listConfigs(); + expect(initial).toEqual([]); - yield* executor.onepassword.configure(twoVaultConfig); + // Add personal configuration + yield* executor.onepassword.configure(personalConfig); - const loaded = yield* executor.onepassword.getConfig(); - expect(loaded?.vaults).toEqual([ - { id: "vault-123", name: "Personal" }, - { id: "vault-456", name: "Work" }, - ]); - expect(loaded?.name).toBe("1Password"); - expect(loaded?.auth.kind).toBe("desktop-app"); + const afterFirst = yield* executor.onepassword.listConfigs(); + expect(afterFirst).toHaveLength(1); + expect(afterFirst[0]?.id).toBe("personal"); + expect(afterFirst[0]?.name).toBe("Personal"); + expect(afterFirst[0]?.vaultId).toBe("vault-personal"); + + // Add work configuration (should not overwrite personal) + yield* executor.onepassword.configure(workConfig); + + const afterSecond = yield* executor.onepassword.listConfigs(); + expect(afterSecond).toHaveLength(2); + expect(afterSecond.map((c) => c.id)).toEqual(["personal", "work"]); - yield* executor.onepassword.removeConfig(); - const afterRemove = yield* executor.onepassword.getConfig(); - expect(afterRemove).toBeNull(); + // Get individual config by id + const singlePersonal = yield* executor.onepassword.getConfig("personal"); + expect(singlePersonal?.id).toBe("personal"); + + const singleWork = yield* executor.onepassword.getConfig("work"); + expect(singleWork?.id).toBe("work"); + + // Update personal config + yield* executor.onepassword.configure({ + ...personalConfig, + name: "Personal Updated", + }); + + const afterUpdate = yield* executor.onepassword.listConfigs(); + expect(afterUpdate).toHaveLength(2); + expect(afterUpdate.find((c) => c.id === "personal")?.name).toBe("Personal Updated"); + expect(afterUpdate.find((c) => c.id === "work")?.name).toBe("Work"); + + // Remove personal config without affecting work + yield* executor.onepassword.removeConfig("personal"); + + const afterRemoveOne = yield* executor.onepassword.listConfigs(); + expect(afterRemoveOne).toHaveLength(1); + expect(afterRemoveOne[0]?.id).toBe("work"); + + // Remove work config + yield* executor.onepassword.removeConfig("work"); + const afterRemoveAll = yield* executor.onepassword.listConfigs(); + expect(afterRemoveAll).toEqual([]); }), ); - it.effect("getConfig redacts the service-account token", () => + it.effect("redacts service-account tokens across all config listing and retrieval", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [onepasswordPlugin()] as const }), ); - yield* executor.onepassword.configure( - OnePasswordConfig.make({ - auth: { kind: "service-account", token: "super-secret-token" }, - vaults: [{ id: "vault-123", name: "CI" }], - name: "CI", - }), - ); + yield* executor.onepassword.configure(workConfig); + + const list = yield* executor.onepassword.listConfigs(); + expect(list[0]?.auth.kind).toBe("service-account"); + expect(JSON.stringify(list)).not.toContain("ops_work_token"); - const loaded = yield* executor.onepassword.getConfig(); - expect(loaded?.auth.kind).toBe("service-account"); - // The token must never be surfaced through the redacted projection. - expect(JSON.stringify(loaded)).not.toContain("super-secret-token"); + const single = yield* executor.onepassword.getConfig("work"); + expect(single?.auth.kind).toBe("service-account"); + expect(JSON.stringify(single)).not.toContain("ops_work_token"); }), ); - it.effect("exposes provider configuration as agent-callable static tools", () => + it.effect("exposes multi-config tools on the static integration", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [onepasswordPlugin()] as const }), ); - const configured = yield* executor.execute( + const configuredPersonal = yield* executor.execute( ToolAddress.make("executor.onepassword.configure"), - { - auth: { kind: "desktop-app", accountName: "my.1password.com" }, - vaults: [ - { id: "vault-123", name: "Personal" }, - { id: "vault-456", name: "Work" }, - ], - name: "1Password", - }, + personalConfig, { onElicitation: "accept-all" }, ); + expect(configuredPersonal).toEqual({ + ok: true, + data: { configured: true, id: "personal" }, + }); - expect(configured).toEqual({ ok: true, data: { configured: true } }); - expect( - yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), - ).toMatchObject({ + const configuredWork = yield* executor.execute( + ToolAddress.make("executor.onepassword.configure"), + workConfig, + { onElicitation: "accept-all" }, + ); + expect(configuredWork).toEqual({ + ok: true, + data: { configured: true, id: "work" }, + }); + + const listResult = yield* executor.execute( + ToolAddress.make("executor.onepassword.listConfigs"), + {}, + ); + expect(listResult).toMatchObject({ ok: true, data: { - config: { - vaults: [ - { id: "vault-123", name: "Personal" }, - { id: "vault-456", name: "Work" }, - ], - name: "1Password", - }, + configs: [ + { id: "personal", name: "Personal", vaultId: "vault-personal" }, + { id: "work", name: "Work", vaultId: "vault-work" }, + ], }, }); const removed = yield* executor.execute( ToolAddress.make("executor.onepassword.removeConfig"), - {}, + { id: "personal" }, { onElicitation: "accept-all" }, ); - expect(removed).toEqual({ ok: true, data: { removed: true } }); - expect(yield* executor.onepassword.getConfig()).toBeNull(); - }), - ); - it.effect("status reports not-configured before configure", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), - ); - const status = yield* executor.onepassword.status(); - expect(status.connected).toBe(false); - expect(status.error).toBe("Not configured"); + const afterRemove = yield* executor.onepassword.listConfigs(); + expect(afterRemove).toHaveLength(1); + expect(afterRemove[0]?.id).toBe("work"); }), ); }); -// --------------------------------------------------------------------------- -// Stored-config compatibility — blobs written before multi-vault support hold -// `{ auth, vaultId, name }`. Reads normalize that to a one-element vaults -// array; the next save writes the current shape. -// --------------------------------------------------------------------------- - -describe("onepassword store", () => { +describe("onepassword store — backward compatibility", () => { const makeStore = () => { const blobs = pluginBlobStore( makeInMemoryBlobStore(), @@ -162,7 +201,7 @@ describe("onepassword store", () => { return { blobs, store: makeOnePasswordStore(blobs) }; }; - it.effect("upgrades a legacy single-vault blob on read", () => + it.effect("normalizes legacy single-vault blob on read", () => Effect.gen(function* () { const { blobs, store } = makeStore(); yield* blobs.put( @@ -175,150 +214,165 @@ describe("onepassword store", () => { { owner: "org" }, ); - const config = yield* store.getConfig(); - expect(config).toEqual({ + const configs = yield* store.getConfigs(); + expect(configs).toEqual([ + { + id: "default", + name: "Personal", + auth: { kind: "desktop-app", accountName: "my.1password.com" }, + vaultId: "vault-123", + vaultName: "Personal", + }, + ]); + }), + ); + + it.effect("normalizes legacy multi-vault array blob on read", () => + Effect.gen(function* () { + const { blobs, store } = makeStore(); + yield* blobs.put( + "config", + JSON.stringify({ + auth: { kind: "desktop-app", accountName: "my.1password.com" }, + vaults: [ + { id: "v-1", name: "Primary" }, + { id: "v-2", name: "Secondary" }, + ], + name: "1Password", + }), + { owner: "org" }, + ); + + const configs = yield* store.getConfigs(); + expect(configs).toHaveLength(2); + expect(configs[0]).toEqual({ + id: "default", + name: "1Password", auth: { kind: "desktop-app", accountName: "my.1password.com" }, - vaults: [{ id: "vault-123", name: "Personal" }], - name: "Personal", + vaultId: "v-1", + vaultName: "Primary", + }); + expect(configs[1]).toEqual({ + id: "v-2", + name: "Secondary", + auth: { kind: "desktop-app", accountName: "my.1password.com" }, + vaultId: "v-2", + vaultName: "Secondary", }); }), ); - it.effect("persists and reads back the multi-vault shape", () => + it.effect("persists and reads back multiple named configurations", () => Effect.gen(function* () { const { store } = makeStore(); - yield* store.saveConfig(twoVaultConfig, "org"); - const config = yield* store.getConfig(); - expect(config).toEqual(twoVaultConfig); + yield* store.saveConfig(personalConfig, "org"); + yield* store.saveConfig(workConfig, "org"); + + const configs = yield* store.getConfigs(); + expect(configs).toEqual([personalConfig, workConfig]); }), ); }); -// --------------------------------------------------------------------------- -// Explicit ref resolution — vault-qualified refs resolve directly; bare refs -// must locate exactly one item, and a multi-vault match is an explicit -// ambiguity, never a precedence pick. -// --------------------------------------------------------------------------- +describe("resolveConfiguredRef — multi-configuration resolution", () => { + const configs = [personalConfig, workConfig]; + const itemsByVault = { + "vault-personal": [{ id: "item-p1", title: "GitHub Token" }], + "vault-work": [{ id: "item-w1", title: "Stripe Key" }], + }; -const fakeService = ( - itemsByVault: Readonly>, - onResolve?: (uri: string) => void, -): OnePasswordService => ({ - resolveSecret: (uri) => { - onResolve?.(uri); - return Effect.succeed(`secret:${uri}`); - }, - listVaults: () => Effect.succeed(Object.keys(itemsByVault).map((id) => ({ id, title: id }))), - listItems: (vaultId) => { - const items = itemsByVault[vaultId]; - return items === undefined - ? Effect.fail(new OnePasswordError({ operation: "item listing", message: "no such vault" })) - : Effect.succeed(items); - }, -}); + const getSvc = (_config: OnePasswordConfig) => Effect.succeed(fakeService(itemsByVault)); -describe("resolveConfiguredRef", () => { - it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () => + it.effect("resolves op:/// directly", () => Effect.gen(function* () { - const svc = fakeService({}); - const result = yield* resolveConfiguredRef( - svc, - twoVaultConfig, - "op://vault-456/item-abc/password", - ); + const result = yield* resolveConfiguredRef(getSvc, configs, "op://personal/item-p1"); expect(result).toEqual({ kind: "resolved", - value: "secret:op://vault-456/item-abc/password", + value: "secret:op://vault-personal/item-p1/credential", }); }), ); - it.effect("appends the credential field to a picker-shaped op://vault/item ref", () => + it.effect("resolves op://// directly", () => Effect.gen(function* () { - const svc = fakeService({}); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc"); + const result = yield* resolveConfiguredRef( + getSvc, + configs, + "op://work/vault-work/item-w1/api-key", + ); expect(result).toEqual({ kind: "resolved", - value: "secret:op://vault-123/item-abc/credential", + value: "secret:op://vault-work/item-w1/api-key", }); }), ); - it.effect("accepts an op:// URI addressed by vault name", () => + it.effect("resolves legacy op:/// by matching vaultId across configs", () => Effect.gen(function* () { - const svc = fakeService({}); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://Work/item/password"); - expect(result).toEqual({ kind: "resolved", value: "secret:op://Work/item/password" }); + const result = yield* resolveConfiguredRef( + getSvc, + configs, + "op://vault-personal/item-p1/password", + ); + expect(result).toEqual({ + kind: "resolved", + value: "secret:op://vault-personal/item-p1/password", + }); }), ); - it.effect("reports an op:// URI outside the configured vaults", () => + it.effect("rejects an op:// URI referencing an unconfigured vault (strict isolation)", () => Effect.gen(function* () { - const svc = fakeService({}); const result = yield* resolveConfiguredRef( - svc, - twoVaultConfig, - "op://vault-999/item/password", + getSvc, + configs, + "op://vault-unknown/item-123/password", ); expect(result).toEqual({ kind: "outside-vaults" }); }), ); - it.effect("resolves a bare ref that matches exactly one item across vaults", () => + it.effect("resolves a bare ref when it matches in exactly one vault", () => Effect.gen(function* () { - const svc = fakeService({ - "vault-123": [{ id: "item-1", title: "GitHub Token" }], - "vault-456": [{ id: "item-2", title: "Stripe Key" }], - }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); + const result = yield* resolveConfiguredRef(getSvc, configs, "Stripe Key"); expect(result).toEqual({ kind: "resolved", - value: "secret:op://vault-123/item-1/credential", + value: "secret:op://vault-work/item-w1/credential", }); }), ); - it.effect("fails a bare ref that matches in two vaults with the vaults named", () => + it.effect("detects ambiguity when a bare ref matches in multiple configured vaults", () => Effect.gen(function* () { - const svc = fakeService({ - "vault-123": [{ id: "item-1", title: "GitHub Token" }], - "vault-456": [{ id: "item-2", title: "GitHub Token" }], - }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); + const ambiguousItems = { + "vault-personal": [{ id: "item-1", title: "Shared Key" }], + "vault-work": [{ id: "item-2", title: "Shared Key" }], + }; + const ambiguousSvc = (_config: OnePasswordConfig) => + Effect.succeed(fakeService(ambiguousItems)); + + const result = yield* resolveConfiguredRef(ambiguousSvc, configs, "Shared Key"); expect(result).toEqual({ kind: "ambiguous", matches: [ { - vaultId: "vault-123", - vaultName: "Personal", + configId: "personal", + configName: "Personal", + vaultId: "vault-personal", + vaultName: "Personal Vault", itemId: "item-1", - itemTitle: "GitHub Token", + itemTitle: "Shared Key", + }, + { + configId: "work", + configName: "Work", + vaultId: "vault-work", + vaultName: "Work Vault", + itemId: "item-2", + itemTitle: "Shared Key", }, - { vaultId: "vault-456", vaultName: "Work", itemId: "item-2", itemTitle: "GitHub Token" }, - ], - }); - }), - ); - - it.effect("treats duplicate titles inside one vault as ambiguous too", () => - Effect.gen(function* () { - const svc = fakeService({ - "vault-123": [ - { id: "item-1", title: "GitHub Token" }, - { id: "item-9", title: "GitHub Token" }, ], - "vault-456": [], }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); - expect(result.kind).toBe("ambiguous"); - }), - ); - - it.effect("reports not-found for a bare ref matching nothing", () => - Effect.gen(function* () { - const svc = fakeService({ "vault-123": [], "vault-456": [] }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "missing"); - expect(result).toEqual({ kind: "not-found" }); }), ); }); diff --git a/packages/plugins/onepassword/src/sdk/plugin.ts b/packages/plugins/onepassword/src/sdk/plugin.ts index fa7ad5f52a..7efb4f256c 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Effect, Schema, Exit } from "effect"; import { definePlugin, @@ -22,8 +22,9 @@ import { RedactedOnePasswordConfig, StoredOnePasswordConfig, Vault, + VaultStatus, ConnectionStatus, - normalizeStoredConfig, + normalizeStoredConfigs, redactConfig, } from "./types"; import { OnePasswordError } from "./errors"; @@ -48,6 +49,15 @@ const OnePasswordConfigureInput = OnePasswordConfig; const OnePasswordConfigureOutput = Schema.Struct({ configured: Schema.Boolean, + id: Schema.String, +}); + +const OnePasswordListConfigsOutput = Schema.Struct({ + configs: Schema.Array(RedactedOnePasswordConfig), +}); + +const OnePasswordGetConfigInput = Schema.Struct({ + id: Schema.optional(Schema.String), }); const OnePasswordGetConfigOutput = Schema.Struct({ @@ -60,10 +70,18 @@ const OnePasswordListVaultsOutput = Schema.Struct({ vaults: Schema.Array(Vault), }); +const OnePasswordRemoveConfigInput = Schema.Struct({ + id: Schema.optional(Schema.String), +}); + const OnePasswordRemoveConfigOutput = Schema.Struct({ removed: Schema.Boolean, }); +const OnePasswordStatusInput = Schema.Struct({ + id: Schema.optional(Schema.String), +}); + const OnePasswordStatusOutput = ConnectionStatus; const OnePasswordConfigureInputStd = schemaToStaticToolSchema< @@ -71,49 +89,57 @@ const OnePasswordConfigureInputStd = schemaToStaticToolSchema< typeof OnePasswordConfigureInput.Encoded >(OnePasswordConfigureInput); const OnePasswordConfigureOutputStd = schemaToStaticToolSchema(OnePasswordConfigureOutput); +const OnePasswordListConfigsOutputStd = schemaToStaticToolSchema(OnePasswordListConfigsOutput); +const OnePasswordGetConfigInputStd = schemaToStaticToolSchema< + typeof OnePasswordGetConfigInput.Type, + typeof OnePasswordGetConfigInput.Encoded +>(OnePasswordGetConfigInput); const OnePasswordGetConfigOutputStd = schemaToStaticToolSchema(OnePasswordGetConfigOutput); const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< typeof OnePasswordListVaultsInput.Type, typeof OnePasswordListVaultsInput.Encoded >(OnePasswordListVaultsInput); const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput); +const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema< + typeof OnePasswordRemoveConfigInput.Type, + typeof OnePasswordRemoveConfigInput.Encoded +>(OnePasswordRemoveConfigInput); const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput); +const OnePasswordStatusInputStd = schemaToStaticToolSchema< + typeof OnePasswordStatusInput.Type, + typeof OnePasswordStatusInput.Encoded +>(OnePasswordStatusInput); const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); // --------------------------------------------------------------------------- // Shared failure alias. -// -// Every extension method either touches storage (`ctx.storage` blobs) or -// reaches the 1Password backend. Storage I/O surfaces as `StorageFailure`; -// the HTTP edge (`withCapture`) translates `StorageError` to -// `InternalError({ traceId })`. Domain problems (not configured, backend RPC -// failure) stay as `OnePasswordError` and encode to 502 via the schema -// annotation on the class. // --------------------------------------------------------------------------- export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; // --------------------------------------------------------------------------- -// Typed config store — single blob, JSON encoded, owner-partitioned. The -// stored config carries the auth credential (desktop account name, or -// service-account token) plus the selected vaults. v1 keyed this by executor -// scope; v2 partitions by `owner` — the plugin-owned config row owns the -// partition, mirroring the connection model. Reads also accept the legacy -// single-`vaultId` shape and normalize it to the vaults array; saves always -// write the current shape. Blob I/O failures surface as `StorageError`; -// decode failures stay `OnePasswordError`. +// Typed config store — JSON encoded, owner-partitioned list of named configs. +// Reads accept legacy shapes and normalize them to a list of named configs. +// Saves persist the full `{ configs: [...] }` list. // --------------------------------------------------------------------------- export interface OnePasswordStore { - readonly getConfig: () => Effect.Effect< - OnePasswordConfig | null, + readonly getConfigs: () => Effect.Effect< + readonly OnePasswordConfig[], StorageError | OnePasswordError >; + readonly getConfig: ( + id?: string, + ) => Effect.Effect; readonly saveConfig: ( config: OnePasswordConfig, owner: Owner, ) => Effect.Effect; - readonly deleteConfig: (owner: Owner) => Effect.Effect; + readonly deleteConfig: ( + id: string | undefined, + owner: Owner, + ) => Effect.Effect; + readonly deleteAllConfigs: (owner: Owner) => Effect.Effect; } const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig)); @@ -126,14 +152,17 @@ const blobStorageError = cause, }); -export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore => ({ - getConfig: () => +export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore => { + const getConfigs = (): Effect.Effect< + readonly OnePasswordConfig[], + StorageError | OnePasswordError + > => blobs.get(CONFIG_KEY).pipe( Effect.mapError(blobStorageError("read")), Effect.flatMap((raw) => { - if (raw === null) return Effect.succeed(null); + if (raw === null) return Effect.succeed([] as readonly OnePasswordConfig[]); return decodeConfig(raw).pipe( - Effect.map(normalizeStoredConfig), + Effect.map(normalizeStoredConfigs), Effect.mapError( () => new OnePasswordError({ @@ -143,24 +172,65 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = ), ); }), - ), + ); - saveConfig: (config, owner) => - blobs - .put( - CONFIG_KEY, - JSON.stringify({ - auth: config.auth, - vaults: config.vaults, - name: config.name, - }), - { owner }, - ) - .pipe(Effect.mapError(blobStorageError("write"))), - - deleteConfig: (owner) => - blobs.delete(CONFIG_KEY, { owner }).pipe(Effect.mapError(blobStorageError("delete"))), -}); + const getConfig = ( + id?: string, + ): Effect.Effect => + getConfigs().pipe( + Effect.map((configs) => { + if (configs.length === 0) return null; + if (!id) return configs[0] ?? null; + return configs.find((c) => c.id === id) ?? null; + }), + ); + + const saveConfig = (config: OnePasswordConfig, owner: Owner): Effect.Effect => + getConfigs().pipe( + Effect.catchTag("OnePasswordError", () => Effect.succeed([] as readonly OnePasswordConfig[])), + Effect.flatMap((existing) => { + const id = config.id.trim() || "default"; + const normalizedConfig: OnePasswordConfig = { ...config, id }; + const index = existing.findIndex((c) => c.id === id); + const updated = + index >= 0 + ? [...existing.slice(0, index), normalizedConfig, ...existing.slice(index + 1)] + : [...existing, normalizedConfig]; + return blobs + .put(CONFIG_KEY, JSON.stringify({ configs: updated }), { owner }) + .pipe(Effect.mapError(blobStorageError("write"))); + }), + ); + + const deleteConfig = (id: string | undefined, owner: Owner): Effect.Effect => + getConfigs().pipe( + Effect.catchTag("OnePasswordError", () => Effect.succeed([] as readonly OnePasswordConfig[])), + Effect.flatMap((existing) => { + if (existing.length === 0) return Effect.void; + const targetId = id?.trim(); + const updated = targetId ? existing.filter((c) => c.id !== targetId) : []; + if (updated.length === 0) { + return blobs + .delete(CONFIG_KEY, { owner }) + .pipe(Effect.mapError(blobStorageError("delete"))); + } + return blobs + .put(CONFIG_KEY, JSON.stringify({ configs: updated }), { owner }) + .pipe(Effect.mapError(blobStorageError("write"))); + }), + ); + + const deleteAllConfigs = (owner: Owner): Effect.Effect => + blobs.delete(CONFIG_KEY, { owner }).pipe(Effect.mapError(blobStorageError("delete"))); + + return { + getConfigs, + getConfig, + saveConfig, + deleteConfig, + deleteAllConfigs, + }; +}; // --------------------------------------------------------------------------- // Helpers — auth resolution + service construction @@ -179,18 +249,7 @@ const getServiceFromConfig = ( makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk }); // --------------------------------------------------------------------------- -// Explicit ref resolution. -// -// A ref is one of: -// - `op://vault/item/field...` — fully qualified, resolved as-is. -// - `op://vault/item` — picker-shaped; the default credential field -// is appended. This is the id shape `list()` -// hands out, so every picked item permanently -// records which vault it came from. -// - a bare item id or title — located by listing the configured vaults. -// Exactly one match resolves; several matches -// are an explicit ambiguity failure naming the -// vaults — never a silent precedence pick. +// Explicit ref resolution across named configurations. // --------------------------------------------------------------------------- export type RefResolution = @@ -200,6 +259,8 @@ export type RefResolution = | { readonly kind: "ambiguous"; readonly matches: readonly { + readonly configId: string; + readonly configName: string; readonly vaultId: string; readonly vaultName: string; readonly itemId: string; @@ -213,55 +274,115 @@ export const ambiguityMessage = ( ): string => [ `1Password ref "${ref}" is ambiguous: it matches`, - matches.map((m) => `"${m.itemTitle}" in vault "${m.vaultName}"`).join(", "), - `. Use op:/// to pick one.`, + matches + .map((m) => `"${m.itemTitle}" in config "${m.configName}" (vault "${m.vaultName}")`) + .join(", "), + `. Use op:/// to pick one.`, ].join(" "); -const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean => - config.vaults.some((vault) => vault.id === segment || vault.name === segment); - -/** Resolve a ref against the configured vaults. Backend failures stay on the - * error channel; every addressing outcome is an explicit `RefResolution`. */ export const resolveConfiguredRef = ( - svc: OnePasswordService, - config: OnePasswordConfig, + getService: (config: OnePasswordConfig) => Effect.Effect, + configs: readonly OnePasswordConfig[], ref: string, ): Effect.Effect => { if (ref.startsWith("op://")) { - const segments = ref.slice("op://".length).split("/"); - const vaultSegment = segments[0]; - if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { + const raw = ref.slice("op://".length); + const segments = raw.split("/").filter((s) => s.length > 0); + if (segments.length < 2) { return Effect.succeed({ kind: "not-found" }); } - if (!isConfiguredVaultSegment(config, vaultSegment)) { - return Effect.succeed({ kind: "outside-vaults" }); + + // 1. Check if first segment matches a config ID + const configById = configs.find((c) => c.id === segments[0]); + if (configById) { + return Effect.gen(function* () { + const svc = yield* getService(configById); + if (segments.length === 2) { + const itemId = segments[1]!; + const uri = `op://${configById.vaultId}/${itemId}/${CREDENTIAL_FIELD}`; + const value = yield* svc.resolveSecret(uri); + return { kind: "resolved", value } as const; + } + + const secondIsVault = + segments[1] === configById.vaultId || + (configById.vaultName !== undefined && segments[1] === configById.vaultName); + + if (secondIsVault) { + const itemId = segments[2]!; + const field = segments.slice(3).join("/") || CREDENTIAL_FIELD; + const uri = `op://${configById.vaultId}/${itemId}/${field}`; + const value = yield* svc.resolveSecret(uri); + return { kind: "resolved", value } as const; + } + + const itemId = segments[1]!; + const field = segments.slice(2).join("/") || CREDENTIAL_FIELD; + const uri = `op://${configById.vaultId}/${itemId}/${field}`; + const value = yield* svc.resolveSecret(uri); + return { kind: "resolved", value } as const; + }); + } + + // 2. Check if first segment matches a vaultId or vaultName across configs + const configsWithVault = configs.filter( + (c) => + c.vaultId === segments[0] || (c.vaultName !== undefined && c.vaultName === segments[0]), + ); + if (configsWithVault.length > 0) { + return Effect.gen(function* () { + const config = configsWithVault[0]!; + const svc = yield* getService(config); + const itemId = segments[1]!; + const field = segments.slice(2).join("/") || CREDENTIAL_FIELD; + const uri = `op://${config.vaultId}/${itemId}/${field}`; + const value = yield* svc.resolveSecret(uri); + return { kind: "resolved", value } as const; + }); } - const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; - return svc - .resolveSecret(uri) - .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value }))); + + return Effect.succeed({ kind: "outside-vaults" }); } + // Bare ref lookup (title or id) return Effect.gen(function* () { - const matches = (yield* Effect.forEach(config.vaults, (vault) => - svc.listItems(vault.id).pipe( + const matches = (yield* Effect.forEach(configs, (config) => + getService(config).pipe( + Effect.flatMap((svc) => svc.listItems(config.vaultId)), Effect.map((items) => items .filter((item) => item.id === ref || item.title === ref) .map((item) => ({ - vaultId: vault.id, - vaultName: vault.name, + configId: config.id, + configName: config.name, + vaultId: config.vaultId, + vaultName: config.vaultName ?? config.name, itemId: item.id, itemTitle: item.title, + config, })), ), + Effect.catch(() => Effect.succeed([])), ), )).flat(); const [only, ...extra] = matches; if (only === undefined) return { kind: "not-found" } as const; - if (extra.length > 0) return { kind: "ambiguous", matches } as const; + if (extra.length > 0) { + return { + kind: "ambiguous", + matches: matches.map((m) => ({ + configId: m.configId, + configName: m.configName, + vaultId: m.vaultId, + vaultName: m.vaultName, + itemId: m.itemId, + itemTitle: m.itemTitle, + })), + } as const; + } + const svc = yield* getService(only.config); const value = yield* svc.resolveSecret( `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, ); @@ -271,12 +392,6 @@ export const resolveConfiguredRef = ( // --------------------------------------------------------------------------- // CredentialProvider — read-only, resolves op:// URIs or vault-scoped lookups. -// -// v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is -// a vault-qualified `op://` ref (what `list()` hands out) or a bare item -// id/title that must locate exactly one item across the configured vaults. -// The plugin's stored config supplies the auth + vault bindings; the provider -// never writes (writable: false). // --------------------------------------------------------------------------- const makeProvider = ( @@ -288,19 +403,13 @@ const makeProvider = ( writable: false, get: (id: ProviderItemId): Effect.Effect => - ctx.storage.getConfig().pipe( - // An undecodable stored config reads as "not configured" here; the - // settings surface reports the decode problem. - Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), - Effect.flatMap((config) => { - if (!config) return Effect.succeed(null as string | null); - - return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( - Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)), - // Backend unreachability degrades to "no value", matching the other - // providers. Ambiguity does NOT: silently picking a vault (or - // silently failing) hides a real conflict, so it surfaces as a - // typed failure with the full explanation. + ctx.storage.getConfigs().pipe( + Effect.catchTag("OnePasswordError", () => Effect.succeed([] as readonly OnePasswordConfig[])), + Effect.flatMap((configs) => { + if (configs.length === 0) return Effect.succeed(null as string | null); + const getSvc = (config: OnePasswordConfig) => + getServiceFromConfig(config, timeoutMs, preferSdk); + return resolveConfiguredRef(getSvc, configs, id).pipe( Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), Effect.flatMap( (resolution): Effect.Effect => @@ -318,39 +427,34 @@ const makeProvider = ( ), list: (): Effect.Effect => - ctx.storage.getConfig().pipe( - Effect.flatMap((config) => { - if (!config) return Effect.succeed([] as readonly ProviderEntry[]); - return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( - Effect.flatMap((svc) => - Effect.forEach(config.vaults, (vault) => - svc.listItems(vault.id).pipe( + ctx.storage.getConfigs().pipe( + Effect.flatMap((configs) => { + if (configs.length === 0) return Effect.succeed([] as readonly ProviderEntry[]); + return Effect.forEach(configs, (config) => + getServiceFromConfig(config, timeoutMs, preferSdk).pipe( + Effect.flatMap((svc) => + svc.listItems(config.vaultId).pipe( Effect.map((items) => items.map( - // Vault-qualified ids: picking an entry permanently - // records which vault it came from, so identically-titled - // items in different vaults can never collide. (item): ProviderEntry => ({ - id: ProviderItemId.make(`op://${vault.id}/${item.id}`), + id: ProviderItemId.make(`op://${config.id}/${item.id}`), name: item.title, - group: vault.name, + group: `${config.name} (${config.vaultName ?? config.vaultId})`, }), ), ), ), ), + Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), ), - Effect.map((groups): readonly ProviderEntry[] => groups.flat()), - ); + ).pipe(Effect.map((groups): readonly ProviderEntry[] => groups.flat())); }), Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), ), }); // --------------------------------------------------------------------------- -// Owner resolution — config is a single shared 1Password binding. We persist -// it under the `user` partition when the executor is bound to a subject, else -// the shared `org` partition. +// Owner resolution // --------------------------------------------------------------------------- const ownerForCtx = (ctx: PluginCtx): Owner => @@ -362,39 +466,95 @@ const makeOnePasswordExtension = ( preferSdk: boolean | undefined, ) => { return { - configure: (config: OnePasswordConfig) => ctx.storage.saveConfig(config, ownerForCtx(ctx)), - - getConfig: (): Effect.Effect< - RedactedOnePasswordConfig | null, + listConfigs: (): Effect.Effect< + readonly RedactedOnePasswordConfig[], StorageError | OnePasswordError - > => - ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))), + > => ctx.storage.getConfigs().pipe(Effect.map((configs) => configs.map(redactConfig))), + + getConfig: ( + id?: string, + ): Effect.Effect => + ctx.storage + .getConfig(id) + .pipe(Effect.map((config) => (config ? redactConfig(config) : null))), + + configure: (config: OnePasswordConfig) => ctx.storage.saveConfig(config, ownerForCtx(ctx)), - removeConfig: () => ctx.storage.deleteConfig(ownerForCtx(ctx)), + removeConfig: (id?: string) => ctx.storage.deleteConfig(id, ownerForCtx(ctx)), - status: () => + status: (id?: string) => Effect.gen(function* () { - const config = yield* ctx.storage.getConfig(); - if (!config) { + const configs = yield* ctx.storage.getConfigs(); + if (configs.length === 0) { return ConnectionStatus.make({ connected: false, error: "Not configured", }); } - const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); - const live = yield* svc.listVaults(); - const liveById = new Map(live.map((v) => [v.id, v.title])); - const missing = config.vaults.filter((vault) => !liveById.has(vault.id)); + const targetConfigs = id ? configs.filter((c) => c.id === id) : configs; + if (targetConfigs.length === 0) { + return ConnectionStatus.make({ + connected: false, + error: `Configuration "${id}" not found`, + }); + } + + const vaultStatuses = yield* Effect.forEach(targetConfigs, (config) => + Effect.gen(function* () { + const svcExit = yield* getServiceFromConfig(config, timeoutMs, preferSdk).pipe( + Effect.exit, + ); + if (Exit.isFailure(svcExit)) { + return VaultStatus.make({ + id: config.id, + name: config.name, + vaultId: config.vaultId, + vaultName: config.vaultName, + connected: false, + error: "Failed to initialize 1Password service", + }); + } + const svc = svcExit.value; + const liveExit = yield* svc.listVaults().pipe(Effect.exit); + if (Exit.isFailure(liveExit)) { + return VaultStatus.make({ + id: config.id, + name: config.name, + vaultId: config.vaultId, + vaultName: config.vaultName, + connected: false, + error: "Failed to reach 1Password vaults", + }); + } + const live = liveExit.value; + const found = live.find((v) => v.id === config.vaultId); + if (!found) { + return VaultStatus.make({ + id: config.id, + name: config.name, + vaultId: config.vaultId, + vaultName: config.vaultName, + connected: true, + error: `Vault "${config.vaultName ?? config.vaultId}" not found in account`, + }); + } + return VaultStatus.make({ + id: config.id, + name: config.name, + vaultId: config.vaultId, + vaultName: found.title, + connected: true, + }); + }), + ); + + const allConnected = vaultStatuses.every((v) => v.connected && !v.error); + const anyError = vaultStatuses.find((v) => v.error)?.error; + return ConnectionStatus.make({ - connected: true, - vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name), - ...(missing.length > 0 - ? { - error: `Configured vaults not found: ${missing - .map((vault) => vault.name) - .join(", ")}`, - } - : {}), + connected: allConnected, + vaults: vaultStatuses, + ...(anyError ? { error: anyError } : {}), }); }), @@ -412,15 +572,16 @@ const makeOnePasswordExtension = ( resolve: (uri: string) => Effect.gen(function* () { - const config = yield* ctx.storage.getConfig(); - if (!config) { + const configs = yield* ctx.storage.getConfigs(); + if (configs.length === 0) { return yield* new OnePasswordError({ operation: "resolve", message: "1Password is not configured", }); } - const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); - const resolution = yield* resolveConfiguredRef(svc, config, uri); + const getSvc = (config: OnePasswordConfig) => + getServiceFromConfig(config, timeoutMs, preferSdk); + const resolution = yield* resolveConfiguredRef(getSvc, configs, uri); if (resolution.kind === "resolved") return resolution.value; if (resolution.kind === "outside-vaults") { return yield* new OnePasswordError({ @@ -476,15 +637,26 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption name: "status", description: "Check whether the 1Password credential provider is configured and can reach its selected vaults. This returns status only, never secret values.", + inputSchema: OnePasswordStatusInputStd, outputSchema: OnePasswordStatusOutputStd, - execute: () => Effect.map(self.status(), ToolResult.ok), + execute: (input) => Effect.map(self.status(input?.id), ToolResult.ok), + }), + tool({ + name: "listConfigs", + description: + "List all configured 1Password vault configurations for the acting owner. Metadata only; service-account tokens are never returned.", + outputSchema: OnePasswordListConfigsOutputStd, + execute: () => + Effect.map(self.listConfigs(), (configs) => ToolResult.ok({ configs: [...configs] })), }), tool({ name: "getConfig", description: - "Read the current 1Password provider configuration. This returns account/vault metadata only; service-account token values are never returned.", + "Read a 1Password provider configuration by ID (or the default configuration). This returns metadata only; service-account tokens are never returned.", + inputSchema: OnePasswordGetConfigInputStd, outputSchema: OnePasswordGetConfigOutputStd, - execute: () => Effect.map(self.getConfig(), (config) => ToolResult.ok({ config })), + execute: (input) => + Effect.map(self.getConfig(input?.id), (config) => ToolResult.ok({ config })), }), tool({ name: "listVaults", @@ -498,29 +670,28 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption tool({ name: "configure", description: - "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.", + "Add or update a named 1Password vault configuration 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.", annotations: { requiresApproval: true, - approvalDescription: "Configure the 1Password credential provider", + approvalDescription: "Configure a 1Password vault", }, inputSchema: OnePasswordConfigureInputStd, outputSchema: OnePasswordConfigureOutputStd, execute: (input) => - Effect.as( - self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }), - ToolResult.ok({ configured: true }), - ), + Effect.as(self.configure(input), ToolResult.ok({ configured: true, id: input.id })), }), tool({ name: "removeConfig", description: - "Remove the 1Password provider configuration for the acting owner. Future 1Password secret resolution stops until reconfigured.", + "Remove a named 1Password provider configuration for the acting owner (or all if omitted).", annotations: { requiresApproval: true, - approvalDescription: "Remove the 1Password credential provider configuration", + approvalDescription: "Remove a 1Password provider configuration", }, + inputSchema: OnePasswordRemoveConfigInputStd, outputSchema: OnePasswordRemoveConfigOutputStd, - execute: () => Effect.as(self.removeConfig(), ToolResult.ok({ removed: true })), + execute: (input) => + Effect.as(self.removeConfig(input?.id), ToolResult.ok({ removed: true })), }), ], }, @@ -528,8 +699,4 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption credentialProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)], }; - // HTTP transport (routes/handlers/extensionService) is layered on by - // the api-aware factory in `@executor-js/plugin-onepassword/api`. Hosts - // that want the HTTP surface import the plugin from there; SDK-only - // consumers stay on this entry and avoid the server-only deps. }); diff --git a/packages/plugins/onepassword/src/sdk/types.ts b/packages/plugins/onepassword/src/sdk/types.ts index 82f70269ed..4feeba0c6f 100644 --- a/packages/plugins/onepassword/src/sdk/types.ts +++ b/packages/plugins/onepassword/src/sdk/types.ts @@ -14,8 +14,8 @@ export type DesktopAppAuth = typeof DesktopAppAuth.Type; export const ServiceAccountAuth = Schema.Struct({ kind: Schema.Literal("service-account"), /** The service account token. Persisted in the plugin's owner-partitioned - * config blob — never surfaced to agents (`getConfig` redacts it). v1 stored - * this behind a separate secret id; v2 has no secrets table, so the + * config blob — never surfaced to agents (`getConfig` / `listConfigs` redacts it). + * v1 stored this behind a separate secret id; v2 has no secrets table, so the * plugin-owned config row carries it directly. */ token: Schema.String, }); @@ -35,46 +35,93 @@ export const Vault = Schema.Struct({ export type Vault = typeof Vault.Type; // --------------------------------------------------------------------------- -// Stored config — persisted via KV +// Named Stored config — persisted via KV // --------------------------------------------------------------------------- export const OnePasswordConfig = Schema.Struct({ + /** Stable identifier for this named configuration (e.g. "default", "personal", "work") */ + id: Schema.String, + /** Human display label for this named configuration */ + name: Schema.String, + auth: OnePasswordAuth, + /** Selected 1Password vault ID */ + vaultId: Schema.String, + /** Human label for the selected vault */ + vaultName: Schema.optional(Schema.String), +}); +export type OnePasswordConfig = typeof OnePasswordConfig.Type; + +/** Legacy multi-vault stored shape: `{ auth, vaults, name }`. */ +export const LegacyMultiVaultConfig = Schema.Struct({ auth: OnePasswordAuth, - /** Vaults to scope operations to. Order is presentational only: refs are - * vault-qualified, and a bare ref that matches in more than one vault is an - * explicit ambiguity failure, never a precedence pick. */ vaults: Schema.NonEmptyArray(Vault), - /** Human label for the whole connection */ name: Schema.String, }); -export type OnePasswordConfig = typeof OnePasswordConfig.Type; +export type LegacyMultiVaultConfig = typeof LegacyMultiVaultConfig.Type; -/** Pre-multi-vault stored shape: a single vault id whose display name doubled - * as the connection label. Still accepted on read; every save writes the - * current shape, so a config row upgrades the first time it is re-saved. */ -export const LegacyOnePasswordConfig = Schema.Struct({ +/** Pre-multi-vault stored shape: `{ auth, vaultId, name }`. */ +export const LegacySingleVaultConfig = Schema.Struct({ auth: OnePasswordAuth, vaultId: Schema.String, name: Schema.String, }); -export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type; +export type LegacySingleVaultConfig = typeof LegacySingleVaultConfig.Type; -export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]); +export const StoredConfigsWrapper = Schema.Struct({ + configs: Schema.Array(OnePasswordConfig), +}); +export type StoredConfigsWrapper = typeof StoredConfigsWrapper.Type; + +export const StoredOnePasswordConfig = Schema.Union([ + StoredConfigsWrapper, + Schema.Array(OnePasswordConfig), + LegacyMultiVaultConfig, + LegacySingleVaultConfig, + OnePasswordConfig, +]); export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type; -export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => - "vaultId" in stored - ? { - auth: stored.auth, - vaults: [{ id: stored.vaultId, name: stored.name }], +export const normalizeStoredConfigs = ( + stored: StoredOnePasswordConfig, +): readonly OnePasswordConfig[] => { + if ("configs" in stored && Array.isArray(stored.configs)) { + return stored.configs; + } + if (Array.isArray(stored)) { + return stored; + } + if ("vaults" in stored && Array.isArray(stored.vaults)) { + return stored.vaults.map((vault, index) => ({ + id: index === 0 ? "default" : vault.id, + name: index === 0 ? stored.name : vault.name, + auth: stored.auth, + vaultId: vault.id, + vaultName: vault.name, + })); + } + if ("vaultId" in stored) { + if ("id" in stored && typeof stored.id === "string") { + return [stored as OnePasswordConfig]; + } + return [ + { + id: "default", name: stored.name, - } - : stored; + auth: stored.auth, + vaultId: stored.vaultId, + vaultName: stored.name, + }, + ]; + } + return []; +}; + +export const normalizeStoredConfig = normalizeStoredConfigs; // --------------------------------------------------------------------------- -// Redacted config — what `getConfig` returns to agents / the UI. The -// service-account token is stripped; only the auth kind + account metadata is -// surfaced. +// Redacted config — what `getConfig` / `listConfigs` returns to agents / the UI. +// The service-account token is stripped; only the auth kind + account metadata +// is surfaced. // --------------------------------------------------------------------------- export const RedactedDesktopAppAuth = DesktopAppAuth; @@ -89,29 +136,43 @@ export const RedactedOnePasswordAuth = Schema.Union([ ]); export const RedactedOnePasswordConfig = Schema.Struct({ - auth: RedactedOnePasswordAuth, - vaults: Schema.NonEmptyArray(Vault), + id: Schema.String, name: Schema.String, + auth: RedactedOnePasswordAuth, + vaultId: Schema.String, + vaultName: Schema.optional(Schema.String), }); export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; /** Strip the service-account token from a stored config for external exposure. */ export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => ({ + id: config.id, + name: config.name, auth: config.auth.kind === "desktop-app" ? { kind: "desktop-app", accountName: config.auth.accountName } : { kind: "service-account" }, - vaults: config.vaults, - name: config.name, + vaultId: config.vaultId, + ...(config.vaultName !== undefined ? { vaultName: config.vaultName } : {}), }); // --------------------------------------------------------------------------- // Connection status // --------------------------------------------------------------------------- +export const VaultStatus = Schema.Struct({ + id: Schema.String, + name: Schema.String, + vaultId: Schema.String, + vaultName: Schema.optional(Schema.String), + connected: Schema.Boolean, + error: Schema.optional(Schema.String), +}); +export type VaultStatus = typeof VaultStatus.Type; + export const ConnectionStatus = Schema.Struct({ connected: Schema.Boolean, - vaultNames: Schema.optional(Schema.Array(Schema.String)), + vaults: Schema.optional(Schema.Array(VaultStatus)), error: Schema.optional(Schema.String), }); export type ConnectionStatus = typeof ConnectionStatus.Type;