pi-git-auth 1.1.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -7
- package/auth.ts +31 -4
- package/commands.ts +2 -1
- package/index.ts +3 -1
- package/keyring.ts +215 -29
- package/package.json +1 -1
- package/store.ts +117 -16
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,
|
|
6
|
-
|
|
5
|
+
OS keyring (kwallet), switch account, and every `git`command
|
|
6
|
+
the agent runs is transparently authenticated with that account's
|
|
7
7
|
token for its host. It also manages accounts and repositories through each
|
|
8
8
|
service's REST API.
|
|
9
9
|
|
|
@@ -138,9 +138,11 @@ available; the extension picks one at load time.
|
|
|
138
138
|
### OS keyring (preferred)
|
|
139
139
|
The token lives in the session keyring, addressed by the
|
|
140
140
|
[freedesktop Secret Service API](https://specifications.freedesktop.org/secret-service/)
|
|
141
|
-
(`org.freedesktop.secrets` over D-Bus).
|
|
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
|
|
@@ -154,6 +156,41 @@ provider.
|
|
|
154
156
|
- `credentials.json` keeps only a `wallet:v1:<accountKey>` marker per
|
|
155
157
|
account; the token itself is in the keyring.
|
|
156
158
|
|
|
159
|
+
#### KDE Plasma (KWallet / ksecretd)
|
|
160
|
+
|
|
161
|
+
`ksecretd` fires a KWallet unlock dialog for **every** D-Bus operation on
|
|
162
|
+
a locked collection, so the client is built to keep wallet access to the
|
|
163
|
+
absolute minimum:
|
|
164
|
+
|
|
165
|
+
- **Lookup never prompts.** A read on a locked collection returns
|
|
166
|
+
`locked` without touching the collection — no stacked prompts, no kded
|
|
167
|
+
"Repeated attempts to access a wallet have occurred" warning.
|
|
168
|
+
- **Interactive unlock uses the Secret Service Prompt protocol.**
|
|
169
|
+
`Service.Unlock()` on a locked `ksecretd` collection returns a Prompt
|
|
170
|
+
object; the "KDE Wallet Service" password dialog is only shown once the
|
|
171
|
+
client calls `Prompt.Prompt()` on that object. The client does both:
|
|
172
|
+
trigger the dialog, then poll the collection's `Locked` property until it
|
|
173
|
+
actually opens (or the wait expires).
|
|
174
|
+
- **Store is a single roundtrip** (create the new item + delete any older
|
|
175
|
+
matches, at most one unlock attempt) and only runs when a token actually
|
|
176
|
+
changed. `/auth switch`, `status`, and plain git use perform **zero**
|
|
177
|
+
keyring writes. On an interactive save (login) the client **waits up to
|
|
178
|
+
20 s for the unlock prompt to be answered** — without the wait the token
|
|
179
|
+
would silently fall back to the file every time the wallet happens to be
|
|
180
|
+
locked.
|
|
181
|
+
- **When the wallet is still locked (or the keyring is otherwise
|
|
182
|
+
unreachable) after the wait**, the token is transparently kept in the
|
|
183
|
+
encrypted file instead of leaving a dead `wallet:v1:` marker — the token
|
|
184
|
+
stays available. Once the wallet unlocks, the token moves back into the
|
|
185
|
+
keyring on the next load.
|
|
186
|
+
- If the wallet is locked when pi starts, the in-memory token is empty
|
|
187
|
+
(git auth is disabled meanwhile) and `/auth status` says so explicitly
|
|
188
|
+
instead of showing a bare `(none)`. The lookup is retried automatically
|
|
189
|
+
(throttled, prompt-safe) on the next `/auth status` or git-gate hit, so
|
|
190
|
+
the token recovers as soon as the wallet unlocks — no re-login needed.
|
|
191
|
+
- `/auth status` reports where the active token **actually** lives
|
|
192
|
+
(keyring vs. encrypted file), not just which backend was selected.
|
|
193
|
+
|
|
157
194
|
### Encrypted file (fallback)
|
|
158
195
|
When python3 / D-Bus / a keyring are unavailable (headless server, no
|
|
159
196
|
session bus), the extension transparently falls back to an on-disk
|
|
@@ -178,8 +215,9 @@ reachable, else file), `wallet`, or `file`.
|
|
|
178
215
|
|
|
179
216
|
- The git gate adds one regex pass per `git` command (same as any string
|
|
180
217
|
rewrite) and no network calls; login adds one TUI prompt.
|
|
181
|
-
- The keyring round-trip is one D-Bus exchange per account
|
|
182
|
-
|
|
218
|
+
- The keyring round-trip is one D-Bus exchange per account at load, and a
|
|
219
|
+
single upsert roundtrip **only for accounts whose token changed** at
|
|
220
|
+
write time (a `/auth switch` writes no keyring at all).
|
|
183
221
|
- GitHub's details view uses a single bounded set of REST calls
|
|
184
222
|
(repo meta + 5 commits + 1 recursive tree). GitLab's tree is top-level
|
|
185
223
|
only (its API does not recurse), bounded to 100 entries.
|
package/auth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, type StoreData } from "./store";
|
|
2
|
+
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, activeTokenStorage, keyringUnavailableAtLoadFlag, type StoreData } from "./store";
|
|
3
3
|
import { SERVICES, type Platform, type Service } from "./forge";
|
|
4
4
|
|
|
5
5
|
/** The subset of ctx.ui the auth flows need. */
|
|
@@ -132,13 +132,40 @@ export function statusDetail(data: StoreData): string {
|
|
|
132
132
|
}
|
|
133
133
|
const activeKey = data.activeLogin;
|
|
134
134
|
if (activeKey) lines.push("");
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
const backendIsKeyring = storeBackend() === "keyring";
|
|
136
|
+
const kwLocked = backendIsKeyring && keyringUnavailableAtLoadFlag();
|
|
137
137
|
const active = activeKey ? data.accounts[activeKey] : undefined;
|
|
138
|
+
let storeLine = backendIsKeyring
|
|
139
|
+
? "Store: OS keyring (Secret Service)"
|
|
140
|
+
: "Store: encrypted file (fallback)";
|
|
141
|
+
if (!backendIsKeyring) {
|
|
142
|
+
storeLine +=
|
|
143
|
+
process.env.PI_GIT_AUTH_STORE?.toLowerCase() === "file"
|
|
144
|
+
? " — forced by PI_GIT_AUTH_STORE=file"
|
|
145
|
+
: " — keyring unavailable";
|
|
146
|
+
} else if (!active?.accessToken && kwLocked) {
|
|
147
|
+
storeLine += " (wallet locked on load — token unavailable)";
|
|
148
|
+
}
|
|
149
|
+
lines.push(storeLine);
|
|
138
150
|
if (active && activeKey) {
|
|
139
151
|
lines.push("");
|
|
140
152
|
lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
|
|
141
|
-
|
|
153
|
+
if (!active.accessToken && kwLocked) {
|
|
154
|
+
lines.push(
|
|
155
|
+
"Token: (none) — keyring locked: the token is stored in the keyring and is available again once the wallet unlocks (git auth is disabled meanwhile)"
|
|
156
|
+
);
|
|
157
|
+
} else if (active.accessToken) {
|
|
158
|
+
const inKeyring = backendIsKeyring && activeTokenStorage() === "keyring";
|
|
159
|
+
lines.push(
|
|
160
|
+
inKeyring
|
|
161
|
+
? `Token: ${maskToken(active.accessToken)} (stored in OS keyring)`
|
|
162
|
+
: `Token: ${maskToken(active.accessToken)} (stored in encrypted file${
|
|
163
|
+
backendIsKeyring ? " — wallet was locked at save" : ""
|
|
164
|
+
})`,
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
lines.push(`Token: ${maskToken(active.accessToken)}`);
|
|
168
|
+
}
|
|
142
169
|
if (active.scopes) lines.push(`Scopes: ${active.scopes}`);
|
|
143
170
|
lines.push(`Saved: ${active.savedAt}`);
|
|
144
171
|
}
|
package/commands.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadStore, activeAccount, type StoreData } from "./store";
|
|
2
|
+
import { loadStore, activeAccount, retryKeyringLoad, type StoreData } from "./store";
|
|
3
3
|
import { SERVICES, type Service, type TreeEntry } from "./forge";
|
|
4
4
|
import { buildDetailsText, RepoDetailsPanel } from "./details";
|
|
5
5
|
import { activeService, accountName, loginWithPastedToken, removeAccount, setActiveAccount, statusDetail } from "./auth";
|
|
@@ -7,6 +7,7 @@ import { activeService, accountName, loginWithPastedToken, removeAccount, setAct
|
|
|
7
7
|
type Ctx = ExtensionCommandContext;
|
|
8
8
|
|
|
9
9
|
async function showStatus(ctx: Ctx): Promise<void> {
|
|
10
|
+
retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
|
|
10
11
|
const data = loadStore();
|
|
11
12
|
if (Object.keys(data.accounts).length === 0) {
|
|
12
13
|
ctx.ui.notify("git auth: not connected — run /auth login", "info");
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { loadStore, activeAccount } from "./store";
|
|
4
|
+
import { loadStore, activeAccount, retryKeyringLoad } from "./store";
|
|
5
5
|
import { SERVICES } from "./forge";
|
|
6
6
|
import { findAccounts, setActiveAccount, statusDetail } from "./auth";
|
|
7
7
|
import { instrumentGit } from "./git-gate";
|
|
@@ -15,6 +15,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15
15
|
// ------------------------------------------------------------------
|
|
16
16
|
pi.on("tool_call", async (event, ctx) => {
|
|
17
17
|
if (!isToolCallEventType("bash", event)) return;
|
|
18
|
+
retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
|
|
18
19
|
const acc = activeAccount(loadStore());
|
|
19
20
|
if (!acc?.accessToken) return;
|
|
20
21
|
const host = SERVICES[acc.platform].host;
|
|
@@ -86,6 +87,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
86
87
|
|
|
87
88
|
switch (params.action) {
|
|
88
89
|
case "status": {
|
|
90
|
+
retryKeyringLoad(); // prompt-safe: recovers the token once the wallet unlocks
|
|
89
91
|
const data = loadStore();
|
|
90
92
|
if (Object.keys(data.accounts).length === 0) return { ...text(notConnected), isError: true };
|
|
91
93
|
return text(statusDetail(data));
|
package/keyring.ts
CHANGED
|
@@ -8,11 +8,32 @@
|
|
|
8
8
|
* It talks JSON over stdin/stdout, which means the secret NEVER appears in
|
|
9
9
|
* a process argument list — only in the parent process's memory.
|
|
10
10
|
*
|
|
11
|
+
* KWallet/ksecretd (KDE Plasma) notes:
|
|
12
|
+
* ksecretd implements the Secret Service "Prompt" protocol: calling
|
|
13
|
+
* Service.Unlock() on a locked collection returns a Prompt object, and
|
|
14
|
+
* the unlock dialog (the real "KDE Wallet Service" password dialog,
|
|
15
|
+
* shown by ksecretd itself) only appears when the client then calls
|
|
16
|
+
* Prompt.Prompt(window_id) on it. We do exactly that (with an empty
|
|
17
|
+
* window id — headless) and then wait `wait` seconds (default 20) for
|
|
18
|
+
* the collection to actually unlock:
|
|
19
|
+
* * "lookup" on a locked collection returns {"locked": true} and
|
|
20
|
+
* never touches the collection (no prompt — reads happen in the
|
|
21
|
+
* background during git operations);
|
|
22
|
+
* * "upsert" creates the new item (with its secret) FIRST and only
|
|
23
|
+
* then deletes the previously matched ones — kill-safe: an
|
|
24
|
+
* interrupted upsert never destroys the keyring copy; when the
|
|
25
|
+
* collection is locked and a non-empty secret is being written it
|
|
26
|
+
* triggers ONE interactive unlock (Service.Unlock + Prompt) and
|
|
27
|
+
* waits for it to complete;
|
|
28
|
+
* * delete-only ("clear" / empty secret) never unlocks.
|
|
29
|
+
* Non-interactive callers (bulk migration/repair at load) pass wait=0 and
|
|
30
|
+
* get the old instant-fallback behavior.
|
|
31
|
+
*
|
|
11
32
|
* Two API generations are auto-detected at runtime by introspection:
|
|
12
33
|
* - modern 0.0.1 (gnome-keyring, kwallet --secretservice):
|
|
13
34
|
* Service.Store / SearchItems / item.GetSecret
|
|
14
35
|
* - legacy 0.0.0 (KDE ksecretd, default with KWallet 6):
|
|
15
|
-
* Collection.CreateItem /
|
|
36
|
+
* Collection.CreateItem / SearchItems / Service.GetSecrets
|
|
16
37
|
*
|
|
17
38
|
* Fallback: when python3/dbus/keyring are unavailable (headless, no D-Bus),
|
|
18
39
|
* store.ts silently keeps the on-disk AES-encrypted file format.
|
|
@@ -25,11 +46,19 @@ import { join } from "node:path";
|
|
|
25
46
|
export const STATE_DIR = join(homedir(), ".pi", "agent", "pi-git-auth");
|
|
26
47
|
const PY_PATH = join(STATE_DIR, "wallet-tool.py");
|
|
27
48
|
const TIMEOUT_MS = 8000;
|
|
49
|
+
/** Default seconds to wait for an interactive wallet unlock on write. */
|
|
50
|
+
export const UNLOCK_WAIT_S = 20;
|
|
51
|
+
/** Upserts may block on an interactive unlock: give the wait room. */
|
|
52
|
+
const UPSERT_TIMEOUT_MS = (UNLOCK_WAIT_S + 15) * 1000;
|
|
53
|
+
|
|
54
|
+
/** Outcome of a keyring write: ok, keyring locked, or unreachable. */
|
|
55
|
+
export type WalletResult = "ok" | "locked" | "unreachable";
|
|
28
56
|
|
|
29
57
|
interface WalletRes {
|
|
30
58
|
ok: boolean;
|
|
31
59
|
secret?: string;
|
|
32
60
|
api?: string;
|
|
61
|
+
locked?: boolean;
|
|
33
62
|
error?: string;
|
|
34
63
|
}
|
|
35
64
|
|
|
@@ -38,16 +67,38 @@ const PY = `#!/usr/bin/env python3
|
|
|
38
67
|
|
|
39
68
|
Protocol: one JSON request on stdin, one JSON response line on stdout.
|
|
40
69
|
{"cmd": "available"}
|
|
41
|
-
{"cmd": "
|
|
70
|
+
{"cmd": "upsert", "attrs": {...}, "secret": "...", "wait": 20}
|
|
71
|
+
# empty secret = delete only; wait = seconds to wait for an
|
|
72
|
+
# interactive unlock while writing (0 = never wait)
|
|
42
73
|
{"cmd": "lookup", "attrs": {...}}
|
|
43
74
|
{"cmd": "clear", "attrs": {...}}
|
|
44
75
|
|
|
45
76
|
Auto-detects the Secret Service API generation (modern 0.0.1 vs legacy
|
|
46
77
|
0.0.0/ksecretd) by introspecting the service.
|
|
78
|
+
|
|
79
|
+
KWallet/ksecretd (KDE) note: ksecretd implements the Secret Service Prompt
|
|
80
|
+
protocol. Service.Unlock() on a locked collection returns a Prompt object;
|
|
81
|
+
the "KDE Wallet Service" password dialog (shown by ksecretd itself) only
|
|
82
|
+
appears once the client calls Prompt.Prompt() on it. We do that (empty
|
|
83
|
+
window id — we are headless) and then wait "wait" seconds for the
|
|
84
|
+
collection to actually unlock.
|
|
85
|
+
- "lookup" on a locked collection returns {"locked": true} and never
|
|
86
|
+
touches the collection (no prompt);
|
|
87
|
+
- "upsert" creates the new item (with its secret) FIRST and only then
|
|
88
|
+
deletes the previously matched ones — kill-safe: an interrupted
|
|
89
|
+
upsert never destroys the keyring copy; when locked and a non-empty
|
|
90
|
+
secret is written it triggers ONE interactive unlock (Service.Unlock
|
|
91
|
+
+ Prompt.Prompt) and waits for it;
|
|
92
|
+
- delete-only never unlocks.
|
|
93
|
+
This keeps the client from stacking unlock prompts, which is what makes
|
|
94
|
+
kded warn "Repeated attempts to access a wallet have occurred".
|
|
47
95
|
"""
|
|
48
96
|
import sys
|
|
49
97
|
import json
|
|
50
98
|
import re
|
|
99
|
+
import time
|
|
100
|
+
|
|
101
|
+
UNLOCK_WAIT_S = 20.0
|
|
51
102
|
|
|
52
103
|
|
|
53
104
|
def out(obj):
|
|
@@ -114,12 +165,12 @@ def main():
|
|
|
114
165
|
if str(coll) == "/":
|
|
115
166
|
out({"ok": False, "error": "no default collection in keyring"})
|
|
116
167
|
return
|
|
117
|
-
try:
|
|
118
|
-
dbusi.Unlock([coll])
|
|
119
|
-
except Exception:
|
|
120
|
-
pass
|
|
121
168
|
|
|
122
169
|
def find_items():
|
|
170
|
+
"""Return (item paths in unlocked collections, locked flag).
|
|
171
|
+
legacy ksecretd reports items in LOCKED collections separately;
|
|
172
|
+
touching them would fire KWallet unlock dialogs, so callers
|
|
173
|
+
get the flag instead."""
|
|
123
174
|
if MODERN:
|
|
124
175
|
res = dbusi.SearchItems(
|
|
125
176
|
dbus.UInt32(0),
|
|
@@ -128,11 +179,51 @@ def main():
|
|
|
128
179
|
),
|
|
129
180
|
dbus.ObjectPath("/"),
|
|
130
181
|
)
|
|
131
|
-
return [str(k) for k in res]
|
|
132
|
-
(u,
|
|
182
|
+
return [str(k) for k in res], False
|
|
183
|
+
(u, locked) = dbusi.SearchItems(dbus.Dictionary(
|
|
133
184
|
{k: v for k, v in attrs.items()}, "ss"
|
|
134
185
|
))
|
|
135
|
-
return [str(k) for k in list(u)
|
|
186
|
+
return [str(k) for k in list(u)], bool(locked)
|
|
187
|
+
|
|
188
|
+
def is_locked_now():
|
|
189
|
+
"""Authoritative collection lock state, via the Locked
|
|
190
|
+
property. (SearchItems only reports locking through items that
|
|
191
|
+
match the query — a locked collection with NO matching items
|
|
192
|
+
would otherwise look unlocked!)"""
|
|
193
|
+
if MODERN:
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
return bool(dbus.Interface(
|
|
197
|
+
bus.get_object(owner, str(coll)),
|
|
198
|
+
"org.freedesktop.DBus.Properties",
|
|
199
|
+
).Get("org.freedesktop.Secret.Collection", "Locked"))
|
|
200
|
+
except Exception:
|
|
201
|
+
return bool(find_items()[1])
|
|
202
|
+
|
|
203
|
+
def unlock_with_prompt():
|
|
204
|
+
"""Trigger ksecretd's interactive unlock flow. Service.Unlock()
|
|
205
|
+
returns (unlocked collections, prompt object path); the dialog
|
|
206
|
+
is only shown once Prompt.Prompt(window_id) is called on that
|
|
207
|
+
object — and it is shown by ksecretd itself, so it survives
|
|
208
|
+
this process exiting."""
|
|
209
|
+
if MODERN:
|
|
210
|
+
return # modern (0.0.1) API has no locking
|
|
211
|
+
try:
|
|
212
|
+
res = dbusi.Unlock([dbus.ObjectPath(str(coll))])
|
|
213
|
+
prompt = str(res[1]) if res and len(res) > 1 else "/"
|
|
214
|
+
except Exception:
|
|
215
|
+
return
|
|
216
|
+
if prompt and prompt != "/":
|
|
217
|
+
try:
|
|
218
|
+
piface = dbus.Interface(
|
|
219
|
+
bus.get_object(owner, prompt),
|
|
220
|
+
"org.freedesktop.Secret.Prompt",
|
|
221
|
+
)
|
|
222
|
+
# Empty window id: ksecretd still shows the dialog,
|
|
223
|
+
# unparented, kept above all windows.
|
|
224
|
+
piface.Prompt("")
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
136
227
|
|
|
137
228
|
def get_content(path):
|
|
138
229
|
"""Return the secret bytes for an item path, or None."""
|
|
@@ -171,7 +262,50 @@ def main():
|
|
|
171
262
|
except Exception:
|
|
172
263
|
pass
|
|
173
264
|
|
|
174
|
-
if cmd
|
|
265
|
+
if cmd in ("upsert", "store", "clear"):
|
|
266
|
+
wait = max(0.0, float(req.get("wait", UNLOCK_WAIT_S)))
|
|
267
|
+
paths, is_locked = find_items()
|
|
268
|
+
is_locked = is_locked_now() or is_locked
|
|
269
|
+
writing = cmd != "clear" and bool(secret)
|
|
270
|
+
if is_locked:
|
|
271
|
+
if not writing:
|
|
272
|
+
# delete-only on a locked keyring: skip it rather than
|
|
273
|
+
# prompt (best-effort purge; the file is already clean).
|
|
274
|
+
out({"ok": False, "locked": True,
|
|
275
|
+
"error": "keyring is locked"})
|
|
276
|
+
return
|
|
277
|
+
if wait <= 0:
|
|
278
|
+
# Non-interactive caller: NEVER prompt. Fall through to
|
|
279
|
+
# the write attempt below — it may still succeed if the
|
|
280
|
+
# wallet is being unlocked in parallel; otherwise the
|
|
281
|
+
# IsLocked D-Bus error is caught below and reported as
|
|
282
|
+
# "locked".
|
|
283
|
+
pass
|
|
284
|
+
else:
|
|
285
|
+
# Writing while locked: trigger the interactive unlock
|
|
286
|
+
# (Service.Unlock + Prompt — ksecretd shows the "KDE
|
|
287
|
+
# Wallet Service" password dialog) and wait for the
|
|
288
|
+
# collection to actually unlock.
|
|
289
|
+
unlock_with_prompt()
|
|
290
|
+
deadline = time.time() + wait
|
|
291
|
+
while is_locked_now() and time.time() < deadline:
|
|
292
|
+
time.sleep(1)
|
|
293
|
+
if is_locked_now():
|
|
294
|
+
out({"ok": False, "locked": True,
|
|
295
|
+
"error": "keyring is locked"})
|
|
296
|
+
return
|
|
297
|
+
paths, is_locked = find_items()
|
|
298
|
+
if cmd == "clear" or not secret:
|
|
299
|
+
for path in paths:
|
|
300
|
+
item_delete(path)
|
|
301
|
+
out({"ok": True})
|
|
302
|
+
return
|
|
303
|
+
# Kill-safe order: create the new item (with its secret) first,
|
|
304
|
+
# then delete the previously matched items. Even if this process
|
|
305
|
+
# is killed mid-upsert the keyring copy is never destroyed
|
|
306
|
+
# (worst case: orphan items remain; the next upsert cleans them
|
|
307
|
+
# up, and lookups take the first non-empty secret).
|
|
308
|
+
new_path = ""
|
|
175
309
|
if MODERN:
|
|
176
310
|
item = "/org/freedesktop/secrets/0/item/" + re.sub(
|
|
177
311
|
r"[^A-Za-z0-9_]", "_", "%s_%s" % (
|
|
@@ -211,9 +345,10 @@ def main():
|
|
|
211
345
|
"sv",
|
|
212
346
|
),
|
|
213
347
|
)
|
|
348
|
+
new_path = item
|
|
214
349
|
else:
|
|
215
350
|
coll_obj = dbus.Interface(
|
|
216
|
-
bus.get_object(owner, str(
|
|
351
|
+
bus.get_object(owner, str(coll)),
|
|
217
352
|
"org.freedesktop.Secret.Collection",
|
|
218
353
|
)
|
|
219
354
|
secret_arg = dbus.Struct((
|
|
@@ -231,12 +366,35 @@ def main():
|
|
|
231
366
|
{k: v for k, v in attrs.items()}, "ss"
|
|
232
367
|
),
|
|
233
368
|
}, "sv")
|
|
234
|
-
coll_obj.CreateItem(props, secret_arg, True)
|
|
369
|
+
create_res = coll_obj.CreateItem(props, secret_arg, True)
|
|
370
|
+
# ksecretd returns (item_path, prompt) — the item comes
|
|
371
|
+
# FIRST (the 0.0.1 spec says (session, item)); scan the
|
|
372
|
+
# whole reply for a path under this collection instead of
|
|
373
|
+
# trusting a fixed index.
|
|
374
|
+
for cand in (
|
|
375
|
+
create_res if isinstance(create_res, tuple) else ()
|
|
376
|
+
):
|
|
377
|
+
s = str(cand)
|
|
378
|
+
if s.startswith(str(coll) + "/"):
|
|
379
|
+
new_path = s
|
|
380
|
+
break
|
|
381
|
+
if new_path:
|
|
382
|
+
for path in paths:
|
|
383
|
+
if path != new_path:
|
|
384
|
+
item_delete(path)
|
|
385
|
+
# else: can't tell which pre-matched item was updated in place —
|
|
386
|
+
# keep them all (orphans are harmless); deleting blindly would
|
|
387
|
+
# destroy the copy we just wrote.
|
|
235
388
|
out({"ok": True})
|
|
236
389
|
return
|
|
237
390
|
|
|
238
391
|
if cmd == "lookup":
|
|
239
|
-
|
|
392
|
+
if is_locked_now():
|
|
393
|
+
out({"ok": False, "locked": True,
|
|
394
|
+
"error": "keyring is locked"})
|
|
395
|
+
return
|
|
396
|
+
paths, is_locked = find_items()
|
|
397
|
+
for path in paths:
|
|
240
398
|
content = get_content(path)
|
|
241
399
|
if content:
|
|
242
400
|
out({
|
|
@@ -244,21 +402,29 @@ def main():
|
|
|
244
402
|
"secret": content.decode("utf-8", "replace"),
|
|
245
403
|
})
|
|
246
404
|
return
|
|
405
|
+
if is_locked:
|
|
406
|
+
# Item exists but its collection is locked (KWallet):
|
|
407
|
+
# report it, don't touch the collection (no unlock prompt).
|
|
408
|
+
out({"ok": False, "locked": True,
|
|
409
|
+
"error": "keyring is locked"})
|
|
410
|
+
return
|
|
247
411
|
out({"ok": False, "error": "item not found"})
|
|
248
412
|
return
|
|
249
413
|
|
|
250
|
-
if cmd == "clear":
|
|
251
|
-
for path in find_items():
|
|
252
|
-
item_delete(path)
|
|
253
|
-
out({"ok": True})
|
|
254
|
-
return
|
|
255
|
-
|
|
256
414
|
out({"ok": False, "error": "unknown command"})
|
|
257
415
|
except Exception as e:
|
|
416
|
+
name = ""
|
|
417
|
+
try:
|
|
418
|
+
name = e.get_dbus_name() or "" # D-Bus error name (python-dbus)
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
258
421
|
msg = str(e)
|
|
259
422
|
if secret:
|
|
260
423
|
msg = msg.replace(secret, "***")
|
|
261
|
-
|
|
424
|
+
if "IsLocked" in name or "IsLocked" in msg:
|
|
425
|
+
out({"ok": False, "locked": True, "error": "keyring is locked"})
|
|
426
|
+
else:
|
|
427
|
+
out({"ok": False, "error": msg or e.__class__.__name__})
|
|
262
428
|
|
|
263
429
|
|
|
264
430
|
main()
|
|
@@ -319,20 +485,40 @@ export function walletAvailable(): boolean {
|
|
|
319
485
|
return availCache;
|
|
320
486
|
}
|
|
321
487
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
488
|
+
let lastLookupLocked = false;
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Upsert a secret for the given attrs in ONE D-Bus roundtrip: the new item
|
|
492
|
+
* is created (with its secret) first, then the previously matched items are
|
|
493
|
+
* deleted — an interrupted upsert never destroys the keyring copy. An empty
|
|
494
|
+
* secret deletes only (best-effort, never prompts).
|
|
495
|
+
* `waitSec` is how long to wait for an interactive unlock when the
|
|
496
|
+
* keyring is locked while writing (default UNLOCK_WAIT_S for interactive
|
|
497
|
+
* callers such as login; pass 0 for non-interactive paths so they fall
|
|
498
|
+
* back to the file instantly). "locked" = the keyring exists but is
|
|
499
|
+
* locked (KWallet): the token must be kept in the file fallback;
|
|
500
|
+
* "unreachable" = no keyring/D-Bus at all.
|
|
501
|
+
*/
|
|
502
|
+
export function walletUpsert(attrs: Record<string, string>, secret: string, waitSec = UNLOCK_WAIT_S): WalletResult {
|
|
503
|
+
const r = call({ cmd: "upsert", attrs, secret, wait: waitSec }, Math.max(UPSERT_TIMEOUT_MS, (waitSec + 15) * 1000));
|
|
504
|
+
if (!r) return "unreachable";
|
|
505
|
+
if (r.ok) return "ok";
|
|
506
|
+
if (r.locked) return "locked";
|
|
507
|
+
return "unreachable";
|
|
326
508
|
}
|
|
327
509
|
|
|
328
|
-
/**
|
|
510
|
+
/**
|
|
511
|
+
* Read a secret; null when not found, the keyring is locked, or the
|
|
512
|
+
* keyring is unreachable. See walletWasLocked() to distinguish a locked
|
|
513
|
+
* keyring from a missing item.
|
|
514
|
+
*/
|
|
329
515
|
export function walletLookup(attrs: Record<string, string>): string | null {
|
|
330
516
|
const r = call({ cmd: "lookup", attrs });
|
|
517
|
+
lastLookupLocked = !!(r && r.locked);
|
|
331
518
|
return r && r.ok ? (r.secret ?? null) : null;
|
|
332
519
|
}
|
|
333
520
|
|
|
334
|
-
/**
|
|
335
|
-
export function
|
|
336
|
-
|
|
337
|
-
return !!(r && r.ok);
|
|
521
|
+
/** True when the last lookup failed because the keyring is locked. */
|
|
522
|
+
export function walletWasLocked(): boolean {
|
|
523
|
+
return lastLookupLocked;
|
|
338
524
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-git-auth",
|
|
3
|
-
"version": "1.
|
|
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,
|
|
6
|
+
import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs, UNLOCK_WAIT_S } from "./keyring";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Credential persistence for pi-git-auth (multi-account, multi-service).
|
|
@@ -20,6 +20,15 @@ import { STATE_DIR, walletAvailable, walletStore, walletLookup, walletClear, wal
|
|
|
20
20
|
*
|
|
21
21
|
* Migration is transparent: legacy plaintext or `enc:v1:` tokens are moved
|
|
22
22
|
* into the keyring on first load (a `.bak` copy of the file is kept).
|
|
23
|
+
* This is also the self-heal: a token kept in the file after a locked
|
|
24
|
+
* wallet goes back to the keyring on the next load while it is reachable
|
|
25
|
+
* (load-time writes use wait=0, so a still-locked wallet never delays
|
|
26
|
+
* startup).
|
|
27
|
+
*
|
|
28
|
+
* If the keyring is locked on load, the token for `wallet:v1:` accounts
|
|
29
|
+
* is "" for this process but is retried (throttled, prompt-safe) via
|
|
30
|
+
* retryKeyringLoad() on the next status/git-gate hit, so it recovers
|
|
31
|
+
* automatically once the wallet unlocks.
|
|
23
32
|
*
|
|
24
33
|
* Env override: PI_GIT_AUTH_STORE = auto (default) | wallet | file
|
|
25
34
|
*
|
|
@@ -67,6 +76,16 @@ export interface StoreData {
|
|
|
67
76
|
|
|
68
77
|
let cache: StoreData | null = null;
|
|
69
78
|
let keyCache: Buffer | null = null;
|
|
79
|
+
/** Stored strings (markers/envelopes) from the last file read/write. */
|
|
80
|
+
let diskData: StoreData | null = null;
|
|
81
|
+
/** Plaintext tokens as last persisted, per account key ("" = unreadable). */
|
|
82
|
+
let storedPlaintext: Record<string, string> = {};
|
|
83
|
+
/** A `wallet:v1:` token could not be read on load (keyring locked/absent). */
|
|
84
|
+
let keyringUnavailableAtLoad = false;
|
|
85
|
+
/** Accounts with a `wallet:v1:` marker that failed to read on load. */
|
|
86
|
+
let loadFailedKeys: string[] = [];
|
|
87
|
+
let lastRetryAt = 0;
|
|
88
|
+
const RETRY_THROTTLE_MS = 10_000;
|
|
70
89
|
|
|
71
90
|
const ENC_PREFIX = "enc:v1:";
|
|
72
91
|
const WALLET_PREFIX = "wallet:v1:";
|
|
@@ -163,22 +182,40 @@ function decryptToken(enc: string): string {
|
|
|
163
182
|
// Store API
|
|
164
183
|
// ---------------------------------------------------------------------------
|
|
165
184
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Persist the in-memory (plaintext) state. Only accounts whose token
|
|
187
|
+
* changed since the last persist are (re)written to the backend; the
|
|
188
|
+
* others keep their on-disk marker/envelope untouched. This matters on
|
|
189
|
+
* KDE/ksecretd: a keyring write is the only operation that may trigger
|
|
190
|
+
* the KWallet unlock dialog, so e.g. `/auth switch` performs zero
|
|
191
|
+
* keyring writes (no prompts, no "repeated wallet access" warnings).
|
|
192
|
+
* When the keyring is locked/unreachable the token is kept in the
|
|
193
|
+
* encrypted file instead of leaving a dead `wallet:v1:` marker behind.
|
|
194
|
+
*
|
|
195
|
+
* `waitSec` = how long a keyring write may wait for an interactive
|
|
196
|
+
* wallet unlock. Interactive callers (login) pass UNLOCK_WAIT_S so the
|
|
197
|
+
* user can answer the KWallet prompt; load-time migration/repair passes
|
|
198
|
+
* 0 so a locked wallet never delays startup.
|
|
199
|
+
*/
|
|
200
|
+
function persist(data: StoreData, forceStore = false, waitSec = 0): void {
|
|
169
201
|
const mode = storeMode();
|
|
170
202
|
const accounts: Record<string, AccountRecord> = {};
|
|
203
|
+
const nowPlaintext: Record<string, string> = {};
|
|
171
204
|
for (const [k, a] of Object.entries(data.accounts)) {
|
|
205
|
+
const changed = forceStore || storedPlaintext[k] !== a.accessToken;
|
|
206
|
+
const diskStored = diskData?.accounts[k]?.accessToken;
|
|
172
207
|
let stored: string;
|
|
173
|
-
if (
|
|
174
|
-
//
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
208
|
+
if (changed && a.accessToken) {
|
|
209
|
+
// Single keyring roundtrip (delete matching items + create).
|
|
210
|
+
const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken, waitSec) : null;
|
|
211
|
+
stored = r === "ok" ? WALLET_PREFIX + k : encryptToken(a.accessToken);
|
|
212
|
+
} else if (diskStored) {
|
|
213
|
+
stored = diskStored; // unchanged: keep the existing marker/envelope
|
|
178
214
|
} else {
|
|
179
|
-
stored = encryptToken(a.accessToken);
|
|
215
|
+
stored = a.accessToken ? encryptToken(a.accessToken) : "";
|
|
180
216
|
}
|
|
181
217
|
accounts[k] = { ...a, accessToken: stored };
|
|
218
|
+
nowPlaintext[k] = a.accessToken;
|
|
182
219
|
}
|
|
183
220
|
const out: StoreData = {
|
|
184
221
|
accounts,
|
|
@@ -189,6 +226,9 @@ function persist(data: StoreData): void {
|
|
|
189
226
|
writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
|
|
190
227
|
renameSync(tmp, CREDENTIALS_FILE);
|
|
191
228
|
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
229
|
+
diskData = out;
|
|
230
|
+
storedPlaintext = nowPlaintext;
|
|
231
|
+
cache = data;
|
|
192
232
|
}
|
|
193
233
|
|
|
194
234
|
export function loadStore(): StoreData {
|
|
@@ -201,6 +241,12 @@ export function loadStore(): StoreData {
|
|
|
201
241
|
} catch {
|
|
202
242
|
raw = {};
|
|
203
243
|
}
|
|
244
|
+
// Deep snapshot BEFORE absorb() mutates records in place — diskData must
|
|
245
|
+
// keep the on-disk stored strings (markers/envelopes), never the
|
|
246
|
+
// plaintext tokens that absorb decrypts into the same objects.
|
|
247
|
+
diskData = raw.accounts && typeof raw.accounts === "object" ? JSON.parse(JSON.stringify(raw)) : null;
|
|
248
|
+
keyringUnavailableAtLoad = false;
|
|
249
|
+
loadFailedKeys = [];
|
|
204
250
|
const data: StoreData = { accounts: {} };
|
|
205
251
|
let migrated = false;
|
|
206
252
|
|
|
@@ -209,7 +255,12 @@ export function loadStore(): StoreData {
|
|
|
209
255
|
if (rec.accessToken.startsWith(WALLET_PREFIX)) {
|
|
210
256
|
const got = walletLookup(recAttrs(rec, key));
|
|
211
257
|
if (got === null) {
|
|
212
|
-
|
|
258
|
+
// keyring locked/cleared: keep the marker on disk, run this
|
|
259
|
+
// process without the token, and flag it for the status output
|
|
260
|
+
// and for retryKeyringLoad().
|
|
261
|
+
rec.accessToken = "";
|
|
262
|
+
keyringUnavailableAtLoad = true;
|
|
263
|
+
loadFailedKeys.push(key);
|
|
213
264
|
} else {
|
|
214
265
|
rec.accessToken = got;
|
|
215
266
|
}
|
|
@@ -229,6 +280,7 @@ export function loadStore(): StoreData {
|
|
|
229
280
|
migrated = true;
|
|
230
281
|
}
|
|
231
282
|
data.accounts[key] = rec as AccountRecord;
|
|
283
|
+
storedPlaintext[key] = (rec as AccountRecord).accessToken;
|
|
232
284
|
};
|
|
233
285
|
|
|
234
286
|
if (raw.accounts && typeof raw.accounts === "object") {
|
|
@@ -253,7 +305,7 @@ export function loadStore(): StoreData {
|
|
|
253
305
|
// Keep a rollback copy of the pre-migration file (still 0600, no
|
|
254
306
|
// new secrets — it only contains ciphertexts/markers).
|
|
255
307
|
if (hadFile && existsSync(CREDENTIALS_FILE)) copyFileSync(CREDENTIALS_FILE, CREDENTIALS_FILE + ".bak");
|
|
256
|
-
persist(cache);
|
|
308
|
+
persist(cache, true); // force re-store under the current backend
|
|
257
309
|
} catch {
|
|
258
310
|
/* best-effort migration */
|
|
259
311
|
}
|
|
@@ -261,15 +313,62 @@ export function loadStore(): StoreData {
|
|
|
261
313
|
return cache;
|
|
262
314
|
}
|
|
263
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Persist after an interactive change (login): keyring writes may wait
|
|
318
|
+
* up to UNLOCK_WAIT_S for the user to answer the wallet unlock prompt.
|
|
319
|
+
*/
|
|
264
320
|
export function saveStore(data: StoreData): void {
|
|
265
|
-
|
|
266
|
-
|
|
321
|
+
persist(data, false, UNLOCK_WAIT_S);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Lazy recovery for tokens that could not be read on load (keyring
|
|
326
|
+
* locked): retry the lookup, throttled. Prompt-safe — a lookup never
|
|
327
|
+
* touches a locked collection, so this cannot stack unlock prompts.
|
|
328
|
+
* No-op once nothing is pending. Call it from hot paths (git gate,
|
|
329
|
+
* /auth status) so the token recovers as soon as the wallet unlocks.
|
|
330
|
+
*/
|
|
331
|
+
export function retryKeyringLoad(): void {
|
|
332
|
+
if (loadFailedKeys.length === 0) return;
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
if (now - lastRetryAt < RETRY_THROTTLE_MS) return;
|
|
335
|
+
lastRetryAt = now;
|
|
336
|
+
const data = cache;
|
|
337
|
+
if (!data) return;
|
|
338
|
+
for (const k of [...loadFailedKeys]) {
|
|
339
|
+
const rec = data.accounts[k];
|
|
340
|
+
if (!rec || rec.accessToken) {
|
|
341
|
+
loadFailedKeys = loadFailedKeys.filter((x) => x !== k);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const got = walletLookup(recAttrs(rec, k));
|
|
345
|
+
if (got !== null) {
|
|
346
|
+
rec.accessToken = got;
|
|
347
|
+
storedPlaintext[k] = got;
|
|
348
|
+
loadFailedKeys = loadFailedKeys.filter((x) => x !== k);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (loadFailedKeys.length === 0) keyringUnavailableAtLoad = false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Where the active account's token actually lives on disk right now. */
|
|
355
|
+
export function activeTokenStorage(): "keyring" | "file" {
|
|
356
|
+
const key = cache?.activeLogin;
|
|
357
|
+
const stored = key ? diskData?.accounts[key]?.accessToken : undefined;
|
|
358
|
+
return stored?.startsWith(WALLET_PREFIX) ? "keyring" : "file";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** True when a `wallet:v1:` token could not be read on load (keyring
|
|
362
|
+
* locked or unreachable) — the in-memory token for that account is "". */
|
|
363
|
+
export function keyringUnavailableAtLoadFlag(): boolean {
|
|
364
|
+
return keyringUnavailableAtLoad;
|
|
267
365
|
}
|
|
268
366
|
|
|
269
367
|
/** Remove one account's keyring item (idempotent, best-effort). */
|
|
270
368
|
export function purgeAccountStorage(key: string, rec?: AccountRecord | null): void {
|
|
271
369
|
try {
|
|
272
|
-
|
|
370
|
+
// Empty secret = delete-only: best-effort, never triggers a prompt.
|
|
371
|
+
if (rec) walletUpsert(recAttrs(rec, key), "");
|
|
273
372
|
} catch {
|
|
274
373
|
/* best-effort */
|
|
275
374
|
}
|
|
@@ -280,13 +379,15 @@ export function clearStore(): void {
|
|
|
280
379
|
try {
|
|
281
380
|
if (cache) {
|
|
282
381
|
for (const [k, rec] of Object.entries(cache.accounts)) {
|
|
283
|
-
|
|
382
|
+
walletUpsert(recAttrs(rec, k), "");
|
|
284
383
|
}
|
|
285
384
|
}
|
|
286
385
|
} catch {
|
|
287
386
|
/* best-effort */
|
|
288
387
|
}
|
|
289
388
|
cache = { accounts: {} };
|
|
389
|
+
diskData = null;
|
|
390
|
+
storedPlaintext = {};
|
|
290
391
|
try {
|
|
291
392
|
rmSync(CREDENTIALS_FILE);
|
|
292
393
|
} catch {
|