dsh-connect-agnes-token-plan 0.5.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 +386 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/client.js +3921 -0
- package/cordis.patch.yml +147 -0
- package/lib/agnescode-llm-adapter-DDvQ-xvK.js +135 -0
- package/lib/agnescode-models-CUcL9UwK.js +713 -0
- package/lib/host-config-BNGgm_j7.js +456 -0
- package/lib/index.js +7648 -0
- package/lib/llm-adapter-qvvVg7Gb.js +168 -0
- package/lib/llm-error-fix-DQUng8AL.js +247 -0
- package/lib/llm-models-CRYZmwHg.js +1070 -0
- package/lib/raccoon-llm-adapter-n6ZdIdvk.js +143 -0
- package/lib/raccoon-models-DV1sQN4n.js +491 -0
- package/package.json +90 -0
- package/screenshots.json +4 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import { c as num, p as str, u as obj } from "./host-config-BNGgm_j7.js";
|
|
2
|
+
import { createDecipheriv } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/host/agnescode.ts
|
|
5
|
+
/**
|
|
6
|
+
* The AgnesCode(爱思编程)protocol layer — the pure, peer-free API half of the
|
|
7
|
+
* third upstream provider (ROADMAP §6.3 "third upstream").
|
|
8
|
+
*
|
|
9
|
+
* This is the plugin's FIRST "local-login-state harvest" line (the workbuddy /
|
|
10
|
+
* qoder / trae precedent family, §5.3): the credential is NOT obtained by a
|
|
11
|
+
* login this plugin performs — the user signs into the AgnesCode desktop App
|
|
12
|
+
* (WeChat scan, `app_id: agnes`), the App encrypts its session file with
|
|
13
|
+
* Chromium os_crypt, and this module reads THAT file. Nothing here writes to
|
|
14
|
+
* the App's directory; the harvested JWT lands in the DSH credentials service
|
|
15
|
+
* (`agnescode-store.ts`), never in this plugin's directory, git, or logs
|
|
16
|
+
* (red line 1).
|
|
17
|
+
*
|
|
18
|
+
* Wire facts this module encodes (all probed live 2026-10-01, ROADMAP §6.3):
|
|
19
|
+
* - the session file is `%APPDATA%/AgnesCode/code-auth-session.cn.v1`,
|
|
20
|
+
* Chromium os_crypt: `v10` prefix + AES-256-GCM, key = DPAPI-unwrapped
|
|
21
|
+
* `os_crypt.encrypted_key` from the sibling `Local State` JSON;
|
|
22
|
+
* - the decrypted JSON carries `accessToken` (JWT, ~28-day `exp`), the user
|
|
23
|
+
* object, and `bffPublicBaseUrl` — the per-account API base, which is why
|
|
24
|
+
* NO base URL is hardcoded here (the reference reverse-proxy pins the
|
|
25
|
+
* INTERNATIONAL `.com` host as a constant — the same trap video.ts hit);
|
|
26
|
+
* - chat is plain OpenAI-compatible `{bff}/chat/completions`; models list at
|
|
27
|
+
* `{bff}/models` (needs `X-App-Id: 1` + `X-Platform: 1`); the credit pool
|
|
28
|
+
* lives OUTSIDE the `/v1` prefix at `{origin}/api/v2/subscription/
|
|
29
|
+
* credits-balance` (a subscription-pool envelope, NOT Token Plan semantics).
|
|
30
|
+
*
|
|
31
|
+
* Safety stance for third-party data: `bffPublicBaseUrl` is PINNED to the
|
|
32
|
+
* Agnes origin family (https + `*.agnes-ai.cn` / `*.agnes-ai.com`) before any
|
|
33
|
+
* Authorization header is aimed at it — a corrupted or hostile session file
|
|
34
|
+
* must not be able to redirect the stored token to a foreign host.
|
|
35
|
+
*
|
|
36
|
+
* Everything here takes injected io/fetchers and pure data, so the offline
|
|
37
|
+
* suites exercise it without a network or a real DPAPI call; no Host peer is
|
|
38
|
+
* imported.
|
|
39
|
+
*
|
|
40
|
+
* @module dsh-connect-agnes-token-plan/agnescode
|
|
41
|
+
*/
|
|
42
|
+
/** The model-catalogue request headers the BFF gates on (probed: required). */
|
|
43
|
+
const AGNESCODE_CATALOG_HEADERS = Object.freeze({
|
|
44
|
+
"X-App-Id": "1",
|
|
45
|
+
"X-Platform": "1"
|
|
46
|
+
});
|
|
47
|
+
/** The request-language header the desktop client sends. */
|
|
48
|
+
const AGNESCODE_LANGUAGE_HEADER = "X-User-Language";
|
|
49
|
+
/**
|
|
50
|
+
* The session-file name pattern, matched case-insensitively against the App's
|
|
51
|
+
* roaming directory. The CN build ships `code-auth-session.cn.v1`; other
|
|
52
|
+
* region variants (`code-auth-session.v1`, a future `.com` tag) share the
|
|
53
|
+
* shape, so the harvest matches the FAMILY rather than one literal — but each
|
|
54
|
+
* match is diagnosed individually and the first usable one wins.
|
|
55
|
+
*/
|
|
56
|
+
const AGNESCODE_SESSION_FILE_PATTERN = /^code-auth-session[^/\\]*\.v1$/i;
|
|
57
|
+
/** Where the desktop App keeps its Chromium profile, per platform. */
|
|
58
|
+
const AGNESCODE_APP_DATA_CANDIDATES = Object.freeze([
|
|
59
|
+
{
|
|
60
|
+
platform: "win32",
|
|
61
|
+
dirEnv: "APPDATA",
|
|
62
|
+
leaf: "AgnesCode"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
platform: "darwin",
|
|
66
|
+
dirEnv: "HOME",
|
|
67
|
+
leaf: "Library/Application Support/AgnesCode"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
platform: "linux",
|
|
71
|
+
dirEnv: "HOME",
|
|
72
|
+
leaf: ".config/AgnesCode"
|
|
73
|
+
}
|
|
74
|
+
]);
|
|
75
|
+
/**
|
|
76
|
+
* The tiers of a failed harvest, one per row of the panel's diagnosis list
|
|
77
|
+
* (the workbuddy five-tier discipline, restated for this file family). The
|
|
78
|
+
* tiers deliberately split "the App is not here" from "the App is here but we
|
|
79
|
+
* cannot read its crypto" — they want different user advice.
|
|
80
|
+
*/
|
|
81
|
+
const AGNESCODE_HARVEST_TIER = Object.freeze({
|
|
82
|
+
FILE_MISSING: "file_missing",
|
|
83
|
+
UNREADABLE: "unreadable",
|
|
84
|
+
MALFORMED: "malformed",
|
|
85
|
+
NO_KEY: "no_key",
|
|
86
|
+
DECRYPT_FAILED: "decrypt_failed",
|
|
87
|
+
NO_TOKEN: "no_token",
|
|
88
|
+
UNTRUSTED_BASE: "untrusted_base",
|
|
89
|
+
UNSUPPORTED_PLATFORM: "unsupported_platform",
|
|
90
|
+
OK: "ok"
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* Whether a BFF base may carry the account's Authorization header. Pinned to
|
|
94
|
+
* the Agnes origin family over https — see the module header. Anything else
|
|
95
|
+
* reads as `UNTRUSTED_BASE` and the harvest keeps walking.
|
|
96
|
+
* @param {unknown} value - the `bffPublicBaseUrl` from the session file.
|
|
97
|
+
* @returns {string|null} the normalized base (no trailing slash), or `null`.
|
|
98
|
+
*/
|
|
99
|
+
function trustAgnescodeBffBase(value) {
|
|
100
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
101
|
+
let url;
|
|
102
|
+
try {
|
|
103
|
+
url = new URL(value.trim());
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (url.protocol !== "https:") return null;
|
|
108
|
+
if (url.port !== "") return null;
|
|
109
|
+
const host = url.hostname.toLowerCase();
|
|
110
|
+
if (!(host === "agnes-ai.cn" || host === "agnes-ai.com" || host.endsWith(".agnes-ai.cn") || host.endsWith(".agnes-ai.com"))) return null;
|
|
111
|
+
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parse one DECRYPTED session document into the shape the store persists.
|
|
115
|
+
* Malformed input reads as `null`, never throws — one re-harvest, never a
|
|
116
|
+
* crash. Only secret-FREE facts plus the token itself survive; the raw user
|
|
117
|
+
* object (avatar URL, provider ids) is dropped, not stored.
|
|
118
|
+
* @param {unknown} raw - the decrypted JSON value.
|
|
119
|
+
* @returns {{accessToken: string, userId: string, nickname: string, bffBase: string}|null}
|
|
120
|
+
*/
|
|
121
|
+
function parseAgnescodeSession(raw) {
|
|
122
|
+
const source = obj(raw);
|
|
123
|
+
const accessToken = str(source.accessToken, "");
|
|
124
|
+
if (accessToken === "") return null;
|
|
125
|
+
const bffBase = trustAgnescodeBffBase(source.bffPublicBaseUrl);
|
|
126
|
+
if (bffBase === null) return null;
|
|
127
|
+
const user = obj(source.userInfo);
|
|
128
|
+
return {
|
|
129
|
+
accessToken,
|
|
130
|
+
userId: str(user.id, ""),
|
|
131
|
+
nickname: str(user.username, ""),
|
|
132
|
+
bffBase
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Derive the API root the account-scoped (`/api/...`) endpoints address —
|
|
137
|
+
* the BFF base WITHOUT its `/v1` suffix. The credits endpoint does NOT live
|
|
138
|
+
* under `/v1` (probed: `{origin}/api/v2/...`), so a caller that concatenated
|
|
139
|
+
* the full base would 404.
|
|
140
|
+
* @param {string} bffBase - a {@link trustAgnescodeBffBase}-normalized base.
|
|
141
|
+
* @returns {string} the origin (plus any non-`/v1` prefix, preserved).
|
|
142
|
+
*/
|
|
143
|
+
function agnescodeApiRoot(bffBase) {
|
|
144
|
+
const base = str(bffBase, "").replace(/\/+$/, "");
|
|
145
|
+
return base.endsWith("/v1") ? base.slice(0, -3) : base;
|
|
146
|
+
}
|
|
147
|
+
/** Decode a JWT's `exp` claim to MILLISECONDS; `undefined` on any failure. */
|
|
148
|
+
function decodeAgnescodeJwtExpMs(token) {
|
|
149
|
+
if (typeof token !== "string" || token.length === 0) return void 0;
|
|
150
|
+
const parts = token.split(".");
|
|
151
|
+
if (parts.length < 2) return void 0;
|
|
152
|
+
try {
|
|
153
|
+
const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8"));
|
|
154
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
155
|
+
const seconds = num(payload.exp);
|
|
156
|
+
return typeof seconds === "number" ? seconds * 1e3 : void 0;
|
|
157
|
+
} catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The header set one AgnesCode BFF request carries. The catalogue headers are
|
|
163
|
+
* REQUIRED on `/v1/models` (probed) and harmless elsewhere; the language
|
|
164
|
+
* header mirrors the desktop client.
|
|
165
|
+
* @param {object} credential - `{ accessToken }`.
|
|
166
|
+
* @returns {object} the header map.
|
|
167
|
+
*/
|
|
168
|
+
function agnescodeHeaders(credential) {
|
|
169
|
+
return {
|
|
170
|
+
Accept: "application/json",
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
Authorization: `Bearer ${str(obj(credential).accessToken, "")}`,
|
|
173
|
+
...AGNESCODE_CATALOG_HEADERS,
|
|
174
|
+
[AGNESCODE_LANGUAGE_HEADER]: "zh-Hans"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Chromium os_crypt blob → plaintext: strip the `v10` prefix, AES-256-GCM with
|
|
179
|
+
* a 12-byte nonce and a trailing 16-byte auth tag.
|
|
180
|
+
* @param {Buffer|Uint8Array} blob - the raw session-file bytes.
|
|
181
|
+
* @param {Buffer|Uint8Array} key - the 32-byte os_crypt key (already unwrapped).
|
|
182
|
+
* @returns {Buffer} the decrypted bytes.
|
|
183
|
+
* @throws when the prefix, the key size, or the auth tag does not match.
|
|
184
|
+
*/
|
|
185
|
+
function decryptAgnescodeSessionBlob(blob, key) {
|
|
186
|
+
const bytes = Buffer.isBuffer(blob) ? blob : Buffer.from(blob ?? []);
|
|
187
|
+
const keyBytes = Buffer.isBuffer(key) ? key : Buffer.from(key ?? []);
|
|
188
|
+
if (keyBytes.length !== 32) throw new Error(`os_crypt key must be 32 bytes, got ${keyBytes.length}`);
|
|
189
|
+
const prefix = bytes.subarray(0, 3).toString("latin1");
|
|
190
|
+
if (prefix !== "v10") throw new Error(`unexpected session-blob prefix ${JSON.stringify(prefix)}`);
|
|
191
|
+
const body = bytes.subarray(3);
|
|
192
|
+
if (body.length < 28) throw new Error("session blob too short for nonce + tag");
|
|
193
|
+
const nonce = body.subarray(0, 12);
|
|
194
|
+
const tag = body.subarray(body.length - 16);
|
|
195
|
+
const ciphertext = body.subarray(12, body.length - 16);
|
|
196
|
+
const decipher = createDecipheriv("aes-256-gcm", keyBytes, nonce);
|
|
197
|
+
decipher.setAuthTag(tag);
|
|
198
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Unwrap the os_crypt key from a `Local State` document. The stored value is
|
|
202
|
+
* base64 of `DPAPI` + the DPAPI-protected key bytes; the DPAPI call itself is
|
|
203
|
+
* injected (the real one is a PowerShell child process — no native deps).
|
|
204
|
+
* @param {unknown} localStateRaw - the parsed (or raw) `Local State` JSON.
|
|
205
|
+
* @param {(wrapped: Buffer) => Promise<Buffer>} dpapiUnprotect - injected DPAPI.
|
|
206
|
+
* @returns {Promise<Buffer>} the 32-byte os_crypt key.
|
|
207
|
+
* @throws on any shape mismatch — the caller maps it to `NO_KEY`/`DECRYPT_FAILED`.
|
|
208
|
+
*/
|
|
209
|
+
async function unwrapAgnescodeLocalStateKey(localStateRaw, dpapiUnprotect) {
|
|
210
|
+
let source;
|
|
211
|
+
if (typeof localStateRaw === "string" || Buffer.isBuffer(localStateRaw) || localStateRaw instanceof Uint8Array) {
|
|
212
|
+
const text = typeof localStateRaw === "string" ? localStateRaw : Buffer.from(localStateRaw).toString("utf8");
|
|
213
|
+
try {
|
|
214
|
+
source = JSON.parse(text);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error("Local State is not valid JSON");
|
|
217
|
+
}
|
|
218
|
+
} else source = obj(localStateRaw);
|
|
219
|
+
const encryptedKeyB64 = str(obj(obj(source).os_crypt).encrypted_key, "");
|
|
220
|
+
if (encryptedKeyB64 === "") throw new Error("Local State carries no os_crypt.encrypted_key");
|
|
221
|
+
const wrapped = Buffer.from(encryptedKeyB64, "base64");
|
|
222
|
+
if (wrapped.length < 6 || wrapped.subarray(0, 5).toString("latin1") !== "DPAPI") throw new Error("os_crypt.encrypted_key lacks the DPAPI prefix");
|
|
223
|
+
return dpapiUnprotect(wrapped.subarray(5));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The production DPAPI unwrap: a PowerShell child process. No native addon and
|
|
227
|
+
* no build step (the Host half is build-free, ROADMAP §6.2), and the unwrapped
|
|
228
|
+
* key transits the stdout pipe IN MEMORY — it is never printed, logged, or
|
|
229
|
+
* written anywhere. Runs only on win32; every other platform maps to
|
|
230
|
+
* `UNSUPPORTED_PLATFORM` upstream of this call.
|
|
231
|
+
* @param {Buffer} wrapped - the DPAPI-protected key bytes.
|
|
232
|
+
* @returns {Promise<Buffer>} the unwrapped key bytes.
|
|
233
|
+
*/
|
|
234
|
+
async function defaultDpapiUnprotect(wrapped) {
|
|
235
|
+
const { spawn } = await import("node:child_process");
|
|
236
|
+
const script = [
|
|
237
|
+
"Add-Type -AssemblyName System.Security",
|
|
238
|
+
"$in = [Console]::OpenStandardInput()",
|
|
239
|
+
"$ms = New-Object IO.MemoryStream",
|
|
240
|
+
"$in.CopyTo($ms)",
|
|
241
|
+
"$pt = [Security.Cryptography.ProtectedData]::Unprotect($ms.ToArray(), $null, 'CurrentUser')",
|
|
242
|
+
"[Console]::OpenStandardOutput().Write($pt, 0, $pt.Length)"
|
|
243
|
+
].join("; ");
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const child = spawn("powershell.exe", [
|
|
246
|
+
"-NoProfile",
|
|
247
|
+
"-NonInteractive",
|
|
248
|
+
"-Command",
|
|
249
|
+
script
|
|
250
|
+
], {
|
|
251
|
+
stdio: [
|
|
252
|
+
"pipe",
|
|
253
|
+
"pipe",
|
|
254
|
+
"pipe"
|
|
255
|
+
],
|
|
256
|
+
windowsHide: true
|
|
257
|
+
});
|
|
258
|
+
const chunks = [];
|
|
259
|
+
let failure = "";
|
|
260
|
+
const timer = setTimeout(() => {
|
|
261
|
+
child.kill();
|
|
262
|
+
reject(/* @__PURE__ */ new Error("DPAPI unprotect timed out (10s)"));
|
|
263
|
+
}, 1e4);
|
|
264
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
265
|
+
child.stderr.on("data", (chunk) => {
|
|
266
|
+
failure += chunk.toString();
|
|
267
|
+
});
|
|
268
|
+
child.stdin.on("error", () => {});
|
|
269
|
+
child.on("error", (why) => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
reject(why);
|
|
272
|
+
});
|
|
273
|
+
child.on("close", (code) => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
if (code !== 0) {
|
|
276
|
+
reject(/* @__PURE__ */ new Error(`DPAPI unprotect failed (exit ${code})${failure ? `: ${failure.slice(0, 200)}` : ""}`));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
resolve(Buffer.concat(chunks));
|
|
280
|
+
});
|
|
281
|
+
child.stdin.write(wrapped);
|
|
282
|
+
child.stdin.end();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Harvest the AgnesCode session from the local desktop App — the walk behind
|
|
287
|
+
* the panel's「重新检测」. One diagnostic row per candidate file, in the order
|
|
288
|
+
* the candidates were tried; the FIRST usable session wins and the walk stops.
|
|
289
|
+
* A walk that finds nothing usable returns `ok: false` WITH the rows, so the
|
|
290
|
+
* panel can show exactly what was probed and why it failed (the workbuddy
|
|
291
|
+
* discipline: never a bare "not logged in" when the App state is the problem).
|
|
292
|
+
*
|
|
293
|
+
* The token appears ONLY in the return value's `session` — the `attempts` rows
|
|
294
|
+
* carry tier codes and shape facts (lengths, file sizes), never values.
|
|
295
|
+
* @param {object} [options]
|
|
296
|
+
* @param {object} [options.env] - the process env (defaults to `process.env`).
|
|
297
|
+
* @param {object} [options.platform] - `process.platform` (defaults to real).
|
|
298
|
+
* @param {(path: string) => Promise<Buffer>} [options.readFile] - injected reader.
|
|
299
|
+
* @param {(path: string) => Promise<string[]>} [options.readDir] - injected lister.
|
|
300
|
+
* @param {(wrapped: Buffer) => Promise<Buffer>} [options.dpapiUnprotect] - injected DPAPI.
|
|
301
|
+
* @returns {Promise<{ok: boolean, session?: object, attempts: object[]}>}
|
|
302
|
+
*/
|
|
303
|
+
async function harvestAgnescodeLocalSession(options = {}) {
|
|
304
|
+
const env = options.env ?? process.env;
|
|
305
|
+
const platform = options.platform ?? process.platform;
|
|
306
|
+
const readFile = options.readFile ?? (async (path) => (await import("node:fs/promises")).readFile(path));
|
|
307
|
+
const readDir = options.readDir ?? (async (path) => (await import("node:fs/promises")).readdir(path));
|
|
308
|
+
const dpapiUnprotect = options.dpapiUnprotect ?? defaultDpapiUnprotect;
|
|
309
|
+
/** One tier row: {file, tier, detail?} — detail carries shapes, never values. */
|
|
310
|
+
const attempts = [];
|
|
311
|
+
if (platform !== "win32") return {
|
|
312
|
+
ok: false,
|
|
313
|
+
attempts: [{
|
|
314
|
+
file: null,
|
|
315
|
+
tier: AGNESCODE_HARVEST_TIER.UNSUPPORTED_PLATFORM,
|
|
316
|
+
detail: `no verified harvest path for ${platform} yet (Windows only; macOS reads state.vscdb — unimplemented)`
|
|
317
|
+
}]
|
|
318
|
+
};
|
|
319
|
+
const appDirs = AGNESCODE_APP_DATA_CANDIDATES.filter((candidate) => candidate.platform === platform).map((candidate) => {
|
|
320
|
+
const base = str(env[candidate.dirEnv], "");
|
|
321
|
+
return base === "" ? null : `${base.replace(/[\\/]+$/, "")}/${candidate.leaf}`;
|
|
322
|
+
}).filter(Boolean);
|
|
323
|
+
if (appDirs.length === 0) return {
|
|
324
|
+
ok: false,
|
|
325
|
+
attempts: [{
|
|
326
|
+
file: null,
|
|
327
|
+
tier: AGNESCODE_HARVEST_TIER.UNSUPPORTED_PLATFORM,
|
|
328
|
+
detail: `no app-data candidate for platform ${platform}`
|
|
329
|
+
}]
|
|
330
|
+
};
|
|
331
|
+
for (const appDir of appDirs) {
|
|
332
|
+
let fileNames = [];
|
|
333
|
+
try {
|
|
334
|
+
fileNames = (await readDir(appDir)).filter((name) => AGNESCODE_SESSION_FILE_PATTERN.test(name));
|
|
335
|
+
} catch {
|
|
336
|
+
attempts.push({
|
|
337
|
+
file: appDir,
|
|
338
|
+
tier: AGNESCODE_HARVEST_TIER.FILE_MISSING,
|
|
339
|
+
detail: "the AgnesCode app directory is absent"
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (fileNames.length === 0) {
|
|
344
|
+
attempts.push({
|
|
345
|
+
file: appDir,
|
|
346
|
+
tier: AGNESCODE_HARVEST_TIER.FILE_MISSING,
|
|
347
|
+
detail: "no code-auth-session*.v1 in the app directory"
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
fileNames.sort((a, b) => a.endsWith(".cn.v1") === b.endsWith(".cn.v1") ? a.localeCompare(b) : a.endsWith(".cn.v1") ? -1 : 1);
|
|
352
|
+
for (const fileName of fileNames) {
|
|
353
|
+
const filePath = `${appDir}/${fileName}`;
|
|
354
|
+
let blob;
|
|
355
|
+
try {
|
|
356
|
+
blob = await readFile(filePath);
|
|
357
|
+
} catch (why) {
|
|
358
|
+
attempts.push({
|
|
359
|
+
file: filePath,
|
|
360
|
+
tier: AGNESCODE_HARVEST_TIER.UNREADABLE,
|
|
361
|
+
detail: why instanceof Error ? why.message : String(why)
|
|
362
|
+
});
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
let localStateRaw;
|
|
366
|
+
try {
|
|
367
|
+
localStateRaw = await readFile(`${appDir}/Local State`);
|
|
368
|
+
} catch {
|
|
369
|
+
attempts.push({
|
|
370
|
+
file: filePath,
|
|
371
|
+
tier: AGNESCODE_HARVEST_TIER.NO_KEY,
|
|
372
|
+
detail: "the sibling Local State is absent"
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
let key;
|
|
377
|
+
try {
|
|
378
|
+
key = await unwrapAgnescodeLocalStateKey(localStateRaw, dpapiUnprotect);
|
|
379
|
+
} catch (why) {
|
|
380
|
+
attempts.push({
|
|
381
|
+
file: filePath,
|
|
382
|
+
tier: AGNESCODE_HARVEST_TIER.NO_KEY,
|
|
383
|
+
detail: why instanceof Error ? why.message : String(why)
|
|
384
|
+
});
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
let plain;
|
|
388
|
+
try {
|
|
389
|
+
plain = decryptAgnescodeSessionBlob(blob, key);
|
|
390
|
+
} catch (why) {
|
|
391
|
+
attempts.push({
|
|
392
|
+
file: filePath,
|
|
393
|
+
tier: AGNESCODE_HARVEST_TIER.DECRYPT_FAILED,
|
|
394
|
+
detail: `${why instanceof Error ? why.message : String(why)} (blob ${blob.length} B)`
|
|
395
|
+
});
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = JSON.parse(plain.toString("utf8"));
|
|
401
|
+
} catch {
|
|
402
|
+
attempts.push({
|
|
403
|
+
file: filePath,
|
|
404
|
+
tier: AGNESCODE_HARVEST_TIER.MALFORMED,
|
|
405
|
+
detail: "decrypted bytes are not JSON"
|
|
406
|
+
});
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const session = parseAgnescodeSession(parsed);
|
|
410
|
+
if (session === null) {
|
|
411
|
+
attempts.push({
|
|
412
|
+
file: filePath,
|
|
413
|
+
tier: str(obj(parsed).accessToken, "") === "" ? AGNESCODE_HARVEST_TIER.NO_TOKEN : AGNESCODE_HARVEST_TIER.UNTRUSTED_BASE,
|
|
414
|
+
detail: "the decrypted session lacks a usable token or a pinned Agnes base"
|
|
415
|
+
});
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
attempts.push({
|
|
419
|
+
file: filePath,
|
|
420
|
+
tier: AGNESCODE_HARVEST_TIER.OK,
|
|
421
|
+
detail: `JWT ${session.accessToken.length} chars`
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
session,
|
|
426
|
+
attempts
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
attempts
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Fetch the live model catalogue, or `null` when it cannot be read. The
|
|
437
|
+
* adapter falls back to the static roster on `null` (the `console-client`
|
|
438
|
+
* silent-fallback discipline).
|
|
439
|
+
*
|
|
440
|
+
* The BFF rows carry `is_member_only` — a fact about gating, not visibility,
|
|
441
|
+
* so it is KEPT (the panel badges it; offering a member-gated model is
|
|
442
|
+
* preferable to silently hiding one, mirroring red line 7's spirit of stating
|
|
443
|
+
* account-level limits instead of dropping models).
|
|
444
|
+
* @param {object} credential - `{ accessToken, bffBase }`.
|
|
445
|
+
* @param {typeof fetch} [fetcher] - injected fetch.
|
|
446
|
+
* @returns {Promise<object[]|null>} `[{id, name, vision, memberOnly, contextWindow, maxOutputLength}]`, or `null`.
|
|
447
|
+
*/
|
|
448
|
+
async function fetchAgnescodeCatalog(credential, fetcher) {
|
|
449
|
+
const effective = fetcher ?? globalThis.fetch;
|
|
450
|
+
const source = obj(credential);
|
|
451
|
+
const bffBase = trustAgnescodeBffBase(source.bffBase);
|
|
452
|
+
if (bffBase === null) return null;
|
|
453
|
+
try {
|
|
454
|
+
const response = await effective(`${bffBase}/models`, {
|
|
455
|
+
headers: agnescodeHeaders(source),
|
|
456
|
+
signal: AbortSignal.timeout(3e4)
|
|
457
|
+
});
|
|
458
|
+
if (!response.ok) return null;
|
|
459
|
+
const body = obj(await response.json().catch(() => ({})));
|
|
460
|
+
const models = Array.isArray(body.data) ? body.data : [];
|
|
461
|
+
const seen = /* @__PURE__ */ new Set();
|
|
462
|
+
const out = [];
|
|
463
|
+
for (const raw of models) {
|
|
464
|
+
const model = obj(raw);
|
|
465
|
+
const id = str(model.id, "");
|
|
466
|
+
if (id === "" || seen.has(id)) continue;
|
|
467
|
+
if (str(model.model_type, "text") !== "text") continue;
|
|
468
|
+
seen.add(id);
|
|
469
|
+
out.push({
|
|
470
|
+
id,
|
|
471
|
+
name: str(model.name, id),
|
|
472
|
+
vision: false,
|
|
473
|
+
memberOnly: model.is_member_only === true,
|
|
474
|
+
contextWindow: num(model.max_input_tokens),
|
|
475
|
+
maxOutputLength: num(model.max_output_tokens)
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return out.length > 0 ? out : null;
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Read the account's credit pool. Read-only; the envelope is a SUBSCRIPTION
|
|
485
|
+
* pool (level / time-sensitive vs permanent credits), NOT a Token Plan
|
|
486
|
+
* usage-overview — the panel must not render it with quota semantics.
|
|
487
|
+
* @param {object} credential - `{ accessToken, bffBase }`.
|
|
488
|
+
* @param {typeof fetch} [fetcher] - injected fetch.
|
|
489
|
+
* @returns {Promise<object|null>} `{totalBalance, timeSensitiveBalance, permanentBalance, subscriptionCredits, dailyFreeCredits, level}` or `null`.
|
|
490
|
+
*/
|
|
491
|
+
async function fetchAgnescodeBalance(credential, fetcher) {
|
|
492
|
+
const effective = fetcher ?? globalThis.fetch;
|
|
493
|
+
const source = obj(credential);
|
|
494
|
+
const bffBase = trustAgnescodeBffBase(source.bffBase);
|
|
495
|
+
if (bffBase === null) return null;
|
|
496
|
+
try {
|
|
497
|
+
const response = await effective(`${agnescodeApiRoot(bffBase)}/api/v2/subscription/credits-balance`, {
|
|
498
|
+
headers: agnescodeHeaders(source),
|
|
499
|
+
signal: AbortSignal.timeout(3e4)
|
|
500
|
+
});
|
|
501
|
+
if (!response.ok) return null;
|
|
502
|
+
const envelope = obj(await response.json().catch(() => ({})));
|
|
503
|
+
if (str(envelope.code, "") !== "000000" || envelope.data === null || typeof envelope.data !== "object") return null;
|
|
504
|
+
const data = obj(envelope.data);
|
|
505
|
+
const total = numOrNullSafe(data.total_balance);
|
|
506
|
+
if (total === null) return null;
|
|
507
|
+
return {
|
|
508
|
+
totalBalance: total,
|
|
509
|
+
timeSensitiveBalance: numOrNullSafe(data.time_sensitive_balance) ?? 0,
|
|
510
|
+
permanentBalance: numOrNullSafe(data.permanent_balance) ?? 0,
|
|
511
|
+
subscriptionCredits: numOrNullSafe(data.subscription_credits) ?? 0,
|
|
512
|
+
dailyFreeCredits: numOrNullSafe(data.daily_free_credits) ?? 0,
|
|
513
|
+
level: num(data.level)
|
|
514
|
+
};
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Read a finite non-negative number, else `null`. */
|
|
520
|
+
function numOrNullSafe(value) {
|
|
521
|
+
const parsed = Number(value);
|
|
522
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The static fallback roster: the seven models the CN BFF listed at probe time
|
|
526
|
+
* (2026-10-01, ROADMAP §6.3). All `model_type: text`, no multiplier concept
|
|
527
|
+
* (billing is the credit pool, not per-model rates). Used ONLY when
|
|
528
|
+
* `fetchAgnescodeCatalog` comes back empty; a fresh catalogue always wins.
|
|
529
|
+
*/
|
|
530
|
+
const AGNESCODE_FALLBACK_MODELS = Object.freeze([
|
|
531
|
+
{
|
|
532
|
+
id: "agnes-3.0-flash",
|
|
533
|
+
name: "Agnes 3.0 Flash",
|
|
534
|
+
memberOnly: false,
|
|
535
|
+
vision: false,
|
|
536
|
+
contextWindow: 512e3,
|
|
537
|
+
maxOutputLength: 65536
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
id: "agnes-2.5-flash",
|
|
541
|
+
name: "Agnes 2.5 Flash",
|
|
542
|
+
memberOnly: false,
|
|
543
|
+
vision: false,
|
|
544
|
+
contextWindow: 512e3,
|
|
545
|
+
maxOutputLength: 65536
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
id: "agnes-2.5-pro",
|
|
549
|
+
name: "Agnes 2.5 Pro",
|
|
550
|
+
memberOnly: false,
|
|
551
|
+
vision: false,
|
|
552
|
+
contextWindow: 512e3,
|
|
553
|
+
maxOutputLength: 65536
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: "deepseek-v4-flash",
|
|
557
|
+
name: "DeepSeek V4 Flash",
|
|
558
|
+
memberOnly: true,
|
|
559
|
+
vision: false,
|
|
560
|
+
contextWindow: 1e6,
|
|
561
|
+
maxOutputLength: 393216
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
id: "glm-5.2",
|
|
565
|
+
name: "GLM-5.2",
|
|
566
|
+
memberOnly: true,
|
|
567
|
+
vision: false,
|
|
568
|
+
contextWindow: 1e6,
|
|
569
|
+
maxOutputLength: 131072
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
id: "kimi-k3",
|
|
573
|
+
name: "Kimi K3",
|
|
574
|
+
memberOnly: true,
|
|
575
|
+
vision: false,
|
|
576
|
+
contextWindow: 1048576,
|
|
577
|
+
maxOutputLength: 131072
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
id: "deepseek-v4-pro",
|
|
581
|
+
name: "DeepSeek V4 Pro",
|
|
582
|
+
memberOnly: true,
|
|
583
|
+
vision: false,
|
|
584
|
+
contextWindow: 1e6,
|
|
585
|
+
maxOutputLength: 393216
|
|
586
|
+
}
|
|
587
|
+
]);
|
|
588
|
+
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/host/agnescode-models.ts
|
|
591
|
+
/**
|
|
592
|
+
* AgnesCode catalogue entry → pi-ai model descriptor mapping — the peer-free
|
|
593
|
+
* half of the third upstream provider. Mirrors `raccoon-models.ts`'s
|
|
594
|
+
* discipline: plain objects only (no Host peer import); `agnescode-llm-adapter.ts`
|
|
595
|
+
* is the peer-dependent half.
|
|
596
|
+
*
|
|
597
|
+
* Three decisions carried here:
|
|
598
|
+
* 1. `reasoning: false` — the catalogue rows declare a `thinking_toggle`
|
|
599
|
+
* (default ON), but the WIRE field that would control it from OpenAI
|
|
600
|
+
* chat is UNVERIFIED (ROADMAP §6.3). Offering a thinking selector would
|
|
601
|
+
* promise something the descriptor cannot emit; the honest v1 shape is
|
|
602
|
+
* "no toggle; the BFF defaults to thinking ON". A stated limitation, not
|
|
603
|
+
* an oversight.
|
|
604
|
+
* 2. The base URL is PER-ACCOUNT (`bffPublicBaseUrl` from the session
|
|
605
|
+
* file), so it rides through the builder's options instead of a module
|
|
606
|
+
* constant — the reference reverse-proxy's hardcoded `.com` constant is
|
|
607
|
+
* the trap this avoids.
|
|
608
|
+
* 3. `memberOnly` models are OFFERED, not dropped: gating is account state,
|
|
609
|
+
* not model truth (red line 7's spirit — state limits, never silently
|
|
610
|
+
* retract models). The panel badges them; a request that hits the gate
|
|
611
|
+
* fails visibly at the upstream.
|
|
612
|
+
*
|
|
613
|
+
* @module dsh-connect-agnes-token-plan/agnescode-models
|
|
614
|
+
*/
|
|
615
|
+
/** The provider id this plugin registers under for AgnesCode. */
|
|
616
|
+
const AGNESCODE_PROVIDER_ID = "sensenova-agnescode";
|
|
617
|
+
/** What the DSH model picker shows as the AgnesCode provider's name. */
|
|
618
|
+
const AGNESCODE_DISPLAY_NAME = "SenseNova AgnesCode";
|
|
619
|
+
/** The zero-cost sentinel: per-token prices are unknowable (credit-gated). */
|
|
620
|
+
const NO_COST = Object.freeze({
|
|
621
|
+
input: 0,
|
|
622
|
+
output: 0,
|
|
623
|
+
cacheRead: 0,
|
|
624
|
+
cacheWrite: 0
|
|
625
|
+
});
|
|
626
|
+
/** The catalogue/identity headers every request carries (probed: required). */
|
|
627
|
+
function agnescodeRequestHeaders() {
|
|
628
|
+
return {
|
|
629
|
+
Accept: "application/json",
|
|
630
|
+
"Content-Type": "application/json",
|
|
631
|
+
"X-App-Id": "1",
|
|
632
|
+
"X-Platform": "1",
|
|
633
|
+
"X-User-Language": "zh-Hans"
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The AgnesCode model rows the panel shows and the adapter offers.
|
|
638
|
+
*
|
|
639
|
+
* A live catalogue row always beats the static fallback roster. The row keeps
|
|
640
|
+
* what the picker and panel need — plus `memberOnly`, which this provider HAS
|
|
641
|
+
* a platform-declared fact for and the panel badges.
|
|
642
|
+
* @param {object[]|null} [catalog] - the `fetchAgnescodeCatalog` result.
|
|
643
|
+
* @returns {object[]} the AgnesCode model rows.
|
|
644
|
+
*/
|
|
645
|
+
function agnescodeRoster(catalog) {
|
|
646
|
+
const rows = Array.isArray(catalog) && catalog.length > 0 ? catalog : AGNESCODE_FALLBACK_MODELS;
|
|
647
|
+
const out = [];
|
|
648
|
+
for (const row of rows) {
|
|
649
|
+
const id = str(row?.id, "");
|
|
650
|
+
if (id === "") continue;
|
|
651
|
+
out.push({
|
|
652
|
+
id,
|
|
653
|
+
name: str(row?.name, id),
|
|
654
|
+
vision: row?.vision === true,
|
|
655
|
+
memberOnly: row?.memberOnly === true,
|
|
656
|
+
multiplier: typeof row?.multiplier === "number" ? row.multiplier : void 0,
|
|
657
|
+
contextWindow: num(row?.contextWindow),
|
|
658
|
+
maxOutputLength: num(row?.maxOutputLength)
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Map one AgnesCode row onto the pi-ai model descriptor the adapter offers.
|
|
665
|
+
* @param {object} row - an {@link agnescodeRoster} row (must carry `id`).
|
|
666
|
+
* @param {object} [options] - `{ bffBase }` — the pinned per-account base.
|
|
667
|
+
* @returns {object} the pi-ai descriptor.
|
|
668
|
+
*/
|
|
669
|
+
function agnescodeToDescriptor(row, options = {}) {
|
|
670
|
+
const id = str(row?.id, "");
|
|
671
|
+
if (id === "") throw new Error("agnescodeToDescriptor: row has no id");
|
|
672
|
+
const bffBase = str(options.bffBase, "");
|
|
673
|
+
if (bffBase === "") throw new Error("agnescodeToDescriptor: a pinned bffBase is required");
|
|
674
|
+
const vision = row?.vision === true;
|
|
675
|
+
return {
|
|
676
|
+
id,
|
|
677
|
+
name: str(row?.name, id),
|
|
678
|
+
api: "openai-completions",
|
|
679
|
+
provider: AGNESCODE_PROVIDER_ID,
|
|
680
|
+
baseUrl: bffBase,
|
|
681
|
+
input: vision ? ["text", "image"] : ["text"],
|
|
682
|
+
reasoning: false,
|
|
683
|
+
cost: { ...NO_COST },
|
|
684
|
+
contextWindow: num(row?.contextWindow) ?? 512e3,
|
|
685
|
+
maxTokens: num(row?.maxOutputLength) ?? 32e3,
|
|
686
|
+
headers: agnescodeRequestHeaders(),
|
|
687
|
+
compat: {
|
|
688
|
+
maxTokensField: "max_tokens",
|
|
689
|
+
supportsDeveloperRole: false
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Build the whole descriptor list for one AgnesCode roster.
|
|
695
|
+
* @param {object[]} [roster] - the {@link agnescodeRoster} result.
|
|
696
|
+
* @param {object} [options] - `{ bffBase }`.
|
|
697
|
+
* @returns {object[]} the pi-ai descriptors.
|
|
698
|
+
*/
|
|
699
|
+
function buildAgnescodeDescriptors(roster, options = {}) {
|
|
700
|
+
const list = Array.isArray(roster) ? roster : agnescodeRoster(null);
|
|
701
|
+
const out = [];
|
|
702
|
+
const seen = /* @__PURE__ */ new Set();
|
|
703
|
+
for (const row of list) {
|
|
704
|
+
const id = str(row?.id, "");
|
|
705
|
+
if (id === "" || seen.has(id)) continue;
|
|
706
|
+
seen.add(id);
|
|
707
|
+
out.push(agnescodeToDescriptor(row, options));
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
//#endregion
|
|
713
|
+
export { AGNESCODE_FALLBACK_MODELS as a, fetchAgnescodeCatalog as c, buildAgnescodeDescriptors as i, harvestAgnescodeLocalSession as l, AGNESCODE_PROVIDER_ID as n, decodeAgnescodeJwtExpMs as o, agnescodeRoster as r, fetchAgnescodeBalance as s, AGNESCODE_DISPLAY_NAME as t, trustAgnescodeBffBase as u };
|