Skip to content

Commit 2cad774

Browse files
authored
Cache 1Password resolutions and unblock the op spawn (#1860)
1 parent 058592d commit 2cad774

8 files changed

Lines changed: 579 additions & 172 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@executor-js/plugin-onepassword": patch
3+
---
4+
5+
**1Password-backed connections no longer pay a 1Password read on every tool call**
6+
7+
Each tool call resolves its connection's credential, and for 1Password-backed connections every resolution shelled out to the `op` CLI — roughly a second per call under desktop-app auth, multiplying the latency of every call several times over. The spawn was also synchronous, so one slow resolution (for example `op` waiting on a 1Password approval prompt) blocked the whole local server for every other request, with no timeout on that path.
8+
9+
Three changes:
10+
11+
- Successful resolutions are now served from memory for a short TTL (default 60s, `secretCacheTtlMs`). The cache keys by a fingerprint of the provider config, so editing or removing an account drops all cached secrets at once; not-found, ambiguity, and failure outcomes are never retained. Concurrent resolutions of the same ref share one backend read even with the TTL set to `0`.
12+
- The `op` CLI now runs as an asynchronous spawn with a hard deadline (the plugin's existing `timeoutMs`), so a stuck `op` fails with the troubleshooting message instead of freezing the server. Auth reaches the child per spawn (service-account token via the environment, desktop account via `--account`) instead of through the previous backend's process-global token state.
13+
- Services are memoized per auth identity, so the SDK fallback reuses one authenticated client instead of re-authenticating per resolution.

‎bun.lock‎

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/plugins/onepassword/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
"typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
5454
},
5555
"dependencies": {
56-
"@1password/op-js": "^0.1.13",
5756
"@1password/sdk": "^0.4.1-beta.1",
5857
"@effect/atom-react": "catalog:",
5958
"@executor-js/sdk": "workspace:*"
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { execFile } from "node:child_process";
2+
3+
// Raw `op` CLI spawn boundary. Kept as its own module so the service can be
4+
// tested against a fake without mocking node builtins. The spawn is
5+
// asynchronous on purpose: the previous backend (`@1password/op-js`) ran `op`
6+
// with execFileSync, so an `op` stuck on a 1Password approval prompt blocked
7+
// the host's entire event loop — on the single-threaded local daemon that
8+
// froze every in-flight request until the prompt was answered.
9+
10+
/** Spawn outcome as plain data. The promise always resolves; the service
11+
* layer owns failure typing and message shaping (redaction, truncation). */
12+
export type OpCliResult =
13+
| { readonly ok: true; readonly stdout: string }
14+
| {
15+
readonly ok: false;
16+
/** True when the child was killed by the spawn timeout. */
17+
readonly timedOut: boolean;
18+
readonly message: string;
19+
};
20+
21+
export interface OpCliInvocation {
22+
readonly args: readonly string[];
23+
readonly env: Readonly<Record<string, string | undefined>>;
24+
/** Hard deadline for the child; on expiry it is killed and the result
25+
* carries `timedOut: true`. */
26+
readonly timeoutMs: number;
27+
/** Fiber interruption reaches the child through this signal. */
28+
readonly signal: AbortSignal;
29+
}
30+
31+
const isExecFileError = (
32+
error: unknown,
33+
): error is NodeJS.ErrnoException & { readonly killed?: boolean } =>
34+
typeof error === "object" && error !== null && "message" in error;
35+
36+
const describeSpawnError = (error: unknown): string => {
37+
if (isExecFileError(error)) {
38+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: normalizing the untyped execFile callback error into plain result data
39+
return error.message;
40+
}
41+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: last-resort stringification of a non-Error spawn failure
42+
return String(error);
43+
};
44+
45+
/** Run `op` once. stdout carries the successful payload; stderr carries the
46+
* CLI's human-readable diagnostics, so a non-zero exit reports stderr when
47+
* present (falling back to the spawn error, e.g. `spawn op ENOENT`). */
48+
export const opCliExec = ({
49+
args,
50+
env,
51+
timeoutMs,
52+
signal,
53+
}: OpCliInvocation): Promise<OpCliResult> =>
54+
new Promise((resolve) => {
55+
execFile(
56+
"op",
57+
args,
58+
{
59+
env: env as NodeJS.ProcessEnv,
60+
timeout: timeoutMs,
61+
signal,
62+
maxBuffer: 16 * 1024 * 1024,
63+
},
64+
(error, stdout, stderr) => {
65+
if (error === null) {
66+
resolve({ ok: true, stdout });
67+
return;
68+
}
69+
const stderrText = stderr.trim();
70+
resolve({
71+
ok: false,
72+
timedOut: isExecFileError(error) && error.killed === true,
73+
message: stderrText.length > 0 ? stderrText : describeSpawnError(error),
74+
});
75+
},
76+
);
77+
});

‎packages/plugins/onepassword/src/sdk/plugin.test.ts‎

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { describe, it, expect } from "@effect/vitest";
22
import { Effect } from "effect";
3+
import { TestClock } from "effect/testing";
34

45
import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk";
56
import { makeInMemoryBlobStore, pluginBlobStore } from "@executor-js/sdk/core";
67
import { makeTestConfig } from "@executor-js/sdk/testing";
78

8-
import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin";
9+
import {
10+
makeCachedRefResolver,
11+
makeOnePasswordStore,
12+
onepasswordPlugin,
13+
resolveConfiguredRef,
14+
} from "./plugin";
915
import type { OnePasswordService } from "./service";
1016
import { OnePasswordError } from "./errors";
1117
import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types";
@@ -524,3 +530,121 @@ describe("resolveConfiguredRef", () => {
524530
}),
525531
);
526532
});
533+
534+
// ---------------------------------------------------------------------------
535+
// Cached ref resolution — the executor resolves a connection's credential on
536+
// every tool call, so successful resolutions are served from memory for a
537+
// short TTL instead of paying a 1Password round trip per call.
538+
// ---------------------------------------------------------------------------
539+
540+
describe("makeCachedRefResolver", () => {
541+
const countingBackend = () => {
542+
let resolves = 0;
543+
const serviceFor = (account: OnePasswordAccount) =>
544+
Effect.succeed<OnePasswordService>({
545+
resolveSecret: (uri) =>
546+
Effect.sync(() => {
547+
resolves += 1;
548+
return `secret:${account.id}:${uri}`;
549+
}),
550+
listVaults: () => Effect.succeed([]),
551+
listItems: () => Effect.succeed([]),
552+
});
553+
return { serviceFor, resolveCount: () => resolves };
554+
};
555+
556+
it.effect("serves a repeated resolution from memory within the TTL", () =>
557+
Effect.gen(function* () {
558+
const backend = countingBackend();
559+
const resolve = makeCachedRefResolver(backend.serviceFor, 60_000);
560+
561+
const first = yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
562+
const second = yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
563+
564+
expect(first).toEqual({
565+
kind: "resolved",
566+
value: "secret:acct-default:op://vault-123/item-1/credential",
567+
});
568+
expect(second).toEqual(first);
569+
expect(backend.resolveCount()).toBe(1);
570+
}),
571+
);
572+
573+
it.effect("asks the backend again once the TTL has passed", () =>
574+
Effect.gen(function* () {
575+
const backend = countingBackend();
576+
const resolve = makeCachedRefResolver(backend.serviceFor, 60_000);
577+
578+
yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
579+
yield* TestClock.adjust("61 seconds");
580+
yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
581+
582+
expect(backend.resolveCount()).toBe(2);
583+
}),
584+
);
585+
586+
it.effect("never retains a not-found outcome", () =>
587+
Effect.gen(function* () {
588+
// A bare ref against empty vault listings resolves to not-found; the
589+
// item may be created a moment later, so the miss must not stick.
590+
const backend = countingBackend();
591+
let listings = 0;
592+
const serviceFor = (account: OnePasswordAccount) =>
593+
backend.serviceFor(account).pipe(
594+
Effect.map((service) => ({
595+
...service,
596+
listItems: () =>
597+
Effect.sync(() => {
598+
listings += 1;
599+
return [];
600+
}),
601+
})),
602+
);
603+
const resolve = makeCachedRefResolver(serviceFor, 60_000);
604+
605+
const first = yield* resolve(oneAccountConfig, "missing-item");
606+
const second = yield* resolve(oneAccountConfig, "missing-item");
607+
608+
expect(first).toEqual({ kind: "not-found" });
609+
expect(second).toEqual({ kind: "not-found" });
610+
// Two vaults in the config, listed once per resolution.
611+
expect(listings).toBe(4);
612+
}),
613+
);
614+
615+
it.effect("drops every cached secret the moment the config changes", () =>
616+
Effect.gen(function* () {
617+
const backend = countingBackend();
618+
const resolve = makeCachedRefResolver(backend.serviceFor, 60_000);
619+
620+
yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
621+
// Same ref, edited config (one vault removed): a removed account or
622+
// vault must not keep serving secrets it used to grant.
623+
const edited = OnePasswordConfig.make({
624+
accounts: [
625+
OnePasswordAccount.make({
626+
id: "acct-default",
627+
name: "1Password",
628+
auth: desktopAuth,
629+
vaults: [{ id: "vault-123", name: "Personal" }],
630+
}),
631+
],
632+
});
633+
yield* resolve(edited, "op://vault-123/item-1/credential");
634+
635+
expect(backend.resolveCount()).toBe(2);
636+
}),
637+
);
638+
639+
it.effect("with a zero TTL every sequential resolution reaches the backend", () =>
640+
Effect.gen(function* () {
641+
const backend = countingBackend();
642+
const resolve = makeCachedRefResolver(backend.serviceFor, 0);
643+
644+
yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
645+
yield* resolve(oneAccountConfig, "op://vault-123/item-1/credential");
646+
647+
expect(backend.resolveCount()).toBe(2);
648+
}),
649+
);
650+
});

0 commit comments

Comments
 (0)