pi-git-auth 1.1.1 → 1.2.3

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.
@@ -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
@@ -138,9 +152,11 @@ available; the extension picks one at load time.
138
152
  ### OS keyring (preferred)
139
153
  The token lives in the session keyring, addressed by the
140
154
  [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.
155
+ (`org.freedesktop.secrets` over D-Bus). Any provider that implements
156
+ that API over D-Bus is targeted: KWallet (via `ksecretd`) is the one
157
+ verified end-to-end (including the interactive unlock dialog); GNOME
158
+ Keyring and KeePassXC are covered by the same generic code path but
159
+ have not been tested.
144
160
 
145
161
  - The embedded python3 client (a small script that `keyring.ts` keeps
146
162
  in sync in the state dir) opens a D-Bus session and talks
@@ -163,18 +179,31 @@ absolute minimum:
163
179
  - **Lookup never prompts.** A read on a locked collection returns
164
180
  `locked` without touching the collection — no stacked prompts, no kded
165
181
  "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
182
+ - **Interactive unlock uses the Secret Service Prompt protocol.**
183
+ `Service.Unlock()` on a locked `ksecretd` collection returns a Prompt
184
+ object; the "KDE Wallet Service" password dialog is only shown once the
185
+ client calls `Prompt.Prompt()` on that object. The client does both:
186
+ trigger the dialog, then poll the collection's `Locked` property until it
187
+ actually opens (or the wait expires).
188
+ - **Store is a single roundtrip** (create the new item + delete any older
189
+ matches, at most one unlock attempt) and only runs when a token actually
168
190
  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)`.
191
+ keyring writes. On an interactive save (login) the client **waits up to
192
+ 20 s for the unlock prompt to be answered** — without the wait the token
193
+ would silently fall back to the file every time the wallet happens to be
194
+ locked.
195
+ - **When the wallet is still locked (or the keyring is otherwise
196
+ unreachable) after the wait**, the token is transparently kept in the
197
+ encrypted file instead of leaving a dead `wallet:v1:` marker the token
198
+ stays available. Once the wallet unlocks, the token moves back into the
199
+ keyring on the next load.
200
+ - If the wallet is locked when pi starts, the in-memory token is empty
201
+ (git auth is disabled meanwhile) and `/auth status` says so explicitly
202
+ instead of showing a bare `(none)`. The lookup is retried automatically
203
+ (throttled, prompt-safe) on the next `/auth status` or git-gate hit, so
204
+ the token recovers as soon as the wallet unlocks — no re-login needed.
205
+ - `/auth status` reports where the active token **actually** lives
206
+ (keyring vs. encrypted file), not just which backend was selected.
178
207
 
179
208
  ### Encrypted file (fallback)
180
209
  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. */
@@ -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:"];
@@ -132,11 +132,26 @@ 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);
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
+ }
140
155
  if (active && activeKey) {
141
156
  lines.push("");
142
157
  lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
@@ -144,6 +159,15 @@ export function statusDetail(data: StoreData): string {
144
159
  lines.push(
145
160
  "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
161
  );
162
+ } else if (active.accessToken) {
163
+ const inKeyring = backendIsKeyring && activeTokenStorage() === "keyring";
164
+ lines.push(
165
+ inKeyring
166
+ ? `Token: ${maskToken(active.accessToken)} (stored in OS keyring)`
167
+ : `Token: ${maskToken(active.accessToken)} (stored in encrypted file${
168
+ backendIsKeyring ? " — wallet was locked at save" : ""
169
+ })`,
170
+ );
147
171
  } else {
148
172
  lines.push(`Token: ${maskToken(active.accessToken)}`);
149
173
  }
package/commands.ts CHANGED
@@ -1,17 +1,20 @@
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";
6
+ import { detectCredHelperSink } from "./git-helpers";
6
7
 
7
8
  type Ctx = ExtensionCommandContext;
8
9
 
9
10
  async function showStatus(ctx: Ctx): Promise<void> {
11
+ retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
10
12
  const data = loadStore();
11
13
  if (Object.keys(data.accounts).length === 0) {
12
14
  ctx.ui.notify("git auth: not connected — run /auth login", "info");
13
15
  } else {
14
- ctx.ui.notify(statusDetail(data), "info");
16
+ const sink = await detectCredHelperSink(); // cached, fail-silent
17
+ ctx.ui.notify(statusDetail(data, { credHelperSink: sink }), "info");
15
18
  }
16
19
  }
17
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
@@ -1,10 +1,11 @@
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";
8
+ import { detectCredHelperSink } from "./git-helpers";
8
9
  import { redactSecrets } from "./redact";
9
10
  import { handleAuthCommand } from "./commands";
10
11
 
@@ -15,6 +16,7 @@ export default function (pi: ExtensionAPI) {
15
16
  // ------------------------------------------------------------------
16
17
  pi.on("tool_call", async (event, ctx) => {
17
18
  if (!isToolCallEventType("bash", event)) return;
19
+ retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
18
20
  const acc = activeAccount(loadStore());
19
21
  if (!acc?.accessToken) return;
20
22
  const host = SERVICES[acc.platform].host;
@@ -86,9 +88,11 @@ export default function (pi: ExtensionAPI) {
86
88
 
87
89
  switch (params.action) {
88
90
  case "status": {
91
+ retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
89
92
  const data = loadStore();
90
93
  if (Object.keys(data.accounts).length === 0) return { ...text(notConnected), isError: true };
91
- return text(statusDetail(data));
94
+ const sink = await detectCredHelperSink(); // cached, fail-silent
95
+ return text(statusDetail(data, { credHelperSink: sink }));
92
96
  }
93
97
 
94
98
  case "switch": {
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.3",
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