import { Effect, Schema } from "effect";
import {
definePlugin,
StorageError,
ToolResult,
tool,
type PluginCtx,
type PluginBlobStore,
type SecretProvider,
type StaticToolSchema,
type StorageFailure,
} from "@executor-js/sdk/core";
import { OnePasswordAuth, OnePasswordConfig, Vault, ConnectionStatus } 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;
const CONFIG_KEY = "config";
const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema =>
Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema<
A,
I
>;
const OnePasswordConfigureInput = Schema.Struct({
scope: Schema.String,
auth: OnePasswordAuth,
vaultId: Schema.String,
name: Schema.String,
});
const OnePasswordConfigureOutput = Schema.Struct({
configured: Schema.Boolean,
});
const OnePasswordGetConfigOutput = Schema.Struct({
config: Schema.NullOr(OnePasswordConfig),
});
const OnePasswordListVaultsInput = OnePasswordAuth;
const OnePasswordListVaultsOutput = Schema.Struct({
vaults: Schema.Array(Vault),
});
const OnePasswordRemoveConfigInput = Schema.Struct({
targetScope: 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
// `ctx.secrets`) or reaches the 1Password backend. Storage I/O surfaces
// as `StorageFailure`; the HTTP edge (`withCapture`) translates
// `StorageError` to `InternalError({ traceId })`. Domain problems (not
// configured, service-account token missing, backend RPC failure) stay
// as `OnePasswordError` and encode to 502 via the schema annotation on
// the class.
// ---------------------------------------------------------------------------
export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure;
// ---------------------------------------------------------------------------
// Plugin extension — public API on executor.onepassword
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Typed config store — single blob, JSON encoded. Blob I/O failures surface
// as `StorageError` (HTTP edge translates to `InternalError`); decode
// failures stay `OnePasswordError` — the blob's contents are a plugin
// concern, not an infrastructure one.
// ---------------------------------------------------------------------------
export interface OnePasswordStore {
readonly getConfig: () => Effect.Effect<
OnePasswordConfig | null,
StorageError | OnePasswordError
>;
readonly saveConfig: (
config: OnePasswordConfig,
targetScope: string,
) => Effect.Effect;
readonly deleteConfig: (targetScope: string) => Effect.Effect;
}
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(OnePasswordConfig));
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.mapError(
() =>
new OnePasswordError({
operation: "config decode",
message: "Failed to decode 1Password config",
}),
),
);
}),
),
saveConfig: (config, targetScope) =>
blobs
.put(
CONFIG_KEY,
JSON.stringify({
auth: config.auth,
vaultId: config.vaultId,
name: config.name,
}),
{ scope: targetScope },
)
.pipe(Effect.mapError(blobStorageError("write"))),
deleteConfig: (targetScope) =>
blobs
.delete(CONFIG_KEY, { scope: targetScope })
.pipe(Effect.mapError(blobStorageError("delete"))),
});
// ---------------------------------------------------------------------------
// Helpers — auth resolution + service construction
// ---------------------------------------------------------------------------
const resolveAuth = (
auth: OnePasswordAuth,
ctx: PluginCtx,
): Effect.Effect => {
if (auth.kind === "desktop-app") {
return Effect.succeed({
kind: "desktop-app" as const,
accountName: auth.accountName,
});
}
return ctx.secrets.get(auth.tokenSecretId).pipe(
Effect.catchTag("SecretOwnedByConnectionError", () =>
Effect.fail(
new OnePasswordError({
operation: "auth resolution",
message: `Service account token secret "${auth.tokenSecretId}" not found`,
}),
),
),
Effect.flatMap((token) => {
if (token === null) {
return Effect.fail(
new OnePasswordError({
operation: "auth resolution",
message: `Service account token secret "${auth.tokenSecretId}" not found`,
}),
);
}
return Effect.succeed({
kind: "service-account" as const,
token,
});
}),
);
};
const getServiceFromConfig = (
config: OnePasswordConfig,
ctx: PluginCtx,
timeoutMs: number,
preferSdk: boolean | undefined,
): Effect.Effect =>
resolveAuth(config.auth, ctx).pipe(
Effect.flatMap((resolved) => makeOnePasswordService(resolved, { timeoutMs, preferSdk })),
);
const configuredVaultUri = (config: OnePasswordConfig, secretId: string): string | null => {
if (!secretId.startsWith("op://")) {
return `op://${config.vaultId}/${secretId}/${CREDENTIAL_FIELD}`;
}
const match = secretId.match(/^op:\/\/([^/]+)\/.+/);
if (!match || match[1] !== config.vaultId) return null;
return secretId;
};
// ---------------------------------------------------------------------------
// SecretProvider — read-only, resolves op:// URIs or vaultId-based lookups
// ---------------------------------------------------------------------------
const makeProvider = (
ctx: PluginCtx,
timeoutMs: number,
preferSdk: boolean | undefined,
): SecretProvider => ({
key: "onepassword",
writable: false,
allowFallback: false,
// 1Password vaults are named in the stored config; the executor-scope
// arg isn't used for routing here. A future refactor could let the
// plugin store per-scope vault bindings and pick based on `scope`.
get: (secretId, _scope) =>
ctx.storage.getConfig().pipe(
Effect.flatMap((config) => {
if (!config) return Effect.succeed(null as string | null);
const uri = configuredVaultUri(config, secretId);
if (uri === null) return Effect.succeed(null as string | null);
return getServiceFromConfig(config, ctx, timeoutMs, preferSdk).pipe(
Effect.flatMap((svc) => svc.resolveSecret(uri)),
Effect.map((v): string | null => v),
Effect.orElseSucceed(() => null),
);
}),
Effect.orElseSucceed(() => null),
),
list: () =>
ctx.storage.getConfig().pipe(
Effect.flatMap((config) => {
if (!config) return Effect.succeed([] as ReadonlyArray<{ id: string; name: string }>);
return getServiceFromConfig(config, ctx, timeoutMs, preferSdk).pipe(
Effect.flatMap((svc) => svc.listItems(config.vaultId)),
Effect.map(
(items): ReadonlyArray<{ id: string; name: string }> =>
items.map((item) => ({ id: item.id, name: item.title })),
),
);
}),
Effect.orElseSucceed(() => [] as ReadonlyArray<{ id: string; name: string }>),
),
});
const makeOnePasswordExtension = (
ctx: PluginCtx,
timeoutMs: number,
preferSdk: boolean | undefined,
) => {
return {
configure: (config: OnePasswordConfig, targetScope: string) =>
ctx.storage.saveConfig(config, targetScope),
getConfig: () => ctx.storage.getConfig(),
removeConfig: (targetScope: string) => ctx.storage.deleteConfig(targetScope),
status: () =>
Effect.gen(function* () {
const config = yield* ctx.storage.getConfig();
if (!config) {
return ConnectionStatus.make({
connected: false,
error: "Not configured",
});
}
const svc = yield* getServiceFromConfig(config, ctx, timeoutMs, preferSdk);
const vaults = yield* svc.listVaults();
const vault = vaults.find((v) => v.id === config.vaultId);
return ConnectionStatus.make({
connected: true,
vaultName: vault?.title,
});
}),
listVaults: (auth: OnePasswordAuth) =>
Effect.gen(function* () {
const resolved = yield* resolveAuth(auth, ctx);
const svc = yield* makeOnePasswordService(resolved, {
timeoutMs,
preferSdk,
});
const vaults = yield* svc.listVaults();
return vaults
.map((v) => Vault.make({ id: v.id, name: v.title }))
.sort((a, b) => a.name.localeCompare(b.name));
}),
resolve: (uri: string) =>
Effect.gen(function* () {
const config = yield* ctx.storage.getConfig();
if (!config) {
return yield* new OnePasswordError({
operation: "resolve",
message: "1Password is not configured",
});
}
const scopedUri = configuredVaultUri(config, uri);
if (scopedUri === null) {
return yield* new OnePasswordError({
operation: "resolve",
message: "1Password secret URI is outside the configured vault",
});
}
const svc = yield* getServiceFromConfig(config, ctx, timeoutMs, preferSdk);
return yield* svc.resolveSecret(scopedUri);
}),
};
};
export type OnePasswordExtension = ReturnType;
// ---------------------------------------------------------------------------
// Plugin factory
// ---------------------------------------------------------------------------
export interface OnePasswordPluginOptions {
/** Request timeout in ms (default: 15000) */
readonly timeoutMs?: number;
/** Force use of the native SDK instead of the CLI (default: false) */
readonly preferSdk?: boolean;
}
export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOptions) => {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const preferSdk = options?.preferSdk;
return {
id: "onepassword" as const,
packageName: "@executor-js/plugin-onepassword",
storage: ({ blobs }) => makeOnePasswordStore(blobs),
extension: (ctx) => makeOnePasswordExtension(ctx, timeoutMs, preferSdk),
staticSources: (self) => [
{
id: "onepassword",
kind: "executor",
name: "1Password",
tools: [
tool({
name: "status",
description:
"Check whether the 1Password secret provider is configured and can reach its selected vault. This returns status only, never secret values.",
outputSchema: OnePasswordStatusOutputStd,
execute: () => Effect.map(self.status(), ToolResult.ok),
}),
tool({
name: "getConfig",
description:
"Read the current 1Password provider configuration. This returns account/vault metadata and secret ids only; service-account token values are never returned.",
outputSchema: OnePasswordGetConfigOutputStd,
execute: () => Effect.map(self.getConfig(), (config) => ToolResult.ok({ config })),
}),
tool({
name: "listVaults",
description:
"List available 1Password vaults before configuring the provider. For service-account auth, first call `executor.coreTools.secrets.create` so the token is entered in the browser, then pass that token secret id here.",
inputSchema: OnePasswordListVaultsInputStd,
outputSchema: OnePasswordListVaultsOutputStd,
execute: (input) =>
Effect.map(self.listVaults(input), (vaults) => ToolResult.ok({ vaults })),
}),
tool({
name: "configure",
description:
"Configure the 1Password secret provider for a target executor scope. Use desktop-app auth for local biometric access, or service-account auth with a token secret id created through `executor.coreTools.secrets.create`; never ask the user to paste the token in chat.",
annotations: {
requiresApproval: true,
approvalDescription: "Configure the 1Password secret provider",
},
inputSchema: OnePasswordConfigureInputStd,
outputSchema: OnePasswordConfigureOutputStd,
execute: (input) =>
Effect.as(
self.configure(
{ auth: input.auth, vaultId: input.vaultId, name: input.name },
input.scope,
),
ToolResult.ok({ configured: true }),
),
}),
tool({
name: "removeConfig",
description:
"Remove the 1Password provider configuration from a target scope. Existing secrets are not revealed; future 1Password secret resolution will stop until reconfigured.",
annotations: {
requiresApproval: true,
approvalDescription: "Remove the 1Password secret provider configuration",
},
inputSchema: OnePasswordRemoveConfigInputStd,
outputSchema: OnePasswordRemoveConfigOutputStd,
execute: (input) =>
Effect.as(self.removeConfig(input.targetScope), ToolResult.ok({ removed: true })),
}),
],
},
],
secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)],
};
// HTTP transport (routes/handlers/extensionService) is layered on by
// the api-aware factory in `@executor-js/plugin-onepassword/api`. Hosts
// that want the HTTP surface import the plugin from there; SDK-only
// consumers stay on this entry and avoid the server-only deps.
});