import { Cache, Data, Duration, Effect, Exit, Schema } from "effect"; import { definePlugin, StorageError, ToolResult, tool, ProviderItemId, ProviderKey, type CredentialProvider, type Owner, type PluginCtx, type PluginBlobStore, type ProviderEntry, type StaticToolSchema, type StorageFailure,} from "@executor-js/sdk/core"; import { OnePasswordAccount, OnePasswordAuth, OnePasswordConfig, RedactedOnePasswordConfig, StoredOnePasswordConfig, Vault, ConnectionStatus, AccountStatus, normalizeStoredConfig, redactConfig,} from "./types";import { OnePasswordError } from "./errors";import { makeOnePasswordService, type ResolvedAuth, type OnePasswordService } from "./service"; // ---------------------------------------------------------------------------// Constants// --------------------------------------------------------------------------- const CREDENTIAL_FIELD = "credential";const DEFAULT_TIMEOUT_MS = 15_000;// How long a resolved secret may be served from memory before 1Password is// asked again. Every 1Password read is at least one `op` spawn or SDK IPC// round trip (~1s with desktop-app auth), and the executor resolves the// connection's credential on every tool call — uncached, a 1Password-backed// connection multiplied each call's latency several times over. One minute// keeps a revoked or rotated item's window small while collapsing an agent's// call burst onto a single backend read. This cache is deliberately scoped to// this plugin; other credential providers stay uncached.const DEFAULT_SECRET_CACHE_TTL_MS = 60_000;const SECRET_CACHE_CAPACITY = 256;const SERVICE_CACHE_CAPACITY = 16;const CONFIG_KEY = "config";const PROVIDER_KEY = ProviderKey.make("onepassword"); const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToolSchema<A, I> => Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< A, I >; // ---------------------------------------------------------------------------// Upsert payload — `configure` adds or replaces one account. The id is// generated on first save so the caller can address the account later// (edit, remove) without the name doubling as an identifier.// --------------------------------------------------------------------------- export const OnePasswordAccountUpsert = Schema.Struct({ id: Schema.optional(Schema.String), name: Schema.String, auth: OnePasswordAuth, vaults: Schema.NonEmptyArray(Vault),});export type OnePasswordAccountUpsert = typeof OnePasswordAccountUpsert.Type; const OnePasswordConfigureInput = OnePasswordAccountUpsert; const OnePasswordConfigureOutput = Schema.Struct({ configured: Schema.Boolean, accountId: Schema.String,}); const OnePasswordGetConfigOutput = Schema.Struct({ config: Schema.NullOr(RedactedOnePasswordConfig),}); const OnePasswordListVaultsInput = OnePasswordAuth; const OnePasswordListVaultsOutput = Schema.Struct({ vaults: Schema.Array(Vault),}); const OnePasswordRemoveConfigInput = Schema.Struct({ accountId: Schema.optional(Schema.String),}); const OnePasswordRemoveConfigOutput = Schema.Struct({ removed: Schema.Boolean,}); const OnePasswordStatusOutput = ConnectionStatus; const OnePasswordConfigureInputStd = schemaToStaticToolSchema< typeof OnePasswordConfigureInput.Type, typeof OnePasswordConfigureInput.Encoded>(OnePasswordConfigureInput);const OnePasswordConfigureOutputStd = schemaToStaticToolSchema(OnePasswordConfigureOutput);const OnePasswordGetConfigOutputStd = schemaToStaticToolSchema(OnePasswordGetConfigOutput);const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< typeof OnePasswordListVaultsInput.Type, typeof OnePasswordListVaultsInput.Encoded>(OnePasswordListVaultsInput);const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput);const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema< typeof OnePasswordRemoveConfigInput.Type, typeof OnePasswordRemoveConfigInput.Encoded>(OnePasswordRemoveConfigInput);const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput);const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); // ---------------------------------------------------------------------------// Shared failure alias.//// Every extension method either touches storage (`ctx.storage` blobs) or// reaches the 1Password backend. Storage I/O surfaces as `StorageFailure`;// the HTTP edge (`withCapture`) translates `StorageError` to// `InternalError({ traceId })`. Domain problems (not configured, backend RPC// failure) stay as `OnePasswordError` and encode to 502 via the schema// annotation on the class.// --------------------------------------------------------------------------- export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; // ---------------------------------------------------------------------------// Typed config store — single blob, JSON encoded, owner-partitioned. The// stored config carries every account's auth credential (desktop account// name, or service-account token) plus its selected vaults. v1 keyed this by// executor scope; v2 partitions by `owner` — the plugin-owned config row owns// the partition, mirroring the connection model. Reads also accept the two// pre-multi-account shapes and normalize them onto a single "default"// account; saves always write the current shape. Blob I/O failures surface as// `StorageError`; decode failures stay `OnePasswordError`.// --------------------------------------------------------------------------- export interface OnePasswordStore { readonly getConfig: () => Effect.Effect< OnePasswordConfig | null, StorageError | OnePasswordError >; readonly saveConfig: ( config: OnePasswordConfig, owner: Owner, ) => Effect.Effect<void, StorageError>; readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>;} const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig)); const blobStorageError = (operation: string) => (cause: unknown): StorageError => new StorageError({ message: `onepassword blob ${operation} failed`, cause, }); export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore => ({ getConfig: () => blobs.get(CONFIG_KEY).pipe( Effect.mapError(blobStorageError("read")), Effect.flatMap((raw) => { if (raw === null) return Effect.succeed(null); return decodeConfig(raw).pipe( Effect.map(normalizeStoredConfig), Effect.mapError( () => new OnePasswordError({ operation: "config decode", message: "Failed to decode 1Password config", }), ), ); }), ), saveConfig: (config, owner) => blobs .put(CONFIG_KEY, JSON.stringify({ accounts: config.accounts }), { owner }) .pipe(Effect.mapError(blobStorageError("write"))), deleteConfig: (owner) => blobs.delete(CONFIG_KEY, { owner }).pipe(Effect.mapError(blobStorageError("delete"))),}); // ---------------------------------------------------------------------------// Helpers — auth resolution + service construction// --------------------------------------------------------------------------- const resolveAuth = (auth: OnePasswordAuth): ResolvedAuth => auth.kind === "desktop-app" ? { kind: "desktop-app", accountName: auth.accountName } : { kind: "service-account", token: auth.token }; /** Cache keys with structural equality (`Data.Class`), one per auth shape so * the two kinds can never collide. Every account row carrying the same * credential identity maps onto one cached service. */class DesktopAuthKey extends Data.Class<{ readonly accountName: string }> {}class ServiceAccountKey extends Data.Class<{ readonly token: string }> {}type AuthKey = DesktopAuthKey | ServiceAccountKey; const authKey = (auth: ResolvedAuth): AuthKey => auth.kind === "desktop-app" ? new DesktopAuthKey({ accountName: auth.accountName }) : new ServiceAccountKey({ token: auth.token }); const authFromKey = (key: AuthKey): ResolvedAuth => key instanceof DesktopAuthKey ? { kind: "desktop-app", accountName: key.accountName } : { kind: "service-account", token: key.token }; /** One service per auth identity, memoized for the plugin instance's * lifetime. Keying by the resolved auth keeps accounts isolated (a shared * client can never leak one account's credential into another's calls) while * reusing the constructed service — and with it the SDK backend's cached * client — across calls instead of re-authenticating per resolution. * Construction failures are not retained, so a transient backend problem * does not poison the account until restart. */export const makeServiceForAuth = ( timeoutMs: number, preferSdk: boolean | undefined,): ((auth: OnePasswordAuth) => Effect.Effect<OnePasswordService, OnePasswordError>) => { const cache = Effect.runSync( Cache.makeWith( (key: AuthKey) => makeOnePasswordService(authFromKey(key), { timeoutMs, preferSdk }), { capacity: SERVICE_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), }, ), ); return (auth) => Cache.get(cache, authKey(resolveAuth(auth)));}; type ServiceForAccount = ( account: OnePasswordAccount,) => Effect.Effect<OnePasswordService, OnePasswordError>; // ---------------------------------------------------------------------------// Explicit ref resolution.//// A ref is one of:// - `op://vault/item/field...` — fully qualified, resolved as-is via the// account that owns the vault. The first// segment stays a VAULT (id or name),// matching 1Password's own op:// semantics —// account selection is derived, never// encoded into the ref.// - `op://vault/item` — picker-shaped; the default credential field// is appended. This is the id shape `list()`// hands out, so every picked item permanently// records which vault it came from.// - a bare item id or title — located by listing every account's// configured vaults. Exactly one match// resolves; several matches are an explicit// ambiguity failure naming the vaults — never// a precedence pick.// --------------------------------------------------------------------------- export type RefResolution = | { readonly kind: "resolved"; readonly value: string } | { readonly kind: "not-found" } | { readonly kind: "outside-vaults" } | { readonly kind: "ambiguous"; readonly matches: readonly { readonly accountName: string; readonly vaultId: string; readonly vaultName: string; readonly itemId: string; readonly itemTitle: string; }[]; } | { readonly kind: "ambiguous-vault"; readonly vaultName: string; readonly matches: readonly { readonly accountName: string; readonly vaultId: string; }[]; }; export const ambiguityMessage = ( ref: string, matches: Extract<RefResolution, { kind: "ambiguous" }>["matches"],): string => [ `1Password ref "${ref}" is ambiguous: it matches`, matches .map((m) => `"${m.itemTitle}" in vault "${m.vaultName}" (account "${m.accountName}")`) .join(", "), `. Use op://<vaultId>/<itemId> to pick one.`, ].join(" "); export const vaultAmbiguityMessage = ( resolution: Extract<RefResolution, { kind: "ambiguous-vault" }>,): string => [ `1Password vault name "${resolution.vaultName}" is configured in more than one account:`, resolution.matches.map((m) => `"${m.accountName}" (vault id ${m.vaultId})`).join(", "), `. Use op://<vaultId>/... to pick one.`, ].join(" "); /** Resolve a ref against every configured account. Backend failures stay on * the error channel; every addressing outcome is an explicit * `RefResolution`. Services are built per account and only for the accounts * a ref actually needs. */export const resolveConfiguredRef = ( config: OnePasswordConfig, serviceFor: (account: OnePasswordAccount) => Effect.Effect<OnePasswordService, OnePasswordError>, ref: string,): Effect.Effect<RefResolution, OnePasswordError> => { if (ref.startsWith("op://")) { const segments = ref.slice("op://".length).split("/"); const vaultSegment = segments[0]; if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { return Effect.succeed({ kind: "not-found" }); } const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; // Vault ids are globally unique, so an id-addressed ref names one vault no // matter how many accounts configure it — any owning account can serve it. const idOwner = config.accounts.find((account) => account.vaults.some((vault) => vault.id === vaultSegment), ); if (idOwner !== undefined) { return serviceFor(idOwner).pipe( Effect.flatMap((svc) => svc.resolveSecret(uri)), Effect.map((value): RefResolution => ({ kind: "resolved", value })), ); } // Vault names are only unique per account: the same name in two accounts // is two different vaults, so a name-addressed ref must be an explicit // ambiguity, never a precedence pick. const nameOwners = config.accounts.flatMap((account) => account.vaults .filter((vault) => vault.name === vaultSegment) .map((vault) => ({ account, vault })), ); const [onlyOwner, ...extraOwners] = nameOwners; if (onlyOwner === undefined) return Effect.succeed({ kind: "outside-vaults" }); if (extraOwners.length > 0) { return Effect.succeed({ kind: "ambiguous-vault", vaultName: vaultSegment, matches: nameOwners.map((owner) => ({ accountName: owner.account.name, vaultId: owner.vault.id, })), }); } return serviceFor(onlyOwner.account).pipe( Effect.flatMap((svc) => svc.resolveSecret(uri)), Effect.map((value): RefResolution => ({ kind: "resolved", value })), ); } return Effect.gen(function* () { const matches = (yield* Effect.forEach(config.accounts, (account) => serviceFor(account).pipe( Effect.flatMap((svc) => Effect.forEach(account.vaults, (vault) => svc.listItems(vault.id).pipe( Effect.map((items) => items .filter((item) => item.id === ref || item.title === ref) .map((item) => ({ accountId: account.id, accountName: account.name, vaultId: vault.id, vaultName: vault.name, itemId: item.id, itemTitle: item.title, })), ), ), ), ), Effect.map((groups) => groups.flat()), ), )).flat(); const [only, ...extra] = matches; if (only === undefined) return { kind: "not-found" } as const; if (extra.length > 0) { return { kind: "ambiguous", matches: matches.map(({ accountId: _accountId, ...match }) => match), } as const; } const owner = config.accounts.find((account) => account.id === only.accountId); if (owner === undefined) return { kind: "not-found" } as const; const svc = yield* serviceFor(owner); const value = yield* svc.resolveSecret( `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, ); return { kind: "resolved", value } as const; });}; // ---------------------------------------------------------------------------// Cached ref resolution — the hot path.//// The executor resolves a connection's credential on EVERY tool call, and for// this provider each resolution is at least one `op` spawn or SDK IPC round// trip (~1s under desktop-app auth; a bare ref pays a per-vault item listing// on top). Successful resolutions are therefore served from memory for a// short TTL. The cache is keyed by (config fingerprint, ref): editing or// removing an account changes the fingerprint, which drops the whole cached// generation immediately — a removed account's secrets never outlive the// config that granted them, and no explicit invalidation wiring is needed.// Only `kind: "resolved"` entries get the TTL; not-found, ambiguity, and// failures are never retained, so a just-created item resolves on the next// call. Concurrent lookups for the same key share one backend read even when// the TTL is 0.// --------------------------------------------------------------------------- export type CachedRefResolver = ( config: OnePasswordConfig, ref: string,) => Effect.Effect<RefResolution, OnePasswordError>; export const makeCachedRefResolver = ( serviceFor: ServiceForAccount, ttlMs: number,): CachedRefResolver => { // Single generation: config edits are rare and refs from an older config // must not be served, so the previous generation's cache is discarded // rather than kept alongside. let generation: { readonly fingerprint: string; readonly cache: Cache.Cache<string, RefResolution, OnePasswordError>; } | null = null; return (config, ref) => Effect.suspend(() => { const fingerprint = JSON.stringify(config.accounts); if (generation === null || generation.fingerprint !== fingerprint) { generation = { fingerprint, cache: Effect.runSync( Cache.makeWith((key: string) => resolveConfiguredRef(config, serviceFor, key), { capacity: SECRET_CACHE_CAPACITY, timeToLive: (exit) => Exit.isSuccess(exit) && exit.value.kind === "resolved" ? Duration.millis(ttlMs) : Duration.zero, }), ), }; } return Cache.get(generation.cache, ref); });}; // ---------------------------------------------------------------------------// CredentialProvider — read-only, resolves op:// URIs or vault-scoped lookups.//// v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is// a vault-qualified `op://` ref (what `list()` hands out) or a bare item// id/title that must locate exactly one item across every account's// configured vaults. The plugin's stored config supplies the auth + vault// bindings; the provider never writes (writable: false).// --------------------------------------------------------------------------- const makeProvider = ( ctx: PluginCtx<OnePasswordStore>, serviceFor: ServiceForAccount, resolveRef: CachedRefResolver,): CredentialProvider => { return { key: PROVIDER_KEY, writable: false, get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => ctx.storage.getConfig().pipe( // An undecodable stored config reads as "not configured" here; the // settings surface reports the decode problem. Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), Effect.flatMap((config) => { if (!config) return Effect.succeed(null as string | null); return resolveRef(config, id).pipe( // Backend unreachability degrades to "no value", matching the other // providers. Ambiguity does NOT: silently picking a vault (or // silently failing) hides a real conflict, so it surfaces as a // typed failure with the full explanation. Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), Effect.flatMap((resolution): Effect.Effect<string | null, StorageError> => { if (resolution.kind === "ambiguous") { return Effect.fail(Lines 1–500 of 841. Load more
import { Cache, Data, Duration, Effect, Exit, Schema } from "effect"; import { definePlugin, StorageError, ToolResult, tool, ProviderItemId, ProviderKey, type CredentialProvider, type Owner, type PluginCtx, type PluginBlobStore, type ProviderEntry, type StaticToolSchema, type StorageFailure, } from "@executor-js/sdk/core"; import { OnePasswordAccount, OnePasswordAuth, OnePasswordConfig, RedactedOnePasswordConfig, StoredOnePasswordConfig, Vault, ConnectionStatus, AccountStatus, normalizeStoredConfig, redactConfig, } from "./types"; import { OnePasswordError } from "./errors"; import { makeOnePasswordService, type ResolvedAuth, type OnePasswordService } from "./service"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CREDENTIAL_FIELD = "credential"; const DEFAULT_TIMEOUT_MS = 15_000; // How long a resolved secret may be served from memory before 1Password is // asked again. Every 1Password read is at least one `op` spawn or SDK IPC // round trip (~1s with desktop-app auth), and the executor resolves the // connection's credential on every tool call — uncached, a 1Password-backed // connection multiplied each call's latency several times over. One minute // keeps a revoked or rotated item's window small while collapsing an agent's // call burst onto a single backend read. This cache is deliberately scoped to // this plugin; other credential providers stay uncached. const DEFAULT_SECRET_CACHE_TTL_MS = 60_000; const SECRET_CACHE_CAPACITY = 256; const SERVICE_CACHE_CAPACITY = 16; const CONFIG_KEY = "config"; const PROVIDER_KEY = ProviderKey.make("onepassword"); const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToolSchema<A, I> => Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< A, I >; // --------------------------------------------------------------------------- // Upsert payload — `configure` adds or replaces one account. The id is // generated on first save so the caller can address the account later // (edit, remove) without the name doubling as an identifier. // --------------------------------------------------------------------------- export const OnePasswordAccountUpsert = Schema.Struct({ id: Schema.optional(Schema.String), name: Schema.String, auth: OnePasswordAuth, vaults: Schema.NonEmptyArray(Vault), }); export type OnePasswordAccountUpsert = typeof OnePasswordAccountUpsert.Type; const OnePasswordConfigureInput = OnePasswordAccountUpsert; const OnePasswordConfigureOutput = Schema.Struct({ configured: Schema.Boolean, accountId: Schema.String, }); const OnePasswordGetConfigOutput = Schema.Struct({ config: Schema.NullOr(RedactedOnePasswordConfig), }); const OnePasswordListVaultsInput = OnePasswordAuth; const OnePasswordListVaultsOutput = Schema.Struct({ vaults: Schema.Array(Vault), }); const OnePasswordRemoveConfigInput = Schema.Struct({ accountId: Schema.optional(Schema.String), }); const OnePasswordRemoveConfigOutput = Schema.Struct({ removed: Schema.Boolean, }); const OnePasswordStatusOutput = ConnectionStatus; const OnePasswordConfigureInputStd = schemaToStaticToolSchema< typeof OnePasswordConfigureInput.Type, typeof OnePasswordConfigureInput.Encoded >(OnePasswordConfigureInput); const OnePasswordConfigureOutputStd = schemaToStaticToolSchema(OnePasswordConfigureOutput); const OnePasswordGetConfigOutputStd = schemaToStaticToolSchema(OnePasswordGetConfigOutput); const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< typeof OnePasswordListVaultsInput.Type, typeof OnePasswordListVaultsInput.Encoded >(OnePasswordListVaultsInput); const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput); const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema< typeof OnePasswordRemoveConfigInput.Type, typeof OnePasswordRemoveConfigInput.Encoded >(OnePasswordRemoveConfigInput); const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput); const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); // --------------------------------------------------------------------------- // Shared failure alias. // // Every extension method either touches storage (`ctx.storage` blobs) or // reaches the 1Password backend. Storage I/O surfaces as `StorageFailure`; // the HTTP edge (`withCapture`) translates `StorageError` to // `InternalError({ traceId })`. Domain problems (not configured, backend RPC // failure) stay as `OnePasswordError` and encode to 502 via the schema // annotation on the class. // --------------------------------------------------------------------------- export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; // --------------------------------------------------------------------------- // Typed config store — single blob, JSON encoded, owner-partitioned. The // stored config carries every account's auth credential (desktop account // name, or service-account token) plus its selected vaults. v1 keyed this by // executor scope; v2 partitions by `owner` — the plugin-owned config row owns // the partition, mirroring the connection model. Reads also accept the two // pre-multi-account shapes and normalize them onto a single "default" // account; saves always write the current shape. Blob I/O failures surface as // `StorageError`; decode failures stay `OnePasswordError`. // --------------------------------------------------------------------------- export interface OnePasswordStore { readonly getConfig: () => Effect.Effect< OnePasswordConfig | null, StorageError | OnePasswordError >; readonly saveConfig: ( config: OnePasswordConfig, owner: Owner, ) => Effect.Effect<void, StorageError>; readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>; } const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig)); const blobStorageError = (operation: string) => (cause: unknown): StorageError => new StorageError({ message: `onepassword blob ${operation} failed`, cause, }); export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore => ({ getConfig: () => blobs.get(CONFIG_KEY).pipe( Effect.mapError(blobStorageError("read")), Effect.flatMap((raw) => { if (raw === null) return Effect.succeed(null); return decodeConfig(raw).pipe( Effect.map(normalizeStoredConfig), Effect.mapError( () => new OnePasswordError({ operation: "config decode", message: "Failed to decode 1Password config", }), ), ); }), ), saveConfig: (config, owner) => blobs .put(CONFIG_KEY, JSON.stringify({ accounts: config.accounts }), { owner }) .pipe(Effect.mapError(blobStorageError("write"))), deleteConfig: (owner) => blobs.delete(CONFIG_KEY, { owner }).pipe(Effect.mapError(blobStorageError("delete"))), }); // --------------------------------------------------------------------------- // Helpers — auth resolution + service construction // --------------------------------------------------------------------------- const resolveAuth = (auth: OnePasswordAuth): ResolvedAuth => auth.kind === "desktop-app" ? { kind: "desktop-app", accountName: auth.accountName } : { kind: "service-account", token: auth.token }; /** Cache keys with structural equality (`Data.Class`), one per auth shape so * the two kinds can never collide. Every account row carrying the same * credential identity maps onto one cached service. */ class DesktopAuthKey extends Data.Class<{ readonly accountName: string }> {} class ServiceAccountKey extends Data.Class<{ readonly token: string }> {} type AuthKey = DesktopAuthKey | ServiceAccountKey; const authKey = (auth: ResolvedAuth): AuthKey => auth.kind === "desktop-app" ? new DesktopAuthKey({ accountName: auth.accountName }) : new ServiceAccountKey({ token: auth.token }); const authFromKey = (key: AuthKey): ResolvedAuth => key instanceof DesktopAuthKey ? { kind: "desktop-app", accountName: key.accountName } : { kind: "service-account", token: key.token }; /** One service per auth identity, memoized for the plugin instance's * lifetime. Keying by the resolved auth keeps accounts isolated (a shared * client can never leak one account's credential into another's calls) while * reusing the constructed service — and with it the SDK backend's cached * client — across calls instead of re-authenticating per resolution. * Construction failures are not retained, so a transient backend problem * does not poison the account until restart. */ export const makeServiceForAuth = ( timeoutMs: number, preferSdk: boolean | undefined, ): ((auth: OnePasswordAuth) => Effect.Effect<OnePasswordService, OnePasswordError>) => { const cache = Effect.runSync( Cache.makeWith( (key: AuthKey) => makeOnePasswordService(authFromKey(key), { timeoutMs, preferSdk }), { capacity: SERVICE_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), }, ), ); return (auth) => Cache.get(cache, authKey(resolveAuth(auth))); }; type ServiceForAccount = ( account: OnePasswordAccount, ) => Effect.Effect<OnePasswordService, OnePasswordError>; // --------------------------------------------------------------------------- // Explicit ref resolution. // // A ref is one of: // - `op://vault/item/field...` — fully qualified, resolved as-is via the // account that owns the vault. The first // segment stays a VAULT (id or name), // matching 1Password's own op:// semantics — // account selection is derived, never // encoded into the ref. // - `op://vault/item` — picker-shaped; the default credential field // is appended. This is the id shape `list()` // hands out, so every picked item permanently // records which vault it came from. // - a bare item id or title — located by listing every account's // configured vaults. Exactly one match // resolves; several matches are an explicit // ambiguity failure naming the vaults — never // a precedence pick. // --------------------------------------------------------------------------- export type RefResolution = | { readonly kind: "resolved"; readonly value: string } | { readonly kind: "not-found" } | { readonly kind: "outside-vaults" } | { readonly kind: "ambiguous"; readonly matches: readonly { readonly accountName: string; readonly vaultId: string; readonly vaultName: string; readonly itemId: string; readonly itemTitle: string; }[]; } | { readonly kind: "ambiguous-vault"; readonly vaultName: string; readonly matches: readonly { readonly accountName: string; readonly vaultId: string; }[]; }; export const ambiguityMessage = ( ref: string, matches: Extract<RefResolution, { kind: "ambiguous" }>["matches"], ): string => [ `1Password ref "${ref}" is ambiguous: it matches`, matches .map((m) => `"${m.itemTitle}" in vault "${m.vaultName}" (account "${m.accountName}")`) .join(", "), `. Use op://<vaultId>/<itemId> to pick one.`, ].join(" "); export const vaultAmbiguityMessage = ( resolution: Extract<RefResolution, { kind: "ambiguous-vault" }>, ): string => [ `1Password vault name "${resolution.vaultName}" is configured in more than one account:`, resolution.matches.map((m) => `"${m.accountName}" (vault id ${m.vaultId})`).join(", "), `. Use op://<vaultId>/... to pick one.`, ].join(" "); /** Resolve a ref against every configured account. Backend failures stay on * the error channel; every addressing outcome is an explicit * `RefResolution`. Services are built per account and only for the accounts * a ref actually needs. */ export const resolveConfiguredRef = ( config: OnePasswordConfig, serviceFor: (account: OnePasswordAccount) => Effect.Effect<OnePasswordService, OnePasswordError>, ref: string, ): Effect.Effect<RefResolution, OnePasswordError> => { if (ref.startsWith("op://")) { const segments = ref.slice("op://".length).split("/"); const vaultSegment = segments[0]; if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { return Effect.succeed({ kind: "not-found" }); } const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; // Vault ids are globally unique, so an id-addressed ref names one vault no // matter how many accounts configure it — any owning account can serve it. const idOwner = config.accounts.find((account) => account.vaults.some((vault) => vault.id === vaultSegment), ); if (idOwner !== undefined) { return serviceFor(idOwner).pipe( Effect.flatMap((svc) => svc.resolveSecret(uri)), Effect.map((value): RefResolution => ({ kind: "resolved", value })), ); } // Vault names are only unique per account: the same name in two accounts // is two different vaults, so a name-addressed ref must be an explicit // ambiguity, never a precedence pick. const nameOwners = config.accounts.flatMap((account) => account.vaults .filter((vault) => vault.name === vaultSegment) .map((vault) => ({ account, vault })), ); const [onlyOwner, ...extraOwners] = nameOwners; if (onlyOwner === undefined) return Effect.succeed({ kind: "outside-vaults" }); if (extraOwners.length > 0) { return Effect.succeed({ kind: "ambiguous-vault", vaultName: vaultSegment, matches: nameOwners.map((owner) => ({ accountName: owner.account.name, vaultId: owner.vault.id, })), }); } return serviceFor(onlyOwner.account).pipe( Effect.flatMap((svc) => svc.resolveSecret(uri)), Effect.map((value): RefResolution => ({ kind: "resolved", value })), ); } return Effect.gen(function* () { const matches = (yield* Effect.forEach(config.accounts, (account) => serviceFor(account).pipe( Effect.flatMap((svc) => Effect.forEach(account.vaults, (vault) => svc.listItems(vault.id).pipe( Effect.map((items) => items .filter((item) => item.id === ref || item.title === ref) .map((item) => ({ accountId: account.id, accountName: account.name, vaultId: vault.id, vaultName: vault.name, itemId: item.id, itemTitle: item.title, })), ), ), ), ), Effect.map((groups) => groups.flat()), ), )).flat(); const [only, ...extra] = matches; if (only === undefined) return { kind: "not-found" } as const; if (extra.length > 0) { return { kind: "ambiguous", matches: matches.map(({ accountId: _accountId, ...match }) => match), } as const; } const owner = config.accounts.find((account) => account.id === only.accountId); if (owner === undefined) return { kind: "not-found" } as const; const svc = yield* serviceFor(owner); const value = yield* svc.resolveSecret( `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, ); return { kind: "resolved", value } as const; }); }; // --------------------------------------------------------------------------- // Cached ref resolution — the hot path. // // The executor resolves a connection's credential on EVERY tool call, and for // this provider each resolution is at least one `op` spawn or SDK IPC round // trip (~1s under desktop-app auth; a bare ref pays a per-vault item listing // on top). Successful resolutions are therefore served from memory for a // short TTL. The cache is keyed by (config fingerprint, ref): editing or // removing an account changes the fingerprint, which drops the whole cached // generation immediately — a removed account's secrets never outlive the // config that granted them, and no explicit invalidation wiring is needed. // Only `kind: "resolved"` entries get the TTL; not-found, ambiguity, and // failures are never retained, so a just-created item resolves on the next // call. Concurrent lookups for the same key share one backend read even when // the TTL is 0. // --------------------------------------------------------------------------- export type CachedRefResolver = ( config: OnePasswordConfig, ref: string, ) => Effect.Effect<RefResolution, OnePasswordError>; export const makeCachedRefResolver = ( serviceFor: ServiceForAccount, ttlMs: number, ): CachedRefResolver => { // Single generation: config edits are rare and refs from an older config // must not be served, so the previous generation's cache is discarded // rather than kept alongside. let generation: { readonly fingerprint: string; readonly cache: Cache.Cache<string, RefResolution, OnePasswordError>; } | null = null; return (config, ref) => Effect.suspend(() => { const fingerprint = JSON.stringify(config.accounts); if (generation === null || generation.fingerprint !== fingerprint) { generation = { fingerprint, cache: Effect.runSync( Cache.makeWith((key: string) => resolveConfiguredRef(config, serviceFor, key), { capacity: SECRET_CACHE_CAPACITY, timeToLive: (exit) => Exit.isSuccess(exit) && exit.value.kind === "resolved" ? Duration.millis(ttlMs) : Duration.zero, }), ), }; } return Cache.get(generation.cache, ref); }); }; // --------------------------------------------------------------------------- // CredentialProvider — read-only, resolves op:// URIs or vault-scoped lookups. // // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is // a vault-qualified `op://` ref (what `list()` hands out) or a bare item // id/title that must locate exactly one item across every account's // configured vaults. The plugin's stored config supplies the auth + vault // bindings; the provider never writes (writable: false). // --------------------------------------------------------------------------- const makeProvider = ( ctx: PluginCtx<OnePasswordStore>, serviceFor: ServiceForAccount, resolveRef: CachedRefResolver, ): CredentialProvider => { return { key: PROVIDER_KEY, writable: false, get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => ctx.storage.getConfig().pipe( // An undecodable stored config reads as "not configured" here; the // settings surface reports the decode problem. Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), Effect.flatMap((config) => { if (!config) return Effect.succeed(null as string | null); return resolveRef(config, id).pipe( // Backend unreachability degrades to "no value", matching the other // providers. Ambiguity does NOT: silently picking a vault (or // silently failing) hides a real conflict, so it surfaces as a // typed failure with the full explanation. Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), Effect.flatMap((resolution): Effect.Effect<string | null, StorageError> => { if (resolution.kind === "ambiguous") { return Effect.fail(