pi-git-auth 1.1.0 → 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,8 +2,8 @@
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, the active account is selected in the TUI, and every `git`
6
- command the agent runs is transparently authenticated with that account's
5
+ OS keyring (kwallet), switch account, and every `git`command
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.
9
9
 
@@ -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
@@ -154,6 +156,41 @@ provider.
154
156
  - `credentials.json` keeps only a `wallet:v1:<accountKey>` marker per
155
157
  account; the token itself is in the keyring.
156
158
 
159
+ #### KDE Plasma (KWallet / ksecretd)
160
+
161
+ `ksecretd` fires a KWallet unlock dialog for **every** D-Bus operation on
162
+ a locked collection, so the client is built to keep wallet access to the
163
+ absolute minimum:
164
+
165
+ - **Lookup never prompts.** A read on a locked collection returns
166
+ `locked` without touching the collection — no stacked prompts, no kded
167
+ "Repeated attempts to access a wallet have occurred" warning.
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
176
+ changed. `/auth switch`, `status`, and plain git use perform **zero**
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.
193
+
157
194
  ### Encrypted file (fallback)
158
195
  When python3 / D-Bus / a keyring are unavailable (headless server, no
159
196
  session bus), the extension transparently falls back to an on-disk
@@ -178,8 +215,9 @@ reachable, else file), `wallet`, or `file`.
178
215
 
179
216
  - The git gate adds one regex pass per `git` command (same as any string
180
217
  rewrite) and no network calls; login adds one TUI prompt.
181
- - The keyring round-trip is one D-Bus exchange per account, only at load
182
- and write time.
218
+ - The keyring round-trip is one D-Bus exchange per account at load, and a
219
+ single upsert roundtrip **only for accounts whose token changed** at
220
+ write time (a `/auth switch` writes no keyring at all).
183
221
  - GitHub's details view uses a single bounded set of REST calls
184
222
  (repo meta + 5 commits + 1 recursive tree). GitLab's tree is top-level
185
223
  only (its API does not recurse), bounded to 100 entries.
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, 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,13 +132,40 @@ export function statusDetail(data: StoreData): string {
132
132
  }
133
133
  const activeKey = data.activeLogin;
134
134
  if (activeKey) lines.push("");
135
- lines.push(`Store: ${storeBackend() === "keyring" ? "OS keyring (Secret Service)" : "encrypted file"}`);
136
- if (activeKey) lines.push("");
135
+ const backendIsKeyring = storeBackend() === "keyring";
136
+ const kwLocked = backendIsKeyring && keyringUnavailableAtLoadFlag();
137
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);
138
150
  if (active && activeKey) {
139
151
  lines.push("");
140
152
  lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
141
- lines.push(`Token: ${maskToken(active.accessToken)}`);
153
+ if (!active.accessToken && kwLocked) {
154
+ lines.push(
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)"
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
+ );
166
+ } else {
167
+ lines.push(`Token: ${maskToken(active.accessToken)}`);
168
+ }
142
169
  if (active.scopes) lines.push(`Scopes: ${active.scopes}`);
143
170
  lines.push(`Saved: ${active.savedAt}`);
144
171
  }
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
@@ -8,11 +8,32 @@
8
8
  * It talks JSON over stdin/stdout, which means the secret NEVER appears in
9
9
  * a process argument list — only in the parent process's memory.
10
10
  *
11
+ * KWallet/ksecretd (KDE Plasma) notes:
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:
19
+ * * "lookup" on a locked collection returns {"locked": true} and
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;
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.
31
+ *
11
32
  * Two API generations are auto-detected at runtime by introspection:
12
33
  * - modern 0.0.1 (gnome-keyring, kwallet --secretservice):
13
34
  * Service.Store / SearchItems / item.GetSecret
14
35
  * - legacy 0.0.0 (KDE ksecretd, default with KWallet 6):
15
- * Collection.CreateItem / Service.SearchItems / Service.GetSecrets
36
+ * Collection.CreateItem / SearchItems / Service.GetSecrets
16
37
  *
17
38
  * Fallback: when python3/dbus/keyring are unavailable (headless, no D-Bus),
18
39
  * store.ts silently keeps the on-disk AES-encrypted file format.
@@ -25,11 +46,19 @@ import { join } from "node:path";
25
46
  export const STATE_DIR = join(homedir(), ".pi", "agent", "pi-git-auth");
26
47
  const PY_PATH = join(STATE_DIR, "wallet-tool.py");
27
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;
53
+
54
+ /** Outcome of a keyring write: ok, keyring locked, or unreachable. */
55
+ export type WalletResult = "ok" | "locked" | "unreachable";
28
56
 
29
57
  interface WalletRes {
30
58
  ok: boolean;
31
59
  secret?: string;
32
60
  api?: string;
61
+ locked?: boolean;
33
62
  error?: string;
34
63
  }
35
64
 
@@ -38,16 +67,38 @@ const PY = `#!/usr/bin/env python3
38
67
 
39
68
  Protocol: one JSON request on stdin, one JSON response line on stdout.
40
69
  {"cmd": "available"}
41
- {"cmd": "store", "attrs": {...}, "secret": "..."}
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)
42
73
  {"cmd": "lookup", "attrs": {...}}
43
74
  {"cmd": "clear", "attrs": {...}}
44
75
 
45
76
  Auto-detects the Secret Service API generation (modern 0.0.1 vs legacy
46
77
  0.0.0/ksecretd) by introspecting the service.
78
+
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.
85
+ - "lookup" on a locked collection returns {"locked": true} and never
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;
92
+ - delete-only never unlocks.
93
+ This keeps the client from stacking unlock prompts, which is what makes
94
+ kded warn "Repeated attempts to access a wallet have occurred".
47
95
  """
48
96
  import sys
49
97
  import json
50
98
  import re
99
+ import time
100
+
101
+ UNLOCK_WAIT_S = 20.0
51
102
 
52
103
 
53
104
  def out(obj):
@@ -114,12 +165,12 @@ def main():
114
165
  if str(coll) == "/":
115
166
  out({"ok": False, "error": "no default collection in keyring"})
116
167
  return
117
- try:
118
- dbusi.Unlock([coll])
119
- except Exception:
120
- pass
121
168
 
122
169
  def find_items():
170
+ """Return (item paths in unlocked collections, locked flag).
171
+ legacy ksecretd reports items in LOCKED collections separately;
172
+ touching them would fire KWallet unlock dialogs, so callers
173
+ get the flag instead."""
123
174
  if MODERN:
124
175
  res = dbusi.SearchItems(
125
176
  dbus.UInt32(0),
@@ -128,11 +179,51 @@ def main():
128
179
  ),
129
180
  dbus.ObjectPath("/"),
130
181
  )
131
- return [str(k) for k in res]
132
- (u, _l) = dbusi.SearchItems(dbus.Dictionary(
182
+ return [str(k) for k in res], False
183
+ (u, locked) = dbusi.SearchItems(dbus.Dictionary(
133
184
  {k: v for k, v in attrs.items()}, "ss"
134
185
  ))
135
- return [str(k) for k in list(u) + list(_l)]
186
+ return [str(k) for k in list(u)], bool(locked)
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
136
227
 
137
228
  def get_content(path):
138
229
  """Return the secret bytes for an item path, or None."""
@@ -171,7 +262,50 @@ def main():
171
262
  except Exception:
172
263
  pass
173
264
 
174
- if cmd == "store":
265
+ if cmd in ("upsert", "store", "clear"):
266
+ wait = max(0.0, float(req.get("wait", UNLOCK_WAIT_S)))
267
+ paths, is_locked = find_items()
268
+ is_locked = is_locked_now() or is_locked
269
+ writing = cmd != "clear" and bool(secret)
270
+ if is_locked:
271
+ if not writing:
272
+ # delete-only on a locked keyring: skip it rather than
273
+ # prompt (best-effort purge; the file is already clean).
274
+ out({"ok": False, "locked": True,
275
+ "error": "keyring is locked"})
276
+ return
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".
283
+ pass
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()
298
+ if cmd == "clear" or not secret:
299
+ for path in paths:
300
+ item_delete(path)
301
+ out({"ok": True})
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 = ""
175
309
  if MODERN:
176
310
  item = "/org/freedesktop/secrets/0/item/" + re.sub(
177
311
  r"[^A-Za-z0-9_]", "_", "%s_%s" % (
@@ -211,9 +345,10 @@ def main():
211
345
  "sv",
212
346
  ),
213
347
  )
348
+ new_path = item
214
349
  else:
215
350
  coll_obj = dbus.Interface(
216
- bus.get_object(owner, str(dbusi.ReadAlias("default"))),
351
+ bus.get_object(owner, str(coll)),
217
352
  "org.freedesktop.Secret.Collection",
218
353
  )
219
354
  secret_arg = dbus.Struct((
@@ -231,12 +366,35 @@ def main():
231
366
  {k: v for k, v in attrs.items()}, "ss"
232
367
  ),
233
368
  }, "sv")
234
- 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.
235
388
  out({"ok": True})
236
389
  return
237
390
 
238
391
  if cmd == "lookup":
239
- for path in find_items():
392
+ if is_locked_now():
393
+ out({"ok": False, "locked": True,
394
+ "error": "keyring is locked"})
395
+ return
396
+ paths, is_locked = find_items()
397
+ for path in paths:
240
398
  content = get_content(path)
241
399
  if content:
242
400
  out({
@@ -244,21 +402,29 @@ def main():
244
402
  "secret": content.decode("utf-8", "replace"),
245
403
  })
246
404
  return
405
+ if is_locked:
406
+ # Item exists but its collection is locked (KWallet):
407
+ # report it, don't touch the collection (no unlock prompt).
408
+ out({"ok": False, "locked": True,
409
+ "error": "keyring is locked"})
410
+ return
247
411
  out({"ok": False, "error": "item not found"})
248
412
  return
249
413
 
250
- if cmd == "clear":
251
- for path in find_items():
252
- item_delete(path)
253
- out({"ok": True})
254
- return
255
-
256
414
  out({"ok": False, "error": "unknown command"})
257
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
258
421
  msg = str(e)
259
422
  if secret:
260
423
  msg = msg.replace(secret, "***")
261
- 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__})
262
428
 
263
429
 
264
430
  main()
@@ -319,20 +485,40 @@ export function walletAvailable(): boolean {
319
485
  return availCache;
320
486
  }
321
487
 
322
- /** Store (upsert) a secret. */
323
- export function walletStore(attrs: Record<string, string>, secret: string): boolean {
324
- const r = call({ cmd: "store", attrs, secret });
325
- return !!(r && r.ok);
488
+ let lastLookupLocked = false;
489
+
490
+ /**
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.
501
+ */
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));
504
+ if (!r) return "unreachable";
505
+ if (r.ok) return "ok";
506
+ if (r.locked) return "locked";
507
+ return "unreachable";
326
508
  }
327
509
 
328
- /** Read a secret; null when not found or the keyring is unreachable. */
510
+ /**
511
+ * Read a secret; null when not found, the keyring is locked, or the
512
+ * keyring is unreachable. See walletWasLocked() to distinguish a locked
513
+ * keyring from a missing item.
514
+ */
329
515
  export function walletLookup(attrs: Record<string, string>): string | null {
330
516
  const r = call({ cmd: "lookup", attrs });
517
+ lastLookupLocked = !!(r && r.locked);
331
518
  return r && r.ok ? (r.secret ?? null) : null;
332
519
  }
333
520
 
334
- /** Remove every item matching attrs. No-op when none exist. */
335
- export function walletClear(attrs: Record<string, string>): boolean {
336
- const r = call({ cmd: "clear", attrs });
337
- return !!(r && r.ok);
521
+ /** True when the last lookup failed because the keyring is locked. */
522
+ export function walletWasLocked(): boolean {
523
+ return lastLookupLocked;
338
524
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-git-auth",
3
- "version": "1.1.0",
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, walletStore, walletLookup, walletClear, 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, walletStore, walletLookup, walletClear, wal
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
  *
@@ -67,6 +76,16 @@ export interface StoreData {
67
76
 
68
77
  let cache: StoreData | null = null;
69
78
  let keyCache: Buffer | null = null;
79
+ /** Stored strings (markers/envelopes) from the last file read/write. */
80
+ let diskData: StoreData | null = null;
81
+ /** Plaintext tokens as last persisted, per account key ("" = unreadable). */
82
+ let storedPlaintext: Record<string, string> = {};
83
+ /** A `wallet:v1:` token could not be read on load (keyring locked/absent). */
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;
70
89
 
71
90
  const ENC_PREFIX = "enc:v1:";
72
91
  const WALLET_PREFIX = "wallet:v1:";
@@ -163,22 +182,40 @@ function decryptToken(enc: string): string {
163
182
  // Store API
164
183
  // ---------------------------------------------------------------------------
165
184
 
166
- function persist(data: StoreData): void {
167
- // The in-memory copy holds plaintext; for each account the token is
168
- // stored in the backend and the file keeps only a marker/envelope.
185
+ /**
186
+ * Persist the in-memory (plaintext) state. Only accounts whose token
187
+ * changed since the last persist are (re)written to the backend; the
188
+ * others keep their on-disk marker/envelope untouched. This matters on
189
+ * KDE/ksecretd: a keyring write is the only operation that may trigger
190
+ * the KWallet unlock dialog, so e.g. `/auth switch` performs zero
191
+ * keyring writes (no prompts, no "repeated wallet access" warnings).
192
+ * When the keyring is locked/unreachable the token is kept in the
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.
199
+ */
200
+ function persist(data: StoreData, forceStore = false, waitSec = 0): void {
169
201
  const mode = storeMode();
170
202
  const accounts: Record<string, AccountRecord> = {};
203
+ const nowPlaintext: Record<string, string> = {};
171
204
  for (const [k, a] of Object.entries(data.accounts)) {
205
+ const changed = forceStore || storedPlaintext[k] !== a.accessToken;
206
+ const diskStored = diskData?.accounts[k]?.accessToken;
172
207
  let stored: string;
173
- if (mode === "wallet") {
174
- // Clear-then-store keeps the keyring free of duplicate items.
175
- const attrs = recAttrs(a, k);
176
- walletClear(attrs);
177
- stored = walletStore(attrs, a.accessToken) ? WALLET_PREFIX + k : encryptToken(a.accessToken);
208
+ if (changed && a.accessToken) {
209
+ // Single keyring roundtrip (delete matching items + create).
210
+ const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken, waitSec) : null;
211
+ stored = r === "ok" ? WALLET_PREFIX + k : encryptToken(a.accessToken);
212
+ } else if (diskStored) {
213
+ stored = diskStored; // unchanged: keep the existing marker/envelope
178
214
  } else {
179
- stored = encryptToken(a.accessToken);
215
+ stored = a.accessToken ? encryptToken(a.accessToken) : "";
180
216
  }
181
217
  accounts[k] = { ...a, accessToken: stored };
218
+ nowPlaintext[k] = a.accessToken;
182
219
  }
183
220
  const out: StoreData = {
184
221
  accounts,
@@ -189,6 +226,9 @@ function persist(data: StoreData): void {
189
226
  writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
190
227
  renameSync(tmp, CREDENTIALS_FILE);
191
228
  chmodSync(CREDENTIALS_FILE, 0o600);
229
+ diskData = out;
230
+ storedPlaintext = nowPlaintext;
231
+ cache = data;
192
232
  }
193
233
 
194
234
  export function loadStore(): StoreData {
@@ -201,6 +241,12 @@ export function loadStore(): StoreData {
201
241
  } catch {
202
242
  raw = {};
203
243
  }
244
+ // Deep snapshot BEFORE absorb() mutates records in place — diskData must
245
+ // keep the on-disk stored strings (markers/envelopes), never the
246
+ // plaintext tokens that absorb decrypts into the same objects.
247
+ diskData = raw.accounts && typeof raw.accounts === "object" ? JSON.parse(JSON.stringify(raw)) : null;
248
+ keyringUnavailableAtLoad = false;
249
+ loadFailedKeys = [];
204
250
  const data: StoreData = { accounts: {} };
205
251
  let migrated = false;
206
252
 
@@ -209,7 +255,12 @@ export function loadStore(): StoreData {
209
255
  if (rec.accessToken.startsWith(WALLET_PREFIX)) {
210
256
  const got = walletLookup(recAttrs(rec, key));
211
257
  if (got === null) {
212
- rec.accessToken = ""; // keyring unreachable/cleared: don't crash, don't leak
258
+ // keyring locked/cleared: keep the marker on disk, run this
259
+ // process without the token, and flag it for the status output
260
+ // and for retryKeyringLoad().
261
+ rec.accessToken = "";
262
+ keyringUnavailableAtLoad = true;
263
+ loadFailedKeys.push(key);
213
264
  } else {
214
265
  rec.accessToken = got;
215
266
  }
@@ -229,6 +280,7 @@ export function loadStore(): StoreData {
229
280
  migrated = true;
230
281
  }
231
282
  data.accounts[key] = rec as AccountRecord;
283
+ storedPlaintext[key] = (rec as AccountRecord).accessToken;
232
284
  };
233
285
 
234
286
  if (raw.accounts && typeof raw.accounts === "object") {
@@ -253,7 +305,7 @@ export function loadStore(): StoreData {
253
305
  // Keep a rollback copy of the pre-migration file (still 0600, no
254
306
  // new secrets — it only contains ciphertexts/markers).
255
307
  if (hadFile && existsSync(CREDENTIALS_FILE)) copyFileSync(CREDENTIALS_FILE, CREDENTIALS_FILE + ".bak");
256
- persist(cache);
308
+ persist(cache, true); // force re-store under the current backend
257
309
  } catch {
258
310
  /* best-effort migration */
259
311
  }
@@ -261,15 +313,62 @@ export function loadStore(): StoreData {
261
313
  return cache;
262
314
  }
263
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
+ */
264
320
  export function saveStore(data: StoreData): void {
265
- cache = data;
266
- 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";
359
+ }
360
+
361
+ /** True when a `wallet:v1:` token could not be read on load (keyring
362
+ * locked or unreachable) — the in-memory token for that account is "". */
363
+ export function keyringUnavailableAtLoadFlag(): boolean {
364
+ return keyringUnavailableAtLoad;
267
365
  }
268
366
 
269
367
  /** Remove one account's keyring item (idempotent, best-effort). */
270
368
  export function purgeAccountStorage(key: string, rec?: AccountRecord | null): void {
271
369
  try {
272
- if (rec) walletClear(recAttrs(rec, key));
370
+ // Empty secret = delete-only: best-effort, never triggers a prompt.
371
+ if (rec) walletUpsert(recAttrs(rec, key), "");
273
372
  } catch {
274
373
  /* best-effort */
275
374
  }
@@ -280,13 +379,15 @@ export function clearStore(): void {
280
379
  try {
281
380
  if (cache) {
282
381
  for (const [k, rec] of Object.entries(cache.accounts)) {
283
- walletClear(recAttrs(rec, k));
382
+ walletUpsert(recAttrs(rec, k), "");
284
383
  }
285
384
  }
286
385
  } catch {
287
386
  /* best-effort */
288
387
  }
289
388
  cache = { accounts: {} };
389
+ diskData = null;
390
+ storedPlaintext = {};
290
391
  try {
291
392
  rmSync(CREDENTIALS_FILE);
292
393
  } catch {