magicrew-auth 0.3.18 → 0.3.24

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/src/headless.mjs CHANGED
@@ -118,24 +118,73 @@ export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, de
118
118
 
119
119
  const { jwt, org } = extractLoginCreds(json)
120
120
  if (!jwt) {
121
- throw Object.assign(new Error("登录成功但响应里没找到 JWT —— 请把这条原始响应反馈给插件作者"), {
122
- raw: json,
123
- })
121
+ throw Object.assign(
122
+ new Error(`登录成功但响应里没找到可用令牌。响应结构:${describeShape(json)}`),
123
+ { raw: json },
124
+ )
124
125
  }
125
126
  // 响应里给了组织码就用它;没给就把该账号的组织**全列出来**,
126
- // 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)——
127
- // 只取第一个是不够的:多组织账号会落到「列表第一个」而不是用户平时用的那个。
127
+ // 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)。
128
128
  const orgs = org ? [org] : await fetchOrgs(site, jwt)
129
129
  if (!orgs.length) {
130
- throw Object.assign(new Error("登录成功但拿不到组织码(该账号没有任何组织?)"), { raw: json })
130
+ throw Object.assign(
131
+ new Error(
132
+ `登录成功但拿不到组织码。\n` +
133
+ ` · 登录响应结构:${describeShape(json)}\n` +
134
+ ` · 用该令牌调 login-data 的探测结果:${orgProbeErrors().join(" / ") || "(没探)"}\n` +
135
+ ` (前端拿 access_token 直接 setAuthorization 后就用它调这个接口)`,
136
+ ),
137
+ { raw: json },
138
+ )
131
139
  }
132
140
  return { jwt, org: orgs[0], orgs, raw: json }
133
141
  }
134
142
 
143
+ /**
144
+ * 把一段 JSON 的形状描述出来:**只有键名和类型,没有任何值**。
145
+ *
146
+ * 用来让真机上的报错自带诊断信息 —— 用户截一张图,就能看出服务端到底回了什么结构,
147
+ * 不用反复试。token 之类的东西一律不会出现在这里。
148
+ */
149
+ export function describeShape(o, depth = 0) {
150
+ if (o === null) return "null"
151
+ if (o === undefined) return "undefined"
152
+ const t = typeof o
153
+ if (t !== "object") return t === "string" ? `str(${String(o).length})` : t
154
+ if (depth >= 3) return "…"
155
+ if (Array.isArray(o)) {
156
+ return `[${o.length}]` + (o.length ? describeShape(o[0], depth + 1) : "")
157
+ }
158
+ const keys = Object.keys(o).slice(0, 24)
159
+ return "{" + keys.map((k) => `${k}:${describeShape(o[k], depth + 1)}`).join(", ") + "}"
160
+ }
161
+
162
+ /**
163
+ * 从登录响应里取「授权令牌」和「组织码」。
164
+ *
165
+ * ⚠ 登录返回的**不是 JWT,是 `access_token`** —— 前端拿到之后立刻
166
+ * `setAuthorization(access_token)` 并用它调 `getUserProfile`。
167
+ * 早先这里只按 `eyJ` 前缀找 JWT,结果在响应里挑到了别的字段,
168
+ * 拿去调 `login-data` 自然拿不到组织(真机表现就是
169
+ * 「登录成功但拿不到组织码」)。
170
+ */
135
171
  function extractLoginCreds(json) {
136
- // 只认 eyJ 三段式当 JWT;组织码优先取键名带 org 的那个
137
- const { jwt, org } = extractCreds(json)
138
- return { jwt, org }
172
+ const d = json?.data ?? json ?? {}
173
+ const pick = (...keys) => {
174
+ for (const k of keys) {
175
+ const v = d?.[k]
176
+ if (typeof v === "string" && v.trim()) return v.trim()
177
+ }
178
+ return null
179
+ }
180
+ // 优先认登录接口自己给的字段名
181
+ const token =
182
+ pick("access_token", "accessToken", "token", "authorization", "teamshare_token") ??
183
+ extractCreds(json).jwt
184
+ const org =
185
+ pick("organization_code", "organizationCode", "magic_organization_code") ??
186
+ extractCreds(json).org
187
+ return { jwt: token, org }
139
188
  }
140
189
 
141
190
  /**
@@ -143,22 +192,80 @@ function extractLoginCreds(json) {
143
192
  *
144
193
  * ⚠ 浏览器登录能直接读到用户**当前选中**的组织(localStorage 里的
145
194
  * `MAGIC:user:organizationCode`),无头这条路没有那个信息 —— 所以只能把候选
146
- * 全拿回来,让调用方逐个验,验不过再换下一个。单组织账号没有区别;
147
- * 多组织账号靠这一步才不会落错地方。
195
+ * 全拿回来,让调用方逐个验,验不过再换下一个。
196
+ *
197
+ * ⚠ `authorization` 头**两种写法都要试**:前端这个接口是
198
+ * `h.set("authorization", authorization)` —— 直接放 token,**没有 `Bearer ` 前缀**;
199
+ * 但平台其它接口(`/api/v1/flows/models` 等)走的是 `Bearer <jwt>` 并且能通。
200
+ * 登录拿到的是 438 字符的 `access_token`,和网页 localStorage 里那把 354 字符的不是
201
+ * 同一个东西,所以不能想当然 —— 两种都打,先成的算。
202
+ * 全失败时把每一步的真实原因记在模块变量里,供上层错误信息引用。
148
203
  */
204
+ let lastOrgProbeErrors = []
205
+ export function orgProbeErrors() {
206
+ return lastOrgProbeErrors.slice()
207
+ }
208
+
149
209
  export async function fetchOrgs(site, jwt) {
150
- const res = await fetch(`${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, {
151
- headers: {
152
- authorization: `Bearer ${jwt}`,
153
- "user-agent": "magpie-magicrew-auth",
154
- },
155
- signal: AbortSignal.timeout(20_000),
156
- }).catch(() => null)
157
- if (!res?.ok) return []
158
- const j = await res.json().catch(() => null)
159
- const list = j?.data?.auth_status ?? j?.auth_status ?? []
160
- if (!Array.isArray(list)) return []
210
+ lastOrgProbeErrors = []
211
+
212
+ // 三个端点 × 两种 authorization 写法,全试一遍 —— 它们对令牌形态的宽容度不一样:
213
+ // · login-data 前端是 h.set("authorization", token)(无 Bearer),但它同时
214
+ // 接受 `Bearer <网页版 JWT>`(用本机 354 字符 JWT 实测通过)
215
+ // · me/organizations 实测连 organization-code 头都不要,只认令牌
216
+ // · auth/status 前端 bindMagicAuthorization 用它,返回 Array<MagicOrganization>
217
+ // 登录拿到的是 438 字符的 access_token,和网页 localStorage 那把 354 的不同,
218
+ // 所以不能想当然只打一个。
219
+ const probes = [
220
+ { tag: "login-data/Bearer", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
221
+ { tag: "login-data/裸", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: jwt, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
222
+ { tag: "auth-status/Bearer", url: `${site.host}/api/v1/auth/status?login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data ?? j },
223
+ { tag: "auth-status/裸", url: `${site.host}/api/v1/auth/status?login_code=`, auth: jwt, pick: (j) => j?.data ?? j },
224
+ { tag: "me-organizations/Bearer", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.items ?? j?.items },
225
+ { tag: "me-organizations/裸", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: jwt, pick: (j) => j?.data?.items ?? j?.items },
226
+ ]
227
+
228
+ for (const p of probes) {
229
+ let res = null
230
+ try {
231
+ res = await fetch(p.url, {
232
+ headers: { authorization: p.auth, "user-agent": "magpie-magicrew-auth" },
233
+ signal: AbortSignal.timeout(20_000),
234
+ })
235
+ } catch (e) {
236
+ lastOrgProbeErrors.push(`${p.tag}:网络失败 ${String(e?.message ?? e).slice(0, 60)}`)
237
+ continue
238
+ }
239
+
240
+ const text = await res.text().catch(() => "")
241
+ if (!res.ok) {
242
+ lastOrgProbeErrors.push(`${p.tag}:HTTP ${res.status} ${text.slice(0, 70)}`)
243
+ continue
244
+ }
245
+ let j = null
246
+ try {
247
+ j = JSON.parse(text)
248
+ } catch {
249
+ lastOrgProbeErrors.push(`${p.tag}:非 JSON ${text.slice(0, 70)}`)
250
+ continue
251
+ }
252
+ if (j?.code !== 1000) {
253
+ lastOrgProbeErrors.push(`${p.tag}:code ${j?.code} ${j?.message ?? ""}`.trim())
254
+ continue
255
+ }
256
+ const list = p.pick(j)
257
+ if (!Array.isArray(list) || !list.length) {
258
+ lastOrgProbeErrors.push(`${p.tag}:200 但列表为空`)
259
+ continue
260
+ }
261
+ const orgs = parseOrgs(list)
262
+ if (orgs.length) return orgs
263
+ lastOrgProbeErrors.push(`${p.tag}:有 ${list.length} 项但取不到组织码`)
264
+ }
265
+ return []
266
+ }
161
267
 
268
+ function parseOrgs(list) {
162
269
  // ⚠ 字段名是 `magic_organization_code`(实测)。`organization_code` 只在**外层**存在,
163
270
  // 而且是空串 —— 认它就会拿到 ""。每项里另有 third_platform_ / teamshare_ 两份同值码。
164
271
  const KEYS = [
@@ -1,93 +1,234 @@
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
- }
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
+ /**
16
+ * 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
17
+ *
18
+ * 上游只认布尔值,收到任何对象都回 HTTP 400
19
+ * `{"error":{"message":"模型响应失败","code":4017}}`,整个请求被拒。
20
+ * 实测(2026-10-28,glm-5.3-flash 与 deepseek-v4.1-flash **两个模型完全一致**,
21
+ * 同一把 key、同一份请求、只改这一个字段):
22
+ *
23
+ * additionalProperties: 缺省 → 200
24
+ * additionalProperties: false → 200
25
+ * additionalProperties: true → 200
26
+ * additionalProperties: {} → 400 code 4017
27
+ * additionalProperties: {type:"string"} → 400 code 4017
28
+ * additionalProperties: {type:"string",description:"x"} → 400 code 4017
29
+ *
30
+ * ⚠ 注意**不只是空对象**。有约束的 schema 形态(`{type:"string"}`)一样被拒 ——
31
+ * 这一点比"上游只认 true/false"更进一步:它根本不支持 schema 形态。
32
+ * 所以这里一律换成 `true`,属于**降级放宽**:
33
+ *
34
+ * · `{}` → `true` 语义等价(都是"任意,不加约束")
35
+ * · `{type:"string"}` → `true` **不等价** —— 丢掉了"额外属性必须是 string"的约束
36
+ *
37
+ * 为什么接受这个不等价:上游无法表达它,保留原值就是整个请求失败。两害相权,
38
+ * 让工具调用能跑(可能多出未声明的字段)比让 agent 完全不可用要好。方向也安全 ——
39
+ * 是放宽不是收紧,不会让原本合法的调用被拒。
40
+ *
41
+ * 命中来源:ZCode / Claude Code 系客户端生成 schema 时爱写 `{}`;
42
+ * 一次真实请求里 54 个工具中的 `ExitPlanMode` 与 `CreateWorkflow` 就这么写。
43
+ * 客户端没写错(按规范 `{}` 与 `true` 等价),是上游兼容性不足,插件这边兜住。
44
+ *
45
+ * @returns {boolean} 有没有改到
46
+ */
47
+ export function loosenAdditionalProperties(node) {
48
+ if (!node || typeof node !== "object") return false
49
+ if (Array.isArray(node)) {
50
+ let any = false
51
+ for (const n of node) if (loosenAdditionalProperties(n)) any = true
52
+ return any
53
+ }
54
+ let changed = false
55
+ for (const [k, v] of Object.entries(node)) {
56
+ if (k === "additionalProperties") {
57
+ // 布尔值上游认,原样留着(false 是"不许额外属性",语义相反,绝对不能动)
58
+ if (typeof v !== "boolean") {
59
+ node[k] = true
60
+ changed = true
61
+ }
62
+ continue
63
+ }
64
+ if (v && typeof v === "object" && loosenAdditionalProperties(v)) changed = true
65
+ }
66
+ return changed
67
+ }
68
+
69
+ /**
70
+ * 上游的 JSON Schema 校验器不接受 `items: {}`,整轮请求回 HTTP 400:
71
+ *
72
+ * {"error":{"code":null,"message":"超级麦吉: HTTP 400 请求错误: {\"error\":
73
+ * {\"code\":\"InvalidParameter\",\"message\":\"tools.40.function.parameters/
74
+ * properties/functions/items: invalid JSON Schema; check the schema
75
+ * keywords and references Request id: ...\",\"param\":\"\",\"type\":
76
+ * \"BadRequest\"}}","param":null,"type":"invalid_request_error"}}
77
+ *
78
+ * 报错只给路径,不说是哪个客户端。实测(2026-10-28,同一把 key、同一份请求、
79
+ * 只改 tools[0] 的这一处;国内站 `magicrew` 与国际站 `magicrew-intl` 逐行一致):
80
+ *
81
+ * items: 缺省 → 200
82
+ * items: {} → 400 invalid JSON Schema
83
+ * items: {description:"x"} → 200 // 空对象才炸,不是"缺 type"
84
+ * items: {type:"string"} → 200
85
+ * items: true → 400 模型响应失败(另一种 400)
86
+ * items: false → 400 模型响应失败
87
+ *
88
+ * 为什么可以删:JSON Schema 里 `items` 的缺省值就是 `{}` —— "任意元素"。
89
+ * 所以 `items: {}` 删掉是**严格等价**(`true` 同理),不是降级放宽。
90
+ * `items: false` 表示"数组必须为空",语义不同,不能删,原样留着。
91
+ *
92
+ * 命中来源:客户端用 `z.array(z.unknown())` / 动态函数列表生成工具 schema 时,
93
+ * 数组元素会序列化成 `{}`。一个真实请求里第 41 个工具的参数是
94
+ * `properties.functions.items: {}`,于是整轮失败。
95
+ *
96
+ * @returns {boolean} 有没有改到
97
+ */
98
+ export function loosenEmptyItems(node) {
99
+ if (!node || typeof node !== "object") return false
100
+ if (Array.isArray(node)) {
101
+ let any = false
102
+ for (const n of node) if (loosenEmptyItems(n)) any = true
103
+ return any
104
+ }
105
+ let changed = false
106
+ for (const [k, v] of Object.entries(node)) {
107
+ if (k === "items") {
108
+ const emptyObject =
109
+ v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0
110
+ if (emptyObject || v === true) {
111
+ delete node[k]
112
+ changed = true
113
+ continue
114
+ }
115
+ }
116
+ if (v && typeof v === "object" && loosenEmptyItems(v)) changed = true
117
+ }
118
+ return changed
119
+ }
120
+
121
+ /**
122
+ * 改写发往上游的请求体。**一次 parse,四处改写都在这**。
123
+ *
124
+ * 四处都是实测出来的,不是猜的:
125
+ *
126
+ * ① max_tokens 抬到 512
127
+ * 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
128
+ * 512 → 正常出话(reasoning 208 / total 233)
129
+ *
130
+ * ② 流式请求补 stream_options.include_usage
131
+ * magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
132
+ * 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
133
+ * magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
134
+ * 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
135
+ *
136
+ * ③ tools 里的 additionalProperties: {} → true(见 looser 的注释)
137
+ *
138
+ * ④ tools 里的 items: {} → 删除(见 loosenEmptyItems 的注释)
139
+ *
140
+ * 没改到时返回原始字符串(省一次序列化 —— 54 个工具的请求体不小)。
141
+ *
142
+ * @param {unknown} raw
143
+ * @returns {unknown} 改了就是新字符串,没改就原样返回
144
+ */
145
+ export function rewriteRequestBody(raw) {
146
+ if (typeof raw !== "string") return raw
147
+ // 粗筛:三个关键字一个都没有,就不必 parse
148
+ if (!/"stream"|"max_tokens"|"tools"/.test(raw)) return raw
149
+
150
+ let j
151
+ try {
152
+ j = JSON.parse(raw)
153
+ } catch {
154
+ return raw
155
+ }
156
+ if (!j || typeof j !== "object") return raw
157
+
158
+ let changed = false
159
+
160
+ if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) {
161
+ j.max_tokens = 512
162
+ changed = true
163
+ }
164
+
165
+ if (j.stream === true && !j.stream_options) {
166
+ j.stream_options = { include_usage: true }
167
+ changed = true
168
+ }
169
+
170
+ if (Array.isArray(j.tools)) {
171
+ for (const t of j.tools) {
172
+ const p = t?.function?.parameters
173
+ if (!p || typeof p !== "object") continue
174
+ if (loosenAdditionalProperties(p)) changed = true
175
+ if (loosenEmptyItems(p)) changed = true
176
+ }
177
+ }
178
+
179
+ return changed ? JSON.stringify(j) : raw
180
+ }
181
+
182
+ export function makeLoader(c) {
183
+ return async (getAuth) => {
184
+ const auth = await getAuth()
185
+ if (!auth) return {}
186
+ return {
187
+ baseURL: c.BASE,
188
+ apiKey: auth.access,
189
+ headers: { "user-agent": "MagicrewClient/1.0" },
190
+ async fetch(input, init) {
191
+ const fresh = await getAuth()
192
+ if (!fresh) throw new Error(`${c.SHORT} 未登录`)
193
+
194
+ // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
195
+ const key = await ensureKey(c, getAuth)
196
+ if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
197
+ const account = accountOf(fresh)
198
+
199
+ const req = input instanceof Request ? input : null
200
+ const url =
201
+ typeof input === "string"
202
+ ? input
203
+ : input instanceof URL
204
+ ? input.toString()
205
+ : (req?.url ?? "")
206
+
207
+ const incoming = new Headers(init?.headers ?? req?.headers ?? {})
208
+ const headers = {
209
+ authorization: `Bearer ${key.apiKey}`,
210
+ "content-type": "application/json",
211
+ }
212
+ if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
213
+
214
+ for (const [k, v] of incoming) {
215
+ if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
216
+ headers[k] = v
217
+ }
218
+
219
+ let body = init?.body
220
+ if (body === undefined && req) body = await req.text()
221
+
222
+ // 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
223
+ body = rewriteRequestBody(body)
224
+
225
+ return fetch(url, {
226
+ method: init?.method ?? "POST",
227
+ headers,
228
+ body,
229
+ signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
230
+ })
231
+ },
232
+ }
233
+ }
234
+ }
@@ -44,10 +44,17 @@ function kickoffFirstTask(c, jwt, org) {
44
44
  * 记组织 / 滚邀请码 / 补首任务 / 取昵称」这一串只有一份实现。
45
45
  *
46
46
  * `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
47
- * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算 —— 多组织账号靠这个
48
- * 才不会落错地方。浏览器登录只有一个候选,行为不变。
47
+ * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
48
+ *
49
+ * ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
50
+ * 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
51
+ * 所以「第一个验得过的」实际就是「列表第一个」。
52
+ * 真正的区别在浏览器登录那条路:它直接读 localStorage 里用户**当前选中**的那个。
53
+ * 服务端虽然给了 `is_current` 字段,但实测恒为 false(带不带 organization-code
54
+ * 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
55
+ * 这是已知限制,不是 bug。
49
56
  */
50
- async function completeSignIn(c, jwt, org, orgs = []) {
57
+ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
51
58
  const candidates = [org, ...orgs.filter((o) => o && o !== org)]
52
59
  let lastErr = null
53
60
  let picked = null
@@ -71,12 +78,23 @@ async function completeSignIn(c, jwt, org, orgs = []) {
71
78
  await rollInvite(c.id, jwt, picked, c.host)
72
79
  kickoffFirstTask(c, jwt, picked)
73
80
  const who = await fetchWho(jwt, picked, c.host).catch(() => null)
81
+
82
+ // ⚠ `accountId` 决定 magpie 是「替换」还是「新增」—— 同一个账号在两条登录路上
83
+ // 必须算出同一个字符串,否则就会出现「同一个号被加了两遍」。
84
+ //
85
+ // 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
86
+ // 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
87
+ // 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
88
+ //
89
+ // 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
90
+ // `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
91
+ const accountId = preferredAccountId || who?.name || picked
74
92
  return {
75
93
  type: "success",
76
94
  access: key.apiKey,
77
95
  refresh: key.refreshToken,
78
96
  expires: Date.now() + 2 * 3600_000,
79
- accountId: who?.name || picked,
97
+ accountId,
80
98
  uid: picked,
81
99
  jwt,
82
100
  org: picked,
@@ -109,7 +127,13 @@ export function makeMethods(c) {
109
127
  ],
110
128
  async authorize(inputs) {
111
129
  const phone = String(inputs?.phone || "").replace(/[\s-]/g, "")
112
- if (!/^1\d{10}$/.test(phone)) return { type: "failed", error: "手机号不完整" }
130
+ // ⚠ authorize() 里**不能**返回 { type: "failed" }:magpie 把这里的返回值当作
131
+ // 「登录流程对象」,会去调它的 `.callback()` —— 少了这个函数就是一句
132
+ // `callback is not a function`,用户只看到莫名其妙的报错。
133
+ // 实测触发路径:callback 里失败 → magpie 显示「重试」 → 点重试会**重新调
134
+ // authorize** → 这次走到这条失败分支 → 返回没有 callback 的对象 → 崩。
135
+ // ⇒ authorize 里失败一律抛异常。
136
+ if (!/^1\d{10}$/.test(phone)) throw new Error("手机号不完整")
113
137
 
114
138
  const vision = readVisionModel()
115
139
  const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
@@ -195,7 +219,9 @@ export function makeMethods(c) {
195
219
  try {
196
220
  sent = await startSmsLogin(c, { mobile: phone })
197
221
  } catch (e) {
198
- return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
222
+ // 同上:这里也不能 return failed —— 点「重试」会重新走 authorize,
223
+ // 返回没有 callback 的对象会让 magpie 崩在 `.callback()`
224
+ throw new Error(String(e?.message ?? e).slice(0, 500))
199
225
  }
200
226
 
201
227
  return {
@@ -220,7 +246,9 @@ export function makeMethods(c) {
220
246
  return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
221
247
  }
222
248
  try {
223
- return await completeSignIn(c, creds.jwt, creds.org, creds.orgs)
249
+ // 传 masked(打码手机号)当 accountId —— 浏览器登录那条路拿到的也是
250
+ // 这个字符串,两边一致才不会把同一个号加成两条
251
+ return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
224
252
  } catch (e) {
225
253
  return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
226
254
  }
@@ -407,8 +435,9 @@ export function makeMethods(c) {
407
435
  async authorize(inputs) {
408
436
  const jwt = String(inputs?.jwt || "").trim().replace(/^Bearer\s+/i, "")
409
437
  const org = String(inputs?.org || "").trim()
410
- if (!jwt.startsWith("eyJ")) return { type: "failed", error: "不是 JWT" }
411
- if (!org) return { type: "failed", error: "没填组织码" }
438
+ // 同一条规矩:authorize 里失败只抛异常,不返回 { type: "failed" }
439
+ if (!jwt.startsWith("eyJ")) throw new Error("不是 JWT")
440
+ if (!org) throw new Error("没填组织码")
412
441
 
413
442
  const result = (async () => {
414
443
  await fetchPoints(jwt, org, c.host)