Skip to content

Commit fcd0430

Browse files
authored
Fix 1Password vault listing: dead CLI→SDK fallback and swallowed error causes (#1290)
* Fix 1Password vault listing fallback * Appease effect-typed-errors lint at the 1Password error boundaries
1 parent 379e95e commit fcd0430

3 files changed

Lines changed: 194 additions & 18 deletions

File tree

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ 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+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message`
48+
const message = error.message.trim();
49+
return message ? `${VAULT_LIST_ERROR_FALLBACK}: ${message}` : VAULT_LIST_ERROR_FALLBACK;
50+
};
51+
4452
function VaultPicker(props: {
4553
authKind: "desktop-app" | "service-account";
4654
accountName: string;
@@ -61,15 +69,15 @@ function VaultPicker(props: {
6169
isLoading: true,
6270
error: null,
6371
}),
64-
onError: () => ({
72+
onError: (queryError) => ({
6573
vaults: [] as { id: string; name: string }[],
6674
isLoading: false,
67-
error: "Failed to list vaults",
75+
error: formatVaultListError(queryError),
6876
}),
6977
onDefect: () => ({
7078
vaults: [] as { id: string; name: string }[],
7179
isLoading: false,
72-
error: "Failed to list vaults",
80+
error: VAULT_LIST_ERROR_FALLBACK,
7381
}),
7482
onSuccess: ({ value }) => {
7583
const v = value.vaults;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { beforeEach, describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock/vi.hoisted must come from vitest itself for mock hoisting to resolve
4+
import { vi } from "vitest";
5+
6+
import { OnePasswordError } from "./errors";
7+
import { makeOnePasswordService } from "./service";
8+
9+
const opMocks = vi.hoisted(() => ({
10+
setGlobalFlags: vi.fn(),
11+
setServiceAccount: vi.fn(),
12+
vaultList: vi.fn(),
13+
itemList: vi.fn(),
14+
readParse: vi.fn(),
15+
}));
16+
17+
const sdkMocks = vi.hoisted(() => ({
18+
createClient: vi.fn(),
19+
DesktopAuth: vi.fn((accountName: string) => ({ accountName })),
20+
}));
21+
22+
vi.mock("@1password/op-js", () => ({
23+
setGlobalFlags: opMocks.setGlobalFlags,
24+
setServiceAccount: opMocks.setServiceAccount,
25+
vault: { list: opMocks.vaultList },
26+
item: { list: opMocks.itemList },
27+
read: { parse: opMocks.readParse },
28+
}));
29+
30+
vi.mock("@1password/sdk", () => ({
31+
createClient: sdkMocks.createClient,
32+
DesktopAuth: sdkMocks.DesktopAuth,
33+
}));
34+
35+
describe("makeOnePasswordService", () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
opMocks.vaultList.mockReturnValue([]);
39+
opMocks.itemList.mockReturnValue([]);
40+
opMocks.readParse.mockReturnValue("secret");
41+
sdkMocks.createClient.mockResolvedValue({
42+
secrets: { resolve: vi.fn(async () => "secret") },
43+
vaults: { list: vi.fn(async () => []) },
44+
items: { list: vi.fn(async () => []) },
45+
});
46+
});
47+
48+
it.effect("falls back to the SDK when the CLI throws while listing vaults", () =>
49+
Effect.gen(function* () {
50+
const sdkVaultsList = vi.fn(async () => [{ id: "sdk-vault", title: "SDK Vault" }]);
51+
opMocks.vaultList.mockImplementation(() => {
52+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the untyped op-js CLI wrapper throwing
53+
throw new Error("spawn op ENOENT");
54+
});
55+
sdkMocks.createClient.mockResolvedValue({
56+
secrets: { resolve: vi.fn(async () => "secret") },
57+
vaults: { list: sdkVaultsList },
58+
items: { list: vi.fn(async () => []) },
59+
});
60+
61+
const service = yield* makeOnePasswordService(
62+
{ kind: "service-account", token: "ops_test_token" },
63+
{ timeoutMs: 1_000 },
64+
);
65+
const vaults = yield* service.listVaults();
66+
67+
expect(vaults).toEqual([{ id: "sdk-vault", title: "SDK Vault" }]);
68+
expect(sdkMocks.createClient).toHaveBeenCalledTimes(1);
69+
expect(sdkVaultsList).toHaveBeenCalledTimes(1);
70+
}),
71+
);
72+
73+
it.effect("includes the backend cause when both vault listing backends fail", () =>
74+
Effect.gen(function* () {
75+
opMocks.vaultList.mockImplementation(() => {
76+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the untyped op-js CLI wrapper throwing
77+
throw new Error("spawn op ENOENT");
78+
});
79+
sdkMocks.createClient.mockResolvedValue({
80+
secrets: { resolve: vi.fn(async () => "secret") },
81+
vaults: {
82+
list: vi.fn(async () => {
83+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the untyped 1Password SDK rejecting
84+
throw new Error("desktop approval refused for account");
85+
}),
86+
},
87+
items: { list: vi.fn(async () => []) },
88+
});
89+
90+
const error = yield* makeOnePasswordService(
91+
{ kind: "service-account", token: "ops_test_token" },
92+
{ timeoutMs: 1_000 },
93+
).pipe(
94+
Effect.flatMap((service) => service.listVaults()),
95+
Effect.flip,
96+
);
97+
98+
expect(error).toBeInstanceOf(OnePasswordError);
99+
// oxlint-disable executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed message; asserting its contents
100+
expect(error.message).toContain("1Password SDK vault listing failed:");
101+
expect(error.message).toContain("desktop approval refused for account");
102+
expect(error.message).not.toBe("1Password CLI vault listing failed");
103+
// oxlint-enable executor/no-unknown-error-message
104+
}),
105+
);
106+
});

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

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,37 @@ 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+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: normalizing untyped op-js/SDK throwables into OnePasswordError.message
57+
const maybeMessage = (cause as { readonly message?: unknown } | null | undefined)?.message;
58+
const raw =
59+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: last-resort stringification of a non-Error throwable
60+
typeof maybeMessage === "string" && maybeMessage.length > 0 ? maybeMessage : String(cause);
61+
return raw
62+
.replace(SERVICE_ACCOUNT_TOKEN_RE, "[redacted 1Password token]")
63+
.replace(/\s+/g, " ")
64+
.trim();
65+
};
66+
67+
const messageWithCause = (prefix: string, cause: unknown): string => {
68+
const causeMessage = formatCause(cause);
69+
const message = causeMessage ? `${prefix}: ${causeMessage}` : prefix;
70+
return message.length > MAX_ERROR_MESSAGE_LENGTH
71+
? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...`
72+
: message;
73+
};
74+
5375
const loadOnePasswordSdk = (): Effect.Effect<OnePasswordSdkModule, OnePasswordError> =>
5476
Effect.tryPromise({
5577
try: () => import("@1password/sdk"),
56-
catch: () =>
78+
catch: (cause) =>
5779
new OnePasswordError({
5880
operation: "sdk module load",
59-
message: "Failed to load 1Password SDK",
81+
message: messageWithCause("Failed to load 1Password SDK", cause),
6082
}),
6183
});
6284

@@ -99,20 +121,20 @@ export const makeNativeSdkService = (
99121
integrationName: "Executor",
100122
integrationVersion: "0.0.0",
101123
}),
102-
catch: () =>
124+
catch: (cause) =>
103125
new OnePasswordError({
104126
operation: "client setup",
105-
message: "Failed to set up 1Password client",
127+
message: messageWithCause("Failed to set up 1Password client", cause),
106128
}),
107129
}).pipe(timeoutWithOnePasswordError("client setup", timeoutMs));
108130

109131
const wrap = <A>(fn: () => Promise<A>, operation: string): Effect.Effect<A, OnePasswordError> =>
110132
Effect.tryPromise({
111133
try: fn,
112-
catch: () =>
134+
catch: (cause) =>
113135
new OnePasswordError({
114136
operation,
115-
message: `1Password SDK ${operation} failed`,
137+
message: messageWithCause(`1Password SDK ${operation} failed`, cause),
116138
}),
117139
}).pipe(
118140
timeoutWithOnePasswordError(operation, timeoutMs),
@@ -158,10 +180,10 @@ export const makeCliService = (
158180
}
159181
return fn();
160182
},
161-
catch: () =>
183+
catch: (cause) =>
162184
new OnePasswordError({
163185
operation,
164-
message: `1Password CLI ${operation} failed`,
186+
message: messageWithCause(`1Password CLI ${operation} failed`, cause),
165187
}),
166188
}),
167189
)
@@ -186,6 +208,24 @@ export const makeCliService = (
186208
// Smart factory — tries CLI first (avoids IPC hang), falls back to SDK
187209
// ---------------------------------------------------------------------------
188210

211+
const isCliUnavailable = (error: OnePasswordError): boolean => {
212+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message`
213+
const message = error.message.toLowerCase();
214+
return (
215+
message.includes("enoent") ||
216+
message.includes("not found") ||
217+
message.includes("command not found") ||
218+
message.includes("not installed") ||
219+
message.includes("no such file") ||
220+
message.includes("spawn op")
221+
);
222+
};
223+
224+
const chooseFallbackError = (
225+
cliError: OnePasswordError,
226+
sdkError: OnePasswordError,
227+
): OnePasswordError => (isCliUnavailable(cliError) ? sdkError : cliError);
228+
189229
export const makeOnePasswordService = (
190230
auth: ResolvedAuth,
191231
options?: { readonly preferSdk?: boolean; readonly timeoutMs?: number },
@@ -196,11 +236,33 @@ export const makeOnePasswordService = (
196236
return makeNativeSdkService(auth, timeoutMs);
197237
}
198238

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-
);
239+
return Effect.gen(function* () {
240+
const cliService = yield* makeCliService(auth);
241+
const sdkService = yield* Effect.cached(makeNativeSdkService(auth, timeoutMs));
242+
243+
const withSdkFallback = <A>(
244+
cliEffect: Effect.Effect<A, OnePasswordError>,
245+
sdkEffect: (service: OnePasswordService) => Effect.Effect<A, OnePasswordError>,
246+
): Effect.Effect<A, OnePasswordError> =>
247+
cliEffect.pipe(
248+
Effect.catch((cliError: OnePasswordError) =>
249+
sdkService.pipe(
250+
Effect.flatMap(sdkEffect),
251+
Effect.mapError((sdkError: OnePasswordError) =>
252+
chooseFallbackError(cliError, sdkError),
253+
),
254+
),
255+
),
256+
);
257+
258+
return OnePasswordServiceTag.of({
259+
resolveSecret: (uri) =>
260+
withSdkFallback(cliService.resolveSecret(uri), (service) => service.resolveSecret(uri)),
261+
262+
listVaults: () => withSdkFallback(cliService.listVaults(), (service) => service.listVaults()),
263+
264+
listItems: (vaultId) =>
265+
withSdkFallback(cliService.listItems(vaultId), (service) => service.listItems(vaultId)),
266+
});
267+
}).pipe(Effect.withSpan("onepassword.make_service"));
206268
};

0 commit comments

Comments
 (0)