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;