dsh-connect-workbuddy 4.0.0 → 4.3.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/CHANGELOG.md +88 -0
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/docs/WINDOWS.md +38 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +1130 -15
- package/lib/{host-heartbeat-vvVjdY_q.js → host-heartbeat-jNlsUMZO.js} +610 -26
- package/lib/index.d.ts +297 -2
- package/lib/index.js +794 -11
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-jNlsUMZO.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -2337,7 +2337,7 @@ async function checkinOne(target, deps) {
|
|
|
2337
2337
|
return {
|
|
2338
2338
|
...base,
|
|
2339
2339
|
status: "failed",
|
|
2340
|
-
message: messageOf(error)
|
|
2340
|
+
message: messageOf$1(error)
|
|
2341
2341
|
};
|
|
2342
2342
|
}
|
|
2343
2343
|
if (credential === void 0) return {
|
|
@@ -2367,7 +2367,7 @@ async function checkinOne(target, deps) {
|
|
|
2367
2367
|
return {
|
|
2368
2368
|
...base,
|
|
2369
2369
|
status: "failed",
|
|
2370
|
-
message: messageOf(error)
|
|
2370
|
+
message: messageOf$1(error)
|
|
2371
2371
|
};
|
|
2372
2372
|
}
|
|
2373
2373
|
}
|
|
@@ -2413,7 +2413,7 @@ async function testOne(target, modelId, deps) {
|
|
|
2413
2413
|
result: {
|
|
2414
2414
|
modelId,
|
|
2415
2415
|
outcome: "credential-rejected",
|
|
2416
|
-
message: messageOf(error)
|
|
2416
|
+
message: messageOf$1(error)
|
|
2417
2417
|
}
|
|
2418
2418
|
};
|
|
2419
2419
|
}
|
|
@@ -2477,7 +2477,7 @@ function defaultWait(ms) {
|
|
|
2477
2477
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2478
2478
|
}
|
|
2479
2479
|
/** A safe message from an unknown throwable, bounded in length. */
|
|
2480
|
-
function messageOf(error) {
|
|
2480
|
+
function messageOf$1(error) {
|
|
2481
2481
|
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
2482
2482
|
}
|
|
2483
2483
|
/**
|
|
@@ -2689,6 +2689,469 @@ function resolveTargetModel(catalog, preferredId) {
|
|
|
2689
2689
|
};
|
|
2690
2690
|
}
|
|
2691
2691
|
//#endregion
|
|
2692
|
+
//#region src/oauth.ts
|
|
2693
|
+
/**
|
|
2694
|
+
* OAuth QR sign-in: add a WorkBuddy account by scanning a code, without the
|
|
2695
|
+
* desktop app.
|
|
2696
|
+
*
|
|
2697
|
+
* 参考:WorkBuddy OAuth 采集的通行实现(对照官网 cockpit 的
|
|
2698
|
+
* `oauth_start` / `oauth_poll`):
|
|
2699
|
+
* 向 `{billing_base}{API_PREFIX}/auth/state?platform={platform}` 申请一个
|
|
2700
|
+
* state,用户在浏览器打开授权页完成登录,客户端轮询
|
|
2701
|
+
* `{billing_base}{API_PREFIX}/auth/token?state={state}` 换取凭据,再以
|
|
2702
|
+
* Bearer 调 `{API_PREFIX}/login/account` 拉取账号资料。三步的 URL 形态、
|
|
2703
|
+
* 响应字段双拼写(camelCase/snake_case)容错、以及「区域必须绑定会话」的
|
|
2704
|
+
* 约定均沿用该实现。
|
|
2705
|
+
* 改动:端点按本插件 `upstream.ts` 的区域约定路由 —— CN 为
|
|
2706
|
+
* `https://www.codebuddy.cn`;国际版按凭据域分家(`workbuddy.ai` 与
|
|
2707
|
+
* `codebuddy.ai` 两把钥匙各自签发,混用会被网关拒绝),插件发起登录时
|
|
2708
|
+
* 还没有凭据,因此由用户显式传基址,缺省 `https://www.workbuddy.ai`。
|
|
2709
|
+
* 登录结果不落独立账号库,而是交给宿主写入统一凭据库(`src/auth.ts` 的
|
|
2710
|
+
* vault),与桌面端扫描到的账号共用一套发现/选择/刷新机制。
|
|
2711
|
+
*
|
|
2712
|
+
* 会话状态只在进程内(`LoginSession`):PKCE 类 state 与登录进度不落盘,
|
|
2713
|
+
* 拿到凭据即终结。刷新凭据仍走 `refreshToken`(上游 refresh 端点),此处
|
|
2714
|
+
* 只负责首次取凭据。
|
|
2715
|
+
*
|
|
2716
|
+
* @module dsh-connect-workbuddy/oauth
|
|
2717
|
+
*/
|
|
2718
|
+
/** The CN billing base; matches `upstream.ts`'s CN_BILLING_BASE. */
|
|
2719
|
+
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
2720
|
+
/** Default international base; the desktop app's gateway. */
|
|
2721
|
+
const GLOBAL_BILLING_BASE = "https://www.workbuddy.ai";
|
|
2722
|
+
/** Upstream plugin API prefix shared by both regions. */
|
|
2723
|
+
const API_PREFIX = "/v2/plugin";
|
|
2724
|
+
/** In-process session map. Deliberately not persisted: a restart cancels. */
|
|
2725
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2726
|
+
/**
|
|
2727
|
+
* The billing base a login for `region` should start from.
|
|
2728
|
+
*
|
|
2729
|
+
* CN is single-homed. The international product signs in through either of
|
|
2730
|
+
* two brand domains (see `upstream.ts` `globalBase`); the caller may pin
|
|
2731
|
+
* one with `explicitBase` — a token issued at one domain is refused by the
|
|
2732
|
+
* other, so the choice must be made BEFORE the state is issued, not after.
|
|
2733
|
+
*/
|
|
2734
|
+
function oauthBillingBase(region, explicitBase) {
|
|
2735
|
+
if (region === "cn") return CN_BILLING_BASE;
|
|
2736
|
+
const trimmed = explicitBase?.trim();
|
|
2737
|
+
if (trimmed !== void 0 && trimmed !== "") return normalizeBase(trimmed);
|
|
2738
|
+
return GLOBAL_BILLING_BASE;
|
|
2739
|
+
}
|
|
2740
|
+
/** trim + strip one trailing slash; no scheme invented (see upstream.ts). */
|
|
2741
|
+
function normalizeBase(base) {
|
|
2742
|
+
return base.trim().replace(/\/+$/, "");
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Start one login: ask the upstream for a state and return the URL the user
|
|
2746
|
+
* opens to scan/confirm. The session is bound to `region` AND `base`;
|
|
2747
|
+
* both are checked on every poll.
|
|
2748
|
+
*
|
|
2749
|
+
* Never throws for an upstream refusal — the error becomes the returned
|
|
2750
|
+
* `error` — because the card renders the reason inline rather than through
|
|
2751
|
+
* an exception path.
|
|
2752
|
+
*/
|
|
2753
|
+
async function oauthStart(region, options) {
|
|
2754
|
+
const base = normalizeBase(oauthBillingBase(region, options?.base));
|
|
2755
|
+
const doFetch = options?.fetchImpl ?? fetch;
|
|
2756
|
+
let response;
|
|
2757
|
+
try {
|
|
2758
|
+
response = await doFetch(`${base}${API_PREFIX}/auth/state?platform=workbuddy`, {
|
|
2759
|
+
method: "POST",
|
|
2760
|
+
headers: {
|
|
2761
|
+
"Content-Type": "application/json",
|
|
2762
|
+
"Accept": "application/json"
|
|
2763
|
+
},
|
|
2764
|
+
body: "{}",
|
|
2765
|
+
signal: AbortSignal.timeout(3e4)
|
|
2766
|
+
});
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
return { error: `could not reach ${base} to start the sign-in (${messageOf(error)})` };
|
|
2769
|
+
}
|
|
2770
|
+
const document = await readJson(response);
|
|
2771
|
+
if (document === void 0) return { error: `the sign-in service answered with a non-JSON body (http ${response.status})` };
|
|
2772
|
+
const data = envelopeData(document);
|
|
2773
|
+
const state = firstString(data, ["state", "State"]);
|
|
2774
|
+
if (state === "") return { error: `the sign-in service did not issue a state (http ${response.status})` };
|
|
2775
|
+
const authUrl = firstString(data, [
|
|
2776
|
+
"authUrl",
|
|
2777
|
+
"auth_url",
|
|
2778
|
+
"url",
|
|
2779
|
+
"Url"
|
|
2780
|
+
]) || `${base}/login?state=${encodeURIComponent(state)}`;
|
|
2781
|
+
const loginId = `wbo_${randomUUID().replaceAll("-", "")}`;
|
|
2782
|
+
sessions.set(loginId, {
|
|
2783
|
+
region,
|
|
2784
|
+
base,
|
|
2785
|
+
state,
|
|
2786
|
+
expiresAtMs: Date.now() + 6e5,
|
|
2787
|
+
done: false
|
|
2788
|
+
});
|
|
2789
|
+
return {
|
|
2790
|
+
loginId,
|
|
2791
|
+
verificationUri: authUrl,
|
|
2792
|
+
expiresIn: 600
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Poll once for a login's outcome.
|
|
2797
|
+
*
|
|
2798
|
+
* Ordering matters and mirrors the reference implementation: the session's
|
|
2799
|
+
* region/base binding is checked BEFORE any I/O, so a poll that names the
|
|
2800
|
+
* wrong tab never reaches the network, and an unknown id fails without a
|
|
2801
|
+
* request too.
|
|
2802
|
+
*/
|
|
2803
|
+
async function oauthPoll(loginId, region, options) {
|
|
2804
|
+
const session = sessions.get(loginId);
|
|
2805
|
+
if (session === void 0) return {
|
|
2806
|
+
done: true,
|
|
2807
|
+
error: "sign-in session not found; start a new one"
|
|
2808
|
+
};
|
|
2809
|
+
if (session.region !== region) return {
|
|
2810
|
+
done: true,
|
|
2811
|
+
error: "sign-in session belongs to another region tab"
|
|
2812
|
+
};
|
|
2813
|
+
if (session.done) return terminalOf(session);
|
|
2814
|
+
if (Date.now() > session.expiresAtMs) {
|
|
2815
|
+
session.done = true;
|
|
2816
|
+
session.error = "the sign-in timed out; start again";
|
|
2817
|
+
sessions.delete(loginId);
|
|
2818
|
+
return {
|
|
2819
|
+
done: true,
|
|
2820
|
+
error: session.error
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
const doFetch = options?.fetchImpl ?? fetch;
|
|
2824
|
+
let response;
|
|
2825
|
+
try {
|
|
2826
|
+
response = await doFetch(`${session.base}${API_PREFIX}/auth/token?state=${encodeURIComponent(session.state)}`, {
|
|
2827
|
+
method: "GET",
|
|
2828
|
+
headers: { "Accept": "application/json" },
|
|
2829
|
+
signal: AbortSignal.timeout(3e4)
|
|
2830
|
+
});
|
|
2831
|
+
} catch {
|
|
2832
|
+
return { done: false };
|
|
2833
|
+
}
|
|
2834
|
+
const document = await readJson(response);
|
|
2835
|
+
if (document === void 0) return { done: false };
|
|
2836
|
+
const code = typeof document["code"] === "number" ? document["code"] : -1;
|
|
2837
|
+
if (code !== 0 && code !== 200) return { done: false };
|
|
2838
|
+
const data = envelopeData(document);
|
|
2839
|
+
const accessToken = firstString(data, [
|
|
2840
|
+
"accessToken",
|
|
2841
|
+
"access_token",
|
|
2842
|
+
"AccessToken"
|
|
2843
|
+
]);
|
|
2844
|
+
if (accessToken === "") return { done: false };
|
|
2845
|
+
session.done = true;
|
|
2846
|
+
try {
|
|
2847
|
+
const account = await collectAccount(session, accessToken, data, doFetch);
|
|
2848
|
+
sessions.delete(loginId);
|
|
2849
|
+
return {
|
|
2850
|
+
done: true,
|
|
2851
|
+
account
|
|
2852
|
+
};
|
|
2853
|
+
} catch (error) {
|
|
2854
|
+
session.error = messageOf(error);
|
|
2855
|
+
sessions.delete(loginId);
|
|
2856
|
+
return {
|
|
2857
|
+
done: true,
|
|
2858
|
+
error: session.error
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
/** Terminal view of a session, for a poll that arrives after completion. */
|
|
2863
|
+
function terminalOf(session) {
|
|
2864
|
+
if (session.result !== void 0) return {
|
|
2865
|
+
done: true,
|
|
2866
|
+
account: session.result
|
|
2867
|
+
};
|
|
2868
|
+
return {
|
|
2869
|
+
done: true,
|
|
2870
|
+
error: session.error ?? "the sign-in ended without a result"
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
/** Fetch the account profile and assemble the success value. */
|
|
2874
|
+
async function collectAccount(session, accessToken, tokenData, doFetch) {
|
|
2875
|
+
const headers = {
|
|
2876
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
2877
|
+
"Accept": "application/json"
|
|
2878
|
+
};
|
|
2879
|
+
const domain = firstString(tokenData, ["domain", "Domain"]);
|
|
2880
|
+
if (domain !== "") headers["X-Domain"] = domain;
|
|
2881
|
+
let profile = {};
|
|
2882
|
+
try {
|
|
2883
|
+
const document = await readJson(await doFetch(`${session.base}${API_PREFIX}/login/account`, {
|
|
2884
|
+
method: "GET",
|
|
2885
|
+
headers,
|
|
2886
|
+
signal: AbortSignal.timeout(3e4)
|
|
2887
|
+
}));
|
|
2888
|
+
if (document !== void 0) profile = envelopeData(document);
|
|
2889
|
+
} catch {}
|
|
2890
|
+
const uid = firstString(profile, [
|
|
2891
|
+
"uid",
|
|
2892
|
+
"Uid",
|
|
2893
|
+
"userId",
|
|
2894
|
+
"UserID"
|
|
2895
|
+
]);
|
|
2896
|
+
const nickname = optionalString(profile, [
|
|
2897
|
+
"nickname",
|
|
2898
|
+
"NickName",
|
|
2899
|
+
"nickname",
|
|
2900
|
+
"ScreenName"
|
|
2901
|
+
]);
|
|
2902
|
+
const uin = optionalString(profile, ["uin", "Uin"]);
|
|
2903
|
+
const enterpriseId = optionalString(profile, ["enterpriseId", "EnterpriseId"]);
|
|
2904
|
+
const expiresAtMs = expiryMs(tokenData, ["expiresAt", "expires_at"], ["expiresIn", "expires_in"]) ?? 0;
|
|
2905
|
+
const refreshExpiresAtMs = expiryMs(tokenData, ["refreshExpiresAt", "refresh_expires_at"], ["refreshExpiresIn", "refresh_expires_in"]);
|
|
2906
|
+
return {
|
|
2907
|
+
accessToken,
|
|
2908
|
+
refreshToken: firstString(tokenData, [
|
|
2909
|
+
"refreshToken",
|
|
2910
|
+
"refresh_token",
|
|
2911
|
+
"RefreshToken"
|
|
2912
|
+
]),
|
|
2913
|
+
expiresAtMs,
|
|
2914
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
2915
|
+
domain: domain !== "" ? domain : firstString(profile, ["domain", "Domain"]),
|
|
2916
|
+
uid,
|
|
2917
|
+
...nickname === void 0 ? {} : { nickname },
|
|
2918
|
+
...uin === void 0 ? {} : { uin },
|
|
2919
|
+
...enterpriseId === void 0 ? {} : { enterpriseId }
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
/** Parse a JSON body; undefined for non-JSON (the caller decides severity). */
|
|
2923
|
+
async function readJson(response) {
|
|
2924
|
+
try {
|
|
2925
|
+
const text = await response.text();
|
|
2926
|
+
const parsed = JSON.parse(text);
|
|
2927
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
2928
|
+
} catch {}
|
|
2929
|
+
}
|
|
2930
|
+
/** The envelope's `data` object; a non-object data is an empty one. */
|
|
2931
|
+
function envelopeData(document) {
|
|
2932
|
+
const data = document["data"];
|
|
2933
|
+
return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
|
|
2934
|
+
}
|
|
2935
|
+
/** First non-empty string among `keys`, else ''. */
|
|
2936
|
+
function firstString(source, keys) {
|
|
2937
|
+
for (const key of keys) {
|
|
2938
|
+
const value = source[key];
|
|
2939
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
2940
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
2941
|
+
}
|
|
2942
|
+
return "";
|
|
2943
|
+
}
|
|
2944
|
+
/** First non-empty string among `keys`, else undefined. */
|
|
2945
|
+
function optionalString(source, keys) {
|
|
2946
|
+
const value = firstString(source, keys);
|
|
2947
|
+
return value === "" ? void 0 : value;
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* An expiry field that may arrive as an absolute timestamp (s or ms) or as a
|
|
2951
|
+
* relative TTL in seconds — the same tolerance `auth.ts` `expiryToMs` and
|
|
2952
|
+
* the reference implementation's `norm_ts` apply.
|
|
2953
|
+
*/
|
|
2954
|
+
function expiryMs(source, absoluteKeys, ttlKeys) {
|
|
2955
|
+
for (const key of absoluteKeys) {
|
|
2956
|
+
const value = source[key];
|
|
2957
|
+
if (typeof value === "number" && value > 0) return value > 0xe8d4a51000 ? value : value * 1e3;
|
|
2958
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
2959
|
+
const parsed = Number(value);
|
|
2960
|
+
return parsed > 0xe8d4a51000 ? parsed : parsed * 1e3;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
for (const key of ttlKeys) {
|
|
2964
|
+
const value = source[key];
|
|
2965
|
+
if (typeof value === "number" && value > 0) return Date.now() + value * 1e3;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
/** Human message from a thrown error, for the card's inline display. */
|
|
2969
|
+
function messageOf(error) {
|
|
2970
|
+
return error instanceof Error ? error.message : String(error);
|
|
2971
|
+
}
|
|
2972
|
+
//#endregion
|
|
2973
|
+
//#region src/transfer.ts
|
|
2974
|
+
/**
|
|
2975
|
+
* Account transfer: the export / import file format shared with sibling
|
|
2976
|
+
* credential managers for the same upstream.
|
|
2977
|
+
*
|
|
2978
|
+
* 参考:通行实现 —— 文件是一个 JSON 数组,每项为一条凭据记录,顶层字段
|
|
2979
|
+
* snake_case(`access_token` / `refresh_token` / `uid` / `nickname` /
|
|
2980
|
+
* `email` / `domain` / `expiresAt`(ms) / `refreshExpiresAt` / `auth_raw`),
|
|
2981
|
+
* 导入按 `uid` 去重(同 uid 覆盖),缺 `access_token` 的记录跳过;预览只
|
|
2982
|
+
* 回脱敏字段(含 `hasToken` 布尔量),不回明文。解析函数不依赖文件系统,
|
|
2983
|
+
* 便于无 UI 环境单测;落库由 Host 侧的 store 完成。
|
|
2984
|
+
* 互通:`expiresAt` 一族按契约是**毫秒**(由 auth_raw 的原始认证文档携带);
|
|
2985
|
+
* 本插件的 vault 文档 `expiresAt` 是秒 —— 换算只发生在导出/导入边界。
|
|
2986
|
+
* 导出时 `auth_raw` 直接携带本插件 vault 文档的 `{auth, account}` 双键块,
|
|
2987
|
+
* 与桌面认证文件同构,供写入桌面认证文件的工具原样消费。
|
|
2988
|
+
*
|
|
2989
|
+
* @module dsh-connect-workbuddy/transfer
|
|
2990
|
+
*/
|
|
2991
|
+
/**
|
|
2992
|
+
* Parse transfer-file text: a JSON array, every element an object.
|
|
2993
|
+
*
|
|
2994
|
+
* The error strings carry the position (1-based item number) because a file
|
|
2995
|
+
* from another tool can be big, and "第 N 项" is what lets the user find the
|
|
2996
|
+
* broken record in their editor.
|
|
2997
|
+
*/
|
|
2998
|
+
function parseTransferFile(text) {
|
|
2999
|
+
if (text.trim() === "") throw new Error("transfer file is empty");
|
|
3000
|
+
let parsed;
|
|
3001
|
+
try {
|
|
3002
|
+
parsed = JSON.parse(text);
|
|
3003
|
+
} catch (error) {
|
|
3004
|
+
throw new Error("transfer file is not valid JSON: " + (error instanceof Error ? error.message : String(error)));
|
|
3005
|
+
}
|
|
3006
|
+
if (!Array.isArray(parsed)) throw new Error("transfer file must be a JSON array of account records");
|
|
3007
|
+
for (const [index, item] of parsed.entries()) if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("transfer file item " + (index + 1) + " is not an account object");
|
|
3008
|
+
return parsed;
|
|
3009
|
+
}
|
|
3010
|
+
/** One string field of a record, tolerating the sibling format's odd shapes. */
|
|
3011
|
+
function displayString(value) {
|
|
3012
|
+
if (typeof value === "string") return value;
|
|
3013
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
3014
|
+
return "";
|
|
3015
|
+
}
|
|
3016
|
+
/** Whether a record carries a usable token, envelope-shaped or plain. */
|
|
3017
|
+
function hasToken(record) {
|
|
3018
|
+
const token = record["access_token"];
|
|
3019
|
+
return typeof token === "string" && token !== "";
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* The transfer file's desensitized preview: display fields only.
|
|
3023
|
+
*
|
|
3024
|
+
* Fields come from EXTERNAL files, so a display field may hold an object or an
|
|
3025
|
+
* array (a sibling tool can store the desktop app's encrypted-wrapper nickname
|
|
3026
|
+
* verbatim); every preview field is normalized to a string or emptied rather
|
|
3027
|
+
* than letting a non-renderable value reach the card.
|
|
3028
|
+
*/
|
|
3029
|
+
function previewTransferFile(text) {
|
|
3030
|
+
const records = parseTransferFile(text);
|
|
3031
|
+
return {
|
|
3032
|
+
accounts: records.map((record, index) => ({
|
|
3033
|
+
index,
|
|
3034
|
+
uid: displayString(record["uid"]),
|
|
3035
|
+
nickname: displayString(record["nickname"]),
|
|
3036
|
+
email: displayString(record["email"]),
|
|
3037
|
+
hasToken: hasToken(record)
|
|
3038
|
+
})),
|
|
3039
|
+
total: records.length
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Project one vault credential into the shared transfer record shape.
|
|
3044
|
+
*
|
|
3045
|
+
* `expiresAt` is emitted in MILLISECONDS — the transfer contract's unit and
|
|
3046
|
+
* the sibling tools' display field — while the vault document stores seconds.
|
|
3047
|
+
* `auth_raw` carries the vault's nested `{auth, account}` document so a tool
|
|
3048
|
+
* that writes desktop auth files can consume it verbatim.
|
|
3049
|
+
*/
|
|
3050
|
+
function transferRecordOf(credential, vaultDocument) {
|
|
3051
|
+
return {
|
|
3052
|
+
access_token: credential.accessToken,
|
|
3053
|
+
...credential.refreshToken !== "" ? { refresh_token: credential.refreshToken } : {},
|
|
3054
|
+
...credential.uid !== "" ? { uid: credential.uid } : {},
|
|
3055
|
+
...credential.nickname !== void 0 ? { nickname: credential.nickname } : {},
|
|
3056
|
+
...credential.domain !== "" ? { domain: credential.domain } : {},
|
|
3057
|
+
...credential.expiresAtMs > 0 ? { expiresAt: credential.expiresAtMs } : {},
|
|
3058
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: credential.refreshExpiresAtMs },
|
|
3059
|
+
auth_raw: vaultDocument
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
/**
|
|
3063
|
+
* Recover `uin` from a record's `auth_raw` document.
|
|
3064
|
+
*
|
|
3065
|
+
* `uin` is the billing identity the account id derives from
|
|
3066
|
+
* (`uin ?? uid ?? nickname`), but it is NOT one of the transfer format's
|
|
3067
|
+
* contract fields — sibling exports carry it only inside `auth_raw`
|
|
3068
|
+
* (the original auth document, whose `account` object holds it). Restoring it
|
|
3069
|
+
* is what makes an exported-and-re-imported account converge on the SAME
|
|
3070
|
+
* vault id instead of duplicating under a uid-derived id. Both document
|
|
3071
|
+
* shapes are honored: the nested `{auth, account}` block this plugin
|
|
3072
|
+
* exports, and a record whose `auth_raw.account` came straight from a
|
|
3073
|
+
* desktop file.
|
|
3074
|
+
*/
|
|
3075
|
+
function uinOfRaw(raw) {
|
|
3076
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
3077
|
+
const root = raw;
|
|
3078
|
+
const uin = displayString((typeof root["account"] === "object" && root["account"] !== null ? root["account"] : root)["uin"]);
|
|
3079
|
+
return uin === "" ? void 0 : uin;
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Project one transfer record into a vault credential, or undefined when the
|
|
3083
|
+
* record carries no token (the importer's skip rule).
|
|
3084
|
+
*
|
|
3085
|
+
* `uid` empty is tolerated at THIS layer because a token-only record still
|
|
3086
|
+
* describes a usable credential; the Host's region gate and the store's
|
|
3087
|
+
* account id (`uin ?? uid ?? nickname`) decide whether it is representable.
|
|
3088
|
+
* An unopenable `auth_raw` is ignored — the flat fields above are the
|
|
3089
|
+
* contract, `auth_raw` is extra fidelity for the write-back path.
|
|
3090
|
+
*/
|
|
3091
|
+
function credentialOfTransferRecord(record) {
|
|
3092
|
+
const accessToken = record["access_token"];
|
|
3093
|
+
if (typeof accessToken !== "string" || accessToken === "") return void 0;
|
|
3094
|
+
const refreshToken = typeof record["refresh_token"] === "string" ? record["refresh_token"] : "";
|
|
3095
|
+
const expiresAtMs = typeof record["expiresAt"] === "number" && Number.isFinite(record["expiresAt"]) && record["expiresAt"] > 0 ? record["expiresAt"] : 0;
|
|
3096
|
+
const refreshExpiresAtMs = typeof record["refreshExpiresAt"] === "number" && Number.isFinite(record["refreshExpiresAt"]) && record["refreshExpiresAt"] > 0 ? record["refreshExpiresAt"] : void 0;
|
|
3097
|
+
const uin = uinOfRaw(record["auth_raw"]);
|
|
3098
|
+
return {
|
|
3099
|
+
accessToken,
|
|
3100
|
+
refreshToken,
|
|
3101
|
+
expiresAtMs,
|
|
3102
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
3103
|
+
domain: typeof record["domain"] === "string" ? record["domain"] : "",
|
|
3104
|
+
uid: displayString(record["uid"]),
|
|
3105
|
+
...record["nickname"] !== void 0 ? { nickname: displayString(record["nickname"]) } : {},
|
|
3106
|
+
...uin === void 0 ? {} : { uin }
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Pure merge: fold selected records into a CHANGES map keyed by account id.
|
|
3111
|
+
*
|
|
3112
|
+
* Mirrors the sibling merger's contract: a record with the same account id
|
|
3113
|
+
* OVERWRITES the stored entry (the imported tokens replace what is stored —
|
|
3114
|
+
* re-importing a re-login must refresh it; the write layer stores the record
|
|
3115
|
+
* atomically, so "overwrite" needs no knowledge of the incumbent), and a
|
|
3116
|
+
* record without a token is skipped. The region gate lives HERE rather than
|
|
3117
|
+
* in the route so it is testable without a Host: a record whose login domain
|
|
3118
|
+
* maps to the other region's tab is skipped, because the two regions'
|
|
3119
|
+
* providers are parallel stacks and a cross-region import would cross-bill.
|
|
3120
|
+
*
|
|
3121
|
+
* The caller writes one vault file per map entry, so a selection that is
|
|
3122
|
+
* already stored rewrites those files with identical content — idempotent,
|
|
3123
|
+
* and the steady state of importing the same file twice.
|
|
3124
|
+
*/
|
|
3125
|
+
function mergeTransferRecords(records, indexes, region, knownRegionOfDomain) {
|
|
3126
|
+
const changes = /* @__PURE__ */ new Map();
|
|
3127
|
+
let imported = 0;
|
|
3128
|
+
let skipped = 0;
|
|
3129
|
+
for (const index of indexes) {
|
|
3130
|
+
const record = records[index];
|
|
3131
|
+
if (record === void 0) {
|
|
3132
|
+
skipped += 1;
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
const credential = credentialOfTransferRecord(record);
|
|
3136
|
+
if (credential === void 0) {
|
|
3137
|
+
skipped += 1;
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
if (knownRegionOfDomain(credential.domain) !== region) {
|
|
3141
|
+
skipped += 1;
|
|
3142
|
+
continue;
|
|
3143
|
+
}
|
|
3144
|
+
const id = workbuddyAccountId(credential);
|
|
3145
|
+
changes.set(id, credential);
|
|
3146
|
+
imported += 1;
|
|
3147
|
+
}
|
|
3148
|
+
return {
|
|
3149
|
+
changes,
|
|
3150
|
+
imported,
|
|
3151
|
+
skipped
|
|
3152
|
+
};
|
|
3153
|
+
}
|
|
3154
|
+
//#endregion
|
|
2692
3155
|
//#region src/account-pool-store.ts
|
|
2693
3156
|
/**
|
|
2694
3157
|
* The account pool's RUNTIME store: measured facts, on disk, outside settings.
|
|
@@ -2872,14 +3335,59 @@ const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
|
|
|
2872
3335
|
* splitting them would duplicate the guards and invite them to drift apart.
|
|
2873
3336
|
*/
|
|
2874
3337
|
const WORKBUDDY_POOL_PATH = "/plugins/dsh-connect-workbuddy/pool";
|
|
3338
|
+
/**
|
|
3339
|
+
* Plugin-owned OAuth sign-in endpoint: one session step per call.
|
|
3340
|
+
*
|
|
3341
|
+
* `?action=start` asks the upstream for a login state and returns the URL to
|
|
3342
|
+
* open; `?action=poll` (+ `loginId` in the body) reports whether the user
|
|
3343
|
+
* finished, and on success persists the account into the vault. POST +
|
|
3344
|
+
* loopback-only like the card's other mutations: the poll carries the login
|
|
3345
|
+
* binding, and the Host-side session state must not be settable by another
|
|
3346
|
+
* origin.
|
|
3347
|
+
*/
|
|
3348
|
+
const WORKBUDDY_OAUTH_PATH = "/plugins/dsh-connect-workbuddy/oauth";
|
|
2875
3349
|
/** Query parameter selecting which pool batch action a request means. */
|
|
2876
3350
|
const WORKBUDDY_POOL_ACTION_PARAM = "action";
|
|
3351
|
+
/** Read the OAuth action off a request URL; unknown/absent means undefined. */
|
|
3352
|
+
function oauthActionOf(url) {
|
|
3353
|
+
const at = url.indexOf("?");
|
|
3354
|
+
if (at === -1) return void 0;
|
|
3355
|
+
const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
|
|
3356
|
+
return value === "start" || value === "poll" ? value : void 0;
|
|
3357
|
+
}
|
|
2877
3358
|
/** Read the pool action off a request URL; unknown/absent means undefined. */
|
|
2878
3359
|
function poolActionOf(url) {
|
|
2879
3360
|
const at = url.indexOf("?");
|
|
2880
3361
|
if (at === -1) return void 0;
|
|
2881
3362
|
const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
|
|
2882
|
-
return value === "checkin" || value === "test" ? value : void 0;
|
|
3363
|
+
return value === "checkin" || value === "test" || value === "remove" ? value : void 0;
|
|
3364
|
+
}
|
|
3365
|
+
/** Query parameter naming ONE pool account a per-row request addresses. */
|
|
3366
|
+
const WORKBUDDY_POOL_ACCOUNT_PARAM = "accountId";
|
|
3367
|
+
/**
|
|
3368
|
+
* Plugin-owned account transfer endpoint: the batch export / import of
|
|
3369
|
+
* credentials between machines and between tools of the SAME format family.
|
|
3370
|
+
*
|
|
3371
|
+
* `?action=export` POSTs account ids, answers a JSON ARRAY of credential
|
|
3372
|
+
* records; `?action=preview` POSTs file text, answers a token-free listing;
|
|
3373
|
+
* `?action=import` POSTs file text plus selected indexes, merges into the
|
|
3374
|
+
* vault. POST + loopback-only like every other mutation: the file content is
|
|
3375
|
+
* arbitrary user data and export carries token material.
|
|
3376
|
+
*/
|
|
3377
|
+
const WORKBUDDY_TRANSFER_PATH = "/plugins/dsh-connect-workbuddy/transfer";
|
|
3378
|
+
/** Read the transfer action off a request URL; unknown/absent means undefined. */
|
|
3379
|
+
function transferActionOf(url) {
|
|
3380
|
+
const at = url.indexOf("?");
|
|
3381
|
+
if (at === -1) return void 0;
|
|
3382
|
+
const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
|
|
3383
|
+
return value === "export" || value === "preview" || value === "import" ? value : void 0;
|
|
3384
|
+
}
|
|
3385
|
+
/** Read the per-row account id off a request URL, when the action takes one. */
|
|
3386
|
+
function poolAccountIdOf(url) {
|
|
3387
|
+
const at = url.indexOf("?");
|
|
3388
|
+
if (at === -1) return void 0;
|
|
3389
|
+
const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACCOUNT_PARAM);
|
|
3390
|
+
return value !== null && value !== "" ? value : void 0;
|
|
2883
3391
|
}
|
|
2884
3392
|
/** Query parameter naming the region a card request addresses. */
|
|
2885
3393
|
const WORKBUDDY_REGION_PARAM = "region";
|
|
@@ -3039,6 +3547,23 @@ function json(res, status, body) {
|
|
|
3039
3547
|
});
|
|
3040
3548
|
res.end(payload);
|
|
3041
3549
|
}
|
|
3550
|
+
/**
|
|
3551
|
+
* A JSON response that must never be cached.
|
|
3552
|
+
*
|
|
3553
|
+
* Used for every answer that carries live credentials — the transfer export
|
|
3554
|
+
* hands the browser real access and refresh tokens. Without `no-store` such a
|
|
3555
|
+
* response is eligible for the browser's HTTP cache, where a token can outlive
|
|
3556
|
+
* the session on disk.
|
|
3557
|
+
*/
|
|
3558
|
+
function jsonPrivate(res, status, body) {
|
|
3559
|
+
const payload = JSON.stringify(body);
|
|
3560
|
+
res.writeHead(status, {
|
|
3561
|
+
"Content-Type": "application/json",
|
|
3562
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
3563
|
+
"Cache-Control": "no-store"
|
|
3564
|
+
});
|
|
3565
|
+
res.end(payload);
|
|
3566
|
+
}
|
|
3042
3567
|
/** Request-body cap for the probe route: model ids only, never content. */
|
|
3043
3568
|
const PROBE_BODY_LIMIT = 65536;
|
|
3044
3569
|
/**
|
|
@@ -3427,9 +3952,14 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
3427
3952
|
const region = requestRegion(req, res);
|
|
3428
3953
|
if (region === void 0) return;
|
|
3429
3954
|
try {
|
|
3430
|
-
const
|
|
3955
|
+
const store = deps.store(region);
|
|
3956
|
+
const dismissalsCleared = await store.clearDismissals();
|
|
3957
|
+
const accounts = await store.accounts();
|
|
3431
3958
|
deps.regionUsable?.(region, accounts.length > 0);
|
|
3432
|
-
json(res, 200, {
|
|
3959
|
+
json(res, 200, {
|
|
3960
|
+
accounts: accounts.map(toWebAccount),
|
|
3961
|
+
dismissalsCleared
|
|
3962
|
+
});
|
|
3433
3963
|
} catch (error) {
|
|
3434
3964
|
json(res, 500, { error: safeMessage(error) });
|
|
3435
3965
|
}
|
|
@@ -3508,12 +4038,60 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
3508
4038
|
const region = requestRegion(req, res);
|
|
3509
4039
|
if (region === void 0) return;
|
|
3510
4040
|
const action = poolActionOf(req.url ?? "/");
|
|
3511
|
-
if (action === void 0) return json(res, 400, { error: "action must be checkin or
|
|
4041
|
+
if (action === void 0) return json(res, 400, { error: "action must be checkin, test, or remove" });
|
|
3512
4042
|
const pool = deps.pool;
|
|
3513
4043
|
if (pool === void 0) return json(res, 503, {
|
|
3514
4044
|
reason: "pool-unavailable",
|
|
3515
4045
|
error: "account pool unavailable"
|
|
3516
4046
|
});
|
|
4047
|
+
const rowAccountId = poolAccountIdOf(req.url ?? "/");
|
|
4048
|
+
if (action === "remove") {
|
|
4049
|
+
if (pool.removeOne === void 0) return json(res, 503, {
|
|
4050
|
+
reason: "pool-unavailable",
|
|
4051
|
+
error: "per-account removal unavailable"
|
|
4052
|
+
});
|
|
4053
|
+
if (rowAccountId === void 0) return json(res, 400, { error: "accountId must name the account to remove" });
|
|
4054
|
+
if (!isVaultAccountId(rowAccountId)) return json(res, 400, { error: "accountId is not a well-formed account id" });
|
|
4055
|
+
try {
|
|
4056
|
+
json(res, 200, {
|
|
4057
|
+
action: "remove",
|
|
4058
|
+
accountId: rowAccountId,
|
|
4059
|
+
removed: await pool.removeOne(region, rowAccountId)
|
|
4060
|
+
});
|
|
4061
|
+
} catch (error) {
|
|
4062
|
+
json(res, 500, {
|
|
4063
|
+
reason: "pool-failed",
|
|
4064
|
+
error: safeMessage(error)
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
4069
|
+
if (action === "test" && rowAccountId !== void 0) {
|
|
4070
|
+
if (pool.testOne === void 0) return json(res, 503, {
|
|
4071
|
+
reason: "pool-unavailable",
|
|
4072
|
+
error: "per-account test unavailable"
|
|
4073
|
+
});
|
|
4074
|
+
try {
|
|
4075
|
+
const row = await pool.testOne(region, rowAccountId);
|
|
4076
|
+
if (row === void 0) return json(res, 409, {
|
|
4077
|
+
action: "test",
|
|
4078
|
+
modelId: void 0,
|
|
4079
|
+
rows: [],
|
|
4080
|
+
reason: "no-free-model",
|
|
4081
|
+
error: "no zero-multiplier model in this region; refresh the catalog or set a target model"
|
|
4082
|
+
});
|
|
4083
|
+
json(res, 200, {
|
|
4084
|
+
action: "test",
|
|
4085
|
+
rows: [row]
|
|
4086
|
+
});
|
|
4087
|
+
} catch (error) {
|
|
4088
|
+
json(res, 500, {
|
|
4089
|
+
reason: "pool-failed",
|
|
4090
|
+
error: safeMessage(error)
|
|
4091
|
+
});
|
|
4092
|
+
}
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
3517
4095
|
try {
|
|
3518
4096
|
const savedMembers = pool.preferences(region).memberAccountIds;
|
|
3519
4097
|
if ((await pool.effectiveMemberAccountIds?.(region).catch(() => void 0) ?? savedMembers).length === 0) {
|
|
@@ -3645,6 +4223,69 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
3645
4223
|
}
|
|
3646
4224
|
}
|
|
3647
4225
|
});
|
|
4226
|
+
const disposeOAuth = ctx.webServer.register({
|
|
4227
|
+
kind: "exact",
|
|
4228
|
+
path: WORKBUDDY_OAUTH_PATH,
|
|
4229
|
+
handler: async (req, res) => {
|
|
4230
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
4231
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
4232
|
+
const region = requestRegion(req, res);
|
|
4233
|
+
if (region === void 0) return;
|
|
4234
|
+
const action = oauthActionOf(req.url ?? "/");
|
|
4235
|
+
if (action === void 0) return json(res, 400, { error: "action must be start or poll" });
|
|
4236
|
+
const oauth = deps.oauth;
|
|
4237
|
+
if (oauth === void 0) return json(res, 503, {
|
|
4238
|
+
reason: "oauth-unavailable",
|
|
4239
|
+
error: "OAuth sign-in is not available in this build"
|
|
4240
|
+
});
|
|
4241
|
+
try {
|
|
4242
|
+
if (action === "start") {
|
|
4243
|
+
json(res, 200, await oauth.start(region));
|
|
4244
|
+
return;
|
|
4245
|
+
}
|
|
4246
|
+
const body = await readJsonBody(req);
|
|
4247
|
+
if (typeof body.loginId !== "string" || body.loginId === "") return json(res, 400, { error: "loginId must be a non-empty string" });
|
|
4248
|
+
json(res, 200, await oauth.poll(region, body.loginId));
|
|
4249
|
+
} catch (error) {
|
|
4250
|
+
json(res, 500, { error: safeMessage(error) });
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
});
|
|
4254
|
+
const disposeTransfer = ctx.webServer.register({
|
|
4255
|
+
kind: "exact",
|
|
4256
|
+
path: WORKBUDDY_TRANSFER_PATH,
|
|
4257
|
+
handler: async (req, res) => {
|
|
4258
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
4259
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
4260
|
+
const region = requestRegion(req, res);
|
|
4261
|
+
if (region === void 0) return;
|
|
4262
|
+
const action = transferActionOf(req.url ?? "/");
|
|
4263
|
+
if (action === void 0) return json(res, 400, { error: "action must be export, preview, or import" });
|
|
4264
|
+
const transfer = deps.transfer;
|
|
4265
|
+
if (transfer === void 0) return json(res, 503, {
|
|
4266
|
+
reason: "transfer-unavailable",
|
|
4267
|
+
error: "account transfer is not available in this build"
|
|
4268
|
+
});
|
|
4269
|
+
try {
|
|
4270
|
+
if (action === "export") {
|
|
4271
|
+
const body = await readJsonBody(req);
|
|
4272
|
+
const accountIds = Array.isArray(body.accountIds) ? body.accountIds.filter((id) => typeof id === "string" && id !== "") : [];
|
|
4273
|
+
if (accountIds.length === 0) return json(res, 400, { error: "accountIds must be a non-empty array of strings" });
|
|
4274
|
+
const answer = await transfer.exportAccounts(region, accountIds);
|
|
4275
|
+
if (answer.accounts.length === 0) return json(res, 409, { error: "none of the named accounts was found in this region" });
|
|
4276
|
+
return jsonPrivate(res, 200, answer);
|
|
4277
|
+
}
|
|
4278
|
+
const body = await readJsonBody(req);
|
|
4279
|
+
if (typeof body.text !== "string" || body.text.trim() === "") return json(res, 400, { error: "text must be the transfer file's JSON content" });
|
|
4280
|
+
if (action === "preview") return json(res, 200, await transfer.previewImport(region, body.text));
|
|
4281
|
+
const indexes = Array.isArray(body.indexes) ? body.indexes.filter((value) => typeof value === "number" && Number.isInteger(value) && value >= 0) : [];
|
|
4282
|
+
if (indexes.length === 0) return json(res, 400, { error: "indexes must name at least one file entry to import" });
|
|
4283
|
+
return json(res, 200, await transfer.importAccounts(region, body.text, indexes));
|
|
4284
|
+
} catch (error) {
|
|
4285
|
+
json(res, 500, { error: safeMessage(error) });
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
});
|
|
3648
4289
|
return () => {
|
|
3649
4290
|
disposeRefresh();
|
|
3650
4291
|
disposeCheckin();
|
|
@@ -3653,6 +4294,8 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
3653
4294
|
disposeAccountCredits();
|
|
3654
4295
|
disposeProbe();
|
|
3655
4296
|
disposePool();
|
|
4297
|
+
disposeOAuth();
|
|
4298
|
+
disposeTransfer();
|
|
3656
4299
|
disposeDiagWrite();
|
|
3657
4300
|
};
|
|
3658
4301
|
}, "dsh-connect-workbuddy: Web status route");
|
|
@@ -4391,7 +5034,138 @@ function apply(ctx, config) {
|
|
|
4391
5034
|
await writePoolProbes(region, probeUpdatesOf(rows));
|
|
4392
5035
|
return rows;
|
|
4393
5036
|
},
|
|
4394
|
-
catalog: (region) => displayModels(current(), region)
|
|
5037
|
+
catalog: (region) => displayModels(current(), region),
|
|
5038
|
+
/**
|
|
5039
|
+
* Test ONE account against the region's target model.
|
|
5040
|
+
*
|
|
5041
|
+
* The per-row button's Host half. Reuses the batch's probe dependency
|
|
5042
|
+
* (same client, same probe size, same quota-refresh input) so a row
|
|
5043
|
+
* result and a batch result are the SAME measurement — only the
|
|
5044
|
+
* persistence is scoped to one id instead of every member.
|
|
5045
|
+
*
|
|
5046
|
+
* Returns undefined when the region cannot name a target model (the
|
|
5047
|
+
* same refusal the batch route reports as 409) — the card says so
|
|
5048
|
+
* rather than pretending a test ran.
|
|
5049
|
+
*/
|
|
5050
|
+
testOne: async (region, accountId) => {
|
|
5051
|
+
const target = regionTargetModel(region);
|
|
5052
|
+
if (target === void 0) return void 0;
|
|
5053
|
+
const store = stacks[region].store;
|
|
5054
|
+
const credential = await store.credentialFor(accountId).catch(() => void 0);
|
|
5055
|
+
if (credential === void 0) return {
|
|
5056
|
+
accountId,
|
|
5057
|
+
accountName: "",
|
|
5058
|
+
result: {
|
|
5059
|
+
modelId: target,
|
|
5060
|
+
outcome: "credential-rejected",
|
|
5061
|
+
status: 0,
|
|
5062
|
+
message: "no stored credential for this account"
|
|
5063
|
+
}
|
|
5064
|
+
};
|
|
5065
|
+
const accountName = (await store.accounts().catch(() => [])).find((account) => account.id === accountId)?.accountName ?? "";
|
|
5066
|
+
const result = await poolRunnerDeps(region).probe(credential, target);
|
|
5067
|
+
await writePoolProbes(region, probeUpdatesOf([{
|
|
5068
|
+
accountId,
|
|
5069
|
+
accountName,
|
|
5070
|
+
result
|
|
5071
|
+
}]));
|
|
5072
|
+
return {
|
|
5073
|
+
accountId,
|
|
5074
|
+
accountName,
|
|
5075
|
+
result
|
|
5076
|
+
};
|
|
5077
|
+
},
|
|
5078
|
+
/**
|
|
5079
|
+
* Forget ONE account's plugin-stored credential (the vault entry; the
|
|
5080
|
+
* desktop app's files are never touched). The card rescans afterwards,
|
|
5081
|
+
* so the row disappears the moment the write lands.
|
|
5082
|
+
*/
|
|
5083
|
+
removeOne: async (region, accountId) => stacks[region].store.removeAccount(accountId)
|
|
5084
|
+
},
|
|
5085
|
+
/**
|
|
5086
|
+
* OAuth QR sign-in for this card. The session lives in `src/oauth.ts`
|
|
5087
|
+
* (in-process, keyed by loginId); the Host's only extra duty is
|
|
5088
|
+
* persisting the finished credential into the region's vault — the same
|
|
5089
|
+
* store the request path reads, so the account is selectable the moment
|
|
5090
|
+
* the poll answers.
|
|
5091
|
+
*/
|
|
5092
|
+
oauth: {
|
|
5093
|
+
async start(region) {
|
|
5094
|
+
return oauthStart(region);
|
|
5095
|
+
},
|
|
5096
|
+
async poll(region, loginId) {
|
|
5097
|
+
const answer = await oauthPoll(loginId, region);
|
|
5098
|
+
if (!answer.done) return answer;
|
|
5099
|
+
if ("error" in answer) return answer;
|
|
5100
|
+
const store = stacks[region].store;
|
|
5101
|
+
const accountId = await store.addOAuthAccount({
|
|
5102
|
+
accessToken: answer.account.accessToken,
|
|
5103
|
+
refreshToken: answer.account.refreshToken,
|
|
5104
|
+
expiresAtMs: answer.account.expiresAtMs,
|
|
5105
|
+
...answer.account.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: answer.account.refreshExpiresAtMs },
|
|
5106
|
+
domain: answer.account.domain,
|
|
5107
|
+
uid: answer.account.uid,
|
|
5108
|
+
...answer.account.nickname === void 0 ? {} : { nickname: answer.account.nickname },
|
|
5109
|
+
...answer.account.uin === void 0 ? {} : { uin: answer.account.uin },
|
|
5110
|
+
...answer.account.enterpriseId === void 0 ? {} : { enterpriseId: answer.account.enterpriseId }
|
|
5111
|
+
}).catch((error) => {
|
|
5112
|
+
throw new Error("the account was signed in but could not be saved: " + (error instanceof Error ? error.message : String(error)));
|
|
5113
|
+
});
|
|
5114
|
+
return {
|
|
5115
|
+
done: true,
|
|
5116
|
+
accountId,
|
|
5117
|
+
accountName: (await store.accounts().catch(() => [])).find((account) => account.id === accountId)?.accountName ?? answer.account.nickname ?? ""
|
|
5118
|
+
};
|
|
5119
|
+
}
|
|
5120
|
+
},
|
|
5121
|
+
/**
|
|
5122
|
+
* Batch credential export / import, in the transfer file format the
|
|
5123
|
+
* sibling credential managers share. The Host owns every byte of the
|
|
5124
|
+
* vault; the route only ferries parsed answers.
|
|
5125
|
+
*/
|
|
5126
|
+
transfer: {
|
|
5127
|
+
async exportAccounts(region, accountIds) {
|
|
5128
|
+
const store = stacks[region].store;
|
|
5129
|
+
const accounts = await store.accounts().catch(() => []);
|
|
5130
|
+
const wanted = new Set(accountIds);
|
|
5131
|
+
const records = [];
|
|
5132
|
+
for (const account of accounts) {
|
|
5133
|
+
if (!wanted.has(account.id)) continue;
|
|
5134
|
+
const credential = await store.credentialFor(account.id).catch(() => void 0);
|
|
5135
|
+
if (credential === void 0) continue;
|
|
5136
|
+
records.push(transferRecordOf(credential, {
|
|
5137
|
+
auth: {
|
|
5138
|
+
accessToken: credential.accessToken,
|
|
5139
|
+
refreshToken: credential.refreshToken,
|
|
5140
|
+
expiresAt: credential.expiresAtMs > 0 ? Math.floor(credential.expiresAtMs / 1e3) : 0,
|
|
5141
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: Math.floor(credential.refreshExpiresAtMs / 1e3) },
|
|
5142
|
+
...credential.lastRefreshAtMs === void 0 ? {} : { lastRefreshTime: Math.floor(credential.lastRefreshAtMs / 1e3) },
|
|
5143
|
+
domain: credential.domain
|
|
5144
|
+
},
|
|
5145
|
+
account: {
|
|
5146
|
+
uid: credential.uid,
|
|
5147
|
+
...credential.enterpriseId === void 0 ? {} : { enterpriseId: credential.enterpriseId },
|
|
5148
|
+
...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
|
|
5149
|
+
...credential.uin === void 0 ? {} : { uin: credential.uin }
|
|
5150
|
+
}
|
|
5151
|
+
}));
|
|
5152
|
+
}
|
|
5153
|
+
return { accounts: records };
|
|
5154
|
+
},
|
|
5155
|
+
async previewImport(region, text) {
|
|
5156
|
+
return previewTransferFile(text);
|
|
5157
|
+
},
|
|
5158
|
+
async importAccounts(region, text, indexes) {
|
|
5159
|
+
const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes, region, (domain) => knownRegionOf(domain));
|
|
5160
|
+
const store = stacks[region].store;
|
|
5161
|
+
for (const [id, credential] of changes) await store.addOAuthAccount(credential).catch((error) => {
|
|
5162
|
+
throw new Error("imported account " + id + " could not be stored: " + (error instanceof Error ? error.message : String(error)));
|
|
5163
|
+
});
|
|
5164
|
+
return {
|
|
5165
|
+
imported,
|
|
5166
|
+
skipped
|
|
5167
|
+
};
|
|
5168
|
+
}
|
|
4395
5169
|
}
|
|
4396
5170
|
}));
|
|
4397
5171
|
/**
|
|
@@ -4619,6 +5393,15 @@ function apply(ctx, config) {
|
|
|
4619
5393
|
creditsSnapshotAt[region] = Date.now();
|
|
4620
5394
|
return rows;
|
|
4621
5395
|
}
|
|
5396
|
+
/**
|
|
5397
|
+
* The model a per-row test would use right now: the SAME resolution the
|
|
5398
|
+
* batch route performs (`resolveTargetModel` over the region's displayed
|
|
5399
|
+
* catalog and the saved preference), so a row test and a batch test never
|
|
5400
|
+
* measure different models. Undefined mirrors the route's 409 states.
|
|
5401
|
+
*/
|
|
5402
|
+
const regionTargetModel = (region) => {
|
|
5403
|
+
return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
|
|
5404
|
+
};
|
|
4622
5405
|
/** One region's pool preferences, with the schema's defaults applied. */
|
|
4623
5406
|
const poolPreferencesOf = (config, region) => {
|
|
4624
5407
|
const pool = regionStateOf(config, region).pool;
|
|
@@ -4800,4 +5583,4 @@ function apply(ctx, config) {
|
|
|
4800
5583
|
});
|
|
4801
5584
|
}
|
|
4802
5585
|
//#endregion
|
|
4803
|
-
export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|
|
5586
|
+
export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|