@monster-orange/den 0.4.26 → 0.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/shared/hub.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * workspace's name, then the team's default). The token is never logged,
20
20
  * never in a page or an API answer: only in the agent's own 0600 MCP file.
21
21
  */
22
+ import * as crypto from "crypto";
22
23
  import { readDenConfig, readUserConfig, type DenConfig } from "./den-config.js";
23
24
  import {
24
25
  getSecretsProvider,
@@ -28,6 +29,12 @@ import {
28
29
  type SecretsConfig,
29
30
  type SecretsProvider,
30
31
  } from "./secrets/index.js";
32
+ import {
33
+ describeOpTokenSource,
34
+ parseOpTokenSource,
35
+ readOpToken,
36
+ type OpTokenSource,
37
+ } from "./secrets/op-token.js";
31
38
 
32
39
  /** The server's name in an agent's MCP config. */
33
40
  export const HUB_SERVER = "hub";
@@ -41,8 +48,16 @@ export interface HubConfig {
41
48
  url: string;
42
49
  /** The token's ref in this install's store, or null when none is set. */
43
50
  tokenRef: SecretRef | null;
51
+ /**
52
+ * HUB-1.7: or a token source, `file:<path>` (mode 600), `keychain:<service>`
53
+ * or `env:<VAR>`: where Connect keeps a token when the install's store
54
+ * can't, or a distribution's own choice.
55
+ */
56
+ tokenSource?: OpTokenSource;
44
57
  /** Where the ref came from, for messages (never the value). */
45
58
  tokenFrom: string;
59
+ /** HUB-1.7: Hub's MCP endpoint, as Hub gave it (default `<url>/mcp`). */
60
+ mcpUrl: string;
46
61
  /**
47
62
  * hub.allow_tools (default true): agents use Hub's tools without asking.
48
63
  * Off, they ask like any tool. An install's own deny rule still wins.
@@ -51,7 +66,12 @@ export interface HubConfig {
51
66
  }
52
67
 
53
68
  type RawHub =
54
- | { url?: unknown; token?: unknown; allow_tools?: unknown }
69
+ | {
70
+ url?: unknown;
71
+ token?: unknown;
72
+ allow_tools?: unknown;
73
+ mcp_url?: unknown;
74
+ }
55
75
  | undefined;
56
76
 
57
77
  function rawHub(den: unknown, user: unknown): RawHub {
@@ -94,7 +114,11 @@ export function readHubConfig(
94
114
  const t = h.token;
95
115
  let tokenRef: SecretRef | null = null;
96
116
  let tokenFrom = "hub.token";
97
- if (typeof t === "string" && t.trim()) {
117
+ let tokenSource: OpTokenSource | undefined;
118
+ if (typeof t === "string" && /^(keychain|file|env):/.test(t.trim())) {
119
+ tokenSource = parseOpTokenSource(t.trim(), "hub.token");
120
+ tokenFrom = `hub.token (${describeOpTokenSource(tokenSource)})`;
121
+ } else if (typeof t === "string" && t.trim()) {
98
122
  // The name of a secrets.refs entry.
99
123
  tokenFrom = `secrets.refs.${t.trim()}`;
100
124
  tokenRef = namedSecretRef(t.trim(), secrets());
@@ -110,12 +134,28 @@ export function readHubConfig(
110
134
  tokenRef = ref.item || ref.key || ref.field ? ref : null;
111
135
  } else if (t !== undefined && t !== null)
112
136
  throw new Error(
113
- "hub.token must be a secret ref ({ item, field } or { key }) or the name of a secrets.refs entry",
137
+ "hub.token must be a secret ref ({ item, field } or { key }), the name of a secrets.refs entry, or file:<path> / keychain:<service> / env:<VAR>",
114
138
  );
115
139
  const at = h.allow_tools;
116
140
  if (at !== undefined && at !== null && typeof at !== "boolean")
117
141
  throw new Error("hub.allow_tools must be true or false");
118
- return { url, tokenRef, tokenFrom, allowTools: at !== false };
142
+ let mcpUrl = `${url}/mcp`;
143
+ if (h.mcp_url !== undefined && h.mcp_url !== null && h.mcp_url !== "") {
144
+ if (typeof h.mcp_url !== "string")
145
+ throw new Error("hub.mcp_url must be a string");
146
+ const m = h.mcp_url.trim();
147
+ const badMcp = hubUrlProblem(m);
148
+ if (badMcp) throw new Error(badMcp.replace("hub.url", "hub.mcp_url"));
149
+ mcpUrl = m;
150
+ }
151
+ return {
152
+ url,
153
+ tokenRef,
154
+ ...(tokenSource ? { tokenSource } : {}),
155
+ tokenFrom,
156
+ mcpUrl,
157
+ allowTools: at !== false,
158
+ };
119
159
  }
120
160
 
121
161
  /**
@@ -141,6 +181,10 @@ export async function readHubToken(
141
181
  cfg: HubConfig,
142
182
  provider: () => SecretsProvider = getSecretsProvider,
143
183
  ): Promise<string | null> {
184
+ if (cfg.tokenSource) {
185
+ const r = await readOpToken(cfg.tokenSource).catch(() => null);
186
+ return r?.ok && r.token.trim() ? r.token.trim() : null;
187
+ }
144
188
  if (!cfg.tokenRef) return null;
145
189
  try {
146
190
  const v = await provider().tryGet(cfg.tokenRef);
@@ -152,7 +196,10 @@ export async function readHubToken(
152
196
 
153
197
  /** Who calls, as Hub reads it: plain names, never a secret. */
154
198
  export interface HubCaller {
199
+ /** The display name: Hub shows the latest one. */
155
200
  agent: string;
201
+ /** HUB-1.7: the agent's stable id: Hub keys attribution on it. */
202
+ agentId?: string;
156
203
  session: string;
157
204
  workspace: string;
158
205
  repos: string[];
@@ -168,6 +215,7 @@ export function hubHeaders(
168
215
  return {
169
216
  Authorization: `Bearer ${token}`,
170
217
  "X-Den-Agent": headerSafe(who.agent),
218
+ ...(who.agentId ? { "X-Den-Agent-Id": headerSafe(who.agentId) } : {}),
171
219
  "X-Den-Session": headerSafe(who.session),
172
220
  "X-Den-Workspace": headerSafe(who.workspace),
173
221
  "X-Den-Repos": headerSafe(who.repos.join(",")),
@@ -185,97 +233,261 @@ export function ownerRepo(
185
233
  return m ? `${m[1]}/${m[2]}` : null;
186
234
  }
187
235
 
236
+ /** HUB-1.7: who this install is, as Hub's GET /connect/whoami says. */
188
237
  export interface HubWhoami {
189
- team?: string;
190
- label?: string;
238
+ /** The install's label (Hub's `install`). */
239
+ install?: string;
240
+ role?: string;
241
+ /** The person who connected it. */
242
+ login?: string;
243
+ company?: { slug?: string; name?: string; url?: string };
244
+ /** Hub's MCP endpoint; opaque. */
245
+ mcpUrl?: string;
246
+ /** Threads on Hub waiting on the person (the rail's badge). */
247
+ unread?: number;
191
248
  }
192
249
 
250
+ /**
251
+ * Why a call to Hub failed: `rejected` (401: the token isn't accepted:
252
+ * connect again), `forbidden` (403: Hub's own words; ask an admin, never
253
+ * retry), `refused` (another 4xx with Hub's error code), `unreachable`.
254
+ * The message never carries the token.
255
+ */
256
+ export class HubCallError extends Error {
257
+ constructor(
258
+ readonly kind: "rejected" | "forbidden" | "refused" | "unreachable",
259
+ message: string,
260
+ readonly code?: string,
261
+ ) {
262
+ super(message);
263
+ }
264
+ }
265
+
266
+ export type HubState =
267
+ | "connected"
268
+ | "no_token"
269
+ | "rejected"
270
+ | "forbidden"
271
+ | "unreachable"
272
+ | "bad_config";
273
+
193
274
  export type HubStatus =
194
275
  | { configured: false }
195
276
  | {
196
277
  configured: true;
197
278
  url: string;
198
279
  connected: boolean;
280
+ /** HUB-1.7: what Settings shows and offers (Connect, Reconnect, …). */
281
+ state: HubState;
199
282
  /** hub.allow_tools: agents use Hub's tools without asking. */
200
283
  allowTools?: boolean;
284
+ /** The company's name (Hub's company.name). */
201
285
  team?: string;
286
+ /** Its page on Hub, for the deep link. */
287
+ teamUrl?: string;
288
+ /** This install's label on Hub. */
202
289
  label?: string;
290
+ role?: string;
291
+ login?: string;
292
+ /** Hub threads waiting on the person (whoami's unread). */
293
+ unread?: number;
203
294
  /** Why it isn't connected, in words (never the token). */
204
295
  problem?: string;
205
296
  };
206
297
 
207
- /** The first object in a tool result: structured content, else JSON text. */
208
- function resultObject(r: unknown): Record<string, unknown> | null {
209
- const o = r as {
210
- structuredContent?: unknown;
211
- content?: { type?: string; text?: string }[];
212
- };
213
- if (o?.structuredContent && typeof o.structuredContent === "object")
214
- return o.structuredContent as Record<string, unknown>;
215
- for (const c of o?.content ?? [])
216
- if (c?.type === "text" && c.text)
217
- try {
218
- const v = JSON.parse(c.text) as unknown;
219
- if (v && typeof v === "object") return v as Record<string, unknown>;
220
- } catch {
221
- /* not JSON */
222
- }
223
- return null;
298
+ type FetchFn = typeof fetch;
299
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
300
+
301
+ /** One JSON call to Hub, bounded; its answer, or a HubCallError. */
302
+ async function hubCall(
303
+ url: string,
304
+ init: RequestInit,
305
+ opts: { timeoutMs?: number; fetch?: FetchFn },
306
+ ): Promise<Record<string, unknown>> {
307
+ const timeout = opts.timeoutMs ?? 5000;
308
+ let res: Response;
309
+ try {
310
+ res = await (opts.fetch ?? fetch)(url, {
311
+ ...init,
312
+ redirect: "error",
313
+ signal: AbortSignal.timeout(timeout),
314
+ });
315
+ } catch (e) {
316
+ const name = (e as Error)?.name;
317
+ throw new HubCallError(
318
+ "unreachable",
319
+ name === "TimeoutError" || name === "AbortError"
320
+ ? `Hub did not answer in ${timeout} ms`
321
+ : `Hub could not be reached: ${(e as Error)?.message ?? e}`,
322
+ );
323
+ }
324
+ let body: Record<string, unknown> = {};
325
+ try {
326
+ const v = (await res.json()) as unknown;
327
+ if (v && typeof v === "object") body = v as Record<string, unknown>;
328
+ } catch {
329
+ /* not JSON */
330
+ }
331
+ if (res.ok) return body;
332
+ const said =
333
+ str(body.error_description) ?? str(body.message) ?? str(body.error);
334
+ if (res.status === 401)
335
+ throw new HubCallError(
336
+ "rejected",
337
+ "Hub doesn't accept this install's token (revoked, or replaced by a newer connect): connect again",
338
+ str(body.error),
339
+ );
340
+ if (res.status === 403)
341
+ throw new HubCallError(
342
+ "forbidden",
343
+ `${said ?? "Hub refused this install"}: ask an admin of the company on Hub`,
344
+ str(body.error),
345
+ );
346
+ if (res.status >= 400 && res.status < 500)
347
+ throw new HubCallError(
348
+ "refused",
349
+ `Hub refused it (${res.status}${said ? `: ${said}` : ""})`,
350
+ str(body.error),
351
+ );
352
+ throw new HubCallError(
353
+ "unreachable",
354
+ `Hub answered ${res.status}${said ? `: ${said}` : ""}`,
355
+ );
224
356
  }
225
357
 
226
- /**
227
- * Ask Hub who this install is: its whoami tool (the first tool whose name
228
- * ends in "whoami"), over Streamable HTTP with the token. Bounded by
229
- * `timeoutMs`; a failure is said in words, without the token.
230
- */
358
+ const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
359
+
360
+ /** Ask Hub who this install is: GET <url>/connect/whoami with the token. */
231
361
  export async function hubWhoami(
232
362
  url: string,
233
363
  token: string,
234
- opts: { timeoutMs?: number; fetch?: typeof fetch } = {},
364
+ opts: { timeoutMs?: number; fetch?: FetchFn } = {},
235
365
  ): Promise<HubWhoami> {
236
- // Loaded here: only Settings › Hub and doctor ask Hub anything.
237
- const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
238
- const { StreamableHTTPClientTransport } =
239
- await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
240
- const transport = new StreamableHTTPClientTransport(new URL(`${url}/mcp`), {
241
- requestInit: { headers: { Authorization: `Bearer ${token}` } },
242
- ...(opts.fetch ? { fetch: opts.fetch } : {}),
243
- });
244
- const client = new Client({ name: "den", version: "1" });
245
- const timeout = opts.timeoutMs ?? 5000;
246
- let timer: NodeJS.Timeout | undefined;
247
- const bounded = <T>(p: Promise<T>) =>
248
- Promise.race([
249
- p,
250
- new Promise<never>((_r, rej) => {
251
- timer = setTimeout(
252
- () => rej(new Error(`Hub did not answer in ${timeout} ms`)),
253
- timeout,
254
- );
366
+ const b = await hubCall(
367
+ `${url}/connect/whoami`,
368
+ {
369
+ method: "GET",
370
+ headers: { ...bearer(token), Accept: "application/json" },
371
+ },
372
+ opts,
373
+ );
374
+ const c = (b.company ?? {}) as Record<string, unknown>;
375
+ const company = {
376
+ ...(str(c.slug) ? { slug: str(c.slug)! } : {}),
377
+ ...(str(c.name) ? { name: str(c.name)! } : {}),
378
+ ...(str(c.url) ? { url: str(c.url)! } : {}),
379
+ };
380
+ return {
381
+ ...(str(b.install) ? { install: str(b.install)! } : {}),
382
+ ...(str(b.role) ? { role: str(b.role)! } : {}),
383
+ ...(str(b.login) ? { login: str(b.login)! } : {}),
384
+ ...(Object.keys(company).length ? { company } : {}),
385
+ ...(str(b.mcp_url) ? { mcpUrl: str(b.mcp_url)! } : {}),
386
+ ...(typeof b.unread === "number" &&
387
+ Number.isInteger(b.unread) &&
388
+ b.unread >= 0
389
+ ? { unread: Math.min(b.unread, 1_000_000) }
390
+ : {}),
391
+ };
392
+ }
393
+
394
+ /** What POST /connect/token gives: the token, and where Hub is. */
395
+ export interface HubGrant {
396
+ token: string;
397
+ install?: string;
398
+ role?: string;
399
+ company?: { slug?: string; name?: string; url?: string };
400
+ /** Hub's canonical origin: kept as hub.url. */
401
+ hubUrl?: string;
402
+ mcpUrl?: string;
403
+ }
404
+
405
+ /** Trade the one-time code for the install's token, server to server. */
406
+ export async function hubExchange(
407
+ url: string,
408
+ grant: { code: string; redirectUri: string; verifier: string },
409
+ opts: { timeoutMs?: number; fetch?: FetchFn } = {},
410
+ ): Promise<HubGrant> {
411
+ const b = await hubCall(
412
+ `${url}/connect/token`,
413
+ {
414
+ method: "POST",
415
+ headers: {
416
+ "Content-Type": "application/json",
417
+ Accept: "application/json",
418
+ },
419
+ body: JSON.stringify({
420
+ grant_type: "authorization_code",
421
+ code: grant.code,
422
+ redirect_uri: grant.redirectUri,
423
+ code_verifier: grant.verifier,
255
424
  }),
256
- ]).finally(() => clearTimeout(timer));
425
+ },
426
+ { timeoutMs: 15_000, ...opts },
427
+ );
428
+ const token = str(b.token);
429
+ if (!token || !/^\S{8,}$/.test(token))
430
+ throw new HubCallError("refused", "Hub's answer had no token");
431
+ const c = (b.company ?? {}) as Record<string, unknown>;
432
+ return {
433
+ token,
434
+ ...(str(b.install) ? { install: str(b.install)! } : {}),
435
+ ...(str(b.role) ? { role: str(b.role)! } : {}),
436
+ company: {
437
+ ...(str(c.slug) ? { slug: str(c.slug)! } : {}),
438
+ ...(str(c.name) ? { name: str(c.name)! } : {}),
439
+ ...(str(c.url) ? { url: str(c.url)! } : {}),
440
+ },
441
+ ...(str(b.hub_url) ? { hubUrl: str(b.hub_url)!.replace(/\/+$/, "") } : {}),
442
+ ...(str(b.mcp_url) ? { mcpUrl: str(b.mcp_url)! } : {}),
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Revoke the install's token on Hub: POST <url>/connect/revoke with it.
448
+ * True when Hub revoked it now, false when it was already dead (401).
449
+ */
450
+ export async function hubRevoke(
451
+ url: string,
452
+ token: string,
453
+ opts: { timeoutMs?: number; fetch?: FetchFn } = {},
454
+ ): Promise<boolean> {
257
455
  try {
258
- await bounded(client.connect(transport));
259
- const tools = await bounded(client.listTools());
260
- const who = tools.tools.find((t) => /whoami$/.test(t.name));
261
- if (!who) return {};
262
- const r = resultObject(
263
- await bounded(client.callTool({ name: who.name, arguments: {} })),
456
+ await hubCall(
457
+ `${url}/connect/revoke`,
458
+ {
459
+ method: "POST",
460
+ headers: { ...bearer(token), Accept: "application/json" },
461
+ },
462
+ opts,
264
463
  );
265
- const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
266
- return {
267
- ...(str(r?.team ?? (r?.team_name as unknown))
268
- ? { team: str(r?.team ?? r?.team_name)! }
269
- : {}),
270
- ...(str(r?.label ?? r?.install ?? r?.install_label)
271
- ? { label: str(r?.label ?? r?.install ?? r?.install_label)! }
272
- : {}),
273
- };
274
- } finally {
275
- await client.close().catch(() => undefined);
464
+ return true;
465
+ } catch (e) {
466
+ if (e instanceof HubCallError && e.kind === "rejected") return false;
467
+ throw e;
276
468
  }
277
469
  }
278
470
 
471
+ /** base64url without padding. */
472
+ const b64url = (b: Buffer) =>
473
+ b
474
+ .toString("base64")
475
+ .replace(/\+/g, "-")
476
+ .replace(/\//g, "_")
477
+ .replace(/=+$/, "");
478
+
479
+ /** PKCE (S256): a fresh verifier and its challenge. */
480
+ export function newPkce(): { verifier: string; challenge: string } {
481
+ const verifier = b64url(crypto.randomBytes(32));
482
+ const challenge = b64url(
483
+ crypto.createHash("sha256").update(verifier).digest(),
484
+ );
485
+ return { verifier, challenge };
486
+ }
487
+
488
+ /** An opaque, single-use state value. */
489
+ export const newHubState = () => b64url(crypto.randomBytes(24));
490
+
279
491
  /** A message that is safe to show: the token, if it slipped in, cut out. */
280
492
  export function scrubToken(msg: string, token: string | null): string {
281
493
  return token ? msg.split(token).join("[token]") : msg;
@@ -297,28 +509,32 @@ export async function hubStatus(
297
509
  configured: true,
298
510
  url: "",
299
511
  connected: false,
512
+ state: "bad_config",
300
513
  problem: (e as Error).message,
301
514
  };
302
515
  }
303
516
  if (!cfg) return { configured: false };
304
- if (!cfg.tokenRef)
517
+ const base = {
518
+ configured: true as const,
519
+ url: cfg.url,
520
+ allowTools: cfg.allowTools,
521
+ };
522
+ if (!cfg.tokenRef && !cfg.tokenSource)
305
523
  return {
306
- configured: true,
307
- url: cfg.url,
308
- allowTools: cfg.allowTools,
524
+ ...base,
309
525
  connected: false,
526
+ state: "no_token",
310
527
  problem:
311
528
  cfg.tokenFrom === "hub.token"
312
- ? "no hub.token: set it to the install token's secret ref (Hub › Settings › Den installs makes one)"
529
+ ? "not connected yet: Connect signs you in to Hub and sets this install up"
313
530
  : `hub.token names ${cfg.tokenFrom}, which is not set for this install's secrets backend`,
314
531
  };
315
532
  const token = await (deps.token ?? ((c) => readHubToken(c)))(cfg);
316
533
  if (!token)
317
534
  return {
318
- configured: true,
319
- url: cfg.url,
320
- allowTools: cfg.allowTools,
535
+ ...base,
321
536
  connected: false,
537
+ state: "no_token",
322
538
  problem: `${cfg.tokenFrom} could not be read from this install's secrets store`,
323
539
  };
324
540
  try {
@@ -327,19 +543,35 @@ export async function hubStatus(
327
543
  token,
328
544
  );
329
545
  return {
330
- configured: true,
331
- url: cfg.url,
332
- allowTools: cfg.allowTools,
546
+ ...base,
333
547
  connected: true,
334
- ...who,
548
+ state: "connected",
549
+ ...(who.company?.name ? { team: who.company.name } : {}),
550
+ ...(who.company?.url ? { teamUrl: who.company.url } : {}),
551
+ ...(who.install ? { label: who.install } : {}),
552
+ ...(who.role ? { role: who.role } : {}),
553
+ ...(who.login ? { login: who.login } : {}),
554
+ ...(who.unread !== undefined ? { unread: who.unread } : {}),
335
555
  };
336
556
  } catch (e) {
557
+ const kind = e instanceof HubCallError ? e.kind : "unreachable";
337
558
  return {
338
- configured: true,
339
- url: cfg.url,
340
- allowTools: cfg.allowTools,
559
+ ...base,
341
560
  connected: false,
342
- problem: scrubToken(`Hub did not answer: ${(e as Error).message}`, token),
561
+ state:
562
+ kind === "rejected"
563
+ ? "rejected"
564
+ : kind === "forbidden"
565
+ ? "forbidden"
566
+ : "unreachable",
567
+ // A HubCallError's words stand on their own; anything else is
568
+ // something that kept Hub from answering.
569
+ problem: scrubToken(
570
+ e instanceof HubCallError
571
+ ? e.message
572
+ : `Hub did not answer: ${(e as Error)?.message ?? e}`,
573
+ token,
574
+ ),
343
575
  };
344
576
  }
345
577
  }
@@ -32,6 +32,39 @@ export interface OnePasswordOptions {
32
32
  timeoutMs?: number;
33
33
  }
34
34
 
35
+ /** op's own words from a failed run (stderr), without its "[ERROR] date time" prefix. */
36
+ function opText(err: unknown): string {
37
+ const e = err as { stderr?: unknown; message?: unknown };
38
+ const raw = String(e?.stderr ?? "").trim() || String(e?.message ?? err);
39
+ return (
40
+ raw
41
+ .split(/\r?\n/)
42
+ .map((l) =>
43
+ l.replace(/^\[\w+\]\s*\d{4}\/\d\d\/\d\d \d\d:\d\d:\d\d\s*/, "").trim(),
44
+ )
45
+ // execFile's message starts with the command line it ran: never shown.
46
+ .filter((l) => l && !/^Command failed:/.test(l))
47
+ .join(" ")
48
+ );
49
+ }
50
+
51
+ /** The same failure, its message op's words (set's callers strip the value). */
52
+ function opError(err: unknown): Error {
53
+ const e = err as { killed?: boolean; code?: unknown };
54
+ if (e?.killed) return new Error("op timed out");
55
+ if (e?.code === "ENOENT") return new Error("op is not installed");
56
+ return new Error(opText(err) || "op failed (no detail)");
57
+ }
58
+
59
+ /** True only when op said the item isn't there (not "more than one item"). */
60
+ export function opItemMissing(err: unknown): boolean {
61
+ const t = opText(err);
62
+ if (/more than one item/i.test(t)) return false;
63
+ return /isn't an item|is not an item|(?:could not|couldn't) find (?:the )?item|no item (?:found|matches)/i.test(
64
+ t,
65
+ );
66
+ }
67
+
35
68
  export class OnePasswordProvider implements SecretsProvider {
36
69
  readonly name = "1password" as const;
37
70
  private readonly timeout: number;
@@ -184,7 +217,6 @@ export class OnePasswordProvider implements SecretsProvider {
184
217
  value?: string;
185
218
  };
186
219
  let item: { id?: string; fields?: Field[] } | null = null;
187
- let readErr: unknown = null;
188
220
  try {
189
221
  item = JSON.parse(
190
222
  await this.run(
@@ -193,7 +225,10 @@ export class OnePasswordProvider implements SecretsProvider {
193
225
  ),
194
226
  ) as { id?: string; fields?: Field[] };
195
227
  } catch (err) {
196
- readErr = err;
228
+ // Create only when op says the item isn't there. Any other failure
229
+ // (a timeout, no access, two items by that title) is shown as it is:
230
+ // creating then would make a second item with the same title.
231
+ if (!opItemMissing(err)) throw opError(err);
197
232
  }
198
233
  if (item) {
199
234
  const fields = item.fields ?? [];
@@ -206,7 +241,9 @@ export class OnePasswordProvider implements SecretsProvider {
206
241
  await this.runWithInput(
207
242
  ["item", "edit", item.id || ref.item, ...vault],
208
243
  JSON.stringify(item),
209
- );
244
+ ).catch((err) => {
245
+ throw opError(err);
246
+ });
210
247
  return;
211
248
  }
212
249
  const field: Field =
@@ -219,18 +256,18 @@ export class OnePasswordProvider implements SecretsProvider {
219
256
  value,
220
257
  }
221
258
  : { label: ref.field, type: "CONCEALED", value };
222
- try {
223
- await this.runWithInput(
224
- ["item", "create", ...vault, "-"],
225
- JSON.stringify({
226
- title: ref.item,
227
- category: "PASSWORD",
228
- fields: [field],
229
- }),
230
- );
231
- } catch {
232
- throw readErr;
233
- }
259
+ // A failed create shows create's own reason (a vault that isn't
260
+ // there, no access), never the get's "isn't an item".
261
+ await this.runWithInput(
262
+ ["item", "create", ...vault, "-"],
263
+ JSON.stringify({
264
+ title: ref.item,
265
+ category: "PASSWORD",
266
+ fields: [field],
267
+ }),
268
+ ).catch((err) => {
269
+ throw opError(err);
270
+ });
234
271
  }
235
272
 
236
273
  async preflight(): Promise<PreflightResult> {