pi-git-auth 1.1.0 → 1.1.1

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, 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
 
@@ -154,6 +154,28 @@ provider.
154
154
  - `credentials.json` keeps only a `wallet:v1:<accountKey>` marker per
155
155
  account; the token itself is in the keyring.
156
156
 
157
+ #### KDE Plasma (KWallet / ksecretd)
158
+
159
+ `ksecretd` fires a KWallet unlock dialog for **every** D-Bus operation on
160
+ a locked collection, so the client is built to keep wallet access to the
161
+ absolute minimum:
162
+
163
+ - **Lookup never prompts.** A read on a locked collection returns
164
+ `locked` without touching the collection — no stacked prompts, no kded
165
+ "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
+ 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)`.
178
+
157
179
  ### Encrypted file (fallback)
158
180
  When python3 / D-Bus / a keyring are unavailable (headless server, no
159
181
  session bus), the extension transparently falls back to an on-disk
@@ -178,8 +200,9 @@ reachable, else file), `wallet`, or `file`.
178
200
 
179
201
  - The git gate adds one regex pass per `git` command (same as any string
180
202
  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.
203
+ - The keyring round-trip is one D-Bus exchange per account at load, and a
204
+ single upsert roundtrip **only for accounts whose token changed** at
205
+ write time (a `/auth switch` writes no keyring at all).
183
206
  - GitHub's details view uses a single bounded set of REST calls
184
207
  (repo meta + 5 commits + 1 recursive tree). GitLab's tree is top-level
185
208
  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, 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,21 @@ 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"}`);
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)" : ""}`);
136
138
  if (activeKey) lines.push("");
137
139
  const active = activeKey ? data.accounts[activeKey] : undefined;
138
140
  if (active && activeKey) {
139
141
  lines.push("");
140
142
  lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
141
- lines.push(`Token: ${maskToken(active.accessToken)}`);
143
+ if (!active.accessToken && kwLocked) {
144
+ lines.push(
145
+ "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
+ );
147
+ } else {
148
+ lines.push(`Token: ${maskToken(active.accessToken)}`);
149
+ }
142
150
  if (active.scopes) lines.push(`Scopes: ${active.scopes}`);
143
151
  lines.push(`Saved: ${active.savedAt}`);
144
152
  }
package/keyring.ts CHANGED
@@ -8,11 +8,23 @@
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 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):
16
+ * * "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);
21
+ * * delete-only ("clear" / empty secret) never unlocks.
22
+ *
11
23
  * Two API generations are auto-detected at runtime by introspection:
12
24
  * - modern 0.0.1 (gnome-keyring, kwallet --secretservice):
13
25
  * Service.Store / SearchItems / item.GetSecret
14
26
  * - legacy 0.0.0 (KDE ksecretd, default with KWallet 6):
15
- * Collection.CreateItem / Service.SearchItems / Service.GetSecrets
27
+ * Collection.CreateItem / SearchItems / Service.GetSecrets
16
28
  *
17
29
  * Fallback: when python3/dbus/keyring are unavailable (headless, no D-Bus),
18
30
  * store.ts silently keeps the on-disk AES-encrypted file format.
@@ -26,10 +38,14 @@ export const STATE_DIR = join(homedir(), ".pi", "agent", "pi-git-auth");
26
38
  const PY_PATH = join(STATE_DIR, "wallet-tool.py");
27
39
  const TIMEOUT_MS = 8000;
28
40
 
41
+ /** Outcome of a keyring write: ok, keyring locked, or unreachable. */
42
+ export type WalletResult = "ok" | "locked" | "unreachable";
43
+
29
44
  interface WalletRes {
30
45
  ok: boolean;
31
46
  secret?: string;
32
47
  api?: string;
48
+ locked?: boolean;
33
49
  error?: string;
34
50
  }
35
51
 
@@ -38,12 +54,22 @@ const PY = `#!/usr/bin/env python3
38
54
 
39
55
  Protocol: one JSON request on stdin, one JSON response line on stdout.
40
56
  {"cmd": "available"}
41
- {"cmd": "store", "attrs": {...}, "secret": "..."}
57
+ {"cmd": "upsert", "attrs": {...}, "secret": "..."} # empty secret = delete only
42
58
  {"cmd": "lookup", "attrs": {...}}
43
59
  {"cmd": "clear", "attrs": {...}}
44
60
 
45
61
  Auto-detects the Secret Service API generation (modern 0.0.1 vs legacy
46
62
  0.0.0/ksecretd) by introspecting the service.
63
+
64
+ KWallet/ksecretd (KDE) note: every D-Bus operation on a LOCKED collection
65
+ triggers a KWallet unlock dialog. So:
66
+ - "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;
70
+ - delete-only never unlocks.
71
+ This keeps the client from stacking unlock prompts, which is what makes
72
+ kded warn "Repeated attempts to access a wallet have occurred".
47
73
  """
48
74
  import sys
49
75
  import json
@@ -114,12 +140,12 @@ def main():
114
140
  if str(coll) == "/":
115
141
  out({"ok": False, "error": "no default collection in keyring"})
116
142
  return
117
- try:
118
- dbusi.Unlock([coll])
119
- except Exception:
120
- pass
121
143
 
122
144
  def find_items():
145
+ """Return (item paths in unlocked collections, locked flag).
146
+ legacy ksecretd reports items in LOCKED collections separately;
147
+ touching them would fire KWallet unlock dialogs, so callers
148
+ get the flag instead."""
123
149
  if MODERN:
124
150
  res = dbusi.SearchItems(
125
151
  dbus.UInt32(0),
@@ -128,11 +154,11 @@ def main():
128
154
  ),
129
155
  dbus.ObjectPath("/"),
130
156
  )
131
- return [str(k) for k in res]
132
- (u, _l) = dbusi.SearchItems(dbus.Dictionary(
157
+ return [str(k) for k in res], False
158
+ (u, locked) = dbusi.SearchItems(dbus.Dictionary(
133
159
  {k: v for k, v in attrs.items()}, "ss"
134
160
  ))
135
- return [str(k) for k in list(u) + list(_l)]
161
+ return [str(k) for k in list(u)], bool(locked)
136
162
 
137
163
  def get_content(path):
138
164
  """Return the secret bytes for an item path, or None."""
@@ -171,7 +197,32 @@ def main():
171
197
  except Exception:
172
198
  pass
173
199
 
174
- if cmd == "store":
200
+ if cmd in ("upsert", "store", "clear"):
201
+ paths, is_locked = find_items()
202
+ writing = cmd != "clear" and bool(secret)
203
+ if is_locked:
204
+ if not writing:
205
+ # delete-only on a locked keyring: skip it rather than
206
+ # prompt (best-effort purge; the file is already clean).
207
+ out({"ok": False, "locked": True,
208
+ "error": "keyring is locked"})
209
+ return
210
+ # writing while locked: exactly ONE unlock attempt (one
211
+ # prompt), then re-check.
212
+ try:
213
+ dbusi.Unlock([coll])
214
+ except Exception:
215
+ 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)
223
+ if cmd == "clear" or not secret:
224
+ out({"ok": True})
225
+ return
175
226
  if MODERN:
176
227
  item = "/org/freedesktop/secrets/0/item/" + re.sub(
177
228
  r"[^A-Za-z0-9_]", "_", "%s_%s" % (
@@ -213,7 +264,7 @@ def main():
213
264
  )
214
265
  else:
215
266
  coll_obj = dbus.Interface(
216
- bus.get_object(owner, str(dbusi.ReadAlias("default"))),
267
+ bus.get_object(owner, str(coll)),
217
268
  "org.freedesktop.Secret.Collection",
218
269
  )
219
270
  secret_arg = dbus.Struct((
@@ -236,7 +287,8 @@ def main():
236
287
  return
237
288
 
238
289
  if cmd == "lookup":
239
- for path in find_items():
290
+ paths, is_locked = find_items()
291
+ for path in paths:
240
292
  content = get_content(path)
241
293
  if content:
242
294
  out({
@@ -244,15 +296,15 @@ def main():
244
296
  "secret": content.decode("utf-8", "replace"),
245
297
  })
246
298
  return
299
+ if is_locked:
300
+ # Item exists but its collection is locked (KWallet):
301
+ # report it, don't touch the collection (no unlock prompt).
302
+ out({"ok": False, "locked": True,
303
+ "error": "keyring is locked"})
304
+ return
247
305
  out({"ok": False, "error": "item not found"})
248
306
  return
249
307
 
250
- if cmd == "clear":
251
- for path in find_items():
252
- item_delete(path)
253
- out({"ok": True})
254
- return
255
-
256
308
  out({"ok": False, "error": "unknown command"})
257
309
  except Exception as e:
258
310
  msg = str(e)
@@ -319,20 +371,35 @@ export function walletAvailable(): boolean {
319
371
  return availCache;
320
372
  }
321
373
 
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);
374
+ let lastLookupLocked = false;
375
+
376
+ /**
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.
382
+ */
383
+ export function walletUpsert(attrs: Record<string, string>, secret: string): WalletResult {
384
+ const r = call({ cmd: "upsert", attrs, secret });
385
+ if (!r) return "unreachable";
386
+ if (r.ok) return "ok";
387
+ if (r.locked) return "locked";
388
+ return "unreachable";
326
389
  }
327
390
 
328
- /** Read a secret; null when not found or the keyring is unreachable. */
391
+ /**
392
+ * Read a secret; null when not found, the keyring is locked, or the
393
+ * keyring is unreachable. See walletWasLocked() to distinguish a locked
394
+ * keyring from a missing item.
395
+ */
329
396
  export function walletLookup(attrs: Record<string, string>): string | null {
330
397
  const r = call({ cmd: "lookup", attrs });
398
+ lastLookupLocked = !!(r && r.locked);
331
399
  return r && r.ok ? (r.secret ?? null) : null;
332
400
  }
333
401
 
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);
402
+ /** True when the last lookup failed because the keyring is locked. */
403
+ export function walletWasLocked(): boolean {
404
+ return lastLookupLocked;
338
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-git-auth",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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 } from "./keyring";
7
7
 
8
8
  /**
9
9
  * Credential persistence for pi-git-auth (multi-account, multi-service).
@@ -67,6 +67,12 @@ export interface StoreData {
67
67
 
68
68
  let cache: StoreData | null = null;
69
69
  let keyCache: Buffer | null = null;
70
+ /** Stored strings (markers/envelopes) from the last file read/write. */
71
+ let diskData: StoreData | null = null;
72
+ /** Plaintext tokens as last persisted, per account key ("" = unreadable). */
73
+ let storedPlaintext: Record<string, string> = {};
74
+ /** A `wallet:v1:` token could not be read on load (keyring locked/absent). */
75
+ let keyringUnavailableAtLoad = false;
70
76
 
71
77
  const ENC_PREFIX = "enc:v1:";
72
78
  const WALLET_PREFIX = "wallet:v1:";
@@ -163,22 +169,35 @@ function decryptToken(enc: string): string {
163
169
  // Store API
164
170
  // ---------------------------------------------------------------------------
165
171
 
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.
172
+ /**
173
+ * Persist the in-memory (plaintext) state. Only accounts whose token
174
+ * changed since the last persist are (re)written to the backend; the
175
+ * others keep their on-disk marker/envelope untouched. This matters on
176
+ * KDE/ksecretd: a keyring write is the only operation that may trigger
177
+ * the KWallet unlock dialog, so e.g. `/auth switch` performs zero
178
+ * keyring writes (no prompts, no "repeated wallet access" warnings).
179
+ * When the keyring is locked/unreachable the token is kept in the
180
+ * encrypted file instead of leaving a dead `wallet:v1:` marker behind.
181
+ */
182
+ function persist(data: StoreData, forceStore = false): void {
169
183
  const mode = storeMode();
170
184
  const accounts: Record<string, AccountRecord> = {};
185
+ const nowPlaintext: Record<string, string> = {};
171
186
  for (const [k, a] of Object.entries(data.accounts)) {
187
+ const changed = forceStore || storedPlaintext[k] !== a.accessToken;
188
+ const diskStored = diskData?.accounts[k]?.accessToken;
172
189
  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);
190
+ if (changed && a.accessToken) {
191
+ // Single keyring roundtrip (delete matching items + create).
192
+ const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken) : null;
193
+ stored = r === "ok" ? WALLET_PREFIX + k : encryptToken(a.accessToken);
194
+ } else if (diskStored) {
195
+ stored = diskStored; // unchanged: keep the existing marker/envelope
178
196
  } else {
179
- stored = encryptToken(a.accessToken);
197
+ stored = a.accessToken ? encryptToken(a.accessToken) : "";
180
198
  }
181
199
  accounts[k] = { ...a, accessToken: stored };
200
+ nowPlaintext[k] = a.accessToken;
182
201
  }
183
202
  const out: StoreData = {
184
203
  accounts,
@@ -189,6 +208,9 @@ function persist(data: StoreData): void {
189
208
  writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
190
209
  renameSync(tmp, CREDENTIALS_FILE);
191
210
  chmodSync(CREDENTIALS_FILE, 0o600);
211
+ diskData = out;
212
+ storedPlaintext = nowPlaintext;
213
+ cache = data;
192
214
  }
193
215
 
194
216
  export function loadStore(): StoreData {
@@ -201,6 +223,11 @@ export function loadStore(): StoreData {
201
223
  } catch {
202
224
  raw = {};
203
225
  }
226
+ // Deep snapshot BEFORE absorb() mutates records in place — diskData must
227
+ // keep the on-disk stored strings (markers/envelopes), never the
228
+ // plaintext tokens that absorb decrypts into the same objects.
229
+ diskData = raw.accounts && typeof raw.accounts === "object" ? JSON.parse(JSON.stringify(raw)) : null;
230
+ keyringUnavailableAtLoad = false;
204
231
  const data: StoreData = { accounts: {} };
205
232
  let migrated = false;
206
233
 
@@ -209,7 +236,10 @@ export function loadStore(): StoreData {
209
236
  if (rec.accessToken.startsWith(WALLET_PREFIX)) {
210
237
  const got = walletLookup(recAttrs(rec, key));
211
238
  if (got === null) {
212
- rec.accessToken = ""; // keyring unreachable/cleared: don't crash, don't leak
239
+ // keyring locked/cleared: keep the marker on disk, run this
240
+ // process without the token, and flag it for the status output.
241
+ rec.accessToken = "";
242
+ keyringUnavailableAtLoad = true;
213
243
  } else {
214
244
  rec.accessToken = got;
215
245
  }
@@ -229,6 +259,7 @@ export function loadStore(): StoreData {
229
259
  migrated = true;
230
260
  }
231
261
  data.accounts[key] = rec as AccountRecord;
262
+ storedPlaintext[key] = (rec as AccountRecord).accessToken;
232
263
  };
233
264
 
234
265
  if (raw.accounts && typeof raw.accounts === "object") {
@@ -253,7 +284,7 @@ export function loadStore(): StoreData {
253
284
  // Keep a rollback copy of the pre-migration file (still 0600, no
254
285
  // new secrets — it only contains ciphertexts/markers).
255
286
  if (hadFile && existsSync(CREDENTIALS_FILE)) copyFileSync(CREDENTIALS_FILE, CREDENTIALS_FILE + ".bak");
256
- persist(cache);
287
+ persist(cache, true); // force re-store under the current backend
257
288
  } catch {
258
289
  /* best-effort migration */
259
290
  }
@@ -262,14 +293,20 @@ export function loadStore(): StoreData {
262
293
  }
263
294
 
264
295
  export function saveStore(data: StoreData): void {
265
- cache = data;
266
296
  persist(data);
267
297
  }
268
298
 
299
+ /** True when a `wallet:v1:` token could not be read on load (keyring
300
+ * locked or unreachable) — the in-memory token for that account is "". */
301
+ export function keyringUnavailableAtLoadFlag(): boolean {
302
+ return keyringUnavailableAtLoad;
303
+ }
304
+
269
305
  /** Remove one account's keyring item (idempotent, best-effort). */
270
306
  export function purgeAccountStorage(key: string, rec?: AccountRecord | null): void {
271
307
  try {
272
- if (rec) walletClear(recAttrs(rec, key));
308
+ // Empty secret = delete-only: best-effort, never triggers a prompt.
309
+ if (rec) walletUpsert(recAttrs(rec, key), "");
273
310
  } catch {
274
311
  /* best-effort */
275
312
  }
@@ -280,13 +317,15 @@ export function clearStore(): void {
280
317
  try {
281
318
  if (cache) {
282
319
  for (const [k, rec] of Object.entries(cache.accounts)) {
283
- walletClear(recAttrs(rec, k));
320
+ walletUpsert(recAttrs(rec, k), "");
284
321
  }
285
322
  }
286
323
  } catch {
287
324
  /* best-effort */
288
325
  }
289
326
  cache = { accounts: {} };
327
+ diskData = null;
328
+ storedPlaintext = {};
290
329
  try {
291
330
  rmSync(CREDENTIALS_FILE);
292
331
  } catch {