pi-git-auth 1.1.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -15
- package/auth.ts +24 -5
- package/commands.ts +2 -1
- package/index.ts +3 -1
- package/keyring.ts +154 -35
- package/package.json +1 -1
- package/store.ts +67 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A [pi](https://github.com/badlogic/pi-mono) (coding-agent) extension that
|
|
4
4
|
gives the agent **git forges authentication** for **GitHub and GitLab**: login tokens are stored in the
|
|
5
|
-
OS keyring, switch account, and every `git`command
|
|
5
|
+
OS keyring (kwallet), switch account, and every `git`command
|
|
6
6
|
the agent runs is transparently authenticated with that account's
|
|
7
7
|
token for its host. It also manages accounts and repositories through each
|
|
8
8
|
service's REST API.
|
|
@@ -138,9 +138,11 @@ available; the extension picks one at load time.
|
|
|
138
138
|
### OS keyring (preferred)
|
|
139
139
|
The token lives in the session keyring, addressed by the
|
|
140
140
|
[freedesktop Secret Service API](https://specifications.freedesktop.org/secret-service/)
|
|
141
|
-
(`org.freedesktop.secrets` over D-Bus).
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
(`org.freedesktop.secrets` over D-Bus). Any provider that implements
|
|
142
|
+
that API over D-Bus is targeted: KWallet (via `ksecretd`) is the one
|
|
143
|
+
verified end-to-end (including the interactive unlock dialog); GNOME
|
|
144
|
+
Keyring and KeePassXC are covered by the same generic code path but
|
|
145
|
+
have not been tested.
|
|
144
146
|
|
|
145
147
|
- The embedded python3 client (a small script that `keyring.ts` keeps
|
|
146
148
|
in sync in the state dir) opens a D-Bus session and talks
|
|
@@ -163,18 +165,31 @@ absolute minimum:
|
|
|
163
165
|
- **Lookup never prompts.** A read on a locked collection returns
|
|
164
166
|
`locked` without touching the collection — no stacked prompts, no kded
|
|
165
167
|
"Repeated attempts to access a wallet have occurred" warning.
|
|
166
|
-
- **
|
|
167
|
-
|
|
168
|
+
- **Interactive unlock uses the Secret Service Prompt protocol.**
|
|
169
|
+
`Service.Unlock()` on a locked `ksecretd` collection returns a Prompt
|
|
170
|
+
object; the "KDE Wallet Service" password dialog is only shown once the
|
|
171
|
+
client calls `Prompt.Prompt()` on that object. The client does both:
|
|
172
|
+
trigger the dialog, then poll the collection's `Locked` property until it
|
|
173
|
+
actually opens (or the wait expires).
|
|
174
|
+
- **Store is a single roundtrip** (create the new item + delete any older
|
|
175
|
+
matches, at most one unlock attempt) and only runs when a token actually
|
|
168
176
|
changed. `/auth switch`, `status`, and plain git use perform **zero**
|
|
169
|
-
keyring writes.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
keyring writes. On an interactive save (login) the client **waits up to
|
|
178
|
+
20 s for the unlock prompt to be answered** — without the wait the token
|
|
179
|
+
would silently fall back to the file every time the wallet happens to be
|
|
180
|
+
locked.
|
|
181
|
+
- **When the wallet is still locked (or the keyring is otherwise
|
|
182
|
+
unreachable) after the wait**, the token is transparently kept in the
|
|
183
|
+
encrypted file instead of leaving a dead `wallet:v1:` marker — the token
|
|
184
|
+
stays available. Once the wallet unlocks, the token moves back into the
|
|
185
|
+
keyring on the next load.
|
|
186
|
+
- If the wallet is locked when pi starts, the in-memory token is empty
|
|
187
|
+
(git auth is disabled meanwhile) and `/auth status` says so explicitly
|
|
188
|
+
instead of showing a bare `(none)`. The lookup is retried automatically
|
|
189
|
+
(throttled, prompt-safe) on the next `/auth status` or git-gate hit, so
|
|
190
|
+
the token recovers as soon as the wallet unlocks — no re-login needed.
|
|
191
|
+
- `/auth status` reports where the active token **actually** lives
|
|
192
|
+
(keyring vs. encrypted file), not just which backend was selected.
|
|
178
193
|
|
|
179
194
|
### Encrypted file (fallback)
|
|
180
195
|
When python3 / D-Bus / a keyring are unavailable (headless server, no
|
package/auth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, keyringUnavailableAtLoadFlag, type StoreData } from "./store";
|
|
2
|
+
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, activeTokenStorage, keyringUnavailableAtLoadFlag, type StoreData } from "./store";
|
|
3
3
|
import { SERVICES, type Platform, type Service } from "./forge";
|
|
4
4
|
|
|
5
5
|
/** The subset of ctx.ui the auth flows need. */
|
|
@@ -132,11 +132,21 @@ export function statusDetail(data: StoreData): string {
|
|
|
132
132
|
}
|
|
133
133
|
const activeKey = data.activeLogin;
|
|
134
134
|
if (activeKey) lines.push("");
|
|
135
|
-
const
|
|
136
|
-
const kwLocked =
|
|
137
|
-
lines.push(`Store: ${backend}${kwLocked ? " (locked/unreachable on load)" : ""}`);
|
|
138
|
-
if (activeKey) lines.push("");
|
|
135
|
+
const backendIsKeyring = storeBackend() === "keyring";
|
|
136
|
+
const kwLocked = backendIsKeyring && keyringUnavailableAtLoadFlag();
|
|
139
137
|
const active = activeKey ? data.accounts[activeKey] : undefined;
|
|
138
|
+
let storeLine = backendIsKeyring
|
|
139
|
+
? "Store: OS keyring (Secret Service)"
|
|
140
|
+
: "Store: encrypted file (fallback)";
|
|
141
|
+
if (!backendIsKeyring) {
|
|
142
|
+
storeLine +=
|
|
143
|
+
process.env.PI_GIT_AUTH_STORE?.toLowerCase() === "file"
|
|
144
|
+
? " — forced by PI_GIT_AUTH_STORE=file"
|
|
145
|
+
: " — keyring unavailable";
|
|
146
|
+
} else if (!active?.accessToken && kwLocked) {
|
|
147
|
+
storeLine += " (wallet locked on load — token unavailable)";
|
|
148
|
+
}
|
|
149
|
+
lines.push(storeLine);
|
|
140
150
|
if (active && activeKey) {
|
|
141
151
|
lines.push("");
|
|
142
152
|
lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
|
|
@@ -144,6 +154,15 @@ export function statusDetail(data: StoreData): string {
|
|
|
144
154
|
lines.push(
|
|
145
155
|
"Token: (none) — keyring locked: the token is stored in the keyring and is available again once the wallet unlocks (git auth is disabled meanwhile)"
|
|
146
156
|
);
|
|
157
|
+
} else if (active.accessToken) {
|
|
158
|
+
const inKeyring = backendIsKeyring && activeTokenStorage() === "keyring";
|
|
159
|
+
lines.push(
|
|
160
|
+
inKeyring
|
|
161
|
+
? `Token: ${maskToken(active.accessToken)} (stored in OS keyring)`
|
|
162
|
+
: `Token: ${maskToken(active.accessToken)} (stored in encrypted file${
|
|
163
|
+
backendIsKeyring ? " — wallet was locked at save" : ""
|
|
164
|
+
})`,
|
|
165
|
+
);
|
|
147
166
|
} else {
|
|
148
167
|
lines.push(`Token: ${maskToken(active.accessToken)}`);
|
|
149
168
|
}
|
package/commands.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadStore, activeAccount, type StoreData } from "./store";
|
|
2
|
+
import { loadStore, activeAccount, retryKeyringLoad, type StoreData } from "./store";
|
|
3
3
|
import { SERVICES, type Service, type TreeEntry } from "./forge";
|
|
4
4
|
import { buildDetailsText, RepoDetailsPanel } from "./details";
|
|
5
5
|
import { activeService, accountName, loginWithPastedToken, removeAccount, setActiveAccount, statusDetail } from "./auth";
|
|
@@ -7,6 +7,7 @@ import { activeService, accountName, loginWithPastedToken, removeAccount, setAct
|
|
|
7
7
|
type Ctx = ExtensionCommandContext;
|
|
8
8
|
|
|
9
9
|
async function showStatus(ctx: Ctx): Promise<void> {
|
|
10
|
+
retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
|
|
10
11
|
const data = loadStore();
|
|
11
12
|
if (Object.keys(data.accounts).length === 0) {
|
|
12
13
|
ctx.ui.notify("git auth: not connected — run /auth login", "info");
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { loadStore, activeAccount } from "./store";
|
|
4
|
+
import { loadStore, activeAccount, retryKeyringLoad } from "./store";
|
|
5
5
|
import { SERVICES } from "./forge";
|
|
6
6
|
import { findAccounts, setActiveAccount, statusDetail } from "./auth";
|
|
7
7
|
import { instrumentGit } from "./git-gate";
|
|
@@ -15,6 +15,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15
15
|
// ------------------------------------------------------------------
|
|
16
16
|
pi.on("tool_call", async (event, ctx) => {
|
|
17
17
|
if (!isToolCallEventType("bash", event)) return;
|
|
18
|
+
retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
|
|
18
19
|
const acc = activeAccount(loadStore());
|
|
19
20
|
if (!acc?.accessToken) return;
|
|
20
21
|
const host = SERVICES[acc.platform].host;
|
|
@@ -86,6 +87,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
86
87
|
|
|
87
88
|
switch (params.action) {
|
|
88
89
|
case "status": {
|
|
90
|
+
retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
|
|
89
91
|
const data = loadStore();
|
|
90
92
|
if (Object.keys(data.accounts).length === 0) return { ...text(notConnected), isError: true };
|
|
91
93
|
return text(statusDetail(data));
|
package/keyring.ts
CHANGED
|
@@ -9,16 +9,25 @@
|
|
|
9
9
|
* a process argument list — only in the parent process's memory.
|
|
10
10
|
*
|
|
11
11
|
* KWallet/ksecretd (KDE Plasma) notes:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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": "..."
|
|
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:
|
|
65
|
-
|
|
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"
|
|
69
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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:
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "pi coding-agent extension: git auth for GitHub and GitLab: keyring-stored login tokens, account switching, transparent git auth, repo list/create with details overlay",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Carlo Onofrio",
|
package/store.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
5
5
|
import type { Platform } from "./forge";
|
|
6
|
-
import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs } from "./keyring";
|
|
6
|
+
import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs, UNLOCK_WAIT_S } from "./keyring";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Credential persistence for pi-git-auth (multi-account, multi-service).
|
|
@@ -20,6 +20,15 @@ import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs } f
|
|
|
20
20
|
*
|
|
21
21
|
* Migration is transparent: legacy plaintext or `enc:v1:` tokens are moved
|
|
22
22
|
* into the keyring on first load (a `.bak` copy of the file is kept).
|
|
23
|
+
* This is also the self-heal: a token kept in the file after a locked
|
|
24
|
+
* wallet goes back to the keyring on the next load while it is reachable
|
|
25
|
+
* (load-time writes use wait=0, so a still-locked wallet never delays
|
|
26
|
+
* startup).
|
|
27
|
+
*
|
|
28
|
+
* If the keyring is locked on load, the token for `wallet:v1:` accounts
|
|
29
|
+
* is "" for this process but is retried (throttled, prompt-safe) via
|
|
30
|
+
* retryKeyringLoad() on the next status/git-gate hit, so it recovers
|
|
31
|
+
* automatically once the wallet unlocks.
|
|
23
32
|
*
|
|
24
33
|
* Env override: PI_GIT_AUTH_STORE = auto (default) | wallet | file
|
|
25
34
|
*
|
|
@@ -73,6 +82,10 @@ let diskData: StoreData | null = null;
|
|
|
73
82
|
let storedPlaintext: Record<string, string> = {};
|
|
74
83
|
/** A `wallet:v1:` token could not be read on load (keyring locked/absent). */
|
|
75
84
|
let keyringUnavailableAtLoad = false;
|
|
85
|
+
/** Accounts with a `wallet:v1:` marker that failed to read on load. */
|
|
86
|
+
let loadFailedKeys: string[] = [];
|
|
87
|
+
let lastRetryAt = 0;
|
|
88
|
+
const RETRY_THROTTLE_MS = 10_000;
|
|
76
89
|
|
|
77
90
|
const ENC_PREFIX = "enc:v1:";
|
|
78
91
|
const WALLET_PREFIX = "wallet:v1:";
|
|
@@ -178,8 +191,13 @@ function decryptToken(enc: string): string {
|
|
|
178
191
|
* keyring writes (no prompts, no "repeated wallet access" warnings).
|
|
179
192
|
* When the keyring is locked/unreachable the token is kept in the
|
|
180
193
|
* encrypted file instead of leaving a dead `wallet:v1:` marker behind.
|
|
194
|
+
*
|
|
195
|
+
* `waitSec` = how long a keyring write may wait for an interactive
|
|
196
|
+
* wallet unlock. Interactive callers (login) pass UNLOCK_WAIT_S so the
|
|
197
|
+
* user can answer the KWallet prompt; load-time migration/repair passes
|
|
198
|
+
* 0 so a locked wallet never delays startup.
|
|
181
199
|
*/
|
|
182
|
-
function persist(data: StoreData, forceStore = false): void {
|
|
200
|
+
function persist(data: StoreData, forceStore = false, waitSec = 0): void {
|
|
183
201
|
const mode = storeMode();
|
|
184
202
|
const accounts: Record<string, AccountRecord> = {};
|
|
185
203
|
const nowPlaintext: Record<string, string> = {};
|
|
@@ -189,7 +207,7 @@ function persist(data: StoreData, forceStore = false): void {
|
|
|
189
207
|
let stored: string;
|
|
190
208
|
if (changed && a.accessToken) {
|
|
191
209
|
// Single keyring roundtrip (delete matching items + create).
|
|
192
|
-
const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken) : null;
|
|
210
|
+
const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken, waitSec) : null;
|
|
193
211
|
stored = r === "ok" ? WALLET_PREFIX + k : encryptToken(a.accessToken);
|
|
194
212
|
} else if (diskStored) {
|
|
195
213
|
stored = diskStored; // unchanged: keep the existing marker/envelope
|
|
@@ -228,6 +246,7 @@ export function loadStore(): StoreData {
|
|
|
228
246
|
// plaintext tokens that absorb decrypts into the same objects.
|
|
229
247
|
diskData = raw.accounts && typeof raw.accounts === "object" ? JSON.parse(JSON.stringify(raw)) : null;
|
|
230
248
|
keyringUnavailableAtLoad = false;
|
|
249
|
+
loadFailedKeys = [];
|
|
231
250
|
const data: StoreData = { accounts: {} };
|
|
232
251
|
let migrated = false;
|
|
233
252
|
|
|
@@ -237,9 +256,11 @@ export function loadStore(): StoreData {
|
|
|
237
256
|
const got = walletLookup(recAttrs(rec, key));
|
|
238
257
|
if (got === null) {
|
|
239
258
|
// keyring locked/cleared: keep the marker on disk, run this
|
|
240
|
-
// process without the token, and flag it for the status output
|
|
259
|
+
// process without the token, and flag it for the status output
|
|
260
|
+
// and for retryKeyringLoad().
|
|
241
261
|
rec.accessToken = "";
|
|
242
262
|
keyringUnavailableAtLoad = true;
|
|
263
|
+
loadFailedKeys.push(key);
|
|
243
264
|
} else {
|
|
244
265
|
rec.accessToken = got;
|
|
245
266
|
}
|
|
@@ -292,8 +313,49 @@ export function loadStore(): StoreData {
|
|
|
292
313
|
return cache;
|
|
293
314
|
}
|
|
294
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Persist after an interactive change (login): keyring writes may wait
|
|
318
|
+
* up to UNLOCK_WAIT_S for the user to answer the wallet unlock prompt.
|
|
319
|
+
*/
|
|
295
320
|
export function saveStore(data: StoreData): void {
|
|
296
|
-
persist(data);
|
|
321
|
+
persist(data, false, UNLOCK_WAIT_S);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Lazy recovery for tokens that could not be read on load (keyring
|
|
326
|
+
* locked): retry the lookup, throttled. Prompt-safe — a lookup never
|
|
327
|
+
* touches a locked collection, so this cannot stack unlock prompts.
|
|
328
|
+
* No-op once nothing is pending. Call it from hot paths (git gate,
|
|
329
|
+
* /auth status) so the token recovers as soon as the wallet unlocks.
|
|
330
|
+
*/
|
|
331
|
+
export function retryKeyringLoad(): void {
|
|
332
|
+
if (loadFailedKeys.length === 0) return;
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
if (now - lastRetryAt < RETRY_THROTTLE_MS) return;
|
|
335
|
+
lastRetryAt = now;
|
|
336
|
+
const data = cache;
|
|
337
|
+
if (!data) return;
|
|
338
|
+
for (const k of [...loadFailedKeys]) {
|
|
339
|
+
const rec = data.accounts[k];
|
|
340
|
+
if (!rec || rec.accessToken) {
|
|
341
|
+
loadFailedKeys = loadFailedKeys.filter((x) => x !== k);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const got = walletLookup(recAttrs(rec, k));
|
|
345
|
+
if (got !== null) {
|
|
346
|
+
rec.accessToken = got;
|
|
347
|
+
storedPlaintext[k] = got;
|
|
348
|
+
loadFailedKeys = loadFailedKeys.filter((x) => x !== k);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (loadFailedKeys.length === 0) keyringUnavailableAtLoad = false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Where the active account's token actually lives on disk right now. */
|
|
355
|
+
export function activeTokenStorage(): "keyring" | "file" {
|
|
356
|
+
const key = cache?.activeLogin;
|
|
357
|
+
const stored = key ? diskData?.accounts[key]?.accessToken : undefined;
|
|
358
|
+
return stored?.startsWith(WALLET_PREFIX) ? "keyring" : "file";
|
|
297
359
|
}
|
|
298
360
|
|
|
299
361
|
/** True when a `wallet:v1:` token could not be read on load (keyring
|