pi-git-auth 1.2.2 → 1.2.4

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
@@ -54,6 +54,7 @@ github.ts GitHub REST client
54
54
  gitlab.ts GitLab REST client
55
55
  details.ts read-only repo details overlay (tree + commits + metadata)
56
56
  git-gate.ts command rewriting that injects the token for a host
57
+ git-helpers.ts detection of file-persisting credential helpers (status note)
57
58
  redact.ts secret redaction of tool outputs (PAT / URL-credential patterns)
58
59
  ```
59
60
 
@@ -121,15 +122,28 @@ use that account's token for the account's host (`github.com` or
121
122
 
122
123
  ```
123
124
  export GIT_TERMINAL_PROMPT=0 \
124
- GIT_CONFIG_COUNT=1 \
125
+ GIT_CONFIG_COUNT=2 \
125
126
  GIT_CONFIG_KEY_0="url.https://x-access-token:<token>@<host>/.insteadOf" \
126
- GIT_CONFIG_VALUE_0="https://<host>/" && <command>
127
+ GIT_CONFIG_VALUE_0="https://<host>/" \
128
+ GIT_CONFIG_KEY_1="credential.helper" \
129
+ GIT_CONFIG_VALUE_1="" && <command>
127
130
  ```
128
131
 
129
132
  Forging hosts' git-over-HTTPS endpoints ignore `Authorization` headers
130
133
  and only accept URL-embedded credentials, hence the `insteadOf` rewrite.
131
134
  Only the active host is touched; other remotes are untouched.
132
135
 
136
+ The empty `credential.helper` entry disables git's credential helpers for
137
+ the instrumented process only (env config is read after all file configs,
138
+ and an empty value clears previously defined helpers). This is required so
139
+ that git's post-auth store phase does not persist the injected token — e.g.
140
+ a user-configured `credential.helper = store` would otherwise write it
141
+ plaintext to `~/.git-credentials`. All other standard auth mechanisms
142
+ (URL-embedded credentials, `credential.<url>.*` config, `.netrc`, SSH) are
143
+ unaffected; helpers remain fully active everywhere the gate does not run.
144
+ If a file-persisting helper is detected in the user's config, `/auth
145
+ status` notes it passively (one line, no prompt).
146
+
133
147
  ## Token storage
134
148
 
135
149
  Tokens are **never stored plaintext on disk**. Two backends are
package/auth.ts CHANGED
@@ -117,7 +117,7 @@ export function activeService(data: StoreData): Service | undefined {
117
117
  }
118
118
 
119
119
  /** Human-readable status block: all accounts, details for the active one. */
120
- export function statusDetail(data: StoreData): string {
120
+ export function statusDetail(data: StoreData, opts?: { credHelperSink?: { helper: string; target: string } }): string {
121
121
  const keys = Object.keys(data.accounts);
122
122
  if (keys.length === 0) return "No git accounts. Run /auth login.";
123
123
  const lines = ["Git accounts:"];
@@ -147,6 +147,11 @@ export function statusDetail(data: StoreData): string {
147
147
  storeLine += " (wallet locked on load — token unavailable)";
148
148
  }
149
149
  lines.push(storeLine);
150
+ if (opts?.credHelperSink) {
151
+ lines.push(
152
+ `Git: credential.helper=${opts.credHelperSink.helper} detected — git may also cache gate tokens in ${opts.credHelperSink.target} (the gate itself keeps the token keyring-only)`,
153
+ );
154
+ }
150
155
  if (active && activeKey) {
151
156
  lines.push("");
152
157
  lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
package/commands.ts CHANGED
@@ -3,6 +3,7 @@ import { loadStore, activeAccount, retryKeyringLoad, type StoreData } from "./st
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";
6
+ import { detectCredHelperSink } from "./git-helpers";
6
7
 
7
8
  type Ctx = ExtensionCommandContext;
8
9
 
@@ -12,7 +13,8 @@ async function showStatus(ctx: Ctx): Promise<void> {
12
13
  if (Object.keys(data.accounts).length === 0) {
13
14
  ctx.ui.notify("git auth: not connected — run /auth login", "info");
14
15
  } else {
15
- ctx.ui.notify(statusDetail(data), "info");
16
+ const sink = await detectCredHelperSink(); // cached, fail-silent
17
+ ctx.ui.notify(statusDetail(data, { credHelperSink: sink }), "info");
16
18
  }
17
19
  }
18
20
 
package/git-gate.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  * for that host, regardless of git's own credential configuration:
7
7
  *
8
8
  * export GIT_TERMINAL_PROMPT=0 \
9
- * GIT_CONFIG_COUNT=1 \
9
+ * GIT_CONFIG_COUNT=2 \
10
10
  * GIT_CONFIG_KEY_0="url.https://x-access-token:<token>@<host>/.insteadOf" \
11
- * GIT_CONFIG_VALUE_0="https://<host>/" && <command>
11
+ * GIT_CONFIG_VALUE_0="https://<host>/" \
12
+ * GIT_CONFIG_KEY_1="credential.helper" \
13
+ * GIT_CONFIG_VALUE_1="" && <command>
12
14
  *
13
15
  * Forging hosts' git-over-HTTPS endpoints ignore Authorization headers
14
16
  * and only accept URL-embedded (Basic) credentials, hence the insteadOf
@@ -19,6 +21,14 @@
19
21
  * - GIT_TERMINAL_PROMPT=0: a failed auth surfaces as a clean error
20
22
  * instead of an interactive prompt hanging the TUI.
21
23
  * - Deterministic: no credential-helper races, same behavior every run.
24
+ * - The token is never persisted by git's credential helpers: the empty
25
+ * credential.helper entry (read after all file configs, where an empty
26
+ * value clears previously defined helpers) disables helpers for the
27
+ * instrumented process only. Without it, git's post-auth store phase
28
+ * would hand the injected token to file-based helpers such as `store`,
29
+ * persisting it plaintext to ~/.git-credentials. All other standard
30
+ * auth mechanisms (URL-embedded credentials, credential.<url>.* config,
31
+ * .netrc, SSH) are unaffected.
22
32
  * - SSH-style URLs for the host are rewritten to HTTPS so the token applies.
23
33
  */
24
34
 
@@ -37,8 +47,10 @@ export function instrumentGit(command: string, host: string, token: string): str
37
47
  .replace(new RegExp(`git@${host}:`, "g"), `https://${host}/`);
38
48
  const prefix =
39
49
  `export GIT_TERMINAL_PROMPT=0 ` +
40
- `GIT_CONFIG_COUNT=1 ` +
50
+ `GIT_CONFIG_COUNT=2 ` +
41
51
  `GIT_CONFIG_KEY_0="url.https://x-access-token:${token}@${host}/.insteadOf" ` +
42
- `GIT_CONFIG_VALUE_0="https://${host}/" && `;
52
+ `GIT_CONFIG_VALUE_0="https://${host}/" ` +
53
+ `GIT_CONFIG_KEY_1="credential.helper" ` +
54
+ `GIT_CONFIG_VALUE_1="" && `;
43
55
  return prefix + rewritten;
44
56
  }
package/git-helpers.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Detection of file-persisting git credential helpers.
3
+ *
4
+ * The git gate keeps its token out of helpers by disabling them for the
5
+ * instrumented process (see git-gate.ts). This module is the *advisory*
6
+ * side: it detects whether the user's normal git configuration would have
7
+ * persisted the injected token (e.g. `credential.helper = store` →
8
+ * ~/.git-credentials) so the status view can note it. Passive only — no
9
+ * prompt, no blocking, fail-silent (any error → `undefined`).
10
+ */
11
+ import { execFile } from "node:child_process";
12
+
13
+ export interface CredHelperSink {
14
+ /** The `credential.helper` value as configured (e.g. "store"). */
15
+ helper: string;
16
+ /** Where that helper persists credentials, for the status line. */
17
+ target: string;
18
+ }
19
+
20
+ const TTL_MS = 60_000;
21
+ const TIMEOUT_MS = 3_000;
22
+
23
+ let cache: { at: number; sink: CredHelperSink | undefined } | undefined;
24
+ let inFlight: Promise<CredHelperSink | undefined> | undefined;
25
+
26
+ function sinkFor(value: string): CredHelperSink | undefined {
27
+ const v = value.trim();
28
+ const base = v.split("/").pop() ?? v;
29
+ if (v === "store" || base === "credential-store" || v.endsWith("!store")) {
30
+ return { helper: v, target: "~/.git-credentials (plaintext)" };
31
+ }
32
+ if (v === "netrc" || base === "credential-netrc" || v.endsWith("!netrc")) {
33
+ return { helper: v, target: "~/.netrc" };
34
+ }
35
+ return undefined; // OS wallets (gnome-keyring, osxkeychain, …): fine, no note
36
+ }
37
+
38
+ /**
39
+ * Detect a file-persisting credential helper in the user's normal git
40
+ * config. Cached for 60 s, deduplicated in flight, and fail-silent:
41
+ * missing git, timeout, or no such helper → `undefined`.
42
+ */
43
+ export function detectCredHelperSink(): Promise<CredHelperSink | undefined> {
44
+ const now = Date.now();
45
+ if (cache && now - cache.at < TTL_MS) return Promise.resolve(cache.sink);
46
+ if (inFlight) return inFlight;
47
+ inFlight = new Promise<CredHelperSink | undefined>((resolve) => {
48
+ let done = false;
49
+ const finish = (sink: CredHelperSink | undefined) => {
50
+ if (done) return;
51
+ done = true;
52
+ cache = { at: Date.now(), sink };
53
+ inFlight = undefined;
54
+ resolve(sink);
55
+ };
56
+ const timer = setTimeout(() => finish(undefined), TIMEOUT_MS);
57
+ execFile(
58
+ "git",
59
+ ["config", "--get-all", "--show-origin", "credential.helper"],
60
+ { timeout: TIMEOUT_MS },
61
+ (err, stdout) => {
62
+ clearTimeout(timer);
63
+ if (err) return finish(undefined);
64
+ // Lines: "<origin>\t<value>". Prefer the last (most specific) match.
65
+ let sink: CredHelperSink | undefined;
66
+ for (const line of stdout.split("\n")) {
67
+ const idx = line.indexOf("\t");
68
+ if (idx < 0) continue;
69
+ const found = sinkFor(line.slice(idx + 1));
70
+ if (found) sink = found;
71
+ }
72
+ finish(sink);
73
+ },
74
+ );
75
+ });
76
+ return inFlight;
77
+ }
package/index.ts CHANGED
@@ -5,6 +5,7 @@ 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";
8
+ import { detectCredHelperSink } from "./git-helpers";
8
9
  import { redactSecrets } from "./redact";
9
10
  import { handleAuthCommand } from "./commands";
10
11
 
@@ -90,7 +91,8 @@ export default function (pi: ExtensionAPI) {
90
91
  retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
91
92
  const data = loadStore();
92
93
  if (Object.keys(data.accounts).length === 0) return { ...text(notConnected), isError: true };
93
- return text(statusDetail(data));
94
+ const sink = await detectCredHelperSink(); // cached, fail-silent
95
+ return text(statusDetail(data, { credHelperSink: sink }));
94
96
  }
95
97
 
96
98
  case "switch": {
package/keyring.ts CHANGED
@@ -24,7 +24,8 @@
24
24
  * interrupted upsert never destroys the keyring copy; when the
25
25
  * collection is locked and a non-empty secret is being written it
26
26
  * triggers ONE interactive unlock (Service.Unlock + Prompt) and
27
- * waits for it to complete;
27
+ * waits for it to complete (Prompt.Completed signal — a user
28
+ * cancel is detected and reported as such — or timeout);
28
29
  * * delete-only ("clear" / empty secret) never unlocks.
29
30
  * Non-interactive callers (bulk migration/repair at load) pass wait=0 and
30
31
  * get the old instant-fallback behavior.
@@ -85,10 +86,12 @@ collection to actually unlock.
85
86
  - "lookup" on a locked collection returns {"locked": true} and never
86
87
  touches the collection (no prompt);
87
88
  - "upsert" creates the new item (with its secret) FIRST and only then
88
- deletes the previously matched ones kill-safe: an interrupted
89
+ deletes the previously matched ones, each re-verified to still carry
90
+ our exact attributes before deletion — kill-safe: an interrupted
89
91
  upsert never destroys the keyring copy; when locked and a non-empty
90
92
  secret is written it triggers ONE interactive unlock (Service.Unlock
91
- + Prompt.Prompt) and waits for it;
93
+ + Prompt.Prompt) and waits for the Prompt.Completed signal (a user
94
+ cancel is reported as such) or timeout;
92
95
  - delete-only never unlocks.
93
96
  This keeps the client from stacking unlock prompts, which is what makes
94
97
  kded warn "Repeated attempts to access a wallet have occurred".
@@ -131,8 +134,21 @@ def main():
131
134
  try:
132
135
  owner = bus.get_name_owner(SVC)
133
136
  except Exception:
134
- out({"ok": False, "error": "no keyring service on session bus"})
135
- return
137
+ owner = None
138
+ if owner is None:
139
+ # No owner yet — the service may be D-Bus ACTIVATABLE and simply
140
+ # not started: a light Introspect call both triggers activation
141
+ # and confirms presence; NameHasNoOwner on the call = truly
142
+ # absent.
143
+ try:
144
+ dbus.Interface(
145
+ bus.get_object(SVC, "/org/freedesktop/secrets"),
146
+ "org.freedesktop.DBus.Introspectable",
147
+ ).Introspect()
148
+ owner = bus.get_name_owner(SVC)
149
+ except Exception:
150
+ out({"ok": False, "error": "no keyring service on session bus"})
151
+ return
136
152
 
137
153
  svc = bus.get_object(SVC, "/org/freedesktop/secrets")
138
154
  dbusi = dbus.Interface(svc, "org.freedesktop.Secret.Service")
@@ -156,11 +172,20 @@ def main():
156
172
  attrs.get("login", "?"),
157
173
  )
158
174
 
159
- # open a plaintext session
160
- if MODERN:
161
- _o, session = dbusi.OpenSession("none", "")
162
- else:
163
- _o, session = dbusi.OpenSession("plain", "")
175
+ # open a plaintext session. Algorithm support differs per backend
176
+ # (ksecretd historically "plain", gnome-keyring "none"): try the
177
+ # generation's preference first, fall back to the other.
178
+ session = None
179
+ for algo in (("none", "plain") if MODERN else ("plain", "none")):
180
+ try:
181
+ _o, session = dbusi.OpenSession(algo, "")
182
+ break
183
+ except Exception:
184
+ session = None
185
+ if session is None:
186
+ out({"ok": False, "error": "OpenSession failed"})
187
+ return
188
+ if not MODERN:
164
189
  coll = dbusi.ReadAlias("default")
165
190
  if str(coll) == "/":
166
191
  out({"ok": False, "error": "no default collection in keyring"})
@@ -205,14 +230,15 @@ def main():
205
230
  returns (unlocked collections, prompt object path); the dialog
206
231
  is only shown once Prompt.Prompt(window_id) is called on that
207
232
  object — and it is shown by ksecretd itself, so it survives
208
- this process exiting."""
233
+ this process exiting. Returns the prompt object path ("" when
234
+ none), which the caller races against Prompt.Completed."""
209
235
  if MODERN:
210
- return # modern (0.0.1) API has no locking
236
+ return "" # modern (0.0.1) API has no locking
211
237
  try:
212
238
  res = dbusi.Unlock([dbus.ObjectPath(str(coll))])
213
- prompt = str(res[1]) if res and len(res) > 1 else "/"
239
+ prompt = str(res[1]) if res and len(res) > 1 else ""
214
240
  except Exception:
215
- return
241
+ return ""
216
242
  if prompt and prompt != "/":
217
243
  try:
218
244
  piface = dbus.Interface(
@@ -222,8 +248,10 @@ def main():
222
248
  # Empty window id: ksecretd still shows the dialog,
223
249
  # unparented, kept above all windows.
224
250
  piface.Prompt("")
251
+ return prompt
225
252
  except Exception:
226
- pass
253
+ return ""
254
+ return ""
227
255
 
228
256
  def get_content(path):
229
257
  """Return the secret bytes for an item path, or None."""
@@ -253,7 +281,47 @@ def main():
253
281
  except Exception:
254
282
  return None
255
283
 
284
+ def order_paths(paths):
285
+ """Deterministic newest-first order for duplicate items, plus
286
+ dedup. ksecretd names created items "Entry N" (N grows), so
287
+ the LARGEST numeric suffix in the last path segment is the
288
+ most recent; items without a suffix sort by path. Search
289
+ results are unordered — this makes lookup stable."""
290
+ seen, out = set(), []
291
+ for p in paths:
292
+ s = str(p)
293
+ if s not in seen:
294
+ seen.add(s)
295
+ out.append(s)
296
+ def key(p):
297
+ seg = p.rsplit("/", 1)[-1]
298
+ i = len(seg)
299
+ while i > 0 and seg[i - 1].isdigit():
300
+ i -= 1
301
+ num = int(seg[i:]) if i < len(seg) else None
302
+ # newest first: descending numeric suffix, then path
303
+ return (num is None, -(num or 0), p)
304
+ return sorted(out, key=key)
305
+
256
306
  def item_delete(path):
307
+ # Verified delete: never destroy an item that does not (any
308
+ # more) carry our exact attribute set. Modern (0.0.1) items
309
+ # expose Attributes — re-read them before deleting, and on
310
+ # ANY doubt (property gone, mismatch, any error) the item is
311
+ # kept. Legacy (0.0.0) items have no properties, but
312
+ # ksecretd's SearchItems matches (app, platform, login)
313
+ # exactly, so its results are ours by construction.
314
+ if MODERN:
315
+ try:
316
+ a = dict(dbus.Interface(
317
+ bus.get_object(owner, path),
318
+ "org.freedesktop.DBus.Properties",
319
+ ).Get("org.freedesktop.Secret.Item", "Attributes"))
320
+ for k in ("app", "platform", "login"):
321
+ if str(a.get(k)) != str(attrs.get(k)):
322
+ return
323
+ except Exception:
324
+ return
257
325
  try:
258
326
  dbus.Interface(
259
327
  bus.get_object(owner, path),
@@ -286,10 +354,42 @@ def main():
286
354
  # (Service.Unlock + Prompt — ksecretd shows the "KDE
287
355
  # Wallet Service" password dialog) and wait for the
288
356
  # collection to actually unlock.
289
- unlock_with_prompt()
357
+ prompt = unlock_with_prompt()
290
358
  deadline = time.time() + wait
359
+ cancelled = False
291
360
  while is_locked_now() and time.time() < deadline:
292
- time.sleep(1)
361
+ # Poll for Prompt.Completed: it arrives the moment
362
+ # the user acts, and its code distinguishes
363
+ # success (0) from CANCEL — pure polling cannot
364
+ # tell the two apart.
365
+ try:
366
+ msg = bus.recv(timeout=0.25)
367
+ except Exception:
368
+ msg = None
369
+ if msg is not None and prompt:
370
+ try:
371
+ if (
372
+ msg.is_signal_message()
373
+ and msg.get_interface()
374
+ == "org.freedesktop.Secret.Prompt"
375
+ and msg.get_member() == "Completed"
376
+ and str(msg.get_path()) == prompt
377
+ ):
378
+ args = msg.get_args_list()
379
+ cancelled = int(args[0]) != 0
380
+ break
381
+ except Exception:
382
+ pass
383
+ if is_locked_now():
384
+ # Small grace: Completed can arrive a hair before
385
+ # the Locked property flips.
386
+ grace = time.time() + 2
387
+ while is_locked_now() and time.time() < grace:
388
+ time.sleep(0.2)
389
+ if cancelled:
390
+ out({"ok": False, "locked": True,
391
+ "error": "keyring unlock was cancelled"})
392
+ return
293
393
  if is_locked_now():
294
394
  out({"ok": False, "locked": True,
295
395
  "error": "keyring is locked"})
@@ -304,7 +404,8 @@ def main():
304
404
  # then delete the previously matched items. Even if this process
305
405
  # is killed mid-upsert the keyring copy is never destroyed
306
406
  # (worst case: orphan items remain; the next upsert cleans them
307
- # up, and lookups take the first non-empty secret).
407
+ # up, and lookups take the NEWEST non-empty secret
408
+ # deterministic order via order_paths).
308
409
  new_path = ""
309
410
  if MODERN:
310
411
  item = "/org/freedesktop/secrets/0/item/" + re.sub(
@@ -313,36 +414,34 @@ def main():
313
414
  attrs.get("login", "x"),
314
415
  )
315
416
  )
316
- item_props = dbus.Struct((
317
- dbus.ObjectPath(item),
318
- dbus.Dictionary({
319
- "org.freedesktop.Secret.Item.Label": dbus.ByteArray(
320
- label.encode("utf-8")
321
- ),
322
- "org.freedesktop.Secret.Item.Attributes":
323
- dbus.Dictionary(
324
- {k: v for k, v in attrs.items()}, "sv"
325
- ),
326
- }, "sv"),
327
- ))
328
- secret_props = dbus.Struct((
329
- dbus.ObjectPath(item),
330
- dbus.Dictionary({
331
- "org.freedesktop.Secret.Secret.Value": dbus.ByteArray(
332
- secret.encode("utf-8")
417
+ # 0.0.1 Store: items = {item path -> session path},
418
+ # secrets = {item path -> properties}, where the ONE
419
+ # properties map holds BOTH the item properties and the
420
+ # secret properties (per spec). Storing at an existing
421
+ # path UPDATES the item — that is the upsert.
422
+ item_props = dbus.Dictionary({
423
+ "org.freedesktop.Secret.Item.Label": dbus.ByteArray(
424
+ label.encode("utf-8")
425
+ ),
426
+ "org.freedesktop.Secret.Item.Attributes":
427
+ dbus.Dictionary(
428
+ {k: v for k, v in attrs.items()}, "sv"
333
429
  ),
334
- "org.freedesktop.Secret.Secret.Content-Type":
335
- "application/octet-stream",
336
- "org.freedesktop.Secret.Secret.Parameters":
337
- dbus.Dictionary({}, "sv"),
338
- }, "sv"),
339
- ))
430
+ "org.freedesktop.Secret.Secret.Value": dbus.ByteArray(
431
+ secret.encode("utf-8")
432
+ ),
433
+ "org.freedesktop.Secret.Secret.Content-Type":
434
+ "application/octet-stream",
435
+ "org.freedesktop.Secret.Secret.Parameters":
436
+ dbus.Dictionary({}, "sv"),
437
+ }, "sv")
340
438
  dbusi.Store(
341
- dbus.Dictionary({item: ""}, "sv"),
439
+ dbus.Dictionary(
440
+ {item: dbus.ObjectPath(str(session))}, "sv"
441
+ ),
342
442
  dbus.UInt32(0),
343
443
  dbus.Dictionary(
344
- {item: dbus.Struct((item_props, secret_props))},
345
- "sv",
444
+ {item: dbus.Variant(item_props)}, "sv"
346
445
  ),
347
446
  )
348
447
  new_path = item
@@ -394,7 +493,7 @@ def main():
394
493
  "error": "keyring is locked"})
395
494
  return
396
495
  paths, is_locked = find_items()
397
- for path in paths:
496
+ for path in order_paths(paths):
398
497
  content = get_content(path)
399
498
  if content:
400
499
  out({
@@ -475,14 +574,18 @@ function call(req: Record<string, unknown>, timeoutMs = TIMEOUT_MS): WalletRes |
475
574
  }
476
575
  }
477
576
 
478
- let availCache: boolean | null = null;
577
+ let availCache: { ok: boolean; at: number } | null = null;
578
+ /** Re-probe after this long: a keyring service may start late (D-Bus
579
+ * activation, CI, re-login) — a permanently cached "no" would be wrong. */
580
+ const AVAIL_TTL_MS = 60_000;
479
581
 
480
- /** True when a keyring (Secret Service) is reachable. Result is cached. */
582
+ /** True when a keyring (Secret Service) is reachable. Cached (TTL). */
481
583
  export function walletAvailable(): boolean {
482
- if (availCache !== null) return availCache;
584
+ if (availCache !== null && Date.now() - availCache.at < AVAIL_TTL_MS)
585
+ return availCache.ok;
483
586
  const r = call({ cmd: "available" }, 5000);
484
- availCache = !!(r && r.ok);
485
- return availCache;
587
+ availCache = { ok: !!(r && r.ok), at: Date.now() };
588
+ return availCache.ok;
486
589
  }
487
590
 
488
591
  let lastLookupLocked = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-git-auth",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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",