@jmcombs/pi-1password 1.0.2 → 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
@@ -14,25 +14,42 @@
14
14
  */
15
15
 
16
16
  import { exec } from "node:child_process";
17
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
17
+ import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
18
18
  import { homedir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { promisify } from "node:util";
22
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
23
- import {
24
- createBashTool,
25
- createLocalBashOperations,
26
- getAgentDir,
27
- } from "@earendil-works/pi-coding-agent";
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";
28
31
  import { type Static, Type } from "typebox";
29
32
 
33
+ import type { UiContext } from "./credential-api.js";
30
34
  import {
31
35
  confirmInBorderedPopup,
32
36
  inputInBorderedPopup,
33
37
  selectInBorderedPopup,
34
38
  } from "./ui/bordered-popups.js";
35
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
+
36
53
  const execAsync = promisify(exec);
37
54
 
38
55
  // ── Internal helpers for diagnostics (properly typed) ──────────────────
@@ -40,7 +57,19 @@ const execAsync = promisify(exec);
40
57
  interface OpStatus {
41
58
  available: boolean;
42
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
+ */
43
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;
44
73
  account: Record<string, unknown> | null;
45
74
  }
46
75
 
@@ -72,38 +101,73 @@ interface OpField {
72
101
  type?: string;
73
102
  }
74
103
 
75
- 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
+ }
76
119
  try {
77
- const { stdout } = await execAsync("op --version", { encoding: "utf8" });
78
- 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
+ }
79
131
 
80
- try {
81
- const { stdout: whoamiOut } = await execAsync("op whoami --format json", {
82
- encoding: "utf8",
83
- });
84
- const whoami = JSON.parse(whoamiOut) as Record<string, unknown>;
85
- return {
86
- available: true,
87
- version,
88
- signedIn: true,
89
- account: whoami,
90
- };
91
- } catch {
92
- return {
93
- available: true,
94
- version,
95
- signedIn: false,
96
- account: null,
97
- };
98
- }
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();
99
148
  } catch {
100
- return {
101
- available: false,
102
- version: null,
103
- signedIn: false,
104
- account: null,
105
- };
149
+ return { available: false, version: null, signedIn: false, configured: false, account: null };
106
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;
166
+ }
167
+
168
+ const configured = await isOpConfigured();
169
+
170
+ return { available: true, version, signedIn, configured, account };
107
171
  }
108
172
 
109
173
  async function inspectPluginIfRelevant(command: string): Promise<PluginInspection | null> {
@@ -145,17 +209,22 @@ function formatOpStatus(status: OpStatus): string {
145
209
  if (!status.available) {
146
210
  return "1Password CLI (`op`) is not available in PATH.";
147
211
  }
148
- if (!status.signedIn) {
149
- 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).`;
150
214
  }
151
215
  const acct = status.account ?? {};
152
216
  const name =
153
217
  (acct.name as string | undefined) ??
154
218
  (acct.email as string | undefined) ??
155
219
  (acct.account_uuid as string | undefined) ??
156
- "unknown";
157
- const url = (acct.url as string | undefined) ?? "unknown account";
158
- 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).`;
159
228
  }
160
229
 
161
230
  // ── Shell env loading from ~/.pi/agent/auth.json (top-level keys per user choice A) ──
@@ -191,7 +260,13 @@ const KNOWN_PROVIDER_KEYS = new Set([
191
260
  "xiaomi-token-plan-sgp",
192
261
  ]);
193
262
 
194
- 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> {
195
270
  if (typeof raw !== "string") return null;
196
271
  const trimmed = raw.trim();
197
272
 
@@ -262,7 +337,7 @@ async function loadShellEnvMap(): Promise<Record<string, string>> {
262
337
  // In-memory map for the current session (populated on session_start)
263
338
  let currentShellEnv: Record<string, string> = {};
264
339
 
265
- // 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)
266
341
  let curatedPlugins: CuratedPlugin[] = [];
267
342
 
268
343
  /** Returns the *names* of currently injected shell env vars (never the values). Safe for diagnostics / LLM. */
@@ -270,7 +345,7 @@ export function getShellEnvNames(): string[] {
270
345
  return Object.keys(currentShellEnv);
271
346
  }
272
347
 
273
- // ── Curated list + auth.json writer (for /1password_onboard) ────────────
348
+ // ── Curated list + auth.json writer (for /1password_setup) ────────────
274
349
 
275
350
  /** Load the maintained list of 1P shell plugins (generated by scripts/update-1p-shell-plugins.ts). */
276
351
  async function loadCuratedPlugins(): Promise<CuratedPlugin[]> {
@@ -334,15 +409,362 @@ async function addAuthEntry(
334
409
  return { success: true, message: "Entry added.", path: authPath };
335
410
  }
336
411
 
337
- // ── Tool schemas ───────────────────────────────────────────────────────
412
+ // ── Stateless auth.json access + locked provider-shaped writer (D3/D4) ──
338
413
 
339
- const runSchema = Type.Object({
340
- command: Type.String({
341
- description:
342
- "The command to run with 1Password credential injection (e.g. 'gh auth status' or 'gh repo view owner/repo')",
343
- }),
344
- });
345
- 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 ───────────────────────────────────────────────────────
346
768
 
347
769
  const diagnoseSchema = Type.Object({});
348
770
  export type DiagnoseInput = Static<typeof diagnoseSchema>;
@@ -353,9 +775,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
353
775
  // Load initial shell env (top-level keys from auth.json)
354
776
  currentShellEnv = await loadShellEnvMap();
355
777
 
356
- // Load curated list for /1password_onboard
778
+ // Load curated list for /1password_setup
357
779
  curatedPlugins = await loadCuratedPlugins();
358
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
+
359
786
  // ── Bash tool wrapper with transparent 1P env injection ───────────────
360
787
  const cwd = process.cwd();
361
788
  const injectedBash = createBashTool(cwd, {
@@ -367,89 +794,21 @@ export default async function (pi: ExtensionAPI): Promise<void> {
367
794
  });
368
795
  pi.registerTool(injectedBash);
369
796
 
370
- pi.on("user_bash", () => {
371
- return { operations: createLocalBashOperations() };
372
- });
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
+ }
373
807
 
374
808
  pi.on("session_start", async () => {
375
809
  currentShellEnv = await loadShellEnvMap();
376
810
  curatedPlugins = await loadCuratedPlugins();
377
- });
378
-
379
- // ── 1p_run ───────────────────────────────────────────────────────────
380
- pi.registerTool({
381
- name: "1p_run",
382
- label: "1Password Run Command",
383
- description:
384
- "Run a shell command with 1Password credential injection via `op run -- <command>`. Includes automatic diagnostics on failure.",
385
- parameters: runSchema,
386
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
387
- const status = await getOpStatus();
388
- const pluginInfo = await inspectPluginIfRelevant(params.command);
389
-
390
- if (!status.available) {
391
- return {
392
- content: [{ type: "text", text: `1p_run failed: ${formatOpStatus(status)}` }],
393
- details: { error: "op not found", opStatus: status },
394
- };
395
- }
396
- if (!status.signedIn) {
397
- return {
398
- content: [
399
- {
400
- type: "text",
401
- text: `1p_run failed: ${formatOpStatus(status)}\n\nPlease run 'op signin' in your terminal.`,
402
- },
403
- ],
404
- details: { error: "not signed in", opStatus: status },
405
- };
406
- }
407
-
408
- try {
409
- const { stdout, stderr } = await execAsync(`op run -- ${params.command}`, {
410
- encoding: "utf8",
411
- maxBuffer: 5 * 1024 * 1024,
412
- timeout: 120000,
413
- });
414
-
415
- const output = (stdout || "").trim();
416
- const err = (stderr || "").trim();
417
-
418
- let text = output || "(no stdout)";
419
- if (err) text += `\n\n[stderr]\n${err}`;
420
-
421
- return {
422
- content: [{ type: "text", text }],
423
- details: {
424
- command: params.command,
425
- opStatus: status,
426
- pluginInspection: pluginInfo,
427
- },
428
- };
429
- } catch (error: unknown) {
430
- const err = error as { stderr?: string; message?: string };
431
- const rawError = (err.stderr?.trim() ?? err.message ?? "") || String(error);
432
- const diagnostic = formatOpStatus(status);
433
-
434
- let helpful = `Command failed under 1Password injection.\n\n${diagnostic}`;
435
-
436
- if (pluginInfo) {
437
- helpful += `\n\nPlugin status for "${pluginInfo.plugin}":\n${pluginInfo.output ?? pluginInfo.error ?? ""}`;
438
- }
439
-
440
- helpful += `\n\nError from command:\n${rawError}`;
441
-
442
- return {
443
- content: [{ type: "text", text: helpful }],
444
- details: {
445
- error: rawError,
446
- command: params.command,
447
- opStatus: status,
448
- pluginInspection: pluginInfo,
449
- },
450
- };
451
- }
452
- },
811
+ await warmOpSessionIfNeeded();
453
812
  });
454
813
 
455
814
  // ── Shared diagnostic logic (used by both 1p_diagnose tool and /1password_diagnose command) ──
@@ -502,7 +861,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
502
861
  name: "1p_diagnose",
503
862
  label: "1Password Diagnostics",
504
863
  description:
505
- "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.",
506
865
  parameters: diagnoseSchema,
507
866
  async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
508
867
  const { report, details } = await get1PasswordDiagnosticReport();
@@ -530,130 +889,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
530
889
 
531
890
  // TODO (known limitation): /1password_diagnose currently gathers data directly
532
891
  // for reliability and presents it. Injecting a prompt via sendUserMessage does not
533
- // 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
534
893
  // nicer formatting, because regular command handlers have limited access to
535
894
  // the "deliverAs: nextTurn" / sendUserMessage APIs that force LLM reasoning.
536
895
  // This should be revisited when better support exists or a different pattern
537
896
  // is found. Tracked as a follow-up issue.
538
897
 
539
- // Local helper: vault → item → field picker using the custom bordered TUI.
540
- // Long lists benefit from live filtering. Esc (or picking Cancel) backs out.
541
- async function pickOpReferenceSimple(ctx: ExtensionCommandContext): Promise<string | null> {
542
- ctx.ui.setStatus("1p-onboard", "Loading vaults...");
543
- let vaultNames: string[] = [];
544
- try {
545
- const { stdout } = await execAsync(`op vault list --format json`, {
546
- encoding: "utf8",
547
- timeout: 20000,
548
- });
549
- const parsed = JSON.parse(stdout || "[]") as OpVault[];
550
- vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
551
- } catch {
552
- ctx.ui.notify("Failed to load vaults from 1Password.", "error");
553
- ctx.ui.setStatus("1p-onboard", undefined);
554
- return null;
555
- }
556
- ctx.ui.setStatus("1p-onboard", undefined);
557
-
558
- if (vaultNames.length === 0) {
559
- ctx.ui.notify("No vaults found in 1Password.", "warning");
560
- return null;
561
- }
562
-
563
- const vaultItems = [
564
- ...vaultNames.map((name) => ({ value: name, label: name })),
565
- { value: "__cancel", label: "Cancel" },
566
- ];
567
- const chosenVault = await selectInBorderedPopup(ctx, {
568
- title: "Select 1Password vault",
569
- items: vaultItems,
570
- helpText: "↑↓ • Enter • Esc = cancel • Type to filter",
571
- maxVisible: 14,
572
- });
573
- if (!chosenVault || chosenVault === "__cancel") return null;
574
-
575
- ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
576
- let items: OpItem[] = [];
577
- try {
578
- const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
579
- const { stdout } = await execAsync(cmd, {
580
- encoding: "utf8",
581
- timeout: 25000,
582
- maxBuffer: 8 * 1024 * 1024,
583
- });
584
- const parsed = JSON.parse(stdout || "[]") as OpItem[];
585
- items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
586
- } catch {
587
- ctx.ui.notify("Failed to load items.", "error");
588
- ctx.ui.setStatus("1p-onboard", undefined);
589
- return null;
590
- }
591
- ctx.ui.setStatus("1p-onboard", undefined);
592
-
593
- if (items.length === 0) {
594
- ctx.ui.notify("No matching items in that vault.", "warning");
595
- return null;
596
- }
597
-
598
- const itemItems = [
599
- ...items.map((it) => ({
600
- value: it.id,
601
- label: `${it.title}${it.category ? ` — ${it.category}` : ""}`,
602
- })),
603
- { value: "__cancel", label: "Cancel" },
604
- ];
605
- const chosenItemId = await selectInBorderedPopup(ctx, {
606
- title: `Select item in ${chosenVault}`,
607
- items: itemItems,
608
- helpText: "↑↓ • Enter • Esc = cancel • Type to filter (can be long)",
609
- maxVisible: 16,
610
- });
611
- if (!chosenItemId || chosenItemId === "__cancel") return null;
612
-
613
- const chosenItem = items.find((it) => it.id === chosenItemId);
614
- if (!chosenItem) return null;
615
-
616
- ctx.ui.setStatus("1p-onboard", "Loading fields...");
617
- let fields: { label: string; type?: string }[] = [];
618
- try {
619
- const { stdout } = await execAsync(
620
- `op item get ${JSON.stringify(chosenItem.id)} --format json`,
621
- { encoding: "utf8", timeout: 15000 },
622
- );
623
- const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
624
- fields = full?.fields?.filter(Boolean) ?? [];
625
- } catch {
626
- ctx.ui.notify("Failed to load fields.", "error");
627
- ctx.ui.setStatus("1p-onboard", undefined);
628
- return null;
629
- }
630
- ctx.ui.setStatus("1p-onboard", undefined);
631
-
632
- if (fields.length === 0) {
633
- ctx.ui.notify("Selected item has no fields.", "warning");
634
- return null;
635
- }
636
-
637
- const fieldItems = [
638
- ...fields.map((f) => ({
639
- value: f.label,
640
- label: `${f.label} (${f.type ?? "text"})`,
641
- })),
642
- { value: "__cancel", label: "Cancel" },
643
- ];
644
- const chosenFieldLabel = await selectInBorderedPopup(ctx, {
645
- title: `Select field for "${chosenItem.title}"`,
646
- items: fieldItems,
647
- helpText: "↑↓ • Enter • Esc = cancel",
648
- maxVisible: 12,
649
- });
650
- if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
651
-
652
- return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
653
- }
898
+ // Vault → item → field picker now lives at module scope as the exported
899
+ // pickOpReferenceSimple (reused by the public onboardSecret API).
654
900
 
655
- // ── /1password_onboard — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
656
- pi.registerCommand("1password_onboard", {
901
+ // ── /1password_setup — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
902
+ pi.registerCommand("1password_setup", {
657
903
  description:
658
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.",
659
905
  handler: async (_args, ctx) => {