import { Context, Duration, Effect, Semaphore } from "effect";import * as op from "@1password/op-js"; import { OnePasswordError } from "./errors";import type { OnePasswordSdkModule } from "./onepassword-sdk"; // ---------------------------------------------------------------------------// Canonical service interface — all backends (SDK, CLI) implement this// --------------------------------------------------------------------------- export interface OnePasswordVault { readonly id: string; readonly title: string;} export interface OnePasswordItem { readonly id: string; readonly title: string;} export interface OnePasswordService { /** Resolve a secret by op:// URI */ readonly resolveSecret: (uri: string) => Effect.Effect<string, OnePasswordError>; /** List accessible vaults */ readonly listVaults: () => Effect.Effect<ReadonlyArray<OnePasswordVault>, OnePasswordError>; /** List items in a vault */ readonly listItems: ( vaultId: string, ) => Effect.Effect<ReadonlyArray<OnePasswordItem>, OnePasswordError>;} export class OnePasswordServiceTag extends Context.Service< OnePasswordServiceTag, OnePasswordService>()("@executor-js/plugin-onepassword/OnePasswordService") {} // ---------------------------------------------------------------------------// Resolved auth — raw credentials ready for any backend// --------------------------------------------------------------------------- export type ResolvedAuth = | { readonly kind: "desktop-app"; readonly accountName: string } | { readonly kind: "service-account"; readonly token: string }; // ---------------------------------------------------------------------------// SDK backend — uses @1password/sdk native IPC// --------------------------------------------------------------------------- const DEFAULT_TIMEOUT_MS = 15_000;const MAX_ERROR_MESSAGE_LENGTH = 300;const SERVICE_ACCOUNT_TOKEN_RE = /ops_[A-Za-z0-9_-]+/g; const formatCause = (cause: unknown): string => { // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: normalizing untyped op-js/SDK throwables into OnePasswordError.message const maybeMessage = (cause as { readonly message?: unknown } | null | undefined)?.message; const raw = // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: last-resort stringification of a non-Error throwable typeof maybeMessage === "string" && maybeMessage.length > 0 ? maybeMessage : String(cause); return raw .replace(SERVICE_ACCOUNT_TOKEN_RE, "[redacted 1Password token]") .replace(/\s+/g, " ") .trim();}; const messageWithCause = (prefix: string, cause: unknown): string => { const causeMessage = formatCause(cause); const message = causeMessage ? `${prefix}: ${causeMessage}` : prefix; return message.length > MAX_ERROR_MESSAGE_LENGTH ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...` : message;}; const hasOnePasswordSdkShape = (value: unknown): value is OnePasswordSdkModule => { const sdk = value as Partial<OnePasswordSdkModule> | null | undefined; return typeof sdk?.createClient === "function" && typeof sdk.DesktopAuth === "function";}; const invalidOnePasswordSdkError = () => new OnePasswordError({ operation: "sdk module load", message: [ "Failed to load 1Password SDK: the packaged SDK module did not expose createClient and DesktopAuth.", "Install the 1Password CLI (`op`) in /opt/homebrew/bin or /usr/local/bin and retry, or update Executor.", ].join(" "), }); const loadOnePasswordSdk = (): Effect.Effect<OnePasswordSdkModule, OnePasswordError> => Effect.tryPromise({ try: () => import("./onepassword-sdk").then((module) => module.onePasswordSdk), catch: (cause) => new OnePasswordError({ operation: "sdk module load", message: messageWithCause("Failed to load 1Password SDK", cause), }), }).pipe( Effect.flatMap((sdk) => hasOnePasswordSdkShape(sdk) ? Effect.succeed(sdk) : Effect.fail(invalidOnePasswordSdkError()), ), ); const makeTimeoutMessage = (operation: string, timeoutMs: number): string => [ `${operation}: timed out after ${Math.floor(timeoutMs / 1000)}s.`, "Troubleshooting:", "1. Make sure the 1Password desktop app is open and unlocked", "2. Check for an approval prompt in the 1Password app — it may be behind other windows", "3. Ensure 'Developer > Connect with 1Password CLI' is enabled in 1Password Settings", "4. Make sure no other app or terminal is waiting for 1Password approval (only one prompt at a time)", "5. Try quitting 1Password completely and reopening it, then retry", ].join("\n"); const timeoutWithOnePasswordError = (operation: string, timeoutMs: number) => Effect.timeoutOrElse({ duration: Duration.millis(timeoutMs), orElse: () => Effect.fail( new OnePasswordError({ operation, message: makeTimeoutMessage(operation, timeoutMs), }), ), }); export const makeNativeSdkService = ( auth: ResolvedAuth, timeoutMs: number = DEFAULT_TIMEOUT_MS,): Effect.Effect<OnePasswordService, OnePasswordError> => Effect.gen(function* () { const sdk = yield* loadOnePasswordSdk().pipe( timeoutWithOnePasswordError("sdk module load", timeoutMs), ); const client = yield* Effect.tryPromise({ try: () => sdk.createClient({ auth: auth.kind === "desktop-app" ? new sdk.DesktopAuth(auth.accountName) : auth.token, integrationName: "Executor", integrationVersion: "0.0.0", }), catch: (cause) => new OnePasswordError({ operation: "client setup", message: messageWithCause("Failed to set up 1Password client", cause), }), }).pipe(timeoutWithOnePasswordError("client setup", timeoutMs)); const wrap = <A>(fn: () => Promise<A>, operation: string): Effect.Effect<A, OnePasswordError> => Effect.tryPromise({ try: fn, catch: (cause) => new OnePasswordError({ operation, message: messageWithCause(`1Password SDK ${operation} failed`, cause), }), }).pipe( timeoutWithOnePasswordError(operation, timeoutMs), Effect.withSpan(`onepassword.sdk.${operation}`), ); return OnePasswordServiceTag.of({ resolveSecret: (uri) => wrap(() => client.secrets.resolve(uri), "secret resolution"), listVaults: () => wrap(() => client.vaults.list({ decryptDetails: true }), "vault listing").pipe( Effect.map((vaults) => vaults.map((v) => ({ id: v.id, title: v.title }))), ), listItems: (vaultId) => wrap(() => client.items.list(vaultId), "item listing").pipe( Effect.map((items) => items.map((i) => ({ id: i.id, title: i.title }))), ), }); }).pipe(Effect.withSpan("onepassword.sdk.make_service")); // ---------------------------------------------------------------------------// CLI backend — uses @1password/op-js (shells out to `op` CLI)// --------------------------------------------------------------------------- const cliAuthLock = Semaphore.makeUnsafe(1); export const makeCliService = ( auth: ResolvedAuth,): Effect.Effect<OnePasswordService, OnePasswordError> => Effect.sync(() => { const wrapSync = <A>(fn: () => A, operation: string): Effect.Effect<A, OnePasswordError> => cliAuthLock .withPermits(1)( Effect.try({ try: () => { if (auth.kind === "service-account") { op.setGlobalFlags({}); op.setServiceAccount(auth.token); } else { op.setServiceAccount(""); op.setGlobalFlags({ account: auth.accountName }); } return fn(); }, catch: (cause) => new OnePasswordError({ operation, message: messageWithCause(`1Password CLI ${operation} failed`, cause), }), }).pipe( // `op-js` keeps the service-account token in a PROCESS-GLOBAL // (`cli.serviceAccountToken`, a field on the module's single CLI // instance) and reads it when spawning `op`. Nothing in the library // clears it, so a token set to serve one resolve stayed readable for // the rest of the executor's life — long after the call that needed // it, and with no reader. The account-name branch above happens to // blank it, but only if a differently-authenticated call comes next, // which in a service-account-only deployment never happens. // // So clear it as soon as the call is done: on success, on failure and // on interruption alike. Safe because every write and every read of // that global happens inside this same semaphore, so the next // operation re-sets the token before it spawns anything. Effect.ensuring(Effect.sync(() => op.setServiceAccount(""))), ), ) .pipe(Effect.withSpan(`onepassword.cli.${operation}`)); return OnePasswordServiceTag.of({ resolveSecret: (uri) => wrapSync(() => op.read.parse(uri), "secret resolution"), listVaults: () => wrapSync(() => op.vault.list(), "vault listing").pipe( Effect.map((vaults) => vaults.map((v) => ({ id: v.id, title: v.name }))), ), listItems: (vaultId) => wrapSync(() => op.item.list({ vault: vaultId }), "item listing").pipe( Effect.map((items) => items.map((i) => ({ id: i.id, title: i.title }))), ), }); }).pipe(Effect.withSpan("onepassword.cli.make_service")); // ---------------------------------------------------------------------------// Smart factory — tries CLI first (avoids IPC hang), falls back to SDK// --------------------------------------------------------------------------- const isCliUnavailable = (error: OnePasswordError): boolean => { // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message` const message = error.message.toLowerCase(); return ( message.includes("enoent") || message.includes("not found") || message.includes("command not found") || message.includes("not installed") || message.includes("no such file") || message.includes("spawn op") );}; const chooseFallbackError = ( cliError: OnePasswordError, sdkError: OnePasswordError,): OnePasswordError => (isCliUnavailable(cliError) ? sdkError : cliError); export const makeOnePasswordService = ( auth: ResolvedAuth, options?: { readonly preferSdk?: boolean; readonly timeoutMs?: number },): Effect.Effect<OnePasswordService, OnePasswordError> => { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; if (options?.preferSdk) { return makeNativeSdkService(auth, timeoutMs); } return Effect.gen(function* () { const cliService = yield* makeCliService(auth); const sdkService = yield* Effect.cached(makeNativeSdkService(auth, timeoutMs)); const withSdkFallback = <A>( cliEffect: Effect.Effect<A, OnePasswordError>, sdkEffect: (service: OnePasswordService) => Effect.Effect<A, OnePasswordError>, ): Effect.Effect<A, OnePasswordError> => cliEffect.pipe( Effect.catch((cliError: OnePasswordError) => sdkService.pipe( Effect.flatMap(sdkEffect), Effect.mapError((sdkError: OnePasswordError) => chooseFallbackError(cliError, sdkError), ), ), ), ); return OnePasswordServiceTag.of({ resolveSecret: (uri) => withSdkFallback(cliService.resolveSecret(uri), (service) => service.resolveSecret(uri)), listVaults: () => withSdkFallback(cliService.listVaults(), (service) => service.listVaults()), listItems: (vaultId) => withSdkFallback(cliService.listItems(vaultId), (service) => service.listItems(vaultId)), }); }).pipe(Effect.withSpan("onepassword.make_service"));};
import { Context, Duration, Effect, Semaphore } from "effect";
import * as op from "@1password/op-js";
import { OnePasswordError } from "./errors";
import type { OnePasswordSdkModule } from "./onepassword-sdk";
// ---------------------------------------------------------------------------
// Canonical service interface — all backends (SDK, CLI) implement this
// ---------------------------------------------------------------------------
export interface OnePasswordVault {
readonly id: string;
readonly title: string;
}
export interface OnePasswordItem {
readonly id: string;
readonly title: string;
}
export interface OnePasswordService {
/** Resolve a secret by op:// URI */
readonly resolveSecret: (uri: string) => Effect.Effect<string, OnePasswordError>;
/** List accessible vaults */
readonly listVaults: () => Effect.Effect<ReadonlyArray<OnePasswordVault>, OnePasswordError>;
/** List items in a vault */
readonly listItems: (
vaultId: string,
) => Effect.Effect<ReadonlyArray<OnePasswordItem>, OnePasswordError>;
}
export class OnePasswordServiceTag extends Context.Service<
OnePasswordServiceTag,
OnePasswordService
>()("@executor-js/plugin-onepassword/OnePasswordService") {}
// ---------------------------------------------------------------------------
// Resolved auth — raw credentials ready for any backend
// ---------------------------------------------------------------------------
export type ResolvedAuth =
| { readonly kind: "desktop-app"; readonly accountName: string }
| { readonly kind: "service-account"; readonly token: string };
// ---------------------------------------------------------------------------
// SDK backend — uses @1password/sdk native IPC
// ---------------------------------------------------------------------------
const DEFAULT_TIMEOUT_MS = 15_000;
const MAX_ERROR_MESSAGE_LENGTH = 300;
const SERVICE_ACCOUNT_TOKEN_RE = /ops_[A-Za-z0-9_-]+/g;
const formatCause = (cause: unknown): string => {
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: normalizing untyped op-js/SDK throwables into OnePasswordError.message
const maybeMessage = (cause as { readonly message?: unknown } | null | undefined)?.message;
const raw =
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: last-resort stringification of a non-Error throwable
typeof maybeMessage === "string" && maybeMessage.length > 0 ? maybeMessage : String(cause);
return raw
.replace(SERVICE_ACCOUNT_TOKEN_RE, "[redacted 1Password token]")
.replace(/\s+/g, " ")
.trim();
};
const messageWithCause = (prefix: string, cause: unknown): string => {
const causeMessage = formatCause(cause);
const message = causeMessage ? `${prefix}: ${causeMessage}` : prefix;
return message.length > MAX_ERROR_MESSAGE_LENGTH
? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...`
: message;
};
const hasOnePasswordSdkShape = (value: unknown): value is OnePasswordSdkModule => {
const sdk = value as Partial<OnePasswordSdkModule> | null | undefined;
return typeof sdk?.createClient === "function" && typeof sdk.DesktopAuth === "function";
};
const invalidOnePasswordSdkError = () =>
new OnePasswordError({
operation: "sdk module load",
message: [
"Failed to load 1Password SDK: the packaged SDK module did not expose createClient and DesktopAuth.",
"Install the 1Password CLI (`op`) in /opt/homebrew/bin or /usr/local/bin and retry, or update Executor.",
].join(" "),
});
const loadOnePasswordSdk = (): Effect.Effect<OnePasswordSdkModule, OnePasswordError> =>
Effect.tryPromise({
try: () => import("./onepassword-sdk").then((module) => module.onePasswordSdk),
catch: (cause) =>
new OnePasswordError({
operation: "sdk module load",
message: messageWithCause("Failed to load 1Password SDK", cause),
}),
}).pipe(
Effect.flatMap((sdk) =>
hasOnePasswordSdkShape(sdk) ? Effect.succeed(sdk) : Effect.fail(invalidOnePasswordSdkError()),
),
);
const makeTimeoutMessage = (operation: string, timeoutMs: number): string =>
[
`${operation}: timed out after ${Math.floor(timeoutMs / 1000)}s.`,
"Troubleshooting:",
"1. Make sure the 1Password desktop app is open and unlocked",
"2. Check for an approval prompt in the 1Password app — it may be behind other windows",
"3. Ensure 'Developer > Connect with 1Password CLI' is enabled in 1Password Settings",
"4. Make sure no other app or terminal is waiting for 1Password approval (only one prompt at a time)",
"5. Try quitting 1Password completely and reopening it, then retry",
].join("\n");
const timeoutWithOnePasswordError = (operation: string, timeoutMs: number) =>
Effect.timeoutOrElse({
duration: Duration.millis(timeoutMs),
orElse: () =>
Effect.fail(
new OnePasswordError({
operation,
message: makeTimeoutMessage(operation, timeoutMs),
}),
),
});
export const makeNativeSdkService = (
auth: ResolvedAuth,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Effect.Effect<OnePasswordService, OnePasswordError> =>
Effect.gen(function* () {
const sdk = yield* loadOnePasswordSdk().pipe(
timeoutWithOnePasswordError("sdk module load", timeoutMs),
);
const client = yield* Effect.tryPromise({
try: () =>
sdk.createClient({
auth: auth.kind === "desktop-app" ? new sdk.DesktopAuth(auth.accountName) : auth.token,
integrationName: "Executor",
integrationVersion: "0.0.0",
}),
catch: (cause) =>
new OnePasswordError({
operation: "client setup",
message: messageWithCause("Failed to set up 1Password client", cause),
}),
}).pipe(timeoutWithOnePasswordError("client setup", timeoutMs));
const wrap = <A>(fn: () => Promise<A>, operation: string): Effect.Effect<A, OnePasswordError> =>
Effect.tryPromise({
try: fn,
catch: (cause) =>
new OnePasswordError({
operation,
message: messageWithCause(`1Password SDK ${operation} failed`, cause),
}),
}).pipe(
timeoutWithOnePasswordError(operation, timeoutMs),
Effect.withSpan(`onepassword.sdk.${operation}`),
);
return OnePasswordServiceTag.of({
resolveSecret: (uri) => wrap(() => client.secrets.resolve(uri), "secret resolution"),
listVaults: () =>
wrap(() => client.vaults.list({ decryptDetails: true }), "vault listing").pipe(
Effect.map((vaults) => vaults.map((v) => ({ id: v.id, title: v.title }))),
),
listItems: (vaultId) =>
wrap(() => client.items.list(vaultId), "item listing").pipe(
Effect.map((items) => items.map((i) => ({ id: i.id, title: i.title }))),
),
});
}).pipe(Effect.withSpan("onepassword.sdk.make_service"));
// ---------------------------------------------------------------------------
// CLI backend — uses @1password/op-js (shells out to `op` CLI)
// ---------------------------------------------------------------------------
const cliAuthLock = Semaphore.makeUnsafe(1);
export const makeCliService = (
auth: ResolvedAuth,
): Effect.Effect<OnePasswordService, OnePasswordError> =>
Effect.sync(() => {
const wrapSync = <A>(fn: () => A, operation: string): Effect.Effect<A, OnePasswordError> =>
cliAuthLock
.withPermits(1)(
Effect.try({
try: () => {
if (auth.kind === "service-account") {
op.setGlobalFlags({});
op.setServiceAccount(auth.token);
} else {
op.setServiceAccount("");
op.setGlobalFlags({ account: auth.accountName });
}
return fn();
},
catch: (cause) =>
new OnePasswordError({
operation,
message: messageWithCause(`1Password CLI ${operation} failed`, cause),
}),
}).pipe(
// `op-js` keeps the service-account token in a PROCESS-GLOBAL
// (`cli.serviceAccountToken`, a field on the module's single CLI
// instance) and reads it when spawning `op`. Nothing in the library
// clears it, so a token set to serve one resolve stayed readable for
// the rest of the executor's life — long after the call that needed
// it, and with no reader. The account-name branch above happens to
// blank it, but only if a differently-authenticated call comes next,
// which in a service-account-only deployment never happens.
//
// So clear it as soon as the call is done: on success, on failure and
// on interruption alike. Safe because every write and every read of
// that global happens inside this same semaphore, so the next
// operation re-sets the token before it spawns anything.
Effect.ensuring(Effect.sync(() => op.setServiceAccount(""))),
),
)
.pipe(Effect.withSpan(`onepassword.cli.${operation}`));
return OnePasswordServiceTag.of({
resolveSecret: (uri) => wrapSync(() => op.read.parse(uri), "secret resolution"),
listVaults: () =>
wrapSync(() => op.vault.list(), "vault listing").pipe(
Effect.map((vaults) => vaults.map((v) => ({ id: v.id, title: v.name }))),
),
listItems: (vaultId) =>
wrapSync(() => op.item.list({ vault: vaultId }), "item listing").pipe(
Effect.map((items) => items.map((i) => ({ id: i.id, title: i.title }))),
),
});
}).pipe(Effect.withSpan("onepassword.cli.make_service"));
// ---------------------------------------------------------------------------
// Smart factory — tries CLI first (avoids IPC hang), falls back to SDK
// ---------------------------------------------------------------------------
const isCliUnavailable = (error: OnePasswordError): boolean => {
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message`
const message = error.message.toLowerCase();
return (
message.includes("enoent") ||
message.includes("not found") ||
message.includes("command not found") ||
message.includes("not installed") ||
message.includes("no such file") ||
message.includes("spawn op")
);
};
const chooseFallbackError = (
cliError: OnePasswordError,
sdkError: OnePasswordError,
): OnePasswordError => (isCliUnavailable(cliError) ? sdkError : cliError);
export const makeOnePasswordService = (
auth: ResolvedAuth,
options?: { readonly preferSdk?: boolean; readonly timeoutMs?: number },
): Effect.Effect<OnePasswordService, OnePasswordError> => {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (options?.preferSdk) {
return makeNativeSdkService(auth, timeoutMs);
}
return Effect.gen(function* () {
const cliService = yield* makeCliService(auth);
const sdkService = yield* Effect.cached(makeNativeSdkService(auth, timeoutMs));
const withSdkFallback = <A>(
cliEffect: Effect.Effect<A, OnePasswordError>,
sdkEffect: (service: OnePasswordService) => Effect.Effect<A, OnePasswordError>,
): Effect.Effect<A, OnePasswordError> =>
cliEffect.pipe(
Effect.catch((cliError: OnePasswordError) =>
sdkService.pipe(
Effect.flatMap(sdkEffect),
Effect.mapError((sdkError: OnePasswordError) =>
chooseFallbackError(cliError, sdkError),
),
),
),
);
return OnePasswordServiceTag.of({
resolveSecret: (uri) =>
withSdkFallback(cliService.resolveSecret(uri), (service) => service.resolveSecret(uri)),
listVaults: () => withSdkFallback(cliService.listVaults(), (service) => service.listVaults()),
listItems: (vaultId) =>
withSdkFallback(cliService.listItems(vaultId), (service) => service.listItems(vaultId)),
});
}).pipe(Effect.withSpan("onepassword.make_service"));
};