bdip-office-assistant-cli 0.0.0-stage → 1.0.1

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.
Files changed (63) hide show
  1. package/README.md +37 -2
  2. package/dist/accounts.js +30 -0
  3. package/dist/args.js +36 -0
  4. package/dist/auth-token.js +783 -0
  5. package/dist/auth.js +331 -0
  6. package/dist/blog.js +562 -0
  7. package/dist/browser-links.js +224 -0
  8. package/dist/captcha-display.js +30 -0
  9. package/dist/cli.js +5533 -0
  10. package/dist/client.js +254 -0
  11. package/dist/config.js +170 -0
  12. package/dist/createcontext.js +41 -0
  13. package/dist/crypto.js +139 -0
  14. package/dist/cube.js +1771 -0
  15. package/dist/documents.js +98 -0
  16. package/dist/email.js +841 -0
  17. package/dist/field-preparation.js +237 -0
  18. package/dist/fileutil.js +20 -0
  19. package/dist/formula-runtime.js +55 -0
  20. package/dist/hrm.js +478 -0
  21. package/dist/keepalive.js +99 -0
  22. package/dist/kq.js +280 -0
  23. package/dist/linkage-calculations.js +68 -0
  24. package/dist/links.js +34 -0
  25. package/dist/meetings.js +172 -0
  26. package/dist/nodemap.js +242 -0
  27. package/dist/odoc.js +199 -0
  28. package/dist/office-tools.js +155 -0
  29. package/dist/pageopen.js +29 -0
  30. package/dist/parse.js +177 -0
  31. package/dist/password-input.js +84 -0
  32. package/dist/password-login.js +208 -0
  33. package/dist/person-selection.js +81 -0
  34. package/dist/querystate.js +38 -0
  35. package/dist/render.js +21 -0
  36. package/dist/report.js +50 -0
  37. package/dist/session-worker.js +54 -0
  38. package/dist/session.js +280 -0
  39. package/dist/submissionplan.js +45 -0
  40. package/dist/submissionstate.js +81 -0
  41. package/dist/subordinate.js +458 -0
  42. package/dist/supervise.js +152 -0
  43. package/dist/todoaskidea.js +54 -0
  44. package/dist/todobatch.js +172 -0
  45. package/dist/todocc.js +128 -0
  46. package/dist/todoforward.js +66 -0
  47. package/dist/todoreject.js +215 -0
  48. package/dist/todosubmit.js +325 -0
  49. package/dist/todotransfer.js +54 -0
  50. package/dist/util.js +25 -0
  51. package/dist/wfagent.js +538 -0
  52. package/dist/wfcreate.js +102 -0
  53. package/dist/wfform.js +169 -0
  54. package/dist/wfops.js +40 -0
  55. package/dist/wfreceiver.js +43 -0
  56. package/dist/wfstart.js +558 -0
  57. package/dist/wfsummary.js +89 -0
  58. package/dist/wfupload.js +83 -0
  59. package/dist/wfuser.js +96 -0
  60. package/dist/workflow-runtime.js +177 -0
  61. package/dist/workflow.js +570 -0
  62. package/dist/workplan.js +431 -0
  63. package/package.json +29 -6
@@ -0,0 +1,783 @@
1
+ /**
2
+ * aiAgentToken 认证核心(对标 e9-cli lib/auth-token.js)。
3
+ *
4
+ * 链路:
5
+ * loginForCookie(有头 autoFill,失败转手动;轮询 loginidweaver,60s hash-portal 兜底)
6
+ * → 平台账号接口再次核验,关闭浏览器
7
+ * → saveCookieToProfile(存 cookie 供回退,写回 config.json 对应字段)
8
+ * → registerToken: GET /api/ai/agent/login/register 带 cookie → aiAgentToken(8h,加密存)
9
+ * 成功 → { token, expireTime, expireSeconds, cookieSavedAt, noToken:false }
10
+ * 404/异常 → { token:'', noToken:true }(回退 cookie)
11
+ *
12
+ * token 文件:~/.bdip/tokens/<profileName>.json(AES-256-GCM 加密)。
13
+ * profileName 由当前 baseUrl 的 hostname+port 派生(一个环境一个 token 文件)。
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import { execSync, spawn } from 'node:child_process';
19
+ import { encrypt, decrypt } from './crypto.js';
20
+ import { endpointFor, profileNameFromBaseUrl, updateConfigCookie } from './config.js';
21
+ import { probeCookieSession, mergeResponseCookies, authFailureCode, latchForcedOffline } from './session.js';
22
+ function rejectForcedOffline(cfg, state, checkedCookie = cfg?.cookie) {
23
+ if (state.code !== 'FORCED_OFFLINE') return;
24
+ if (cfg) latchForcedOffline(cfg, state, checkedCookie);
25
+ const error = new Error('当前会话已被强制下线(003/004),已停止续期与业务请求;请核实后重新登录。');
26
+ error.code = 'FORCED_OFFLINE';
27
+ throw error;
28
+ }
29
+ // token 文件固定写在 ~/.bdip/tokens/(与 storageState、config 的 ~/.bdip 回退一致),
30
+ // 不再用 process.cwd()——否则换工作目录就找不到 token → 回退 cookie → 连锁触发续期弹浏览器
31
+ const CONFIG_DIR = path.join(os.homedir(), '.bdip');
32
+ const TOKEN_DIR = path.join(CONFIG_DIR, 'tokens');
33
+ function ensureDir(p) {
34
+ if (!fs.existsSync(p))
35
+ fs.mkdirSync(p, { recursive: true });
36
+ }
37
+ function tokenFilePath(profileName) {
38
+ return path.join(TOKEN_DIR, `${profileName}.json`);
39
+ }
40
+ function sleep(ms) {
41
+ return new Promise((r) => setTimeout(r, ms));
42
+ }
43
+ /**
44
+ * 启动浏览器:优先用系统默认浏览器(注册表查),Chromium 系(Chrome/Edge/Brave)直接 executablePath 打开;
45
+ * 非 Chromium 系(Firefox 等)跳过 → 回退 channel chrome/msedge → 最后 Playwright 自带 chromium
46
+ * (自带内核未下载时才懒下载一次——见末尾兜底)。
47
+ */
48
+ async function launchSystemBrowser(chromium, headless, args) {
49
+ // 1. 查系统默认浏览器路径
50
+ const defaultPath = getDefaultBrowserPath();
51
+ if (defaultPath) {
52
+ const lower = defaultPath.toLowerCase();
53
+ // Chromium 系浏览器可直接用 executablePath 打开
54
+ if (/chrome|msedge|edge|brave|chromium/i.test(lower)) {
55
+ try {
56
+ return await chromium.launch({ executablePath: defaultPath, headless, args });
57
+ }
58
+ catch {
59
+ /* 路径无效,试下一个 */
60
+ }
61
+ }
62
+ // Firefox 等非 Chromium 系:跳过(Playwright chromium 模块不支持),走下面的 channel 回退
63
+ }
64
+ // 2. 回退:试 channel chrome → msedge → Playwright 自带 chromium
65
+ for (const channel of ['chrome', 'msedge']) {
66
+ try {
67
+ return await chromium.launch({ channel, headless, args });
68
+ }
69
+ catch {
70
+ /* 未安装,试下一个 */
71
+ }
72
+ }
73
+ try {
74
+ return await chromium.launch({ headless, args });
75
+ }
76
+ catch (e) {
77
+ // 前两条都失败(系统无 Chrome/Edge/Brave)才走到这里:此时才需要 Playwright 自带内核。
78
+ // 懒加载:仅当这次 launch 是因为缺内核失败,才去下载;下载成功再重试一次。
79
+ const msg = String(e?.message || e);
80
+ const missingCore = /Executable doesn't exist|executablePath.*(exist|found)|not found.*browser|download.*chromium|Please run the following command|playwright install/i.test(msg);
81
+ if (!missingCore)
82
+ throw new Error(`无法启动浏览器(系统浏览器与 Playwright 自带内核均不可用):${msg}`);
83
+ console.log(' [AUTH] 系统未检测到 Chrome/Edge/Brave,需下载 Playwright chromium 内核(约 120-200 MiB,仅此情况才需要)...');
84
+ const dl = await runCmd('npx playwright install chromium', 300000);
85
+ if (dl.code !== 0) {
86
+ throw new Error(`chromium 内核下载失败(exit ${dl.code},网络可能拦截)。可手动 \`npx playwright install chromium\`,或改用 \`bdip auth cookie-login\`。`);
87
+ }
88
+ try {
89
+ return await chromium.launch({ headless, args });
90
+ }
91
+ catch (e2) {
92
+ throw new Error(`无法启动浏览器:${String(e2?.message || e2)}`);
93
+ }
94
+ }
95
+ }
96
+ /** 查系统默认浏览器可执行路径。Windows 用注册表;macOS 用 Launch Services;Linux 用 xdg-mime。 */
97
+ function getDefaultBrowserPath() {
98
+ const tryExec = (cmd) => {
99
+ try {
100
+ return execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim();
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ };
106
+ if (process.platform === 'win32') {
107
+ // Windows: 注册表两步(ProgId → command → exe)
108
+ const out1 = tryExec('reg query "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice" /v ProgId');
109
+ const progIdMatch = out1?.match(/ProgId\s+REG_SZ\s+(\S+)/);
110
+ if (!progIdMatch)
111
+ return null;
112
+ const out2 = tryExec(`reg query "HKEY_CLASSES_ROOT\\${progIdMatch[1]}\\shell\\open\\command" /ve`);
113
+ const exeMatch = out2?.match(/"([^"]+\.exe)"/i);
114
+ return exeMatch ? exeMatch[1] : null;
115
+ }
116
+ if (process.platform === 'darwin') {
117
+ // macOS: Launch Services 查 http handler 的 bundle ID → mdfind 取 app 路径
118
+ const out = tryExec('defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers 2>/dev/null');
119
+ if (!out)
120
+ return null;
121
+ const lines = out.split('\n');
122
+ let inHttps = false;
123
+ let bundleId = null;
124
+ for (const line of lines) {
125
+ if (line.includes('"https"'))
126
+ inHttps = true;
127
+ if (inHttps && line.includes('LSHandlerRoleViewer')) {
128
+ const m = line.match(/=\s*"([^"]+)"/);
129
+ if (m)
130
+ bundleId = m[1];
131
+ inHttps = false;
132
+ }
133
+ }
134
+ if (!bundleId)
135
+ return null;
136
+ const appPath = tryExec(`mdfind "kMDItemCFBundleIdentifier == '${bundleId}'" 2>/dev/null | head -1`);
137
+ return appPath || null;
138
+ }
139
+ if (process.platform === 'linux') {
140
+ // Linux: xdg-mime 查默认 http handler → which 取路径
141
+ const handler = tryExec('xdg-mime query default x-scheme-handler/http 2>/dev/null');
142
+ if (!handler)
143
+ return null;
144
+ const exe = tryExec(`which ${handler} 2>/dev/null`) || tryExec(`which ${handler.split('.')[0]} 2>/dev/null`);
145
+ return exe || null;
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * 探测当前是否处于非交互式环境(cron/CI/无显示/BDIP_NO_REAUTH)。保守判定,避免误杀交互式终端。
151
+ * 用于 ensureValid 跳过会弹浏览器的续期——非交互式下 acquireToken 必失败,直接回退 cookie。
152
+ */
153
+ export function looksNonInteractive() {
154
+ if (process.env.BDIP_NO_REAUTH)
155
+ return true;
156
+ if (process.env.CI)
157
+ return true;
158
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
159
+ return true;
160
+ return false;
161
+ }
162
+ /** 探测 URL 是否可连(短超时),忽略 HTTPS 错误,只看能否拿到 <500 响应。 */
163
+ export async function canReach(url, timeoutMs = 6000) {
164
+ try {
165
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
166
+ return res.status < 500;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ /** 继承 stdio 跑 shell 命令,超时强杀。返回 { code }。 */
173
+ function runCmd(cmd, timeoutMs) {
174
+ return new Promise((resolve) => {
175
+ const p = spawn(cmd, { stdio: 'inherit', shell: true, timeout: timeoutMs });
176
+ const t = setTimeout(() => { try {
177
+ p.kill('SIGKILL');
178
+ }
179
+ catch { /* ignore */ } }, timeoutMs);
180
+ p.on('exit', (code) => { clearTimeout(t); resolve({ code: code ?? -1 }); });
181
+ p.on('error', (e) => { clearTimeout(t); console.error(` [AUTH] 命令执行失败: ${e.message}`); resolve({ code: -1 }); });
182
+ });
183
+ }
184
+ /**
185
+ * 确保 playwright 可用:能 import 返回 chromium;不能 import 则探网→能联网就自动装模块(不装内核)。
186
+ * 内核改为懒加载:launchSystemBrowser 优先用系统 Chrome/Edge/Brave,只有系统里确实没有时才下载自带 chromium。
187
+ * 装不上抛清晰错并引导 cookie-login。**仅在 token-login(用户主动触发)调,勿在 ensureValid 运行时调**
188
+ * (否则每次请求过期都会触发安装)。
189
+ */
190
+ export async function ensurePlaywright() {
191
+ try {
192
+ // @ts-ignore
193
+ return (await import('playwright')).chromium;
194
+ }
195
+ catch { /* 未装,尝试自动安装 */ }
196
+ const online = await canReach('https://registry.npmjs.org', 5000);
197
+ if (!online) {
198
+ throw new Error('playwright 未安装且当前环境无法联网安装。请先 `npm install playwright`,或改用 `bdip auth cookie-login` 手动导入 cookie(无需浏览器)。');
199
+ }
200
+ console.log(' [AUTH] playwright 未安装,联网自动安装模块(chromium 内核按需懒下载,通常无需)...');
201
+ const r1 = await runCmd('npm install playwright', 120000);
202
+ if (r1.code !== 0) {
203
+ throw new Error(`playwright 模块安装失败(npm exit ${r1.code})。可手动 \`npm install playwright\`,或改用 \`bdip auth cookie-login\`。`);
204
+ }
205
+ try {
206
+ // @ts-ignore
207
+ return (await import('playwright')).chromium;
208
+ }
209
+ catch {
210
+ throw new Error('playwright 安装后仍无法 import,请检查 node_modules,或改用 `bdip auth cookie-login`。');
211
+ }
212
+ }
213
+ /** 跨进程续期锁窗口(ms):窗口内其他进程检测到 acquiringAt 即跳过续期走 cookie,防并发弹多个浏览器。 */
214
+ const ACQUIRING_WINDOW_MS = 120_000;
215
+ /** 进程内续期共享锁表:key=profileName → 进行中的 acquireToken promise(同进程同环境只弹一个浏览器)。 */
216
+ const _renewInflight = new Map();
217
+ const EMPTY_DATA = { token: '', expireTime: '', expireSeconds: 0, cookieSavedAt: 0, acquireFailedAt: 0, acquiringAt: 0 };
218
+ /** 读 token 文件(加密信封 → decrypt → JSON;兼容明文 {token})。 */
219
+ function loadTokenFile(filePath) {
220
+ if (!fs.existsSync(filePath))
221
+ return null;
222
+ try {
223
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
224
+ const j = JSON.parse(raw);
225
+ if (j && j.enc === true && typeof j.data === 'string') {
226
+ return JSON.parse(decrypt(j.data));
227
+ }
228
+ if (j && typeof j.token === 'string')
229
+ return j; // 明文兼容
230
+ }
231
+ catch {
232
+ /* 损坏,视为无 */
233
+ }
234
+ return null;
235
+ }
236
+ /**
237
+ * 双模式浏览器登录拿 cookie(对标 e9-cli loginForCookie)。
238
+ * 有 username+password → 有头 autoFill(wui headless 不渲染登录框);失败转手动。
239
+ * 轮询 loginidweaver(200ms),60s hash-portal 兜底(导航 /wui/index.html#/main/portal)。
240
+ * 返回 "name=value; name=value; ..." 形式的 cookie 串;缺 loginidweaver 抛错。
241
+ */
242
+ /** Keep only this OA origin's persisted state, never send other sites' cookies to E9. */
243
+ export function filterStorageState(state, baseUrl) {
244
+ const target = new URL(baseUrl);
245
+ return {
246
+ cookies: Array.isArray(state?.cookies) ? state.cookies.filter(c => {
247
+ const domain = String(c.domain ?? '').replace(/^\./, '').toLowerCase();
248
+ return domain && (target.hostname === domain || target.hostname.endsWith('.' + domain)) &&
249
+ (c.expires === -1 || !c.expires || c.expires * 1000 > Date.now()) &&
250
+ (!c.secure || target.protocol === 'https:');
251
+ }) : [],
252
+ origins: Array.isArray(state?.origins) ? state.origins.filter(o => o.origin === target.origin) : [],
253
+ };
254
+ }
255
+ async function browserIdentityValid(context, baseUrl) {
256
+ try {
257
+ const r = await context.request.get(baseUrl + '/api/hrm/login/getAccountList', { timeout: 4000, maxRedirects: 0 });
258
+ if (r.status() !== 200) return false;
259
+ const j = await r.json();
260
+ if (j.status === false || j.errorCode) return false;
261
+ const rows = j.data?.accountlist;
262
+ const current = Array.isArray(rows) ? rows.filter(a => String(a.iscurrent) === '1') : [];
263
+ return current.length === 1 && /^[1-9]\d*$/.test(String(current[0].userid));
264
+ } catch { return false; }
265
+ }
266
+
267
+ export async function loginForCookie(baseUrl, username, password, opts = {}) {
268
+ const headless = opts.headless ?? false;
269
+ let chromium;
270
+ try {
271
+ // @ts-ignore
272
+ chromium = (await import('playwright')).chromium;
273
+ }
274
+ catch {
275
+ const error = new Error('浏览器登录组件不可用;可手动安装 Playwright,或在主动登录流程中查看临时兼容方式。');
276
+ error.code = 'BROWSER_ENV_UNAVAILABLE';
277
+ throw error;
278
+ }
279
+ const autoFill = !!(username && password);
280
+ const args = headless ? ['--disable-blink-features=AutomationControlled'] : ['--start-maximized'];
281
+ let browser;
282
+ let context;
283
+ const statePath = path.join(CONFIG_DIR, `storageState_${profileNameFromBaseUrl(baseUrl)}.json`);
284
+ let storageState;
285
+ if (opts.reuseStorageState !== false) {
286
+ try { storageState = filterStorageState(JSON.parse(fs.readFileSync(statePath, 'utf8')), baseUrl); } catch { /* first login */ }
287
+ }
288
+ // 启动浏览器会话;开不出窗口(内核缺/无桌面)抛清晰错并引导 cookie-login,不让 Playwright 天书外泄
289
+ try {
290
+ browser = await launchSystemBrowser(chromium, headless, args);
291
+ context = await browser.newContext({
292
+ storageState,
293
+ ignoreHTTPSErrors: true,
294
+ viewport: { width: 1280, height: 800 },
295
+ });
296
+ }
297
+ catch (launchErr) {
298
+ // Only transport setup failure is classified as an unavailable environment.
299
+ // Navigation, rejected login and user consent failures below never receive this code.
300
+ try { await browser?.close(); } catch { /* release partially started browser */ }
301
+ const error = new Error('无法启动浏览器会话:当前环境缺少可用浏览器、桌面或运行依赖;可查看主动登录的临时兼容方式。');
302
+ error.code = 'BROWSER_ENV_UNAVAILABLE';
303
+ throw error;
304
+ }
305
+ await context.addInitScript(() => {
306
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
307
+ });
308
+ const page = await context.newPage();
309
+ try {
310
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch((e) => {
311
+ if (headless)
312
+ throw e;
313
+ console.error(` ⚠ 首页加载失败: ${e.message},请在浏览器手动访问登录`);
314
+ });
315
+ const reusedLogin = await browserIdentityValid(context, baseUrl);
316
+ if (autoFill && !reusedLogin) {
317
+ // 有头 autoFill:等待登录输入框渲染;headless 不渲染会超时抛错降级
318
+ await page.waitForSelector('#loginid, input[name=loginid], input[type=text]', { timeout: 10000 }).catch(() => { });
319
+ await page.fill('#loginid, input[name=loginid], input[type=text]', username).catch(() => { });
320
+ await page.fill('#userpassword, input[type=password]', password).catch(() => { });
321
+ await Promise.all([
322
+ page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => { }),
323
+ page.click('button#submit, button[type=submit], button, input[type=submit]').catch(() => { }),
324
+ ]);
325
+ }
326
+ else if (!headless && !reusedLogin) {
327
+ console.log(' 请在浏览器登录页输入账密并点登录(最长 600s)...');
328
+ }
329
+ // 轮询 loginidweaver。autoFill 未拿到 cookie 时原地转手动登录(复用同一浏览器会话,不重开)
330
+ const t0 = Date.now();
331
+ const autoFillDeadline = t0 + (autoFill ? 45000 : 0);
332
+ const deadline = t0 + (headless ? 30000 : 600000);
333
+ let cookies = [];
334
+ let prompted = false;
335
+ let portaled = false;
336
+ let fellBackToManual = false;
337
+ let authenticated = false;
338
+ while (Date.now() < deadline) {
339
+ cookies = await context.cookies(baseUrl + '/wui/index.html');
340
+ if (cookies.some((c) => c.name === 'loginidweaver') && (reusedLogin || await browserIdentityValid(context, baseUrl))) {
341
+ authenticated = true;
342
+ break;
343
+ }
344
+ // autoFill 用尽仍无 cookie → 原地提示手动登录(同一浏览器,避免二次 launchSystemBrowser)
345
+ if (autoFill && !fellBackToManual && Date.now() >= autoFillDeadline) {
346
+ fellBackToManual = true;
347
+ console.error(' [AUTH] autoFill 未拿到登录 Cookie,请在已打开的浏览器手动登录(最长 600s)...');
348
+ }
349
+ if (!headless && !portaled && Date.now() - t0 > 60000) {
350
+ // hash-portal 兜底:hash 路由切 SPA portal 设 cookie
351
+ await page
352
+ .goto(`${baseUrl}/wui/index.html#/main/portal`, { waitUntil: 'commit', timeout: 30000 })
353
+ .catch(() => { });
354
+ await sleep(3000);
355
+ portaled = true;
356
+ }
357
+ if (!headless && !prompted && Date.now() - t0 > 30000) {
358
+ console.log(' ⏳ 仍在等待 loginidweaver cookie...');
359
+ prompted = true;
360
+ }
361
+ await sleep(200);
362
+ }
363
+ if (!authenticated) throw new Error('未验证到真实登录用户,不能只凭 loginidweaver Cookie 认定登录成功');
364
+ if (!await browserIdentityValid(context, baseUrl)) throw new Error('登录后账号核验失败,请重新登录');
365
+ cookies = await context.cookies(baseUrl + '/wui/index.html');
366
+ const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
367
+ // Save only this OA's state after login consent and identity verification.
368
+ try {
369
+ ensureDir(CONFIG_DIR);
370
+ const state = filterStorageState(await context.storageState(), baseUrl);
371
+ fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
372
+ try { fs.chmodSync(statePath, 0o600); } catch { /* Windows ACL applies */ }
373
+ }
374
+ catch { /* Cookie login still works if state persistence is unavailable */ }
375
+ return cookieString;
376
+ }
377
+ finally {
378
+ try {
379
+ if (browser)
380
+ await browser.close();
381
+ }
382
+ catch { /* 关闭失败忽略 */ }
383
+ }
384
+ }
385
+ /** 已知 agent 工具的 env 签名表(叶子层探测,按顺序匹配)。 */
386
+ const KNOWN_AGENT_TOOLS = [
387
+ // Claude Code:子进程注入 CLAUDECODE=1 / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_EXECPATH(已实跑确认)
388
+ { keys: ['CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_EXECPATH'], display: 'Claude-Code' },
389
+ // Kimi Work:签名待该工具实跑 env 确认;未命中时走 AI_AGENT 或产品名兜底。
390
+ { keys: ['KIMI_WORK', 'KIMI', 'MOONSHOT_SESSION_ID'], display: 'Kimi-Work' },
391
+ // TRAE Work(字节 Trae):签名待确认(猜 TRAE_WORK/TRAE/TRAE_SESSION_ID)
392
+ { keys: ['TRAE_WORK', 'TRAE', 'TRAE_SESSION_ID'], display: 'Trae-Work' },
393
+ // DuMate:签名待确认(猜 DUMATE/DU_MATE/DUMATE_SESSION_ID)
394
+ { keys: ['DUMATE', 'DU_MATE', 'DUMATE_SESSION_ID'], display: 'DuMate' },
395
+ ];
396
+ /** 无法识别实际 agent 时的兜底展示名。 */
397
+ const FALLBACK_DISPLAY = '毕埃慕ai助手';
398
+ function detectKnownAgent() {
399
+ for (const tool of KNOWN_AGENT_TOOLS) {
400
+ for (const k of tool.keys) {
401
+ const v = process.env[k];
402
+ if (v !== undefined && v !== '')
403
+ return tool.display;
404
+ }
405
+ }
406
+ return null;
407
+ }
408
+ export function getAgentInfo() {
409
+ // 0. 通用覆盖:任何 agent 工具/宿主都可设 E9_AGENT_DISPLAY=<名> 自报家门(免改代码)
410
+ const override = process.env.E9_AGENT_DISPLAY;
411
+ if (override) {
412
+ const display = override.trim();
413
+ return { framework: display.toLowerCase(), display };
414
+ }
415
+ // 1. 叶子层:已知 agent 工具 env 签名探测
416
+ const known = detectKnownAgent();
417
+ if (known)
418
+ return { framework: known.toLowerCase(), display: known };
419
+ // 2. 回退:AI_AGENT 宿主/注册名解析
420
+ const aiAgent = process.env.AI_AGENT || '';
421
+ // claude-code_2-1-218_agent → "Claude-Code"(取下划线前的框架名,分段首字母大写,保留 "-" 分隔)
422
+ let framework = '';
423
+ if (aiAgent) {
424
+ const m = aiAgent.match(/^([a-zA-Z]+(?:-[a-zA-Z]+)*)_/);
425
+ if (m) {
426
+ framework = m[1]
427
+ .split('-')
428
+ .map((s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s))
429
+ .join('-');
430
+ }
431
+ }
432
+ // 都识别不出 → 使用产品名,避免向最终用户暴露历史内部 CLI 名称。
433
+ if (!framework)
434
+ framework = FALLBACK_DISPLAY;
435
+ return { framework: framework.toLowerCase(), display: framework };
436
+ }
437
+ const SKILL_AUTH_VERIFY_BASE = 'https://bim.bdip.com';
438
+ export const SKILL_AUTH_CONTACT = [
439
+ '如需申请或更新授权,请联系毕埃慕:',
440
+ ' 400咨询热线:4000-835-957',
441
+ ' 座机:021-35081131',
442
+ ' 企业邮箱:bim@17bim.com',
443
+ ].join('\n');
444
+ export function userFacingSkillAuthMessage(kind = 'no_auth') {
445
+ if (kind === 'expired') return `授权已过期。\n${SKILL_AUTH_CONTACT}`;
446
+ if (kind === 'verify_failed') return `授权校验未通过。\n${SKILL_AUTH_CONTACT}`;
447
+ if (kind === 'no_domain') return `尚未配置办公平台地址,无法校验授权。请先提供 OA 地址;若还没有授权,\n${SKILL_AUTH_CONTACT}`;
448
+ return `当前办公平台未获得有效授权。\n${SKILL_AUTH_CONTACT}`;
449
+ }
450
+ /** Authorization is bound to the configured OA host, including a non-default port. */
451
+ export function skillAuthDomain(baseUrl) {
452
+ const raw = String(baseUrl ?? '').trim();
453
+ if (!raw) return '';
454
+ try { return new URL(raw).host; }
455
+ catch { return raw.replace(/^https?:\/\//i, '').replace(/\/+$/, '').split('/')[0]; }
456
+ }
457
+ function authCodeError(kind, code, message) {
458
+ const error = new Error(message);
459
+ error.e9kind = kind;
460
+ error.code = code;
461
+ return error;
462
+ }
463
+ /** Check the configured OA domain without sending Cookie or login credentials. */
464
+ export async function verifySkillAuth(baseUrl) {
465
+ const domain = skillAuthDomain(baseUrl);
466
+ if (!domain) throw authCodeError('no-auth-domain', 'AI_SKILL_NO_AUTH', userFacingSkillAuthMessage('no_domain'));
467
+ const url = `${SKILL_AUTH_VERIFY_BASE}/bdipCloud/api/ai/aiSkillAuth/verifyByDomain?${new URLSearchParams({ domain })}`;
468
+ let response;
469
+ try {
470
+ response = await fetch(url, {
471
+ method: 'GET',
472
+ headers: {
473
+ Accept: 'application/json, text/plain, */*',
474
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
475
+ },
476
+ signal: AbortSignal.timeout(15000),
477
+ redirect: 'manual',
478
+ });
479
+ } catch {
480
+ throw authCodeError('network', 'AI_SKILL_AUTH_VERIFY_FAILED', '授权校验请求失败,请检查网络后重试。');
481
+ }
482
+ let json;
483
+ try { json = await response.json(); }
484
+ catch { throw authCodeError('auth-verify-invalid', 'AI_SKILL_AUTH_VERIFY_FAILED', userFacingSkillAuthMessage('verify_failed')); }
485
+ const code = Number(json?.code);
486
+ const msgKey = String(json?.msgKey ?? json?.msg_key ?? '');
487
+ const message = String(json?.message ?? json?.msg ?? '');
488
+ if (response.ok && (code === 200 || msgKey === 'OperOk'))
489
+ return { ok: true, code, msgKey: msgKey || 'OperOk', message, domain };
490
+ if (msgKey === 'ai_skill_auth_expired' || /授权已过期/.test(message))
491
+ throw authCodeError('auth-code-expired', 'AI_SKILL_AUTH_EXPIRED', userFacingSkillAuthMessage('expired'));
492
+ if (msgKey === 'ai_skill_no_auth' || /没有授权/.test(message))
493
+ throw authCodeError('no-auth-code', 'AI_SKILL_NO_AUTH', userFacingSkillAuthMessage('no_auth'));
494
+ throw authCodeError('auth-verify-failed', 'AI_SKILL_AUTH_VERIFY_FAILED', userFacingSkillAuthMessage('verify_failed'));
495
+ }
496
+ export async function ensureAgentAuth(baseUrl) {
497
+ const domain = skillAuthDomain(baseUrl);
498
+ console.error(' [AUTH] 登录前按域名校验授权...');
499
+ await verifySkillAuth(baseUrl);
500
+ console.error(' [AUTH] 域名授权有效。');
501
+ return { domain };
502
+ }
503
+ function looksAuthCodeRejected(status, json, text) {
504
+ const msgKey = String(json?.msgKey ?? json?.msg_key ?? '');
505
+ if (msgKey === 'ai_skill_no_auth' || msgKey === 'ai_skill_auth_expired') return true;
506
+ const msg = String(json?.msg ?? json?.message ?? text ?? '');
507
+ if (/授权码|authCode|auth.?code|没有授权|授权已过期|域名授权/i.test(msg)
508
+ && /过期|无效|失效|不存在|错误|拒绝|invalid|expired|forbidden|denied|没有授权/i.test(msg)) return true;
509
+ if (status === 401 || status === 403) return /授权码|authCode|auth.?code|授权/i.test(msg);
510
+ return /AUTH_CODE|AUTHCODE|LICENSE|AI_SKILL/i.test(String(json?.errorCode ?? ''));
511
+ }
512
+ /** GET /api/ai/agent/login/register 用 cookie 换 token(仿 curl 头)。失败抛带 e9kind/e9status 的错,供上层大声诊断。 */
513
+ export async function registerToken(baseUrl, cookieString, cfg) {
514
+ const agent = getAgentInfo();
515
+ let resp;
516
+ try {
517
+ resp = await fetch(`${baseUrl}/api/ai/agent/login/register?${new URLSearchParams({ aiAgentType: agent.display })}`, {
518
+ headers: {
519
+ Cookie: cookieString,
520
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
521
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
522
+ },
523
+ signal: AbortSignal.timeout(15000), redirect: 'manual',
524
+ });
525
+ }
526
+ catch (e) {
527
+ const err = new Error(`register 请求失败(${e.message})。可能 baseUrl 不可达或网络不通。`);
528
+ err.e9kind = 'network';
529
+ throw err;
530
+ }
531
+ const status = resp.status;
532
+ const text = await resp.text();
533
+ if (status === 404) {
534
+ const err = new Error(`此 BDIP5 无 /api/ai/agent/login/register 接口(HTTP 404),该环境不支持 aiAgentToken。`);
535
+ err.e9kind = 'unsupported';
536
+ err.e9status = status;
537
+ throw err;
538
+ }
539
+ let json;
540
+ try {
541
+ json = JSON.parse(text);
542
+ }
543
+ catch {
544
+ const err = new Error(`register 返回非 JSON(HTTP ${status}),未输出可能含凭据的响应正文`);
545
+ err.e9status = status;
546
+ throw err;
547
+ }
548
+ rejectForcedOffline(cfg, { authenticated: false, mode: 'cookie', code: authFailureCode(status, json) }, cookieString);
549
+ if (looksAuthCodeRejected(status, json, text)) {
550
+ if (String(json?.msgKey ?? '') === 'ai_skill_auth_expired' || /授权已过期/.test(String(json?.message ?? json?.msg ?? '')))
551
+ throw authCodeError('auth-code-expired', 'AI_SKILL_AUTH_EXPIRED', userFacingSkillAuthMessage('expired'));
552
+ throw authCodeError('no-auth-code', 'AI_SKILL_NO_AUTH', userFacingSkillAuthMessage('no_auth'));
553
+ }
554
+ if (!resp.ok || !json || json.status === false || json.errorCode) {
555
+ const code = /^[\w-]{1,64}$/.test(String(json?.errorCode ?? '')) ? String(json.errorCode) : 'UNKNOWN';
556
+ const err = new Error(`register 失败(HTTP ${status},code=${code})`);
557
+ err.e9status = status;
558
+ throw err;
559
+ }
560
+ const data = json.data || {};
561
+ if (typeof data.aiAgentToken !== 'string' || !data.aiAgentToken || /[\r\n]/.test(data.aiAgentToken)) {
562
+ const err = new Error(`register 未返回有效 aiAgentToken(HTTP ${status})`);
563
+ err.e9status = status;
564
+ throw err;
565
+ }
566
+ if (cfg) mergeResponseCookies(cfg, resp.headers, cookieString);
567
+ return { token: data.aiAgentToken, expireTime: data.expireTime || '', expireSeconds: data.expireSeconds || 0 };
568
+ }
569
+ /**
570
+ * 当前环境的 token 会话(单一端点)。持有 cfg/endpoint(回写 cookie 到 config)、token 文件路径与缓存数据。
571
+ */
572
+ export class AuthTokenSession {
573
+ cfg;
574
+ endpoint;
575
+ filePath;
576
+ data;
577
+ constructor(cfg, endpoint, filePath, data, profileName) {
578
+ this.cfg = cfg;
579
+ this.endpoint = endpoint;
580
+ this.filePath = filePath;
581
+ this.data = data || { ...EMPTY_DATA };
582
+ this.profileName = profileName || path.basename(filePath, '.json');
583
+ }
584
+ get token() {
585
+ return this.data.token || '';
586
+ }
587
+ get baseUrl() {
588
+ return this.endpoint.baseUrl.replace(/\/+$/, '');
589
+ }
590
+ get isExpired() {
591
+ if (!this.data.token)
592
+ return true;
593
+ const savedAt = this.data.cookieSavedAt || 0;
594
+ const ttl = (this.data.expireSeconds || 0) * 1000;
595
+ if (savedAt && ttl)
596
+ return Date.now() > savedAt + ttl - 60000;
597
+ if (this.data.expireTime)
598
+ return Date.now() > new Date(this.data.expireTime).getTime() - 60000;
599
+ return false; // 缺过期信息时信任本地 token,由 reactive 重试(requestWithRetry)兜底服务端吊销
600
+ }
601
+ static fromProfile(cfg) {
602
+ ensureDir(TOKEN_DIR);
603
+ const endpoint = endpointFor(cfg);
604
+ const profileName = profileNameFromBaseUrl(endpoint.baseUrl);
605
+ const filePath = tokenFilePath(profileName);
606
+ const data = loadTokenFile(filePath) || { ...EMPTY_DATA };
607
+ return new AuthTokenSession(cfg, endpoint, filePath, data, profileName);
608
+ }
609
+ /** 从磁盘重新加载 token 数据(跨进程锁检测需要最新快照,避免读到旧 data 而忽略别的进程的 acquiringAt)。 */
610
+ _reloadData() {
611
+ const fresh = loadTokenFile(this.filePath);
612
+ if (fresh)
613
+ this.data = { ...EMPTY_DATA, ...fresh };
614
+ }
615
+ /** 把浏览器登录拿到的刷新 cookie 写回 config.json 的 cookie 字段(供回退)。 */
616
+ saveCookieToProfile(cookieString) {
617
+ if (!cookieString)
618
+ return;
619
+ if (!updateConfigCookie(this.cfg, cookieString)) throw new Error('未保存 Cookie:平台配置已变更或 Cookie 格式无效');
620
+ }
621
+ /** 申请/续期 token:loginForCookie → saveCookieToProfile → registerToken。register 失败标记 noToken 返回 null。
622
+ * 双层互斥防「同时弹很多个登录页」:
623
+ * ① 进程内锁:同 profile 已有续期在跑 → 其余调用 await 同一个 promise(report collect 7 路并发只弹 1 个);
624
+ * ② 跨进程锁:token 文件写 acquiringAt 时间戳,其他进程见窗口内标记 → 跳过续期走 cookie。
625
+ * reason: 'first'(cmdTokenLogin 首次登录)/ 'renew'(ensureValid/reactive 续期,默认)—— 区分文案。 */
626
+ async acquireToken(reason = 'renew') {
627
+ const key = this.profileName || 'default';
628
+ if (_renewInflight.has(key))
629
+ return _renewInflight.get(key); // 同进程已在续期:等同一个结果,不重复弹浏览器
630
+ const p = this._acquireTokenInner(reason);
631
+ _renewInflight.set(key, p);
632
+ try {
633
+ return await p;
634
+ }
635
+ finally {
636
+ _renewInflight.delete(key);
637
+ }
638
+ }
639
+ /** acquireToken 真实主体(持有跨进程锁时执行);跳过续期/未拿到 token 均返回 null。 */
640
+ async _acquireTokenInner(reason = 'renew') {
641
+ await ensureAgentAuth(this.baseUrl);
642
+ // ② 跨进程锁:重读磁盘看别的进程是否正在续期(窗口内跳过)
643
+ this._reloadData();
644
+ const acquiringAt = this.data.acquiringAt || 0;
645
+ if (acquiringAt && Date.now() - acquiringAt < ACQUIRING_WINDOW_MS) {
646
+ console.error(` [AUTH] 另一进程正在续期(${Math.round((Date.now() - acquiringAt) / 1000)}s 前开始),本进程跳过续期,走 cookie 回退。`);
647
+ return null;
648
+ }
649
+ // 抢跨进程锁
650
+ this.data.acquiringAt = Date.now();
651
+ this.save();
652
+ try {
653
+ const password = this.cfg.encPassword ? decrypt(this.cfg.encPassword) : '';
654
+ const username = this.cfg.username || '';
655
+ // ★ #15 文案区分:续期 vs 首登(loginForCookie 的"请在浏览器登录页输入账密"是共享等待语,这里先打场景横幅)
656
+ if (reason === 'first')
657
+ console.error('[AUTH] 首次登录:浏览器登录并核验账号后申请 aiAgentToken。');
658
+ else
659
+ console.error('[AUTH] token 已过期:浏览器登录并核验账号后申请新 aiAgentToken。');
660
+ // autoFill 失败已在 loginForCookie 内原地转手动登录(同一浏览器会话),不再二次重开浏览器
661
+ const cookieString = await loginForCookie(this.baseUrl, username, username ? password : '', { headless: false });
662
+ this.saveCookieToProfile(cookieString);
663
+ let checkedCookie = this.cfg.cookie;
664
+ const sessionState = await probeCookieSession(this.cfg, { onCredential: cookie => { checkedCookie = cookie; } });
665
+ rejectForcedOffline(this.cfg, sessionState, checkedCookie);
666
+ if (sessionState.code === 'SESSION_EXPIRED') throw new Error(sessionState.message);
667
+ if (sessionState.authenticated) console.error(`[AUTH] 已验证 Cookie 当前用户 ${sessionState.userId};${sessionState.mode === 'extension' ? `空闲超时 ${sessionState.idleTimeoutSeconds} 秒` : '当前以原生会话保活'}`);
668
+ try {
669
+ const { token, expireTime, expireSeconds } = await registerToken(this.baseUrl, this.cfg.cookie, this.cfg);
670
+ this.data = { token, expireTime, expireSeconds, cookieSavedAt: Date.now(), noToken: false,
671
+ userId: sessionState.authenticated ? sessionState.userId : undefined, acquireFailedAt: 0, acquiringAt: 0 };
672
+ this.save();
673
+ return token;
674
+ }
675
+ catch (e) {
676
+ if (e.code === 'FORCED_OFFLINE' || e.code === 'AI_SKILL_NO_AUTH' || e.code === 'AI_SKILL_AUTH_EXPIRED') throw e;
677
+ // 大声诊断:区分 404 不支持 / 网络不通 / 其它,不再含糊"系统可能不支持"
678
+ const kind = e?.e9kind || (e?.e9status === 404 ? 'unsupported' : 'other');
679
+ if (kind === 'unsupported')
680
+ console.error(` [AUTH] 此 BDIP5 不支持 aiAgentToken(${e.message})。已切到 cookie 鉴权备胎。`);
681
+ else if (kind === 'network')
682
+ console.error(` [AUTH] register 请求失败:${e.message}。已切到 cookie 鉴权备胎(请用 \`bdip auth status --check\` 排查 baseUrl 可达性)。`);
683
+ else
684
+ console.error(` [AUTH] register 拿 token 失败:${e.message}。已切到 cookie 鉴权备胎。`);
685
+ console.error(` [AUTH] cookie 有效期由服务器定,过期需重新导入;运行 \`bdip auth status\` 查看当前模式。`);
686
+ this.data = { token: '', expireTime: '', expireSeconds: 0, cookieSavedAt: Date.now(), noToken: true, acquireFailedAt: Date.now(), acquiringAt: 0 };
687
+ this.save();
688
+ return null;
689
+ }
690
+ }
691
+ finally {
692
+ // 释放跨进程锁(reload 拿最新快照,避免覆盖续期期间写入的 token/acquireFailedAt)
693
+ this._reloadData();
694
+ this.data.acquiringAt = 0;
695
+ this.save();
696
+ }
697
+ }
698
+ /** Renew an expiring agent token with the live Cookie, without opening a browser. */
699
+ async renewWithCookie() {
700
+ await ensureAgentAuth(this.baseUrl);
701
+ const key = this.profileName || 'default';
702
+ if (_renewInflight.has(key)) return _renewInflight.get(key);
703
+ const work = async () => {
704
+ this._reloadData();
705
+ if (this.data.acquiringAt && Date.now() - this.data.acquiringAt < ACQUIRING_WINDOW_MS) return null;
706
+ if (this.data.cookieRenewFailedAt && Date.now() - this.data.cookieRenewFailedAt < 600000) return null;
707
+ let checkedCookie = this.cfg.cookie;
708
+ const state = await probeCookieSession(this.cfg, { onCredential: cookie => { checkedCookie = cookie; } });
709
+ rejectForcedOffline(this.cfg, state, checkedCookie);
710
+ if (!state.authenticated) return null;
711
+ const acquiredAt = Date.now();
712
+ this.data.acquiringAt = acquiredAt;
713
+ this.save();
714
+ try {
715
+ const { token, expireTime, expireSeconds } = await registerToken(this.baseUrl, this.cfg.cookie, this.cfg);
716
+ this.data = { token, expireTime, expireSeconds, cookieSavedAt: Date.now(), noToken: false,
717
+ userId: state.userId, acquireFailedAt: 0, acquiringAt: 0, cookieRenewFailedAt: 0 };
718
+ this.save();
719
+ return token;
720
+ }
721
+ catch (error) {
722
+ if (error.code === 'FORCED_OFFLINE' || error.code === 'AI_SKILL_NO_AUTH' || error.code === 'AI_SKILL_AUTH_EXPIRED') throw error;
723
+ this.data.cookieRenewFailedAt = Date.now();
724
+ this.data.acquiringAt = 0;
725
+ this.save();
726
+ return null;
727
+ }
728
+ finally {
729
+ this._reloadData();
730
+ if (this.data.acquiringAt === acquiredAt) { this.data.acquiringAt = 0; this.save(); }
731
+ }
732
+ };
733
+ const p = work();
734
+ _renewInflight.set(key, p);
735
+ try { return await p; } finally { _renewInflight.delete(key); }
736
+ }
737
+ async ensureValid(expectedUserId) {
738
+ this._reloadData();
739
+ if (this.data.noToken)
740
+ return false;
741
+ if (!this.data.token)
742
+ return false;
743
+ // Never mix a previous/unknown token identity with a verified Cookie account.
744
+ if (expectedUserId && String(this.data.userId ?? '') !== String(expectedUserId)) return !!(await this.renewWithCookie());
745
+ if (!this.isExpired)
746
+ return true;
747
+ // A live Cookie can renew the agent token without interrupting a long-running task.
748
+ if (this.cfg.cookie) return !!(await this.renewWithCookie());
749
+ // ★ #13 近期续期失败过 → 退避窗口内回退 cookie,不反复弹浏览器
750
+ if (this.data.acquireFailedAt && Date.now() - this.data.acquireFailedAt < 10 * 60 * 1000)
751
+ return false;
752
+ // 非交互式环境不弹浏览器续期(必失败且会卡住),直接回退 cookie;服务端吊销由 reactive 重试兜底
753
+ if (looksNonInteractive())
754
+ return false;
755
+ try {
756
+ // 用 acquireToken 返回值判断:跨进程锁跳过(返回 null)→ false(走 cookie),不能看 data.token 旧值
757
+ const t = await this.acquireToken();
758
+ return !!t;
759
+ }
760
+ catch (e) {
761
+ console.error(`[AUTH] token 续期失败: ${e.message}`);
762
+ // ★ #13 持久化续期失败标记,10min 退避窗口内不再弹浏览器(避免每条命令重复弹)
763
+ try {
764
+ this.data.acquireFailedAt = Date.now();
765
+ this.save();
766
+ }
767
+ catch { /* 写盘失败不阻断当次回退 cookie */ }
768
+ return false;
769
+ }
770
+ }
771
+ /** 加密信封写 token 文件。 */
772
+ save() {
773
+ ensureDir(TOKEN_DIR);
774
+ const enc = encrypt(JSON.stringify(this.data));
775
+ fs.writeFileSync(this.filePath, JSON.stringify({ enc: true, v: 1, data: enc }, null, 2) + '\n', 'utf8');
776
+ try {
777
+ fs.chmodSync(this.filePath, 0o600);
778
+ }
779
+ catch {
780
+ /* Windows 忽略 */
781
+ }
782
+ }
783
+ }