pi-git-auth 1.0.0
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/LICENSE +21 -0
- package/README.md +192 -0
- package/auth.ts +146 -0
- package/commands.ts +208 -0
- package/details.ts +219 -0
- package/forge.ts +144 -0
- package/git-gate.ts +44 -0
- package/github.ts +189 -0
- package/gitlab.ts +157 -0
- package/index.ts +119 -0
- package/keyring.ts +338 -0
- package/package.json +40 -0
- package/store.ts +302 -0
package/gitlab.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal GitLab REST client (gitlab.com, no dependencies).
|
|
3
|
+
* Mirrors github.ts: verify, list, create, meta, commits, tree —
|
|
4
|
+
* all returning the normalized types from forge.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CommitInfo, ForgeRepo, RepoMeta, TreeEntry } from "./forge";
|
|
8
|
+
|
|
9
|
+
const API_BASE = "https://gitlab.com/api/v4";
|
|
10
|
+
|
|
11
|
+
interface ApiResult {
|
|
12
|
+
status: number;
|
|
13
|
+
data: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function glFetch(path: string, token: string, init: RequestInit = {}): Promise<ApiResult> {
|
|
17
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
18
|
+
...init,
|
|
19
|
+
headers: {
|
|
20
|
+
accept: "application/json",
|
|
21
|
+
authorization: `Bearer ${token}`,
|
|
22
|
+
...(init.body ? { "content-type": "application/json" } : {}),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
let data: any = null;
|
|
26
|
+
if (res.status !== 204) {
|
|
27
|
+
try {
|
|
28
|
+
data = await res.json();
|
|
29
|
+
} catch {
|
|
30
|
+
data = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { status: res.status, data };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function errMsg(status: number, data: any): string {
|
|
37
|
+
return typeof data?.message === "string"
|
|
38
|
+
? data.message
|
|
39
|
+
: Array.isArray(data?.errors)
|
|
40
|
+
? data.errors.join(", ")
|
|
41
|
+
: data?.error
|
|
42
|
+
? String(data.error)
|
|
43
|
+
: "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkAuth(status: number): void {
|
|
47
|
+
if (status === 401) throw new Error("Token invalid or revoked (HTTP 401)");
|
|
48
|
+
if (status === 403) throw new Error("Token lacks permissions (HTTP 403)");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Verify a token by resolving the current user. */
|
|
52
|
+
export async function getUser(token: string): Promise<{ login: string; name?: string }> {
|
|
53
|
+
const { status, data } = await glFetch("/user", token);
|
|
54
|
+
checkAuth(status);
|
|
55
|
+
if (status >= 400 || !data?.username) throw new Error(`Unexpected response (HTTP ${status})`);
|
|
56
|
+
return { login: data.username, name: data.name };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Projects, normalized. With `org`: the group's projects; if the name is
|
|
61
|
+
* not a group, falls back to a user's projects.
|
|
62
|
+
*/
|
|
63
|
+
export async function listRepos(token: string, opts: { org?: string; perPage?: number }): Promise<ForgeRepo[]> {
|
|
64
|
+
const perPage = opts.perPage ?? 100;
|
|
65
|
+
if (opts.org) {
|
|
66
|
+
// Prefer group; fall back to a user's projects.
|
|
67
|
+
const g = await glFetch(`/groups/${encodeURIComponent(opts.org)}/projects?include_subgroups=true&per_page=${perPage}`, token);
|
|
68
|
+
if (g.status < 400 && Array.isArray(g.data)) return g.data.map(mapProject);
|
|
69
|
+
const u = await glFetch(`/users/${encodeURIComponent(opts.org)}/projects?per_page=${perPage}`, token);
|
|
70
|
+
checkAuth(u.status);
|
|
71
|
+
if (u.status >= 400 || !Array.isArray(u.data)) {
|
|
72
|
+
throw new Error(`List projects failed (HTTP ${u.status}): ${errMsg(u.status, u.data)}`);
|
|
73
|
+
}
|
|
74
|
+
return u.data.map(mapProject);
|
|
75
|
+
}
|
|
76
|
+
const { status, data } = await glFetch(`/projects?owned=true&per_page=${perPage}`, token);
|
|
77
|
+
checkAuth(status);
|
|
78
|
+
if (status >= 400 || !Array.isArray(data)) throw new Error(`List projects failed (HTTP ${status}): ${errMsg(status, data)}`);
|
|
79
|
+
return data.map(mapProject);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mapProject(p: any): ForgeRepo {
|
|
83
|
+
return {
|
|
84
|
+
fullName: p.path_with_namespace,
|
|
85
|
+
private: p.visibility === "private",
|
|
86
|
+
description: p.description ?? undefined,
|
|
87
|
+
htmlUrl: p.web_url,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Full project metadata for the details view. */
|
|
92
|
+
export async function getRepo(token: string, fullName: string): Promise<RepoMeta> {
|
|
93
|
+
const { status, data } = await glFetch(`/projects/${encodeURIComponent(fullName)}`, token);
|
|
94
|
+
checkAuth(status);
|
|
95
|
+
if (status === 404) throw new Error(`Project ${fullName} not found (HTTP 404)`);
|
|
96
|
+
if (status >= 400 || !data) throw new Error(`Get project failed (HTTP ${status}): ${errMsg(status, data)}`);
|
|
97
|
+
return {
|
|
98
|
+
fullName: data.path_with_namespace,
|
|
99
|
+
private: data.visibility === "private",
|
|
100
|
+
description: data.description ?? undefined,
|
|
101
|
+
defaultBranch: data.default_branch ?? "main",
|
|
102
|
+
// GitLab's API does not expose project size.
|
|
103
|
+
stars: data.star_count ?? 0,
|
|
104
|
+
forks: data.forks_count ?? 0,
|
|
105
|
+
lastPush: data.last_activity_at || undefined,
|
|
106
|
+
htmlUrl: data.web_url,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Latest commits, newest first (default branch). */
|
|
111
|
+
export async function listCommits(token: string, fullName: string, perPage = 5): Promise<CommitInfo[]> {
|
|
112
|
+
const { status, data } = await glFetch(
|
|
113
|
+
`/projects/${encodeURIComponent(fullName)}/repository/commits?per_page=${perPage}`,
|
|
114
|
+
token,
|
|
115
|
+
);
|
|
116
|
+
checkAuth(status);
|
|
117
|
+
if (status === 404) throw new Error(`Project ${fullName} not found (HTTP 404)`);
|
|
118
|
+
if (status >= 400 || !Array.isArray(data)) throw new Error(`List commits failed (HTTP ${status}): ${errMsg(status, data)}`);
|
|
119
|
+
return data.map((c: any) => ({
|
|
120
|
+
sha: String(c.short_id ?? c.id ?? ""),
|
|
121
|
+
subject: String(c.title ?? "").split("\n")[0],
|
|
122
|
+
date: c.committed_date ?? c.created_at ?? "",
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Flat recursive tree (default branch, or `ref`). Returns [] on 404. */
|
|
127
|
+
export async function getTree(token: string, fullName: string, ref?: string): Promise<TreeEntry[]> {
|
|
128
|
+
const q = `recursive=1&per_page=100${ref ? `&ref=${encodeURIComponent(ref)}` : ""}`;
|
|
129
|
+
const { status, data } = await glFetch(`/projects/${encodeURIComponent(fullName)}/repository/tree?${q}`, token);
|
|
130
|
+
if (status === 404) return [];
|
|
131
|
+
if (status >= 400 || !Array.isArray(data)) throw new Error(`Get tree failed (HTTP ${status}): ${errMsg(status, data)}`);
|
|
132
|
+
return data.map((t: any) => ({
|
|
133
|
+
path: String(t.path),
|
|
134
|
+
type: t.type === "tree" ? ("tree" as const) : ("blob" as const),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a project. Without `org` it is created in the user's namespace.
|
|
140
|
+
*/
|
|
141
|
+
export async function createRepo(
|
|
142
|
+
token: string,
|
|
143
|
+
name: string,
|
|
144
|
+
opts: { org?: string; private?: boolean; description?: string },
|
|
145
|
+
): Promise<ForgeRepo> {
|
|
146
|
+
const body = JSON.stringify({
|
|
147
|
+
name,
|
|
148
|
+
description: opts.description ?? null,
|
|
149
|
+
visibility: opts.private ? "private" : "public",
|
|
150
|
+
...(opts.org ? { namespace_path: opts.org } : {}),
|
|
151
|
+
});
|
|
152
|
+
const { status, data } = await glFetch("/projects", token, { method: "POST", body });
|
|
153
|
+
if (status >= 400 || !data?.web_url) {
|
|
154
|
+
throw new Error(`Create project failed (HTTP ${status}): ${errMsg(status, data) || (opts.org ? `unknown namespace ${opts.org}` : "")}`);
|
|
155
|
+
}
|
|
156
|
+
return mapProject(data);
|
|
157
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { loadStore, activeAccount } from "./store";
|
|
5
|
+
import { SERVICES } from "./forge";
|
|
6
|
+
import { findAccounts, setActiveAccount, statusDetail } from "./auth";
|
|
7
|
+
import { instrumentGit } from "./git-gate";
|
|
8
|
+
import { handleAuthCommand } from "./commands";
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
// ------------------------------------------------------------------
|
|
12
|
+
// Deterministic git auth: instrument bash tool calls that run git,
|
|
13
|
+
// using the token of the ACTIVE account for its host.
|
|
14
|
+
// ------------------------------------------------------------------
|
|
15
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
16
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
17
|
+
const acc = activeAccount(loadStore());
|
|
18
|
+
if (!acc?.accessToken) return;
|
|
19
|
+
const host = SERVICES[acc.platform].host;
|
|
20
|
+
event.input.command = instrumentGit(event.input.command, host, acc.accessToken);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ------------------------------------------------------------------
|
|
24
|
+
// /auth command
|
|
25
|
+
// ------------------------------------------------------------------
|
|
26
|
+
pi.registerCommand("auth", {
|
|
27
|
+
description: "Git auth (GitHub/GitLab): status, login, logout, switch account, repos, create repos",
|
|
28
|
+
handler: (args, ctx) => handleAuthCommand(args, ctx),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------------
|
|
32
|
+
// Tool for the LLM
|
|
33
|
+
// ------------------------------------------------------------------
|
|
34
|
+
pi.registerTool({
|
|
35
|
+
name: "auth",
|
|
36
|
+
label: "git auth",
|
|
37
|
+
description:
|
|
38
|
+
"Manage git forges (GitHub & GitLab): multiple accounts (status, switch), list/create repositories. " +
|
|
39
|
+
"Every action uses the service of the active account — switch accounts to work as a different user. " +
|
|
40
|
+
"Requires the user to have run `/auth login`. For clone/push just run `git` via bash — the harness " +
|
|
41
|
+
"authenticates the active account's host automatically (the git gate).",
|
|
42
|
+
promptSnippet: "Git forges (GitHub/GitLab): accounts (status/switch), list/create repos (git auth managed by harness)",
|
|
43
|
+
parameters: Type.Object({
|
|
44
|
+
action: Type.Union([
|
|
45
|
+
Type.Literal("status"),
|
|
46
|
+
Type.Literal("switch"),
|
|
47
|
+
Type.Literal("list"),
|
|
48
|
+
Type.Literal("create"),
|
|
49
|
+
]),
|
|
50
|
+
repo: Type.Optional(Type.String({ description: 'Repository as "owner/name"; bare name for create' })),
|
|
51
|
+
org: Type.Optional(Type.String({ description: "Org/owner for list, create (default: logged-in user)" })),
|
|
52
|
+
private: Type.Optional(Type.Boolean({ description: "Visibility for create (default: private)" })),
|
|
53
|
+
description: Type.Optional(Type.String({ description: "Description for create" })),
|
|
54
|
+
account: Type.Optional(Type.String({ description: "Login for switch (\"platform:login\" to disambiguate)" })),
|
|
55
|
+
}),
|
|
56
|
+
async execute(_toolCallId, params, signal) {
|
|
57
|
+
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }], details: {} });
|
|
58
|
+
const notConnected = "Not connected. Ask the user to run /auth login, then retry.";
|
|
59
|
+
|
|
60
|
+
const active = () => {
|
|
61
|
+
const data = loadStore();
|
|
62
|
+
const acc = activeAccount(data);
|
|
63
|
+
const service = acc ? SERVICES[acc.platform] : undefined;
|
|
64
|
+
return acc?.accessToken && service ? { data, acc, service } : undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
switch (params.action) {
|
|
68
|
+
case "status": {
|
|
69
|
+
const data = loadStore();
|
|
70
|
+
if (Object.keys(data.accounts).length === 0) return { ...text(notConnected), isError: true };
|
|
71
|
+
return text(statusDetail(data));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case "switch": {
|
|
75
|
+
if (!params.account) return { ...text("Missing account (login) for switch."), isError: true };
|
|
76
|
+
const matches = findAccounts(params.account);
|
|
77
|
+
if (matches.length === 0) return { ...text(`Unknown account "${params.account}".`), isError: true };
|
|
78
|
+
if (matches.length > 1)
|
|
79
|
+
return { ...text(`Ambiguous login — use "platform:login" (e.g. github:${params.account}).`), isError: true };
|
|
80
|
+
if (setActiveAccount(matches[0])) {
|
|
81
|
+
const data = loadStore();
|
|
82
|
+
const acc = data.accounts[matches[0]];
|
|
83
|
+
return text(`Active account: @${acc?.user ?? matches[0]} (${acc?.platform ?? "github"})`);
|
|
84
|
+
}
|
|
85
|
+
return { ...text(`Switch failed for "${params.account}".`), isError: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case "list": {
|
|
89
|
+
const a = active();
|
|
90
|
+
if (!a) return { ...text(notConnected), isError: true };
|
|
91
|
+
const repos = await a.service.listRepos(a.acc.accessToken, params.org);
|
|
92
|
+
return text(
|
|
93
|
+
repos.length
|
|
94
|
+
? repos.map((r) => `${r.fullName} [${r.private ? "private" : "public"}] ${r.description ?? ""}`).join("\n")
|
|
95
|
+
: "No repositories found.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case "create": {
|
|
100
|
+
const a = active();
|
|
101
|
+
if (!a) return { ...text(notConnected), isError: true };
|
|
102
|
+
if (!params.repo) return { ...text("Missing repo name for create."), isError: true };
|
|
103
|
+
const sep = params.repo.indexOf("/");
|
|
104
|
+
const org = params.repo.includes("/") ? params.repo.slice(0, sep) : params.org;
|
|
105
|
+
const name = params.repo.includes("/") ? params.repo.slice(sep + 1) : params.repo;
|
|
106
|
+
const repo = await a.service.createRepo(a.acc.accessToken, name, {
|
|
107
|
+
org,
|
|
108
|
+
private: params.private ?? true,
|
|
109
|
+
description: params.description,
|
|
110
|
+
});
|
|
111
|
+
return text(`Created ${repo.fullName} (${repo.private ? "private" : "public"}) → ${repo.htmlUrl}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
default:
|
|
115
|
+
return { ...text(`Unknown action.`), isError: true };
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
package/keyring.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS keyring backend (freedesktop Secret Service API — the
|
|
3
|
+
* org.freedesktop.secrets D-Bus standard, implemented by GNOME Keyring, KDE
|
|
4
|
+
* KWallet via ksecretd, KeePassXC, …) for token storage.
|
|
5
|
+
*
|
|
6
|
+
* The python3 client is EMBEDDED below and (re)written to the state dir on
|
|
7
|
+
* first use, so there is nothing to install and nothing to ship separately.
|
|
8
|
+
* It talks JSON over stdin/stdout, which means the secret NEVER appears in
|
|
9
|
+
* a process argument list — only in the parent process's memory.
|
|
10
|
+
*
|
|
11
|
+
* Two API generations are auto-detected at runtime by introspection:
|
|
12
|
+
* - modern 0.0.1 (gnome-keyring, kwallet --secretservice):
|
|
13
|
+
* Service.Store / SearchItems / item.GetSecret
|
|
14
|
+
* - legacy 0.0.0 (KDE ksecretd, default with KWallet 6):
|
|
15
|
+
* Collection.CreateItem / Service.SearchItems / Service.GetSecrets
|
|
16
|
+
*
|
|
17
|
+
* Fallback: when python3/dbus/keyring are unavailable (headless, no D-Bus),
|
|
18
|
+
* store.ts silently keeps the on-disk AES-encrypted file format.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
export const STATE_DIR = join(homedir(), ".pi", "agent", "pi-git-auth");
|
|
26
|
+
const PY_PATH = join(STATE_DIR, "wallet-tool.py");
|
|
27
|
+
const TIMEOUT_MS = 8000;
|
|
28
|
+
|
|
29
|
+
interface WalletRes {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
secret?: string;
|
|
32
|
+
api?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PY = `#!/usr/bin/env python3
|
|
37
|
+
"""pi-git-auth keyring client (freedesktop Secret Service API).
|
|
38
|
+
|
|
39
|
+
Protocol: one JSON request on stdin, one JSON response line on stdout.
|
|
40
|
+
{"cmd": "available"}
|
|
41
|
+
{"cmd": "store", "attrs": {...}, "secret": "..."}
|
|
42
|
+
{"cmd": "lookup", "attrs": {...}}
|
|
43
|
+
{"cmd": "clear", "attrs": {...}}
|
|
44
|
+
|
|
45
|
+
Auto-detects the Secret Service API generation (modern 0.0.1 vs legacy
|
|
46
|
+
0.0.0/ksecretd) by introspecting the service.
|
|
47
|
+
"""
|
|
48
|
+
import sys
|
|
49
|
+
import json
|
|
50
|
+
import re
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def out(obj):
|
|
54
|
+
sys.stdout.write(json.dumps(obj) + "\\n")
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_bytes(v):
|
|
59
|
+
return bytes(bytearray(v))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
line = sys.stdin.readline()
|
|
64
|
+
req = json.loads(line) if line.strip() else {}
|
|
65
|
+
cmd = req.get("cmd")
|
|
66
|
+
secret = req.get("secret", "")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
import dbus
|
|
70
|
+
except Exception:
|
|
71
|
+
out({"ok": False, "error": "python3 dbus module not available"})
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
SVC = "org.freedesktop.secrets"
|
|
75
|
+
try:
|
|
76
|
+
bus = dbus.SessionBus()
|
|
77
|
+
except Exception:
|
|
78
|
+
out({"ok": False, "error": "no D-Bus session bus (headless?)"})
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
owner = bus.get_name_owner(SVC)
|
|
82
|
+
except Exception:
|
|
83
|
+
out({"ok": False, "error": "no keyring service on session bus"})
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
svc = bus.get_object(SVC, "/org/freedesktop/secrets")
|
|
87
|
+
dbusi = dbus.Interface(svc, "org.freedesktop.Secret.Service")
|
|
88
|
+
try:
|
|
89
|
+
xml = dbus.Interface(
|
|
90
|
+
bus.get_object(owner, "/org/freedesktop/secrets"),
|
|
91
|
+
"org.freedesktop.DBus.Introspectable",
|
|
92
|
+
).Introspect()
|
|
93
|
+
except Exception:
|
|
94
|
+
xml = ""
|
|
95
|
+
MODERN = 'name="Store"' in xml
|
|
96
|
+
|
|
97
|
+
if cmd == "available":
|
|
98
|
+
out({"ok": True, "api": "modern" if MODERN else "legacy"})
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
attrs = req["attrs"]
|
|
103
|
+
label = "pi-git-auth %s %s" % (
|
|
104
|
+
attrs.get("platform", "?"),
|
|
105
|
+
attrs.get("login", "?"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# open a plaintext session
|
|
109
|
+
if MODERN:
|
|
110
|
+
_o, session = dbusi.OpenSession("none", "")
|
|
111
|
+
else:
|
|
112
|
+
_o, session = dbusi.OpenSession("plain", "")
|
|
113
|
+
coll = dbusi.ReadAlias("default")
|
|
114
|
+
if str(coll) == "/":
|
|
115
|
+
out({"ok": False, "error": "no default collection in keyring"})
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
dbusi.Unlock([coll])
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def find_items():
|
|
123
|
+
if MODERN:
|
|
124
|
+
res = dbusi.SearchItems(
|
|
125
|
+
dbus.UInt32(0),
|
|
126
|
+
dbus.Dictionary(
|
|
127
|
+
{k: v for k, v in attrs.items()}, "sv"
|
|
128
|
+
),
|
|
129
|
+
dbus.ObjectPath("/"),
|
|
130
|
+
)
|
|
131
|
+
return [str(k) for k in res]
|
|
132
|
+
(u, _l) = dbusi.SearchItems(dbus.Dictionary(
|
|
133
|
+
{k: v for k, v in attrs.items()}, "ss"
|
|
134
|
+
))
|
|
135
|
+
return [str(k) for k in list(u) + list(_l)]
|
|
136
|
+
|
|
137
|
+
def get_content(path):
|
|
138
|
+
"""Return the secret bytes for an item path, or None."""
|
|
139
|
+
try:
|
|
140
|
+
if MODERN:
|
|
141
|
+
_s, content = dbus.Interface(
|
|
142
|
+
bus.get_object(owner, path),
|
|
143
|
+
"org.freedesktop.Secret.Item",
|
|
144
|
+
).GetSecret(session)
|
|
145
|
+
return to_bytes(content)
|
|
146
|
+
secs = dbusi.GetSecrets(
|
|
147
|
+
[dbus.ObjectPath(path)], session
|
|
148
|
+
)
|
|
149
|
+
for st in secs.values():
|
|
150
|
+
c = to_bytes(st[2])
|
|
151
|
+
if c:
|
|
152
|
+
return c
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
# fallback: per-item GetSecret (both generations)
|
|
156
|
+
try:
|
|
157
|
+
_s, content = dbus.Interface(
|
|
158
|
+
bus.get_object(owner, path),
|
|
159
|
+
"org.freedesktop.Secret.Item",
|
|
160
|
+
).GetSecret(session)
|
|
161
|
+
return to_bytes(content)
|
|
162
|
+
except Exception:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def item_delete(path):
|
|
166
|
+
try:
|
|
167
|
+
dbus.Interface(
|
|
168
|
+
bus.get_object(owner, path),
|
|
169
|
+
"org.freedesktop.Secret.Item",
|
|
170
|
+
).Delete()
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
if cmd == "store":
|
|
175
|
+
if MODERN:
|
|
176
|
+
item = "/org/freedesktop/secrets/0/item/" + re.sub(
|
|
177
|
+
r"[^A-Za-z0-9_]", "_", "%s_%s" % (
|
|
178
|
+
attrs.get("platform", "x"),
|
|
179
|
+
attrs.get("login", "x"),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
item_props = dbus.Struct((
|
|
183
|
+
dbus.ObjectPath(item),
|
|
184
|
+
dbus.Dictionary({
|
|
185
|
+
"org.freedesktop.Secret.Item.Label": dbus.ByteArray(
|
|
186
|
+
label.encode("utf-8")
|
|
187
|
+
),
|
|
188
|
+
"org.freedesktop.Secret.Item.Attributes":
|
|
189
|
+
dbus.Dictionary(
|
|
190
|
+
{k: v for k, v in attrs.items()}, "sv"
|
|
191
|
+
),
|
|
192
|
+
}, "sv"),
|
|
193
|
+
))
|
|
194
|
+
secret_props = dbus.Struct((
|
|
195
|
+
dbus.ObjectPath(item),
|
|
196
|
+
dbus.Dictionary({
|
|
197
|
+
"org.freedesktop.Secret.Secret.Value": dbus.ByteArray(
|
|
198
|
+
secret.encode("utf-8")
|
|
199
|
+
),
|
|
200
|
+
"org.freedesktop.Secret.Secret.Content-Type":
|
|
201
|
+
"application/octet-stream",
|
|
202
|
+
"org.freedesktop.Secret.Secret.Parameters":
|
|
203
|
+
dbus.Dictionary({}, "sv"),
|
|
204
|
+
}, "sv"),
|
|
205
|
+
))
|
|
206
|
+
dbusi.Store(
|
|
207
|
+
dbus.Dictionary({item: ""}, "sv"),
|
|
208
|
+
dbus.UInt32(0),
|
|
209
|
+
dbus.Dictionary(
|
|
210
|
+
{item: dbus.Struct((item_props, secret_props))},
|
|
211
|
+
"sv",
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
coll_obj = dbus.Interface(
|
|
216
|
+
bus.get_object(owner, str(dbusi.ReadAlias("default"))),
|
|
217
|
+
"org.freedesktop.Secret.Collection",
|
|
218
|
+
)
|
|
219
|
+
secret_arg = dbus.Struct((
|
|
220
|
+
session,
|
|
221
|
+
dbus.ByteArray(b""),
|
|
222
|
+
dbus.ByteArray(secret.encode("utf-8")),
|
|
223
|
+
"application/octet-stream",
|
|
224
|
+
))
|
|
225
|
+
props = dbus.Dictionary({
|
|
226
|
+
"org.freedesktop.Secret.Item.Label": dbus.ByteArray(
|
|
227
|
+
label.encode("utf-8")
|
|
228
|
+
),
|
|
229
|
+
"org.freedesktop.Secret.Item.Attributes":
|
|
230
|
+
dbus.Dictionary(
|
|
231
|
+
{k: v for k, v in attrs.items()}, "ss"
|
|
232
|
+
),
|
|
233
|
+
}, "sv")
|
|
234
|
+
coll_obj.CreateItem(props, secret_arg, True)
|
|
235
|
+
out({"ok": True})
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
if cmd == "lookup":
|
|
239
|
+
for path in find_items():
|
|
240
|
+
content = get_content(path)
|
|
241
|
+
if content:
|
|
242
|
+
out({
|
|
243
|
+
"ok": True,
|
|
244
|
+
"secret": content.decode("utf-8", "replace"),
|
|
245
|
+
})
|
|
246
|
+
return
|
|
247
|
+
out({"ok": False, "error": "item not found"})
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if cmd == "clear":
|
|
251
|
+
for path in find_items():
|
|
252
|
+
item_delete(path)
|
|
253
|
+
out({"ok": True})
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
out({"ok": False, "error": "unknown command"})
|
|
257
|
+
except Exception as e:
|
|
258
|
+
msg = str(e)
|
|
259
|
+
if secret:
|
|
260
|
+
msg = msg.replace(secret, "***")
|
|
261
|
+
out({"ok": False, "error": msg or e.__class__.__name__})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
main()
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
/** Attribute set that identifies one account in the keyring. */
|
|
268
|
+
export function walletAttrs(platform: string, login: string): Record<string, string> {
|
|
269
|
+
return { app: "pi-git-auth", platform, login: login.trim().toLowerCase() };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function writeScript(): boolean {
|
|
273
|
+
try {
|
|
274
|
+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
275
|
+
writeFileSync(PY_PATH, PY, { mode: 0o600 });
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Run one keyring round-trip. Returns null when python3 itself is missing. */
|
|
283
|
+
function call(req: Record<string, unknown>, timeoutMs = TIMEOUT_MS): WalletRes | null {
|
|
284
|
+
try {
|
|
285
|
+
if (!writeScript()) return null;
|
|
286
|
+
const r = spawnSync("python3", [PY_PATH], {
|
|
287
|
+
input: JSON.stringify(req),
|
|
288
|
+
encoding: "utf8",
|
|
289
|
+
timeout: timeoutMs,
|
|
290
|
+
});
|
|
291
|
+
if (r.error) return { ok: false, error: r.error.message };
|
|
292
|
+
const lines = (r.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
293
|
+
const last = lines[lines.length - 1];
|
|
294
|
+
if (last) {
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(last) as WalletRes;
|
|
297
|
+
} catch {
|
|
298
|
+
/* fall through to stderr-based error */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error:
|
|
304
|
+
(r.stderr ?? "").trim().split("\n").slice(-1)[0] ||
|
|
305
|
+
`keyring client exited with status ${r.status}`,
|
|
306
|
+
};
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let availCache: boolean | null = null;
|
|
313
|
+
|
|
314
|
+
/** True when a keyring (Secret Service) is reachable. Result is cached. */
|
|
315
|
+
export function walletAvailable(): boolean {
|
|
316
|
+
if (availCache !== null) return availCache;
|
|
317
|
+
const r = call({ cmd: "available" }, 5000);
|
|
318
|
+
availCache = !!(r && r.ok);
|
|
319
|
+
return availCache;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Store (upsert) a secret. */
|
|
323
|
+
export function walletStore(attrs: Record<string, string>, secret: string): boolean {
|
|
324
|
+
const r = call({ cmd: "store", attrs, secret });
|
|
325
|
+
return !!(r && r.ok);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Read a secret; null when not found or the keyring is unreachable. */
|
|
329
|
+
export function walletLookup(attrs: Record<string, string>): string | null {
|
|
330
|
+
const r = call({ cmd: "lookup", attrs });
|
|
331
|
+
return r && r.ok ? (r.secret ?? null) : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Remove every item matching attrs. No-op when none exist. */
|
|
335
|
+
export function walletClear(attrs: Record<string, string>): boolean {
|
|
336
|
+
const r = call({ cmd: "clear", attrs });
|
|
337
|
+
return !!(r && r.ok);
|
|
338
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-git-auth",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "Carlo Onofrio",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/comicrocharly/pi-git-auth.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-coding-agent",
|
|
15
|
+
"extension",
|
|
16
|
+
"git",
|
|
17
|
+
"git-auth",
|
|
18
|
+
"github",
|
|
19
|
+
"gitlab",
|
|
20
|
+
"keyring"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"*.ts",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"typebox": "*"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|