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
51Password: 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
3import { InternalError } from "@executor-js/sdk/shared";3import { InternalError } from "@executor-js/sdk/shared";
4 4
5import { OnePasswordError } from "../sdk/errors";5import { OnePasswordError } from "../sdk/errors";
6import {6import { OnePasswordAccountUpsert } from "../sdk/plugin";
7 OnePasswordConfig,7import { RedactedOnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";
8 RedactedOnePasswordConfig,
9 Vault,
10 ConnectionStatus,
11} from "../sdk/types";
12 8
13// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
14// Payloads10// Payloads
15//11//
16// v2: config is a single per-owner binding the extension derives from the12// v2: config is a single per-owner binding the extension derives from the
17// executor's owner binding — there are no scope segments in the path. The13// executor's owner binding — there are no scope segments in the path. The
18// configure payload carries the full config (including the service-account14// configure payload carries one account upsert (including the
19// token); reads return the redacted projection so the token never leaves the15// service-account token); reads return the redacted projection so the token
20// plugin.16// never leaves the plugin.
21// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
22 18
23const ConfigurePayload = OnePasswordConfig;19const ConfigurePayload = OnePasswordAccountUpsert;
20
21const ConfigureResponse = Schema.Struct({
22 accountId: Schema.String,
23});
24
25const RemoveConfigParams = Schema.Struct({
26 accountId: Schema.optional(Schema.String),
27});
24 28
25const ListVaultsParams = Schema.Struct({29const ListVaultsParams = Schema.Struct({
26 authKind: Schema.Literals(["desktop-app", "service-account"]),30 authKind: Schema.Literals(["desktop-app", "service-account"]),
@@ -60,12 +64,13 @@ export const OnePasswordGroup = HttpApiGroup.make("onepassword") · 33 unmodified lines
60 .add(64 .add(
61 HttpApiEndpoint.put("configure", "/onepassword/config", {65 HttpApiEndpoint.put("configure", "/onepassword/config", {
62 payload: ConfigurePayload,66 payload: ConfigurePayload,
63 success: Schema.Void,67 success: ConfigureResponse,
64 error: [InternalError, OnePasswordError],68 error: [InternalError, OnePasswordError],
65 }),69 }),
66 )70 )
67 .add(71 .add(
68 HttpApiEndpoint.delete("removeConfig", "/onepassword/config", {72 HttpApiEndpoint.delete("removeConfig", "/onepassword/config", {
73 query: RemoveConfigParams,
69 success: Schema.Void,74 success: Schema.Void,
70 error: [InternalError, OnePasswordError],75 error: [InternalError, OnePasswordError],
71 }),76 }),
packages/plugins/onepassword/src/api/handlers.ts+3−3
@@ -54,15 +54,15 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 53 unmodified lines
54 capture(54 capture(
55 Effect.gen(function* () {55 Effect.gen(function* () {
56 const ext = yield* OnePasswordExtensionService;56 const ext = yield* OnePasswordExtensionService;
57 yield* ext.configure(payload);57 return yield* ext.configure(payload);
58 }),58 }),
59 ),59 ),
60 )60 )
61 .handle("removeConfig", () =>61 .handle("removeConfig", ({ query }) =>
62 capture(62 capture(
63 Effect.gen(function* () {63 Effect.gen(function* () {
64 const ext = yield* OnePasswordExtensionService;64 const ext = yield* OnePasswordExtensionService;
65 yield* ext.removeConfig();65 yield* ext.removeConfig(query.accountId);
66 }),66 }),
67 ),67 ),
68 )68 )
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+119−77
@@ -36,7 +36,7 @@ import { · 35 unmodified lines
36 removeOnePasswordConfig,36 removeOnePasswordConfig,
37 onepasswordWriteKeys,37 onepasswordWriteKeys,
38} from "./atoms";38} from "./atoms";
39import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";39import type { RedactedOnePasswordAccount, RedactedOnePasswordConfig, Vault } from "../sdk/types";
40 40
41// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
42// Vault picker — multi-select42// Vault picker — multi-select
@@ -170,13 +170,14 @@ function VaultPicker(props: { · 127 unmodified lines
170}170}
171 171
172// ---------------------------------------------------------------------------172// ---------------------------------------------------------------------------
173// Config dialog173// Account dialog — add a new account, or edit one by id
174// ---------------------------------------------------------------------------174// ---------------------------------------------------------------------------
175 175
176function ConfigDialog(props: {176function AccountDialog(props: {
177 open: boolean;177 open: boolean;
178 onOpenChange: (v: boolean) => void;178 onOpenChange: (v: boolean) => void;
179 initial?: {179 initial?: {
180 id: string;
180 authKind: string;181 authKind: string;
181 accountName: string;182 accountName: string;
182 vaults: ReadonlyArray<Vault>;183 vaults: ReadonlyArray<Vault>;
@@ -221,6 +222,7 @@ function ConfigDialog(props: { · 38 unmodified lines
221 222
222 const exit = await doConfigure({223 const exit = await doConfigure({
223 payload: {224 payload: {
225 ...(props.initial ? { id: props.initial.id } : {}),
224 auth,226 auth,
225 vaults: [firstVault, ...restVaults],227 vaults: [firstVault, ...restVaults],
226 name: displayName.trim() || "1Password",228 name: displayName.trim() || "1Password",
@@ -248,11 +250,11 @@ function ConfigDialog(props: { · 21 unmodified lines
248 <DialogContent className="sm:max-w-[420px]">250 <DialogContent className="sm:max-w-[420px]">
249 <DialogHeader>251 <DialogHeader>
250 <DialogTitle className="font-display text-xl">252 <DialogTitle className="font-display text-xl">
251 {isEdit ? "Edit 1Password" : "Connect 1Password"}253 {isEdit ? "Edit 1Password account" : "Add 1Password account"}
252 </DialogTitle>254 </DialogTitle>
253 <DialogDescription className="text-[13px] leading-relaxed">255 <DialogDescription className="text-[13px] leading-relaxed">
254 Link one or more vaults to resolve secrets via the 1Password desktop app or a service256 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.
256 </DialogDescription>258 </DialogDescription>
257 </DialogHeader>259 </DialogHeader>
258 260
@@ -310,10 +312,10 @@ function ConfigDialog(props: { · 51 unmodified lines
310 {/* Display name */}312 {/* Display name */}
311 <div className="grid gap-1.5">313 <div className="grid gap-1.5">
312 <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">314 <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
313 Display name315 Name
314 </Label>316 </Label>
315 <Input317 <Input
316 placeholder="1Password"318 placeholder="Work"
317 value={displayName}319 value={displayName}
318 onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}320 onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
319 className="text-[13px] h-9"321 className="text-[13px] h-9"
@@ -347,18 +349,65 @@ function ConfigDialog(props: { · 27 unmodified lines
347}349}
348 350
349// ---------------------------------------------------------------------------351// ---------------------------------------------------------------------------
350// Settings card352// Settings entries — one card per account, plus the add action
351// ---------------------------------------------------------------------------353// ---------------------------------------------------------------------------
352 354
353export default function OnePasswordSettings() {355function AccountEntry(props: { account: RedactedOnePasswordAccount; onEdit: () => void }) {
354 const [configOpen, setConfigOpen] = useState(false);
355 const configResult = useAtomValue(onepasswordConfigAtom);
356 const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" });356 const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" });
357 const [removing, setRemoving] = useState(false);
357 358
358 const handleRemove = async () => {359 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);
360 };366 };
361 367
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
406export 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
362 const config: RedactedOnePasswordConfig | null = AsyncResult.match(411 const config: RedactedOnePasswordConfig | null = AsyncResult.match(
363 configResult as AsyncResult.AsyncResult<RedactedOnePasswordConfig | null, unknown>,412 configResult as AsyncResult.AsyncResult<RedactedOnePasswordConfig | null, unknown>,
364 {413 {
@@ -384,88 +433,81 @@ export default function OnePasswordSettings() { · 19 unmodified lines
384 },433 },
385 );434 );
386 435
436 const accounts = config?.accounts ?? [];
437
387 return (438 return (
388 <>439 <>
389 <CardStackEntry>440 {isLoading || isError || accounts.length === 0 ? (
390 <CardStackEntryContent>441 <CardStackEntry>
391 {isLoading ? (442 <CardStackEntryContent>
392 <CardStackEntryDescription>Loading…</CardStackEntryDescription>443 {isLoading ? (
393 ) : isError ? (444 <CardStackEntryDescription>Loading…</CardStackEntryDescription>
394 <CardStackEntryDescription className="text-destructive">445 ) : isError ? (
395 Failed to load configuration446 <CardStackEntryDescription className="text-destructive">
396 </CardStackEntryDescription>447 Failed to load configuration
397 ) : config ? (448 </CardStackEntryDescription>
398 <div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]">449 ) : (
399 <span className="text-muted-foreground/60">Auth</span>450 <CardStackEntryDescription>
400 <span className="font-mono text-foreground/80 truncate">451 Resolve secrets from your 1Password vaults.
401 {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}452 </CardStackEntryDescription>
402 </span>453 )}
403 <span className="text-muted-foreground/60">454 </CardStackEntryContent>
404 {config.vaults.length === 1 ? "Vault" : "Vaults"}455 <CardStackEntryActions>
405 </span>456 {!isLoading && !isError && (
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 <>
421 <Button457 <Button
422 variant="ghost"458 variant="link"
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"
431 size="sm"459 size="sm"
432 className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive"460 className="h-7 px-0 text-[12px] shrink-0"
433 onClick={handleRemove}461 onClick={() => setDialogTarget("new")}
434 >462 >
435 Disconnect463 Add 1Password
436 </Button>464 </Button>
437 </>465 )}
438 ) : (466 </CardStackEntryActions>
439 !isLoading &&467 </CardStackEntry>
440 !isError && (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>
441 <Button479 <Button
442 variant="link"480 variant="link"
443 size="sm"481 size="sm"
444 className="h-7 px-0 text-[12px] shrink-0"482 className="h-7 px-0 text-[12px] shrink-0"
445 onClick={() => setConfigOpen(true)}483 onClick={() => setDialogTarget("new")}
446 >484 >
447 Add 1Password485 + Add 1Password account
448 </Button>486 </Button>
449 )487 </CardStackEntryContent>
450 )}488 </CardStackEntry>
451 </CardStackEntryActions>489 </>
452 </CardStackEntry>490 )}
453 491
454 {configOpen && (492 {dialogTarget !== null && (
455 <ConfigDialog493 <AccountDialog
456 open={configOpen}494 open={dialogTarget !== null}
457 onOpenChange={setConfigOpen}495 onOpenChange={(v) => {
496 if (!v) setDialogTarget(null);
497 }}
458 initial={498 initial={
459 config499 dialogTarget === "new"
460 ? {500 ? undefined
461 authKind: config.auth.kind,501 : {
502 id: dialogTarget.id,
503 authKind: dialogTarget.auth.kind,
462 // Service-account tokens are never surfaced (redacted); the504 // Service-account tokens are never surfaced (redacted); the
463 // user re-enters the token when editing that auth method.505 // user re-enters the token when editing that auth method.
464 accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",506 accountName:
465 vaults: config.vaults,507 dialogTarget.auth.kind === "desktop-app" ? dialogTarget.auth.accountName : "",
466 name: config.name,508 vaults: dialogTarget.vaults,
509 name: dialogTarget.name,
467 }510 }
468 : undefined
469 }511 }
470 />512 />
471 )}513 )}
packages/plugins/onepassword/src/sdk/index.ts+8−0
@@ -3,20 +3,28 @@ export { · 2 unmodified lines
3 makeOnePasswordStore,3 makeOnePasswordStore,
4 resolveConfiguredRef,4 resolveConfiguredRef,
5 ambiguityMessage,5 ambiguityMessage,
6 vaultAmbiguityMessage,
7 OnePasswordAccountUpsert,
6 type RefResolution,8 type RefResolution,
7 type OnePasswordExtension,9 type OnePasswordExtension,
8 type OnePasswordPluginOptions,10 type OnePasswordPluginOptions,
9 type OnePasswordStore,11 type OnePasswordStore,
10} from "./plugin";12} from "./plugin";
11export {13export {
14 OnePasswordAccount,
12 OnePasswordConfig,15 OnePasswordConfig,
16 SingleAccountOnePasswordConfig,
13 LegacyOnePasswordConfig,17 LegacyOnePasswordConfig,
14 StoredOnePasswordConfig,18 StoredOnePasswordConfig,
19 DEFAULT_ACCOUNT_ID,
15 normalizeStoredConfig,20 normalizeStoredConfig,
21 RedactedOnePasswordAccount,
16 RedactedOnePasswordConfig,22 RedactedOnePasswordConfig,
17 RedactedOnePasswordAuth,23 RedactedOnePasswordAuth,
24 redactAccount,
18 redactConfig,25 redactConfig,
19 Vault,26 Vault,
27 AccountStatus,
20 ConnectionStatus,28 ConnectionStatus,
21 OnePasswordAuth,29 OnePasswordAuth,
22 DesktopAppAuth,30 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
8import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin";8import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin";
9import type { OnePasswordService } from "./service";9import type { OnePasswordService } from "./service";
10import { OnePasswordError } from "./errors";10import { OnePasswordError } from "./errors";
11import { OnePasswordConfig, DesktopAppAuth } from "./types";11import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types";
12 12
13// removed: v1 routed configure/removeConfig through an explicit `ScopeId`13// removed: v1 routed configure/removeConfig through an explicit `ScopeId`
14// (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and14// (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and
@@ -19,16 +19,38 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 4 unmodified lines
19 19
20const ONEPASSWORD = ProviderKey.make("onepassword");20const ONEPASSWORD = ProviderKey.make("onepassword");
21 21
22const twoVaultConfig = OnePasswordConfig.make({22const desktopAuth = DesktopAppAuth.make({
23 auth: DesktopAppAuth.make({23 kind: "desktop-app",
24 kind: "desktop-app",24 accountName: "my.1password.com",
25 accountName: "my.1password.com",25});
26 }),26
27const twoVaultAccount = OnePasswordAccount.make({
28 id: "acct-default",
29 name: "1Password",
30 auth: desktopAuth,
27 vaults: [31 vaults: [
28 { id: "vault-123", name: "Personal" },32 { id: "vault-123", name: "Personal" },
29 { id: "vault-456", name: "Work" },33 { id: "vault-456", name: "Work" },
30 ],34 ],
31 name: "1Password",35});
36
37const oneAccountConfig = OnePasswordConfig.make({ accounts: [twoVaultAccount] });
38
39const 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 ],
32});54});
33 55
34describe("onepassword plugin", () => {56describe("onepassword plugin", () => {
@@ -42,7 +64,7 @@ describe("onepassword plugin", () => { · 7 unmodified lines
42 }),64 }),
43 );65 );
44 66
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", () =>
46 Effect.gen(function* () {68 Effect.gen(function* () {
47 const executor = yield* createExecutor(69 const executor = yield* createExecutor(
48 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),70 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
@@ -51,40 +73,92 @@ describe("onepassword plugin", () => { · 2 unmodified lines
51 const initial = yield* executor.onepassword.getConfig();73 const initial = yield* executor.onepassword.getConfig();
52 expect(initial).toBeNull();74 expect(initial).toBeNull();
53 75
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);
55 87
56 const loaded = yield* executor.onepassword.getConfig();88 const loaded = yield* executor.onepassword.getConfig();
57 expect(loaded?.vaults).toEqual([89 expect(loaded?.accounts.map((account) => account.name).sort()).toEqual(["Personal", "Work"]);
58 { id: "vault-123", name: "Personal" },90
59 { id: "vault-456", name: "Work" },91 // Re-saving with an id replaces that account in place.
60 ]);92 yield* executor.onepassword.configure({
61 expect(loaded?.name).toBe("1Password");93 id: first.accountId,
62 expect(loaded?.auth.kind).toBe("desktop-app");94 name: "Work (renamed)",
63 95 auth: desktopAuth,
64 yield* executor.onepassword.removeConfig();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);
65 const afterRemove = yield* executor.onepassword.getConfig();109 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();
67 }),115 }),
68 );116 );
69 117
70 it.effect("getConfig redacts the service-account token", () =>118 it.effect("removeConfig without an id removes everything", () =>
71 Effect.gen(function* () {119 Effect.gen(function* () {
72 const executor = yield* createExecutor(120 const executor = yield* createExecutor(
73 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),121 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
74 );122 );
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 );
75 137
76 yield* executor.onepassword.configure(138 it.effect("getConfig redacts every account's service-account token", () =>
77 OnePasswordConfig.make({139 Effect.gen(function* () {
78 auth: { kind: "service-account", token: "super-secret-token" },140 const executor = yield* createExecutor(
79 vaults: [{ id: "vault-123", name: "CI" }],141 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
80 name: "CI",
81 }),
82 );142 );
83 143
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
84 const loaded = yield* executor.onepassword.getConfig();155 const loaded = yield* executor.onepassword.getConfig();
85 expect(loaded?.auth.kind).toBe("service-account");156 expect(loaded?.accounts.every((account) => account.auth.kind === "service-account")).toBe(
86 // The token must never be surfaced through the redacted projection.157 true,
158 );
159 // Tokens must never be surfaced through the redacted projection.
87 expect(JSON.stringify(loaded)).not.toContain("super-secret-token");160 expect(JSON.stringify(loaded)).not.toContain("super-secret-token");
161 expect(JSON.stringify(loaded)).not.toContain("other-secret-token");
88 }),162 }),
89 );163 );
90 164
@@ -97,35 +171,43 @@ describe("onepassword plugin", () => { · 6 unmodified lines
97 const configured = yield* executor.execute(171 const configured = yield* executor.execute(
98 ToolAddress.make("executor.onepassword.configure"),172 ToolAddress.make("executor.onepassword.configure"),
99 {173 {
174 name: "1Password",
100 auth: { kind: "desktop-app", accountName: "my.1password.com" },175 auth: { kind: "desktop-app", accountName: "my.1password.com" },
101 vaults: [176 vaults: [
102 { id: "vault-123", name: "Personal" },177 { id: "vault-123", name: "Personal" },
103 { id: "vault-456", name: "Work" },178 { id: "vault-456", name: "Work" },
104 ],179 ],
105 name: "1Password",
106 },180 },
107 { onElicitation: "accept-all" },181 { onElicitation: "accept-all" },
108 );182 );
109 183
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
111 expect(188 expect(
112 yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}),189 yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}),
113 ).toMatchObject({190 ).toMatchObject({
114 ok: true,191 ok: true,
115 data: {192 data: {
116 config: {193 config: {
117 vaults: [194 accounts: [
118 { id: "vault-123", name: "Personal" },195 {
119 { id: "vault-456", name: "Work" },196 id: accountId,
197 name: "1Password",
198 vaults: [
199 { id: "vault-123", name: "Personal" },
200 { id: "vault-456", name: "Work" },
201 ],
202 },
120 ],203 ],
121 name: "1Password",
122 },204 },
123 },205 },
124 });206 });
125 207
126 const removed = yield* executor.execute(208 const removed = yield* executor.execute(
127 ToolAddress.make("executor.onepassword.removeConfig"),209 ToolAddress.make("executor.onepassword.removeConfig"),
128 {},210 { accountId },
129 { onElicitation: "accept-all" },211 { onElicitation: "accept-all" },
130 );212 );
131 213
@@ -141,15 +223,17 @@ describe("onepassword plugin", () => { · 9 unmodified lines
141 );223 );
142 const status = yield* executor.onepassword.status();224 const status = yield* executor.onepassword.status();
143 expect(status.connected).toBe(false);225 expect(status.connected).toBe(false);
226 expect(status.accounts).toEqual([]);
144 expect(status.error).toBe("Not configured");227 expect(status.error).toBe("Not configured");
145 }),228 }),
146 );229 );
147});230});
148 231
149// ---------------------------------------------------------------------------232// ---------------------------------------------------------------------------
150// Stored-config compatibility — blobs written before multi-vault support hold233// Stored-config compatibility — blobs written before multi-account support
151// `{ auth, vaultId, name }`. Reads normalize that to a one-element vaults234// hold either `{ auth, vaultId, name }` (original) or `{ auth, vaults, name }`
152// array; the next save writes the current shape.235// (multi-vault). Reads normalize both onto a single "default" account; the
236// next save writes the current shape.
153// ---------------------------------------------------------------------------237// ---------------------------------------------------------------------------
154 238
155describe("onepassword store", () => {239describe("onepassword store", () => {
@@ -177,27 +261,65 @@ describe("onepassword store", () => { · 21 unmodified lines
177 261
178 const config = yield* store.getConfig();262 const config = yield* store.getConfig();
179 expect(config).toEqual({263 expect(config).toEqual({
180 auth: { kind: "desktop-app", accountName: "my.1password.com" },264 accounts: [
181 vaults: [{ id: "vault-123", name: "Personal" }],265 {
182 name: "Personal",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 ],
183 });305 });
184 }),306 }),
185 );307 );
186 308
187 it.effect("persists and reads back the multi-vault shape", () =>309 it.effect("persists and reads back the multi-account shape", () =>
188 Effect.gen(function* () {310 Effect.gen(function* () {
189 const { store } = makeStore();311 const { store } = makeStore();
190 yield* store.saveConfig(twoVaultConfig, "org");312 yield* store.saveConfig(twoAccountConfig, "org");
191 const config = yield* store.getConfig();313 const config = yield* store.getConfig();
192 expect(config).toEqual(twoVaultConfig);314 expect(config).toEqual(twoAccountConfig);
193 }),315 }),
194 );316 );
195});317});
196 318
197// ---------------------------------------------------------------------------319// ---------------------------------------------------------------------------
198// Explicit ref resolution — vault-qualified refs resolve directly; bare refs320// Explicit ref resolution — vault-qualified refs resolve directly via the
199// must locate exactly one item, and a multi-vault match is an explicit321// owning account; bare refs must locate exactly one item, and a multi-vault
200// ambiguity, never a precedence pick.322// match is an explicit ambiguity, never a precedence pick.
201// ---------------------------------------------------------------------------323// ---------------------------------------------------------------------------
202 324
203const fakeService = (325const fakeService = (
@@ -217,84 +339,160 @@ const fakeService = ( · 13 unmodified lines
217 },339 },
218});340});
219 341
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. */
344const 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
220describe("resolveConfiguredRef", () => {358describe("resolveConfiguredRef", () => {
359 const serviceFor = fakeServiceFor({});
360
221 it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () =>361 it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () =>
222 Effect.gen(function* () {362 Effect.gen(function* () {
223 const svc = fakeService({});
224 const result = yield* resolveConfiguredRef(363 const result = yield* resolveConfiguredRef(
225 svc,364 oneAccountConfig,
226 twoVaultConfig,365 serviceFor,
227 "op://vault-456/item-abc/password",366 "op://vault-456/item-abc/password",
228 );367 );
229 expect(result).toEqual({368 expect(result).toEqual({
230 kind: "resolved",369 kind: "resolved",
231 value: "secret:op://vault-456/item-abc/password",370 value: "secret:acct-default:op://vault-456/item-abc/password",
232 });371 });
233 }),372 }),
234 );373 );
235 374
236 it.effect("appends the credential field to a picker-shaped op://vault/item ref", () =>375 it.effect("appends the credential field to a picker-shaped op://vault/item ref", () =>
237 Effect.gen(function* () {376 Effect.gen(function* () {
238 const svc = fakeService({});377 const result = yield* resolveConfiguredRef(
239 const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc");378 oneAccountConfig,
379 serviceFor,
380 "op://vault-123/item-abc",
381 );
240 expect(result).toEqual({382 expect(result).toEqual({
241 kind: "resolved",383 kind: "resolved",
242 value: "secret:op://vault-123/item-abc/credential",384 value: "secret:acct-default:op://vault-123/item-abc/credential",
243 });385 });
244 }),386 }),
245 );387 );
246 388
247 it.effect("accepts an op:// URI addressed by vault name", () =>389 it.effect("accepts an op:// URI addressed by vault name", () =>
248 Effect.gen(function* () {390 Effect.gen(function* () {
249 const svc = fakeService({});391 const result = yield* resolveConfiguredRef(
250 const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://Work/item/password");392 oneAccountConfig,
251 expect(result).toEqual({ kind: "resolved", value: "secret:op://Work/item/password" });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 });
252 }),444 }),
253 );445 );
254 446
255 it.effect("reports an op:// URI outside the configured vaults", () =>447 it.effect("reports an op:// URI outside the configured vaults", () =>
256 Effect.gen(function* () {448 Effect.gen(function* () {
257 const svc = fakeService({});
258 const result = yield* resolveConfiguredRef(449 const result = yield* resolveConfiguredRef(
259 svc,450 oneAccountConfig,
260 twoVaultConfig,451 serviceFor,
261 "op://vault-999/item/password",452 "op://vault-999/item/password",
262 );453 );
263 expect(result).toEqual({ kind: "outside-vaults" });454 expect(result).toEqual({ kind: "outside-vaults" });
264 }),455 }),
265 );456 );
266 457
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", () =>
268 Effect.gen(function* () {459 Effect.gen(function* () {
269 const svc = fakeService({460 const withItems = fakeServiceFor({
270 "vault-123": [{ id: "item-1", title: "GitHub Token" }],461 "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] },
271 "vault-456": [{ id: "item-2", title: "Stripe Key" }],462 "acct-personal": { "vault-home": [{ id: "item-2", title: "Stripe Key" }] },
272 });463 });
273 const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");464 const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token");
274 expect(result).toEqual({465 expect(result).toEqual({
275 kind: "resolved",466 kind: "resolved",
276 value: "secret:op://vault-123/item-1/credential",467 value: "secret:acct-work:op://vault-eng/item-1/credential",
277 });468 });
278 }),469 }),
279 );470 );
280 471
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", () =>
282 Effect.gen(function* () {473 Effect.gen(function* () {
283 const svc = fakeService({474 const withItems = fakeServiceFor({
284 "vault-123": [{ id: "item-1", title: "GitHub Token" }],475 "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] },
285 "vault-456": [{ id: "item-2", title: "GitHub Token" }],476 "acct-personal": { "vault-home": [{ id: "item-2", title: "GitHub Token" }] },
286 });477 });
287 const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");478 const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token");
288 expect(result).toEqual({479 expect(result).toEqual({
289 kind: "ambiguous",480 kind: "ambiguous",
290 matches: [481 matches: [
291 {482 {
292 vaultId: "vault-123",483 accountName: "Work",
293 vaultName: "Personal",484 vaultId: "vault-eng",
485 vaultName: "Engineering",
294 itemId: "item-1",486 itemId: "item-1",
295 itemTitle: "GitHub Token",487 itemTitle: "GitHub Token",
296 },488 },
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 },
298 ],496 ],
299 });497 });
300 }),498 }),
@@ -302,22 +500,26 @@ describe("resolveConfiguredRef", () => { · 1 unmodified line
302 500
303 it.effect("treats duplicate titles inside one vault as ambiguous too", () =>501 it.effect("treats duplicate titles inside one vault as ambiguous too", () =>
304 Effect.gen(function* () {502 Effect.gen(function* () {
305 const svc = fakeService({503 const withItems = fakeServiceFor({
306 "vault-123": [504 "acct-default": {
307 { id: "item-1", title: "GitHub Token" },505 "vault-123": [
308 { id: "item-9", title: "GitHub Token" },506 { id: "item-1", title: "GitHub Token" },
309 ],507 { id: "item-9", title: "GitHub Token" },
310 "vault-456": [],508 ],
509 "vault-456": [],
510 },
311 });511 });
312 const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token");512 const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "GitHub Token");
313 expect(result.kind).toBe("ambiguous");513 expect(result.kind).toBe("ambiguous");
314 }),514 }),
315 );515 );
316 516
317 it.effect("reports not-found for a bare ref matching nothing", () =>517 it.effect("reports not-found for a bare ref matching nothing", () =>
318 Effect.gen(function* () {518 Effect.gen(function* () {
319 const svc = fakeService({ "vault-123": [], "vault-456": [] });519 const withItems = fakeServiceFor({
320 const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "missing");520 "acct-default": { "vault-123": [], "vault-456": [] },
521 });
522 const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "missing");
321 expect(result).toEqual({ kind: "not-found" });523 expect(result).toEqual({ kind: "not-found" });
322 }),524 }),
323 );525 );
packages/plugins/onepassword/src/sdk/plugin.ts+330−136
@@ -17,12 +17,14 @@ import { · 16 unmodified lines
17} from "@executor-js/sdk/core";17} from "@executor-js/sdk/core";
18 18
19import {19import {
20 OnePasswordAccount,
20 OnePasswordAuth,21 OnePasswordAuth,
21 OnePasswordConfig,22 OnePasswordConfig,
22 RedactedOnePasswordConfig,23 RedactedOnePasswordConfig,
23 StoredOnePasswordConfig,24 StoredOnePasswordConfig,
24 Vault,25 Vault,
25 ConnectionStatus,26 ConnectionStatus,
27 AccountStatus,
26 normalizeStoredConfig,28 normalizeStoredConfig,
27 redactConfig,29 redactConfig,
28} from "./types";30} from "./types";
@@ -44,10 +46,25 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 15 unmodified lines
44 I46 I
45 >;47 >;
46 48
47const 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
55export const OnePasswordAccountUpsert = Schema.Struct({
56 id: Schema.optional(Schema.String),
57 name: Schema.String,
58 auth: OnePasswordAuth,
59 vaults: Schema.NonEmptyArray(Vault),
60});
61export type OnePasswordAccountUpsert = typeof OnePasswordAccountUpsert.Type;
62
63const OnePasswordConfigureInput = OnePasswordAccountUpsert;
48 64
49const OnePasswordConfigureOutput = Schema.Struct({65const OnePasswordConfigureOutput = Schema.Struct({
50 configured: Schema.Boolean,66 configured: Schema.Boolean,
67 accountId: Schema.String,
51});68});
52 69
53const OnePasswordGetConfigOutput = Schema.Struct({70const OnePasswordGetConfigOutput = Schema.Struct({
@@ -60,6 +77,10 @@ const OnePasswordListVaultsOutput = Schema.Struct({ · 6 unmodified lines
60 vaults: Schema.Array(Vault),77 vaults: Schema.Array(Vault),
61});78});
62 79
80const OnePasswordRemoveConfigInput = Schema.Struct({
81 accountId: Schema.optional(Schema.String),
82});
83
63const OnePasswordRemoveConfigOutput = Schema.Struct({84const OnePasswordRemoveConfigOutput = Schema.Struct({
64 removed: Schema.Boolean,85 removed: Schema.Boolean,
65});86});
@@ -77,6 +98,10 @@ const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< · 11 unmodified lines
77 typeof OnePasswordListVaultsInput.Encoded98 typeof OnePasswordListVaultsInput.Encoded
78>(OnePasswordListVaultsInput);99>(OnePasswordListVaultsInput);
79const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput);100const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput);
101const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema<
102 typeof OnePasswordRemoveConfigInput.Type,
103 typeof OnePasswordRemoveConfigInput.Encoded
104>(OnePasswordRemoveConfigInput);
80const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput);105const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput);
81const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput);106const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput);
82 107
@@ -95,13 +120,13 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 12 unmodified lines
95 120
96// ---------------------------------------------------------------------------121// ---------------------------------------------------------------------------
97// Typed config store — single blob, JSON encoded, owner-partitioned. The122// Typed config store — single blob, JSON encoded, owner-partitioned. The
98// stored config carries the auth credential (desktop account name, or123// stored config carries every account's auth credential (desktop account
99// service-account token) plus the selected vaults. v1 keyed this by executor124// name, or service-account token) plus its selected vaults. v1 keyed this by
100// scope; v2 partitions by `owner` — the plugin-owned config row owns the125// executor scope; v2 partitions by `owner` — the plugin-owned config row owns
101// partition, mirroring the connection model. Reads also accept the legacy126// the partition, mirroring the connection model. Reads also accept the two
102// single-`vaultId` shape and normalize it to the vaults array; saves always127// pre-multi-account shapes and normalize them onto a single "default"
103// write the current shape. Blob I/O failures surface as `StorageError`;128// account; saves always write the current shape. Blob I/O failures surface as
104// decode failures stay `OnePasswordError`.129// `StorageError`; decode failures stay `OnePasswordError`.
105// ---------------------------------------------------------------------------130// ---------------------------------------------------------------------------
106 131
107export interface OnePasswordStore {132export interface OnePasswordStore {
@@ -147,15 +172,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 39 unmodified lines
147 172
148 saveConfig: (config, owner) =>173 saveConfig: (config, owner) =>
149 blobs174 blobs
150 .put(175 .put(CONFIG_KEY, JSON.stringify({ accounts: config.accounts }), { owner })
151 CONFIG_KEY,
152 JSON.stringify({
153 auth: config.auth,
154 vaults: config.vaults,
155 name: config.name,
156 }),
157 { owner },
158 )
159 .pipe(Effect.mapError(blobStorageError("write"))),176 .pipe(Effect.mapError(blobStorageError("write"))),
160 177
161 deleteConfig: (owner) =>178 deleteConfig: (owner) =>
@@ -171,26 +188,32 @@ const resolveAuth = (auth: OnePasswordAuth): ResolvedAuth => · 9 unmodified lines
171 ? { kind: "desktop-app", accountName: auth.accountName }188 ? { kind: "desktop-app", accountName: auth.accountName }
172 : { kind: "service-account", token: auth.token };189 : { kind: "service-account", token: auth.token };
173 190
174const getServiceFromConfig = (191/** One service per account: each account carries its own auth, so a shared
175 config: OnePasswordConfig,192 * client can never leak one account's credential into another's calls. */
176 timeoutMs: number,193const serviceForAccount =
177 preferSdk: boolean | undefined,194 (timeoutMs: number, preferSdk: boolean | undefined) =>
178): Effect.Effect<OnePasswordService, OnePasswordError> =>195 (account: OnePasswordAccount): Effect.Effect<OnePasswordService, OnePasswordError> =>
179 makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk });196 makeOnePasswordService(resolveAuth(account.auth), { timeoutMs, preferSdk });
180 197
181// ---------------------------------------------------------------------------198// ---------------------------------------------------------------------------
182// Explicit ref resolution.199// Explicit ref resolution.
183//200//
184// A ref is one of:201// 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.
186// - `op://vault/item` — picker-shaped; the default credential field208// - `op://vault/item` — picker-shaped; the default credential field
187// is appended. This is the id shape `list()`209// is appended. This is the id shape `list()`
188// hands out, so every picked item permanently210// hands out, so every picked item permanently
189// records which vault it came from.211// records which vault it came from.
190// - a bare item id or title — located by listing the configured vaults.212// - a bare item id or title — located by listing every account's
191// Exactly one match resolves; several matches213// configured vaults. Exactly one match
192// are an explicit ambiguity failure naming the214// resolves; several matches are an explicit
193// vaults — never a silent precedence pick.215// ambiguity failure naming the vaults — never
216// a precedence pick.
194// ---------------------------------------------------------------------------217// ---------------------------------------------------------------------------
195 218
196export type RefResolution =219export type RefResolution =
@@ -200,11 +223,20 @@ export type RefResolution = · 3 unmodified lines
200 | {223 | {
201 readonly kind: "ambiguous";224 readonly kind: "ambiguous";
202 readonly matches: readonly {225 readonly matches: readonly {
226 readonly accountName: string;
203 readonly vaultId: string;227 readonly vaultId: string;
204 readonly vaultName: string;228 readonly vaultName: string;
205 readonly itemId: string;229 readonly itemId: string;
206 readonly itemTitle: string;230 readonly itemTitle: string;
207 }[];231 }[];
232 }
233 | {
234 readonly kind: "ambiguous-vault";
235 readonly vaultName: string;
236 readonly matches: readonly {
237 readonly accountName: string;
238 readonly vaultId: string;
239 }[];
208 };240 };
209 241
210export const ambiguityMessage = (242export const ambiguityMessage = (
@@ -213,18 +245,28 @@ export const ambiguityMessage = ( · 2 unmodified lines
213): string =>245): string =>
214 [246 [
215 `1Password ref "${ref}" is ambiguous: it matches`,247 `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(", "),
217 `. Use op://<vaultId>/<itemId> to pick one.`,251 `. Use op://<vaultId>/<itemId> to pick one.`,
218 ].join(" ");252 ].join(" ");
219 253
220const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean =>254export const vaultAmbiguityMessage = (
221 config.vaults.some((vault) => vault.id === segment || vault.name === segment);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(" ");
222 262
223/** Resolve a ref against the configured vaults. Backend failures stay on the263/** Resolve a ref against every configured account. Backend failures stay on
224 * error channel; every addressing outcome is an explicit `RefResolution`. */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. */
225export const resolveConfiguredRef = (267export const resolveConfiguredRef = (
226 svc: OnePasswordService,
227 config: OnePasswordConfig,268 config: OnePasswordConfig,
269 serviceFor: (account: OnePasswordAccount) => Effect.Effect<OnePasswordService, OnePasswordError>,
228 ref: string,270 ref: string,
229): Effect.Effect<RefResolution, OnePasswordError> => {271): Effect.Effect<RefResolution, OnePasswordError> => {
230 if (ref.startsWith("op://")) {272 if (ref.startsWith("op://")) {
@@ -233,35 +275,83 @@ export const resolveConfiguredRef = ( · 2 unmodified lines
233 if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) {275 if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) {
234 return Effect.succeed({ kind: "not-found" });276 return Effect.succeed({ kind: "not-found" });
235 }277 }
236 if (!isConfiguredVaultSegment(config, vaultSegment)) {
237 return Effect.succeed({ kind: "outside-vaults" });
238 }
239 const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref;278 const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref;
240 return svc279
241 .resolveSecret(uri)280 // Vault ids are globally unique, so an id-addressed ref names one vault no
242 .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value })));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 );
243 }316 }
244 317
245 return Effect.gen(function* () {318 return Effect.gen(function* () {
246 const matches = (yield* Effect.forEach(config.vaults, (vault) =>319 const matches = (yield* Effect.forEach(config.accounts, (account) =>
247 svc.listItems(vault.id).pipe(320 serviceFor(account).pipe(
248 Effect.map((items) =>321 Effect.flatMap((svc) =>
249 items322 Effect.forEach(account.vaults, (vault) =>
250 .filter((item) => item.id === ref || item.title === ref)323 svc.listItems(vault.id).pipe(
251 .map((item) => ({324 Effect.map((items) =>
252 vaultId: vault.id,325 items
253 vaultName: vault.name,326 .filter((item) => item.id === ref || item.title === ref)
254 itemId: item.id,327 .map((item) => ({
255 itemTitle: item.title,328 accountId: account.id,
256 })),329 accountName: account.name,
330 vaultId: vault.id,
331 vaultName: vault.name,
332 itemId: item.id,
333 itemTitle: item.title,
334 })),
335 ),
336 ),
337 ),
257 ),338 ),
339 Effect.map((groups) => groups.flat()),
258 ),340 ),
259 )).flat();341 )).flat();
260 342
261 const [only, ...extra] = matches;343 const [only, ...extra] = matches;
262 if (only === undefined) return { kind: "not-found" } as const;344 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 }
264 351
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);
265 const value = yield* svc.resolveSecret(355 const value = yield* svc.resolveSecret(
266 `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`,356 `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`,
267 );357 );
@@ -274,78 +364,93 @@ export const resolveConfiguredRef = ( · 6 unmodified lines
274//364//
275// v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is365// v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is
276// a vault-qualified `op://` ref (what `list()` hands out) or a bare item366// 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.367// id/title that must locate exactly one item across every account's
278// The plugin's stored config supplies the auth + vault bindings; the provider368// configured vaults. The plugin's stored config supplies the auth + vault
279// never writes (writable: false).369// bindings; the provider never writes (writable: false).
280// ---------------------------------------------------------------------------370// ---------------------------------------------------------------------------
281 371
282const makeProvider = (372const makeProvider = (
283 ctx: PluginCtx<OnePasswordStore>,373 ctx: PluginCtx<OnePasswordStore>,
284 timeoutMs: number,374 timeoutMs: number,
285 preferSdk: boolean | undefined,375 preferSdk: boolean | undefined,
286): CredentialProvider => ({376): CredentialProvider => {
287 key: PROVIDER_KEY,377 const serviceFor = serviceForAccount(timeoutMs, preferSdk);
288 writable: false,378 return {
289 379 key: PROVIDER_KEY,
290 get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>380 writable: false,
291 ctx.storage.getConfig().pipe(381
292 // An undecodable stored config reads as "not configured" here; the382 get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> =>
293 // settings surface reports the decode problem.383 ctx.storage.getConfig().pipe(
294 Effect.catchTag("OnePasswordError", () => Effect.succeed(null)),384 // An undecodable stored config reads as "not configured" here; the
295 Effect.flatMap((config) => {385 // settings surface reports the decode problem.
296 if (!config) return Effect.succeed(null as string | null);386 Effect.catchTag("OnePasswordError", () => Effect.succeed(null)),
297 387 Effect.flatMap((config) => {
298 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(388 if (!config) return Effect.succeed(null as string | null);
299 Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)),389
300 // Backend unreachability degrades to "no value", matching the other390 return resolveConfiguredRef(config, serviceFor, id).pipe(
301 // providers. Ambiguity does NOT: silently picking a vault (or391 // Backend unreachability degrades to "no value", matching the other
302 // silently failing) hides a real conflict, so it surfaces as a392 // providers. Ambiguity does NOT: silently picking a vault (or
303 // typed failure with the full explanation.393 // silently failing) hides a real conflict, so it surfaces as a
304 Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)),394 // typed failure with the full explanation.
305 Effect.flatMap(395 Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)),
306 (resolution): Effect.Effect<string | null, StorageError> =>396 Effect.flatMap((resolution): Effect.Effect<string | null, StorageError> => {
307 resolution.kind === "ambiguous"397 if (resolution.kind === "ambiguous") {
308 ? Effect.fail(398 return Effect.fail(
309 new StorageError({399 new StorageError({
310 message: ambiguityMessage(id, resolution.matches),400 message: ambiguityMessage(id, resolution.matches),
311 cause: undefined,401 cause: undefined,
312 }),402 }),
313 )403 );
314 : Effect.succeed(resolution.kind === "resolved" ? resolution.value : null),404 }
315 ),405 if (resolution.kind === "ambiguous-vault") {
316 );406 return Effect.fail(
317 }),407 new StorageError({
318 ),408 message: vaultAmbiguityMessage(resolution),
409 cause: undefined,
410 }),
411 );
412 }
413 return Effect.succeed(resolution.kind === "resolved" ? resolution.value : null);
414 }),
415 );
416 }),
417 ),
319 418
320 list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>419 list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> =>
321 ctx.storage.getConfig().pipe(420 ctx.storage.getConfig().pipe(
322 Effect.flatMap((config) => {421 Effect.flatMap((config) => {
323 if (!config) return Effect.succeed([] as readonly ProviderEntry[]);422 if (!config) return Effect.succeed([] as readonly ProviderEntry[]);
324 return getServiceFromConfig(config, timeoutMs, preferSdk).pipe(423 const multipleAccounts = config.accounts.length > 1;
325 Effect.flatMap((svc) =>424 return Effect.forEach(config.accounts, (account) =>
326 Effect.forEach(config.vaults, (vault) =>425 serviceFor(account).pipe(
327 svc.listItems(vault.id).pipe(426 Effect.flatMap((svc) =>
328 Effect.map((items) =>427 Effect.forEach(account.vaults, (vault) =>
329 items.map(428 svc.listItems(vault.id).pipe(
330 // Vault-qualified ids: picking an entry permanently429 Effect.map((items) =>
331 // records which vault it came from, so identically-titled430 items.map(
332 // items in different vaults can never collide.431 // Vault-qualified ids: picking an entry permanently
333 (item): ProviderEntry => ({432 // records which vault it came from, so identically-titled
334 id: ProviderItemId.make(`op://${vault.id}/${item.id}`),433 // items in different vaults can never collide.
335 name: item.title,434 (item): ProviderEntry => ({
336 group: vault.name,435 id: ProviderItemId.make(`op://${vault.id}/${item.id}`),
337 }),436 name: item.title,
437 group: multipleAccounts ? `${account.name} · ${vault.name}` : vault.name,
438 }),
439 ),
440 ),
338 ),441 ),
339 ),442 ),
340 ),443 ),
444 Effect.map((groups) => groups.flat()),
445 // One unreachable account must not hide the others' items.
446 Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
341 ),447 ),
342 ),448 ).pipe(Effect.map((groups): readonly ProviderEntry[] => groups.flat()));
343 Effect.map((groups): readonly ProviderEntry[] => groups.flat()),449 }),
344 );450 Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),
345 }),451 ),
346 Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])),452 };
347 ),453};
348});
349 454
350// ---------------------------------------------------------------------------455// ---------------------------------------------------------------------------
351// Owner resolution — config is a single shared 1Password binding. We persist456// Owner resolution — config is a single shared 1Password binding. We persist
@@ -361,8 +466,73 @@ const makeOnePasswordExtension = ( · 9 unmodified lines
361 timeoutMs: number,466 timeoutMs: number,
362 preferSdk: boolean | undefined,467 preferSdk: boolean | undefined,
363) => {468) => {
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
364 return {504 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 }),
366 536
367 getConfig: (): Effect.Effect<537 getConfig: (): Effect.Effect<
368 RedactedOnePasswordConfig | null,538 RedactedOnePasswordConfig | null,
@@ -370,7 +540,22 @@ const makeOnePasswordExtension = ( · 1 unmodified line
370 > =>540 > =>
371 ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))),541 ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))),
372 542
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 },
374 559
375 status: () =>560 status: () =>
376 Effect.gen(function* () {561 Effect.gen(function* () {
@@ -378,21 +563,18 @@ const makeOnePasswordExtension = ( · 1 unmodified line
378 if (!config) {563 if (!config) {
379 return ConnectionStatus.make({564 return ConnectionStatus.make({
380 connected: false,565 connected: false,
566 accounts: [],
381 error: "Not configured",567 error: "Not configured",
382 });568 });
383 }569 }
384 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);570 const accounts = yield* Effect.forEach(config.accounts, accountStatus);
385 const live = yield* svc.listVaults();571 const broken = accounts.filter((account) => !account.connected);
386 const liveById = new Map(live.map((v) => [v.id, v.title]));
387 const missing = config.vaults.filter((vault) => !liveById.has(vault.id));
388 return ConnectionStatus.make({572 return ConnectionStatus.make({
389 connected: true,573 connected: broken.length === 0,
390 vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name),574 accounts,
391 ...(missing.length > 0575 ...(broken.length > 0
392 ? {576 ? {
393 error: `Configured vaults not found: ${missing577 error: `Unreachable accounts: ${broken.map((account) => account.name).join(", ")}`,
394 .map((vault) => vault.name)
395 .join(", ")}`,
396 }578 }
397 : {}),579 : {}),
398 });580 });
@@ -419,8 +601,7 @@ const makeOnePasswordExtension = ( · 20 unmodified lines
419 message: "1Password is not configured",601 message: "1Password is not configured",
420 });602 });
421 }603 }
422 const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk);604 const resolution = yield* resolveConfiguredRef(config, serviceFor, uri);
423 const resolution = yield* resolveConfiguredRef(svc, config, uri);
424 if (resolution.kind === "resolved") return resolution.value;605 if (resolution.kind === "resolved") return resolution.value;
425 if (resolution.kind === "outside-vaults") {606 if (resolution.kind === "outside-vaults") {
426 return yield* new OnePasswordError({607 return yield* new OnePasswordError({
@@ -434,6 +615,12 @@ const makeOnePasswordExtension = ( · 7 unmodified lines
434 message: ambiguityMessage(uri, resolution.matches),615 message: ambiguityMessage(uri, resolution.matches),
435 });616 });
436 }617 }
618 if (resolution.kind === "ambiguous-vault") {
619 return yield* new OnePasswordError({
620 operation: "resolve",
621 message: vaultAmbiguityMessage(resolution),
622 });
623 }
437 return yield* new OnePasswordError({624 return yield* new OnePasswordError({
438 operation: "resolve",625 operation: "resolve",
439 message: `1Password item "${uri}" was not found in the configured vaults`,626 message: `1Password item "${uri}" was not found in the configured vaults`,
@@ -475,7 +662,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 35 unmodified lines
475 tool({662 tool({
476 name: "status",663 name: "status",
477 description:664 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.",
479 outputSchema: OnePasswordStatusOutputStd,666 outputSchema: OnePasswordStatusOutputStd,
480 execute: () => Effect.map(self.status(), ToolResult.ok),667 execute: () => Effect.map(self.status(), ToolResult.ok),
481 }),668 }),
@@ -498,29 +685,36 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines
498 tool({685 tool({
499 name: "configure",686 name: "configure",
500 description:687 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.",
502 annotations: {689 annotations: {
503 requiresApproval: true,690 requiresApproval: true,
504 approvalDescription: "Configure the 1Password credential provider",691 approvalDescription: "Configure a 1Password credential provider account",
505 },692 },
506 inputSchema: OnePasswordConfigureInputStd,693 inputSchema: OnePasswordConfigureInputStd,
507 outputSchema: OnePasswordConfigureOutputStd,694 outputSchema: OnePasswordConfigureOutputStd,
508 execute: (input) =>695 execute: (input) =>
509 Effect.as(696 Effect.map(
510 self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }),697 self.configure({
511 ToolResult.ok({ configured: true }),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 }),
512 ),704 ),
513 }),705 }),
514 tool({706 tool({
515 name: "removeConfig",707 name: "removeConfig",
516 description:708 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.",
518 annotations: {710 annotations: {
519 requiresApproval: true,711 requiresApproval: true,
520 approvalDescription: "Remove the 1Password credential provider configuration",712 approvalDescription: "Remove 1Password credential provider configuration",
521 },713 },
714 inputSchema: OnePasswordRemoveConfigInputStd,
522 outputSchema: OnePasswordRemoveConfigOutputStd,715 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 })),
524 }),718 }),
525 ],719 ],
526 },720 },
packages/plugins/onepassword/src/sdk/types.ts+96−28
@@ -35,23 +35,50 @@ export const Vault = Schema.Struct({ · 34 unmodified lines
35export type Vault = typeof Vault.Type;35export type Vault = typeof Vault.Type;
36 36
37// ---------------------------------------------------------------------------37// ---------------------------------------------------------------------------
38// Stored config — persisted via KV38// 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.
39// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
40 42
41export const OnePasswordConfig = Schema.Struct({43export 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,
42 auth: OnePasswordAuth,50 auth: OnePasswordAuth,
43 /** Vaults to scope operations to. Order is presentational only: refs are51 /** Vaults to scope operations to. Order is presentational only: refs are
44 * vault-qualified, and a bare ref that matches in more than one vault is an52 * vault-qualified, and a bare ref that matches in more than one vault is an
45 * explicit ambiguity failure, never a precedence pick. */53 * explicit ambiguity failure, never a precedence pick. */
46 vaults: Schema.NonEmptyArray(Vault),54 vaults: Schema.NonEmptyArray(Vault),
47 /** Human label for the whole connection */55});
48 name: Schema.String,56export type OnePasswordAccount = typeof OnePasswordAccount.Type;
57
58/** The account id every pre-multi-account config normalizes onto. */
59export const DEFAULT_ACCOUNT_ID = "default";
60
61// ---------------------------------------------------------------------------
62// Stored config — persisted via KV
63// ---------------------------------------------------------------------------
64
65export const OnePasswordConfig = Schema.Struct({
66 accounts: Schema.NonEmptyArray(OnePasswordAccount),
49});67});
50export type OnePasswordConfig = typeof OnePasswordConfig.Type;68export type OnePasswordConfig = typeof OnePasswordConfig.Type;
51 69
52/** Pre-multi-vault stored shape: a single vault id whose display name doubled70/** Single-account stored shape (multi-vault, pre-multi-account). Still
53 * as the connection label. Still accepted on read; every save writes the71 * accepted on read; every save writes the current shape, so a config row
54 * current shape, so a config row upgrades the first time it is re-saved. */72 * upgrades the first time it is re-saved. */
73export const SingleAccountOnePasswordConfig = Schema.Struct({
74 auth: OnePasswordAuth,
75 vaults: Schema.NonEmptyArray(Vault),
76 name: Schema.String,
77});
78export type SingleAccountOnePasswordConfig = typeof SingleAccountOnePasswordConfig.Type;
79
80/** Original stored shape: a single vault id whose display name doubled as the
81 * connection label. */
55export const LegacyOnePasswordConfig = Schema.Struct({82export const LegacyOnePasswordConfig = Schema.Struct({
56 auth: OnePasswordAuth,83 auth: OnePasswordAuth,
57 vaultId: Schema.String,84 vaultId: Schema.String,
@@ -59,17 +86,33 @@ export const LegacyOnePasswordConfig = Schema.Struct({ · 1 unmodified line
59});86});
60export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type;87export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type;
61 88
62export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]);89export const StoredOnePasswordConfig = Schema.Union([
90 OnePasswordConfig,
91 SingleAccountOnePasswordConfig,
92 LegacyOnePasswordConfig,
93]);
63export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type;94export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type;
64 95
65export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig =>96export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => {
66 "vaultId" in stored97 if ("accounts" in stored) return stored;
67 ? {98 if ("vaultId" in stored) {
68 auth: stored.auth,99 return {
69 vaults: [{ id: stored.vaultId, name: stored.name }],100 accounts: [
70 name: stored.name,101 {
71 }102 id: DEFAULT_ACCOUNT_ID,
72 : stored;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};
73 116
74// ---------------------------------------------------------------------------117// ---------------------------------------------------------------------------
75// Redacted config — what `getConfig` returns to agents / the UI. The118// Redacted config — what `getConfig` returns to agents / the UI. The
@@ -88,30 +131,55 @@ export const RedactedOnePasswordAuth = Schema.Union([ · 12 unmodified lines
88 RedactedServiceAccountAuth,131 RedactedServiceAccountAuth,
89]);132]);
90 133
91export const RedactedOnePasswordConfig = Schema.Struct({134export const RedactedOnePasswordAccount = Schema.Struct({
135 id: Schema.String,
136 name: Schema.String,
92 auth: RedactedOnePasswordAuth,137 auth: RedactedOnePasswordAuth,
93 vaults: Schema.NonEmptyArray(Vault),138 vaults: Schema.NonEmptyArray(Vault),
94 name: Schema.String,139});
140export type RedactedOnePasswordAccount = typeof RedactedOnePasswordAccount.Type;
141
142export const RedactedOnePasswordConfig = Schema.Struct({
143 accounts: Schema.NonEmptyArray(RedactedOnePasswordAccount),
95});144});
96export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type;145export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type;
97 146
98/** Strip the service-account token from a stored config for external exposure. */147const redactAuth = (auth: OnePasswordAuth): typeof RedactedOnePasswordAuth.Type =>
99export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => ({148 auth.kind === "desktop-app"
100 auth:149 ? { kind: "desktop-app", accountName: auth.accountName }
101 config.auth.kind === "desktop-app"150 : { kind: "service-account" };
102 ? { kind: "desktop-app", accountName: config.auth.accountName }151
103 : { kind: "service-account" },152export const redactAccount = (account: OnePasswordAccount): RedactedOnePasswordAccount => ({
104 vaults: config.vaults,153 id: account.id,
105 name: config.name,154 name: account.name,
155 auth: redactAuth(account.auth),
156 vaults: account.vaults,
106});157});
107 158
159/** Strip the service-account tokens from a stored config for external exposure. */
160export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => {
161 const [first, ...rest] = config.accounts;
162 return { accounts: [redactAccount(first), ...rest.map(redactAccount)] };
163};
164
108// ---------------------------------------------------------------------------165// ---------------------------------------------------------------------------
109// Connection status166// Connection status — reported per account so one unreachable account never
167// masks (or fakes) the health of another.
110// ---------------------------------------------------------------------------168// ---------------------------------------------------------------------------
111 169
112export const ConnectionStatus = Schema.Struct({170export const AccountStatus = Schema.Struct({
171 id: Schema.String,
172 name: Schema.String,
113 connected: Schema.Boolean,173 connected: Schema.Boolean,
114 vaultNames: Schema.optional(Schema.Array(Schema.String)),174 vaultNames: Schema.optional(Schema.Array(Schema.String)),
115 error: Schema.optional(Schema.String),175 error: Schema.optional(Schema.String),
116});176});
177export type AccountStatus = typeof AccountStatus.Type;
178
179export 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});
117export type ConnectionStatus = typeof ConnectionStatus.Type;185export type ConnectionStatus = typeof ConnectionStatus.Type;