magicrew-auth 0.0.0-stage → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +159 -0
- package/LICENSE +21 -0
- package/NOTICE.md +33 -0
- package/README.md +194 -3
- package/docs/internals.md +784 -0
- package/index.mjs +99 -0
- package/package.json +52 -4
- package/src/api.mjs +409 -0
- package/src/browser-login.mjs +322 -0
- package/src/clipboard.mjs +108 -0
- package/src/context.mjs +60 -0
- package/src/hooks/checkin.mjs +21 -0
- package/src/hooks/config.mjs +25 -0
- package/src/hooks/headers.mjs +14 -0
- package/src/hooks/loader.mjs +93 -0
- package/src/hooks/methods.mjs +191 -0
- package/src/hooks/models.mjs +37 -0
- package/src/hooks/refresh.mjs +29 -0
- package/src/hooks/usage.mjs +40 -0
- package/src/icons.mjs +24 -0
- package/src/key-store.mjs +188 -0
- package/src/keys.mjs +109 -0
- package/src/model-context.mjs +100 -0
- package/src/self-register.mjs +234 -0
- package/src/store.mjs +183 -0
- package/src/tasks.mjs +119 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// 自己拉起浏览器登录,再从 CDP 里把凭据读回来。
|
|
2
|
+
//
|
|
3
|
+
// ═══ 为什么必须自己拉浏览器 ══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// magicrew 没有 OAuth 回调通道(见 docs/internals.md 第九节),所以「官方页面登录
|
|
6
|
+
// 完,插件自动拿到 token」这件事,只有一条路:**浏览器得是插件能看进去的那一个**。
|
|
7
|
+
//
|
|
8
|
+
// magpie 打开的浏览器是系统默认实例 —— 插件没有它的调试端口,读不到它的 localStorage。
|
|
9
|
+
// 所以这里自己拉一个:独立 profile + `--remote-debugging-port=0`,登录完用 CDP
|
|
10
|
+
// 在页面上下文里执行一次 localStorage 读取,拿到 jwt + organizationCode,关掉窗口。
|
|
11
|
+
//
|
|
12
|
+
// 这正是 authorize 返回 `opened: true` 的正当场景(HOOKS.md 陷阱 3):
|
|
13
|
+
// 页面是我们自己开的,告诉 magpie 「别再开一次」。
|
|
14
|
+
//
|
|
15
|
+
// ⚠ 独立 profile 意味着用户要在这个窗口里登一次(没有他日常浏览器的 cookie)。
|
|
16
|
+
// 所以 profile 目录**不删**,下次还能复用登录态:~/.cache/magpie/magicrew-login-profile
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process"
|
|
19
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs"
|
|
20
|
+
import { homedir } from "node:os"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
|
|
23
|
+
/** 复用同一个 profile:第二次登录时里面已经有 cookie 了 */
|
|
24
|
+
function profileDir() {
|
|
25
|
+
const d = join(homedir(), ".cache", "magpie", "magicrew-login-profile")
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(d, { recursive: true })
|
|
28
|
+
} catch {}
|
|
29
|
+
return d
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 按平台找浏览器;找不到返回 null,调用方降级 */
|
|
33
|
+
export function findBrowser() {
|
|
34
|
+
const plat = process.platform
|
|
35
|
+
const candidates =
|
|
36
|
+
plat === "win32"
|
|
37
|
+
? [
|
|
38
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
39
|
+
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
40
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
41
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
42
|
+
join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"),
|
|
43
|
+
"C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
44
|
+
]
|
|
45
|
+
: plat === "darwin"
|
|
46
|
+
? [
|
|
47
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
48
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
49
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
50
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
51
|
+
]
|
|
52
|
+
: [
|
|
53
|
+
"/usr/bin/google-chrome",
|
|
54
|
+
"/usr/bin/chromium",
|
|
55
|
+
"/usr/bin/chromium-browser",
|
|
56
|
+
"/usr/bin/microsoft-edge",
|
|
57
|
+
"/snap/bin/chromium",
|
|
58
|
+
]
|
|
59
|
+
for (const p of candidates) {
|
|
60
|
+
try {
|
|
61
|
+
if (p && existsSync(p)) return p
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 等 Chromium 把 DevToolsActivePort 写出来。
|
|
69
|
+
* 文件两行:第一行端口,第二行 ws 路径。`--remote-debugging-port=0` 让系统挑端口,
|
|
70
|
+
* 避免和已有的调试端口撞车。
|
|
71
|
+
*/
|
|
72
|
+
async function waitPort(dir, timeoutMs = 8_000) {
|
|
73
|
+
const f = join(dir, "DevToolsActivePort")
|
|
74
|
+
const deadline = Date.now() + timeoutMs
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
try {
|
|
77
|
+
const line = readFileSync(f, "utf8").split("\n")[0].trim()
|
|
78
|
+
const port = Number(line)
|
|
79
|
+
if (port > 0) return port
|
|
80
|
+
} catch {}
|
|
81
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
82
|
+
}
|
|
83
|
+
return 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 拉起浏览器并打开登录页。
|
|
88
|
+
* @returns {Promise<{port:number, dir:string, child:import("node:child_process").ChildProcess} | null>}
|
|
89
|
+
*/
|
|
90
|
+
export async function launchLoginBrowser(url) {
|
|
91
|
+
const exe = findBrowser()
|
|
92
|
+
if (!exe) throw new Error("本机没找到 Chrome / Edge / Bravo / Chromium")
|
|
93
|
+
|
|
94
|
+
const dir = profileDir()
|
|
95
|
+
// ⚠ 上一次的残留会让这次「看起来起来了、其实什么都没做」:
|
|
96
|
+
// DevToolsActivePort —— 不清掉,waitPort 会立刻返回一个已失效的旧端口
|
|
97
|
+
// SingletonLock / SingletonCookie / SingletonSocket / lockfile
|
|
98
|
+
// —— 不清掉,新进程判定 profile 被占用,把 URL 转交给
|
|
99
|
+
// 那个已经死掉的实例,然后自己退出
|
|
100
|
+
const { unlinkSync } = await import("node:fs")
|
|
101
|
+
for (const f of ["DevToolsActivePort", "SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"]) {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(join(dir, f))
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let child
|
|
108
|
+
try {
|
|
109
|
+
child = spawn(
|
|
110
|
+
exe,
|
|
111
|
+
[
|
|
112
|
+
`--user-data-dir=${dir}`,
|
|
113
|
+
"--remote-debugging-port=0",
|
|
114
|
+
"--no-first-run",
|
|
115
|
+
"--no-default-browser-check",
|
|
116
|
+
"--disable-features=Translate,MediaRouter",
|
|
117
|
+
"--disable-background-networking",
|
|
118
|
+
url,
|
|
119
|
+
],
|
|
120
|
+
{ stdio: "ignore", detached: false, windowsHide: false },
|
|
121
|
+
)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
throw new Error(`浏览器启动失败:${e?.message ?? e}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const port = await waitPort(dir)
|
|
127
|
+
if (!port) {
|
|
128
|
+
try {
|
|
129
|
+
child.kill()
|
|
130
|
+
} catch {}
|
|
131
|
+
throw new Error("浏览器进程起来了,但 8 秒内没写出调试端口 DevToolsActivePort(多半是 profile 被一个已退出的实例占着)")
|
|
132
|
+
}
|
|
133
|
+
return { port, dir, child }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 最小 CDP 客户端 ───────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async function listPages(port) {
|
|
139
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) })
|
|
140
|
+
return res.json()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 找到登录页那个 target;**必须**URL 匹配,否则返回 null 让调用方继续等 */
|
|
144
|
+
async function pickLoginPage(port, hostFragment) {
|
|
145
|
+
const pages = (await listPages(port)).filter((t) => t.type === "page" && t.webSocketDebuggerUrl)
|
|
146
|
+
if (!pages.length) return null
|
|
147
|
+
// ⚠ 不能退回 pages[0]:浏览器刚起来时那个 target 还是 about:blank,
|
|
148
|
+
// 在它上面访问 localStorage 会抛 SecurityError,被当成「读不到」而空转。
|
|
149
|
+
return pages.find((p) => String(p.url || "").includes(hostFragment)) ?? null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 在页面上下文里求值一次 */
|
|
153
|
+
async function evaluate(port, hostFragment, expression, timeoutMs = 8000) {
|
|
154
|
+
const page = await pickLoginPage(port, hostFragment)
|
|
155
|
+
if (!page) throw new Error("没有可用的页面 target")
|
|
156
|
+
|
|
157
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
const t = setTimeout(() => reject(new Error("CDP 连接超时")), timeoutMs)
|
|
160
|
+
ws.addEventListener("open", () => {
|
|
161
|
+
clearTimeout(t)
|
|
162
|
+
resolve()
|
|
163
|
+
})
|
|
164
|
+
ws.addEventListener("error", (e) => {
|
|
165
|
+
clearTimeout(t)
|
|
166
|
+
reject(new Error(`CDP 连接失败:${e?.message ?? e}`))
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const reply = await new Promise((resolve, reject) => {
|
|
172
|
+
const t = setTimeout(() => reject(new Error("CDP 求值超时")), timeoutMs)
|
|
173
|
+
ws.addEventListener("message", (ev) => {
|
|
174
|
+
let msg
|
|
175
|
+
try {
|
|
176
|
+
msg = JSON.parse(ev.data)
|
|
177
|
+
} catch {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (msg.id !== 1) return
|
|
181
|
+
clearTimeout(t)
|
|
182
|
+
if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)))
|
|
183
|
+
else resolve(msg.result)
|
|
184
|
+
})
|
|
185
|
+
ws.send(
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
id: 1,
|
|
188
|
+
method: "Runtime.evaluate",
|
|
189
|
+
params: { expression, awaitPromise: true, returnByValue: true },
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
if (reply?.exceptionDetails) return ""
|
|
194
|
+
return reply?.result?.value ?? ""
|
|
195
|
+
} finally {
|
|
196
|
+
try {
|
|
197
|
+
ws.close()
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 在页面的 localStorage 里取 jwt + 组织码 —— 一次求值,两个都拿 */
|
|
203
|
+
const READ_CREDS = `(() => {
|
|
204
|
+
const raw = (k) => { try { return localStorage.getItem(k) || "" } catch { return "" } }
|
|
205
|
+
const pick = (k) => {
|
|
206
|
+
const s = raw(k)
|
|
207
|
+
if (!s) return ""
|
|
208
|
+
try {
|
|
209
|
+
const o = JSON.parse(s)
|
|
210
|
+
return typeof o === "object" && o ? String(o.value ?? "") : String(s)
|
|
211
|
+
} catch { return s }
|
|
212
|
+
}
|
|
213
|
+
const obj = (k) => {
|
|
214
|
+
try {
|
|
215
|
+
const o = JSON.parse(raw(k))
|
|
216
|
+
return (o && typeof o === "object" ? (o.value ?? o) : null) || null
|
|
217
|
+
} catch { return null }
|
|
218
|
+
}
|
|
219
|
+
const jwt = pick("MAGIC:user:authorization")
|
|
220
|
+
const org = pick("MAGIC:user:organizationCode")
|
|
221
|
+
if (!jwt || !jwt.startsWith("eyJ") || !org) return ""
|
|
222
|
+
// 卡片上要显示一个能认的名字:**手机号 → 邮箱 → 昵称 → 真名**
|
|
223
|
+
// (昵称是用户自己填的,可以不填也可以乱填;手机号是登录凭据本身。
|
|
224
|
+
// 服务端存的是打码版 138****8000,但仍是唯一稳定的账号标识)
|
|
225
|
+
const ui = obj("MAGIC:user:userInfo") || {}
|
|
226
|
+
const name = String(ui.phone || ui.email || ui.nickname || ui.real_name || "").trim()
|
|
227
|
+
return JSON.stringify({ jwt, org, name })
|
|
228
|
+
})()`
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 盯着那个浏览器,直到页面里的 localStorage 出现完整凭据。
|
|
232
|
+
* @returns {Promise<{jwt:string, org:string} | null>}
|
|
233
|
+
*/
|
|
234
|
+
export async function waitForCreds(session, hostFragment, { timeout = 300_000, interval = 1500 } = {}) {
|
|
235
|
+
const deadline = Date.now() + timeout
|
|
236
|
+
while (Date.now() < deadline) {
|
|
237
|
+
try {
|
|
238
|
+
const raw = await evaluate(session.port, hostFragment, READ_CREDS)
|
|
239
|
+
if (raw) {
|
|
240
|
+
const o = JSON.parse(raw)
|
|
241
|
+
if (o?.jwt?.startsWith("eyJ") && o?.org) return o
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// 页面还在加载 / target 换了 —— 下一轮再试
|
|
245
|
+
}
|
|
246
|
+
await new Promise((r) => setTimeout(r, interval))
|
|
247
|
+
}
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 关掉这个浏览器窗口。profile 留着,下次复用登录态。
|
|
253
|
+
*
|
|
254
|
+
* 两条路都要走:
|
|
255
|
+
* ① CDP 的 `Browser.close` —— 走浏览器自己的退出流程,cookie 会被刷到磁盘
|
|
256
|
+
* (profile 里没有落盘的 cookie,下次登录还得再来一次)
|
|
257
|
+
* ② 兜底 kill —— ①在 2 秒内没关掉就强杀整棵进程树
|
|
258
|
+
* (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile)
|
|
259
|
+
*/
|
|
260
|
+
export async function closeSession(session) {
|
|
261
|
+
if (!session) return
|
|
262
|
+
|
|
263
|
+
// ① 优雅关:Browser.close 走浏览器自己的退出流程
|
|
264
|
+
try {
|
|
265
|
+
const r = await fetch(`http://127.0.0.1:${session.port}/json/version`, { signal: AbortSignal.timeout(3000) })
|
|
266
|
+
const v = await r.json()
|
|
267
|
+
if (v?.webSocketDebuggerUrl) {
|
|
268
|
+
await new Promise((resolve) => {
|
|
269
|
+
const ws = new WebSocket(v.webSocketDebuggerUrl)
|
|
270
|
+
const done = () => {
|
|
271
|
+
try {
|
|
272
|
+
ws.close()
|
|
273
|
+
} catch {}
|
|
274
|
+
resolve()
|
|
275
|
+
}
|
|
276
|
+
const t = setTimeout(done, 2000)
|
|
277
|
+
ws.addEventListener(
|
|
278
|
+
"open",
|
|
279
|
+
() => {
|
|
280
|
+
try {
|
|
281
|
+
ws.send(JSON.stringify({ id: 1, method: "Browser.close" }))
|
|
282
|
+
} catch {}
|
|
283
|
+
// 给它 600ms 自己退,退不掉就走 ②
|
|
284
|
+
setTimeout(() => {
|
|
285
|
+
clearTimeout(t)
|
|
286
|
+
done()
|
|
287
|
+
}, 600)
|
|
288
|
+
},
|
|
289
|
+
{ once: true },
|
|
290
|
+
)
|
|
291
|
+
ws.addEventListener(
|
|
292
|
+
"error",
|
|
293
|
+
() => {
|
|
294
|
+
clearTimeout(t)
|
|
295
|
+
done()
|
|
296
|
+
},
|
|
297
|
+
{ once: true },
|
|
298
|
+
)
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
} catch {}
|
|
302
|
+
|
|
303
|
+
// ② 兜底:还活着就强杀整棵进程树
|
|
304
|
+
const pid = session.child?.pid
|
|
305
|
+
if (!pid) return
|
|
306
|
+
try {
|
|
307
|
+
if (process.platform === "win32") {
|
|
308
|
+
await new Promise((resolve) => {
|
|
309
|
+
const k = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
|
|
310
|
+
k.on("exit", resolve)
|
|
311
|
+
k.on("error", resolve)
|
|
312
|
+
setTimeout(resolve, 3000)
|
|
313
|
+
})
|
|
314
|
+
} else {
|
|
315
|
+
try {
|
|
316
|
+
session.child.kill("SIGKILL")
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export const _internal = { READ_CREDS, profileDir, waitPort, evaluate }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// 剪贴板 —— 浏览器登录的降级通道
|
|
2
|
+
//
|
|
3
|
+
// 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
|
|
4
|
+
// 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
|
|
5
|
+
// 插件读回来 —— 不用他去 DevTools 里翻两个字段。
|
|
6
|
+
//
|
|
7
|
+
// ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
|
|
8
|
+
// 把 promise 递到 callback 才落地。
|
|
9
|
+
|
|
10
|
+
import { execFile as execFileCb } from "node:child_process"
|
|
11
|
+
import { promisify } from "node:util"
|
|
12
|
+
|
|
13
|
+
const execFile = promisify(execFileCb)
|
|
14
|
+
|
|
15
|
+
// ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
|
|
16
|
+
//
|
|
17
|
+
// 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
|
|
18
|
+
// magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
|
|
19
|
+
//
|
|
20
|
+
// ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
|
|
21
|
+
// phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
|
|
22
|
+
// wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
|
|
23
|
+
// dingtalk_app / redirect / apple_login / google_login / feishu_oauth
|
|
24
|
+
// (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
|
|
25
|
+
// 注释写明「登录方式(与API强绑定,禁止修改)」)
|
|
26
|
+
// —— 没有设备码、没有插件授权、没有一次性 ticket。
|
|
27
|
+
//
|
|
28
|
+
// ② WorkBuddy 那种做法在它这里是空的:它没有
|
|
29
|
+
// POST /v2/plugin/auth/state (开登录会话)
|
|
30
|
+
// GET /v2/plugin/auth/token (轮询拿 token)
|
|
31
|
+
// 这套 `/plugin/auth/*` 协议。
|
|
32
|
+
//
|
|
33
|
+
// ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
|
|
34
|
+
// `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
|
|
35
|
+
// environment 回 `{"login_code":null}` 且没有配套的兑现接口。
|
|
36
|
+
//
|
|
37
|
+
// ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
|
|
38
|
+
// 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
|
|
39
|
+
//
|
|
40
|
+
// ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
|
|
41
|
+
// 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
|
|
42
|
+
// 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
|
|
43
|
+
//
|
|
44
|
+
// 剪贴板读法(三平台):
|
|
45
|
+
// win32 powershell Get-Clipboard -Raw
|
|
46
|
+
// darwin pbpaste
|
|
47
|
+
// linux xclip -selection clipboard -o
|
|
48
|
+
const CLIP_HINT = [
|
|
49
|
+
"浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
|
|
50
|
+
"登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
|
|
51
|
+
`copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
|
|
52
|
+
"插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
|
|
53
|
+
].join("\n")
|
|
54
|
+
|
|
55
|
+
async function clipboardText() {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
|
|
58
|
+
timeout: 6000,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
maxBuffer: 1 << 20,
|
|
61
|
+
})
|
|
62
|
+
return String(stdout ?? "")
|
|
63
|
+
} catch {
|
|
64
|
+
return ""
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CLIP_CMD = {
|
|
69
|
+
win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
|
|
70
|
+
darwin: ["pbpaste"],
|
|
71
|
+
linux: ["xclip", "-selection", "clipboard", "-o"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 剪贴板里的内容 → { jwt, org },认不出返回 null */
|
|
75
|
+
function parseCreds(txt) {
|
|
76
|
+
const s = String(txt || "").trim()
|
|
77
|
+
if (!s) return null
|
|
78
|
+
// ① Console 那行给的 JSON
|
|
79
|
+
try {
|
|
80
|
+
const o = JSON.parse(s)
|
|
81
|
+
if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
|
|
82
|
+
return { jwt: o.jwt, org: String(o?.org || "").trim() }
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
// ② 只粘了 JWT(组织码空着,后面会报错让人补)
|
|
86
|
+
if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 盯着剪贴板,直到出现能认的凭证。
|
|
92
|
+
* @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
|
|
93
|
+
*/
|
|
94
|
+
async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
|
|
95
|
+
const deadline = Date.now() + timeout
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
const txt = (await clipboardText()).trim()
|
|
98
|
+
if (txt && txt !== skip) {
|
|
99
|
+
const got = parseCreds(txt)
|
|
100
|
+
if (got) return got
|
|
101
|
+
}
|
|
102
|
+
await new Promise((r) => setTimeout(r, interval))
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
export { CLIP_HINT, clipboardText, parseCreds, pollClipboard }
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 站点上下文 —— 每个供应商拿到的那一份「我是谁」
|
|
2
|
+
//
|
|
3
|
+
// 一个包两个供应商,所以这里不是常量,是**按站点生成**的:
|
|
4
|
+
//
|
|
5
|
+
// siteCtx(SITES.cn) → magicrew 超级麦吉
|
|
6
|
+
// siteCtx(SITES.intl) → magicrew-intl MagiCrew
|
|
7
|
+
//
|
|
8
|
+
// 所有小插头(hooks/*)拿到的都是这个对象,它们不自己去认站点。
|
|
9
|
+
|
|
10
|
+
import { SITES } from "./api.mjs"
|
|
11
|
+
import { ICON_BY_SITE } from "./icons.mjs"
|
|
12
|
+
import { contextOf } from "./model-context.mjs"
|
|
13
|
+
|
|
14
|
+
export { SITES }
|
|
15
|
+
|
|
16
|
+
/** 决定 API 形状,别乱选(见 src/api.mjs 的 base 与 hooks/models 的 api 字段) */
|
|
17
|
+
export const SDK = "@ai-sdk/openai-compatible"
|
|
18
|
+
|
|
19
|
+
/** api_key 只活 2 小时时,提前多久换 */
|
|
20
|
+
export const REFRESH_LEAD_MS = 10 * 60_000
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 站点 → 上下文。
|
|
24
|
+
*
|
|
25
|
+
* 除了 SITES 里那些(id / host / web / needsOrgOnGateway),再补三个派生值:
|
|
26
|
+
* SHORT 卡片上显示的短名
|
|
27
|
+
* BASE 模型网关 <host>/v1
|
|
28
|
+
* ICON 这个供应商自己的图标(插件卡片的图标另外在 package.json.magpie.icon)
|
|
29
|
+
*/
|
|
30
|
+
export function siteCtx(SITE) {
|
|
31
|
+
return {
|
|
32
|
+
...SITE,
|
|
33
|
+
PROVIDER: SITE.id,
|
|
34
|
+
SHORT: SITE.id === "magicrew" ? "超级麦吉" : "MagiCrew",
|
|
35
|
+
BASE: `${SITE.host}/v1`,
|
|
36
|
+
ICON: SITE.id === "magicrew" ? ICON_BY_SITE.cn : ICON_BY_SITE.intl,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 把一条 flows/models 变成一个 magpie 模型条目 */
|
|
41
|
+
export function modelEntry(c, id, name, vision) {
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
name,
|
|
45
|
+
api: { id, url: c.BASE, npm: SDK },
|
|
46
|
+
status: "active",
|
|
47
|
+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
48
|
+
// 上下文窗口向 magpie 的模型库查 —— 两个模型接口都不返回它,
|
|
49
|
+
// 早前一律填 256K,结果 agent 拿 256K 判断"放不下",而实际是 1M
|
|
50
|
+
limit: { context: contextOf(id), output: 64_000 },
|
|
51
|
+
capabilities: {
|
|
52
|
+
temperature: true,
|
|
53
|
+
reasoning: true,
|
|
54
|
+
toolcall: true,
|
|
55
|
+
attachment: !!vision,
|
|
56
|
+
input: { text: true, image: !!vision },
|
|
57
|
+
output: { text: true },
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ⑥ auth.checkin(getAuth) —— 签到
|
|
2
|
+
//
|
|
3
|
+
// magpie 按北京时间每账号每天调一次,失败当天重试。
|
|
4
|
+
// 幂等、状态文件、UI 全是 magpie 的,插件只回答「这一次做成没有」。
|
|
5
|
+
//
|
|
6
|
+
// ⚠ 不要写定时器、不要自己存状态 —— 会让同一天跑两次。
|
|
7
|
+
//
|
|
8
|
+
// 签到逻辑在独立模块里**按需加载**:它挂了,登录/模型/额度照常工作。
|
|
9
|
+
|
|
10
|
+
export function makeCheckin(c) {
|
|
11
|
+
return async (getAuth) => {
|
|
12
|
+
const auth = await getAuth()
|
|
13
|
+
if (!auth) return { outcome: "failed", message: "未登录" }
|
|
14
|
+
try {
|
|
15
|
+
const { runCheckin } = await import("../tasks.mjs")
|
|
16
|
+
return await runCheckin({ ...auth, host: c.host })
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { outcome: "failed", message: String(e?.message ?? e) }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ① config(cfg) —— 声明 provider
|
|
2
|
+
//
|
|
3
|
+
// magpie 启动时调一次:写「我是谁、用哪个 SDK、有哪些模型」。
|
|
4
|
+
//
|
|
5
|
+
// ⚠ 三条不许改:
|
|
6
|
+
// · 用 ??= 不用 =,否则覆盖用户在 plugins.json 里手配的值
|
|
7
|
+
// · models 必须有内容 —— 未登录也要有,空表会让卡片显示 "0 models"
|
|
8
|
+
// · 不要在这里发网络请求:它是配置,不是钩子
|
|
9
|
+
|
|
10
|
+
import { SDK } from "../context.mjs"
|
|
11
|
+
import { readSnapshot } from "../store.mjs"
|
|
12
|
+
|
|
13
|
+
export function makeConfig(c) {
|
|
14
|
+
return async (cfg) => {
|
|
15
|
+
cfg.provider ??= {}
|
|
16
|
+
cfg.provider[c.PROVIDER] ??= {
|
|
17
|
+
name: c.SHORT,
|
|
18
|
+
npm: SDK,
|
|
19
|
+
api: c.BASE,
|
|
20
|
+
// 未登录时给上次的快照;从没登录过就是空表(宁缺毋假)。
|
|
21
|
+
// 一旦登录,⑦ models 会把真表拉回来并落盘。
|
|
22
|
+
models: readSnapshot(c.id) ?? {},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ⑧ "chat.headers"(input, output) —— 每请求加头
|
|
2
|
+
//
|
|
3
|
+
// input.sessionID 是 magpie 的会话 id,同一会话多轮不变,
|
|
4
|
+
// 拿它给上游做 session / prompt cache 的分组键。
|
|
5
|
+
//
|
|
6
|
+
// ⚠ 只加头,不改体 —— 改体在 loader 里。
|
|
7
|
+
|
|
8
|
+
export function makeHeaders(c) {
|
|
9
|
+
return async (input, output) => {
|
|
10
|
+
const id = input?.model?.providerID ?? input?.provider?.info?.id
|
|
11
|
+
if (id !== c.PROVIDER || !input?.sessionID) return
|
|
12
|
+
output.headers["x-magicrew-session"] = String(input.sessionID).slice(0, 128)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ④ auth.loader(getAuth) —— 请求接管
|
|
2
|
+
//
|
|
3
|
+
// 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
|
|
4
|
+
// 这是唯一能改请求体的地方。本项目用它干两件事:
|
|
5
|
+
//
|
|
6
|
+
// · 国际站网关强制要 organization-code(国内站不要),不带就是
|
|
7
|
+
// HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
|
|
8
|
+
// · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
|
|
9
|
+
//
|
|
10
|
+
// ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
|
|
11
|
+
// 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
|
|
12
|
+
|
|
13
|
+
import { ensureKey, accountOf } from "../keys.mjs"
|
|
14
|
+
|
|
15
|
+
export function makeLoader(c) {
|
|
16
|
+
return async (getAuth) => {
|
|
17
|
+
const auth = await getAuth()
|
|
18
|
+
if (!auth) return {}
|
|
19
|
+
return {
|
|
20
|
+
baseURL: c.BASE,
|
|
21
|
+
apiKey: auth.access,
|
|
22
|
+
headers: { "user-agent": "MagicrewClient/1.0" },
|
|
23
|
+
async fetch(input, init) {
|
|
24
|
+
const fresh = await getAuth()
|
|
25
|
+
if (!fresh) throw new Error(`${c.SHORT} 未登录`)
|
|
26
|
+
|
|
27
|
+
// key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
|
|
28
|
+
const key = await ensureKey(c, getAuth)
|
|
29
|
+
if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
|
|
30
|
+
const account = accountOf(fresh)
|
|
31
|
+
|
|
32
|
+
const req = input instanceof Request ? input : null
|
|
33
|
+
const url =
|
|
34
|
+
typeof input === "string"
|
|
35
|
+
? input
|
|
36
|
+
: input instanceof URL
|
|
37
|
+
? input.toString()
|
|
38
|
+
: (req?.url ?? "")
|
|
39
|
+
|
|
40
|
+
const incoming = new Headers(init?.headers ?? req?.headers ?? {})
|
|
41
|
+
const headers = {
|
|
42
|
+
authorization: `Bearer ${key.apiKey}`,
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
}
|
|
45
|
+
if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
|
|
46
|
+
|
|
47
|
+
for (const [k, v] of incoming) {
|
|
48
|
+
if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
|
|
49
|
+
headers[k] = v
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let body = init?.body
|
|
53
|
+
if (body === undefined && req) body = await req.text()
|
|
54
|
+
|
|
55
|
+
// 改写两处请求体,都为实测出来的:
|
|
56
|
+
//
|
|
57
|
+
// ① max_tokens 抬到 512
|
|
58
|
+
// 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
|
|
59
|
+
// 512 → 正常出话(reasoning 208 / total 233)
|
|
60
|
+
//
|
|
61
|
+
// ② 流式请求补 stream_options.include_usage
|
|
62
|
+
// magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
|
|
63
|
+
// 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
|
|
64
|
+
// magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
|
|
65
|
+
// 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
|
|
66
|
+
// 控制台里看到的就是:模型请求重试 (3/5) · Stream ended without finish_reason
|
|
67
|
+
if (typeof body === "string" && body.includes('"stream"')) {
|
|
68
|
+
try {
|
|
69
|
+
const j = JSON.parse(body)
|
|
70
|
+
if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
|
|
71
|
+
if (j.stream === true && !j.stream_options) {
|
|
72
|
+
j.stream_options = { include_usage: true }
|
|
73
|
+
}
|
|
74
|
+
body = JSON.stringify(j)
|
|
75
|
+
} catch {}
|
|
76
|
+
} else if (typeof body === "string" && body.includes('"max_tokens"')) {
|
|
77
|
+
try {
|
|
78
|
+
const j = JSON.parse(body)
|
|
79
|
+
if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
|
|
80
|
+
body = JSON.stringify(j)
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return fetch(url, {
|
|
85
|
+
method: init?.method ?? "POST",
|
|
86
|
+
headers,
|
|
87
|
+
body,
|
|
88
|
+
signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|