dsh-llm-codebuddy 1.3.3 → 1.3.5

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.
@@ -0,0 +1,186 @@
1
+ import { spawn } from "node:child_process";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+
4
+ export const CODEBUDDY_SESSION_REF = "CODEBUDDY_LOGIN_SESSION";
5
+
6
+ const BASE_URL = "https://copilot.tencent.com/v2/plugin";
7
+ const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1";
8
+ const REQUEST_HEADERS = {
9
+ accept: "application/json",
10
+ "content-type": "application/json",
11
+ "user-agent": USER_AGENT,
12
+ "x-product": "SaaS",
13
+ };
14
+ const NO_ACCOUNT_HEADERS = {
15
+ "X-No-Authorization": "true",
16
+ "X-No-User-Id": "true",
17
+ "X-No-Enterprise-Id": "true",
18
+ "X-No-Department-Info": "true",
19
+ };
20
+ const NO_ID_HEADERS = {
21
+ "X-No-User-Id": "true",
22
+ "X-No-Enterprise-Id": "true",
23
+ "X-No-Department-Info": "true",
24
+ };
25
+
26
+ function calculateExpiresAt(auth, now = Date.now()) {
27
+ const result = { ...auth };
28
+ if (!result.expiresAt && Number.isFinite(result.expiresIn)) result.expiresAt = now + result.expiresIn * 1000;
29
+ if (!result.refreshExpiresAt && Number.isFinite(result.refreshExpiresIn)) result.refreshExpiresAt = now + result.refreshExpiresIn * 1000;
30
+ return result;
31
+ }
32
+
33
+ async function responseBody(response, action) {
34
+ try {
35
+ return await response.json();
36
+ } catch (error) {
37
+ throw new Error(`${action}返回了无法解析的数据`, { cause: error });
38
+ }
39
+ }
40
+
41
+ async function request(path, options, action) {
42
+ let response;
43
+ try {
44
+ response = await fetch(`${BASE_URL}${path}`, options);
45
+ } catch (error) {
46
+ if (options.signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
47
+ throw new Error(`${action}无法连接 CodeBuddy 中国站`, { cause: error });
48
+ }
49
+ const body = await responseBody(response, action);
50
+ if (!response.ok || body?.code !== 0) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
51
+ return body.data;
52
+ }
53
+
54
+ function enterpriseHeaders(session) {
55
+ const enterpriseId = session.account?.enterpriseId;
56
+ return {
57
+ ...(enterpriseId ? { "X-Enterprise-Id": enterpriseId, "X-Tenant-Id": enterpriseId } : {}),
58
+ ...(session.auth?.domain ? { "X-Domain": session.auth.domain } : {}),
59
+ };
60
+ }
61
+
62
+ async function poll(path, headers, action, timeoutMs, signal) {
63
+ const deadline = Date.now() + timeoutMs;
64
+ while (Date.now() < deadline) {
65
+ await delay(1000, undefined, { signal });
66
+ let response;
67
+ try {
68
+ response = await fetch(`${BASE_URL}${path}`, { headers: { ...REQUEST_HEADERS, ...headers }, signal });
69
+ } catch (error) {
70
+ if (signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
71
+ continue;
72
+ }
73
+ const body = await responseBody(response, action);
74
+ if (response.ok && body?.code === 0 && body.data) return body.data;
75
+ if (response.status === 401 || response.status === 403) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
76
+ }
77
+ throw new Error(`${action}超时`);
78
+ }
79
+
80
+ function openBrowser(url) {
81
+ const [command, args] = process.platform === "win32"
82
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
83
+ : process.platform === "darwin"
84
+ ? ["open", [url]]
85
+ : ["xdg-open", [url]];
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
88
+ child.once("spawn", () => {
89
+ child.unref();
90
+ resolve();
91
+ });
92
+ child.once("error", reject);
93
+ });
94
+ }
95
+
96
+ function normalizeAccount(account) {
97
+ return {
98
+ ...(account?.userId || account?.uid ? { userId: account.userId ?? account.uid } : {}),
99
+ ...(account?.enterpriseId || account?.tenantId ? { enterpriseId: account.enterpriseId ?? account.tenantId } : {}),
100
+ };
101
+ }
102
+
103
+ export async function loginCodeBuddy(onAuthUrl, signal) {
104
+ const state = await request("/auth/state?platform=CLI", {
105
+ method: "POST",
106
+ headers: { ...REQUEST_HEADERS, ...NO_ACCOUNT_HEADERS },
107
+ body: "{}",
108
+ signal,
109
+ }, "创建 CodeBuddy 登录会话");
110
+ if (!state?.state || !state?.authUrl) throw new Error("CodeBuddy 登录接口没有返回登录地址");
111
+ try {
112
+ await openBrowser(state.authUrl);
113
+ onAuthUrl?.(state.authUrl, true);
114
+ } catch {
115
+ onAuthUrl?.(state.authUrl, false);
116
+ }
117
+ const auth = calculateExpiresAt(await poll(
118
+ `/auth/token?state=${encodeURIComponent(state.state)}`,
119
+ NO_ACCOUNT_HEADERS,
120
+ "等待 CodeBuddy 登录",
121
+ 10 * 60_000,
122
+ signal,
123
+ ));
124
+ if (!auth.accessToken || !auth.refreshToken) throw new Error("CodeBuddy 登录接口没有返回完整令牌");
125
+ const account = await poll(
126
+ `/login/account?state=${encodeURIComponent(state.state)}`,
127
+ { ...enterpriseHeaders({ auth }), authorization: `Bearer ${auth.accessToken}`, ...NO_ID_HEADERS },
128
+ "获取 CodeBuddy 账号",
129
+ 60_000,
130
+ signal,
131
+ );
132
+ return { auth, account: normalizeAccount(account) };
133
+ }
134
+
135
+ export async function refreshCodeBuddySession(session, signal) {
136
+ if (!session?.auth?.refreshToken) throw new Error("CodeBuddy 登录会话缺少刷新令牌,请重新登录");
137
+ const auth = await request("/auth/token/refresh", {
138
+ method: "POST",
139
+ headers: {
140
+ ...REQUEST_HEADERS,
141
+ ...enterpriseHeaders(session),
142
+ "X-Refresh-Token": session.auth.refreshToken,
143
+ "X-Auth-Refresh-Source": "plugin",
144
+ },
145
+ body: "{}",
146
+ signal,
147
+ }, "刷新 CodeBuddy 登录令牌");
148
+ const fresh = calculateExpiresAt(auth);
149
+ const merged = { ...session.auth, ...fresh, refreshToken: fresh?.refreshToken ?? session.auth.refreshToken };
150
+ if (!merged.accessToken) throw new Error("CodeBuddy 刷新接口没有返回访问令牌");
151
+ return { auth: merged, account: normalizeAccount(session.account) };
152
+ }
153
+
154
+ export function serializeCodeBuddySession(session) {
155
+ if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("CodeBuddy 登录会话无效");
156
+ return JSON.stringify({ auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account) });
157
+ }
158
+
159
+ export function parseCodeBuddySession(value) {
160
+ let session;
161
+ try {
162
+ session = JSON.parse(value);
163
+ } catch (error) {
164
+ throw new Error("CodeBuddy 登录凭据已损坏,请重新登录", { cause: error });
165
+ }
166
+ if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("CodeBuddy 登录凭据不完整,请重新登录");
167
+ return { auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account) };
168
+ }
169
+
170
+ export function sessionNeedsRefresh(session, now = Date.now()) {
171
+ const expiresAt = Number(session?.auth?.expiresAt);
172
+ if (Number.isFinite(expiresAt)) return expiresAt <= now + 2 * 60_000;
173
+ try {
174
+ const payload = JSON.parse(Buffer.from(session.auth.accessToken.split(".")[1], "base64url").toString("utf8"));
175
+ return Number.isFinite(payload.exp) ? payload.exp * 1000 <= now + 2 * 60_000 : true;
176
+ } catch {
177
+ return true;
178
+ }
179
+ }
180
+
181
+ export function sessionCacheDeadline(session, now = Date.now()) {
182
+ const expiresAt = Number(session?.auth?.expiresAt);
183
+ return Number.isFinite(expiresAt)
184
+ ? Math.max(now, Math.min(expiresAt - 2 * 60_000, now + 30 * 60_000))
185
+ : now + 5 * 60_000;
186
+ }
@@ -0,0 +1,98 @@
1
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
+ import { CODEBUDDY_SESSION_REF, loginCodeBuddy, serializeCodeBuddySession } from "./codebuddy-auth.js";
3
+
4
+ const PROVIDER = "codebuddy-cn";
5
+ const API_KEY_ENV = "CODEBUDDY_API_KEY";
6
+ const ROUTE = "/dsh-llm-codebuddy/auth";
7
+
8
+ export function authenticationMode(config) {
9
+ const profile = config?.providers?.[PROVIDER];
10
+ return profile && profile.apiKeyEnv === undefined ? "token" : "api-key";
11
+ }
12
+
13
+ function json(res, status, body) {
14
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
15
+ res.end(JSON.stringify(body));
16
+ }
17
+
18
+ function localPost(req) {
19
+ const address = req.socket.remoteAddress;
20
+ const loopback = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
21
+ if (!loopback) return false;
22
+ const origin = req.headers.origin;
23
+ if (!origin) return req.headers["sec-fetch-site"] === "same-origin";
24
+ try {
25
+ return ["127.0.0.1", "localhost", "[::1]"].includes(new URL(origin).hostname);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function setMode(settings, mode) {
32
+ const config = settings.get("llm-pi-ai");
33
+ const exists = Object.hasOwn(config?.providers ?? {}, PROVIDER);
34
+ const path = ["providers", PROVIDER];
35
+ if (!exists) {
36
+ await settings.mutate("llm-pi-ai", [{ op: "set", path, value: mode === "token" ? {} : { apiKeyEnv: API_KEY_ENV } }]);
37
+ return;
38
+ }
39
+ await settings.mutate("llm-pi-ai", [{
40
+ op: mode === "token" ? "unset" : "set",
41
+ path: [...path, "apiKeyEnv"],
42
+ ...(mode === "api-key" ? { value: API_KEY_ENV } : {}),
43
+ }]);
44
+ }
45
+
46
+ export function installCodeBuddyWeb(ctx) {
47
+ ctx.inject(["webServer", "settings", "credentials"], (webCtx) => {
48
+ let loginPromise;
49
+ const currentState = async () => ({
50
+ ok: true,
51
+ mode: authenticationMode(webCtx.settings.get("llm-pi-ai")),
52
+ authenticated: (await webCtx.credentials.describe(credentialRef(CODEBUDDY_SESSION_REF))).configured,
53
+ });
54
+ const status = async (_req, res) => {
55
+ json(res, 200, await currentState());
56
+ };
57
+ const apiKey = async (req, res) => {
58
+ if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
59
+ if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换认证方式" });
60
+ await setMode(webCtx.settings, "api-key");
61
+ json(res, 200, await currentState());
62
+ };
63
+ const token = async (req, res) => {
64
+ if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
65
+ if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换认证方式" });
66
+ const state = await currentState();
67
+ if (!state.authenticated) return json(res, 409, { ok: false, message: "尚未保存 CodeBuddy 登录令牌" });
68
+ await setMode(webCtx.settings, "token");
69
+ json(res, 200, await currentState());
70
+ };
71
+ const login = async (req, res) => {
72
+ if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
73
+ if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面登录" });
74
+ try {
75
+ loginPromise ??= (async () => {
76
+ const session = await loginCodeBuddy();
77
+ await webCtx.credentials.set(credentialRef(CODEBUDDY_SESSION_REF), serializeCodeBuddySession(session));
78
+ await setMode(webCtx.settings, "token");
79
+ })().finally(() => {
80
+ loginPromise = undefined;
81
+ });
82
+ await loginPromise;
83
+ json(res, 200, { ok: true, mode: "token", authenticated: true });
84
+ } catch (error) {
85
+ json(res, 500, { ok: false, message: error instanceof Error ? error.message : "CodeBuddy 登录失败" });
86
+ }
87
+ };
88
+ webCtx.effect(() => {
89
+ const dispose = [
90
+ webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/status`, handler: status }),
91
+ webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key`, handler: apiKey }),
92
+ webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/token`, handler: token }),
93
+ webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/login`, handler: login }),
94
+ ];
95
+ return () => dispose.forEach((fn) => fn());
96
+ }, "llm-codebuddy: web login routes");
97
+ });
98
+ }