From 14f484618499cee0cf644a0f846e8356fc02186b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 16:13:59 +0000 Subject: [PATCH 1/9] Initial plan From b2bdf2a3974e799b9949fdac0d1f1d4991b0ba46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 16:20:17 +0000 Subject: [PATCH 2/9] feat(1password-plugin): add useCliWithServiceAccount param for headless CLI auth Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/4fe1ccab-22cb-4d56-b5e5-b53bd2c93606 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .../1password-use-cli-with-service-account.md | 5 ++++ packages/plugins/1password/README.md | 22 +++++++++++++++ packages/plugins/1password/src/cli-helper.ts | 27 +++++++++++++++++-- packages/plugins/1password/src/plugin.ts | 26 +++++++++++++++--- 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 .bumpy/1password-use-cli-with-service-account.md diff --git a/.bumpy/1password-use-cli-with-service-account.md b/.bumpy/1password-use-cli-with-service-account.md new file mode 100644 index 00000000..e08085d3 --- /dev/null +++ b/.bumpy/1password-use-cli-with-service-account.md @@ -0,0 +1,5 @@ +--- +"@varlock/1password-plugin": minor-isolated +--- + +add useCliWithServiceAccount param to use op CLI instead of WASM SDK when a service account token is provided, enabling headless auth in memory-constrained environments diff --git a/packages/plugins/1password/README.md b/packages/plugins/1password/README.md index cd4e1c6c..7e9d5be2 100644 --- a/packages/plugins/1password/README.md +++ b/packages/plugins/1password/README.md @@ -63,6 +63,28 @@ OP_TOKEN= Vault access rules cannot be edited after creation. If your vault setup changes, you'll need to create a new service account. ::: +### CLI-based service account auth (memory-constrained environments) + +By default, service account tokens use the 1Password JavaScript SDK (which includes a WASM module). In memory-constrained environments (e.g., 512 MB containers), the SDK's memory footprint may be prohibitive. + +Set `useCliWithServiceAccount=true` to use the `op` CLI binary instead of the SDK while still authenticating via `OP_SERVICE_ACCOUNT_TOKEN`: + +```env-spec +# @plugin(@varlock/1password-plugin) +# @initOp(token=$OP_TOKEN, useCliWithServiceAccount=true) +# --- + +# @type=opServiceAccountToken @sensitive +OP_TOKEN= +``` + +**Requirements:** + +1. Install the `op` CLI: [Installation guide](https://developer.1password.com/docs/cli/get-started/) +2. The `OP_SERVICE_ACCOUNT_TOKEN` (i.e. `$OP_TOKEN` above) must resolve to a valid service account token at load time. + +The `op` binary is significantly lighter than the WASM SDK. `op` authentication is handled headlessly via the token — no desktop app or interactive sign-in is needed. + ### Desktop app auth (for local dev) During local development, you can use the 1Password desktop app instead of a service account: diff --git a/packages/plugins/1password/src/cli-helper.ts b/packages/plugins/1password/src/cli-helper.ts index 0b049535..6d932d83 100644 --- a/packages/plugins/1password/src/cli-helper.ts +++ b/packages/plugins/1password/src/cli-helper.ts @@ -53,6 +53,20 @@ let lockCliToOpAccount: string | undefined; // use a singleton within the module to track op cli auth state as a mutex / deferred promise let opAuthDeferred: DeferredPromise | undefined; +/** + * When true, OP_SERVICE_ACCOUNT_TOKEN is forwarded to `op` subprocesses. + * Set via enableCliServiceAccountAuth() when useCliWithServiceAccount=true. + */ +let passServiceAccountTokenToCli = false; + +/** + * Enable forwarding of OP_SERVICE_ACCOUNT_TOKEN to `op` subprocesses. + * Call this when the user opts into CLI-based auth using a service account token. + */ +export function enableCliServiceAccountAuth() { + passServiceAccountTokenToCli = true; +} + /** Called after each `op` invocation so parallel waiters can proceed; no-op when this caller only waited on the mutex. */ type OpAuthCompletedFn = (success: boolean) => void; @@ -86,9 +100,14 @@ export async function execOpCliCommand(cmdArgs: Array) { // uses system-installed copy of `op` debug('op cli command args', cmdArgs); // strip OP_SERVICE_ACCOUNT_TOKEN from env so the CLI doesn't auto-detect it - // when the user hasn't explicitly wired it into their schema + // when the user hasn't explicitly wired it into their schema. + // When useCliWithServiceAccount is enabled we keep it so `op` can authenticate. const { OP_SERVICE_ACCOUNT_TOKEN: _, ...cleanEnv } = process.env; - const cliResult = await spawnAsync('op', cmdArgs, { env: cleanEnv }); + const cliResult = await spawnAsync('op', cmdArgs, { + env: passServiceAccountTokenToCli && process.env.OP_SERVICE_ACCOUNT_TOKEN + ? { ...cleanEnv, OP_SERVICE_ACCOUNT_TOKEN: process.env.OP_SERVICE_ACCOUNT_TOKEN } + : cleanEnv, + }); authCompletedFn?.(true); debug(`> took ${+new Date() - +startAt}ms`); // OP_CLI_CACHE[cacheKey] = cliResult; @@ -230,6 +249,10 @@ async function executeReadBatch(batchToExecute: NonNullable) ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, // proxy env vars so `op` can connect through HTTP/SOCKS proxies ...pickProxyEnv(), + // forward service account token when the user has opted into CLI-based service account auth + ...passServiceAccountTokenToCli && process.env.OP_SERVICE_ACCOUNT_TOKEN && { + OP_SERVICE_ACCOUNT_TOKEN: process.env.OP_SERVICE_ACCOUNT_TOKEN, + }, // this setting actually just enables the CLI + Desktop App integration // which in some cases op has a hard time detecting via app setting OP_BIOMETRIC_UNLOCK_ENABLED: 'true', diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 808ecc25..b2b8c9ee 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -2,7 +2,7 @@ import { type Resolver, plugin } from 'varlock/plugin-lib'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { Client, createClient } from '@1password/sdk'; -import { opCliRead, opCliEnvironmentRead } from './cli-helper'; +import { opCliRead, opCliEnvironmentRead, enableCliServiceAccountAuth } from './cli-helper'; const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; @@ -91,6 +91,11 @@ class OpPluginInstance { * (will not be set to true if a token is provided) * */ private allowAppAuth?: boolean; + /** + * if true, use the `op` CLI with the service account token instead of the WASM SDK. + * Useful in memory-constrained environments where the SDK is too heavy. + * */ + private useCliWithServiceAccount?: boolean; /** URL of a 1Password Connect server */ private connectHost?: string; /** API token for authenticating with the Connect server */ @@ -110,14 +115,17 @@ class OpPluginInstance { connectHost?: string, connectToken?: string, allowMissing?: boolean, + useCliWithServiceAccount?: boolean, ) { if (token && typeof token === 'string') this.token = token; this.allowAppAuth = allowAppAuth; + this.useCliWithServiceAccount = useCliWithServiceAccount; this.account = account; if (connectHost && typeof connectHost === 'string') this.connectHost = connectHost.replace(/\/+$/, ''); if (connectToken && typeof connectToken === 'string') this.connectToken = connectToken; if (allowMissing !== undefined) this.allowMissing = allowMissing; - debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account, 'connect:', !!connectHost); + if (useCliWithServiceAccount) enableCliServiceAccountAuth(); + debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account, 'connect:', !!connectHost, 'useCliWithServiceAccount:', useCliWithServiceAccount); } /** Whether this instance is configured for Connect server */ @@ -273,6 +281,10 @@ class OpPluginInstance { async readItem(opReference: string) { if (this.isConnect) { return await this.readItemViaConnect(opReference); + } else if (this.token && this.useCliWithServiceAccount) { + // user wants to use the `op` CLI with service account token instead of the WASM SDK + // NOTE - cli helper does its own batching, untethered to a specific op instance + return await opCliRead(opReference, this.account); } else if (this.token) { // using JS SDK client using service account token await this.initSdkClient(); @@ -311,6 +323,10 @@ class OpPluginInstance { 'Use a service account token or desktop app auth instead, or use op() to read individual items.', ], }); + } else if (this.token && this.useCliWithServiceAccount) { + // user wants to use the `op` CLI with service account token instead of the WASM SDK + const cliResult = await opCliEnvironmentRead(environmentId, this.account); + return parseOpEnvOutput(cliResult); } else if (this.token) { // Use SDK - supports environments since v0.4.1-beta.1 await this.initSdkClient(); @@ -454,10 +470,12 @@ plugin.registerRootDecorator({ tokenResolver: objArgs.token, allowAppAuthResolver: objArgs.allowAppAuth, connectTokenResolver: objArgs.connectToken, + useCliWithServiceAccountResolver: objArgs.useCliWithServiceAccount, }; }, async execute({ - id, account, connectHost, allowMissingResolver, tokenResolver, allowAppAuthResolver, connectTokenResolver, + id, account, connectHost, allowMissingResolver, tokenResolver, + allowAppAuthResolver, connectTokenResolver, useCliWithServiceAccountResolver, }) { // even if these are empty, we can't throw errors yet // in case the instance is never actually used @@ -465,6 +483,7 @@ plugin.registerRootDecorator({ const enableAppAuth = await allowAppAuthResolver?.resolve(); const connectToken = await connectTokenResolver?.resolve(); const allowMissing = await allowMissingResolver?.resolve(); + const useCliWithServiceAccount = await useCliWithServiceAccountResolver?.resolve(); pluginInstances[id].setAuth( token, !!enableAppAuth, @@ -472,6 +491,7 @@ plugin.registerRootDecorator({ connectHost, connectToken as string | undefined, allowMissing as boolean | undefined, + !!useCliWithServiceAccount, ); }, }); From f0d6d4755204d5357bf8f03675a00b2bee1f4461 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 16:39:32 +0000 Subject: [PATCH 3/9] refactor(1password-plugin): pass service account token as parameter instead of global flag Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/a9f1a7e8-25c9-4f94-a10b-1dd124697e25 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .../1password-use-cli-with-service-account.md | 2 +- packages/plugins/1password/src/cli-helper.ts | 39 +++++++------------ packages/plugins/1password/src/plugin.ts | 7 ++-- 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/.bumpy/1password-use-cli-with-service-account.md b/.bumpy/1password-use-cli-with-service-account.md index e08085d3..4ef02b42 100644 --- a/.bumpy/1password-use-cli-with-service-account.md +++ b/.bumpy/1password-use-cli-with-service-account.md @@ -1,5 +1,5 @@ --- -"@varlock/1password-plugin": minor-isolated +"@varlock/1password-plugin": minor --- add useCliWithServiceAccount param to use op CLI instead of WASM SDK when a service account token is provided, enabling headless auth in memory-constrained environments diff --git a/packages/plugins/1password/src/cli-helper.ts b/packages/plugins/1password/src/cli-helper.ts index 6d932d83..3921ee52 100644 --- a/packages/plugins/1password/src/cli-helper.ts +++ b/packages/plugins/1password/src/cli-helper.ts @@ -34,6 +34,9 @@ function pickProxyEnv(): Record { // but we'll likely want to support multiple accounts in the future // note that the SDK does not currently support this - but service accounts are already limited to an account let lockCliToOpAccount: string | undefined; +// service account token to forward to `op` subprocesses when useCliWithServiceAccount=true +// stored at module level (mirroring lockCliToOpAccount) since CLI batching is a module-level singleton +let cliServiceAccountToken: string | undefined; /* ! IMPORTANT INFO ON CLI AUTH @@ -53,20 +56,6 @@ let lockCliToOpAccount: string | undefined; // use a singleton within the module to track op cli auth state as a mutex / deferred promise let opAuthDeferred: DeferredPromise | undefined; -/** - * When true, OP_SERVICE_ACCOUNT_TOKEN is forwarded to `op` subprocesses. - * Set via enableCliServiceAccountAuth() when useCliWithServiceAccount=true. - */ -let passServiceAccountTokenToCli = false; - -/** - * Enable forwarding of OP_SERVICE_ACCOUNT_TOKEN to `op` subprocesses. - * Call this when the user opts into CLI-based auth using a service account token. - */ -export function enableCliServiceAccountAuth() { - passServiceAccountTokenToCli = true; -} - /** Called after each `op` invocation so parallel waiters can proceed; no-op when this caller only waited on the mutex. */ type OpAuthCompletedFn = (success: boolean) => void; @@ -83,7 +72,7 @@ async function checkOpCliAuth(): Promise { } -export async function execOpCliCommand(cmdArgs: Array) { +export async function execOpCliCommand(cmdArgs: Array, serviceAccountToken?: string) { // very simple in-memory cache, will persist between runs in watch mode // but need to think through how a user can opt out // and interact with this cache from the web UI when we add it for the regular cache @@ -101,11 +90,11 @@ export async function execOpCliCommand(cmdArgs: Array) { debug('op cli command args', cmdArgs); // strip OP_SERVICE_ACCOUNT_TOKEN from env so the CLI doesn't auto-detect it // when the user hasn't explicitly wired it into their schema. - // When useCliWithServiceAccount is enabled we keep it so `op` can authenticate. + // When useCliWithServiceAccount is enabled the caller passes the token explicitly. const { OP_SERVICE_ACCOUNT_TOKEN: _, ...cleanEnv } = process.env; const cliResult = await spawnAsync('op', cmdArgs, { - env: passServiceAccountTokenToCli && process.env.OP_SERVICE_ACCOUNT_TOKEN - ? { ...cleanEnv, OP_SERVICE_ACCOUNT_TOKEN: process.env.OP_SERVICE_ACCOUNT_TOKEN } + env: serviceAccountToken + ? { ...cleanEnv, OP_SERVICE_ACCOUNT_TOKEN: serviceAccountToken } : cleanEnv, }); authCompletedFn?.(true); @@ -249,10 +238,8 @@ async function executeReadBatch(batchToExecute: NonNullable) ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, // proxy env vars so `op` can connect through HTTP/SOCKS proxies ...pickProxyEnv(), - // forward service account token when the user has opted into CLI-based service account auth - ...passServiceAccountTokenToCli && process.env.OP_SERVICE_ACCOUNT_TOKEN && { - OP_SERVICE_ACCOUNT_TOKEN: process.env.OP_SERVICE_ACCOUNT_TOKEN, - }, + // forward service account token when the instance opted into CLI-based service account auth + ...cliServiceAccountToken && { OP_SERVICE_ACCOUNT_TOKEN: cliServiceAccountToken }, // this setting actually just enables the CLI + Desktop App integration // which in some cases op has a hard time detecting via app setting OP_BIOMETRIC_UNLOCK_ENABLED: 'true', @@ -339,8 +326,9 @@ async function executeReadBatch(batchToExecute: NonNullable) * reads a single value from 1Password by reference (similar to `op read`) * but internally batches requests and uses `op run` * */ -export async function opCliRead(opReference: string, account?: string) { +export async function opCliRead(opReference: string, account?: string, serviceAccountToken?: string) { lockCliToOpAccount ||= account; + cliServiceAccountToken ||= serviceAccountToken; if (account && lockCliToOpAccount !== account) { throw new ResolutionError('Cannot use multiple different 1Password accounts when using CLI auth with batching enabled', { tip: [ @@ -383,7 +371,7 @@ export async function opCliRead(opReference: string, account?: string) { '--no-newline', ...(lockCliToOpAccount ? ['--account', lockCliToOpAccount] : []), opReference, - ]); + ], cliServiceAccountToken); return result; } } @@ -391,13 +379,14 @@ export async function opCliRead(opReference: string, account?: string) { export async function opCliEnvironmentRead( environmentId: string, account?: string, + serviceAccountToken?: string, ): Promise { const result = await execOpCliCommand([ 'environment', 'read', environmentId, ...(account ? ['--account', account] : []), - ]); + ], serviceAccountToken); return result; } diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index b2b8c9ee..02e2cbd5 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -2,7 +2,7 @@ import { type Resolver, plugin } from 'varlock/plugin-lib'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { Client, createClient } from '@1password/sdk'; -import { opCliRead, opCliEnvironmentRead, enableCliServiceAccountAuth } from './cli-helper'; +import { opCliRead, opCliEnvironmentRead } from './cli-helper'; const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; @@ -124,7 +124,6 @@ class OpPluginInstance { if (connectHost && typeof connectHost === 'string') this.connectHost = connectHost.replace(/\/+$/, ''); if (connectToken && typeof connectToken === 'string') this.connectToken = connectToken; if (allowMissing !== undefined) this.allowMissing = allowMissing; - if (useCliWithServiceAccount) enableCliServiceAccountAuth(); debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account, 'connect:', !!connectHost, 'useCliWithServiceAccount:', useCliWithServiceAccount); } @@ -284,7 +283,7 @@ class OpPluginInstance { } else if (this.token && this.useCliWithServiceAccount) { // user wants to use the `op` CLI with service account token instead of the WASM SDK // NOTE - cli helper does its own batching, untethered to a specific op instance - return await opCliRead(opReference, this.account); + return await opCliRead(opReference, this.account, this.token); } else if (this.token) { // using JS SDK client using service account token await this.initSdkClient(); @@ -325,7 +324,7 @@ class OpPluginInstance { }); } else if (this.token && this.useCliWithServiceAccount) { // user wants to use the `op` CLI with service account token instead of the WASM SDK - const cliResult = await opCliEnvironmentRead(environmentId, this.account); + const cliResult = await opCliEnvironmentRead(environmentId, this.account, this.token); return parseOpEnvOutput(cliResult); } else if (this.token) { // Use SDK - supports environments since v0.4.1-beta.1 From e7cd572bc58595b6c310557eae1390f1a4d06666 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 17:46:58 +0000 Subject: [PATCH 4/9] refactor(1password-plugin): move CLI batching into OpPluginInstance for full per-instance isolation Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/9b68f206-e3be-4973-b9fb-5a5a5c5ddb90 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- packages/plugins/1password/src/cli-helper.ts | 215 +------------------ packages/plugins/1password/src/plugin.ts | 187 +++++++++++++++- 2 files changed, 184 insertions(+), 218 deletions(-) diff --git a/packages/plugins/1password/src/cli-helper.ts b/packages/plugins/1password/src/cli-helper.ts index 3921ee52..725bdf21 100644 --- a/packages/plugins/1password/src/cli-helper.ts +++ b/packages/plugins/1password/src/cli-helper.ts @@ -1,12 +1,9 @@ import { ExecError, spawnAsync } from '@env-spec/utils/exec-helpers'; -import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { plugin } from 'varlock/plugin-lib'; const { debug } = plugin; const { ResolutionError } = plugin.ERRORS; -const ENABLE_BATCHING = true; - const OP_CLI_CACHE: Record = {}; /** Proxy env vars that must be forwarded so `op` can reach 1Password through HTTP/SOCKS proxies @@ -22,7 +19,7 @@ const PROXY_ENV_KEYS = [ 'NO_PROXY', ] as const; -function pickProxyEnv(): Record { +export function pickProxyEnv(): Record { const env: Record = {}; for (const key of PROXY_ENV_KEYS) { if (process.env[key]) env[key] = process.env[key]!; @@ -30,48 +27,6 @@ function pickProxyEnv(): Record { return env; } -// for now we'll just use a single 1pass account for all requests -// but we'll likely want to support multiple accounts in the future -// note that the SDK does not currently support this - but service accounts are already limited to an account -let lockCliToOpAccount: string | undefined; -// service account token to forward to `op` subprocesses when useCliWithServiceAccount=true -// stored at module level (mirroring lockCliToOpAccount) since CLI batching is a module-level singleton -let cliServiceAccountToken: string | undefined; - -/* - ! IMPORTANT INFO ON CLI AUTH - - Because we trigger multiple requests in parallel, if the app/cli is not unlocked, it will show multiple auth popups. - In a big project this is super awkward because you may need to scan your finger over and over again. - - To work around this, we track if we are currently making the first op cli command, and if so acquire a mutex in the form of - a deferred promise that other requests can then wait on. We also use the additional trick of checking `op whoami` so that - if the app is already unlocked, we dont have to actually wait for the first request to finish to proceed with the rest. - - Ideally 1Password will fix this issue at some point and we can remove this extra logic. - - NOTE - We don't currently do anything special to handle if the user denies the login, or is logged into the wrong account. -*/ - -// use a singleton within the module to track op cli auth state as a mutex / deferred promise -let opAuthDeferred: DeferredPromise | undefined; - -/** Called after each `op` invocation so parallel waiters can proceed; no-op when this caller only waited on the mutex. */ -type OpAuthCompletedFn = (success: boolean) => void; - -async function checkOpCliAuth(): Promise { - if (opAuthDeferred) { - // Wait for the in-flight first `op` call to finish (or an earlier batch to settle the mutex). - await opAuthDeferred.promise; - // Mutex is already resolved — still return a callable so callers can always invoke authCompletedFn(success). - return (_success: boolean) => undefined; - } - // First caller creates the mutex and must call the returned fn when its `op` run completes. - opAuthDeferred = createDeferredPromise(); - return opAuthDeferred.resolve; -} - - export async function execOpCliCommand(cmdArgs: Array, serviceAccountToken?: string) { // very simple in-memory cache, will persist between runs in watch mode // but need to think through how a user can opt out @@ -84,7 +39,6 @@ export async function execOpCliCommand(cmdArgs: Array, serviceAccountTok const startAt = new Date(); - const authCompletedFn = await checkOpCliAuth(); try { // uses system-installed copy of `op` debug('op cli command args', cmdArgs); @@ -97,12 +51,10 @@ export async function execOpCliCommand(cmdArgs: Array, serviceAccountTok ? { ...cleanEnv, OP_SERVICE_ACCOUNT_TOKEN: serviceAccountToken } : cleanEnv, }); - authCompletedFn?.(true); debug(`> took ${+new Date() - +startAt}ms`); // OP_CLI_CACHE[cacheKey] = cliResult; return cliResult; } catch (err) { - authCompletedFn?.(false); // eslint-disable-next-line no-use-before-define throw processOpCliError(err); } @@ -113,7 +65,7 @@ export async function execOpCliCommand(cmdArgs: Array, serviceAccountTok * this is all fairly brittle though because it depends on the error messages * luckily it should only _improve_ the experience, and is not critical */ -function processOpCliError(err: Error | any) { +export function processOpCliError(err: Error | any) { if (err instanceof ExecError) { let errMessage = err.data; // get rid of "[ERROR] 2024/01/23 12:34:56 " before actual message @@ -213,169 +165,6 @@ function processOpCliError(err: Error | any) { } } - -let opReadBatch: Record> }> | undefined; -const BATCH_READ_TIMEOUT = 50; - -async function executeReadBatch(batchToExecute: NonNullable) { - debug('execute op read batch', Object.keys(batchToExecute)); - const envMap = {} as Record; - let i = 1; - Object.keys(batchToExecute).forEach((opReference) => { - envMap[`VARLOCK_1P_INJECT_${i++}`] = opReference; - }); - const startAt = new Date(); - - const authCompletedFn = await checkOpCliAuth(); - // `env -0` splits values by a null character instead of newlines - // because otherwise we'll have trouble dealing with values that contain newlines - await spawnAsync('op', `run --no-masking ${lockCliToOpAccount ? `--account ${lockCliToOpAccount} ` : ''}-- env -0`.split(' '), { - env: { - // have to pass a few things through at least path so it can find `op` and related config files - PATH: process.env.PATH!, - ...process.env.USER && { USER: process.env.USER }, - ...process.env.HOME && { HOME: process.env.HOME }, - ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, - // proxy env vars so `op` can connect through HTTP/SOCKS proxies - ...pickProxyEnv(), - // forward service account token when the instance opted into CLI-based service account auth - ...cliServiceAccountToken && { OP_SERVICE_ACCOUNT_TOKEN: cliServiceAccountToken }, - // this setting actually just enables the CLI + Desktop App integration - // which in some cases op has a hard time detecting via app setting - OP_BIOMETRIC_UNLOCK_ENABLED: 'true', - ...envMap, - }, - }) - .then(async (result) => { - authCompletedFn?.(true); - debug(`batched OP request took ${+new Date() - +startAt}ms`); - - const lines = result.split('\0'); - for (const line of lines) { - const eqPos = line.indexOf('='); - const key = line.substring(0, eqPos); - - if (!envMap[key]) continue; - const val = line.substring(eqPos + 1); - const opRef = envMap[key]; - - // resolve the deferred promises with the value - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.resolve(val); - }); - } - }) - .catch(async (err) => { - authCompletedFn?.(false); - - // have to do special handling of errors because if any IDs are no good, it kills the whole request - const opErr = processOpCliError(err); - debug('batch failed', opErr); - if ((opErr as any).code === 'BAD_VAULT_REFERENCE') { - const badId = (opErr as any).extraMetadata.badVaultId; - debug('skipping failed bad vault id -', badId); - for (const opRef in batchToExecute) { - if (opRef.startsWith(`op://${badId}/`)) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - } else if ((opErr as any).code === 'BAD_ITEM_REFERENCE') { - const badId = (opErr as any).extraMetadata.badItemId; - debug('skipping failed bad item id -', badId); - for (const opRef in batchToExecute) { - const itemRef = opRef.split('/')?.[3]; - if (itemRef === badId) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - } else if ((opErr as any).code === 'BAD_FIELD_REFERENCE') { - const badId = (opErr as any).extraMetadata.badFieldId; - debug('skipping failed bad field id -', badId); - for (const opRef in batchToExecute) { - const fieldRef = opRef.split('/')?.slice(4).join('/'); - if (fieldRef === badId) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - } else { - for (const opRef in batchToExecute) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - - if (Object.keys(batchToExecute).length) { - debug('re-executing remainder of batch', Object.keys(batchToExecute)); - await executeReadBatch(batchToExecute); - } - }); -} - -/** - * reads a single value from 1Password by reference (similar to `op read`) - * but internally batches requests and uses `op run` - * */ -export async function opCliRead(opReference: string, account?: string, serviceAccountToken?: string) { - lockCliToOpAccount ||= account; - cliServiceAccountToken ||= serviceAccountToken; - if (account && lockCliToOpAccount !== account) { - throw new ResolutionError('Cannot use multiple different 1Password accounts when using CLI auth with batching enabled', { - tip: [ - 'When using CLI auth with batching, all references must use the same 1Password account', - 'Consider using service account tokens instead of CLI auth to allow multiple accounts', - ], - }); - } - - if (ENABLE_BATCHING) { - // if no batch exists, we'll create it, and this function will kick it off after a timeout - let shouldExecuteBatch = false; - if (!opReadBatch) { - opReadBatch = {}; - shouldExecuteBatch = true; - } - - // otherwise we'll just add to the existing batch - opReadBatch[opReference] ||= { - deferredPromises: [], - }; - - const deferred = createDeferredPromise(); - opReadBatch[opReference].deferredPromises.push(deferred); - - if (shouldExecuteBatch) { - setTimeout(async () => { - if (!opReadBatch) throw Error('expected to find op read batch!'); - const batchToExecute = opReadBatch; - opReadBatch = undefined; - await executeReadBatch(batchToExecute); - }, BATCH_READ_TIMEOUT); - } - return deferred.promise; - } else { - // fetch each item individually - const result = await execOpCliCommand([ - 'read', - '--force', - '--no-newline', - ...(lockCliToOpAccount ? ['--account', lockCliToOpAccount] : []), - opReference, - ], cliServiceAccountToken); - return result; - } -} - export async function opCliEnvironmentRead( environmentId: string, account?: string, diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 02e2cbd5..e620dee6 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -1,14 +1,22 @@ import { type Resolver, plugin } from 'varlock/plugin-lib'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; +import { spawnAsync } from '@env-spec/utils/exec-helpers'; import { Client, createClient } from '@1password/sdk'; -import { opCliRead, opCliEnvironmentRead } from './cli-helper'; +import { + opCliEnvironmentRead, processOpCliError, pickProxyEnv, +} from './cli-helper'; const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; const PLUGIN_VERSION = plugin.version; const OP_ICON = 'simple-icons:1password'; +/** Called after each `op` invocation so parallel waiters can proceed; no-op when this caller only waited on the mutex. */ +type OpAuthCompletedFn = (success: boolean) => void; + +const CLI_BATCH_READ_TIMEOUT = 50; + plugin.name = '1pass'; const { debug } = plugin; debug('init - version =', plugin.version); @@ -103,6 +111,26 @@ class OpPluginInstance { /** If true, missing items/fields/vaults return undefined instead of throwing */ allowMissing?: boolean; + /* + ! IMPORTANT INFO ON CLI AUTH + + Because we trigger multiple requests in parallel, if the app/cli is not unlocked, it will show multiple auth popups. + In a big project this is super awkward because you may need to scan your finger over and over again. + + To work around this, we track if we are currently making the first op cli command, and if so acquire a mutex in the form of + a deferred promise that other requests can then wait on. We also use the additional trick of checking `op whoami` so that + if the app is already unlocked, we dont have to actually wait for the first request to finish to proceed with the rest. + + Ideally 1Password will fix this issue at some point and we can remove this extra logic. + + NOTE - We don't currently do anything special to handle if the user denies the login, or is logged into the wrong account. + */ + + /** Per-instance auth mutex to avoid multiple concurrent auth prompts from the op CLI. */ + private cliAuthDeferred?: DeferredPromise; + /** Per-instance CLI read batch: maps op references to their pending deferred promises. */ + private cliBatch?: Record> }>; + constructor( readonly id: string, ) { @@ -145,6 +173,157 @@ class OpPluginInstance { // ── Connect REST API helpers ────────────────────────────── + // ── CLI helpers (per-instance, no global side effects) ────── + + private async checkCliAuth(): Promise { + if (this.cliAuthDeferred) { + // Wait for the in-flight first `op` call to finish (or an earlier batch to settle the mutex). + await this.cliAuthDeferred.promise; + // Mutex is already resolved — still return a callable so callers can always invoke authCompletedFn(success). + return (_success: boolean) => undefined; + } + // First caller creates the mutex and must call the returned fn when its `op` run completes. + this.cliAuthDeferred = createDeferredPromise(); + return this.cliAuthDeferred.resolve; + } + + /** Executes the accumulated CLI read batch for this instance using `op run`. */ + private async executeCliBatch(batchToExecute: NonNullable) { + debug('execute op read batch', Object.keys(batchToExecute)); + const envMap = {} as Record; + let i = 1; + Object.keys(batchToExecute).forEach((opReference) => { + envMap[`VARLOCK_1P_INJECT_${i++}`] = opReference; + }); + const startAt = new Date(); + + const authCompletedFn = await this.checkCliAuth(); + // `env -0` splits values by a null character instead of newlines + // because otherwise we'll have trouble dealing with values that contain newlines + await spawnAsync('op', `run --no-masking ${this.account ? `--account ${this.account} ` : ''}-- env -0`.split(' '), { + env: { + // have to pass a few things through at least path so it can find `op` and related config files + PATH: process.env.PATH!, + ...process.env.USER && { USER: process.env.USER }, + ...process.env.HOME && { HOME: process.env.HOME }, + ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, + // proxy env vars so `op` can connect through HTTP/SOCKS proxies + ...pickProxyEnv(), + // forward service account token when the instance opted into CLI-based service account auth + ...this.useCliWithServiceAccount && this.token && { OP_SERVICE_ACCOUNT_TOKEN: this.token }, + // this setting actually just enables the CLI + Desktop App integration + // which in some cases op has a hard time detecting via app setting + OP_BIOMETRIC_UNLOCK_ENABLED: 'true', + ...envMap, + }, + }) + .then(async (result) => { + authCompletedFn?.(true); + debug(`batched OP request took ${+new Date() - +startAt}ms`); + + const lines = result.split('\0'); + for (const line of lines) { + const eqPos = line.indexOf('='); + const key = line.substring(0, eqPos); + + if (!envMap[key]) continue; + const val = line.substring(eqPos + 1); + const opRef = envMap[key]; + + // resolve the deferred promises with the value + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.resolve(val); + }); + } + }) + .catch(async (err) => { + authCompletedFn?.(false); + + // have to do special handling of errors because if any IDs are no good, it kills the whole request + const opErr = processOpCliError(err); + debug('batch failed', opErr); + if ((opErr as any).code === 'BAD_VAULT_REFERENCE') { + const badId = (opErr as any).extraMetadata.badVaultId; + debug('skipping failed bad vault id -', badId); + for (const opRef in batchToExecute) { + if (opRef.startsWith(`op://${badId}/`)) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_ITEM_REFERENCE') { + const badId = (opErr as any).extraMetadata.badItemId; + debug('skipping failed bad item id -', badId); + for (const opRef in batchToExecute) { + const itemRef = opRef.split('/')?.[3]; + if (itemRef === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_FIELD_REFERENCE') { + const badId = (opErr as any).extraMetadata.badFieldId; + debug('skipping failed bad field id -', badId); + for (const opRef in batchToExecute) { + const fieldRef = opRef.split('/')?.slice(4).join('/'); + if (fieldRef === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else { + for (const opRef in batchToExecute) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + + if (Object.keys(batchToExecute).length) { + debug('re-executing remainder of batch', Object.keys(batchToExecute)); + await this.executeCliBatch(batchToExecute); + } + }); + } + + /** + * Reads a single value from 1Password by reference (similar to `op read`) + * but internally batches requests per-instance using `op run`. + */ + private async cliRead(opReference: string): Promise { + // if no batch exists, we'll create it, and this function will kick it off after a timeout + let shouldExecuteBatch = false; + if (!this.cliBatch) { + this.cliBatch = {}; + shouldExecuteBatch = true; + } + + // otherwise we'll just add to the existing batch + this.cliBatch[opReference] ||= { deferredPromises: [] }; + + const deferred = createDeferredPromise(); + this.cliBatch[opReference].deferredPromises.push(deferred); + + if (shouldExecuteBatch) { + setTimeout(async () => { + if (!this.cliBatch) throw Error('expected to find op read batch!'); + const batchToExecute = this.cliBatch; + this.cliBatch = undefined; + await this.executeCliBatch(batchToExecute); + }, CLI_BATCH_READ_TIMEOUT); + } + return deferred.promise; + } + + // ── Connect REST API helpers ────────────────────────────── + private async connectRequest(path: string): Promise { const url = `${this.connectHost}/v1${path}`; debug('connect request:', url); @@ -282,8 +461,7 @@ class OpPluginInstance { return await this.readItemViaConnect(opReference); } else if (this.token && this.useCliWithServiceAccount) { // user wants to use the `op` CLI with service account token instead of the WASM SDK - // NOTE - cli helper does its own batching, untethered to a specific op instance - return await opCliRead(opReference, this.account, this.token); + return await this.cliRead(opReference); } else if (this.token) { // using JS SDK client using service account token await this.initSdkClient(); @@ -305,8 +483,7 @@ class OpPluginInstance { } } else if (this.allowAppAuth) { // using op CLI to talk to 1Password desktop app - // NOTE - cli helper does its own batching, untethered to a specific op instance - return await opCliRead(opReference, this.account); + return await this.cliRead(opReference); } else { throw new SchemaError('Unable to authenticate with 1Password', { tip: `Plugin instance (${this.id}) must be provided a service account token, a Connect server, or have app auth enabled (allowAppAuth=true)`, From 2d0c40db88b84788da2a86d977ffc8fbc35b66b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 17:49:13 +0000 Subject: [PATCH 5/9] refactor(1password-plugin): move CLI batching into OpPluginInstance for full per-instance isolation Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/9b68f206-e3be-4973-b9fb-5a5a5c5ddb90 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- packages/plugins/1password/src/plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index e620dee6..fafbc4d8 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -180,7 +180,7 @@ class OpPluginInstance { // Wait for the in-flight first `op` call to finish (or an earlier batch to settle the mutex). await this.cliAuthDeferred.promise; // Mutex is already resolved — still return a callable so callers can always invoke authCompletedFn(success). - return (_success: boolean) => undefined; + return () => undefined; } // First caller creates the mutex and must call the returned fn when its `op` run completes. this.cliAuthDeferred = createDeferredPromise(); @@ -313,7 +313,7 @@ class OpPluginInstance { if (shouldExecuteBatch) { setTimeout(async () => { - if (!this.cliBatch) throw Error('expected to find op read batch!'); + if (!this.cliBatch) throw Error('Internal error: CLI batch was unexpectedly cleared before execution'); const batchToExecute = this.cliBatch; this.cliBatch = undefined; await this.executeCliBatch(batchToExecute); From d9ad1b318f4a4041499a413c2e6dd13ec3ca5791 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 20:31:56 +0000 Subject: [PATCH 6/9] refactor(1password-plugin): use shared auth mutex for app-auth, per-instance for service account CLI path Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/49bf1429-b718-4d95-aef4-53016650f893 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- packages/plugins/1password/src/plugin.ts | 186 ++++++++++++++++++++--- 1 file changed, 165 insertions(+), 21 deletions(-) diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index fafbc4d8..24c24ded 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -8,6 +8,7 @@ import { } from './cli-helper'; const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; +const { debug } = plugin; const PLUGIN_VERSION = plugin.version; const OP_ICON = 'simple-icons:1password'; @@ -17,8 +18,165 @@ type OpAuthCompletedFn = (success: boolean) => void; const CLI_BATCH_READ_TIMEOUT = 50; +/* + ! IMPORTANT INFO ON CLI APP AUTH + + Because we trigger multiple requests in parallel, if the app/cli is not unlocked, it will show multiple auth popups. + In a big project this is super awkward because you may need to scan your finger over and over again. + + To work around this, we track if we are currently making the first op cli command, and if so acquire a mutex in the form of + a deferred promise that other requests can then wait on. We also use the additional trick of checking `op whoami` so that + if the app is already unlocked, we dont have to actually wait for the first request to finish to proceed with the rest. + + Ideally 1Password will fix this issue at some point and we can remove this extra logic. + + NOTE - We don't currently do anything special to handle if the user denies the login, or is logged into the wrong account. + + IMPORTANT: This shared state is intentional for the `allowAppAuth` path. When authenticating via the local 1Password + desktop app (not a service account), all plugin instances share the same local login context, so we must use a single + shared auth mutex to prevent duplicate auth prompts. Service account instances use per-instance state instead. +*/ + +// Module-level auth mutex shared across all allowAppAuth instances (same local `op` login context). +let appAuthDeferred: DeferredPromise | undefined; +// Module-level read batch shared across all allowAppAuth instances. +let appAuthBatch: Record> }> | undefined; +// Locked account for the shared app auth path (all app-auth instances must use the same account). +let lockCliToOpAccount: string | undefined; + +async function checkAppCliAuth(): Promise { + if (appAuthDeferred) { + await appAuthDeferred.promise; + return () => undefined; + } + appAuthDeferred = createDeferredPromise(); + return appAuthDeferred.resolve; +} + +async function executeAppCliBatch(batchToExecute: NonNullable) { + debug('execute op read batch (app auth)', Object.keys(batchToExecute)); + const envMap = {} as Record; + let i = 1; + Object.keys(batchToExecute).forEach((opReference) => { + envMap[`VARLOCK_1P_INJECT_${i++}`] = opReference; + }); + const startAt = new Date(); + + const authCompletedFn = await checkAppCliAuth(); + // `env -0` splits values by a null character instead of newlines + // because otherwise we'll have trouble dealing with values that contain newlines + await spawnAsync('op', `run --no-masking ${lockCliToOpAccount ? `--account ${lockCliToOpAccount} ` : ''}-- env -0`.split(' '), { + env: { + PATH: process.env.PATH!, + ...process.env.USER && { USER: process.env.USER }, + ...process.env.HOME && { HOME: process.env.HOME }, + ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, + ...pickProxyEnv(), + OP_BIOMETRIC_UNLOCK_ENABLED: 'true', + ...envMap, + }, + }) + .then((result) => { + authCompletedFn?.(true); + debug(`batched OP request took ${+new Date() - +startAt}ms`); + + const lines = result.split('\0'); + for (const line of lines) { + const eqPos = line.indexOf('='); + const key = line.substring(0, eqPos); + if (!envMap[key]) continue; + const val = line.substring(eqPos + 1); + const opRef = envMap[key]; + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.resolve(val); + }); + } + }) + .catch(async (err) => { + authCompletedFn?.(false); + const opErr = processOpCliError(err); + debug('batch failed', opErr); + if ((opErr as any).code === 'BAD_VAULT_REFERENCE') { + const badId = (opErr as any).extraMetadata.badVaultId; + for (const opRef in batchToExecute) { + if (opRef.startsWith(`op://${badId}/`)) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_ITEM_REFERENCE') { + const badId = (opErr as any).extraMetadata.badItemId; + for (const opRef in batchToExecute) { + if (opRef.split('/')?.[3] === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_FIELD_REFERENCE') { + const badId = (opErr as any).extraMetadata.badFieldId; + for (const opRef in batchToExecute) { + if (opRef.split('/')?.slice(4).join('/') === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else { + for (const opRef in batchToExecute) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + if (Object.keys(batchToExecute).length) { + debug('re-executing remainder of batch', Object.keys(batchToExecute)); + await executeAppCliBatch(batchToExecute); + } + }); +} + +/** + * Reads a 1Password reference via the CLI + desktop app auth path. + * Uses a module-level shared batch and auth mutex since all allowAppAuth instances + * share the same local `op` login context. + */ +async function appAuthCliRead(opReference: string, account?: string): Promise { + lockCliToOpAccount ||= account; + if (account && lockCliToOpAccount !== account) { + throw new ResolutionError('Cannot use multiple different 1Password accounts when using CLI auth with batching enabled', { + tip: [ + 'When using CLI auth with batching, all references must use the same 1Password account', + 'Consider using service account tokens instead of CLI auth to allow multiple accounts', + ], + }); + } + + let shouldExecuteBatch = false; + if (!appAuthBatch) { + appAuthBatch = {}; + shouldExecuteBatch = true; + } + appAuthBatch[opReference] ||= { deferredPromises: [] }; + const deferred = createDeferredPromise(); + appAuthBatch[opReference].deferredPromises.push(deferred); + if (shouldExecuteBatch) { + setTimeout(async () => { + if (!appAuthBatch) throw Error('Internal error: app-auth CLI batch was unexpectedly cleared before execution'); + const batchToExecute = appAuthBatch; + appAuthBatch = undefined; + await executeAppCliBatch(batchToExecute); + }, CLI_BATCH_READ_TIMEOUT); + } + return deferred.promise; +} + plugin.name = '1pass'; -const { debug } = plugin; debug('init - version =', plugin.version); plugin.icon = OP_ICON; plugin.standardVars = { @@ -111,24 +269,9 @@ class OpPluginInstance { /** If true, missing items/fields/vaults return undefined instead of throwing */ allowMissing?: boolean; - /* - ! IMPORTANT INFO ON CLI AUTH - - Because we trigger multiple requests in parallel, if the app/cli is not unlocked, it will show multiple auth popups. - In a big project this is super awkward because you may need to scan your finger over and over again. - - To work around this, we track if we are currently making the first op cli command, and if so acquire a mutex in the form of - a deferred promise that other requests can then wait on. We also use the additional trick of checking `op whoami` so that - if the app is already unlocked, we dont have to actually wait for the first request to finish to proceed with the rest. - - Ideally 1Password will fix this issue at some point and we can remove this extra logic. - - NOTE - We don't currently do anything special to handle if the user denies the login, or is logged into the wrong account. - */ - - /** Per-instance auth mutex to avoid multiple concurrent auth prompts from the op CLI. */ + /** Per-instance auth mutex for the service-account CLI path (each token is an independent auth context). */ private cliAuthDeferred?: DeferredPromise; - /** Per-instance CLI read batch: maps op references to their pending deferred promises. */ + /** Per-instance CLI read batch for the service-account CLI path. */ private cliBatch?: Record> }>; constructor( @@ -173,7 +316,7 @@ class OpPluginInstance { // ── Connect REST API helpers ────────────────────────────── - // ── CLI helpers (per-instance, no global side effects) ────── + // ── CLI helpers (per-instance, for service account path only) ────── private async checkCliAuth(): Promise { if (this.cliAuthDeferred) { @@ -187,7 +330,7 @@ class OpPluginInstance { return this.cliAuthDeferred.resolve; } - /** Executes the accumulated CLI read batch for this instance using `op run`. */ + /** Executes the per-instance CLI read batch using `op run` with a service account token. */ private async executeCliBatch(batchToExecute: NonNullable) { debug('execute op read batch', Object.keys(batchToExecute)); const envMap = {} as Record; @@ -483,7 +626,8 @@ class OpPluginInstance { } } else if (this.allowAppAuth) { // using op CLI to talk to 1Password desktop app - return await this.cliRead(opReference); + // Uses shared module-level batch+mutex since all app-auth instances share the same local `op` login context. + return await appAuthCliRead(opReference, this.account); } else { throw new SchemaError('Unable to authenticate with 1Password', { tip: `Plugin instance (${this.id}) must be provided a service account token, a Connect server, or have app auth enabled (allowAppAuth=true)`, From ba9aae0c4ef7e9d5587d12e34d34c4115109091e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 20:56:49 +0000 Subject: [PATCH 7/9] docs(1password): add useCliWithServiceAccount section to docs website Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/14590b2e-4951-4db6-ae62-d4027c0a5c74 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .../src/content/docs/plugins/1password.mdx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/varlock-website/src/content/docs/plugins/1password.mdx b/packages/varlock-website/src/content/docs/plugins/1password.mdx index 6a90613e..164ea632 100644 --- a/packages/varlock-website/src/content/docs/plugins/1password.mdx +++ b/packages/varlock-website/src/content/docs/plugins/1password.mdx @@ -76,6 +76,29 @@ Each vault has a toggle to disable service account access _in general_. It is on ::: +### CLI-based service account auth (memory-constrained environments) + +By default, service account tokens use the 1Password JavaScript SDK (which bundles a WASM module, ~400 MB RSS). In memory-constrained environments such as 512 MB containers or VMs, this footprint may be prohibitive. + +Set `useCliWithServiceAccount=true` to use the `op` CLI binary instead of the SDK while still authenticating headlessly via the service account token: + +```diff lang="env-spec" title=".env.schema" add="useCliWithServiceAccount=true" +# @plugin(@varlock/1password-plugin) +# @initOp(token=$OP_TOKEN, useCliWithServiceAccount=true) +# --- + +# @type=opServiceAccountToken @sensitive +OP_TOKEN= +``` + +**Requirements:** + +1. **Install the `op` CLI**: [Installation guide](https://developer.1password.com/docs/cli/get-started/) +2. The token referenced by `token=` must resolve to a valid service account token at load time. + +The `op` binary is significantly lighter than the WASM SDK. Authentication is handled headlessly via `OP_SERVICE_ACCOUNT_TOKEN` — no desktop app or interactive sign-in is needed. Multiple plugin instances configured with different service account tokens are fully isolated from one another. + + ### Desktop app auth (for local dev) During local development, you may find it convenient to skip the service account tokens and instead rely on your local 1Password desktop app (via the [CLI integration](https://developer.1password.com/docs/cli/get-started/#step-2-turn-on-the-1password-desktop-app-integration)), including using its biometric unlocking features. @@ -187,6 +210,7 @@ Initializes an instance of the 1Password plugin - setting up options and authent - `token` (optional): service account token. Should be a reference to a config item of type `opServiceAccountToken`. - `allowAppAuth` (optional): boolean flag to enable authenticating using the local desktop app - `account` (optional): limits the `op` cli to connect to specific 1Password account (shorthand, sign-in address, account ID, or user ID) +- `useCliWithServiceAccount` (optional): when `true`, uses the `op` CLI binary instead of the WASM SDK for service account auth. Useful in memory-constrained environments. Requires the `op` CLI to be installed. ```env-spec "@initOp" # @initOp(id=notProd, token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco) From 8d84e409b9d768db0cd8dad2055e83a4f1d40c29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 23:18:18 +0000 Subject: [PATCH 8/9] fix(1password-plugin): fix TypeScript errors in defer.ts and plugin.ts Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/67e287d0-1be4-45cc-86ad-3de9827cf156 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- packages/plugins/1password/src/plugin.ts | 2 +- packages/utils/src/defer.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 24c24ded..41a3a8da 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -617,7 +617,7 @@ class OpPluginInstance { } // add item to batch, with deferred promise this.readBatch[opReference] ||= { defers: [] }; - const deferred = createDeferredPromise(); + const deferred = createDeferredPromise(); this.readBatch[opReference].defers.push(deferred); if (triggerBatch) { setImmediate(() => this.executeReadBatch()); diff --git a/packages/utils/src/defer.ts b/packages/utils/src/defer.ts index 4d0aac08..6385562f 100644 --- a/packages/utils/src/defer.ts +++ b/packages/utils/src/defer.ts @@ -4,9 +4,9 @@ // should be used sparingly export function createDeferredPromise() { // set to noop, but they will be replaced immediately - let resolve: (value?: T) => void = () => {}; + let resolve: (value: T | PromiseLike) => void = () => {}; let reject: (reason?: unknown) => void = () => {}; - const promise = new Promise((_resolve, _reject) => { + const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); From b8461b98ee327b3d271b123f122556d1a29bb094 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 5 May 2026 21:54:38 -0700 Subject: [PATCH 9/9] fix(1password-plugin): redact tokens in debug logs and add comprehensive tests - Redact token and connectToken in setAuth debug output (was logging plaintext) - Improve debug labels to distinguish auth methods (SDK vs service account CLI vs app auth) - Add 28 tests covering all resolution paths: service account CLI, app auth CLI, SDK, environments, data types, schema errors, and batch error retry logic --- .gitignore | 1 + bun.lock | 1 + framework-tests/bun.lock | 6 +- packages/plugins/1password/package.json | 2 + packages/plugins/1password/src/plugin.ts | 6 +- .../plugins/1password/test/1password.test.ts | 560 ++++++++++++++++++ packages/plugins/1password/test/fake-op.sh | 69 +++ .../plugins/1password/tsup.test.config.ts | 18 + packages/plugins/1password/vitest.config.ts | 11 + 9 files changed, 668 insertions(+), 6 deletions(-) create mode 100644 packages/plugins/1password/test/1password.test.ts create mode 100644 packages/plugins/1password/test/fake-op.sh create mode 100644 packages/plugins/1password/tsup.test.config.ts create mode 100644 packages/plugins/1password/vitest.config.ts diff --git a/.gitignore b/.gitignore index 208212af..ab02868f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +dist-test *.ignore ignore .DS_Store diff --git a/bun.lock b/bun.lock index 35485aff..9cb9c3ff 100644 --- a/bun.lock +++ b/bun.lock @@ -162,6 +162,7 @@ "@env-spec/utils": "workspace:^", "@types/node": "catalog:", "import-meta-resolve": "^4.2.0", + "outdent": "catalog:", "tsup": "catalog:", "varlock": "workspace:^", "vitest": "catalog:", diff --git a/framework-tests/bun.lock b/framework-tests/bun.lock index 02252c73..972ec502 100644 --- a/framework-tests/bun.lock +++ b/framework-tests/bun.lock @@ -5,7 +5,7 @@ "": { "name": "varlock-framework-tests", "devDependencies": { - "@types/node": "^22.0.0", + "@types/node": "^24.0.0", "typescript": "^5.9.3", "vitest": "^3.2.4", }, @@ -122,7 +122,7 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -208,7 +208,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], diff --git a/packages/plugins/1password/package.json b/packages/plugins/1password/package.json index d220a44d..41f9dae2 100644 --- a/packages/plugins/1password/package.json +++ b/packages/plugins/1password/package.json @@ -19,6 +19,7 @@ "scripts": { "dev": "bun run copy-wasm && tsup --watch", "build": "tsup && bun run copy-wasm", + "build:test": "tsup --config tsup.test.config.ts", "copy-wasm": "mkdir -p dist || true && cp node_modules/@1password/sdk-core/nodejs/core_bg.wasm ./dist", "test": "vitest", "typecheck": "tsc --noEmit" @@ -49,6 +50,7 @@ "@env-spec/utils": "workspace:^", "@types/node": "catalog:", "import-meta-resolve": "^4.2.0", + "outdent": "catalog:", "tsup": "catalog:", "varlock": "workspace:^", "vitest": "catalog:" diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 41a3a8da..d71354c8 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -295,7 +295,7 @@ class OpPluginInstance { if (connectHost && typeof connectHost === 'string') this.connectHost = connectHost.replace(/\/+$/, ''); if (connectToken && typeof connectToken === 'string') this.connectToken = connectToken; if (allowMissing !== undefined) this.allowMissing = allowMissing; - debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account, 'connect:', !!connectHost, 'useCliWithServiceAccount:', useCliWithServiceAccount); + debug('op instance', this.id, ' set auth - token:', !!token, 'allowAppAuth:', allowAppAuth, 'account:', account, 'connect:', !!connectHost, 'connectToken:', !!connectToken, 'useCliWithServiceAccount:', useCliWithServiceAccount); } /** Whether this instance is configured for Connect server */ @@ -332,7 +332,7 @@ class OpPluginInstance { /** Executes the per-instance CLI read batch using `op run` with a service account token. */ private async executeCliBatch(batchToExecute: NonNullable) { - debug('execute op read batch', Object.keys(batchToExecute)); + debug('execute op read batch (service account CLI)', Object.keys(batchToExecute)); const envMap = {} as Record; let i = 1; Object.keys(batchToExecute).forEach((opReference) => { @@ -679,7 +679,7 @@ class OpPluginInstance { this.readBatch = undefined; const opReferences = Object.keys(batch || {}); - debug('bulk fetching', opReferences); + debug('bulk fetching (SDK)', opReferences); if (!opReferences.length) return; try { diff --git a/packages/plugins/1password/test/1password.test.ts b/packages/plugins/1password/test/1password.test.ts new file mode 100644 index 00000000..1ddfa150 --- /dev/null +++ b/packages/plugins/1password/test/1password.test.ts @@ -0,0 +1,560 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { + describe, test, beforeAll, afterAll, +} from 'vitest'; +import outdent from 'outdent'; +import { pluginTest } from 'varlock/test-helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PLUGIN_PATH = path.join(__dirname, '..'); +const FIXTURES_DIR = path.join(__dirname, 'fixtures'); + +// Fake `op` CLI script — reads canned responses from a JSON config file +// located next to the script itself (op-config.json in the same directory). +const FAKE_OP_SRC = path.join(__dirname, 'fake-op.sh'); +const FAKE_BIN_DIR = path.join(FIXTURES_DIR, 'bin'); +const FAKE_OP = path.join(FAKE_BIN_DIR, 'op'); +const OP_CONFIG_PATH = path.join(FAKE_BIN_DIR, 'op-config.json'); + +// ── SDK mock setup ─────────────────────────────────────────────────── +// The test build (dist-test/plugin.cjs) externalizes @1password/sdk so +// we can pre-populate Node's require cache with a mock before the plugin loads. + +const SDK_TEST_PLUGIN_PATH = path.join(__dirname, '..', 'dist-test'); +const SDK_TEST_PLUGIN_CJS = path.join(SDK_TEST_PLUGIN_PATH, 'plugin.cjs'); + +/** Mutable state the mock SDK reads from — updated per test */ +const sdkMockState = { + responses: {} as Record, + /** Per-ref errors (NOT_FOUND, etc.) */ + itemErrors: {} as Record, + /** If set, resolveAll throws this string (simulates SDK-level crash) */ + sdkThrow: undefined as string | undefined, + /** Map of environment ID → array of { name, value } */ + environments: {} as Record>, + /** If set, getVariables throws with this message */ + envThrow: undefined as string | undefined, +}; + +function resetSdkMockState() { + sdkMockState.responses = {}; + sdkMockState.itemErrors = {}; + sdkMockState.sdkThrow = undefined; + sdkMockState.environments = {}; + sdkMockState.envThrow = undefined; +} + +const mockSdkClient = { + secrets: { + async resolveAll(refs: Array) { + if (sdkMockState.sdkThrow) throw sdkMockState.sdkThrow; + const individualResponses: Record = {}; + for (const ref of refs) { + if (sdkMockState.itemErrors[ref]) { + individualResponses[ref] = { error: { message: sdkMockState.itemErrors[ref] } }; + } else if (ref in sdkMockState.responses) { + individualResponses[ref] = { content: { secret: sdkMockState.responses[ref] } }; + } + // refs not in responses/errors → no entry (triggers "no response returned" error) + } + return { individualResponses }; + }, + }, + environments: { + async getVariables(envId: string) { + if (sdkMockState.envThrow) throw new Error(sdkMockState.envThrow); + const vars = sdkMockState.environments[envId]; + if (!vars) throw new Error(`environment "${envId}" not found`); + return { variables: vars }; + }, + }, +}; + +const mockSdkExports = { + createClient: async () => mockSdkClient, + Client: class {}, +}; + +beforeAll(() => { + fs.mkdirSync(FAKE_BIN_DIR, { recursive: true }); + fs.copyFileSync(FAKE_OP_SRC, FAKE_OP); + fs.chmodSync(FAKE_OP, 0o755); + + // Write a minimal package.json so the env graph can resolve the test plugin + fs.writeFileSync( + path.join(SDK_TEST_PLUGIN_PATH, 'package.json'), + JSON.stringify({ exports: { './plugin': './plugin.cjs' } }), + ); + + // Pre-populate require.cache so the test build's `require('@1password/sdk')` + // returns our mock instead of loading the real WASM-based SDK. + const testRequire = createRequire(SDK_TEST_PLUGIN_CJS); + const sdkResolvedPath = testRequire.resolve('@1password/sdk'); + testRequire.cache[sdkResolvedPath] = { + id: sdkResolvedPath, + filename: sdkResolvedPath, + loaded: true, + exports: mockSdkExports, + children: [], + paths: [], + path: path.dirname(sdkResolvedPath), + } as any; +}); + +afterAll(() => { + fs.rmSync(FIXTURES_DIR, { recursive: true, force: true }); + // Clean up the test package.json + try { + fs.unlinkSync(path.join(SDK_TEST_PLUGIN_PATH, 'package.json')); + } catch { /* may not exist */ } +}); + +// ── Test helper ────────────────────────────────────────────────────── + +type OpConfig = { + /** Map of op:// references to their resolved values */ + responses?: Record; + /** + * Map of op:// references to error messages. + * When any ref in a batch matches, the entire batch fails with this message + * (matching real `op` behaviour). The plugin then retries the remaining refs. + */ + errors?: Record; + /** Map of environment ID to raw env-format output (KEY=value lines) */ + environments?: Record; +}; + +type OpTestOpts = { + opConfig?: OpConfig; + /** Items section of the schema (after `---`). Auto-wrapped with plugin/init boilerplate. */ + schema?: string; + /** Auth mode: 'serviceAccountCli' uses token + CLI, 'appAuth' uses desktop app auth */ + authMode?: 'serviceAccountCli' | 'appAuth'; + /** Extra @initOp params (e.g. `account="my-acct"`) */ + initParams?: string; + /** Full schema override (skips auto-generated boilerplate) */ + fullSchema?: string; +} & Omit[0], 'schema'>; + +/** + * Build a test case that wires up plugin/initOp boilerplate and + * creates a fake op config with the expected responses. + */ +function opTest(opts: OpTestOpts) { + const { + opConfig = {}, + schema, + authMode = 'serviceAccountCli', + initParams = '', + fullSchema: fullSchemaOverride, + ...rest + } = opts; + + return async () => { + // Write the op config for this test + fs.writeFileSync(OP_CONFIG_PATH, JSON.stringify(opConfig)); + + const origPath = process.env.PATH; + process.env.PATH = `${FAKE_BIN_DIR}:${origPath}`; + + try { + let initLine: string; + let tokenLine = ''; + + if (authMode === 'serviceAccountCli') { + initLine = `# @initOp(token=$OP_SA_TOKEN, useCliWithServiceAccount=true${initParams ? `, ${initParams}` : ''})`; + tokenLine = outdent` + # @type=string @sensitive + OP_SA_TOKEN= + `; + } else { + initLine = `# @initOp(allowAppAuth=true${initParams ? `, ${initParams}` : ''})`; + } + + const fullSchema = fullSchemaOverride ?? outdent` + # @plugin(${PLUGIN_PATH}) + ${initLine} + # --- + ${tokenLine} + ${schema} + `; + + await pluginTest({ + ...rest, + schema: fullSchema, + ...(authMode === 'serviceAccountCli' + ? { injectValues: { OP_SA_TOKEN: 'ops_fake_test_token', ...rest.injectValues } } + : {}), + })(); + } finally { + process.env.PATH = origPath; + } + }; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe('1password plugin', () => { + // ── Service account CLI path ────────────────────────────── + describe('service account CLI path (useCliWithServiceAccount)', () => { + test('resolves a single secret', opTest({ + opConfig: { + responses: { 'op://vault/item/field': 'my-secret-value' }, + }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'my-secret-value' }, + })); + + test('resolves multiple secrets in a batch', opTest({ + opConfig: { + responses: { + 'op://vault/item/username': 'admin', + 'op://vault/item/password': 's3cret', + 'op://vault/other/api-key': 'ak-12345', + }, + }, + schema: outdent` + DB_USER=op("op://vault/item/username") + DB_PASS=op("op://vault/item/password") + API_KEY=op("op://vault/other/api-key") + `, + expectValues: { DB_USER: 'admin', DB_PASS: 's3cret', API_KEY: 'ak-12345' }, + })); + + test('handles values containing special characters', opTest({ + opConfig: { + responses: { 'op://vault/item/field': 'p@ss=w0rd&foo' }, + }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'p@ss=w0rd&foo' }, + })); + + test('bad vault reference rejects that item and retries the rest', opTest({ + opConfig: { + responses: { 'op://good-vault/item/field': 'good-value' }, + errors: { 'op://bad-vault/item/field': '"bad-vault" isn\'t a vault in this account. Specify the vault' }, + }, + schema: outdent` + GOOD=op("op://good-vault/item/field") + BAD=op("op://bad-vault/item/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + + test('bad item reference rejects that item and retries the rest', opTest({ + opConfig: { + responses: { 'op://vault/good-item/field': 'good-value' }, + errors: { 'op://vault/bad-item/field': 'could not find item bad-item in vault vault' }, + }, + schema: outdent` + GOOD=op("op://vault/good-item/field") + BAD=op("op://vault/bad-item/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + + test('bad field reference rejects that item and retries the rest', opTest({ + opConfig: { + responses: { 'op://vault/item/good-field': 'good-value' }, + errors: { 'op://vault/item/bad-field': "item 'vault/item' does not have a field 'bad-field'" }, + }, + schema: outdent` + GOOD=op("op://vault/item/good-field") + BAD=op("op://vault/item/bad-field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + }); + + // ── App auth CLI path ───────────────────────────────────── + describe('app auth CLI path (allowAppAuth)', () => { + test('resolves a single secret', opTest({ + authMode: 'appAuth', + opConfig: { + responses: { 'op://vault/item/field': 'app-auth-secret' }, + }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'app-auth-secret' }, + })); + + test('resolves multiple secrets in a batch', opTest({ + authMode: 'appAuth', + opConfig: { + responses: { + 'op://vault/item/user': 'admin', + 'op://vault/item/pass': 'hunter2', + }, + }, + schema: outdent` + DB_USER=op("op://vault/item/user") + DB_PASS=op("op://vault/item/pass") + `, + expectValues: { DB_USER: 'admin', DB_PASS: 'hunter2' }, + })); + + test('bad vault reference rejects that item and retries the rest', opTest({ + authMode: 'appAuth', + opConfig: { + responses: { 'op://good-vault/item/field': 'good-value' }, + errors: { 'op://bad-vault/item/field': '"bad-vault" isn\'t a vault in this account. Specify the vault' }, + }, + schema: outdent` + GOOD=op("op://good-vault/item/field") + BAD=op("op://bad-vault/item/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + }); + + // ── opLoadEnvironment ───────────────────────────────────── + describe('opLoadEnvironment', () => { + test('loads environment via service account CLI', opTest({ + opConfig: { + environments: { 'env-abc123': 'DB_HOST=localhost\nDB_PORT=5432\n' }, + }, + schema: 'ALL=opLoadEnvironment("env-abc123")', + expectValues: { ALL: JSON.stringify({ DB_HOST: 'localhost', DB_PORT: '5432' }) }, + })); + + test('loads environment via app auth', opTest({ + authMode: 'appAuth', + opConfig: { + environments: { 'env-xyz789': 'API_URL=https://api.example.com\nAPI_KEY=key123\n' }, + }, + schema: 'ALL=opLoadEnvironment("env-xyz789")', + expectValues: { ALL: JSON.stringify({ API_URL: 'https://api.example.com', API_KEY: 'key123' }) }, + })); + + test('invalid environment ID returns error', opTest({ + opConfig: {}, + schema: 'ALL=opLoadEnvironment("bad-env-id")', + expectValues: { ALL: Error }, + })); + }); + + // ── Named instances ─────────────────────────────────────── + describe('named instances', () => { + test('resolves from a named instance', opTest({ + opConfig: { + responses: { 'op://vault/item/field': 'named-value' }, + }, + fullSchema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(id=prod, token=$OP_SA_TOKEN, useCliWithServiceAccount=true) + # --- + # @type=string @sensitive + OP_SA_TOKEN= + SECRET=op(prod, "op://vault/item/field") + `, + injectValues: { OP_SA_TOKEN: 'ops_fake_test_token' }, + expectValues: { SECRET: 'named-value' }, + })); + }); + + // ── Data types ──────────────────────────────────────────── + describe('data types', () => { + test('opServiceAccountToken validates ops_ prefix', opTest({ + opConfig: {}, + schema: outdent` + # @type=opServiceAccountToken + MY_TOKEN=ops_valid_token_12345 + `, + expectValues: { MY_TOKEN: 'ops_valid_token_12345' }, + })); + + test('opServiceAccountToken rejects invalid prefix', opTest({ + opConfig: {}, + schema: outdent` + # @type=opServiceAccountToken + MY_TOKEN=invalid_token + `, + expectValues: { MY_TOKEN: Error }, + })); + + test('opServiceAccountToken is marked sensitive', opTest({ + opConfig: {}, + schema: outdent` + # @type=opServiceAccountToken + MY_TOKEN=ops_valid_token_12345 + `, + expectSensitive: { MY_TOKEN: true }, + })); + + test('opConnectToken is marked sensitive', opTest({ + opConfig: {}, + schema: outdent` + # @type=opConnectToken + MY_TOKEN=some_connect_token + `, + expectSensitive: { MY_TOKEN: true }, + })); + }); + + // ── SDK path (service account token, no useCliWithServiceAccount) ── + describe('SDK path (service account token)', () => { + /** + * Helper for SDK-path tests. Uses the test build (dist-test/) which + * externalizes @1password/sdk so it can be mocked via require.cache. + */ + type SdkTestOpts = { + /** Mock responses: op:// ref → resolved secret value */ + mockResponses?: Record; + /** Mock per-ref errors: op:// ref → error message */ + mockItemErrors?: Record; + /** If set, resolveAll() throws this string (simulates SDK-level crash) */ + mockSdkThrow?: string; + /** Mock environments: envId → array of { name, value } */ + mockEnvironments?: Record>; + /** If set, getVariables() throws with this message */ + mockEnvThrow?: string; + } & Omit; + + function sdkTest(opts: SdkTestOpts) { + const { + mockResponses = {}, + mockItemErrors = {}, + mockSdkThrow, + mockEnvironments = {}, + mockEnvThrow, + schema, + initParams = '', + fullSchema: fullSchemaOverride, + ...rest + } = opts; + + return async () => { + // Set up mock state before plugin loads + sdkMockState.responses = mockResponses; + sdkMockState.itemErrors = mockItemErrors; + sdkMockState.sdkThrow = mockSdkThrow; + sdkMockState.environments = mockEnvironments; + sdkMockState.envThrow = mockEnvThrow; + + const initLine = `# @initOp(token=$OP_SA_TOKEN${initParams ? `, ${initParams}` : ''})`; + + const fullSchema = fullSchemaOverride ?? outdent` + # @plugin(${SDK_TEST_PLUGIN_PATH}) + ${initLine} + # --- + # @type=string @sensitive + OP_SA_TOKEN= + ${schema} + `; + + try { + await pluginTest({ + ...rest, + schema: fullSchema, + injectValues: { OP_SA_TOKEN: 'ops_fake_test_token', ...rest.injectValues }, + })(); + } finally { + resetSdkMockState(); + } + }; + } + + test('resolves a single secret', sdkTest({ + mockResponses: { 'op://vault/item/field': 'sdk-secret-value' }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'sdk-secret-value' }, + })); + + test('resolves multiple secrets in a batch', sdkTest({ + mockResponses: { + 'op://vault/item/username': 'admin', + 'op://vault/item/password': 's3cret', + }, + schema: outdent` + DB_USER=op("op://vault/item/username") + DB_PASS=op("op://vault/item/password") + `, + expectValues: { DB_USER: 'admin', DB_PASS: 's3cret' }, + })); + + test('per-ref error marks only that item as failed', sdkTest({ + mockResponses: { 'op://vault/item/field': 'good-value' }, + mockItemErrors: { 'op://vault/missing/field': 'item not found' }, + schema: outdent` + GOOD=op("op://vault/item/field") + BAD=op("op://vault/missing/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + + test('ref with no response entry returns error', sdkTest({ + mockResponses: {}, + schema: 'MISSING=op("op://vault/no-response/field")', + expectValues: { MISSING: Error }, + })); + + test('SDK-level throw rejects all items', sdkTest({ + mockSdkThrow: 'SDK authentication failed', + schema: outdent` + A=op("op://vault/item/a") + B=op("op://vault/item/b") + `, + expectValues: { A: Error, B: Error }, + })); + + test('loads environment via SDK', sdkTest({ + mockEnvironments: { + 'env-sdk-123': [ + { name: 'API_URL', value: 'https://api.test' }, + { name: 'API_KEY', value: 'key-abc' }, + ], + }, + schema: 'ALL=opLoadEnvironment("env-sdk-123")', + expectValues: { ALL: JSON.stringify({ API_URL: 'https://api.test', API_KEY: 'key-abc' }) }, + })); + + test('invalid environment ID via SDK returns error', sdkTest({ + schema: 'ALL=opLoadEnvironment("bad-env-id")', + expectValues: { ALL: Error }, + })); + }); + + // ── Schema errors ───────────────────────────────────────── + describe('schema errors', () => { + test('missing auth config', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp() + # --- + `, + expectSchemaError: true, + })); + + test('duplicate default instance id', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(allowAppAuth=true) + # @initOp(allowAppAuth=true) + # --- + `, + expectSchemaError: true, + })); + + test('duplicate named instance id', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(id=prod, allowAppAuth=true) + # @initOp(id=prod, allowAppAuth=true) + # --- + `, + expectSchemaError: true, + })); + + test('unused plugin with no op calls causes no errors', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(allowAppAuth=true) + # --- + UNRELATED=hello + `, + expectValues: { UNRELATED: 'hello' }, + })); + }); +}); diff --git a/packages/plugins/1password/test/fake-op.sh b/packages/plugins/1password/test/fake-op.sh new file mode 100644 index 00000000..23b919f0 --- /dev/null +++ b/packages/plugins/1password/test/fake-op.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Fake op CLI for integration tests. +# Reads config from op-config.json next to this script. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="$SCRIPT_DIR/op-config.json" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "[ERROR] fake op: config file not found ($CONFIG_FILE)" >&2 + exit 1 +fi + +case "$1" in + whoami) + echo "Test User " + ;; + run) + # op run --no-masking [--account X] -- env -0 + # Reads VARLOCK_1P_INJECT_* env vars, resolves op:// references from config, + # and outputs null-separated KEY=value pairs (like `env -0`). + node -e " + const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf-8')); + const responses = cfg.responses || {}; + const errors = cfg.errors || {}; + const refs = []; + + for (const [key, ref] of Object.entries(process.env)) { + if (!key.startsWith('VARLOCK_1P_INJECT_')) continue; + refs.push({ key, ref }); + } + + // Check for errors first (real op fails entire batch on first error) + for (const { ref } of refs) { + if (errors[ref]) { + process.stderr.write('[ERROR] 2024/01/01 00:00:00 ' + errors[ref] + '\n'); + process.exit(1); + } + } + + // Output resolved values as null-separated pairs + const results = []; + for (const { key, ref } of refs) { + if (ref in responses) { + results.push(key + '=' + responses[ref]); + } + } + process.stdout.write(results.join('\0') + '\0'); + " + ;; + environment) + # op environment read [--account X] + ENV_ID="$3" + node -e " + const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf-8')); + const environments = cfg.environments || {}; + const envId = '$ENV_ID'; + if (envId in environments) { + process.stdout.write(environments[envId]); + } else { + process.stderr.write('[ERROR] 2024/01/01 00:00:00 environment not found or invalid\n'); + process.exit(1); + } + " + ;; + *) + echo "[ERROR] fake op: unknown subcommand $1" >&2 + exit 1 + ;; +esac diff --git a/packages/plugins/1password/tsup.test.config.ts b/packages/plugins/1password/tsup.test.config.ts new file mode 100644 index 00000000..3b6286bf --- /dev/null +++ b/packages/plugins/1password/tsup.test.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; + +/** + * Test-only build config — identical to production except `@1password/sdk` + * is external so tests can mock it via `require.cache`. + */ +export default defineConfig({ + entry: ['src/plugin.ts'], + dts: false, + sourcemap: true, + treeshake: true, + clean: false, + outDir: 'dist-test', + format: ['cjs'], + splitting: false, + target: 'esnext', + external: ['varlock', '@1password/sdk'], +}); diff --git a/packages/plugins/1password/vitest.config.ts b/packages/plugins/1password/vitest.config.ts new file mode 100644 index 00000000..e9819654 --- /dev/null +++ b/packages/plugins/1password/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + conditions: ['ts-src'], + }, + define: { + __VARLOCK_BUILD_TYPE__: JSON.stringify('test'), + __VARLOCK_SEA_BUILD__: 'false', + }, +});