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