Skip to content

Commit cce1956

Browse files
committed
Fix 1Password vault listing fallback
1 parent 379e95e commit cce1956

3 files changed

Lines changed: 184 additions & 18 deletions

File tree

packages/plugins/onepassword/src/react/OnePasswordSettings.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ import type { RedactedOnePasswordConfig } from "../sdk/types";
4141
// Vault picker
4242
// ---------------------------------------------------------------------------
4343

44+
const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults";
45+
46+
const formatVaultListError = (error: Error): string => {
47+
const message = error.message.trim();
48+
return message ? `${VAULT_LIST_ERROR_FALLBACK}: ${message}` : VAULT_LIST_ERROR_FALLBACK;
49+
};
50+
4451
function VaultPicker(props: {
4552
authKind: "desktop-app" | "service-account";
4653
accountName: string;
@@ -61,15 +68,15 @@ function VaultPicker(props: {
6168
isLoading: true,
6269
error: null,
6370
}),
64-
onError: () => ({
71+
onError: (queryError) => ({
6572
vaults: [] as { id: string; name: string }[],
6673
isLoading: false,
67-
error: "Failed to list vaults",
74+
error: formatVaultListError(queryError),
6875
}),
6976
onDefect: () => ({
7077
vaults: [] as { id: string; name: string }[],
7178
isLoading: false,
72-
error: "Failed to list vaults",
79+
error: VAULT_LIST_ERROR_FALLBACK,
7380
}),
7481
onSuccess: ({ value }) => {
7582
const v = value.vaults;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { beforeEach, describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
import { vi } from "vitest";
4+
5+
import { OnePasswordError } from "./errors";
6+
import { makeOnePasswordService } from "./service";
7+
8+
const opMocks = vi.hoisted(() => ({
9+
setGlobalFlags: vi.fn(),
10+
setServiceAccount: vi.fn(),
11+
vaultList: vi.fn(),
12+
itemList: vi.fn(),
13+
readParse: vi.fn(),
14+
}));
15+
16+
const sdkMocks = vi.hoisted(() => ({
17+
createClient: vi.fn(),
18+
DesktopAuth: vi.fn((accountName: string) => ({ accountName })),
19+
}));
20+
21+
vi.mock("@1password/op-js", () => ({
22+
setGlobalFlags: opMocks.setGlobalFlags,
23+
setServiceAccount: opMocks.setServiceAccount,
24+
vault: { list: opMocks.vaultList },
25+
item: { list: opMocks.itemList },
26+
read: { parse: opMocks.readParse },
27+
}));
28+
29+
vi.mock("@1password/sdk", () => ({
30+
createClient: sdkMocks.createClient,
31+
DesktopAuth: sdkMocks.DesktopAuth,
32+
}));
33+
34+
describe("makeOnePasswordService", () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
opMocks.vaultList.mockReturnValue([]);
38+
opMocks.itemList.mockReturnValue([]);
39+
opMocks.readParse.mockReturnValue("secret");
40+
sdkMocks.createClient.mockResolvedValue({
41+
secrets: { resolve: vi.fn(async () => "secret") },
42+
vaults: { list: vi.fn(async () => []) },
43+
items: { list: vi.fn(async () => []) },
44+
});
45+
});
46+
47+
it.effect("falls back to the SDK when the CLI throws while listing vaults", () =>
48+
Effect.gen(function* () {
49+
const sdkVaultsList = vi.fn(async () => [{ id: "sdk-vault", title: "SDK Vault" }]);
50+
opMocks.vaultList.mockImplementation(() => {
51+
throw new Error("spawn op ENOENT");
52+
});
53+
sdkMocks.createClient.mockResolvedValue({
54+
secrets: { resolve: vi.fn(async () => "secret") },
55+
vaults: { list: sdkVaultsList },
56+
items: { list: vi.fn(async () => []) },
57+
});
58+
59+
const service = yield* makeOnePasswordService(
60+
{ kind: "service-account", token: "ops_test_token" },
61+
{ timeoutMs: 1_000 },
62+
);
63+
const vaults = yield* service.listVaults();
64+
65+
expect(vaults).toEqual([{ id: "sdk-vault", title: "SDK Vault" }]);
66+
expect(sdkMocks.createClient).toHaveBeenCalledTimes(1);
67+
expect(sdkVaultsList).toHaveBeenCalledTimes(1);
68+
}),
69+
);
70+
71+
it.effect("includes the backend cause when both vault listing backends fail", () =>
72+
Effect.gen(function* () {
73+
opMocks.vaultList.mockImplementation(() => {
74+
throw new Error("spawn op ENOENT");
75+
});
76+
sdkMocks.createClient.mockResolvedValue({
77+
secrets: { resolve: vi.fn(async () => "secret") },
78+
vaults: {
79+
list: vi.fn(async () => {
80+
throw new Error("desktop approval refused for account");
81+
}),
82+
},
83+
items: { list: vi.fn(async () => []) },
84+
});
85+
86+
const error = yield* makeOnePasswordService(
87+
{ kind: "service-account", token: "ops_test_token" },
88+
{ timeoutMs: 1_000 },
89+
).pipe(
90+
Effect.flatMap((service) => service.listVaults()),
91+
Effect.flip,
92+
);
93+
94+
expect(error).toBeInstanceOf(OnePasswordError);
95+
expect(error.message).toContain("1Password SDK vault listing failed:");
96+
expect(error.message).toContain("desktop approval refused for account");
97+
expect(error.message).not.toBe("1Password CLI vault listing failed");
98+
}),
99+
);
100+
});

packages/plugins/onepassword/src/sdk/service.ts

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,35 @@ export type ResolvedAuth =
4848
// ---------------------------------------------------------------------------
4949

5050
const DEFAULT_TIMEOUT_MS = 15_000;
51+
const MAX_ERROR_MESSAGE_LENGTH = 300;
52+
const SERVICE_ACCOUNT_TOKEN_RE = /ops_[A-Za-z0-9_-]+/g;
5153
type OnePasswordSdkModule = typeof import("@1password/sdk");
5254

55+
const formatCause = (cause: unknown): string => {
56+
const maybeMessage = (cause as { readonly message?: unknown } | null | undefined)?.message;
57+
const raw =
58+
typeof maybeMessage === "string" && maybeMessage.length > 0 ? maybeMessage : String(cause);
59+
return raw
60+
.replace(SERVICE_ACCOUNT_TOKEN_RE, "[redacted 1Password token]")
61+
.replace(/\s+/g, " ")
62+
.trim();
63+
};
64+
65+
const messageWithCause = (prefix: string, cause: unknown): string => {
66+
const causeMessage = formatCause(cause);
67+
const message = causeMessage ? `${prefix}: ${causeMessage}` : prefix;
68+
return message.length > MAX_ERROR_MESSAGE_LENGTH
69+
? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...`
70+
: message;
71+
};
72+
5373
const loadOnePasswordSdk = (): Effect.Effect<OnePasswordSdkModule, OnePasswordError> =>
5474
Effect.tryPromise({
5575
try: () => import("@1password/sdk"),
56-
catch: () =>
76+
catch: (cause) =>
5777
new OnePasswordError({
5878
operation: "sdk module load",
59-
message: "Failed to load 1Password SDK",
79+
message: messageWithCause("Failed to load 1Password SDK", cause),
6080
}),
6181
});
6282

@@ -99,20 +119,20 @@ export const makeNativeSdkService = (
99119
integrationName: "Executor",
100120
integrationVersion: "0.0.0",
101121
}),
102-
catch: () =>
122+
catch: (cause) =>
103123
new OnePasswordError({
104124
operation: "client setup",
105-
message: "Failed to set up 1Password client",
125+
message: messageWithCause("Failed to set up 1Password client", cause),
106126
}),
107127
}).pipe(timeoutWithOnePasswordError("client setup", timeoutMs));
108128

109129
const wrap = <A>(fn: () => Promise<A>, operation: string): Effect.Effect<A, OnePasswordError> =>
110130
Effect.tryPromise({
111131
try: fn,
112-
catch: () =>
132+
catch: (cause) =>
113133
new OnePasswordError({
114134
operation,
115-
message: `1Password SDK ${operation} failed`,
135+
message: messageWithCause(`1Password SDK ${operation} failed`, cause),
116136
}),
117137
}).pipe(
118138
timeoutWithOnePasswordError(operation, timeoutMs),
@@ -158,10 +178,10 @@ export const makeCliService = (
158178
}
159179
return fn();
160180
},
161-
catch: () =>
181+
catch: (cause) =>
162182
new OnePasswordError({
163183
operation,
164-
message: `1Password CLI ${operation} failed`,
184+
message: messageWithCause(`1Password CLI ${operation} failed`, cause),
165185
}),
166186
}),
167187
)
@@ -186,6 +206,23 @@ export const makeCliService = (
186206
// Smart factory — tries CLI first (avoids IPC hang), falls back to SDK
187207
// ---------------------------------------------------------------------------
188208

209+
const isCliUnavailable = (error: OnePasswordError): boolean => {
210+
const message = error.message.toLowerCase();
211+
return (
212+
message.includes("enoent") ||
213+
message.includes("not found") ||
214+
message.includes("command not found") ||
215+
message.includes("not installed") ||
216+
message.includes("no such file") ||
217+
message.includes("spawn op")
218+
);
219+
};
220+
221+
const chooseFallbackError = (
222+
cliError: OnePasswordError,
223+
sdkError: OnePasswordError,
224+
): OnePasswordError => (isCliUnavailable(cliError) ? sdkError : cliError);
225+
189226
export const makeOnePasswordService = (
190227
auth: ResolvedAuth,
191228
options?: { readonly preferSdk?: boolean; readonly timeoutMs?: number },
@@ -196,11 +233,33 @@ export const makeOnePasswordService = (
196233
return makeNativeSdkService(auth, timeoutMs);
197234
}
198235

199-
// Default: prefer CLI to avoid the IPC hang bug
200-
return makeCliService(auth).pipe(
201-
Effect.catch((cliError: OnePasswordError) =>
202-
// CLI unavailable (e.g. `op` not installed) — fall back to SDK
203-
makeNativeSdkService(auth, timeoutMs).pipe(Effect.mapError(() => cliError)),
204-
),
205-
);
236+
return Effect.gen(function* () {
237+
const cliService = yield* makeCliService(auth);
238+
const sdkService = yield* Effect.cached(makeNativeSdkService(auth, timeoutMs));
239+
240+
const withSdkFallback = <A>(
241+
cliEffect: Effect.Effect<A, OnePasswordError>,
242+
sdkEffect: (service: OnePasswordService) => Effect.Effect<A, OnePasswordError>,
243+
): Effect.Effect<A, OnePasswordError> =>
244+
cliEffect.pipe(
245+
Effect.catch((cliError: OnePasswordError) =>
246+
sdkService.pipe(
247+
Effect.flatMap(sdkEffect),
248+
Effect.mapError((sdkError: OnePasswordError) =>
249+
chooseFallbackError(cliError, sdkError),
250+
),
251+
),
252+
),
253+
);
254+
255+
return OnePasswordServiceTag.of({
256+
resolveSecret: (uri) =>
257+
withSdkFallback(cliService.resolveSecret(uri), (service) => service.resolveSecret(uri)),
258+
259+
listVaults: () => withSdkFallback(cliService.listVaults(), (service) => service.listVaults()),
260+
261+
listItems: (vaultId) =>
262+
withSdkFallback(cliService.listItems(vaultId), (service) => service.listItems(vaultId)),
263+
});
264+
}).pipe(Effect.withSpan("onepassword.make_service"));
206265
};

0 commit comments

Comments
 (0)