History

1Password: multiple named accounts

Rhys Sullivan committed 2 months ago

Commit b7c8cfe1 parent 10e16a58 files · +861 −337Browse files

Unified · Split

8 files
  1. .changeset/onepassword-multiple-accounts.md +5 −0
  2. packages/plugins/onepassword/src/api/group.ts +16 −11
  3. packages/plugins/onepassword/src/api/handlers.ts +3 −3
  4. packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +119 −77
  5. packages/plugins/onepassword/src/sdk/index.ts +8 −0
  6. packages/plugins/onepassword/src/sdk/plugin.test.ts +284 −82
  7. packages/plugins/onepassword/src/sdk/plugin.ts +330 −136
  8. packages/plugins/onepassword/src/sdk/types.ts +96 −28
.changeset/onepassword-multiple-accounts.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+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.
packages/plugins/onepassword/src/api/group.ts+16−11
@@ -3,24 +3,28 @@ import { Schema } from "effect"; · 2 unmodified lines
33 import { InternalError } from "@executor-js/sdk/shared";
44
55 import { OnePasswordError } from "../sdk/errors";
6−import {
7− OnePasswordConfig,
8− RedactedOnePasswordConfig,
9− Vault,
10− ConnectionStatus,
11−} from "../sdk/types";
6+import { OnePasswordAccountUpsert } from "../sdk/plugin";
7+import { RedactedOnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";
128
139 // ---------------------------------------------------------------------------
1410 // Payloads
1511 //
1612 // v2: config is a single per-owner binding the extension derives from the
1713 // executor's owner binding — there are no scope segments in the path. The
18−// configure payload carries the full config (including the service-account
19−// token); reads return the redacted projection so the token never leaves the
20−// plugin.
14+// configure payload carries one account upsert (including the
15+// service-account token); reads return the redacted projection so the token
16+// never leaves the plugin.
2117 // ---------------------------------------------------------------------------
2218
23−const ConfigurePayload = OnePasswordConfig;
19+const ConfigurePayload = OnePasswordAccountUpsert;
20+
21+const ConfigureResponse = Schema.Struct({
22+ accountId: Schema.String,
23+});
24+
25+const RemoveConfigParams = Schema.Struct({
26+ accountId: Schema.optional(Schema.String),
27+});
2428
2529 const ListVaultsParams = Schema.Struct({
2630 authKind: Schema.Literals(["desktop-app", "service-account"]),
@@ -60,12 +64,13 @@ export const OnePasswordGroup = HttpApiGroup.make("onepassword") · 33 unmodified lines
6064 .add(
6165 HttpApiEndpoint.put("configure", "/onepassword/config", {
6266 payload: ConfigurePayload,
63− success: Schema.Void,
67+ success: ConfigureResponse,
6468 error: [InternalError, OnePasswordError],
6569 }),
6670 )
6771 .add(
6872 HttpApiEndpoint.delete("removeConfig", "/onepassword/config", {
73+ query: RemoveConfigParams,
6974 success: Schema.Void,
7075 error: [InternalError, OnePasswordError],
7176 }),
packages/plugins/onepassword/src/api/handlers.ts+3−3
@@ -54,15 +54,15 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 53 unmodified lines
5454 capture(
5555 Effect.gen(function* () {
5656 const ext = yield* OnePasswordExtensionService;
57− yield* ext.configure(payload);
57+ return yield* ext.configure(payload);
5858 }),
5959 ),
6060 )
61− .handle("removeConfig", () =>
61+ .handle("removeConfig", ({ query }) =>
6262 capture(
6363 Effect.gen(function* () {
6464 const ext = yield* OnePasswordExtensionService;
65− yield* ext.removeConfig();
65+ yield* ext.removeConfig(query.accountId);
6666 }),
6767 ),
6868 )
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+119−77
@@ -36,7 +36,7 @@ import { · 35 unmodified lines
3636 removeOnePasswordConfig,
3737 onepasswordWriteKeys,
3838 } from "./atoms";
39−import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";
39+import type { RedactedOnePasswordAccount, RedactedOnePasswordConfig, Vault } from "../sdk/types";
4040
4141 // ---------------------------------------------------------------------------
4242 // Vault picker — multi-select
@@ -170,13 +170,14 @@ function VaultPicker(props: { · 127 unmodified lines
170170 }
171171
172172 // ---------------------------------------------------------------------------
173−// Config dialog
173+// Account dialog — add a new account, or edit one by id
174174 // ---------------------------------------------------------------------------
175175
176−function ConfigDialog(props: {
176+function AccountDialog(props: {
177177 open: boolean;
178178 onOpenChange: (v: boolean) => void;
179179 initial?: {
180+ id: string;
180181 authKind: string;
181182 accountName: string;
182183 vaults: ReadonlyArray<Vault>;
@@ -221,6 +222,7 @@ function ConfigDialog(props: { · 38 unmodified lines
221222
222223 const exit = await doConfigure({
223224 payload: {
225+ ...(props.initial ? { id: props.initial.id } : {}),
224226 auth,
225227 vaults: [firstVault, ...restVaults],
226228 name: displayName.trim() || "1Password",
@@ -248,11 +250,11 @@ function ConfigDialog(props: { · 21 unmodified lines
248250 <DialogContent className="sm:max-w-[420px]">
249251 <DialogHeader>
250252 <DialogTitle className="font-display text-xl">
251− {isEdit ? "Edit 1Password" : "Connect 1Password"}
253+ {isEdit ? "Edit 1Password account" : "Add 1Password account"}
252254 </DialogTitle>
253255 <DialogDescription className="text-[13px] leading-relaxed">
254256 Link one or more vaults to resolve secrets via the 1Password desktop app or a service
255− account.
257+ account. Add more accounts to keep work and personal credentials separate.
256258 </DialogDescription>
257259 </DialogHeader>
258260
@@ -310,10 +312,10 @@ function ConfigDialog(props: { · 51 unmodified lines
310312 {/* Display name */}
311313 <div className="grid gap-1.5">
312314 <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
313− Display name
315+ Name
314316 </Label>
315317 <Input
316− placeholder="1Password"
318+ placeholder="Work"
317319 value={displayName}
318320 onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
319321 className="text-[13px] h-9"
@@ -347,18 +349,65 @@ function ConfigDialog(props: { · 27 unmodified lines
347349 }
348350
349351 // ---------------------------------------------------------------------------
350−// Settings card
352+// Settings entries — one card per account, plus the add action
351353 // ---------------------------------------------------------------------------
352354
353−export default function OnePasswordSettings() {
354− const [configOpen, setConfigOpen] = useState(false);
355− const configResult = useAtomValue(onepasswordConfigAtom);
355+function AccountEntry(props: { account: RedactedOnePasswordAccount; onEdit: () => void }) {
356356 const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" });
357+ const [removing, setRemoving] = useState(false);
357358
358359 const handleRemove = async () => {
359− await doRemove({ reactivityKeys: onepasswordWriteKeys });
360+ setRemoving(true);
361+ await doRemove({
362+ query: { accountId: props.account.id },
363+ reactivityKeys: onepasswordWriteKeys,
364+ });
365+ setRemoving(false);
360366 };
361367
368+ return (
369+ <CardStackEntry>
370+ <CardStackEntryContent>
371+ <div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]">
372+ <span className="text-muted-foreground/60">Name</span>
373+ <span className="text-foreground/80 truncate">{props.account.name}</span>
374+ <span className="text-muted-foreground/60">Auth</span>
375+ <span className="font-mono text-foreground/80 truncate">
376+ {props.account.auth.kind === "desktop-app"
377+ ? props.account.auth.accountName
378+ : "service-account"}
379+ </span>
380+ <span className="text-muted-foreground/60">
381+ {props.account.vaults.length === 1 ? "Vault" : "Vaults"}
382+ </span>
383+ <span className="text-foreground/80 truncate">
384+ {props.account.vaults.map((vault) => vault.name).join(", ")}
385+ </span>
386+ </div>
387+ </CardStackEntryContent>
388+ <CardStackEntryActions>
389+ <Button variant="ghost" size="sm" className="h-7 px-2.5 text-[12px]" onClick={props.onEdit}>
390+ Edit
391+ </Button>
392+ <Button
393+ variant="ghost"
394+ size="sm"
395+ className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive"
396+ disabled={removing}
397+ onClick={handleRemove}
398+ >
399+ Disconnect
400+ </Button>
401+ </CardStackEntryActions>
402+ </CardStackEntry>
403+ );
404+}
405+
406+export default function OnePasswordSettings() {
407+ // null = closed; "new" = add flow; otherwise the account being edited.
408+ const [dialogTarget, setDialogTarget] = useState<RedactedOnePasswordAccount | "new" | null>(null);
409+ const configResult = useAtomValue(onepasswordConfigAtom);
410+
362411 const config: RedactedOnePasswordConfig | null = AsyncResult.match(
363412 configResult as AsyncResult.AsyncResult<RedactedOnePasswordConfig | null, unknown>,
364413 {
@@ -384,88 +433,81 @@ export default function OnePasswordSettings() { · 19 unmodified lines
384433 },
385434 );
386435
436+ const accounts = config?.accounts ?? [];
437+
387438 return (
388439 <>
389− <CardStackEntry>
390− <CardStackEntryContent>
391− {isLoading ? (
392− <CardStackEntryDescription>Loading…</CardStackEntryDescription>
393− ) : isError ? (
394− <CardStackEntryDescription className="text-destructive">
395− Failed to load configuration
396− </CardStackEntryDescription>
397− ) : config ? (
398− <div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]">
399− <span className="text-muted-foreground/60">Auth</span>
400− <span className="font-mono text-foreground/80 truncate">
401− {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
402− </span>
403− <span className="text-muted-foreground/60">
404− {config.vaults.length === 1 ? "Vault" : "Vaults"}
405− </span>
406− <div className="flex items-center gap-2 min-w-0">
407− <span className="text-foreground/80 truncate">
408− {config.vaults.map((vault) => vault.name).join(", ")}
409− </span>
410− </div>
411− </div>
412− ) : (
413− <CardStackEntryDescription>
414− Resolve secrets from your 1Password vaults.
415− </CardStackEntryDescription>
416− )}
417− </CardStackEntryContent>
418− <CardStackEntryActions>
419− {config ? (
420− <>
440+ {isLoading || isError || accounts.length === 0 ? (
441+ <CardStackEntry>
442+ <CardStackEntryContent>
443+ {isLoading ? (
444+ <CardStackEntryDescription>Loading…</CardStackEntryDescription>
445+ ) : isError ? (
446+ <CardStackEntryDescription className="text-destructive">
447+ Failed to load configuration
448+ </CardStackEntryDescription>
449+ ) : (
450+ <CardStackEntryDescription>
451+ Resolve secrets from your 1Password vaults.
452+ </CardStackEntryDescription>
453+ )}
454+ </CardStackEntryContent>
455+ <CardStackEntryActions>
456+ {!isLoading && !isError && (
421457 <Button
422− variant="ghost"
423− size="sm"
424− className="h-7 px-2.5 text-[12px]"
425− onClick={() => setConfigOpen(true)}
426− >
427− Edit
428− </Button>
429− <Button
430− variant="ghost"
458+ variant="link"
431459 size="sm"
432− className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive"
433− onClick={handleRemove}
460+ className="h-7 px-0 text-[12px] shrink-0"
461+ onClick={() => setDialogTarget("new")}
434462 >
435− Disconnect
463+ Add 1Password
436464 </Button>
437− </>
438− ) : (
439− !isLoading &&
440− !isError && (
465+ )}
466+ </CardStackEntryActions>
467+ </CardStackEntry>
468+ ) : (
469+ <>
470+ {accounts.map((account) => (
471+ <AccountEntry
472+ key={account.id}
473+ account={account}
474+ onEdit={() => setDialogTarget(account)}
475+ />
476+ ))}
477+ <CardStackEntry>
478+ <CardStackEntryContent>
441479 <Button
442480 variant="link"
443481 size="sm"
444482 className="h-7 px-0 text-[12px] shrink-0"
445− onClick={() => setConfigOpen(true)}
483+ onClick={() => setDialogTarget("new")}
446484 >
447− Add 1Password
485+ + Add 1Password account
448486 </Button>
449− )
450− )}
451− </CardStackEntryActions>
452− </CardStackEntry>
487+ </CardStackEntryContent>
488+ </CardStackEntry>
489+ </>
490+ )}
453491
454− {configOpen && (
455− <ConfigDialog
456− open={configOpen}
457− onOpenChange={setConfigOpen}
492+ {dialogTarget !== null && (
493+ <AccountDialog
494+ open={dialogTarget !== null}
495+ onOpenChange={(v) => {
496+ if (!v) setDialogTarget(null);
497+ }}
458498 initial={
459− config
460− ? {
461− authKind: config.auth.kind,
499+ dialogTarget === "new"
500+ ? undefined
501+ : {
502+ id: dialogTarget.id,
503+ authKind: dialogTarget.auth.kind,
462504 // Service-account tokens are never surfaced (redacted); the
463505 // user re-enters the token when editing that auth method.
464− accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",
465− vaults: config.vaults,
466− name: config.name,
506+ accountName:
507+ dialogTarget.auth.kind === "desktop-app" ? dialogTarget.auth.accountName : "",
508+ vaults: dialogTarget.vaults,
509+ name: dialogTarget.name,
467510 }
468− : undefined
469511 }
470512 />
471513 )}
packages/plugins/onepassword/src/sdk/index.ts+8−0
@@ -3,20 +3,28 @@ export { · 2 unmodified lines
33 makeOnePasswordStore,
44 resolveConfiguredRef,
55 ambiguityMessage,
6+ vaultAmbiguityMessage,
7+ OnePasswordAccountUpsert,
68 type RefResolution,
79 type OnePasswordExtension,
810 type OnePasswordPluginOptions,
911 type OnePasswordStore,
1012 } from "./plugin";
1113 export {
14+ OnePasswordAccount,
1215 OnePasswordConfig,
16+ SingleAccountOnePasswordConfig,
1317 LegacyOnePasswordConfig,
1418 StoredOnePasswordConfig,
19+ DEFAULT_ACCOUNT_ID,
1520 normalizeStoredConfig,
21+ RedactedOnePasswordAccount,
1622 RedactedOnePasswordConfig,
1723 RedactedOnePasswordAuth,
24+ redactAccount,
1825 redactConfig,
1926 Vault,
27+ AccountStatus,
2028 ConnectionStatus,
2129 OnePasswordAuth,
2230 DesktopAppAuth,
packages/plugins/onepassword/src/sdk/plugin.test.ts+284−82
@@ -8,7 +8,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 7 unmodified lines
88 import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin";
99 import type { OnePasswordService } from "./service";
1010 import { OnePasswordError } from "./errors";
11−import { OnePasswordConfig, DesktopAppAuth } from "./types";
11+import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types";
1212
1313 // removed: v1 routed configure/removeConfig through an explicit `ScopeId`
1414 // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and
@@ -19,16 +19,38 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 4 unmodified lines
1919
2020 const ONEPASSWORD = ProviderKey.make("onepassword");
2121
22−const twoVaultConfig = OnePasswordConfig.make({
23− auth: DesktopAppAuth.make({
24− kind: "desktop-app",
25− accountName: "my.1password.com",
26− }),
22+const desktopAuth = DesktopAppAuth.make({
23+ kind: "desktop-app",
24+ accountName: "my.1password.com",
25+});
26+
27+const twoVaultAccount = OnePasswordAccount.make({
28+ id: "acct-default",
29+ name: "1Password",
30+ auth: desktopAuth,
2731 vaults: [
2832 { id: "vault-123", name: "Personal" },
2933 { id: "vault-456", name: "Work" },
3034 ],
31− name: "1Password",
35+});
36+
37+const oneAccountConfig = OnePasswordConfig.make({ accounts: [twoVaultAccount] });
38+
39+const twoAccountConfig = OnePasswordConfig.make({
40+ accounts: [
41+ OnePasswordAccount.make({
42+ id: "acct-work",
43+ name: "Work",
44+ auth: desktopAuth,
45+ vaults: [{ id: "vault-eng", name: "Engineering" }],
46+ }),
47+ OnePasswordAccount.make({
48+ id: "acct-personal",
49+ name: "Personal",
50+ auth: DesktopAppAuth.make({ kind: "desktop-app", accountName: "family.1password.com" }),
51+ vaults: [{ id: "vault-home", name: "Home" }],
52+ }),
53+ ],
3254 });
3355
3456 describe("onepassword plugin", () => {
@@ -42,7 +64,7 @@ describe("onepassword plugin", () => { · 7 unmodified lines
4264 }),
4365 );
4466
45− it.effect("configure / getConfig / removeConfig round-trip via blob store", () =>
67+ it.effect("configure upserts accounts by id and removeConfig removes them one by one", () =>
4668 Effect.gen(function* () {
4769 const executor = yield* createExecutor(
4870 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
@@ -51,40 +73,92 @@ describe("onepassword plugin", () => { · 2 unmodified lines
5173 const initial = yield* executor.onepassword.getConfig();
5274 expect(initial).toBeNull();
5375
54− yield* executor.onepassword.configure(twoVaultConfig);
76+ const first = yield* executor.onepassword.configure({
77+ name: "Work",
78+ auth: desktopAuth,
79+ vaults: [{ id: "vault-eng", name: "Engineering" }],
80+ });
81+ const second = yield* executor.onepassword.configure({
82+ name: "Personal",
83+ auth: desktopAuth,
84+ vaults: [{ id: "vault-home", name: "Home" }],
85+ });
86+ expect(first.accountId).not.toBe(second.accountId);
5587
5688 const loaded = yield* executor.onepassword.getConfig();
57− expect(loaded?.vaults).toEqual([
58− { id: "vault-123", name: "Personal" },
59− { id: "vault-456", name: "Work" },
60− ]);
61− expect(loaded?.name).toBe("1Password");
62− expect(loaded?.auth.kind).toBe("desktop-app");
63−
64− yield* executor.onepassword.removeConfig();
89+ expect(loaded?.accounts.map((account) => account.name).sort()).toEqual(["Personal", "Work"]);
90+
91+ // Re-saving with an id replaces that account in place.
92+ yield* executor.onepassword.configure({
93+ id: first.accountId,
94+ name: "Work (renamed)",
95+ auth: desktopAuth,
96+ vaults: [
97+ { id: "vault-eng", name: "Engineering" },
98+ { id: "vault-infra", name: "Infra" },
99+ ],
100+ });
101+ const afterEdit = yield* executor.onepassword.getConfig();
102+ expect(afterEdit?.accounts.length).toBe(2);
103+ const edited = afterEdit?.accounts.find((account) => account.id === first.accountId);
104+ expect(edited?.name).toBe("Work (renamed)");
105+ expect(edited?.vaults.length).toBe(2);
106+
107+ // Removing one account keeps the other.
108+ yield* executor.onepassword.removeConfig(first.accountId);
65109 const afterRemove = yield* executor.onepassword.getConfig();
66− expect(afterRemove).toBeNull();
110+ expect(afterRemove?.accounts.map((account) => account.id)).toEqual([second.accountId]);
111+
112+ // Removing the last account deletes the config entirely.
113+ yield* executor.onepassword.removeConfig(second.accountId);
114+ expect(yield* executor.onepassword.getConfig()).toBeNull();
67115 }),
68116 );
69117
70− it.effect("getConfig redacts the service-account token", () =>
118+ it.effect("removeConfig without an id removes everything", () =>
71119 Effect.gen(function* () {
72120 const executor = yield* createExecutor(
73121 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
74122 );
123+ yield* executor.onepassword.configure({
124+ name: "Work",
125+ auth: desktopAuth,
126+ vaults: [{ id: "vault-eng", name: "Engineering" }],
127+ });
128+ yield* executor.onepassword.configure({
129+ name: "Personal",
130+ auth: desktopAuth,
131+ vaults: [{ id: "vault-home", name: "Home" }],
132+ });
133+ yield* executor.onepassword.removeConfig();
134+ expect(yield* executor.onepassword.getConfig()).toBeNull();
135+ }),
136+ );
75137
76− yield* executor.onepassword.configure(
77− OnePasswordConfig.make({
78− auth: { kind: "service-account", token: "super-secret-token" },
79− vaults: [{ id: "vault-123", name: "CI" }],
80− name: "CI",
81− }),
138+ it.effect("getConfig redacts every account's service-account token", () =>
139+ Effect.gen(function* () {
140+ const executor = yield* createExecutor(
141+ makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
82142 );
83143
144+ yield* executor.onepassword.configure({
145+ name: "CI",
146+ auth: { kind: "service-account", token: "super-secret-token" },
147+ vaults: [{ id: "vault-123", name: "CI" }],
148+ });
149+ yield* executor.onepassword.configure({
150+ name: "Deploys",
151+ auth: { kind: "service-account", token: "other-secret-token" },
152+ vaults: [{ id: "vault-456", name: "Deploys" }],
153+ });
154+
84155 const loaded = yield* executor.onepassword.getConfig();
85− expect(loaded?.auth.kind).toBe("service-account");
86− // The token must never be surfaced through the redacted projection.
156+ expect(loaded?.accounts.every((account) => account.auth.kind === "service-account")).toBe(
157+ true,
158+ );
159+ // Tokens must never be surfaced through the redacted projection.
87160 expect(JSON.stringify(loaded)).not.toContain("super-secret-token");
161+ expect(JSON.stringify(loaded)).not.toContain("other-secret-token");
88162 }),
89163 );
90164
@@ -97,35 +171,43 @@ describe("onepassword plugin", () => { · 6 unmodified lines
97171 const configured = yield* executor.execute(
98172 ToolAddress.make("executor.onepassword.configure"),
99173 {
174+ name: "1Password",
100175 auth: { kind: "desktop-app", accountName: "my.1password.com" },
101176 vaults: [
102177 { id: "vault-123", name: "Personal" },
103178 { id: "vault-456", name: "Work" },
104179 ],
105− name: "1Password",
106180 },
107181 { onElicitation: "accept-all" },
108182 );
109183
110− expect(configured).toEqual({ ok: true, data: { configured: true } });
184+ expect(configured).toMatchObject({ ok: true, data: { configured: true } });
185+ const accountId = (configured as { data: { accountId: string } }).data.accountId;
186+ expect(typeof accountId).toBe("string");
187+
111188 expect(
112189 yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}),
113190 ).toMatchObject({
114191 ok: true,
115192 data: {
116193 config: {
117− vaults: [
118− { id: "vault-123", name: "Personal" },
119− { id: "vault-456", name: "Work" },
194+ accounts: [
195+ {
196+ id: accountId,
197+ name: "1Password",
198+ vaults: [
199+ { id: "vault-123", name: "Personal" },
200+ { id: "vault-456", name: "Work" },
201+ ],
202+ },
120203 ],
121− name: "1Password",
122204 },
123205 },
124206 });
125207
126208 const removed = yield* executor.execute(
127209 ToolAddress.make("executor.onepassword.removeConfig"),
128− {},
210+ { accountId },
129211 { onElicitation: "accept-all" },
130212 );
131213
@@ -141,15 +223,17 @@ describe("onepassword plugin", () => { · 9 unmodified lines
141223 );
142224 const status = yield* executor.onepassword.status();
143225 expect(status.connected).toBe(false);
226+ expect(status.accounts).toEqual([]);
144227 expect(status.error).toBe("Not configured");
145228 }),
146229 );
147230 });
148231
149232 // ---------------------------------------------------------------------------
150−// Stored-config compatibility — blobs written before multi-vault support hold
151−// `{ auth, vaultId, name }`. Reads normalize that to a one-element vaults
152−// array; the next save writes the current shape.
233+// Stored-config compatibility — blobs written before multi-account support
234+// hold either `{ auth, vaultId, name }` (original) or `{ auth, vaults, name }`
235+// (multi-vault). Reads normalize both onto a single "default" account; the
236+// next save writes the current shape.
153237 // ---------------------------------------------------------------------------
154238
155239 describe("onepassword store", () => {
@@ -177,27 +261,65 @@ describe("onepassword store", () => { · 21 unmodified lines
177261
178262 const config = yield* store.getConfig();
179263 expect(config).toEqual({
180− auth: { kind: "desktop-app", accountName: "my.1password.com" },
181− vaults: [{ id: "vault-123", name: "Personal" }],
182− name: "Personal",
264+ accounts: [
265+ {
266+ id: "default",
267+ name: "Personal",
268+ auth: { kind: "desktop-app", accountName: "my.1password.com" },
269+ vaults: [{ id: "vault-123", name: "Personal" }],
270+ },
271+ ],
272+ });
273+ }),
274+ );
275+
276+ it.effect("upgrades a single-account multi-vault blob on read", () =>
277+ Effect.gen(function* () {
278+ const { blobs, store } = makeStore();
279+ yield* blobs.put(
280+ "config",
281+ JSON.stringify({
282+ auth: { kind: "desktop-app", accountName: "my.1password.com" },
283+ vaults: [
284+ { id: "vault-123", name: "Personal" },
285+ { id: "vault-456", name: "Work" },
286+ ],
287+ name: "1Password",
288+ }),
289+ { owner: "org" },
290+ );
291+
292+ const config = yield* store.getConfig();
293+ expect(config).toEqual({
294+ accounts: [
295+ {
296+ id: "default",
297+ name: "1Password",
298+ auth: { kind: "desktop-app", accountName: "my.1password.com" },
299+ vaults: [
300+ { id: "vault-123", name: "Personal" },
301+ { id: "vault-456", name: "Work" },
302+ ],
303+ },
304+ ],
183305 });
184306 }),
185307 );
186308
187− it.effect("persists and reads back the multi-vault shape", () =>
309+ it.effect("persists and reads back the multi-account shape", () =>
188310 Effect.gen(function* () {
189311 const { store } = makeStore();
190− yield* store.saveConfig(twoVaultConfig, "org");
312+ yield* store.saveConfig(twoAccountConfig, "org");
191313 const config = yield* store.getConfig();
192− expect(config).toEqual(twoVaultConfig);
314+ expect(config).toEqual(twoAccountConfig);
193315 }),
194316 );
195317 });
196318
197319 // ---------------------------------------------------------------------------
198−// Explicit ref resolution — vault-qualified refs resolve directly; bare refs
199−// must locate exactly one item, and a multi-vault match is an explicit
200−// ambiguity, never a precedence pick.
320+// Explicit ref resolution — vault-qualified refs resolve directly via the
321+// owning account; bare refs must locate exactly one item, and a multi-vault
322+// match is an explicit ambiguity, never a precedence pick.
201323 // ---------------------------------------------------------------------------
202324
203325 const fakeService = (
@@ -217,84 +339,160 @@ const fakeService = ( · 13 unmodified lines
217339 },
218340 });
219341
342+/** One fake backend per account id; secrets resolve tagged with the serving
343+ * account so tests can assert which account's auth handled a ref. */
344+const fakeServiceFor =
345+ (
346+ itemsByAccount: Readonly<
347+ Record<string, Readonly<Record<string, readonly { id: string; title: string }[]>>>
348+ >,
349+ ) =>
350+ (account: OnePasswordAccount) => {
351+ const items = itemsByAccount[account.id] ?? {};
352+ return Effect.succeed<OnePasswordService>({
353+ ...fakeService(items),
354+ resolveSecret: (uri) => Effect.succeed(`secret:${account.id}:${uri}`),
355+ });
356+ };
357+
220358 describe("resolveConfiguredRef", () => {
359+ const serviceFor = fakeServiceFor({});
360+
221361 it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () =>
222362 Effect.gen(function* () {
223− const svc = fakeService({});
224363 const result = yield* resolveConfiguredRef(
225− svc,
226− twoVaultConfig,
364+ oneAccountConfig,
365+ serviceFor,
227366 "op://vault-456/item-abc/password",
228367 );
229368 expect(result).toEqual({
230369 kind: "resolved",
231− value: "secret:op://vault-456/item-abc/password",
370+ value: "secret:acct-default:op://vault-456/item-abc/password",
232371 });
233372 }),
234373 );
235374
236375 it.effect("appends the credential field to a picker-shaped op://vault/item ref", () =>
237376 Effect.gen(function* () {
238− const svc = fakeService({});
239− const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc");
377+ const result = yield* resolveConfiguredRef(
378+ oneAccountConfig,
379+ serviceFor,
380+ "op://vault-123/item-abc",
381+ );
240382 expect(result).toEqual({
241383 kind: "resolved",
242− value: "secret:op://vault-123/item-abc/credential",
384+ value: "secret:acct-default:op://vault-123/item-abc/credential",
243385 });
244386 }),
245387 );
246388
247389 it.effect("accepts an op:// URI addressed by vault name", () =>
248390 Effect.gen(function* () {
249− const svc = fakeService({});
250− const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://Work/item/password");
251− expect(result).toEqual({ kind: "resolved", value: "secret:op://Work/item/password" });
391+ const result = yield* resolveConfiguredRef(
392+ oneAccountConfig,
393+ serviceFor,
394+ "op://Work/item/password",
395+ );
396+ expect(result).toEqual({
397+ kind: "resolved",
398+ value: "secret:acct-default:op://Work/item/password",
399+ });
400+ }),
401+ );
402+
403+ it.effect("routes a vault-qualified ref through the account that owns the vault", () =>
404+ Effect.gen(function* () {
405+ const result = yield* resolveConfiguredRef(
406+ twoAccountConfig,
407+ serviceFor,
408+ "op://vault-home/item-abc/password",
409+ );
410+ expect(result).toEqual({
411+ kind: "resolved",
412+ value: "secret:acct-personal:op://vault-home/item-abc/password",
413+ });
414+ }),
415+ );
416+
417+ it.effect("reports a vault name configured in more than one account as ambiguous", () =>
418+ Effect.gen(function* () {
419+ const config = OnePasswordConfig.make({
420+ accounts: [
421+ OnePasswordAccount.make({
422+ id: "acct-a",
423+ name: "Work",
424+ auth: desktopAuth,
425+ vaults: [{ id: "vault-a", name: "Shared" }],
426+ }),
427+ OnePasswordAccount.make({
428+ id: "acct-b",
429+ name: "Personal",
430+ auth: desktopAuth,
431+ vaults: [{ id: "vault-b", name: "Shared" }],
432+ }),
433+ ],
434+ });
435+ const result = yield* resolveConfiguredRef(config, serviceFor, "op://Shared/item");
436+ expect(result).toEqual({
437+ kind: "ambiguous-vault",
438+ vaultName: "Shared",
439+ matches: [
440+ { accountName: "Work", vaultId: "vault-a" },
441+ { accountName: "Personal", vaultId: "vault-b" },
442+ ],
443+ });
252444 }),
253445 );
254446
255447 it.effect("reports an op:// URI outside the configured vaults", () =>
256448 Effect.gen(function* () {
257− const svc = fakeService({});
258449 const result = yield* resolveConfiguredRef(
259− svc,
260− twoVaultConfig,
450+ oneAccountConfig,
451+ serviceFor,
261452 "op://vault-999/item/password",
262453 );
263454 expect(result).toEqual({ kind: "outside-vaults" });
264455 }),
265456 );
266457
267− it.effect("resolves a bare ref that matches exactly one item across vaults", () =>
458+ it.effect("resolves a bare ref that matches exactly one item across accounts", () =>
268459 Effect.gen(function* () {
269− const svc = fakeService({
270− "vault-123": [{ id: "item-1", title: "GitHub Token" }],
271− "vault-456": [{ id: "item-2", title: "Stripe Key" }],
460+ const withItems = fakeServiceFor({
461+ "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] },
462+ "acct-personal": { "vault-home": [{ id: "item-2", title: "Stripe Key" }] },
272463 });
273− const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");
464+ const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token");
274465 expect(result).toEqual({
275466 kind: "resolved",
276− value: "secret:op://vault-123/item-1/credential",
467+ value: "secret:acct-work:op://vault-eng/item-1/credential",
277468 });
278469 }),
279470 );
280471
281− it.effect("fails a bare ref that matches in two vaults with the vaults named", () =>
472+ it.effect("fails a bare ref that matches in two accounts with the matches named", () =>
282473 Effect.gen(function* () {
283− const svc = fakeService({
284− "vault-123": [{ id: "item-1", title: "GitHub Token" }],
285− "vault-456": [{ id: "item-2", title: "GitHub Token" }],
474+ const withItems = fakeServiceFor({
475+ "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] },
476+ "acct-personal": { "vault-home": [{ id: "item-2", title: "GitHub Token" }] },
286477 });
287− const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");
478+ const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token");
288479 expect(result).toEqual({
289480 kind: "ambiguous",
290481 matches: [
291482 {
292− vaultId: "vault-123",
293− vaultName: "Personal",
483+ accountName: "Work",
484+ vaultId: "vault-eng",
485+ vaultName: "Engineering",
294486 itemId: "item-1",
295487 itemTitle: "GitHub Token",
296488 },
297− { vaultId: "vault-456", vaultName: "Work", itemId: "item-2", itemTitle: "GitHub Token" },
489+ {
490+ accountName: "Personal",
491+ vaultId: "vault-home",
492+ vaultName: "Home",
493+ itemId: "item-2",
494+ itemTitle: "GitHub Token",
495+ },
298496 ],
299497 });
300498 }),
@@ -302,22 +500,26 @@ describe("resolveConfiguredRef", () => { · 1 unmodified line
302500
303501 it.effect("treats duplicate titles inside one vault as ambiguous too", () =>
304502 Effect.gen(function* () {
305− const svc = fakeService({
306− "vault-123": [
307− { id: "item-1", title: "GitHub Token" },
308− { id: "item-9", title: "GitHub Token" },
309− ],
310− "vault-456": [],
503+ const withItems = fakeServiceFor({
504+ "acct-default": {
505+ "vault-123": [
506+ { id: "item-1", title: "GitHub Token" },
507+ { id: "item-9", title: "GitHub Token" },
508+ ],
509+ "vault-456": [],
510+ },
311511 });
312− const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");
512+ const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "GitHub Token");
313513 expect(result.kind).toBe("ambiguous");
314514 }),
315515 );
316516
317517 it.effect("reports not-found for a bare ref matching nothing", () =>
318518 Effect.gen(function* () {
319− const svc = fakeService({ "vault-123": [], "vault-456": [] });
320− const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "missing");
519+ const withItems = fakeServiceFor({
520+ "acct-default": { "vault-123": [], "vault-456": [] },
521+ });
522+ const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "missing");
321523 expect(result).toEqual({ kind: "not-found" });
322524 }),
323525 );
packages/plugins/onepassword/src/sdk/plugin.ts+330−136
@@ -17,12 +17,14 @@ import { · 16 unmodified lines
1717 } from "@executor-js/sdk/core";
1818
1919 import {
20+ OnePasswordAccount,
2021 OnePasswordAuth,
2122 OnePasswordConfig,
2223 RedactedOnePasswordConfig,
2324 StoredOnePasswordConfig,
2425 Vault,
2526 ConnectionStatus,
27+ AccountStatus,
2628 normalizeStoredConfig,
2729 redactConfig,
2830 } from "./types";
@@ -44,10 +46,25 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 15 unmodified lines
4446 I
4547 >;
4648
47−const OnePasswordConfigureInput = OnePasswordConfig;
49+// ---------------------------------------------------------------------------
50+// Upsert payload — `configure` adds or replaces one account. The id is
51+// generated on first save so the caller can address the account later
52+// (edit, remove) without the name doubling as an identifier.
53+// ---------------------------------------------------------------------------
54+
55+export const OnePasswordAccountUpsert = Schema.Struct({
56+ id: Schema.optional(Schema.String),
57+ name: Schema.String,
58+ auth: OnePasswordAuth,
59+ vaults: Schema.NonEmptyArray(Vault),
60+});
61+export type OnePasswordAccountUpsert = typeof OnePasswordAccountUpsert.Type;
62+
63+const OnePasswordConfigureInput = OnePasswordAccountUpsert;
4864
4965 const OnePasswordConfigureOutput = Schema.Struct({
5066 configured: Schema.Boolean,
67+ accountId: Schema.String,
5168 });
5269
5370 const OnePasswordGetConfigOutput = Schema.Struct({
@@ -60,6 +77,10 @@ const OnePasswordListVaultsOutput = Schema.Struct({ · 6 unmodified lines
6077 vaults: Schema.Array(Vault),
6178 });
6279
80+const OnePasswordRemoveConfigInput = Schema.Struct({
81+ accountId: Schema.optional(Schema.String),
82+});
83+
6384 const OnePasswordRemoveConfigOutput = Schema.Struct({
6485 removed: Schema.Boolean,
6586 });
@@ -77,6 +98,10 @@ const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< · 11 unmodified lines
7798 typeof OnePasswordListVaultsInput.Encoded
7899 >(OnePasswordListVaultsInput);
79100 const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput);
101+const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema<
102+ typeof OnePasswordRemoveConfigInput.Type,
103+ typeof OnePasswordRemoveConfigInput.Encoded
104+>(OnePasswordRemoveConfigInput);
80105 const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput);
81106 const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput);
82107
@@ -95,13 +120,13 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 12 unmodified lines
95120
96121 // ---------------------------------------------------------------------------
97122 // Typed config store — single blob, JSON encoded, owner-partitioned. The
98−// stored config carries the auth credential (desktop account name, or
99−// service-account token) plus the selected vaults. v1 keyed this by executor
100−// scope; v2 partitions by `owner` — the plugin-owned config row owns the
101−// partition, mirroring the connection model. Reads also accept the legacy
102−// single-`vaultId` shape and normalize it to the vaults array; saves always
103−// write the current shape. Blob I/O failures surface as `StorageError`;
104−// decode failures stay `OnePasswordError`.
123+// stored config carries every account's auth credential (desktop account
124+// name, or service-account token) plus its selected vaults. v1 keyed this by
125+// executor scope; v2 partitions by `owner` — the plugin-owned config row owns
126+// the partition, mirroring the connection model. Reads also accept the two
127+// pre-multi-account shapes and normalize them onto a single "default"
128+// account; saves always write the current shape. Blob I/O failures surface as
129+// `StorageError`; decode failures stay `OnePasswordError`.
105130 // ---------------------------------------------------------------------------
106131
107132 export interface OnePasswordStore {
@@ -147,15 +172,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 39 unmodified lines
147172
148173 saveConfig: (config, owner) =>
149174 blobs
150− .put(
151− CONFIG_KEY,
152− JSON.stringify({
153− auth: config.auth,
154− vaults: config.vaults,
155− name: config.name,
156− }),
157− { owner },
158− )
175+ .put(CONFIG_KEY, JSON.stringify({ accounts: config.accounts }), { owner })
159176 .pipe(Effect.mapError(blobStorageError("write"))),
160177
161178 deleteConfig: (owner) =>
@@ -171,26 +188,32 @@ const resolveAuth = (auth: OnePasswordAuth): ResolvedAuth => · 9 unmodified lines
171188 ? { kind: "desktop-app", accountName: auth.accountName }
172189 : { kind: "service-account", token: auth.token };
173190
174−const getServiceFromConfig = (
175− config: OnePasswordConfig,
176− timeoutMs: number,
177− preferSdk: boolean | undefined,
178−): Effect.Effect<OnePasswordService, OnePasswordError> =>
179− makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk });
191+/** One service per account: each account carries its own auth, so a shared
192+ * client can never leak one account's credential into another's calls. */
193+const serviceForAccount =
194+ (timeoutMs: number, preferSdk: boolean | undefined) =>
195+ (account: OnePasswordAccount): Effect.Effect<OnePasswordService, OnePasswordError> =>
196+ makeOnePasswordService(resolveAuth(account.auth), { timeoutMs, preferSdk });
180197
181198 // ---------------------------------------------------------------------------
182199 // Explicit ref resolution.
183200 //
184201 // A ref is one of:
185−// - `op://vault/item/field...` — fully qualified, resolved as-is.
202+// - `op://vault/item/field...` — fully qualified, resolved as-is via the
203+// account that owns the vault. The first
204+// segment stays a VAULT (id or name),
205+// matching 1Password's own op:// semantics —
206+// account selection is derived, never
207+// encoded into the ref.
186208 // - `op://vault/item` — picker-shaped; the default credential field
187209 // is appended. This is the id shape `list()`
188210 // hands out, so every picked item permanently
189211 // records which vault it came from.
190−// - a bare item id or title — located by listing the configured vaults.
191−// Exactly one match resolves; several matches
192−// are an explicit ambiguity failure naming the
193−// vaults — never a silent precedence pick.
212+// - a bare item id or title — located by listing every account's
213+// configured vaults. Exactly one match
214+// resolves; several matches are an explicit
215+// ambiguity failure naming the vaults — never
216+// a precedence pick.
194217 // ---------------------------------------------------------------------------
195218
196219 export type RefResolution =
@@ -200,11 +223,20 @@ export type RefResolution = · 3 unmodified lines
200223 | {
201224 readonly kind: "ambiguous";
202225 readonly matches: readonly {
226+ readonly accountName: string;
203227 readonly vaultId: string;
204228 readonly vaultName: string;
205229 readonly itemId: string;
206230 readonly itemTitle: string;
207231 }[];
232+ }
233+ | {
234+ readonly kind: "ambiguous-vault";
235+ readonly vaultName: string;
236+ readonly matches: readonly {
237+ readonly accountName: string;
238+ readonly vaultId: string;
239+ }[];
208240 };
209241
210242 export const ambiguityMessage = (
@@ -213,18 +245,28 @@ export const ambiguityMessage = ( · 2 unmodified lines
213245 ): string =>
214246 [
215247 `1Password ref "${ref}" is ambiguous: it matches`,
216− matches.map((m) => `"${m.itemTitle}" in vault "${m.vaultName}"`).join(", "),
248+ matches
249+ .map((m) => `"${m.itemTitle}" in vault "${m.vaultName}" (account "${m.accountName}")`)
250+ .join(", "),
217251 `. Use op://<vaultId>/<itemId> to pick one.`,
218252 ].join(" ");
219253
220−const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean =>
221− config.vaults.some((vault) => vault.id === segment || vault.name === segment);
254+export const vaultAmbiguityMessage = (
255+ resolution: Extract<RefResolution, { kind: "ambiguous-vault" }>,
256+): string =>
257+ [
258+ `1Password vault name "${resolution.vaultName}" is configured in more than one account:`,
259+ resolution.matches.map((m) => `"${m.accountName}" (vault id ${m.vaultId})`).join(", "),
260+ `. Use op://<vaultId>/... to pick one.`,
261+ ].join(" ");
222262
223−/** Resolve a ref against the configured vaults. Backend failures stay on the
224− * error channel; every addressing outcome is an explicit `RefResolution`. */
263+/** Resolve a ref against every configured account. Backend failures stay on
264+ * the error channel; every addressing outcome is an explicit
265+ * `RefResolution`. Services are built per account and only for the accounts
266+ * a ref actually needs. */
225267 export const resolveConfiguredRef = (
226− svc: OnePasswordService,
227268 config: OnePasswordConfig,
269+ serviceFor: (account: OnePasswordAccount) => Effect.Effect<OnePasswordService, OnePasswordError>,
228270 ref: string,
229271 ): Effect.Effect<RefResolution, OnePasswordError> => {
230272 if (ref.startsWith("op://")) {
@@ -233,35 +275,83 @@ export const resolveConfiguredRef = ( · 2 unmodified lines
233275 if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) {
234276 return Effect.succeed({ kind: "not-found" });
235277 }
236− if (!isConfiguredVaultSegment(config, vaultSegment)) {
237− return Effect.succeed({ kind: "outside-vaults" });
238− }
239278 const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref;
240− return svc
241− .resolveSecret(uri)
242− .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value })));
279+
280+ // Vault ids are globally unique, so an id-addressed ref names one vault no
281+ // matter how many accounts configure it — any owning account can serve it.
282+ const idOwner = config.accounts.find((account) =>
283+ account.vaults.some((vault) => vault.id === vaultSegment),
284+ );
285+ if (idOwner !== undefined) {
286+ return serviceFor(idOwner).pipe(
287+ Effect.flatMap((svc) => svc.resolveSecret(uri)),
288+ Effect.map((value): RefResolution => ({ kind: "resolved", value })),
289+ );
290+ }
291+
292+ // Vault names are only unique per account: the same name in two accounts
293+ // is two different vaults, so a name-addressed ref must be an explicit
294+ // ambiguity, never a precedence pick.
295+ const nameOwners = config.accounts.flatMap((account) =>
296+ account.vaults
297+ .filter((vault) => vault.name === vaultSegment)
298+ .map((vault) => ({ account, vault })),
299+ );
300+ const [onlyOwner, ...extraOwners] = nameOwners;
301+ if (onlyOwner === undefined) return Effect.succeed({ kind: "outside-vaults" });
302+ if (extraOwners.length > 0) {
303+ return Effect.succeed({
304+ kind: "ambiguous-vault",
305+ vaultName: vaultSegment,
306+ matches: nameOwners.map((owner) => ({
307+ accountName: owner.account.name,
308+ vaultId: owner.vault.id,
309+ })),
310+ });
311+ }
312+ return serviceFor(onlyOwner.account).pipe(
313+ Effect.flatMap((svc) => svc.resolveSecret(uri)),
314+ Effect.map((value): RefResolution => ({ kind: "resolved", value })),
315+ );
243316 }
244317
245318 return Effect.gen(function* () {
246− const matches = (yield* Effect.forEach(config.vaults, (vault) =>
247− svc.listItems(vault.id).pipe(
248− Effect.map((items) =>
249− items
250− .filter((item) => item.id === ref || item.title === ref)
251− .map((item) => ({
252− vaultId: vault.id,
253− vaultName: vault.name,
254− itemId: item.id,
255− itemTitle: item.title,
256− })),
319+ const matches = (yield* Effect.forEach(config.accounts, (account) =>
320+ serviceFor(account).pipe(
321+ Effect.flatMap((svc) =>
322+ Effect.forEach(account.vaults, (vault) =>
323+ svc.listItems(vault.id).pipe(
324+ Effect.map((items) =>
325+ items
326+ .filter((item) => item.id === ref || item.title === ref)
327+ .map((item) => ({
328+ accountId: account.id,
329+ accountName: account.name,
330+ vaultId: vault.id,
331+ vaultName: vault.name,
332+ itemId: item.id,
333+ itemTitle: item.title,
334+ })),
335+ ),
336+ ),
337+ ),
257338 ),
339+ Effect.map((groups) => groups.flat()),
258340 ),
259341 )).flat();
260342
261343 const [only, ...extra] = matches;
262344 if (only === undefined) return { kind: "not-found" } as const;
263− if (extra.length > 0) return { kind: "ambiguous", matches } as const;
345+ if (extra.length > 0) {
346+ return {
347+ kind: "ambiguous",
348+ matches: matches.map(({ accountId: _accountId, ...match }) => match),
349+ } as const;
350+ }
264351
352+ const owner = config.accounts.find((account) => account.id === only.accountId);
353+ if (owner === undefined) return { kind: "not-found" } as const;
354+ const svc = yield* serviceFor(owner);
265355 const value = yield* svc.resolveSecret(
266356 `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`,
267357 );
@@ -274,78 +364,93 @@ export const resolveConfiguredRef = ( · 6 unmodified lines
274364 //
275365 // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is
276366 // a vault-qualified `op://` ref (what `list()` hands out) or a bare item
277−// id/title that must locate exactly one item across the configured vaults.
278−// The plugin's stored config supplies the auth + vault bindings; the provider
279−// never writes (writable: false).
367+// id/title that must locate exactly one item across every account's
368+// configured vaults. The plugin's stored config supplies the auth + vault
369+// bindings; the provider never writes (writable: false).
280370 // ---------------------------------------------------------------------------
281371
282372 const makeProvider = (
283373 ctx: PluginCtx<OnePasswordStore>,
284374 timeoutMs: number,
285375 preferSdk: boolean | undefined,
286−): CredentialProvider => ({
287− key: PROVIDER_KEY,
288− writable: false,
289−
290− get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>
291− ctx.storage.getConfig().pipe(
292− // An undecodable stored config reads as "not configured" here; the
293− // settings surface reports the decode problem.
294− Effect.catchTag("OnePasswordError", () => Effect.succeed(null)),
295− Effect.flatMap((config) => {
296− if (!config) return Effect.succeed(null as string | null);
297−
298− return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(
299− Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)),
300− // Backend unreachability degrades to "no value", matching the other
301− // providers. Ambiguity does NOT: silently picking a vault (or
302− // silently failing) hides a real conflict, so it surfaces as a
303− // typed failure with the full explanation.
304− Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)),
305− Effect.flatMap(
306− (resolution): Effect.Effect<string | null, StorageError> =>
307− resolution.kind === "ambiguous"
308− ? Effect.fail(
309− new StorageError({
310− message: ambiguityMessage(id, resolution.matches),
311− cause: undefined,
312− }),
313− )
314− : Effect.succeed(resolution.kind === "resolved" ? resolution.value : null),
315− ),
316− );
317− }),
318− ),
376+): CredentialProvider => {
377+ const serviceFor = serviceForAccount(timeoutMs, preferSdk);
378+ return {
379+ key: PROVIDER_KEY,
380+ writable: false,
381+
382+ get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>
383+ ctx.storage.getConfig().pipe(
384+ // An undecodable stored config reads as "not configured" here; the
385+ // settings surface reports the decode problem.
386+ Effect.catchTag("OnePasswordError", () => Effect.succeed(null)),
387+ Effect.flatMap((config) => {
388+ if (!config) return Effect.succeed(null as string | null);
389+
390+ return resolveConfiguredRef(config, serviceFor, id).pipe(
391+ // Backend unreachability degrades to "no value", matching the other
392+ // providers. Ambiguity does NOT: silently picking a vault (or
393+ // silently failing) hides a real conflict, so it surfaces as a
394+ // typed failure with the full explanation.
395+ Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)),
396+ Effect.flatMap((resolution): Effect.Effect<string | null, StorageError> => {
397+ if (resolution.kind === "ambiguous") {
398+ return Effect.fail(
399+ new StorageError({
400+ message: ambiguityMessage(id, resolution.matches),
401+ cause: undefined,
402+ }),
403+ );
404+ }
405+ if (resolution.kind === "ambiguous-vault") {
406+ return Effect.fail(
407+ new StorageError({
408+ message: vaultAmbiguityMessage(resolution),
409+ cause: undefined,
410+ }),
411+ );
412+ }
413+ return Effect.succeed(resolution.kind === "resolved" ? resolution.value : null);
414+ }),
415+ );
416+ }),
417+ ),
319418
320− list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>
321− ctx.storage.getConfig().pipe(
322− Effect.flatMap((config) => {
323− if (!config) return Effect.succeed([] as readonly ProviderEntry[]);
324− return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(
325− Effect.flatMap((svc) =>
326− Effect.forEach(config.vaults, (vault) =>
327− svc.listItems(vault.id).pipe(
328− Effect.map((items) =>
329− items.map(
330− // Vault-qualified ids: picking an entry permanently
331− // records which vault it came from, so identically-titled
332− // items in different vaults can never collide.
333− (item): ProviderEntry => ({
334− id: ProviderItemId.make(`op://${vault.id}/${item.id}`),
335− name: item.title,
336− group: vault.name,
337− }),
419+ list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>
420+ ctx.storage.getConfig().pipe(
421+ Effect.flatMap((config) => {
422+ if (!config) return Effect.succeed([] as readonly ProviderEntry[]);
423+ const multipleAccounts = config.accounts.length > 1;
424+ return Effect.forEach(config.accounts, (account) =>
425+ serviceFor(account).pipe(
426+ Effect.flatMap((svc) =>
427+ Effect.forEach(account.vaults, (vault) =>
428+ svc.listItems(vault.id).pipe(
429+ Effect.map((items) =>
430+ items.map(
431+ // Vault-qualified ids: picking an entry permanently
432+ // records which vault it came from, so identically-titled
433+ // items in different vaults can never collide.
434+ (item): ProviderEntry => ({
435+ id: ProviderItemId.make(`op://${vault.id}/${item.id}`),
436+ name: item.title,
437+ group: multipleAccounts ? `${account.name} · ${vault.name}` : vault.name,
438+ }),
439+ ),
440+ ),
338441 ),
339442 ),
340443 ),
444+ Effect.map((groups) => groups.flat()),
445+ // One unreachable account must not hide the others' items.
446+ Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
341447 ),
342− ),
343− Effect.map((groups): readonly ProviderEntry[] => groups.flat()),
344− );
345− }),
346− Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
347− ),
348−});
448+ ).pipe(Effect.map((groups): readonly ProviderEntry[] => groups.flat()));
449+ }),
450+ Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
451+ ),
452+ };
453+};
349454
350455 // ---------------------------------------------------------------------------
351456 // Owner resolution — config is a single shared 1Password binding. We persist
@@ -361,8 +466,73 @@ const makeOnePasswordExtension = ( · 9 unmodified lines
361466 timeoutMs: number,
362467 preferSdk: boolean | undefined,
363468 ) => {
469+ const serviceFor = serviceForAccount(timeoutMs, preferSdk);
470+
471+ const accountStatus = (account: OnePasswordAccount): Effect.Effect<AccountStatus, never> =>
472+ serviceFor(account).pipe(
473+ Effect.flatMap((svc) => svc.listVaults()),
474+ Effect.map((live) => {
475+ const liveById = new Map(live.map((v) => [v.id, v.title]));
476+ const missing = account.vaults.filter((vault) => !liveById.has(vault.id));
477+ return AccountStatus.make({
478+ id: account.id,
479+ name: account.name,
480+ connected: true,
481+ vaultNames: account.vaults.map((vault) => liveById.get(vault.id) ?? vault.name),
482+ ...(missing.length > 0
483+ ? {
484+ error: `Configured vaults not found: ${missing
485+ .map((vault) => vault.name)
486+ .join(", ")}`,
487+ }
488+ : {}),
489+ });
490+ }),
491+ Effect.catchTag("OnePasswordError", (error) =>
492+ Effect.succeed(
493+ AccountStatus.make({
494+ id: account.id,
495+ name: account.name,
496+ connected: false,
497+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message`
498+ error: error.message,
499+ }),
500+ ),
501+ ),
502+ );
503+
364504 return {
365− configure: (config: OnePasswordConfig) => ctx.storage.saveConfig(config, ownerForCtx(ctx)),
505+ /** Add or replace one account. A payload without an id creates a new
506+ * account; with an id it replaces the matching account in place. */
507+ configure: (
508+ upsert: OnePasswordAccountUpsert,
509+ ): Effect.Effect<{ readonly accountId: string }, StorageError | OnePasswordError> =>
510+ Effect.gen(function* () {
511+ const existing = yield* ctx.storage.getConfig().pipe(
512+ // An undecodable blob must not brick configuration: the next save
513+ // rewrites the whole config in the current shape.
514+ Effect.catchTag("OnePasswordError", () => Effect.succeed(null)),
515+ );
516+ const accountId = upsert.id ?? crypto.randomUUID();
517+ const account = OnePasswordAccount.make({
518+ id: accountId,
519+ name: upsert.name,
520+ auth: upsert.auth,
521+ vaults: upsert.vaults,
522+ });
523+ // Position-preserving upsert: an edit replaces the account in place,
524+ // a new account appends — the settings list never reshuffles.
525+ const current = existing === null ? [] : existing.accounts;
526+ const accounts = current.some((candidate) => candidate.id === accountId)
527+ ? current.map((candidate) => (candidate.id === accountId ? account : candidate))
528+ : [...current, account];
529+ const [first, ...rest] = accounts;
530+ yield* ctx.storage.saveConfig(
531+ { accounts: first === undefined ? [account] : [first, ...rest] },
532+ ownerForCtx(ctx),
533+ );
534+ return { accountId };
535+ }),
366536
367537 getConfig: (): Effect.Effect<
368538 RedactedOnePasswordConfig | null,
@@ -370,7 +540,22 @@ const makeOnePasswordExtension = ( · 1 unmodified line
370540 > =>
371541 ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))),
372542
373− removeConfig: () => ctx.storage.deleteConfig(ownerForCtx(ctx)),
543+ /** Remove one account by id, or the whole configuration when no id is
544+ * given. Removing the last account deletes the blob. */
545+ removeConfig: (accountId?: string): Effect.Effect<void, StorageError | OnePasswordError> => {
546+ if (accountId === undefined) return ctx.storage.deleteConfig(ownerForCtx(ctx));
547+ return Effect.gen(function* () {
548+ const existing = yield* ctx.storage.getConfig();
549+ if (existing === null) return;
550+ const remaining = existing.accounts.filter((account) => account.id !== accountId);
551+ const [first, ...rest] = remaining;
552+ if (first === undefined) {
553+ yield* ctx.storage.deleteConfig(ownerForCtx(ctx));
554+ return;
555+ }
556+ yield* ctx.storage.saveConfig({ accounts: [first, ...rest] }, ownerForCtx(ctx));
557+ });
558+ },
374559
375560 status: () =>
376561 Effect.gen(function* () {
@@ -378,21 +563,18 @@ const makeOnePasswordExtension = ( · 1 unmodified line
378563 if (!config) {
379564 return ConnectionStatus.make({
380565 connected: false,
566+ accounts: [],
381567 error: "Not configured",
382568 });
383569 }
384− const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
385− const live = yield* svc.listVaults();
386− const liveById = new Map(live.map((v) => [v.id, v.title]));
387− const missing = config.vaults.filter((vault) => !liveById.has(vault.id));
570+ const accounts = yield* Effect.forEach(config.accounts, accountStatus);
571+ const broken = accounts.filter((account) => !account.connected);
388572 return ConnectionStatus.make({
389− connected: true,
390− vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name),
391− ...(missing.length > 0
573+ connected: broken.length === 0,
574+ accounts,
575+ ...(broken.length > 0
392576 ? {
393− error: `Configured vaults not found: ${missing
394− .map((vault) => vault.name)
395− .join(", ")}`,
577+ error: `Unreachable accounts: ${broken.map((account) => account.name).join(", ")}`,
396578 }
397579 : {}),
398580 });
@@ -419,8 +601,7 @@ const makeOnePasswordExtension = ( · 20 unmodified lines
419601 message: "1Password is not configured",
420602 });
421603 }
422− const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);
423− const resolution = yield* resolveConfiguredRef(svc, config, uri);
604+ const resolution = yield* resolveConfiguredRef(config, serviceFor, uri);
424605 if (resolution.kind === "resolved") return resolution.value;
425606 if (resolution.kind === "outside-vaults") {
426607 return yield* new OnePasswordError({
@@ -434,6 +615,12 @@ const makeOnePasswordExtension = ( · 7 unmodified lines
434615 message: ambiguityMessage(uri, resolution.matches),
435616 });
436617 }
618+ if (resolution.kind === "ambiguous-vault") {
619+ return yield* new OnePasswordError({
620+ operation: "resolve",
621+ message: vaultAmbiguityMessage(resolution),
622+ });
623+ }
437624 return yield* new OnePasswordError({
438625 operation: "resolve",
439626 message: `1Password item "${uri}" was not found in the configured vaults`,
@@ -475,7 +662,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 35 unmodified lines
475662 tool({
476663 name: "status",
477664 description:
478− "Check whether the 1Password credential provider is configured and can reach its selected vaults. This returns status only, never secret values.",
665+ "Check whether the 1Password credential provider is configured and can reach each account's selected vaults. This returns status only, never secret values.",
479666 outputSchema: OnePasswordStatusOutputStd,
480667 execute: () => Effect.map(self.status(), ToolResult.ok),
481668 }),
@@ -498,29 +685,36 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines
498685 tool({
499686 name: "configure",
500687 description:
501− "Configure the 1Password credential provider for the acting owner with one or more vaults. Use desktop-app auth for local biometric access, or service-account auth with the token. The token is stored in the plugin's owner-partitioned config and never surfaced again.",
688+ "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.",
502689 annotations: {
503690 requiresApproval: true,
504− approvalDescription: "Configure the 1Password credential provider",
691+ approvalDescription: "Configure a 1Password credential provider account",
505692 },
506693 inputSchema: OnePasswordConfigureInputStd,
507694 outputSchema: OnePasswordConfigureOutputStd,
508695 execute: (input) =>
509− Effect.as(
510− self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }),
511− ToolResult.ok({ configured: true }),
696+ Effect.map(
697+ self.configure({
698+ ...(input.id !== undefined ? { id: input.id } : {}),
699+ name: input.name,
700+ auth: input.auth,
701+ vaults: input.vaults,
702+ }),
703+ ({ accountId }) => ToolResult.ok({ configured: true, accountId }),
512704 ),
513705 }),
514706 tool({
515707 name: "removeConfig",
516708 description:
517− "Remove the 1Password provider configuration for the acting owner. Future 1Password secret resolution stops until reconfigured.",
709+ "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.",
518710 annotations: {
519711 requiresApproval: true,
520− approvalDescription: "Remove the 1Password credential provider configuration",
712+ approvalDescription: "Remove 1Password credential provider configuration",
521713 },
714+ inputSchema: OnePasswordRemoveConfigInputStd,
522715 outputSchema: OnePasswordRemoveConfigOutputStd,
523− execute: () => Effect.as(self.removeConfig(), ToolResult.ok({ removed: true })),
716+ execute: (input) =>
717+ Effect.as(self.removeConfig(input.accountId), ToolResult.ok({ removed: true })),
524718 }),
525719 ],
526720 },
packages/plugins/onepassword/src/sdk/types.ts+96−28
@@ -35,23 +35,50 @@ export const Vault = Schema.Struct({ · 34 unmodified lines
3535 export type Vault = typeof Vault.Type;
3636
3737 // ---------------------------------------------------------------------------
38−// Stored config — persisted via KV
38+// Account — one named auth binding plus the vaults it scopes to. An owner can
39+// hold several (a work account and a personal one, or a service-account token
40+// next to desktop-app biometrics), each addressed by a stable generated id.
3941 // ---------------------------------------------------------------------------
4042
41−export const OnePasswordConfig = Schema.Struct({
43+export const OnePasswordAccount = Schema.Struct({
44+ /** Stable identifier, generated when the account is first saved. Refs never
45+ * embed it — `op://` addressing stays vault-first — so renaming or
46+ * re-authing an account never invalidates a stored ref. */
47+ id: Schema.String,
48+ /** Human label for this account, e.g. "Work" */
49+ name: Schema.String,
4250 auth: OnePasswordAuth,
4351 /** Vaults to scope operations to. Order is presentational only: refs are
4452 * vault-qualified, and a bare ref that matches in more than one vault is an
4553 * explicit ambiguity failure, never a precedence pick. */
4654 vaults: Schema.NonEmptyArray(Vault),
47− /** Human label for the whole connection */
48− name: Schema.String,
55+});
56+export type OnePasswordAccount = typeof OnePasswordAccount.Type;
57+
58+/** The account id every pre-multi-account config normalizes onto. */
59+export const DEFAULT_ACCOUNT_ID = "default";
60+
61+// ---------------------------------------------------------------------------
62+// Stored config — persisted via KV
63+// ---------------------------------------------------------------------------
64+
65+export const OnePasswordConfig = Schema.Struct({
66+ accounts: Schema.NonEmptyArray(OnePasswordAccount),
4967 });
5068 export type OnePasswordConfig = typeof OnePasswordConfig.Type;
5169
52−/** Pre-multi-vault stored shape: a single vault id whose display name doubled
53− * as the connection label. Still accepted on read; every save writes the
54− * current shape, so a config row upgrades the first time it is re-saved. */
70+/** Single-account stored shape (multi-vault, pre-multi-account). Still
71+ * accepted on read; every save writes the current shape, so a config row
72+ * upgrades the first time it is re-saved. */
73+export const SingleAccountOnePasswordConfig = Schema.Struct({
74+ auth: OnePasswordAuth,
75+ vaults: Schema.NonEmptyArray(Vault),
76+ name: Schema.String,
77+});
78+export type SingleAccountOnePasswordConfig = typeof SingleAccountOnePasswordConfig.Type;
79+
80+/** Original stored shape: a single vault id whose display name doubled as the
81+ * connection label. */
5582 export const LegacyOnePasswordConfig = Schema.Struct({
5683 auth: OnePasswordAuth,
5784 vaultId: Schema.String,
@@ -59,17 +86,33 @@ export const LegacyOnePasswordConfig = Schema.Struct({ · 1 unmodified line
5986 });
6087 export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type;
6188
62−export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]);
89+export const StoredOnePasswordConfig = Schema.Union([
90+ OnePasswordConfig,
91+ SingleAccountOnePasswordConfig,
92+ LegacyOnePasswordConfig,
93+]);
6394 export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type;
6495
65−export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig =>
66− "vaultId" in stored
67− ? {
68− auth: stored.auth,
69− vaults: [{ id: stored.vaultId, name: stored.name }],
70− name: stored.name,
71− }
72− : stored;
96+export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => {
97+ if ("accounts" in stored) return stored;
98+ if ("vaultId" in stored) {
99+ return {
100+ accounts: [
101+ {
102+ id: DEFAULT_ACCOUNT_ID,
103+ name: stored.name,
104+ auth: stored.auth,
105+ vaults: [{ id: stored.vaultId, name: stored.name }],
106+ },
107+ ],
108+ };
109+ }
110+ return {
111+ accounts: [
112+ { id: DEFAULT_ACCOUNT_ID, name: stored.name, auth: stored.auth, vaults: stored.vaults },
113+ ],
114+ };
115+};
73116
74117 // ---------------------------------------------------------------------------
75118 // Redacted config — what `getConfig` returns to agents / the UI. The
@@ -88,30 +131,55 @@ export const RedactedOnePasswordAuth = Schema.Union([ · 12 unmodified lines
88131 RedactedServiceAccountAuth,
89132 ]);
90133
91−export const RedactedOnePasswordConfig = Schema.Struct({
134+export const RedactedOnePasswordAccount = Schema.Struct({
135+ id: Schema.String,
136+ name: Schema.String,
92137 auth: RedactedOnePasswordAuth,
93138 vaults: Schema.NonEmptyArray(Vault),
94− name: Schema.String,
139+});
140+export type RedactedOnePasswordAccount = typeof RedactedOnePasswordAccount.Type;
141+
142+export const RedactedOnePasswordConfig = Schema.Struct({
143+ accounts: Schema.NonEmptyArray(RedactedOnePasswordAccount),
95144 });
96145 export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type;
97146
98−/** Strip the service-account token from a stored config for external exposure. */
99−export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => ({
100− auth:
101− config.auth.kind === "desktop-app"
102− ? { kind: "desktop-app", accountName: config.auth.accountName }
103− : { kind: "service-account" },
104− vaults: config.vaults,
105− name: config.name,
147+const redactAuth = (auth: OnePasswordAuth): typeof RedactedOnePasswordAuth.Type =>
148+ auth.kind === "desktop-app"
149+ ? { kind: "desktop-app", accountName: auth.accountName }
150+ : { kind: "service-account" };
151+
152+export const redactAccount = (account: OnePasswordAccount): RedactedOnePasswordAccount => ({
153+ id: account.id,
154+ name: account.name,
155+ auth: redactAuth(account.auth),
156+ vaults: account.vaults,
106157 });
107158
159+/** Strip the service-account tokens from a stored config for external exposure. */
160+export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => {
161+ const [first, ...rest] = config.accounts;
162+ return { accounts: [redactAccount(first), ...rest.map(redactAccount)] };
163+};
164+
108165 // ---------------------------------------------------------------------------
109−// Connection status
166+// Connection status — reported per account so one unreachable account never
167+// masks (or fakes) the health of another.
110168 // ---------------------------------------------------------------------------
111169
112−export const ConnectionStatus = Schema.Struct({
170+export const AccountStatus = Schema.Struct({
171+ id: Schema.String,
172+ name: Schema.String,
113173 connected: Schema.Boolean,
114174 vaultNames: Schema.optional(Schema.Array(Schema.String)),
115175 error: Schema.optional(Schema.String),
116176 });
177+export type AccountStatus = typeof AccountStatus.Type;
178+
179+export const ConnectionStatus = Schema.Struct({
180+ /** True only when configured and every account is reachable. */
181+ connected: Schema.Boolean,
182+ accounts: Schema.Array(AccountStatus),
183+ error: Schema.optional(Schema.String),
184+});
117185 export type ConnectionStatus = typeof ConnectionStatus.Type;