weaver-e9-assistant-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/args.js +36 -0
- package/dist/auth-token.js +690 -0
- package/dist/auth.js +187 -0
- package/dist/blog.js +469 -0
- package/dist/cli.js +4041 -0
- package/dist/client.js +199 -0
- package/dist/config.js +124 -0
- package/dist/createcontext.js +41 -0
- package/dist/crypto.js +139 -0
- package/dist/cube.js +1633 -0
- package/dist/email.js +838 -0
- package/dist/fileutil.js +20 -0
- package/dist/kq.js +204 -0
- package/dist/links.js +34 -0
- package/dist/odoc.js +194 -0
- package/dist/pageopen.js +29 -0
- package/dist/parse.js +159 -0
- package/dist/querystate.js +38 -0
- package/dist/render.js +21 -0
- package/dist/report.js +50 -0
- package/dist/submissionplan.js +43 -0
- package/dist/submissionstate.js +81 -0
- package/dist/todoaskidea.js +54 -0
- package/dist/todobatch.js +154 -0
- package/dist/todocc.js +128 -0
- package/dist/todoforward.js +66 -0
- package/dist/todoreject.js +215 -0
- package/dist/todosubmit.js +277 -0
- package/dist/todotransfer.js +54 -0
- package/dist/util.js +25 -0
- package/dist/wfcreate.js +102 -0
- package/dist/wfform.js +194 -0
- package/dist/wfops.js +40 -0
- package/dist/wfreceiver.js +43 -0
- package/dist/wfstart.js +717 -0
- package/dist/wfsummary.js +89 -0
- package/dist/wfupload.js +78 -0
- package/dist/wfuser.js +60 -0
- package/dist/workflow.js +461 -0
- package/dist/workplan.js +394 -0
- package/package.json +25 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiAgentToken 认证核心(对标 e9-cli lib/auth-token.js)。
|
|
3
|
+
*
|
|
4
|
+
* 链路:
|
|
5
|
+
* loginForCookie(有头 autoFill,失败转手动;轮询 loginidweaver,60s hash-portal 兜底)
|
|
6
|
+
* → saveCookieToProfile(存 cookie 供回退,写回 config.json 对应字段)
|
|
7
|
+
* → registerToken: GET /api/ai/agent/login/register 带 cookie → aiAgentToken(8h,加密存)
|
|
8
|
+
* 成功 → { token, expireTime, expireSeconds, cookieSavedAt, noToken:false }
|
|
9
|
+
* 404/异常 → { token:'', noToken:true }(回退 cookie)
|
|
10
|
+
* → promptAuthConfirm(有头,弹授权 overlay,用户点确认才 registerToken)
|
|
11
|
+
*
|
|
12
|
+
* token 文件:~/.e9/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, saveConfig } from './config.js';
|
|
21
|
+
// token 文件固定写在 ~/.e9/tokens/(与 storageState、config 的 ~/.e9 回退一致),
|
|
22
|
+
// 不再用 process.cwd()——否则换工作目录就找不到 token → 回退 cookie → 连锁触发续期弹浏览器
|
|
23
|
+
const CONFIG_DIR = path.join(os.homedir(), '.e9');
|
|
24
|
+
const TOKEN_DIR = path.join(CONFIG_DIR, 'tokens');
|
|
25
|
+
function ensureDir(p) {
|
|
26
|
+
if (!fs.existsSync(p))
|
|
27
|
+
fs.mkdirSync(p, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
function tokenFilePath(profileName) {
|
|
30
|
+
return path.join(TOKEN_DIR, `${profileName}.json`);
|
|
31
|
+
}
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 启动浏览器:优先用系统默认浏览器(注册表查),Chromium 系(Chrome/Edge/Brave)直接 executablePath 打开;
|
|
37
|
+
* 非 Chromium 系(Firefox 等)跳过 → 回退 channel chrome/msedge → 最后 Playwright 自带 chromium
|
|
38
|
+
* (自带内核未下载时才懒下载一次——见末尾兜底)。
|
|
39
|
+
*/
|
|
40
|
+
async function launchSystemBrowser(chromium, headless, args) {
|
|
41
|
+
// 1. 查系统默认浏览器路径
|
|
42
|
+
const defaultPath = getDefaultBrowserPath();
|
|
43
|
+
if (defaultPath) {
|
|
44
|
+
const lower = defaultPath.toLowerCase();
|
|
45
|
+
// Chromium 系浏览器可直接用 executablePath 打开
|
|
46
|
+
if (/chrome|msedge|edge|brave|chromium/i.test(lower)) {
|
|
47
|
+
try {
|
|
48
|
+
return await chromium.launch({ executablePath: defaultPath, headless, args });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* 路径无效,试下一个 */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Firefox 等非 Chromium 系:跳过(Playwright chromium 模块不支持),走下面的 channel 回退
|
|
55
|
+
}
|
|
56
|
+
// 2. 回退:试 channel chrome → msedge → Playwright 自带 chromium
|
|
57
|
+
for (const channel of ['chrome', 'msedge']) {
|
|
58
|
+
try {
|
|
59
|
+
return await chromium.launch({ channel, headless, args });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* 未安装,试下一个 */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return await chromium.launch({ headless, args });
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
// 前两条都失败(系统无 Chrome/Edge/Brave)才走到这里:此时才需要 Playwright 自带内核。
|
|
70
|
+
// 懒加载:仅当这次 launch 是因为缺内核失败,才去下载;下载成功再重试一次。
|
|
71
|
+
const msg = String(e?.message || e);
|
|
72
|
+
const missingCore = /Executable doesn't exist|executablePath.*(exist|found)|not found.*browser|download.*chromium|Please run the following command|playwright install/i.test(msg);
|
|
73
|
+
if (!missingCore)
|
|
74
|
+
throw new Error(`无法启动浏览器(系统浏览器与 Playwright 自带内核均不可用):${msg}`);
|
|
75
|
+
console.log(' [AUTH] 系统未检测到 Chrome/Edge/Brave,需下载 Playwright chromium 内核(约 120-200 MiB,仅此情况才需要)...');
|
|
76
|
+
const dl = await runCmd('npx playwright install chromium', 300000);
|
|
77
|
+
if (dl.code !== 0) {
|
|
78
|
+
throw new Error(`chromium 内核下载失败(exit ${dl.code},网络可能拦截)。可手动 \`npx playwright install chromium\`,或改用 \`e9 auth cookie-login\`。`);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await chromium.launch({ headless, args });
|
|
82
|
+
}
|
|
83
|
+
catch (e2) {
|
|
84
|
+
throw new Error(`无法启动浏览器:${String(e2?.message || e2)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** 查系统默认浏览器可执行路径。Windows 用注册表;macOS 用 Launch Services;Linux 用 xdg-mime。 */
|
|
89
|
+
function getDefaultBrowserPath() {
|
|
90
|
+
const tryExec = (cmd) => {
|
|
91
|
+
try {
|
|
92
|
+
return execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (process.platform === 'win32') {
|
|
99
|
+
// Windows: 注册表两步(ProgId → command → exe)
|
|
100
|
+
const out1 = tryExec('reg query "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice" /v ProgId');
|
|
101
|
+
const progIdMatch = out1?.match(/ProgId\s+REG_SZ\s+(\S+)/);
|
|
102
|
+
if (!progIdMatch)
|
|
103
|
+
return null;
|
|
104
|
+
const out2 = tryExec(`reg query "HKEY_CLASSES_ROOT\\${progIdMatch[1]}\\shell\\open\\command" /ve`);
|
|
105
|
+
const exeMatch = out2?.match(/"([^"]+\.exe)"/i);
|
|
106
|
+
return exeMatch ? exeMatch[1] : null;
|
|
107
|
+
}
|
|
108
|
+
if (process.platform === 'darwin') {
|
|
109
|
+
// macOS: Launch Services 查 http handler 的 bundle ID → mdfind 取 app 路径
|
|
110
|
+
const out = tryExec('defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers 2>/dev/null');
|
|
111
|
+
if (!out)
|
|
112
|
+
return null;
|
|
113
|
+
const lines = out.split('\n');
|
|
114
|
+
let inHttps = false;
|
|
115
|
+
let bundleId = null;
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (line.includes('"https"'))
|
|
118
|
+
inHttps = true;
|
|
119
|
+
if (inHttps && line.includes('LSHandlerRoleViewer')) {
|
|
120
|
+
const m = line.match(/=\s*"([^"]+)"/);
|
|
121
|
+
if (m)
|
|
122
|
+
bundleId = m[1];
|
|
123
|
+
inHttps = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!bundleId)
|
|
127
|
+
return null;
|
|
128
|
+
const appPath = tryExec(`mdfind "kMDItemCFBundleIdentifier == '${bundleId}'" 2>/dev/null | head -1`);
|
|
129
|
+
return appPath || null;
|
|
130
|
+
}
|
|
131
|
+
if (process.platform === 'linux') {
|
|
132
|
+
// Linux: xdg-mime 查默认 http handler → which 取路径
|
|
133
|
+
const handler = tryExec('xdg-mime query default x-scheme-handler/http 2>/dev/null');
|
|
134
|
+
if (!handler)
|
|
135
|
+
return null;
|
|
136
|
+
const exe = tryExec(`which ${handler} 2>/dev/null`) || tryExec(`which ${handler.split('.')[0]} 2>/dev/null`);
|
|
137
|
+
return exe || null;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 探测当前是否处于非交互式环境(cron/CI/无显示/E9_NO_REAUTH)。保守判定,避免误杀交互式终端。
|
|
143
|
+
* 用于 ensureValid 跳过会弹浏览器的续期——非交互式下 acquireToken 必失败,直接回退 cookie。
|
|
144
|
+
*/
|
|
145
|
+
export function looksNonInteractive() {
|
|
146
|
+
if (process.env.E9_NO_REAUTH)
|
|
147
|
+
return true;
|
|
148
|
+
if (process.env.CI)
|
|
149
|
+
return true;
|
|
150
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
|
|
151
|
+
return true;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
/** 探测 URL 是否可连(短超时),忽略 HTTPS 错误,只看能否拿到 <500 响应。 */
|
|
155
|
+
export async function canReach(url, timeoutMs = 6000) {
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
158
|
+
return res.status < 500;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** 继承 stdio 跑 shell 命令,超时强杀。返回 { code }。 */
|
|
165
|
+
function runCmd(cmd, timeoutMs) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const p = spawn(cmd, { stdio: 'inherit', shell: true, timeout: timeoutMs });
|
|
168
|
+
const t = setTimeout(() => { try {
|
|
169
|
+
p.kill('SIGKILL');
|
|
170
|
+
}
|
|
171
|
+
catch { /* ignore */ } }, timeoutMs);
|
|
172
|
+
p.on('exit', (code) => { clearTimeout(t); resolve({ code: code ?? -1 }); });
|
|
173
|
+
p.on('error', (e) => { clearTimeout(t); console.error(` [AUTH] 命令执行失败: ${e.message}`); resolve({ code: -1 }); });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 确保 playwright 可用:能 import 返回 chromium;不能 import 则探网→能联网就自动装模块(不装内核)。
|
|
178
|
+
* 内核改为懒加载:launchSystemBrowser 优先用系统 Chrome/Edge/Brave,只有系统里确实没有时才下载自带 chromium。
|
|
179
|
+
* 装不上抛清晰错并引导 cookie-login。**仅在 token-login(用户主动触发)调,勿在 ensureValid 运行时调**
|
|
180
|
+
* (否则每次请求过期都会触发安装)。
|
|
181
|
+
*/
|
|
182
|
+
export async function ensurePlaywright() {
|
|
183
|
+
try {
|
|
184
|
+
// @ts-ignore
|
|
185
|
+
return (await import('playwright')).chromium;
|
|
186
|
+
}
|
|
187
|
+
catch { /* 未装,尝试自动安装 */ }
|
|
188
|
+
const online = await canReach('https://registry.npmjs.org', 5000);
|
|
189
|
+
if (!online) {
|
|
190
|
+
throw new Error('playwright 未安装且当前环境无法联网安装。请先 `npm install playwright`,或改用 `e9 auth cookie-login` 手动导入 cookie(无需浏览器)。');
|
|
191
|
+
}
|
|
192
|
+
console.log(' [AUTH] playwright 未安装,联网自动安装模块(chromium 内核按需懒下载,通常无需)...');
|
|
193
|
+
const r1 = await runCmd('npm install playwright', 120000);
|
|
194
|
+
if (r1.code !== 0) {
|
|
195
|
+
throw new Error(`playwright 模块安装失败(npm exit ${r1.code})。可手动 \`npm install playwright\`,或改用 \`e9 auth cookie-login\`。`);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
// @ts-ignore
|
|
199
|
+
return (await import('playwright')).chromium;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new Error('playwright 安装后仍无法 import,请检查 node_modules,或改用 `e9 auth cookie-login`。');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** 跨进程续期锁窗口(ms):窗口内其他进程检测到 acquiringAt 即跳过续期走 cookie,防并发弹多个浏览器。 */
|
|
206
|
+
const ACQUIRING_WINDOW_MS = 120_000;
|
|
207
|
+
/** 进程内续期共享锁表:key=profileName → 进行中的 acquireToken promise(同进程同环境只弹一个浏览器)。 */
|
|
208
|
+
const _renewInflight = new Map();
|
|
209
|
+
const EMPTY_DATA = { token: '', expireTime: '', expireSeconds: 0, cookieSavedAt: 0, acquireFailedAt: 0, acquiringAt: 0 };
|
|
210
|
+
/** 读 token 文件(加密信封 → decrypt → JSON;兼容明文 {token})。 */
|
|
211
|
+
function loadTokenFile(filePath) {
|
|
212
|
+
if (!fs.existsSync(filePath))
|
|
213
|
+
return null;
|
|
214
|
+
try {
|
|
215
|
+
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
|
216
|
+
const j = JSON.parse(raw);
|
|
217
|
+
if (j && j.enc === true && typeof j.data === 'string') {
|
|
218
|
+
return JSON.parse(decrypt(j.data));
|
|
219
|
+
}
|
|
220
|
+
if (j && typeof j.token === 'string')
|
|
221
|
+
return j; // 明文兼容
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
/* 损坏,视为无 */
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 双模式浏览器登录拿 cookie(对标 e9-cli loginForCookie)。
|
|
230
|
+
* 有 username+password → 有头 autoFill(wui headless 不渲染登录框);失败转手动。
|
|
231
|
+
* 轮询 loginidweaver(200ms),60s hash-portal 兜底(导航 /wui/index.html#/main/portal)。
|
|
232
|
+
* 返回 "name=value; name=value; ..." 形式的 cookie 串;缺 loginidweaver 抛错。
|
|
233
|
+
*/
|
|
234
|
+
export async function loginForCookie(baseUrl, username, password, opts = {}) {
|
|
235
|
+
const headless = opts.headless ?? false;
|
|
236
|
+
let chromium;
|
|
237
|
+
try {
|
|
238
|
+
// @ts-ignore
|
|
239
|
+
chromium = (await import('playwright')).chromium;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw new Error('playwright 未安装。浏览器登录需要它。装一下:npm install playwright && npx playwright install chromium\n或改用 `e9 auth cookie-login` 手动导入 cookie(无需浏览器)。\n其他命令(blog/todo/create)走 token 不需浏览器。');
|
|
243
|
+
}
|
|
244
|
+
const autoFill = !!(username && password);
|
|
245
|
+
const args = headless ? ['--disable-blink-features=AutomationControlled'] : ['--start-maximized'];
|
|
246
|
+
let browser;
|
|
247
|
+
let context;
|
|
248
|
+
// 启动浏览器会话;开不出窗口(内核缺/无桌面)抛清晰错并引导 cookie-login,不让 Playwright 天书外泄
|
|
249
|
+
try {
|
|
250
|
+
browser = await launchSystemBrowser(chromium, headless, args);
|
|
251
|
+
context = await browser.newContext({
|
|
252
|
+
ignoreHTTPSErrors: true,
|
|
253
|
+
viewport: { width: 1280, height: 800 },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch (launchErr) {
|
|
257
|
+
const m = String(launchErr?.message || launchErr);
|
|
258
|
+
throw new Error(`无法启动浏览器会话:${m}\n 可能原因:chromium 内核未下载,或当前为后台/无桌面会话(如把 token-login 当后台任务跑)。\n 处置:① 手动 \`npx playwright install chromium\`;② 在可见屏幕的终端直接跑 \`e9 auth token-login\`;③ 或改用 \`e9 auth cookie-login\` 手动导入 cookie(无需浏览器)。`);
|
|
259
|
+
}
|
|
260
|
+
await context.addInitScript(() => {
|
|
261
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
262
|
+
});
|
|
263
|
+
const page = await context.newPage();
|
|
264
|
+
try {
|
|
265
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch((e) => {
|
|
266
|
+
if (headless)
|
|
267
|
+
throw e;
|
|
268
|
+
console.error(` ⚠ 首页加载失败: ${e.message},请在浏览器手动访问登录`);
|
|
269
|
+
});
|
|
270
|
+
if (autoFill) {
|
|
271
|
+
// 有头 autoFill:等待登录输入框渲染;headless 不渲染会超时抛错降级
|
|
272
|
+
await page.waitForSelector('#loginid, input[name=loginid], input[type=text]', { timeout: 10000 }).catch(() => { });
|
|
273
|
+
await page.fill('#loginid, input[name=loginid], input[type=text]', username).catch(() => { });
|
|
274
|
+
await page.fill('#userpassword, input[type=password]', password).catch(() => { });
|
|
275
|
+
await Promise.all([
|
|
276
|
+
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => { }),
|
|
277
|
+
page.click('button#submit, button[type=submit], button, input[type=submit]').catch(() => { }),
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
else if (!headless) {
|
|
281
|
+
console.log(' 请在浏览器登录页输入账密并点登录(最长 600s)...');
|
|
282
|
+
}
|
|
283
|
+
// 轮询 loginidweaver。autoFill 未拿到 cookie 时原地转手动登录(复用同一浏览器会话,不重开)
|
|
284
|
+
const t0 = Date.now();
|
|
285
|
+
const autoFillDeadline = t0 + (autoFill ? 45000 : 0);
|
|
286
|
+
const deadline = t0 + (headless ? 30000 : 600000);
|
|
287
|
+
let cookies = [];
|
|
288
|
+
let prompted = false;
|
|
289
|
+
let portaled = false;
|
|
290
|
+
let fellBackToManual = false;
|
|
291
|
+
while (Date.now() < deadline) {
|
|
292
|
+
cookies = await context.cookies();
|
|
293
|
+
if (cookies.some((c) => c.name === 'loginidweaver'))
|
|
294
|
+
break;
|
|
295
|
+
// autoFill 用尽仍无 cookie → 原地提示手动登录(同一浏览器,避免二次 launchSystemBrowser)
|
|
296
|
+
if (autoFill && !fellBackToManual && Date.now() >= autoFillDeadline) {
|
|
297
|
+
fellBackToManual = true;
|
|
298
|
+
console.error(' [AUTH] autoFill 未拿到登录 cookie,请在已打开的浏览器手动登录并点确认授权(最长 600s)...');
|
|
299
|
+
}
|
|
300
|
+
if (!headless && !portaled && Date.now() - t0 > 60000) {
|
|
301
|
+
// hash-portal 兜底:hash 路由切 SPA portal 设 cookie
|
|
302
|
+
await page
|
|
303
|
+
.goto(`${baseUrl}/wui/index.html#/main/portal`, { waitUntil: 'commit', timeout: 30000 })
|
|
304
|
+
.catch(() => { });
|
|
305
|
+
await sleep(3000);
|
|
306
|
+
portaled = true;
|
|
307
|
+
}
|
|
308
|
+
if (!headless && !prompted && Date.now() - t0 > 30000) {
|
|
309
|
+
console.log(' ⏳ 仍在等待 loginidweaver cookie...');
|
|
310
|
+
prompted = true;
|
|
311
|
+
}
|
|
312
|
+
await sleep(200);
|
|
313
|
+
}
|
|
314
|
+
if (!cookies.some((c) => c.name === 'loginidweaver')) {
|
|
315
|
+
throw new Error('登录后未拿到 loginidweaver cookie,无法换 token');
|
|
316
|
+
}
|
|
317
|
+
const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
318
|
+
if (!headless)
|
|
319
|
+
await promptAuthConfirm(page);
|
|
320
|
+
// 保存浏览器登录态(cookie+localStorage),供打印 PDF headless 复用
|
|
321
|
+
try {
|
|
322
|
+
const os = await import('node:os');
|
|
323
|
+
const p = await import('node:path');
|
|
324
|
+
const cfgDir = p.join(os.homedir(), '.e9');
|
|
325
|
+
const profile = profileNameFromBaseUrl(baseUrl);
|
|
326
|
+
await context.storageState({ path: p.join(cfgDir, `storageState_${profile}.json`) });
|
|
327
|
+
}
|
|
328
|
+
catch { /* 保存失败不阻断 */ }
|
|
329
|
+
return cookieString;
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
try {
|
|
333
|
+
if (browser)
|
|
334
|
+
await browser.close();
|
|
335
|
+
}
|
|
336
|
+
catch { /* 关闭失败忽略 */ }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** 登录后注入授权确认 overlay;用户点「确认授权」才放行。 */
|
|
340
|
+
async function promptAuthConfirm(page) {
|
|
341
|
+
const agent = getAgentInfo();
|
|
342
|
+
await page.evaluate((agentDisplay) => {
|
|
343
|
+
// 注入动画样式
|
|
344
|
+
const style = document.createElement('style');
|
|
345
|
+
style.textContent = `
|
|
346
|
+
@keyframes __e9_fade_in { from { opacity:0; } to { opacity:1; } }
|
|
347
|
+
@keyframes __e9_slide_up { from { opacity:0; transform:translateY(24px) scale(.96); } to { opacity:1; transform:translateY(0) scale(1); } }
|
|
348
|
+
`;
|
|
349
|
+
document.head.appendChild(style);
|
|
350
|
+
const mask = document.createElement('div');
|
|
351
|
+
mask.id = '__e9_auth_confirm__';
|
|
352
|
+
mask.style.cssText =
|
|
353
|
+
'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:2147483647;display:flex;align-items:center;justify-content:center;animation:__e9_fade_in .25s ease;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;';
|
|
354
|
+
const card = document.createElement('div');
|
|
355
|
+
card.style.cssText =
|
|
356
|
+
'background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 12px 48px rgba(0,0,0,.3);width:400px;animation:__e9_slide_up .35s cubic-bezier(.16,1,.3,1);';
|
|
357
|
+
// 顶部渐变色条
|
|
358
|
+
const topBar = document.createElement('div');
|
|
359
|
+
topBar.style.cssText = 'height:5px;background:linear-gradient(90deg,#3b82f6,#6366f1,#8b5cf6);';
|
|
360
|
+
// 内容区
|
|
361
|
+
const body = document.createElement('div');
|
|
362
|
+
body.style.cssText = 'padding:36px 36px 32px;text-align:center;';
|
|
363
|
+
// SVG 盾牌图标
|
|
364
|
+
const icon = document.createElement('div');
|
|
365
|
+
icon.style.cssText = 'width:64px;height:64px;margin:0 auto 20px;';
|
|
366
|
+
icon.innerHTML = `<svg viewBox="0 0 24 24" fill="none" width="64" height="64">
|
|
367
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
368
|
+
<stop offset="0%" stop-color="#3b82f6"/><stop offset="100%" stop-color="#6366f1"/>
|
|
369
|
+
</linearGradient></defs>
|
|
370
|
+
<path d="M12 2L4 5v6c0 5 3.5 9.5 8 11 4.5-1.5 8-6 8-11V5l-8-3z" fill="url(#g)"/>
|
|
371
|
+
<path d="M9 12l2 2 4-4" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
372
|
+
</svg>`;
|
|
373
|
+
const title = document.createElement('div');
|
|
374
|
+
title.style.cssText = 'font-size:17px;font-weight:600;color:#0f172a;margin-bottom:6px;letter-spacing:.3px;';
|
|
375
|
+
title.textContent = `【${agentDisplay}】请求授权访问OA系统,请确认。`;
|
|
376
|
+
const desc = document.createElement('div');
|
|
377
|
+
desc.style.cssText = 'font-size:12px;color:#94a3b8;line-height:1.6;margin-bottom:28px;';
|
|
378
|
+
desc.textContent = '确认后将以您的身份访问系统';
|
|
379
|
+
// 按钮区(只保留确认授权;关浏览器/超时不授权 → 走 cookie 回退)
|
|
380
|
+
const btnYes = document.createElement('button');
|
|
381
|
+
btnYes.id = '__e9_auth_yes__';
|
|
382
|
+
btnYes.style.cssText =
|
|
383
|
+
'width:100%;background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff;border:none;padding:12px 0;border-radius:8px;cursor:pointer;font-size:15px;font-weight:500;transition:transform .15s,box-shadow .15s;box-shadow:0 2px 8px rgba(59,130,246,.35);';
|
|
384
|
+
btnYes.textContent = '确认授权';
|
|
385
|
+
btnYes.onmouseenter = () => { btnYes.style.transform = 'translateY(-1px)'; btnYes.style.boxShadow = '0 4px 16px rgba(59,130,246,.45)'; };
|
|
386
|
+
btnYes.onmouseleave = () => { btnYes.style.transform = 'translateY(0)'; btnYes.style.boxShadow = '0 2px 8px rgba(59,130,246,.35)'; };
|
|
387
|
+
body.appendChild(icon);
|
|
388
|
+
body.appendChild(title);
|
|
389
|
+
body.appendChild(desc);
|
|
390
|
+
body.appendChild(btnYes);
|
|
391
|
+
card.appendChild(topBar);
|
|
392
|
+
card.appendChild(body);
|
|
393
|
+
mask.appendChild(card);
|
|
394
|
+
document.body.appendChild(mask);
|
|
395
|
+
btnYes.addEventListener('click', () => mask.remove());
|
|
396
|
+
}, agent.display);
|
|
397
|
+
await page
|
|
398
|
+
.waitForFunction(() => !document.getElementById('__e9_auth_confirm__'), { timeout: 180000 })
|
|
399
|
+
.catch(() => {
|
|
400
|
+
throw new Error('授权确认超时(180s)或未点击');
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/** 已知 agent 工具的 env 签名表(叶子层探测,按顺序匹配)。 */
|
|
404
|
+
const KNOWN_AGENT_TOOLS = [
|
|
405
|
+
// Claude Code:子进程注入 CLAUDECODE=1 / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_EXECPATH(已实跑确认)
|
|
406
|
+
{ keys: ['CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_EXECPATH'], display: 'Claude-Code' },
|
|
407
|
+
// Kimi Work:签名待该工具实跑 env 确认;未命中时走 AI_AGENT 或产品名兜底。
|
|
408
|
+
{ keys: ['KIMI_WORK', 'KIMI', 'MOONSHOT_SESSION_ID'], display: 'Kimi-Work' },
|
|
409
|
+
// TRAE Work(字节 Trae):签名待确认(猜 TRAE_WORK/TRAE/TRAE_SESSION_ID)
|
|
410
|
+
{ keys: ['TRAE_WORK', 'TRAE', 'TRAE_SESSION_ID'], display: 'Trae-Work' },
|
|
411
|
+
// DuMate:签名待确认(猜 DUMATE/DU_MATE/DUMATE_SESSION_ID)
|
|
412
|
+
{ keys: ['DUMATE', 'DU_MATE', 'DUMATE_SESSION_ID'], display: 'DuMate' },
|
|
413
|
+
];
|
|
414
|
+
/** 无法识别实际 agent 时的兜底展示名。 */
|
|
415
|
+
const FALLBACK_DISPLAY = 'E9办公助手';
|
|
416
|
+
function detectKnownAgent() {
|
|
417
|
+
for (const tool of KNOWN_AGENT_TOOLS) {
|
|
418
|
+
for (const k of tool.keys) {
|
|
419
|
+
const v = process.env[k];
|
|
420
|
+
if (v !== undefined && v !== '')
|
|
421
|
+
return tool.display;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
export function getAgentInfo() {
|
|
427
|
+
// 0. 通用覆盖:任何 agent 工具/宿主都可设 E9_AGENT_DISPLAY=<名> 自报家门(免改代码)
|
|
428
|
+
const override = process.env.E9_AGENT_DISPLAY;
|
|
429
|
+
if (override) {
|
|
430
|
+
const display = override.trim();
|
|
431
|
+
return { framework: display.toLowerCase(), display };
|
|
432
|
+
}
|
|
433
|
+
// 1. 叶子层:已知 agent 工具 env 签名探测
|
|
434
|
+
const known = detectKnownAgent();
|
|
435
|
+
if (known)
|
|
436
|
+
return { framework: known.toLowerCase(), display: known };
|
|
437
|
+
// 2. 回退:AI_AGENT 宿主/注册名解析
|
|
438
|
+
const aiAgent = process.env.AI_AGENT || '';
|
|
439
|
+
// claude-code_2-1-218_agent → "Claude-Code"(取下划线前的框架名,分段首字母大写,保留 "-" 分隔)
|
|
440
|
+
let framework = '';
|
|
441
|
+
if (aiAgent) {
|
|
442
|
+
const m = aiAgent.match(/^([a-zA-Z]+(?:-[a-zA-Z]+)*)_/);
|
|
443
|
+
if (m) {
|
|
444
|
+
framework = m[1]
|
|
445
|
+
.split('-')
|
|
446
|
+
.map((s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s))
|
|
447
|
+
.join('-');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// 都识别不出 → 使用产品名,避免向最终用户暴露历史内部 CLI 名称。
|
|
451
|
+
if (!framework)
|
|
452
|
+
framework = FALLBACK_DISPLAY;
|
|
453
|
+
return { framework: framework.toLowerCase(), display: framework };
|
|
454
|
+
}
|
|
455
|
+
/** GET /api/ai/agent/login/register 用 cookie 换 token(仿 curl 头)。失败抛带 e9kind/e9status 的错,供上层大声诊断。 */
|
|
456
|
+
export async function registerToken(baseUrl, cookieString) {
|
|
457
|
+
const agent = getAgentInfo();
|
|
458
|
+
let resp;
|
|
459
|
+
try {
|
|
460
|
+
resp = await fetch(`${baseUrl}/api/ai/agent/login/register?aiAgentType=${encodeURIComponent(agent.display)}`, {
|
|
461
|
+
headers: {
|
|
462
|
+
Cookie: cookieString,
|
|
463
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
464
|
+
'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',
|
|
465
|
+
},
|
|
466
|
+
signal: AbortSignal.timeout(15000),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
const err = new Error(`register 请求失败(${e.message})。可能 baseUrl 不可达或网络不通。`);
|
|
471
|
+
err.e9kind = 'network';
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
const status = resp.status;
|
|
475
|
+
const text = await resp.text();
|
|
476
|
+
if (status === 404) {
|
|
477
|
+
const err = new Error(`此 E9 无 /api/ai/agent/login/register 接口(HTTP 404),该环境不支持 aiAgentToken。`);
|
|
478
|
+
err.e9kind = 'unsupported';
|
|
479
|
+
err.e9status = status;
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
let json;
|
|
483
|
+
try {
|
|
484
|
+
json = JSON.parse(text);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
const err = new Error(`register 返回非 JSON(HTTP ${status}): ${text.slice(0, 200)}`);
|
|
488
|
+
err.e9status = status;
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
if (json.status === false || json.errorCode) {
|
|
492
|
+
const err = new Error(`register 失败(HTTP ${status}): ${json.msg || JSON.stringify(json).slice(0, 200)}`);
|
|
493
|
+
err.e9status = status;
|
|
494
|
+
err.e9json = json;
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
const data = json.data || {};
|
|
498
|
+
if (!data.aiAgentToken) {
|
|
499
|
+
const err = new Error(`register 未返回 aiAgentToken(HTTP ${status}): ${text.slice(0, 200)}`);
|
|
500
|
+
err.e9status = status;
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
return { token: data.aiAgentToken, expireTime: data.expireTime || '', expireSeconds: data.expireSeconds || 0 };
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* 当前环境的 token 会话(单一端点)。持有 cfg/endpoint(回写 cookie 到 config)、token 文件路径与缓存数据。
|
|
507
|
+
*/
|
|
508
|
+
export class AuthTokenSession {
|
|
509
|
+
cfg;
|
|
510
|
+
endpoint;
|
|
511
|
+
filePath;
|
|
512
|
+
data;
|
|
513
|
+
constructor(cfg, endpoint, filePath, data, profileName) {
|
|
514
|
+
this.cfg = cfg;
|
|
515
|
+
this.endpoint = endpoint;
|
|
516
|
+
this.filePath = filePath;
|
|
517
|
+
this.data = data || { ...EMPTY_DATA };
|
|
518
|
+
this.profileName = profileName || path.basename(filePath, '.json');
|
|
519
|
+
}
|
|
520
|
+
get token() {
|
|
521
|
+
return this.data.token || '';
|
|
522
|
+
}
|
|
523
|
+
get baseUrl() {
|
|
524
|
+
return this.endpoint.baseUrl.replace(/\/+$/, '');
|
|
525
|
+
}
|
|
526
|
+
get isExpired() {
|
|
527
|
+
if (!this.data.token)
|
|
528
|
+
return true;
|
|
529
|
+
const savedAt = this.data.cookieSavedAt || 0;
|
|
530
|
+
const ttl = (this.data.expireSeconds || 0) * 1000;
|
|
531
|
+
if (savedAt && ttl)
|
|
532
|
+
return Date.now() > savedAt + ttl - 60000;
|
|
533
|
+
if (this.data.expireTime)
|
|
534
|
+
return Date.now() > new Date(this.data.expireTime).getTime() - 60000;
|
|
535
|
+
return false; // 缺过期信息时信任本地 token,由 reactive 重试(requestWithRetry)兜底服务端吊销
|
|
536
|
+
}
|
|
537
|
+
static fromProfile(cfg) {
|
|
538
|
+
ensureDir(TOKEN_DIR);
|
|
539
|
+
const endpoint = endpointFor(cfg);
|
|
540
|
+
const profileName = profileNameFromBaseUrl(endpoint.baseUrl);
|
|
541
|
+
const filePath = tokenFilePath(profileName);
|
|
542
|
+
const data = loadTokenFile(filePath) || { ...EMPTY_DATA };
|
|
543
|
+
return new AuthTokenSession(cfg, endpoint, filePath, data, profileName);
|
|
544
|
+
}
|
|
545
|
+
/** 从磁盘重新加载 token 数据(跨进程锁检测需要最新快照,避免读到旧 data 而忽略别的进程的 acquiringAt)。 */
|
|
546
|
+
_reloadData() {
|
|
547
|
+
const fresh = loadTokenFile(this.filePath);
|
|
548
|
+
if (fresh)
|
|
549
|
+
this.data = { ...EMPTY_DATA, ...fresh };
|
|
550
|
+
}
|
|
551
|
+
/** 把浏览器登录拿到的刷新 cookie 写回 config.json 的 cookie 字段(供回退)。 */
|
|
552
|
+
saveCookieToProfile(cookieString) {
|
|
553
|
+
if (!cookieString)
|
|
554
|
+
return;
|
|
555
|
+
try {
|
|
556
|
+
if (process.env.E9_DEBUG)
|
|
557
|
+
console.error(`[COOKIE] saved ${cookieString.length} chars`);
|
|
558
|
+
this.cfg.cookie = cookieString;
|
|
559
|
+
saveConfig(this.cfg);
|
|
560
|
+
}
|
|
561
|
+
catch (e) {
|
|
562
|
+
console.error(` [AUTH] 存 cookie 失败: ${e.message}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** 申请/续期 token:loginForCookie → saveCookieToProfile → registerToken。register 失败标记 noToken 返回 null。
|
|
566
|
+
* 双层互斥防「同时弹很多个登录页」:
|
|
567
|
+
* ① 进程内锁:同 profile 已有续期在跑 → 其余调用 await 同一个 promise(report collect 7 路并发只弹 1 个);
|
|
568
|
+
* ② 跨进程锁:token 文件写 acquiringAt 时间戳,其他进程见窗口内标记 → 跳过续期走 cookie。
|
|
569
|
+
* reason: 'first'(cmdTokenLogin 首次登录)/ 'renew'(ensureValid/reactive 续期,默认)—— 区分文案。 */
|
|
570
|
+
async acquireToken(reason = 'renew') {
|
|
571
|
+
const key = this.profileName || 'default';
|
|
572
|
+
if (_renewInflight.has(key))
|
|
573
|
+
return _renewInflight.get(key); // 同进程已在续期:等同一个结果,不重复弹浏览器
|
|
574
|
+
const p = this._acquireTokenInner(reason);
|
|
575
|
+
_renewInflight.set(key, p);
|
|
576
|
+
try {
|
|
577
|
+
return await p;
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
_renewInflight.delete(key);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** acquireToken 真实主体(持有跨进程锁时执行);跳过续期/未拿到 token 均返回 null。 */
|
|
584
|
+
async _acquireTokenInner(reason = 'renew') {
|
|
585
|
+
// ② 跨进程锁:重读磁盘看别的进程是否正在续期(窗口内跳过)
|
|
586
|
+
this._reloadData();
|
|
587
|
+
const acquiringAt = this.data.acquiringAt || 0;
|
|
588
|
+
if (acquiringAt && Date.now() - acquiringAt < ACQUIRING_WINDOW_MS) {
|
|
589
|
+
console.error(` [AUTH] 另一进程正在续期(${Math.round((Date.now() - acquiringAt) / 1000)}s 前开始),本进程跳过续期,走 cookie 回退。`);
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
// 抢跨进程锁
|
|
593
|
+
this.data.acquiringAt = Date.now();
|
|
594
|
+
this.save();
|
|
595
|
+
try {
|
|
596
|
+
const password = this.cfg.encPassword ? decrypt(this.cfg.encPassword) : '';
|
|
597
|
+
const username = this.cfg.username || '';
|
|
598
|
+
// ★ #15 文案区分:续期 vs 首登(loginForCookie 的"请在浏览器登录页输入账密"是共享等待语,这里先打场景横幅)
|
|
599
|
+
if (reason === 'first')
|
|
600
|
+
console.error('[AUTH] 首次登录:开浏览器拿 cookie → 换 aiAgentToken(点「确认授权」)。');
|
|
601
|
+
else
|
|
602
|
+
console.error('[AUTH] token 已过期,弹浏览器续期:用 cookie 换新 aiAgentToken(点「确认授权」)。');
|
|
603
|
+
// autoFill 失败已在 loginForCookie 内原地转手动登录(同一浏览器会话),不再二次重开浏览器
|
|
604
|
+
const cookieString = await loginForCookie(this.baseUrl, username, username ? password : '', { headless: false });
|
|
605
|
+
this.saveCookieToProfile(cookieString);
|
|
606
|
+
try {
|
|
607
|
+
const { token, expireTime, expireSeconds } = await registerToken(this.baseUrl, cookieString);
|
|
608
|
+
this.data = { token, expireTime, expireSeconds, cookieSavedAt: Date.now(), noToken: false, acquireFailedAt: 0, acquiringAt: 0 };
|
|
609
|
+
this.save();
|
|
610
|
+
return token;
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
// 大声诊断:区分 404 不支持 / 网络不通 / 其它,不再含糊"系统可能不支持"
|
|
614
|
+
const kind = e?.e9kind || (e?.e9status === 404 ? 'unsupported' : 'other');
|
|
615
|
+
if (kind === 'unsupported')
|
|
616
|
+
console.error(` [AUTH] 此 E9 不支持 aiAgentToken(${e.message})。已切到 cookie 鉴权备胎。`);
|
|
617
|
+
else if (kind === 'network')
|
|
618
|
+
console.error(` [AUTH] register 请求失败:${e.message}。已切到 cookie 鉴权备胎(请用 \`e9 auth status --check\` 排查 baseUrl 可达性)。`);
|
|
619
|
+
else
|
|
620
|
+
console.error(` [AUTH] register 拿 token 失败:${e.message}。已切到 cookie 鉴权备胎。`);
|
|
621
|
+
console.error(` [AUTH] cookie 有效期由服务器定,过期需重新导入;运行 \`e9 auth status\` 查看当前模式。`);
|
|
622
|
+
this.data = { token: '', expireTime: '', expireSeconds: 0, cookieSavedAt: Date.now(), noToken: true, acquireFailedAt: Date.now(), acquiringAt: 0 };
|
|
623
|
+
this.save();
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
// 释放跨进程锁(reload 拿最新快照,避免覆盖续期期间写入的 token/acquireFailedAt)
|
|
629
|
+
this._reloadData();
|
|
630
|
+
this.data.acquiringAt = 0;
|
|
631
|
+
this.save();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* 返回 true → 用 aiAgentToken 头;返回 false → 回退 cookie 头。
|
|
636
|
+
* 信任本地过期判定,**不做网络探测**:探测失败(服务器不可达/探测接口 404)会误触发
|
|
637
|
+
* acquireToken 弹浏览器并卡住 cron。token 被服务端提前吊销(未过期但失效)的 reactive
|
|
638
|
+
* 续期由 E9Api 的「登录信息超时」失效重试承担。
|
|
639
|
+
* noToken / 无 token → false(不弹浏览器,走 cookie)。
|
|
640
|
+
* 过期 → ①#12 cookie 可用直接回退 cookie(不弹浏览器,cookie 能用的接口照常工作;
|
|
641
|
+
* aiAgent 专属接口的续期交由 requestWithRetry 的 401 reactive 兜底);
|
|
642
|
+
* ②#13 近期续期失败过(acquireFailedAt 退避窗口 10min)→ 回退 cookie 不反复弹;
|
|
643
|
+
* ③非交互式环境(cron/CI/无显示/E9_NO_REAUTH)→ 回退 cookie;
|
|
644
|
+
* ④以上都不满足(无 cookie + 交互式 + 未在退避)才 try acquireToken,失败→持久化 acquireFailedAt + false。
|
|
645
|
+
*/
|
|
646
|
+
async ensureValid() {
|
|
647
|
+
if (this.data.noToken)
|
|
648
|
+
return false;
|
|
649
|
+
if (!this.data.token)
|
|
650
|
+
return false;
|
|
651
|
+
if (!this.isExpired)
|
|
652
|
+
return true;
|
|
653
|
+
// ★ #12 cookie 可用 → 走 cookie,不弹浏览器(token 续期交给 requestWithRetry 的 401 reactive)
|
|
654
|
+
if (this.cfg.cookie)
|
|
655
|
+
return false;
|
|
656
|
+
// ★ #13 近期续期失败过 → 退避窗口内回退 cookie,不反复弹浏览器
|
|
657
|
+
if (this.data.acquireFailedAt && Date.now() - this.data.acquireFailedAt < 10 * 60 * 1000)
|
|
658
|
+
return false;
|
|
659
|
+
// 非交互式环境不弹浏览器续期(必失败且会卡住),直接回退 cookie;服务端吊销由 reactive 重试兜底
|
|
660
|
+
if (looksNonInteractive())
|
|
661
|
+
return false;
|
|
662
|
+
try {
|
|
663
|
+
// 用 acquireToken 返回值判断:跨进程锁跳过(返回 null)→ false(走 cookie),不能看 data.token 旧值
|
|
664
|
+
const t = await this.acquireToken();
|
|
665
|
+
return !!t;
|
|
666
|
+
}
|
|
667
|
+
catch (e) {
|
|
668
|
+
console.error(`[AUTH] token 续期失败: ${e.message}`);
|
|
669
|
+
// ★ #13 持久化续期失败标记,10min 退避窗口内不再弹浏览器(避免每条命令重复弹)
|
|
670
|
+
try {
|
|
671
|
+
this.data.acquireFailedAt = Date.now();
|
|
672
|
+
this.save();
|
|
673
|
+
}
|
|
674
|
+
catch { /* 写盘失败不阻断当次回退 cookie */ }
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** 加密信封写 token 文件。 */
|
|
679
|
+
save() {
|
|
680
|
+
ensureDir(TOKEN_DIR);
|
|
681
|
+
const enc = encrypt(JSON.stringify(this.data));
|
|
682
|
+
fs.writeFileSync(this.filePath, JSON.stringify({ enc: true, v: 1, data: enc }, null, 2) + '\n', 'utf8');
|
|
683
|
+
try {
|
|
684
|
+
fs.chmodSync(this.filePath, 0o600);
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
/* Windows 忽略 */
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|