@jmcombs/pi-1password 1.0.1 → 2.0.0

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/index.ts CHANGED
@@ -13,23 +13,43 @@
13
13
  * - https://pi.dev/docs/extensions
14
14
  */
15
15
 
16
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
17
- import { createBashTool, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
18
- import { Type, type Static } from "typebox";
19
- import { promisify } from "node:util";
20
16
  import { exec } from "node:child_process";
21
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
17
+ import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
22
18
  import { homedir } from "node:os";
23
19
  import { dirname, join } from "node:path";
24
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
20
  import { fileURLToPath } from "node:url";
26
-
21
+ import { promisify } from "node:util";
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ // `createLocalBashOperations` is NOT on oh-my-pi's shim. Accessing it through a
24
+ // namespace import makes a missing member `undefined` at runtime rather than a
25
+ // hard ESM link error that would take this whole module (and every consumer that
26
+ // imports it, e.g. via `resolveSecret`/`onboardSecret`) down on that runtime.
27
+ import * as piRuntime from "@earendil-works/pi-coding-agent";
28
+ // `createBashTool` + `getAgentDir` are always present on the pi runtime (and on
29
+ // oh-my-pi's legacy-pi compat shim), so they stay as static named imports.
30
+ import { createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
31
+ import { type Static, Type } from "typebox";
32
+
33
+ import type { UiContext } from "./credential-api.js";
27
34
  import {
28
35
  confirmInBorderedPopup,
29
36
  inputInBorderedPopup,
30
37
  selectInBorderedPopup,
31
38
  } from "./ui/bordered-popups.js";
32
39
 
40
+ // ── Public stateless credential API (D1/D3) ────────────────────────────
41
+ // The 1Password extension is the credential authority: consumer extensions
42
+ // import these from "@jmcombs/pi-1password" and never touch pi internals.
43
+ // See docs/1p-credential-api/API.md.
44
+ export {
45
+ changeSecret,
46
+ deleteSecret,
47
+ is1PasswordAvailable,
48
+ onboardSecret,
49
+ resolveSecret,
50
+ verifySecret,
51
+ } from "./credential-api.js";
52
+
33
53
  const execAsync = promisify(exec);
34
54
 
35
55
  // ── Internal helpers for diagnostics (properly typed) ──────────────────
@@ -37,7 +57,19 @@ const execAsync = promisify(exec);
37
57
  interface OpStatus {
38
58
  available: boolean;
39
59
  version: string | null;
60
+ /**
61
+ * DIAGNOSTIC ONLY. Whether `op whoami` reports a live CLI session. Under the
62
+ * 1Password desktop-app biometric integration this is `false` for a cold
63
+ * invocation even when `op read` works — so it MUST NOT gate availability.
64
+ * Surfaced in diagnostics; never used for access decisions.
65
+ */
40
66
  signedIn: boolean;
67
+ /**
68
+ * Whether an `op` auth path is CONFIGURED (service-account token, Connect env,
69
+ * or a desktop/CLI account). This — not `signedIn` — is what gates availability.
70
+ * Determined passively: no unlock, no Touch ID prompt.
71
+ */
72
+ configured: boolean;
41
73
  account: Record<string, unknown> | null;
42
74
  }
43
75
 
@@ -69,38 +101,73 @@ interface OpField {
69
101
  type?: string;
70
102
  }
71
103
 
72
- async function getOpStatus(): Promise<OpStatus> {
104
+ /**
105
+ * Whether an `op` auth path is CONFIGURED, checked passively (no unlock, no Touch
106
+ * ID). True when any of: `OP_SERVICE_ACCOUNT_TOKEN` is set; both `OP_CONNECT_HOST`
107
+ * and `OP_CONNECT_TOKEN` are set; or `op account list --format=json` exits 0 and
108
+ * parses to a non-empty array. Any failure/timeout/unparsable output ⇒ `false`;
109
+ * never throws. Never logs secret values.
110
+ */
111
+ async function isOpConfigured(): Promise<boolean> {
112
+ if ((process.env.OP_SERVICE_ACCOUNT_TOKEN ?? "").length > 0) return true;
113
+ if (
114
+ (process.env.OP_CONNECT_HOST ?? "").length > 0 &&
115
+ (process.env.OP_CONNECT_TOKEN ?? "").length > 0
116
+ ) {
117
+ return true;
118
+ }
73
119
  try {
74
- const { stdout } = await execAsync("op --version", { encoding: "utf8" });
75
- const version = stdout.trim();
120
+ const { stdout } = await execAsync("op account list --format=json", {
121
+ encoding: "utf8",
122
+ timeout: 5000,
123
+ });
124
+ const parsed: unknown = JSON.parse(stdout);
125
+ return Array.isArray(parsed) && parsed.length > 0;
126
+ } catch {
127
+ // Non-zero exit, timeout, or unparsable output → treat as not configured.
128
+ return false;
129
+ }
130
+ }
76
131
 
77
- try {
78
- const { stdout: whoamiOut } = await execAsync("op whoami --format json", {
79
- encoding: "utf8",
80
- });
81
- const whoami = JSON.parse(whoamiOut) as Record<string, unknown>;
82
- return {
83
- available: true,
84
- version,
85
- signedIn: true,
86
- account: whoami,
87
- };
88
- } catch {
89
- return {
90
- available: true,
91
- version,
92
- signedIn: false,
93
- account: null,
94
- };
95
- }
132
+ /**
133
+ * Probe the `op` CLI. Runs `op --version` (→ `available`), `op whoami` (→
134
+ * `signedIn`, DIAGNOSTIC ONLY — see below), and {@link isOpConfigured} (→
135
+ * `configured`) fresh on every call. Reused by {@link is1PasswordAvailable}.
136
+ *
137
+ * `signedIn` from `op whoami` is unreliable for gating: under the 1Password
138
+ * desktop-app biometric integration it returns non-zero for a cold CLI invocation
139
+ * even when `op read` works. Availability therefore gates on `configured`, not
140
+ * `signedIn`; the account session is unlocked lazily on the first
141
+ * `op read`. All `op` probes use a 5s timeout and never throw.
142
+ */
143
+ export async function getOpStatus(): Promise<OpStatus> {
144
+ let version: string | null = null;
145
+ try {
146
+ const { stdout } = await execAsync("op --version", { encoding: "utf8", timeout: 5000 });
147
+ version = stdout.trim();
96
148
  } catch {
97
- return {
98
- available: false,
99
- version: null,
100
- signedIn: false,
101
- account: null,
102
- };
149
+ return { available: false, version: null, signedIn: false, configured: false, account: null };
150
+ }
151
+
152
+ // signedIn — DIAGNOSTIC ONLY (see the OpStatus.signedIn doc). A
153
+ // non-zero `op whoami` is expected under app-integration and is NOT a gate.
154
+ let signedIn = false;
155
+ let account: Record<string, unknown> | null = null;
156
+ try {
157
+ const { stdout: whoamiOut } = await execAsync("op whoami --format json", {
158
+ encoding: "utf8",
159
+ timeout: 5000,
160
+ });
161
+ account = JSON.parse(whoamiOut) as Record<string, unknown>;
162
+ signedIn = true;
163
+ } catch {
164
+ signedIn = false;
165
+ account = null;
103
166
  }
167
+
168
+ const configured = await isOpConfigured();
169
+
170
+ return { available: true, version, signedIn, configured, account };
104
171
  }
105
172
 
106
173
  async function inspectPluginIfRelevant(command: string): Promise<PluginInspection | null> {
@@ -133,7 +200,6 @@ async function inspectPluginIfRelevant(command: string): Promise<PluginInspectio
133
200
  return { plugin: firstWord, output: (stdout || "").trim() };
134
201
  } catch (e: unknown) {
135
202
  const error = e as { stderr?: string; message?: string };
136
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
137
203
  const msg = (error.stderr?.trim() ?? error.message) || "Unknown error";
138
204
  return { plugin: firstWord, error: msg };
139
205
  }
@@ -143,17 +209,22 @@ function formatOpStatus(status: OpStatus): string {
143
209
  if (!status.available) {
144
210
  return "1Password CLI (`op`) is not available in PATH.";
145
211
  }
146
- if (!status.signedIn) {
147
- return `op ${status.version ?? "unknown"} is installed but you are not signed in.`;
212
+ if (!status.configured) {
213
+ return `op ${status.version ?? "unknown"} is installed but no 1Password account is configured. Run 'op signin', or set OP_SERVICE_ACCOUNT_TOKEN (or OP_CONNECT_HOST + OP_CONNECT_TOKEN).`;
148
214
  }
149
215
  const acct = status.account ?? {};
150
216
  const name =
151
217
  (acct.name as string | undefined) ??
152
218
  (acct.email as string | undefined) ??
153
219
  (acct.account_uuid as string | undefined) ??
154
- "unknown";
155
- const url = (acct.url as string | undefined) ?? "unknown account";
156
- return `op ${status.version ?? "unknown"} — signed in as ${name} (${url})`;
220
+ null;
221
+ const url = (acct.url as string | undefined) ?? null;
222
+ if (status.signedIn && name) {
223
+ return `op ${status.version ?? "unknown"} — signed in as ${name}${url ? ` (${url})` : ""}`;
224
+ }
225
+ // Configured but no live CLI session (typical under the desktop-app biometric
226
+ // integration) — the account session unlocks on the first `op read`.
227
+ return `op ${status.version ?? "unknown"} — 1Password account configured (session unlocks on first use).`;
157
228
  }
158
229
 
159
230
  // ── Shell env loading from ~/.pi/agent/auth.json (top-level keys per user choice A) ──
@@ -189,7 +260,13 @@ const KNOWN_PROVIDER_KEYS = new Set([
189
260
  "xiaomi-token-plan-sgp",
190
261
  ]);
191
262
 
192
- async function resolveShellValue(raw: unknown): Promise<string | null> {
263
+ /**
264
+ * Resolve a single stored auth value to its concrete secret. A `!op read '<ref>'`
265
+ * value runs the 1Password CLI; any other `!<cmd>` runs in a minimal shell; a bare
266
+ * literal is returned as-is. Fails closed (returns `null`) on any error. Reused by
267
+ * {@link resolveSecret} and {@link warmOpSessionIfNeeded}.
268
+ */
269
+ export async function resolveShellValue(raw: unknown): Promise<string | null> {
193
270
  if (typeof raw !== "string") return null;
194
271
  const trimmed = raw.trim();
195
272
 
@@ -238,7 +315,6 @@ async function loadShellEnvMap(): Promise<Record<string, string>> {
238
315
  const map = new Map<string, string>();
239
316
 
240
317
  try {
241
- // eslint-disable-next-line security/detect-non-literal-fs-filename
242
318
  const raw = await readFile(authPath, "utf8");
243
319
  const parsed = JSON.parse(raw) as AuthJson;
244
320
 
@@ -261,7 +337,7 @@ async function loadShellEnvMap(): Promise<Record<string, string>> {
261
337
  // In-memory map for the current session (populated on session_start)
262
338
  let currentShellEnv: Record<string, string> = {};
263
339
 
264
- // Curated shell plugin list (loaded once at startup for /1password_onboard suggestions)
340
+ // Curated shell plugin list (loaded once at startup for /1password_setup suggestions)
265
341
  let curatedPlugins: CuratedPlugin[] = [];
266
342
 
267
343
  /** Returns the *names* of currently injected shell env vars (never the values). Safe for diagnostics / LLM. */
@@ -269,7 +345,7 @@ export function getShellEnvNames(): string[] {
269
345
  return Object.keys(currentShellEnv);
270
346
  }
271
347
 
272
- // ── Curated list + auth.json writer (for /1password_onboard) ────────────
348
+ // ── Curated list + auth.json writer (for /1password_setup) ────────────
273
349
 
274
350
  /** Load the maintained list of 1P shell plugins (generated by scripts/update-1p-shell-plugins.ts). */
275
351
  async function loadCuratedPlugins(): Promise<CuratedPlugin[]> {
@@ -298,12 +374,10 @@ async function addAuthEntry(
298
374
  const authDir = getAgentDir();
299
375
  const authPath = join(authDir, "auth.json");
300
376
 
301
- // eslint-disable-next-line security/detect-non-literal-fs-filename
302
377
  await mkdir(authDir, { recursive: true });
303
378
 
304
379
  const existing = new Map<string, unknown>();
305
380
  try {
306
- // eslint-disable-next-line security/detect-non-literal-fs-filename
307
381
  const raw = await readFile(authPath, "utf8");
308
382
  const parsed: unknown = JSON.parse(raw);
309
383
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
@@ -328,24 +402,369 @@ async function addAuthEntry(
328
402
  // Convention: single quotes around the op:// ref
329
403
  existing.set(envVar, `!op read '${opRef}'`);
330
404
 
331
- const content = JSON.stringify(Object.fromEntries(existing), null, 2) + "\n";
332
- // eslint-disable-next-line security/detect-non-literal-fs-filename
405
+ const content = `${JSON.stringify(Object.fromEntries(existing), null, 2)}\n`;
333
406
  await writeFile(authPath, content, "utf8");
334
- // eslint-disable-next-line security/detect-non-literal-fs-filename
335
407
  await chmod(authPath, 0o600);
336
408
 
337
409
  return { success: true, message: "Entry added.", path: authPath };
338
410
  }
339
411
 
340
- // ── Tool schemas ───────────────────────────────────────────────────────
412
+ // ── Stateless auth.json access + locked provider-shaped writer (D3/D4) ──
341
413
 
342
- const runSchema = Type.Object({
343
- command: Type.String({
344
- description:
345
- "The command to run with 1Password credential injection (e.g. 'gh auth status' or 'gh repo view owner/repo')",
346
- }),
347
- });
348
- export type RunInput = Static<typeof runSchema>;
414
+ /** Absolute path to the agent's auth.json (honors PI_CODING_AGENT_DIR). */
415
+ function getAuthFilePath(): string {
416
+ return join(getAgentDir(), "auth.json");
417
+ }
418
+
419
+ /**
420
+ * Read and parse auth.json fresh, returning the top-level object (or `{}` if the
421
+ * file is missing / unreadable / not a JSON object). No caching — every call hits
422
+ * disk, per the stateless contract (D3).
423
+ */
424
+ export async function readAuthJson(): Promise<Record<string, unknown>> {
425
+ try {
426
+ const raw = await readFile(getAuthFilePath(), "utf8");
427
+ const parsed: unknown = JSON.parse(raw);
428
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
429
+ return parsed as Record<string, unknown>;
430
+ }
431
+ } catch {
432
+ // Missing or invalid → behave as an empty store.
433
+ }
434
+ return {};
435
+ }
436
+
437
+ /** Result of a locked provider-shaped write. */
438
+ export interface ProviderWriteResult {
439
+ readonly success: boolean;
440
+ readonly message: string;
441
+ readonly alreadyExists?: boolean;
442
+ }
443
+
444
+ /**
445
+ * Acquire an exclusive advisory lock via an `O_EXCL` lockfile next to auth.json.
446
+ * Retries with jittered backoff; force-clears a lock older than the timeout
447
+ * (treated as stale). Returns a release function that removes the lockfile.
448
+ */
449
+ async function acquireAuthLock(lockPath: string): Promise<() => Promise<void>> {
450
+ const timeoutMs = 5000;
451
+ const start = Date.now();
452
+ for (;;) {
453
+ try {
454
+ // "wx" = O_CREAT | O_EXCL | O_WRONLY — fails if the lockfile already exists.
455
+ const handle = await open(lockPath, "wx");
456
+ await handle.close();
457
+ return async (): Promise<void> => {
458
+ try {
459
+ await unlink(lockPath);
460
+ } catch {
461
+ // Already gone — nothing to release.
462
+ }
463
+ };
464
+ } catch (e) {
465
+ const code = (e as NodeJS.ErrnoException).code;
466
+ if (code !== "EEXIST") throw e;
467
+ if (Date.now() - start > timeoutMs) {
468
+ // Presumed stale (holder crashed); clear it and retry.
469
+ try {
470
+ await unlink(lockPath);
471
+ } catch {
472
+ // Someone else cleared it first.
473
+ }
474
+ }
475
+ await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Read-modify-write auth.json under an exclusive lock, then commit atomically via
482
+ * temp-write + rename. `mutator` returns the next object to persist, or `null` to
483
+ * abort with no write. Every intermediate file is chmod 0600.
484
+ */
485
+ async function mutateAuthFileLocked(
486
+ mutator: (current: Record<string, unknown>) => Record<string, unknown> | null,
487
+ ): Promise<{ changed: boolean }> {
488
+ const authDir = getAgentDir();
489
+ const authPath = join(authDir, "auth.json");
490
+ const lockPath = `${authPath}.lock`;
491
+
492
+ await mkdir(authDir, { recursive: true });
493
+ const release = await acquireAuthLock(lockPath);
494
+ try {
495
+ let current: Record<string, unknown> = {};
496
+ try {
497
+ const raw = await readFile(authPath, "utf8");
498
+ const parsed: unknown = JSON.parse(raw);
499
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
500
+ current = parsed as Record<string, unknown>;
501
+ }
502
+ } catch {
503
+ // Missing or invalid JSON → start fresh.
504
+ }
505
+
506
+ const next = mutator(current);
507
+ if (next === null) return { changed: false };
508
+
509
+ const content = `${JSON.stringify(next, null, 2)}\n`;
510
+ const tmpPath = `${authPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
511
+ try {
512
+ await writeFile(tmpPath, content, { encoding: "utf8", mode: 0o600 });
513
+ await chmod(tmpPath, 0o600);
514
+ await rename(tmpPath, authPath);
515
+ await chmod(authPath, 0o600);
516
+ } catch (e) {
517
+ try {
518
+ await unlink(tmpPath);
519
+ } catch {
520
+ // Temp file may not exist — ignore.
521
+ }
522
+ throw e;
523
+ }
524
+ return { changed: true };
525
+ } finally {
526
+ await release();
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Write a provider-shaped entry `{[name]:{type:"api_key",key}}` to auth.json under
532
+ * the lock (D4). `key` is either a `!op read '<ref>'` command (vault) or a literal
533
+ * secret (manual). Refuses to clobber an existing key unless `overwrite` is set.
534
+ */
535
+ export async function writeProviderAuthEntry(
536
+ name: string,
537
+ key: string,
538
+ options: { overwrite?: boolean } = {},
539
+ ): Promise<ProviderWriteResult> {
540
+ let alreadyExists = false;
541
+ const { changed } = await mutateAuthFileLocked((current) => {
542
+ alreadyExists = Object.hasOwn(current, name);
543
+ if (alreadyExists && !options.overwrite) return null;
544
+ return { ...current, [name]: { type: "api_key", key } };
545
+ });
546
+ if (!changed) {
547
+ return {
548
+ success: false,
549
+ alreadyExists: true,
550
+ message: `Key "${name}" already exists in auth.json. Use changeSecret (overwrite) or remove it first.`,
551
+ };
552
+ }
553
+ return { success: true, message: "Entry saved." };
554
+ }
555
+
556
+ /**
557
+ * Remove `parsed[name]` from auth.json under the lock. `ok` is `true` when an entry
558
+ * was present and removed, `false` when there was nothing to remove.
559
+ */
560
+ export async function deleteAuthEntry(name: string): Promise<{ ok: boolean }> {
561
+ let existed = false;
562
+ await mutateAuthFileLocked((current) => {
563
+ if (!Object.hasOwn(current, name)) {
564
+ existed = false;
565
+ return null;
566
+ }
567
+ existed = true;
568
+ const next = { ...current };
569
+ delete next[name];
570
+ return next;
571
+ });
572
+ return { ok: existed };
573
+ }
574
+
575
+ /**
576
+ * Scan all auth.json values — top-level strings AND nested provider-shaped `.key`
577
+ * values (D7; `loadShellEnvMap` inspects neither nested keys nor provider ids) —
578
+ * for the first `!op read ` reference. Returns the trimmed value or `null`.
579
+ */
580
+ export function findFirstOpRef(parsed: Record<string, unknown>): string | null {
581
+ for (const value of Object.values(parsed)) {
582
+ const candidate =
583
+ typeof value === "string"
584
+ ? value
585
+ : value && typeof value === "object" && !Array.isArray(value)
586
+ ? (value as { key?: unknown }).key
587
+ : undefined;
588
+ if (typeof candidate === "string" && /^!op read /.test(candidate.trim())) {
589
+ return candidate.trim();
590
+ }
591
+ }
592
+ return null;
593
+ }
594
+
595
+ /**
596
+ * Warm the 1Password account session on load (D7/D8). Scans auth.json for any
597
+ * `!op read ` reference and, if one exists, runs a single best-effort `op read`
598
+ * (value discarded) so the OS-level biometric prompt lands at startup. Silent and
599
+ * fail-closed: never throws, does nothing when there are no vault references.
600
+ */
601
+ export async function warmOpSessionIfNeeded(): Promise<void> {
602
+ try {
603
+ const parsed = await readAuthJson();
604
+ const firstRef = findFirstOpRef(parsed);
605
+ if (!firstRef) return;
606
+ const ref = firstRef.replace(/^!op read\s+/, "").replace(/^['"]|['"]$/g, "");
607
+ try {
608
+ await execAsync(`op read "${ref}"`, {
609
+ encoding: "utf8",
610
+ maxBuffer: 1024 * 1024,
611
+ timeout: 15000,
612
+ });
613
+ } catch {
614
+ // Best effort — a failed warm-up must not disrupt startup.
615
+ }
616
+ } catch {
617
+ // Never let warm-on-load throw.
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Interactive vault → item → field picker (custom bordered TUI) that returns a
623
+ * fully-qualified `op://Vault/Item/field` reference or `null` on cancel. Used by
624
+ * both the `/1password_setup` command and the public {@link onboardSecret} flow.
625
+ */
626
+ export async function pickOpReferenceSimple(ctx: UiContext): Promise<string | null> {
627
+ const browseHelp = "↑↓ • Enter • Esc = cancel • Type to filter";
628
+
629
+ ctx.ui.setStatus("1p-onboard", "Loading vaults...");
630
+ let vaultNames: string[] = [];
631
+ try {
632
+ const { stdout } = await execAsync(`op vault list --format json`, {
633
+ encoding: "utf8",
634
+ timeout: 20000,
635
+ });
636
+ const parsed = JSON.parse(stdout || "[]") as OpVault[];
637
+ vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
638
+ } catch {
639
+ ctx.ui.notify("Couldn't reach 1Password — make sure it's unlocked.", "warning");
640
+ ctx.ui.setStatus("1p-onboard", undefined);
641
+ return null;
642
+ }
643
+ ctx.ui.setStatus("1p-onboard", undefined);
644
+
645
+ if (vaultNames.length === 0) {
646
+ ctx.ui.notify("No vaults found in your 1Password account.", "warning");
647
+ return null;
648
+ }
649
+
650
+ const vaultItems = [
651
+ ...vaultNames.map((name) => ({ value: name, label: name })),
652
+ { value: "__cancel", label: "Cancel" },
653
+ ];
654
+ const chosenVault = await selectInBorderedPopup(ctx, {
655
+ title: "Choose a vault",
656
+ items: vaultItems,
657
+ helpText: browseHelp,
658
+ maxVisible: 14,
659
+ });
660
+ if (!chosenVault || chosenVault === "__cancel") return null;
661
+
662
+ ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
663
+ let items: OpItem[] = [];
664
+ try {
665
+ const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
666
+ const { stdout } = await execAsync(cmd, {
667
+ encoding: "utf8",
668
+ timeout: 25000,
669
+ maxBuffer: 8 * 1024 * 1024,
670
+ });
671
+ const parsed = JSON.parse(stdout || "[]") as OpItem[];
672
+ items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
673
+ } catch {
674
+ ctx.ui.notify(`Couldn't load items from "${chosenVault}". Try again.`, "warning");
675
+ ctx.ui.setStatus("1p-onboard", undefined);
676
+ return null;
677
+ }
678
+ ctx.ui.setStatus("1p-onboard", undefined);
679
+
680
+ if (items.length === 0) {
681
+ ctx.ui.notify(`No API keys or logins found in "${chosenVault}".`, "warning");
682
+ return null;
683
+ }
684
+
685
+ const itemItems = [
686
+ ...items.map((it) => ({
687
+ value: it.id,
688
+ label: `${it.title}${it.category ? ` — ${it.category}` : ""}`,
689
+ })),
690
+ { value: "__cancel", label: "Cancel" },
691
+ ];
692
+ const chosenItemId = await selectInBorderedPopup(ctx, {
693
+ title: `Choose an item in ${chosenVault}`,
694
+ items: itemItems,
695
+ helpText: browseHelp,
696
+ maxVisible: 16,
697
+ });
698
+ if (!chosenItemId || chosenItemId === "__cancel") return null;
699
+
700
+ const chosenItem = items.find((it) => it.id === chosenItemId);
701
+ if (!chosenItem) return null;
702
+
703
+ ctx.ui.setStatus("1p-onboard", "Loading fields...");
704
+ let fields: { label: string; type?: string }[] = [];
705
+ try {
706
+ const { stdout } = await execAsync(
707
+ `op item get ${JSON.stringify(chosenItem.id)} --format json`,
708
+ {
709
+ encoding: "utf8",
710
+ timeout: 15000,
711
+ },
712
+ );
713
+ const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
714
+ fields = full?.fields?.filter(Boolean) ?? [];
715
+ } catch {
716
+ ctx.ui.notify("Couldn't load that item's fields. Try again.", "warning");
717
+ ctx.ui.setStatus("1p-onboard", undefined);
718
+ return null;
719
+ }
720
+ ctx.ui.setStatus("1p-onboard", undefined);
721
+
722
+ if (fields.length === 0) {
723
+ ctx.ui.notify("That item has no fields to use.", "warning");
724
+ return null;
725
+ }
726
+
727
+ // Auto-skip the field step when the item has exactly one credential-type field
728
+ // (a concealed value, or a label like password / credential / api key / secret /
729
+ // token) — the overwhelmingly common case for an API-key item.
730
+ const credentialFields = fields.filter(isCredentialField);
731
+ let chosenFieldLabel: string | null;
732
+ if (credentialFields.length === 1) {
733
+ chosenFieldLabel = credentialFields[0]?.label ?? null;
734
+ } else {
735
+ const fieldItems = [
736
+ ...fields.map((f) => ({
737
+ value: f.label,
738
+ label: `${f.label} (${f.type ?? "text"})`,
739
+ })),
740
+ { value: "__cancel", label: "Cancel" },
741
+ ];
742
+ chosenFieldLabel = await selectInBorderedPopup(ctx, {
743
+ title: "Which field holds the key?",
744
+ items: fieldItems,
745
+ helpText: browseHelp,
746
+ maxVisible: 12,
747
+ });
748
+ if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
749
+ }
750
+ if (!chosenFieldLabel) return null;
751
+
752
+ return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
753
+ }
754
+
755
+ /**
756
+ * Heuristic: does this 1Password field hold a credential value? True for a
757
+ * concealed field, or a label naming a password / credential / api key / secret /
758
+ * token. Uses only the field's label and type — never its value — so nothing
759
+ * secret is read here.
760
+ */
761
+ function isCredentialField(field: { label: string; type?: string }): boolean {
762
+ const type = (field.type ?? "").toUpperCase();
763
+ const label = field.label.toLowerCase();
764
+ return type === "CONCEALED" || /password|credential|api[\s_-]?key|secret|token/.test(label);
765
+ }
766
+
767
+ // ── Tool schemas ───────────────────────────────────────────────────────
349
768
 
350
769
  const diagnoseSchema = Type.Object({});
351
770
  export type DiagnoseInput = Static<typeof diagnoseSchema>;
@@ -356,9 +775,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
356
775
  // Load initial shell env (top-level keys from auth.json)
357
776
  currentShellEnv = await loadShellEnvMap();
358
777
 
359
- // Load curated list for /1password_onboard
778
+ // Load curated list for /1password_setup
360
779
  curatedPlugins = await loadCuratedPlugins();
361
780
 
781
+ // Warm the 1Password account session if any auth.json value uses `!op read`
782
+ // (D7/D8) so the OS-level biometric prompt lands once, at startup. No-op and
783
+ // silent when there are no vault references.
784
+ await warmOpSessionIfNeeded();
785
+
362
786
  // ── Bash tool wrapper with transparent 1P env injection ───────────────
363
787
  const cwd = process.cwd();
364
788
  const injectedBash = createBashTool(cwd, {
@@ -370,89 +794,21 @@ export default async function (pi: ExtensionAPI): Promise<void> {
370
794
  });
371
795
  pi.registerTool(injectedBash);
372
796
 
373
- pi.on("user_bash", () => {
374
- return { operations: createLocalBashOperations() };
375
- });
797
+ // Transparent 1P injection for user `!bash` commands — only when the runtime
798
+ // exposes `createLocalBashOperations` (real pi does; oh-my-pi's compat shim does
799
+ // not). Absent it, we skip the hook rather than crash the module load.
800
+ if (typeof piRuntime.createLocalBashOperations === "function") {
801
+ pi.on("user_bash", () => ({ operations: piRuntime.createLocalBashOperations() }));
802
+ } else if (process.env.HEADROOM_DEBUG) {
803
+ console.error(
804
+ "[1password] createLocalBashOperations unavailable on this runtime; user `!` 1P injection disabled (transparent agent-bash injection still active).",
805
+ );
806
+ }
376
807
 
377
808
  pi.on("session_start", async () => {
378
809
  currentShellEnv = await loadShellEnvMap();
379
810
  curatedPlugins = await loadCuratedPlugins();
380
- });
381
-
382
- // ── 1p_run ───────────────────────────────────────────────────────────
383
- pi.registerTool({
384
- name: "1p_run",
385
- label: "1Password Run Command",
386
- description:
387
- "Run a shell command with 1Password credential injection via `op run -- <command>`. Includes automatic diagnostics on failure.",
388
- parameters: runSchema,
389
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
390
- const status = await getOpStatus();
391
- const pluginInfo = await inspectPluginIfRelevant(params.command);
392
-
393
- if (!status.available) {
394
- return {
395
- content: [{ type: "text", text: `1p_run failed: ${formatOpStatus(status)}` }],
396
- details: { error: "op not found", opStatus: status },
397
- };
398
- }
399
- if (!status.signedIn) {
400
- return {
401
- content: [
402
- {
403
- type: "text",
404
- text: `1p_run failed: ${formatOpStatus(status)}\n\nPlease run 'op signin' in your terminal.`,
405
- },
406
- ],
407
- details: { error: "not signed in", opStatus: status },
408
- };
409
- }
410
-
411
- try {
412
- const { stdout, stderr } = await execAsync(`op run -- ${params.command}`, {
413
- encoding: "utf8",
414
- maxBuffer: 5 * 1024 * 1024,
415
- timeout: 120000,
416
- });
417
-
418
- const output = (stdout || "").trim();
419
- const err = (stderr || "").trim();
420
-
421
- let text = output || "(no stdout)";
422
- if (err) text += `\n\n[stderr]\n${err}`;
423
-
424
- return {
425
- content: [{ type: "text", text }],
426
- details: {
427
- command: params.command,
428
- opStatus: status,
429
- pluginInspection: pluginInfo,
430
- },
431
- };
432
- } catch (error: unknown) {
433
- const err = error as { stderr?: string; message?: string };
434
- const rawError = (err.stderr?.trim() ?? err.message ?? "") || String(error);
435
- const diagnostic = formatOpStatus(status);
436
-
437
- let helpful = `Command failed under 1Password injection.\n\n${diagnostic}`;
438
-
439
- if (pluginInfo) {
440
- helpful += `\n\nPlugin status for "${pluginInfo.plugin}":\n${pluginInfo.output ?? pluginInfo.error ?? ""}`;
441
- }
442
-
443
- helpful += `\n\nError from command:\n${rawError}`;
444
-
445
- return {
446
- content: [{ type: "text", text: helpful }],
447
- details: {
448
- error: rawError,
449
- command: params.command,
450
- opStatus: status,
451
- pluginInspection: pluginInfo,
452
- },
453
- };
454
- }
455
- },
811
+ await warmOpSessionIfNeeded();
456
812
  });
457
813
 
458
814
  // ── Shared diagnostic logic (used by both 1p_diagnose tool and /1password_diagnose command) ──
@@ -467,7 +823,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
467
823
  if (info) inspections.push(info);
468
824
  }
469
825
 
470
- let report = formatOpStatus(status) + "\n\n";
826
+ let report = `${formatOpStatus(status)}\n\n`;
471
827
 
472
828
  if (inspections.length > 0) {
473
829
  report += "Plugin configuration:\n";
@@ -505,7 +861,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
505
861
  name: "1p_diagnose",
506
862
  label: "1Password Diagnostics",
507
863
  description:
508
- "Check the current status of the 1Password CLI (`op`), sign-in state, plugin configuration, and active shell env injection (from ~/.pi/agent/auth.json). Use this when 1password_onboard or 1password_diagnose are not working as expected, or to verify transparent token injection for bare `gh` / `aws` etc. (uses 1p_run for plugin inspection).",
864
+ "Check the current status of the 1Password CLI (`op`), sign-in state, plugin configuration, and active shell env injection (from ~/.pi/agent/auth.json). Use this when 1password_setup or 1password_diagnose are not working as expected, or to verify transparent token injection for bare `gh` / `aws` etc.",
509
865
  parameters: diagnoseSchema,
510
866
  async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
511
867
  const { report, details } = await get1PasswordDiagnosticReport();
@@ -533,130 +889,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
533
889
 
534
890
  // TODO (known limitation): /1password_diagnose currently gathers data directly
535
891
  // for reliability and presents it. Injecting a prompt via sendUserMessage does not
536
- // reliably cause the LLM to start a new turn and use 1p_diagnose/1p_run for
892
+ // reliably cause the LLM to start a new turn and use 1p_diagnose for
537
893
  // nicer formatting, because regular command handlers have limited access to
538
894
  // the "deliverAs: nextTurn" / sendUserMessage APIs that force LLM reasoning.
539
895
  // This should be revisited when better support exists or a different pattern
540
896
  // is found. Tracked as a follow-up issue.
541
897
 
542
- // Local helper: vault → item → field picker using the custom bordered TUI.
543
- // Long lists benefit from live filtering. Esc (or picking Cancel) backs out.
544
- async function pickOpReferenceSimple(ctx: ExtensionCommandContext): Promise<string | null> {
545
- ctx.ui.setStatus("1p-onboard", "Loading vaults...");
546
- let vaultNames: string[] = [];
547
- try {
548
- const { stdout } = await execAsync(`op vault list --format json`, {
549
- encoding: "utf8",
550
- timeout: 20000,
551
- });
552
- const parsed = JSON.parse(stdout || "[]") as OpVault[];
553
- vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
554
- } catch {
555
- ctx.ui.notify("Failed to load vaults from 1Password.", "error");
556
- ctx.ui.setStatus("1p-onboard", undefined);
557
- return null;
558
- }
559
- ctx.ui.setStatus("1p-onboard", undefined);
560
-
561
- if (vaultNames.length === 0) {
562
- ctx.ui.notify("No vaults found in 1Password.", "warning");
563
- return null;
564
- }
565
-
566
- const vaultItems = [
567
- ...vaultNames.map((name) => ({ value: name, label: name })),
568
- { value: "__cancel", label: "Cancel" },
569
- ];
570
- const chosenVault = await selectInBorderedPopup(ctx, {
571
- title: "Select 1Password vault",
572
- items: vaultItems,
573
- helpText: "↑↓ • Enter • Esc = cancel • Type to filter",
574
- maxVisible: 14,
575
- });
576
- if (!chosenVault || chosenVault === "__cancel") return null;
577
-
578
- ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
579
- let items: OpItem[] = [];
580
- try {
581
- const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
582
- const { stdout } = await execAsync(cmd, {
583
- encoding: "utf8",
584
- timeout: 25000,
585
- maxBuffer: 8 * 1024 * 1024,
586
- });
587
- const parsed = JSON.parse(stdout || "[]") as OpItem[];
588
- items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
589
- } catch {
590
- ctx.ui.notify("Failed to load items.", "error");
591
- ctx.ui.setStatus("1p-onboard", undefined);
592
- return null;
593
- }
594
- ctx.ui.setStatus("1p-onboard", undefined);
595
-
596
- if (items.length === 0) {
597
- ctx.ui.notify("No matching items in that vault.", "warning");
598
- return null;
599
- }
600
-
601
- const itemItems = [
602
- ...items.map((it) => ({
603
- value: it.id,
604
- label: `${it.title}${it.category ? " — " + it.category : ""}`,
605
- })),
606
- { value: "__cancel", label: "Cancel" },
607
- ];
608
- const chosenItemId = await selectInBorderedPopup(ctx, {
609
- title: `Select item in ${chosenVault}`,
610
- items: itemItems,
611
- helpText: "↑↓ • Enter • Esc = cancel • Type to filter (can be long)",
612
- maxVisible: 16,
613
- });
614
- if (!chosenItemId || chosenItemId === "__cancel") return null;
615
-
616
- const chosenItem = items.find((it) => it.id === chosenItemId);
617
- if (!chosenItem) return null;
618
-
619
- ctx.ui.setStatus("1p-onboard", "Loading fields...");
620
- let fields: { label: string; type?: string }[] = [];
621
- try {
622
- const { stdout } = await execAsync(
623
- `op item get ${JSON.stringify(chosenItem.id)} --format json`,
624
- { encoding: "utf8", timeout: 15000 },
625
- );
626
- const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
627
- fields = full?.fields?.filter(Boolean) ?? [];
628
- } catch {
629
- ctx.ui.notify("Failed to load fields.", "error");
630
- ctx.ui.setStatus("1p-onboard", undefined);
631
- return null;
632
- }
633
- ctx.ui.setStatus("1p-onboard", undefined);
634
-
635
- if (fields.length === 0) {
636
- ctx.ui.notify("Selected item has no fields.", "warning");
637
- return null;
638
- }
639
-
640
- const fieldItems = [
641
- ...fields.map((f) => ({
642
- value: f.label,
643
- label: `${f.label} (${f.type ?? "text"})`,
644
- })),
645
- { value: "__cancel", label: "Cancel" },
646
- ];
647
- const chosenFieldLabel = await selectInBorderedPopup(ctx, {
648
- title: `Select field for "${chosenItem.title}"`,
649
- items: fieldItems,
650
- helpText: "↑↓ • Enter • Esc = cancel",
651
- maxVisible: 12,
652
- });
653
- if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
654
-
655
- return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
656
- }
898
+ // Vault → item → field picker now lives at module scope as the exported
899
+ // pickOpReferenceSimple (reused by the public onboardSecret API).
657
900
 
658
- // ── /1password_onboard — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
659
- pi.registerCommand("1password_onboard", {
901
+ // ── /1password_setup — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
902
+ pi.registerCommand("1password_setup", {
660
903
  description:
661
904
  "Guided setup: pick from supported tools or enter a custom op:// reference, write '!op read ...' entry to ~/.pi/agent/auth.json for transparent injection.",
662
905
  handler: async (_args, ctx) => {