From b7c8cfeb558ed108ca4a027d49f2d8837c861359 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:02:41 -0700 Subject: [PATCH] 1Password: multiple named accounts Co-authored-by: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> --- .changeset/onepassword-multiple-accounts.md | 5 + packages/plugins/onepassword/src/api/group.ts | 27 +- .../plugins/onepassword/src/api/handlers.ts | 6 +- .../src/react/OnePasswordSettings.tsx | 196 +++++--- packages/plugins/onepassword/src/sdk/index.ts | 8 + .../onepassword/src/sdk/plugin.test.ts | 366 +++++++++++--- .../plugins/onepassword/src/sdk/plugin.ts | 466 +++++++++++++----- packages/plugins/onepassword/src/sdk/types.ts | 124 +++-- 8 files changed, 861 insertions(+), 337 deletions(-) create mode 100644 .changeset/onepassword-multiple-accounts.md diff --git a/.changeset/onepassword-multiple-accounts.md b/.changeset/onepassword-multiple-accounts.md new file mode 100644 index 0000000000..a59a29a5ab --- /dev/null +++ b/.changeset/onepassword-multiple-accounts.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +1Password: multiple named accounts. The provider now holds any number of named accounts — a work account next to a personal one, or a service-account token next to desktop-app biometrics — each scoping its own set of vaults. The settings card lists every account with independent edit and disconnect, existing single-account configs upgrade in place, and `op://` refs keep their vault-first addressing: a vault name that exists in more than one account is an explicit ambiguity error, never a silent pick. diff --git a/packages/plugins/onepassword/src/api/group.ts b/packages/plugins/onepassword/src/api/group.ts index e93c4ac79b..9a07ed7862 100644 --- a/packages/plugins/onepassword/src/api/group.ts +++ b/packages/plugins/onepassword/src/api/group.ts @@ -3,24 +3,28 @@ import { Schema } from "effect"; import { InternalError } from "@executor-js/sdk/shared"; import { OnePasswordError } from "../sdk/errors"; -import { - OnePasswordConfig, - RedactedOnePasswordConfig, - Vault, - ConnectionStatus, -} from "../sdk/types"; +import { OnePasswordAccountUpsert } from "../sdk/plugin"; +import { RedactedOnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types"; // --------------------------------------------------------------------------- // Payloads // // v2: config is a single per-owner binding the extension derives from the // executor's owner binding — there are no scope segments in the path. The -// configure payload carries the full config (including the service-account -// token); reads return the redacted projection so the token never leaves the -// plugin. +// configure payload carries one account upsert (including the +// service-account token); reads return the redacted projection so the token +// never leaves the plugin. // --------------------------------------------------------------------------- -const ConfigurePayload = OnePasswordConfig; +const ConfigurePayload = OnePasswordAccountUpsert; + +const ConfigureResponse = Schema.Struct({ + accountId: Schema.String, +}); + +const RemoveConfigParams = Schema.Struct({ + accountId: Schema.optional(Schema.String), +}); const ListVaultsParams = Schema.Struct({ authKind: Schema.Literals(["desktop-app", "service-account"]), @@ -60,12 +64,13 @@ export const OnePasswordGroup = HttpApiGroup.make("onepassword") .add( HttpApiEndpoint.put("configure", "/onepassword/config", { payload: ConfigurePayload, - success: Schema.Void, + success: ConfigureResponse, error: [InternalError, OnePasswordError], }), ) .add( HttpApiEndpoint.delete("removeConfig", "/onepassword/config", { + query: RemoveConfigParams, success: Schema.Void, error: [InternalError, OnePasswordError], }), diff --git a/packages/plugins/onepassword/src/api/handlers.ts b/packages/plugins/onepassword/src/api/handlers.ts index b3747c3c0b..8b06b42678 100644 --- a/packages/plugins/onepassword/src/api/handlers.ts +++ b/packages/plugins/onepassword/src/api/handlers.ts @@ -54,15 +54,15 @@ export const OnePasswordHandlers = HttpApiBuilder.group( capture( Effect.gen(function* () { const ext = yield* OnePasswordExtensionService; - yield* ext.configure(payload); + return yield* ext.configure(payload); }), ), ) - .handle("removeConfig", () => + .handle("removeConfig", ({ query }) => capture( Effect.gen(function* () { const ext = yield* OnePasswordExtensionService; - yield* ext.removeConfig(); + yield* ext.removeConfig(query.accountId); }), ), ) diff --git a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx index 220b2ddbc3..6f9881dfbd 100644 --- a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +++ b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx @@ -36,7 +36,7 @@ import { removeOnePasswordConfig, onepasswordWriteKeys, } from "./atoms"; -import type { RedactedOnePasswordConfig, Vault } from "../sdk/types"; +import type { RedactedOnePasswordAccount, RedactedOnePasswordConfig, Vault } from "../sdk/types"; // --------------------------------------------------------------------------- // Vault picker — multi-select @@ -170,13 +170,14 @@ function VaultPicker(props: { } // --------------------------------------------------------------------------- -// Config dialog +// Account dialog — add a new account, or edit one by id // --------------------------------------------------------------------------- -function ConfigDialog(props: { +function AccountDialog(props: { open: boolean; onOpenChange: (v: boolean) => void; initial?: { + id: string; authKind: string; accountName: string; vaults: ReadonlyArray; @@ -221,6 +222,7 @@ function ConfigDialog(props: { const exit = await doConfigure({ payload: { + ...(props.initial ? { id: props.initial.id } : {}), auth, vaults: [firstVault, ...restVaults], name: displayName.trim() || "1Password", @@ -248,11 +250,11 @@ function ConfigDialog(props: { - {isEdit ? "Edit 1Password" : "Connect 1Password"} + {isEdit ? "Edit 1Password account" : "Add 1Password account"} Link one or more vaults to resolve secrets via the 1Password desktop app or a service - account. + account. Add more accounts to keep work and personal credentials separate. @@ -310,10 +312,10 @@ function ConfigDialog(props: { {/* Display name */}
setDisplayName((e.target as HTMLInputElement).value)} className="text-[13px] h-9" @@ -347,18 +349,65 @@ function ConfigDialog(props: { } // --------------------------------------------------------------------------- -// Settings card +// Settings entries — one card per account, plus the add action // --------------------------------------------------------------------------- -export default function OnePasswordSettings() { - const [configOpen, setConfigOpen] = useState(false); - const configResult = useAtomValue(onepasswordConfigAtom); +function AccountEntry(props: { account: RedactedOnePasswordAccount; onEdit: () => void }) { const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" }); + const [removing, setRemoving] = useState(false); const handleRemove = async () => { - await doRemove({ reactivityKeys: onepasswordWriteKeys }); + setRemoving(true); + await doRemove({ + query: { accountId: props.account.id }, + reactivityKeys: onepasswordWriteKeys, + }); + setRemoving(false); }; + return ( + + +
+ Name + {props.account.name} + Auth + + {props.account.auth.kind === "desktop-app" + ? props.account.auth.accountName + : "service-account"} + + + {props.account.vaults.length === 1 ? "Vault" : "Vaults"} + + + {props.account.vaults.map((vault) => vault.name).join(", ")} + +
+
+ + + + +
+ ); +} + +export default function OnePasswordSettings() { + // null = closed; "new" = add flow; otherwise the account being edited. + const [dialogTarget, setDialogTarget] = useState(null); + const configResult = useAtomValue(onepasswordConfigAtom); + const config: RedactedOnePasswordConfig | null = AsyncResult.match( configResult as AsyncResult.AsyncResult, { @@ -384,88 +433,81 @@ export default function OnePasswordSettings() { }, ); + const accounts = config?.accounts ?? []; + return ( <> - - - {isLoading ? ( - Loading… - ) : 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(", ")} - -
-
- ) : ( - - Resolve secrets from your 1Password vaults. - - )} -
- - {config ? ( - <> + {isLoading || isError || accounts.length === 0 ? ( + + + {isLoading ? ( + Loading… + ) : isError ? ( + + Failed to load configuration + + ) : ( + + Resolve secrets from your 1Password vaults. + + )} + + + {!isLoading && !isError && ( - - - ) : ( - !isLoading && - !isError && ( + )} + + + ) : ( + <> + {accounts.map((account) => ( + setDialogTarget(account)} + /> + ))} + + - ) - )} - -
+ + + + )} - {configOpen && ( - { + if (!v) setDialogTarget(null); + }} initial={ - config - ? { - authKind: config.auth.kind, + dialogTarget === "new" + ? undefined + : { + id: dialogTarget.id, + authKind: dialogTarget.auth.kind, // Service-account tokens are never surfaced (redacted); the // user re-enters the token when editing that auth method. - accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "", - vaults: config.vaults, - name: config.name, + accountName: + dialogTarget.auth.kind === "desktop-app" ? dialogTarget.auth.accountName : "", + vaults: dialogTarget.vaults, + name: dialogTarget.name, } - : undefined } /> )} diff --git a/packages/plugins/onepassword/src/sdk/index.ts b/packages/plugins/onepassword/src/sdk/index.ts index 751108ebda..dc804c865e 100644 --- a/packages/plugins/onepassword/src/sdk/index.ts +++ b/packages/plugins/onepassword/src/sdk/index.ts @@ -3,20 +3,28 @@ export { makeOnePasswordStore, resolveConfiguredRef, ambiguityMessage, + vaultAmbiguityMessage, + OnePasswordAccountUpsert, type RefResolution, type OnePasswordExtension, type OnePasswordPluginOptions, type OnePasswordStore, } from "./plugin"; export { + OnePasswordAccount, OnePasswordConfig, + SingleAccountOnePasswordConfig, LegacyOnePasswordConfig, StoredOnePasswordConfig, + DEFAULT_ACCOUNT_ID, normalizeStoredConfig, + RedactedOnePasswordAccount, RedactedOnePasswordConfig, RedactedOnePasswordAuth, + redactAccount, redactConfig, Vault, + AccountStatus, 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..61e1f4ef6a 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.test.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.test.ts @@ -8,7 +8,7 @@ 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"; +import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types"; // removed: v1 routed configure/removeConfig through an explicit `ScopeId` // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and @@ -19,16 +19,38 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; const ONEPASSWORD = ProviderKey.make("onepassword"); -const twoVaultConfig = OnePasswordConfig.make({ - auth: DesktopAppAuth.make({ - kind: "desktop-app", - accountName: "my.1password.com", - }), +const desktopAuth = DesktopAppAuth.make({ + kind: "desktop-app", + accountName: "my.1password.com", +}); + +const twoVaultAccount = OnePasswordAccount.make({ + id: "acct-default", + name: "1Password", + auth: desktopAuth, vaults: [ { id: "vault-123", name: "Personal" }, { id: "vault-456", name: "Work" }, ], - name: "1Password", +}); + +const oneAccountConfig = OnePasswordConfig.make({ accounts: [twoVaultAccount] }); + +const twoAccountConfig = OnePasswordConfig.make({ + accounts: [ + OnePasswordAccount.make({ + id: "acct-work", + name: "Work", + auth: desktopAuth, + vaults: [{ id: "vault-eng", name: "Engineering" }], + }), + OnePasswordAccount.make({ + id: "acct-personal", + name: "Personal", + auth: DesktopAppAuth.make({ kind: "desktop-app", accountName: "family.1password.com" }), + vaults: [{ id: "vault-home", name: "Home" }], + }), + ], }); describe("onepassword plugin", () => { @@ -42,7 +64,7 @@ describe("onepassword plugin", () => { }), ); - it.effect("configure / getConfig / removeConfig round-trip via blob store", () => + it.effect("configure upserts accounts by id and removeConfig removes them one by one", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [onepasswordPlugin()] as const }), @@ -51,40 +73,92 @@ describe("onepassword plugin", () => { const initial = yield* executor.onepassword.getConfig(); expect(initial).toBeNull(); - yield* executor.onepassword.configure(twoVaultConfig); + const first = yield* executor.onepassword.configure({ + name: "Work", + auth: desktopAuth, + vaults: [{ id: "vault-eng", name: "Engineering" }], + }); + const second = yield* executor.onepassword.configure({ + name: "Personal", + auth: desktopAuth, + vaults: [{ id: "vault-home", name: "Home" }], + }); + expect(first.accountId).not.toBe(second.accountId); 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"); - - yield* executor.onepassword.removeConfig(); + expect(loaded?.accounts.map((account) => account.name).sort()).toEqual(["Personal", "Work"]); + + // Re-saving with an id replaces that account in place. + yield* executor.onepassword.configure({ + id: first.accountId, + name: "Work (renamed)", + auth: desktopAuth, + vaults: [ + { id: "vault-eng", name: "Engineering" }, + { id: "vault-infra", name: "Infra" }, + ], + }); + const afterEdit = yield* executor.onepassword.getConfig(); + expect(afterEdit?.accounts.length).toBe(2); + const edited = afterEdit?.accounts.find((account) => account.id === first.accountId); + expect(edited?.name).toBe("Work (renamed)"); + expect(edited?.vaults.length).toBe(2); + + // Removing one account keeps the other. + yield* executor.onepassword.removeConfig(first.accountId); const afterRemove = yield* executor.onepassword.getConfig(); - expect(afterRemove).toBeNull(); + expect(afterRemove?.accounts.map((account) => account.id)).toEqual([second.accountId]); + + // Removing the last account deletes the config entirely. + yield* executor.onepassword.removeConfig(second.accountId); + expect(yield* executor.onepassword.getConfig()).toBeNull(); }), ); - it.effect("getConfig redacts the service-account token", () => + it.effect("removeConfig without an id removes everything", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [onepasswordPlugin()] as const }), ); + yield* executor.onepassword.configure({ + name: "Work", + auth: desktopAuth, + vaults: [{ id: "vault-eng", name: "Engineering" }], + }); + yield* executor.onepassword.configure({ + name: "Personal", + auth: desktopAuth, + vaults: [{ id: "vault-home", name: "Home" }], + }); + yield* executor.onepassword.removeConfig(); + expect(yield* executor.onepassword.getConfig()).toBeNull(); + }), + ); - yield* executor.onepassword.configure( - OnePasswordConfig.make({ - auth: { kind: "service-account", token: "super-secret-token" }, - vaults: [{ id: "vault-123", name: "CI" }], - name: "CI", - }), + it.effect("getConfig redacts every account's service-account token", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [onepasswordPlugin()] as const }), ); + yield* executor.onepassword.configure({ + name: "CI", + auth: { kind: "service-account", token: "super-secret-token" }, + vaults: [{ id: "vault-123", name: "CI" }], + }); + yield* executor.onepassword.configure({ + name: "Deploys", + auth: { kind: "service-account", token: "other-secret-token" }, + vaults: [{ id: "vault-456", name: "Deploys" }], + }); + const loaded = yield* executor.onepassword.getConfig(); - expect(loaded?.auth.kind).toBe("service-account"); - // The token must never be surfaced through the redacted projection. + expect(loaded?.accounts.every((account) => account.auth.kind === "service-account")).toBe( + true, + ); + // Tokens must never be surfaced through the redacted projection. expect(JSON.stringify(loaded)).not.toContain("super-secret-token"); + expect(JSON.stringify(loaded)).not.toContain("other-secret-token"); }), ); @@ -97,35 +171,43 @@ describe("onepassword plugin", () => { const configured = yield* executor.execute( ToolAddress.make("executor.onepassword.configure"), { + name: "1Password", auth: { kind: "desktop-app", accountName: "my.1password.com" }, vaults: [ { id: "vault-123", name: "Personal" }, { id: "vault-456", name: "Work" }, ], - name: "1Password", }, { onElicitation: "accept-all" }, ); - expect(configured).toEqual({ ok: true, data: { configured: true } }); + expect(configured).toMatchObject({ ok: true, data: { configured: true } }); + const accountId = (configured as { data: { accountId: string } }).data.accountId; + expect(typeof accountId).toBe("string"); + expect( yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), ).toMatchObject({ ok: true, data: { config: { - vaults: [ - { id: "vault-123", name: "Personal" }, - { id: "vault-456", name: "Work" }, + accounts: [ + { + id: accountId, + name: "1Password", + vaults: [ + { id: "vault-123", name: "Personal" }, + { id: "vault-456", name: "Work" }, + ], + }, ], - name: "1Password", }, }, }); const removed = yield* executor.execute( ToolAddress.make("executor.onepassword.removeConfig"), - {}, + { accountId }, { onElicitation: "accept-all" }, ); @@ -141,15 +223,17 @@ describe("onepassword plugin", () => { ); const status = yield* executor.onepassword.status(); expect(status.connected).toBe(false); + expect(status.accounts).toEqual([]); expect(status.error).toBe("Not configured"); }), ); }); // --------------------------------------------------------------------------- -// 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. +// Stored-config compatibility — blobs written before multi-account support +// hold either `{ auth, vaultId, name }` (original) or `{ auth, vaults, name }` +// (multi-vault). Reads normalize both onto a single "default" account; the +// next save writes the current shape. // --------------------------------------------------------------------------- describe("onepassword store", () => { @@ -177,27 +261,65 @@ describe("onepassword store", () => { const config = yield* store.getConfig(); expect(config).toEqual({ - auth: { kind: "desktop-app", accountName: "my.1password.com" }, - vaults: [{ id: "vault-123", name: "Personal" }], - name: "Personal", + accounts: [ + { + id: "default", + name: "Personal", + auth: { kind: "desktop-app", accountName: "my.1password.com" }, + vaults: [{ id: "vault-123", name: "Personal" }], + }, + ], + }); + }), + ); + + it.effect("upgrades a single-account multi-vault 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: "vault-123", name: "Personal" }, + { id: "vault-456", name: "Work" }, + ], + name: "1Password", + }), + { owner: "org" }, + ); + + const config = yield* store.getConfig(); + expect(config).toEqual({ + accounts: [ + { + id: "default", + name: "1Password", + auth: { kind: "desktop-app", accountName: "my.1password.com" }, + vaults: [ + { id: "vault-123", name: "Personal" }, + { id: "vault-456", name: "Work" }, + ], + }, + ], }); }), ); - it.effect("persists and reads back the multi-vault shape", () => + it.effect("persists and reads back the multi-account shape", () => Effect.gen(function* () { const { store } = makeStore(); - yield* store.saveConfig(twoVaultConfig, "org"); + yield* store.saveConfig(twoAccountConfig, "org"); const config = yield* store.getConfig(); - expect(config).toEqual(twoVaultConfig); + expect(config).toEqual(twoAccountConfig); }), ); }); // --------------------------------------------------------------------------- -// 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. +// Explicit ref resolution — vault-qualified refs resolve directly via the +// owning account; bare refs must locate exactly one item, and a multi-vault +// match is an explicit ambiguity, never a precedence pick. // --------------------------------------------------------------------------- const fakeService = ( @@ -217,84 +339,160 @@ const fakeService = ( }, }); +/** One fake backend per account id; secrets resolve tagged with the serving + * account so tests can assert which account's auth handled a ref. */ +const fakeServiceFor = + ( + itemsByAccount: Readonly< + Record>> + >, + ) => + (account: OnePasswordAccount) => { + const items = itemsByAccount[account.id] ?? {}; + return Effect.succeed({ + ...fakeService(items), + resolveSecret: (uri) => Effect.succeed(`secret:${account.id}:${uri}`), + }); + }; + describe("resolveConfiguredRef", () => { + const serviceFor = fakeServiceFor({}); + it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () => Effect.gen(function* () { - const svc = fakeService({}); const result = yield* resolveConfiguredRef( - svc, - twoVaultConfig, + oneAccountConfig, + serviceFor, "op://vault-456/item-abc/password", ); expect(result).toEqual({ kind: "resolved", - value: "secret:op://vault-456/item-abc/password", + value: "secret:acct-default:op://vault-456/item-abc/password", }); }), ); it.effect("appends the credential field to a picker-shaped op://vault/item ref", () => Effect.gen(function* () { - const svc = fakeService({}); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc"); + const result = yield* resolveConfiguredRef( + oneAccountConfig, + serviceFor, + "op://vault-123/item-abc", + ); expect(result).toEqual({ kind: "resolved", - value: "secret:op://vault-123/item-abc/credential", + value: "secret:acct-default:op://vault-123/item-abc/credential", }); }), ); it.effect("accepts an op:// URI addressed by vault name", () => 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( + oneAccountConfig, + serviceFor, + "op://Work/item/password", + ); + expect(result).toEqual({ + kind: "resolved", + value: "secret:acct-default:op://Work/item/password", + }); + }), + ); + + it.effect("routes a vault-qualified ref through the account that owns the vault", () => + Effect.gen(function* () { + const result = yield* resolveConfiguredRef( + twoAccountConfig, + serviceFor, + "op://vault-home/item-abc/password", + ); + expect(result).toEqual({ + kind: "resolved", + value: "secret:acct-personal:op://vault-home/item-abc/password", + }); + }), + ); + + it.effect("reports a vault name configured in more than one account as ambiguous", () => + Effect.gen(function* () { + const config = OnePasswordConfig.make({ + accounts: [ + OnePasswordAccount.make({ + id: "acct-a", + name: "Work", + auth: desktopAuth, + vaults: [{ id: "vault-a", name: "Shared" }], + }), + OnePasswordAccount.make({ + id: "acct-b", + name: "Personal", + auth: desktopAuth, + vaults: [{ id: "vault-b", name: "Shared" }], + }), + ], + }); + const result = yield* resolveConfiguredRef(config, serviceFor, "op://Shared/item"); + expect(result).toEqual({ + kind: "ambiguous-vault", + vaultName: "Shared", + matches: [ + { accountName: "Work", vaultId: "vault-a" }, + { accountName: "Personal", vaultId: "vault-b" }, + ], + }); }), ); it.effect("reports an op:// URI outside the configured vaults", () => Effect.gen(function* () { - const svc = fakeService({}); const result = yield* resolveConfiguredRef( - svc, - twoVaultConfig, + oneAccountConfig, + serviceFor, "op://vault-999/item/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 that matches exactly one item across accounts", () => Effect.gen(function* () { - const svc = fakeService({ - "vault-123": [{ id: "item-1", title: "GitHub Token" }], - "vault-456": [{ id: "item-2", title: "Stripe Key" }], + const withItems = fakeServiceFor({ + "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] }, + "acct-personal": { "vault-home": [{ id: "item-2", title: "Stripe Key" }] }, }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); + const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token"); expect(result).toEqual({ kind: "resolved", - value: "secret:op://vault-123/item-1/credential", + value: "secret:acct-work:op://vault-eng/item-1/credential", }); }), ); - it.effect("fails a bare ref that matches in two vaults with the vaults named", () => + it.effect("fails a bare ref that matches in two accounts with the matches named", () => Effect.gen(function* () { - const svc = fakeService({ - "vault-123": [{ id: "item-1", title: "GitHub Token" }], - "vault-456": [{ id: "item-2", title: "GitHub Token" }], + const withItems = fakeServiceFor({ + "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] }, + "acct-personal": { "vault-home": [{ id: "item-2", title: "GitHub Token" }] }, }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); + const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token"); expect(result).toEqual({ kind: "ambiguous", matches: [ { - vaultId: "vault-123", - vaultName: "Personal", + accountName: "Work", + vaultId: "vault-eng", + vaultName: "Engineering", itemId: "item-1", itemTitle: "GitHub Token", }, - { vaultId: "vault-456", vaultName: "Work", itemId: "item-2", itemTitle: "GitHub Token" }, + { + accountName: "Personal", + vaultId: "vault-home", + vaultName: "Home", + itemId: "item-2", + itemTitle: "GitHub Token", + }, ], }); }), @@ -302,22 +500,26 @@ describe("resolveConfiguredRef", () => { 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 withItems = fakeServiceFor({ + "acct-default": { + "vault-123": [ + { id: "item-1", title: "GitHub Token" }, + { id: "item-9", title: "GitHub Token" }, + ], + "vault-456": [], + }, }); - const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); + const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "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"); + const withItems = fakeServiceFor({ + "acct-default": { "vault-123": [], "vault-456": [] }, + }); + const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "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..9951465849 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.ts @@ -17,12 +17,14 @@ import { } from "@executor-js/sdk/core"; import { + OnePasswordAccount, OnePasswordAuth, OnePasswordConfig, RedactedOnePasswordConfig, StoredOnePasswordConfig, Vault, ConnectionStatus, + AccountStatus, normalizeStoredConfig, redactConfig, } from "./types"; @@ -44,10 +46,25 @@ const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToo I >; -const OnePasswordConfigureInput = OnePasswordConfig; +// --------------------------------------------------------------------------- +// Upsert payload — `configure` adds or replaces one account. The id is +// generated on first save so the caller can address the account later +// (edit, remove) without the name doubling as an identifier. +// --------------------------------------------------------------------------- + +export const OnePasswordAccountUpsert = Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.String, + auth: OnePasswordAuth, + vaults: Schema.NonEmptyArray(Vault), +}); +export type OnePasswordAccountUpsert = typeof OnePasswordAccountUpsert.Type; + +const OnePasswordConfigureInput = OnePasswordAccountUpsert; const OnePasswordConfigureOutput = Schema.Struct({ configured: Schema.Boolean, + accountId: Schema.String, }); const OnePasswordGetConfigOutput = Schema.Struct({ @@ -60,6 +77,10 @@ const OnePasswordListVaultsOutput = Schema.Struct({ vaults: Schema.Array(Vault), }); +const OnePasswordRemoveConfigInput = Schema.Struct({ + accountId: Schema.optional(Schema.String), +}); + const OnePasswordRemoveConfigOutput = Schema.Struct({ removed: Schema.Boolean, }); @@ -77,6 +98,10 @@ const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< typeof OnePasswordListVaultsInput.Encoded >(OnePasswordListVaultsInput); const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput); +const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema< + typeof OnePasswordRemoveConfigInput.Type, + typeof OnePasswordRemoveConfigInput.Encoded +>(OnePasswordRemoveConfigInput); const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput); const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); @@ -95,13 +120,13 @@ 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`. +// stored config carries every account's auth credential (desktop account +// name, or service-account token) plus its 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 two +// pre-multi-account shapes and normalize them onto a single "default" +// account; saves always write the current shape. Blob I/O failures surface as +// `StorageError`; decode failures stay `OnePasswordError`. // --------------------------------------------------------------------------- export interface OnePasswordStore { @@ -147,15 +172,7 @@ 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 }, - ) + .put(CONFIG_KEY, JSON.stringify({ accounts: config.accounts }), { owner }) .pipe(Effect.mapError(blobStorageError("write"))), deleteConfig: (owner) => @@ -171,26 +188,32 @@ const resolveAuth = (auth: OnePasswordAuth): ResolvedAuth => ? { kind: "desktop-app", accountName: auth.accountName } : { kind: "service-account", token: auth.token }; -const getServiceFromConfig = ( - config: OnePasswordConfig, - timeoutMs: number, - preferSdk: boolean | undefined, -): Effect.Effect => - makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk }); +/** One service per account: each account carries its own auth, so a shared + * client can never leak one account's credential into another's calls. */ +const serviceForAccount = + (timeoutMs: number, preferSdk: boolean | undefined) => + (account: OnePasswordAccount): Effect.Effect => + makeOnePasswordService(resolveAuth(account.auth), { timeoutMs, preferSdk }); // --------------------------------------------------------------------------- // Explicit ref resolution. // // A ref is one of: -// - `op://vault/item/field...` — fully qualified, resolved as-is. +// - `op://vault/item/field...` — fully qualified, resolved as-is via the +// account that owns the vault. The first +// segment stays a VAULT (id or name), +// matching 1Password's own op:// semantics — +// account selection is derived, never +// encoded into the ref. // - `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. +// - a bare item id or title — located by listing every account's +// configured vaults. Exactly one match +// resolves; several matches are an explicit +// ambiguity failure naming the vaults — never +// a precedence pick. // --------------------------------------------------------------------------- export type RefResolution = @@ -200,11 +223,20 @@ export type RefResolution = | { readonly kind: "ambiguous"; readonly matches: readonly { + readonly accountName: string; readonly vaultId: string; readonly vaultName: string; readonly itemId: string; readonly itemTitle: string; }[]; + } + | { + readonly kind: "ambiguous-vault"; + readonly vaultName: string; + readonly matches: readonly { + readonly accountName: string; + readonly vaultId: string; + }[]; }; export const ambiguityMessage = ( @@ -213,18 +245,28 @@ export const ambiguityMessage = ( ): string => [ `1Password ref "${ref}" is ambiguous: it matches`, - matches.map((m) => `"${m.itemTitle}" in vault "${m.vaultName}"`).join(", "), + matches + .map((m) => `"${m.itemTitle}" in vault "${m.vaultName}" (account "${m.accountName}")`) + .join(", "), `. Use op:/// to pick one.`, ].join(" "); -const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean => - config.vaults.some((vault) => vault.id === segment || vault.name === segment); +export const vaultAmbiguityMessage = ( + resolution: Extract, +): string => + [ + `1Password vault name "${resolution.vaultName}" is configured in more than one account:`, + resolution.matches.map((m) => `"${m.accountName}" (vault id ${m.vaultId})`).join(", "), + `. Use op:///... to pick one.`, + ].join(" "); -/** Resolve a ref against the configured vaults. Backend failures stay on the - * error channel; every addressing outcome is an explicit `RefResolution`. */ +/** Resolve a ref against every configured account. Backend failures stay on + * the error channel; every addressing outcome is an explicit + * `RefResolution`. Services are built per account and only for the accounts + * a ref actually needs. */ export const resolveConfiguredRef = ( - svc: OnePasswordService, config: OnePasswordConfig, + serviceFor: (account: OnePasswordAccount) => Effect.Effect, ref: string, ): Effect.Effect => { if (ref.startsWith("op://")) { @@ -233,35 +275,83 @@ export const resolveConfiguredRef = ( if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { return Effect.succeed({ kind: "not-found" }); } - if (!isConfiguredVaultSegment(config, vaultSegment)) { - return Effect.succeed({ kind: "outside-vaults" }); - } const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; - return svc - .resolveSecret(uri) - .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value }))); + + // Vault ids are globally unique, so an id-addressed ref names one vault no + // matter how many accounts configure it — any owning account can serve it. + const idOwner = config.accounts.find((account) => + account.vaults.some((vault) => vault.id === vaultSegment), + ); + if (idOwner !== undefined) { + return serviceFor(idOwner).pipe( + Effect.flatMap((svc) => svc.resolveSecret(uri)), + Effect.map((value): RefResolution => ({ kind: "resolved", value })), + ); + } + + // Vault names are only unique per account: the same name in two accounts + // is two different vaults, so a name-addressed ref must be an explicit + // ambiguity, never a precedence pick. + const nameOwners = config.accounts.flatMap((account) => + account.vaults + .filter((vault) => vault.name === vaultSegment) + .map((vault) => ({ account, vault })), + ); + const [onlyOwner, ...extraOwners] = nameOwners; + if (onlyOwner === undefined) return Effect.succeed({ kind: "outside-vaults" }); + if (extraOwners.length > 0) { + return Effect.succeed({ + kind: "ambiguous-vault", + vaultName: vaultSegment, + matches: nameOwners.map((owner) => ({ + accountName: owner.account.name, + vaultId: owner.vault.id, + })), + }); + } + return serviceFor(onlyOwner.account).pipe( + Effect.flatMap((svc) => svc.resolveSecret(uri)), + Effect.map((value): RefResolution => ({ kind: "resolved", value })), + ); } return Effect.gen(function* () { - const matches = (yield* Effect.forEach(config.vaults, (vault) => - svc.listItems(vault.id).pipe( - Effect.map((items) => - items - .filter((item) => item.id === ref || item.title === ref) - .map((item) => ({ - vaultId: vault.id, - vaultName: vault.name, - itemId: item.id, - itemTitle: item.title, - })), + const matches = (yield* Effect.forEach(config.accounts, (account) => + serviceFor(account).pipe( + Effect.flatMap((svc) => + Effect.forEach(account.vaults, (vault) => + svc.listItems(vault.id).pipe( + Effect.map((items) => + items + .filter((item) => item.id === ref || item.title === ref) + .map((item) => ({ + accountId: account.id, + accountName: account.name, + vaultId: vault.id, + vaultName: vault.name, + itemId: item.id, + itemTitle: item.title, + })), + ), + ), + ), ), + Effect.map((groups) => groups.flat()), ), )).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(({ accountId: _accountId, ...match }) => match), + } as const; + } + const owner = config.accounts.find((account) => account.id === only.accountId); + if (owner === undefined) return { kind: "not-found" } as const; + const svc = yield* serviceFor(owner); const value = yield* svc.resolveSecret( `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, ); @@ -274,78 +364,93 @@ export const resolveConfiguredRef = ( // // 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). +// id/title that must locate exactly one item across every account's +// configured vaults. The plugin's stored config supplies the auth + vault +// bindings; the provider never writes (writable: false). // --------------------------------------------------------------------------- const makeProvider = ( ctx: PluginCtx, timeoutMs: number, preferSdk: boolean | undefined, -): CredentialProvider => ({ - key: PROVIDER_KEY, - 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. - Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), - Effect.flatMap( - (resolution): Effect.Effect => - resolution.kind === "ambiguous" - ? Effect.fail( - new StorageError({ - message: ambiguityMessage(id, resolution.matches), - cause: undefined, - }), - ) - : Effect.succeed(resolution.kind === "resolved" ? resolution.value : null), - ), - ); - }), - ), +): CredentialProvider => { + const serviceFor = serviceForAccount(timeoutMs, preferSdk); + return { + key: PROVIDER_KEY, + 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 resolveConfiguredRef(config, serviceFor, id).pipe( + // 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. + Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), + Effect.flatMap((resolution): Effect.Effect => { + if (resolution.kind === "ambiguous") { + return Effect.fail( + new StorageError({ + message: ambiguityMessage(id, resolution.matches), + cause: undefined, + }), + ); + } + if (resolution.kind === "ambiguous-vault") { + return Effect.fail( + new StorageError({ + message: vaultAmbiguityMessage(resolution), + cause: undefined, + }), + ); + } + return Effect.succeed(resolution.kind === "resolved" ? resolution.value : null); + }), + ); + }), + ), - 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( - 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}`), - name: item.title, - group: vault.name, - }), + list: (): Effect.Effect => + ctx.storage.getConfig().pipe( + Effect.flatMap((config) => { + if (!config) return Effect.succeed([] as readonly ProviderEntry[]); + const multipleAccounts = config.accounts.length > 1; + return Effect.forEach(config.accounts, (account) => + serviceFor(account).pipe( + Effect.flatMap((svc) => + Effect.forEach(account.vaults, (vault) => + svc.listItems(vault.id).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}`), + name: item.title, + group: multipleAccounts ? `${account.name} · ${vault.name}` : vault.name, + }), + ), + ), ), ), ), + Effect.map((groups) => groups.flat()), + // One unreachable account must not hide the others' items. + Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), ), - ), - Effect.map((groups): readonly ProviderEntry[] => groups.flat()), - ); - }), - Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), - ), -}); + ).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 @@ -361,8 +466,73 @@ const makeOnePasswordExtension = ( timeoutMs: number, preferSdk: boolean | undefined, ) => { + const serviceFor = serviceForAccount(timeoutMs, preferSdk); + + const accountStatus = (account: OnePasswordAccount): Effect.Effect => + serviceFor(account).pipe( + Effect.flatMap((svc) => svc.listVaults()), + Effect.map((live) => { + const liveById = new Map(live.map((v) => [v.id, v.title])); + const missing = account.vaults.filter((vault) => !liveById.has(vault.id)); + return AccountStatus.make({ + id: account.id, + name: account.name, + connected: true, + vaultNames: account.vaults.map((vault) => liveById.get(vault.id) ?? vault.name), + ...(missing.length > 0 + ? { + error: `Configured vaults not found: ${missing + .map((vault) => vault.name) + .join(", ")}`, + } + : {}), + }); + }), + Effect.catchTag("OnePasswordError", (error) => + Effect.succeed( + AccountStatus.make({ + id: account.id, + name: account.name, + connected: false, + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message` + error: error.message, + }), + ), + ), + ); + return { - configure: (config: OnePasswordConfig) => ctx.storage.saveConfig(config, ownerForCtx(ctx)), + /** Add or replace one account. A payload without an id creates a new + * account; with an id it replaces the matching account in place. */ + configure: ( + upsert: OnePasswordAccountUpsert, + ): Effect.Effect<{ readonly accountId: string }, StorageError | OnePasswordError> => + Effect.gen(function* () { + const existing = yield* ctx.storage.getConfig().pipe( + // An undecodable blob must not brick configuration: the next save + // rewrites the whole config in the current shape. + Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), + ); + const accountId = upsert.id ?? crypto.randomUUID(); + const account = OnePasswordAccount.make({ + id: accountId, + name: upsert.name, + auth: upsert.auth, + vaults: upsert.vaults, + }); + // Position-preserving upsert: an edit replaces the account in place, + // a new account appends — the settings list never reshuffles. + const current = existing === null ? [] : existing.accounts; + const accounts = current.some((candidate) => candidate.id === accountId) + ? current.map((candidate) => (candidate.id === accountId ? account : candidate)) + : [...current, account]; + const [first, ...rest] = accounts; + yield* ctx.storage.saveConfig( + { accounts: first === undefined ? [account] : [first, ...rest] }, + ownerForCtx(ctx), + ); + return { accountId }; + }), getConfig: (): Effect.Effect< RedactedOnePasswordConfig | null, @@ -370,7 +540,22 @@ const makeOnePasswordExtension = ( > => ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))), - removeConfig: () => ctx.storage.deleteConfig(ownerForCtx(ctx)), + /** Remove one account by id, or the whole configuration when no id is + * given. Removing the last account deletes the blob. */ + removeConfig: (accountId?: string): Effect.Effect => { + if (accountId === undefined) return ctx.storage.deleteConfig(ownerForCtx(ctx)); + return Effect.gen(function* () { + const existing = yield* ctx.storage.getConfig(); + if (existing === null) return; + const remaining = existing.accounts.filter((account) => account.id !== accountId); + const [first, ...rest] = remaining; + if (first === undefined) { + yield* ctx.storage.deleteConfig(ownerForCtx(ctx)); + return; + } + yield* ctx.storage.saveConfig({ accounts: [first, ...rest] }, ownerForCtx(ctx)); + }); + }, status: () => Effect.gen(function* () { @@ -378,21 +563,18 @@ const makeOnePasswordExtension = ( if (!config) { return ConnectionStatus.make({ connected: false, + accounts: [], 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 accounts = yield* Effect.forEach(config.accounts, accountStatus); + const broken = accounts.filter((account) => !account.connected); return ConnectionStatus.make({ - connected: true, - vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name), - ...(missing.length > 0 + connected: broken.length === 0, + accounts, + ...(broken.length > 0 ? { - error: `Configured vaults not found: ${missing - .map((vault) => vault.name) - .join(", ")}`, + error: `Unreachable accounts: ${broken.map((account) => account.name).join(", ")}`, } : {}), }); @@ -419,8 +601,7 @@ const makeOnePasswordExtension = ( message: "1Password is not configured", }); } - const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); - const resolution = yield* resolveConfiguredRef(svc, config, uri); + const resolution = yield* resolveConfiguredRef(config, serviceFor, uri); if (resolution.kind === "resolved") return resolution.value; if (resolution.kind === "outside-vaults") { return yield* new OnePasswordError({ @@ -434,6 +615,12 @@ const makeOnePasswordExtension = ( message: ambiguityMessage(uri, resolution.matches), }); } + if (resolution.kind === "ambiguous-vault") { + return yield* new OnePasswordError({ + operation: "resolve", + message: vaultAmbiguityMessage(resolution), + }); + } return yield* new OnePasswordError({ operation: "resolve", message: `1Password item "${uri}" was not found in the configured vaults`, @@ -475,7 +662,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption tool({ name: "status", description: - "Check whether the 1Password credential provider is configured and can reach its selected vaults. This returns status only, never secret values.", + "Check whether the 1Password credential provider is configured and can reach each account's selected vaults. This returns status only, never secret values.", outputSchema: OnePasswordStatusOutputStd, execute: () => Effect.map(self.status(), ToolResult.ok), }), @@ -498,29 +685,36 @@ 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 account for the acting owner, each scoping 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. Pass the account id to update an existing account; omit it to add a new one.", annotations: { requiresApproval: true, - approvalDescription: "Configure the 1Password credential provider", + approvalDescription: "Configure a 1Password credential provider account", }, inputSchema: OnePasswordConfigureInputStd, outputSchema: OnePasswordConfigureOutputStd, execute: (input) => - Effect.as( - self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }), - ToolResult.ok({ configured: true }), + Effect.map( + self.configure({ + ...(input.id !== undefined ? { id: input.id } : {}), + name: input.name, + auth: input.auth, + vaults: input.vaults, + }), + ({ accountId }) => ToolResult.ok({ configured: true, accountId }), ), }), tool({ name: "removeConfig", description: - "Remove the 1Password provider configuration for the acting owner. Future 1Password secret resolution stops until reconfigured.", + "Remove one 1Password account by id, or the whole provider configuration when no id is given. Secret resolution through the removed account stops until reconfigured.", annotations: { requiresApproval: true, - approvalDescription: "Remove the 1Password credential provider configuration", + approvalDescription: "Remove 1Password credential provider configuration", }, + inputSchema: OnePasswordRemoveConfigInputStd, outputSchema: OnePasswordRemoveConfigOutputStd, - execute: () => Effect.as(self.removeConfig(), ToolResult.ok({ removed: true })), + execute: (input) => + Effect.as(self.removeConfig(input.accountId), ToolResult.ok({ removed: true })), }), ], }, diff --git a/packages/plugins/onepassword/src/sdk/types.ts b/packages/plugins/onepassword/src/sdk/types.ts index 82f70269ed..28ea35f153 100644 --- a/packages/plugins/onepassword/src/sdk/types.ts +++ b/packages/plugins/onepassword/src/sdk/types.ts @@ -35,23 +35,50 @@ export const Vault = Schema.Struct({ export type Vault = typeof Vault.Type; // --------------------------------------------------------------------------- -// Stored config — persisted via KV +// Account — one named auth binding plus the vaults it scopes to. An owner can +// hold several (a work account and a personal one, or a service-account token +// next to desktop-app biometrics), each addressed by a stable generated id. // --------------------------------------------------------------------------- -export const OnePasswordConfig = Schema.Struct({ +export const OnePasswordAccount = Schema.Struct({ + /** Stable identifier, generated when the account is first saved. Refs never + * embed it — `op://` addressing stays vault-first — so renaming or + * re-authing an account never invalidates a stored ref. */ + id: Schema.String, + /** Human label for this account, e.g. "Work" */ + name: Schema.String, 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 OnePasswordAccount = typeof OnePasswordAccount.Type; + +/** The account id every pre-multi-account config normalizes onto. */ +export const DEFAULT_ACCOUNT_ID = "default"; + +// --------------------------------------------------------------------------- +// Stored config — persisted via KV +// --------------------------------------------------------------------------- + +export const OnePasswordConfig = Schema.Struct({ + accounts: Schema.NonEmptyArray(OnePasswordAccount), }); export type OnePasswordConfig = typeof OnePasswordConfig.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. */ +/** Single-account stored shape (multi-vault, pre-multi-account). Still + * accepted on read; every save writes the current shape, so a config row + * upgrades the first time it is re-saved. */ +export const SingleAccountOnePasswordConfig = Schema.Struct({ + auth: OnePasswordAuth, + vaults: Schema.NonEmptyArray(Vault), + name: Schema.String, +}); +export type SingleAccountOnePasswordConfig = typeof SingleAccountOnePasswordConfig.Type; + +/** Original stored shape: a single vault id whose display name doubled as the + * connection label. */ export const LegacyOnePasswordConfig = Schema.Struct({ auth: OnePasswordAuth, vaultId: Schema.String, @@ -59,17 +86,33 @@ export const LegacyOnePasswordConfig = Schema.Struct({ }); export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type; -export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]); +export const StoredOnePasswordConfig = Schema.Union([ + OnePasswordConfig, + SingleAccountOnePasswordConfig, + LegacyOnePasswordConfig, +]); 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 }], - name: stored.name, - } - : stored; +export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => { + if ("accounts" in stored) return stored; + if ("vaultId" in stored) { + return { + accounts: [ + { + id: DEFAULT_ACCOUNT_ID, + name: stored.name, + auth: stored.auth, + vaults: [{ id: stored.vaultId, name: stored.name }], + }, + ], + }; + } + return { + accounts: [ + { id: DEFAULT_ACCOUNT_ID, name: stored.name, auth: stored.auth, vaults: stored.vaults }, + ], + }; +}; // --------------------------------------------------------------------------- // Redacted config — what `getConfig` returns to agents / the UI. The @@ -88,30 +131,55 @@ export const RedactedOnePasswordAuth = Schema.Union([ RedactedServiceAccountAuth, ]); -export const RedactedOnePasswordConfig = Schema.Struct({ +export const RedactedOnePasswordAccount = Schema.Struct({ + id: Schema.String, + name: Schema.String, auth: RedactedOnePasswordAuth, vaults: Schema.NonEmptyArray(Vault), - name: Schema.String, +}); +export type RedactedOnePasswordAccount = typeof RedactedOnePasswordAccount.Type; + +export const RedactedOnePasswordConfig = Schema.Struct({ + accounts: Schema.NonEmptyArray(RedactedOnePasswordAccount), }); export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; -/** Strip the service-account token from a stored config for external exposure. */ -export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => ({ - auth: - config.auth.kind === "desktop-app" - ? { kind: "desktop-app", accountName: config.auth.accountName } - : { kind: "service-account" }, - vaults: config.vaults, - name: config.name, +const redactAuth = (auth: OnePasswordAuth): typeof RedactedOnePasswordAuth.Type => + auth.kind === "desktop-app" + ? { kind: "desktop-app", accountName: auth.accountName } + : { kind: "service-account" }; + +export const redactAccount = (account: OnePasswordAccount): RedactedOnePasswordAccount => ({ + id: account.id, + name: account.name, + auth: redactAuth(account.auth), + vaults: account.vaults, }); +/** Strip the service-account tokens from a stored config for external exposure. */ +export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => { + const [first, ...rest] = config.accounts; + return { accounts: [redactAccount(first), ...rest.map(redactAccount)] }; +}; + // --------------------------------------------------------------------------- -// Connection status +// Connection status — reported per account so one unreachable account never +// masks (or fakes) the health of another. // --------------------------------------------------------------------------- -export const ConnectionStatus = Schema.Struct({ +export const AccountStatus = Schema.Struct({ + id: Schema.String, + name: Schema.String, connected: Schema.Boolean, vaultNames: Schema.optional(Schema.Array(Schema.String)), error: Schema.optional(Schema.String), }); +export type AccountStatus = typeof AccountStatus.Type; + +export const ConnectionStatus = Schema.Struct({ + /** True only when configured and every account is reachable. */ + connected: Schema.Boolean, + accounts: Schema.Array(AccountStatus), + error: Schema.optional(Schema.String), +}); export type ConnectionStatus = typeof ConnectionStatus.Type;