pi-git-auth 1.1.1 → 1.2.2

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A [pi](https://github.com/badlogic/pi-mono) (coding-agent) extension that
4
4
  gives the agent **git forges authentication** for **GitHub and GitLab**: login tokens are stored in the
5
- OS keyring, switch account, and every `git`command
5
+ OS keyring (kwallet), switch account, and every `git`command
6
6
  the agent runs is transparently authenticated with that account's
7
7
  token for its host. It also manages accounts and repositories through each
8
8
  service's REST API.
@@ -138,9 +138,11 @@ available; the extension picks one at load time.
138
138
  ### OS keyring (preferred)
139
139
  The token lives in the session keyring, addressed by the
140
140
  [freedesktop Secret Service API](https://specifications.freedesktop.org/secret-service/)
141
- (`org.freedesktop.secrets` over D-Bus). This covers KWallet (via
142
- `ksecretd`), GNOME Keyring, KeePassXC, and any other Secret Service
143
- provider.
141
+ (`org.freedesktop.secrets` over D-Bus). Any provider that implements
142
+ that API over D-Bus is targeted: KWallet (via `ksecretd`) is the one
143
+ verified end-to-end (including the interactive unlock dialog); GNOME
144
+ Keyring and KeePassXC are covered by the same generic code path but
145
+ have not been tested.
144
146
 
145
147
  - The embedded python3 client (a small script that `keyring.ts` keeps
146
148
  in sync in the state dir) opens a D-Bus session and talks
@@ -163,18 +165,31 @@ absolute minimum:
163
165
  - **Lookup never prompts.** A read on a locked collection returns
164
166
  `locked` without touching the collection — no stacked prompts, no kded
165
167
  "Repeated attempts to access a wallet have occurred" warning.
166
- - **Store is a single roundtrip** (delete matching items + create the new
167
- one, at most one unlock attempt) and only runs when a token actually
168
+ - **Interactive unlock uses the Secret Service Prompt protocol.**
169
+ `Service.Unlock()` on a locked `ksecretd` collection returns a Prompt
170
+ object; the "KDE Wallet Service" password dialog is only shown once the
171
+ client calls `Prompt.Prompt()` on that object. The client does both:
172
+ trigger the dialog, then poll the collection's `Locked` property until it
173
+ actually opens (or the wait expires).
174
+ - **Store is a single roundtrip** (create the new item + delete any older
175
+ matches, at most one unlock attempt) and only runs when a token actually
168
176
  changed. `/auth switch`, `status`, and plain git use perform **zero**
169
- keyring writes.
170
- - **When the wallet is locked (or the keyring is otherwise unreachable)
171
- while writing**, the token is transparently kept in the encrypted file
172
- instead of leaving a dead `wallet:v1:` marker — the token stays
173
- available. Once the wallet unlocks, the next token change moves it back
174
- into the keyring.
175
- - If the wallet is locked when pi starts, the in-memory token is empty for
176
- that session (git auth is disabled meanwhile) and `/auth status` says so
177
- explicitly instead of showing a bare `(none)`.
177
+ keyring writes. On an interactive save (login) the client **waits up to
178
+ 20 s for the unlock prompt to be answered** — without the wait the token
179
+ would silently fall back to the file every time the wallet happens to be
180
+ locked.
181
+ - **When the wallet is still locked (or the keyring is otherwise
182
+ unreachable) after the wait**, the token is transparently kept in the
183
+ encrypted file instead of leaving a dead `wallet:v1:` marker the token
184
+ stays available. Once the wallet unlocks, the token moves back into the
185
+ keyring on the next load.
186
+ - If the wallet is locked when pi starts, the in-memory token is empty
187
+ (git auth is disabled meanwhile) and `/auth status` says so explicitly
188
+ instead of showing a bare `(none)`. The lookup is retried automatically
189
+ (throttled, prompt-safe) on the next `/auth status` or git-gate hit, so
190
+ the token recovers as soon as the wallet unlocks — no re-login needed.
191
+ - `/auth status` reports where the active token **actually** lives
192
+ (keyring vs. encrypted file), not just which backend was selected.
178
193
 
179
194
  ### Encrypted file (fallback)
180
195
  When python3 / D-Bus / a keyring are unavailable (headless server, no
package/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, keyringUnavailableAtLoadFlag, type StoreData } from "./store";
2
+ import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, activeTokenStorage, keyringUnavailableAtLoadFlag, type StoreData } from "./store";
3
3
  import { SERVICES, type Platform, type Service } from "./forge";
4
4
 
5
5
  /** The subset of ctx.ui the auth flows need. */
@@ -132,11 +132,21 @@ export function statusDetail(data: StoreData): string {
132
132
  }
133
133
  const activeKey = data.activeLogin;
134
134
  if (activeKey) lines.push("");
135
- const backend = storeBackend() === "keyring" ? "OS keyring (Secret Service)" : "encrypted file";
136
- const kwLocked = storeBackend() === "keyring" && keyringUnavailableAtLoadFlag();
137
- lines.push(`Store: ${backend}${kwLocked ? " (locked/unreachable on load)" : ""}`);
138
- if (activeKey) lines.push("");
135
+ const backendIsKeyring = storeBackend() === "keyring";
136
+ const kwLocked = backendIsKeyring && keyringUnavailableAtLoadFlag();
139
137
  const active = activeKey ? data.accounts[activeKey] : undefined;
138
+ let storeLine = backendIsKeyring
139
+ ? "Store: OS keyring (Secret Service)"
140
+ : "Store: encrypted file (fallback)";
141
+ if (!backendIsKeyring) {
142
+ storeLine +=
143
+ process.env.PI_GIT_AUTH_STORE?.toLowerCase() === "file"
144
+ ? " — forced by PI_GIT_AUTH_STORE=file"
145
+ : " — keyring unavailable";
146
+ } else if (!active?.accessToken && kwLocked) {
147
+ storeLine += " (wallet locked on load — token unavailable)";
148
+ }
149
+ lines.push(storeLine);
140
150
  if (active && activeKey) {
141
151
  lines.push("");
142
152
  lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
@@ -144,6 +154,15 @@ export function statusDetail(data: StoreData): string {
144
154
  lines.push(
145
155
  "Token: (none) — keyring locked: the token is stored in the keyring and is available again once the wallet unlocks (git auth is disabled meanwhile)"
146
156
  );
157
+ } else if (active.accessToken) {
158
+ const inKeyring = backendIsKeyring && activeTokenStorage() === "keyring";
159
+ lines.push(
160
+ inKeyring
161
+ ? `Token: ${maskToken(active.accessToken)} (stored in OS keyring)`
162
+ : `Token: ${maskToken(active.accessToken)} (stored in encrypted file${
163
+ backendIsKeyring ? " — wallet was locked at save" : ""
164
+ })`,
165
+ );
147
166
  } else {
148
167
  lines.push(`Token: ${maskToken(active.accessToken)}`);
149
168
  }
package/commands.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { loadStore, activeAccount, type StoreData } from "./store";
2
+ import { loadStore, activeAccount, retryKeyringLoad, type StoreData } from "./store";
3
3
  import { SERVICES, type Service, type TreeEntry } from "./forge";
4
4
  import { buildDetailsText, RepoDetailsPanel } from "./details";
5
5
  import { activeService, accountName, loginWithPastedToken, removeAccount, setActiveAccount, statusDetail } from "./auth";
@@ -7,6 +7,7 @@ import { activeService, accountName, loginWithPastedToken, removeAccount, setAct
7
7
  type Ctx = ExtensionCommandContext;
8
8
 
9
9
  async function showStatus(ctx: Ctx): Promise<void> {
10
+ retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
10
11
  const data = loadStore();
11
12
  if (Object.keys(data.accounts).length === 0) {
12
13
  ctx.ui.notify("git auth: not connected — run /auth login", "info");
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { loadStore, activeAccount } from "./store";
4
+ import { loadStore, activeAccount, retryKeyringLoad } from "./store";
5
5
  import { SERVICES } from "./forge";
6
6
  import { findAccounts, setActiveAccount, statusDetail } from "./auth";
7
7
  import { instrumentGit } from "./git-gate";
@@ -15,6 +15,7 @@ export default function (pi: ExtensionAPI) {
15
15
  // ------------------------------------------------------------------
16
16
  pi.on("tool_call", async (event, ctx) => {
17
17
  if (!isToolCallEventType("bash", event)) return;
18
+ retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
18
19
  const acc = activeAccount(loadStore());
19
20
  if (!acc?.accessToken) return;
20
21
  const host = SERVICES[acc.platform].host;
@@ -86,6 +87,7 @@ export default function (pi: ExtensionAPI) {
86
87
 
87
88
  switch (params.action) {
88
89
  case "status": {
90
+ retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
89
91
  const data = loadStore();
90
92
  if (Object.keys(data.accounts).length === 0) return { ...text(notConnected), isError: true };
91
93
  return text(statusDetail(data));
package/keyring.ts CHANGED
@@ -9,16 +9,25 @@
9
9
  * a process argument list — only in the parent process's memory.
10
10
  *
11
11
  * KWallet/ksecretd (KDE Plasma) notes:
12
- * - ksecretd triggers a KWallet unlock dialog for every D-Bus operation
13
- * on a LOCKED collection. To keep this from stacking prompts (and
14
- * tripping kded's "Repeated attempts to access a wallet have
15
- * occurred" warning):
12
+ * ksecretd implements the Secret Service "Prompt" protocol: calling
13
+ * Service.Unlock() on a locked collection returns a Prompt object, and
14
+ * the unlock dialog (the real "KDE Wallet Service" password dialog,
15
+ * shown by ksecretd itself) only appears when the client then calls
16
+ * Prompt.Prompt(window_id) on it. We do exactly that (with an empty
17
+ * window id — headless) and then wait `wait` seconds (default 20) for
18
+ * the collection to actually unlock:
16
19
  * * "lookup" on a locked collection returns {"locked": true} and
17
- * never touches the collection;
18
- * * "upsert" does delete+create in ONE D-Bus roundtrip with at most
19
- * ONE explicit unlock attempt (only when a non-empty secret is
20
- * actually being written);
20
+ * never touches the collection (no prompt — reads happen in the
21
+ * background during git operations);
22
+ * * "upsert" creates the new item (with its secret) FIRST and only
23
+ * then deletes the previously matched ones — kill-safe: an
24
+ * interrupted upsert never destroys the keyring copy; when the
25
+ * collection is locked and a non-empty secret is being written it
26
+ * triggers ONE interactive unlock (Service.Unlock + Prompt) and
27
+ * waits for it to complete;
21
28
  * * delete-only ("clear" / empty secret) never unlocks.
29
+ * Non-interactive callers (bulk migration/repair at load) pass wait=0 and
30
+ * get the old instant-fallback behavior.
22
31
  *
23
32
  * Two API generations are auto-detected at runtime by introspection:
24
33
  * - modern 0.0.1 (gnome-keyring, kwallet --secretservice):
@@ -37,6 +46,10 @@ import { join } from "node:path";
37
46
  export const STATE_DIR = join(homedir(), ".pi", "agent", "pi-git-auth");
38
47
  const PY_PATH = join(STATE_DIR, "wallet-tool.py");
39
48
  const TIMEOUT_MS = 8000;
49
+ /** Default seconds to wait for an interactive wallet unlock on write. */
50
+ export const UNLOCK_WAIT_S = 20;
51
+ /** Upserts may block on an interactive unlock: give the wait room. */
52
+ const UPSERT_TIMEOUT_MS = (UNLOCK_WAIT_S + 15) * 1000;
40
53
 
41
54
  /** Outcome of a keyring write: ok, keyring locked, or unreachable. */
42
55
  export type WalletResult = "ok" | "locked" | "unreachable";
@@ -54,19 +67,28 @@ const PY = `#!/usr/bin/env python3
54
67
 
55
68
  Protocol: one JSON request on stdin, one JSON response line on stdout.
56
69
  {"cmd": "available"}
57
- {"cmd": "upsert", "attrs": {...}, "secret": "..."} # empty secret = delete only
70
+ {"cmd": "upsert", "attrs": {...}, "secret": "...", "wait": 20}
71
+ # empty secret = delete only; wait = seconds to wait for an
72
+ # interactive unlock while writing (0 = never wait)
58
73
  {"cmd": "lookup", "attrs": {...}}
59
74
  {"cmd": "clear", "attrs": {...}}
60
75
 
61
76
  Auto-detects the Secret Service API generation (modern 0.0.1 vs legacy
62
77
  0.0.0/ksecretd) by introspecting the service.
63
78
 
64
- KWallet/ksecretd (KDE) note: every D-Bus operation on a LOCKED collection
65
- triggers a KWallet unlock dialog. So:
79
+ KWallet/ksecretd (KDE) note: ksecretd implements the Secret Service Prompt
80
+ protocol. Service.Unlock() on a locked collection returns a Prompt object;
81
+ the "KDE Wallet Service" password dialog (shown by ksecretd itself) only
82
+ appears once the client calls Prompt.Prompt() on it. We do that (empty
83
+ window id — we are headless) and then wait "wait" seconds for the
84
+ collection to actually unlock.
66
85
  - "lookup" on a locked collection returns {"locked": true} and never
67
- touches the collection;
68
- - "upsert" does delete+create in ONE roundtrip with at most ONE explicit
69
- unlock attempt, and only when a non-empty secret is written;
86
+ touches the collection (no prompt);
87
+ - "upsert" creates the new item (with its secret) FIRST and only then
88
+ deletes the previously matched ones kill-safe: an interrupted
89
+ upsert never destroys the keyring copy; when locked and a non-empty
90
+ secret is written it triggers ONE interactive unlock (Service.Unlock
91
+ + Prompt.Prompt) and waits for it;
70
92
  - delete-only never unlocks.
71
93
  This keeps the client from stacking unlock prompts, which is what makes
72
94
  kded warn "Repeated attempts to access a wallet have occurred".
@@ -74,6 +96,9 @@ kded warn "Repeated attempts to access a wallet have occurred".
74
96
  import sys
75
97
  import json
76
98
  import re
99
+ import time
100
+
101
+ UNLOCK_WAIT_S = 20.0
77
102
 
78
103
 
79
104
  def out(obj):
@@ -160,6 +185,46 @@ def main():
160
185
  ))
161
186
  return [str(k) for k in list(u)], bool(locked)
162
187
 
188
+ def is_locked_now():
189
+ """Authoritative collection lock state, via the Locked
190
+ property. (SearchItems only reports locking through items that
191
+ match the query — a locked collection with NO matching items
192
+ would otherwise look unlocked!)"""
193
+ if MODERN:
194
+ return False
195
+ try:
196
+ return bool(dbus.Interface(
197
+ bus.get_object(owner, str(coll)),
198
+ "org.freedesktop.DBus.Properties",
199
+ ).Get("org.freedesktop.Secret.Collection", "Locked"))
200
+ except Exception:
201
+ return bool(find_items()[1])
202
+
203
+ def unlock_with_prompt():
204
+ """Trigger ksecretd's interactive unlock flow. Service.Unlock()
205
+ returns (unlocked collections, prompt object path); the dialog
206
+ is only shown once Prompt.Prompt(window_id) is called on that
207
+ object — and it is shown by ksecretd itself, so it survives
208
+ this process exiting."""
209
+ if MODERN:
210
+ return # modern (0.0.1) API has no locking
211
+ try:
212
+ res = dbusi.Unlock([dbus.ObjectPath(str(coll))])
213
+ prompt = str(res[1]) if res and len(res) > 1 else "/"
214
+ except Exception:
215
+ return
216
+ if prompt and prompt != "/":
217
+ try:
218
+ piface = dbus.Interface(
219
+ bus.get_object(owner, prompt),
220
+ "org.freedesktop.Secret.Prompt",
221
+ )
222
+ # Empty window id: ksecretd still shows the dialog,
223
+ # unparented, kept above all windows.
224
+ piface.Prompt("")
225
+ except Exception:
226
+ pass
227
+
163
228
  def get_content(path):
164
229
  """Return the secret bytes for an item path, or None."""
165
230
  try:
@@ -198,7 +263,9 @@ def main():
198
263
  pass
199
264
 
200
265
  if cmd in ("upsert", "store", "clear"):
266
+ wait = max(0.0, float(req.get("wait", UNLOCK_WAIT_S)))
201
267
  paths, is_locked = find_items()
268
+ is_locked = is_locked_now() or is_locked
202
269
  writing = cmd != "clear" and bool(secret)
203
270
  if is_locked:
204
271
  if not writing:
@@ -207,22 +274,38 @@ def main():
207
274
  out({"ok": False, "locked": True,
208
275
  "error": "keyring is locked"})
209
276
  return
210
- # writing while locked: exactly ONE unlock attempt (one
211
- # prompt), then re-check.
212
- try:
213
- dbusi.Unlock([coll])
214
- except Exception:
277
+ if wait <= 0:
278
+ # Non-interactive caller: NEVER prompt. Fall through to
279
+ # the write attempt below — it may still succeed if the
280
+ # wallet is being unlocked in parallel; otherwise the
281
+ # IsLocked D-Bus error is caught below and reported as
282
+ # "locked".
215
283
  pass
216
- paths, is_locked = find_items()
217
- if is_locked:
218
- out({"ok": False, "locked": True,
219
- "error": "keyring is locked"})
220
- return
221
- for path in paths:
222
- item_delete(path)
284
+ else:
285
+ # Writing while locked: trigger the interactive unlock
286
+ # (Service.Unlock + Prompt — ksecretd shows the "KDE
287
+ # Wallet Service" password dialog) and wait for the
288
+ # collection to actually unlock.
289
+ unlock_with_prompt()
290
+ deadline = time.time() + wait
291
+ while is_locked_now() and time.time() < deadline:
292
+ time.sleep(1)
293
+ if is_locked_now():
294
+ out({"ok": False, "locked": True,
295
+ "error": "keyring is locked"})
296
+ return
297
+ paths, is_locked = find_items()
223
298
  if cmd == "clear" or not secret:
299
+ for path in paths:
300
+ item_delete(path)
224
301
  out({"ok": True})
225
302
  return
303
+ # Kill-safe order: create the new item (with its secret) first,
304
+ # then delete the previously matched items. Even if this process
305
+ # is killed mid-upsert the keyring copy is never destroyed
306
+ # (worst case: orphan items remain; the next upsert cleans them
307
+ # up, and lookups take the first non-empty secret).
308
+ new_path = ""
226
309
  if MODERN:
227
310
  item = "/org/freedesktop/secrets/0/item/" + re.sub(
228
311
  r"[^A-Za-z0-9_]", "_", "%s_%s" % (
@@ -262,6 +345,7 @@ def main():
262
345
  "sv",
263
346
  ),
264
347
  )
348
+ new_path = item
265
349
  else:
266
350
  coll_obj = dbus.Interface(
267
351
  bus.get_object(owner, str(coll)),
@@ -282,11 +366,33 @@ def main():
282
366
  {k: v for k, v in attrs.items()}, "ss"
283
367
  ),
284
368
  }, "sv")
285
- coll_obj.CreateItem(props, secret_arg, True)
369
+ create_res = coll_obj.CreateItem(props, secret_arg, True)
370
+ # ksecretd returns (item_path, prompt) — the item comes
371
+ # FIRST (the 0.0.1 spec says (session, item)); scan the
372
+ # whole reply for a path under this collection instead of
373
+ # trusting a fixed index.
374
+ for cand in (
375
+ create_res if isinstance(create_res, tuple) else ()
376
+ ):
377
+ s = str(cand)
378
+ if s.startswith(str(coll) + "/"):
379
+ new_path = s
380
+ break
381
+ if new_path:
382
+ for path in paths:
383
+ if path != new_path:
384
+ item_delete(path)
385
+ # else: can't tell which pre-matched item was updated in place —
386
+ # keep them all (orphans are harmless); deleting blindly would
387
+ # destroy the copy we just wrote.
286
388
  out({"ok": True})
287
389
  return
288
390
 
289
391
  if cmd == "lookup":
392
+ if is_locked_now():
393
+ out({"ok": False, "locked": True,
394
+ "error": "keyring is locked"})
395
+ return
290
396
  paths, is_locked = find_items()
291
397
  for path in paths:
292
398
  content = get_content(path)
@@ -307,10 +413,18 @@ def main():
307
413
 
308
414
  out({"ok": False, "error": "unknown command"})
309
415
  except Exception as e:
416
+ name = ""
417
+ try:
418
+ name = e.get_dbus_name() or "" # D-Bus error name (python-dbus)
419
+ except Exception:
420
+ pass
310
421
  msg = str(e)
311
422
  if secret:
312
423
  msg = msg.replace(secret, "***")
313
- out({"ok": False, "error": msg or e.__class__.__name__})
424
+ if "IsLocked" in name or "IsLocked" in msg:
425
+ out({"ok": False, "locked": True, "error": "keyring is locked"})
426
+ else:
427
+ out({"ok": False, "error": msg or e.__class__.__name__})
314
428
 
315
429
 
316
430
  main()
@@ -374,14 +488,19 @@ export function walletAvailable(): boolean {
374
488
  let lastLookupLocked = false;
375
489
 
376
490
  /**
377
- * Upsert a secret for the given attrs in ONE D-Bus roundtrip: existing
378
- * matching items are deleted, then (when secret is non-empty) the new item
379
- * is created. An empty secret deletes only (best-effort, never prompts).
380
- * "locked" = the keyring exists but is locked (KWallet): the token must be
381
- * kept in the file fallback; "unreachable" = no keyring/D-Bus at all.
491
+ * Upsert a secret for the given attrs in ONE D-Bus roundtrip: the new item
492
+ * is created (with its secret) first, then the previously matched items are
493
+ * deleted an interrupted upsert never destroys the keyring copy. An empty
494
+ * secret deletes only (best-effort, never prompts).
495
+ * `waitSec` is how long to wait for an interactive unlock when the
496
+ * keyring is locked while writing (default UNLOCK_WAIT_S for interactive
497
+ * callers such as login; pass 0 for non-interactive paths so they fall
498
+ * back to the file instantly). "locked" = the keyring exists but is
499
+ * locked (KWallet): the token must be kept in the file fallback;
500
+ * "unreachable" = no keyring/D-Bus at all.
382
501
  */
383
- export function walletUpsert(attrs: Record<string, string>, secret: string): WalletResult {
384
- const r = call({ cmd: "upsert", attrs, secret });
502
+ export function walletUpsert(attrs: Record<string, string>, secret: string, waitSec = UNLOCK_WAIT_S): WalletResult {
503
+ const r = call({ cmd: "upsert", attrs, secret, wait: waitSec }, Math.max(UPSERT_TIMEOUT_MS, (waitSec + 15) * 1000));
385
504
  if (!r) return "unreachable";
386
505
  if (r.ok) return "ok";
387
506
  if (r.locked) return "locked";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-git-auth",
3
- "version": "1.1.1",
3
+ "version": "1.2.2",
4
4
  "description": "pi coding-agent extension: git auth for GitHub and GitLab: keyring-stored login tokens, account switching, transparent git auth, repo list/create with details overlay",
5
5
  "license": "MIT",
6
6
  "author": "Carlo Onofrio",
package/store.ts CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
5
5
  import type { Platform } from "./forge";
6
- import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs } from "./keyring";
6
+ import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs, UNLOCK_WAIT_S } from "./keyring";
7
7
 
8
8
  /**
9
9
  * Credential persistence for pi-git-auth (multi-account, multi-service).
@@ -20,6 +20,15 @@ import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs } f
20
20
  *
21
21
  * Migration is transparent: legacy plaintext or `enc:v1:` tokens are moved
22
22
  * into the keyring on first load (a `.bak` copy of the file is kept).
23
+ * This is also the self-heal: a token kept in the file after a locked
24
+ * wallet goes back to the keyring on the next load while it is reachable
25
+ * (load-time writes use wait=0, so a still-locked wallet never delays
26
+ * startup).
27
+ *
28
+ * If the keyring is locked on load, the token for `wallet:v1:` accounts
29
+ * is "" for this process but is retried (throttled, prompt-safe) via
30
+ * retryKeyringLoad() on the next status/git-gate hit, so it recovers
31
+ * automatically once the wallet unlocks.
23
32
  *
24
33
  * Env override: PI_GIT_AUTH_STORE = auto (default) | wallet | file
25
34
  *
@@ -73,6 +82,10 @@ let diskData: StoreData | null = null;
73
82
  let storedPlaintext: Record<string, string> = {};
74
83
  /** A `wallet:v1:` token could not be read on load (keyring locked/absent). */
75
84
  let keyringUnavailableAtLoad = false;
85
+ /** Accounts with a `wallet:v1:` marker that failed to read on load. */
86
+ let loadFailedKeys: string[] = [];
87
+ let lastRetryAt = 0;
88
+ const RETRY_THROTTLE_MS = 10_000;
76
89
 
77
90
  const ENC_PREFIX = "enc:v1:";
78
91
  const WALLET_PREFIX = "wallet:v1:";
@@ -178,8 +191,13 @@ function decryptToken(enc: string): string {
178
191
  * keyring writes (no prompts, no "repeated wallet access" warnings).
179
192
  * When the keyring is locked/unreachable the token is kept in the
180
193
  * encrypted file instead of leaving a dead `wallet:v1:` marker behind.
194
+ *
195
+ * `waitSec` = how long a keyring write may wait for an interactive
196
+ * wallet unlock. Interactive callers (login) pass UNLOCK_WAIT_S so the
197
+ * user can answer the KWallet prompt; load-time migration/repair passes
198
+ * 0 so a locked wallet never delays startup.
181
199
  */
182
- function persist(data: StoreData, forceStore = false): void {
200
+ function persist(data: StoreData, forceStore = false, waitSec = 0): void {
183
201
  const mode = storeMode();
184
202
  const accounts: Record<string, AccountRecord> = {};
185
203
  const nowPlaintext: Record<string, string> = {};
@@ -189,7 +207,7 @@ function persist(data: StoreData, forceStore = false): void {
189
207
  let stored: string;
190
208
  if (changed && a.accessToken) {
191
209
  // Single keyring roundtrip (delete matching items + create).
192
- const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken) : null;
210
+ const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken, waitSec) : null;
193
211
  stored = r === "ok" ? WALLET_PREFIX + k : encryptToken(a.accessToken);
194
212
  } else if (diskStored) {
195
213
  stored = diskStored; // unchanged: keep the existing marker/envelope
@@ -228,6 +246,7 @@ export function loadStore(): StoreData {
228
246
  // plaintext tokens that absorb decrypts into the same objects.
229
247
  diskData = raw.accounts && typeof raw.accounts === "object" ? JSON.parse(JSON.stringify(raw)) : null;
230
248
  keyringUnavailableAtLoad = false;
249
+ loadFailedKeys = [];
231
250
  const data: StoreData = { accounts: {} };
232
251
  let migrated = false;
233
252
 
@@ -237,9 +256,11 @@ export function loadStore(): StoreData {
237
256
  const got = walletLookup(recAttrs(rec, key));
238
257
  if (got === null) {
239
258
  // keyring locked/cleared: keep the marker on disk, run this
240
- // process without the token, and flag it for the status output.
259
+ // process without the token, and flag it for the status output
260
+ // and for retryKeyringLoad().
241
261
  rec.accessToken = "";
242
262
  keyringUnavailableAtLoad = true;
263
+ loadFailedKeys.push(key);
243
264
  } else {
244
265
  rec.accessToken = got;
245
266
  }
@@ -292,8 +313,49 @@ export function loadStore(): StoreData {
292
313
  return cache;
293
314
  }
294
315
 
316
+ /**
317
+ * Persist after an interactive change (login): keyring writes may wait
318
+ * up to UNLOCK_WAIT_S for the user to answer the wallet unlock prompt.
319
+ */
295
320
  export function saveStore(data: StoreData): void {
296
- persist(data);
321
+ persist(data, false, UNLOCK_WAIT_S);
322
+ }
323
+
324
+ /**
325
+ * Lazy recovery for tokens that could not be read on load (keyring
326
+ * locked): retry the lookup, throttled. Prompt-safe — a lookup never
327
+ * touches a locked collection, so this cannot stack unlock prompts.
328
+ * No-op once nothing is pending. Call it from hot paths (git gate,
329
+ * /auth status) so the token recovers as soon as the wallet unlocks.
330
+ */
331
+ export function retryKeyringLoad(): void {
332
+ if (loadFailedKeys.length === 0) return;
333
+ const now = Date.now();
334
+ if (now - lastRetryAt < RETRY_THROTTLE_MS) return;
335
+ lastRetryAt = now;
336
+ const data = cache;
337
+ if (!data) return;
338
+ for (const k of [...loadFailedKeys]) {
339
+ const rec = data.accounts[k];
340
+ if (!rec || rec.accessToken) {
341
+ loadFailedKeys = loadFailedKeys.filter((x) => x !== k);
342
+ continue;
343
+ }
344
+ const got = walletLookup(recAttrs(rec, k));
345
+ if (got !== null) {
346
+ rec.accessToken = got;
347
+ storedPlaintext[k] = got;
348
+ loadFailedKeys = loadFailedKeys.filter((x) => x !== k);
349
+ }
350
+ }
351
+ if (loadFailedKeys.length === 0) keyringUnavailableAtLoad = false;
352
+ }
353
+
354
+ /** Where the active account's token actually lives on disk right now. */
355
+ export function activeTokenStorage(): "keyring" | "file" {
356
+ const key = cache?.activeLogin;
357
+ const stored = key ? diskData?.accounts[key]?.accessToken : undefined;
358
+ return stored?.startsWith(WALLET_PREFIX) ? "keyring" : "file";
297
359
  }
298
360
 
299
361
  /** True when a `wallet:v1:` token could not be read on load (keyring