@lingerai/cli 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingerai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Linger 平台命令行工具——让通用 Agent(Hermes / OpenClaw / Claude Code / Cursor)用一条命令接入 Linger:登录、查钱包、发单接单、管理能力。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -19,47 +19,212 @@
19
19
  // - fetchImpl 可注入,便于单测用假响应;默认用全局 fetch(Node 18+ 自带)
20
20
  // - 所有 POST 必须由调用方传入 idempotencyKey(backend middleware 缺则 400)
21
21
  // - 4xx/5xx → 抛含平台错误文案的 Error,401 时提示重新登录
22
+ //
23
+ // v0.4.4 M2 自动续期(authedGetWithRefresh / authedPostWithRefresh):
24
+ // - 命中 401 → 用 refresh_token 走 POST /oauth/token 换新 token(RFC 6749 §6)
25
+ // - 模块级 in-flight Promise 去重:同一凭证目录的并发 401 只发一次 refresh
26
+ // - 新 token 拿到后立即覆写本地凭证文件(防旧 refresh 被后端 revoke)
27
+ // - refresh 本身也 401/失败 → 抛含「linger auth login」的提示,不静默卡死
22
28
 
23
29
  import { randomUUID } from 'node:crypto';
30
+ import { loadCredentials, saveCredentials } from './credentials.js';
24
31
 
25
32
  // ============================================================
26
- // 内部辅助
33
+ // v0.4.4 M2 自动续期层
27
34
  // ============================================================
28
35
 
29
- /** 通用带鉴权 GET:成功返回解析后的 JSON;非 2xx 抛带平台文案的错误。 */
30
- async function authedGet(baseUrl, pathAndQuery, token, { fetchImpl = fetch } = {}) {
31
- const resp = await fetchImpl(`${baseUrl}${pathAndQuery}`, {
32
- method: 'GET',
33
- headers: { Authorization: `Bearer ${token}` },
36
+ /**
37
+ * 模块级「in-flight refresh Promise」映射表。
38
+ *
39
+ * key: 凭证目录绝对路径(不同目录 = 不同 profile,各自独立)。
40
+ * value: 正在飞行中的 refresh Promise(未完成时新来的 401 直接等同一个)。
41
+ *
42
+ * 三重防御:凭证隔离(不同 key)+ in-flight 去重(同 key 只飞一次)+ 立即落盘。
43
+ */
44
+ const _inflightRefresh = new Map();
45
+
46
+ /**
47
+ * 用 refresh_token 向平台换新 token(POST /oauth/token,RFC 6749 §6)。
48
+ *
49
+ * @param {string} baseUrl 平台基础地址
50
+ * @param {string} refreshToken 当前持有的 refresh_token
51
+ * @param {Function} fetchImpl HTTP 实现(默认全局 fetch)
52
+ * @returns {Promise<object>} { access_token, refresh_token, expires_in, ... }
53
+ * @throws {Error} refresh 失败(含重登提示)
54
+ */
55
+ async function doRefreshToken(baseUrl, refreshToken, fetchImpl = fetch) {
56
+ const body = new URLSearchParams({
57
+ grant_type: 'refresh_token',
58
+ refresh_token: refreshToken,
59
+ client_id: 'linger-cli',
60
+ });
61
+ const resp = await fetchImpl(`${baseUrl}/oauth/token`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
64
+ body: body.toString(),
34
65
  });
35
66
  const text = await resp.text();
36
67
  if (!resp.ok) {
68
+ // refresh 本身失败 → 凭证彻底过期,必须重新登录
69
+ throw new Error(
70
+ `登录凭证已失效,无法自动续期(${resp.status})。\n请重新执行 linger auth login 完成登录。`
71
+ );
72
+ }
73
+ return JSON.parse(text);
74
+ }
75
+
76
+ /**
77
+ * 针对单个凭证目录执行「带 in-flight 去重」的 refresh。
78
+ *
79
+ * 同一目录下并发多个 401 → 只有第一个进入 doRefreshToken,其余等同一 Promise。
80
+ * refresh 完成后:① 写入新 token 到凭证文件;② 清掉 in-flight 记录。
81
+ *
82
+ * @param {string} credPath 凭证目录(用作 map key 实现 profile 隔离)
83
+ * @param {string} baseUrl 平台地址
84
+ * @param {string} refreshToken 当前 refresh_token
85
+ * @param {Function} fetchImpl
86
+ * @returns {Promise<object>} 新 token 对象(含 access_token / refresh_token)
87
+ */
88
+ async function refreshWithDedup(credPath, baseUrl, refreshToken, fetchImpl) {
89
+ // 已有正在飞行的 refresh → 等同一个结果(去重核心)
90
+ if (_inflightRefresh.has(credPath)) {
91
+ return _inflightRefresh.get(credPath);
92
+ }
93
+
94
+ // 发起新 refresh,挂在 map 上
95
+ const promise = doRefreshToken(baseUrl, refreshToken, fetchImpl)
96
+ .finally(() => {
97
+ // 无论成功 / 失败,都要清掉 in-flight 记录,防止下次正常 401 也被挡住
98
+ _inflightRefresh.delete(credPath);
99
+ });
100
+
101
+ _inflightRefresh.set(credPath, promise);
102
+ return promise;
103
+ }
104
+
105
+ /**
106
+ * 从 JWT access token 解出 agent_id(纯函数·解析失败返回 null)。
107
+ * 续期路径用它补 agent_id —— 后端 refresh 返回体不含顶层 agent_id(它在 access_token JWT 内部)。
108
+ */
109
+ function _decodeAgentId(token) {
110
+ try {
111
+ const payloadB64 = (token || '').split('.')[1];
112
+ if (!payloadB64) return null;
113
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
114
+ return payload.agent_id || null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 续期落盘:合并新 token,并从新 access token 补 agent_id(旧版无 agent_id 凭证平滑升级)。
122
+ * 防止「续期后凭证仍缺 agent_id → refresh 彻底失效后重新 login 无法复用身份(又刷僵尸)」。
123
+ */
124
+ function persistRefreshedCreds(creds, newTokens, credOpts) {
125
+ const updated = { ...creds, ...newTokens };
126
+ const aid = _decodeAgentId(newTokens.access_token);
127
+ if (aid) updated.agent_id = aid;
128
+ saveCredentials(updated, credOpts);
129
+ }
130
+
131
+ /**
132
+ * 带 401→refresh→重试 的 GET 拦截器(M2 核心)。
133
+ *
134
+ * 与原 authedGet 接口兼容,新增 credOpts 参数({ dir? } 凭证目录选项)。
135
+ *
136
+ * @param {string} baseUrl 平台基础地址
137
+ * @param {string} pathAndQuery 请求路径(含 query string)
138
+ * @param {object} credOpts 凭证目录选项 { dir? }(同 credentials.js 的 opts)
139
+ * @param {object} [httpOpts] { fetchImpl? }
140
+ * @returns {Promise<object>} 解析后的 JSON
141
+ */
142
+ export async function authedGetWithRefresh(baseUrl, pathAndQuery, credOpts = {}, httpOpts = {}) {
143
+ const fetchImpl = httpOpts.fetchImpl || fetch;
144
+
145
+ // 读当前凭证(已登录才能续)
146
+ const creds = loadCredentials(credOpts);
147
+ if (!creds || !creds.access_token) {
148
+ throw new Error('还没登录。请先运行:linger auth login');
149
+ }
150
+
151
+ // 第一次请求
152
+ const firstResp = await fetchImpl(`${baseUrl}${pathAndQuery}`, {
153
+ method: 'GET',
154
+ headers: { Authorization: `Bearer ${creds.access_token}` },
155
+ });
156
+
157
+ if (firstResp.ok) {
158
+ return JSON.parse(await firstResp.text());
159
+ }
160
+
161
+ if (firstResp.status !== 401) {
162
+ // 非 401 错误,走原有错误路径
163
+ const text = await firstResp.text();
37
164
  let msg = text;
38
- try {
39
- const j = JSON.parse(text);
40
- msg = (j.detail && (j.detail.message || j.detail.error)) || j.message || j.error || text;
41
- } catch {
42
- /* JSON 错误体 */
43
- }
44
- if (resp.status === 401) {
45
- throw new Error(`登录凭证无效或已过期(401):${msg}。请重新执行 linger auth login。`);
165
+ try { msg = JSON.parse(text)?.detail?.message || msg; } catch { /* ignore */ }
166
+ throw new Error(`请求失败(${firstResp.status}):${msg}`);
167
+ }
168
+
169
+ // ── 命中 401:触发 refresh ──────────────────────────────────
170
+ // 用凭证目录作 key 实现 profile 隔离(不同目录各自独立 refresh)
171
+ const credDir = credOpts.dir || process.env.LINGER_CONFIG_DIR || (await import('node:os')).homedir() + '/.linger';
172
+
173
+ const newTokens = await refreshWithDedup(credDir, baseUrl, creds.refresh_token, fetchImpl);
174
+
175
+ // 立即覆写凭证文件(防止旧 refresh 被 revoke 后再次使用)+ 补 agent_id(旧版凭证平滑升级)
176
+ persistRefreshedCreds(creds, newTokens, credOpts);
177
+
178
+ // 用新 access_token 重试一次
179
+ const retryResp = await fetchImpl(`${baseUrl}${pathAndQuery}`, {
180
+ method: 'GET',
181
+ headers: { Authorization: `Bearer ${newTokens.access_token}` },
182
+ });
183
+
184
+ if (!retryResp.ok) {
185
+ const text = await retryResp.text();
186
+ let msg = text;
187
+ try { msg = JSON.parse(text)?.detail?.message || msg; } catch { /* ignore */ }
188
+ if (retryResp.status === 401) {
189
+ throw new Error(`登录凭证已失效,无法自动续期。\n请重新执行 linger auth login 完成登录。`);
46
190
  }
47
- throw new Error(`请求失败(${resp.status}):${msg}`);
191
+ throw new Error(`请求失败(${retryResp.status}):${msg}`);
48
192
  }
49
- return JSON.parse(text);
193
+
194
+ return JSON.parse(await retryResp.text());
50
195
  }
51
196
 
52
197
  /**
53
- * 通用带鉴权 POST:成功返回解析后的 JSON;非 2xx 抛带平台文案的错误。
198
+ * 401→refresh→重试 的 POST 拦截器(M2 核心)。
199
+ *
200
+ * 与原 authedPost 接口兼容,新增 credOpts 参数。
54
201
  *
55
- * 所有 POST 都会带 X-Idempotency-Key header:
56
- * - idempotencyKey 为 null/undefined → 每次自动生成随机 UUID(正常调用场景)
57
- * - idempotencyKey 有值 → 透传(重试场景,保证同一操作幂等安全)
202
+ * @param {string} baseUrl 平台基础地址
203
+ * @param {string} pathStr 请求路径
204
+ * @param {object} credOpts 凭证目录选项 { dir? }
205
+ * @param {object} body POST body
206
+ * @param {string} [idempotencyKey] 幂等键(不传则自动生成)
207
+ * @param {object} [httpOpts] { fetchImpl? }
208
+ * @returns {Promise<object>} 解析后的 JSON
58
209
  */
59
- async function authedPost(baseUrl, path, token, body, idempotencyKey, { fetchImpl = fetch } = {}) {
60
- // 后端 middleware 要求所有 POST 带此 header,缺则 400
210
+ export async function authedPostWithRefresh(
211
+ baseUrl,
212
+ pathStr,
213
+ credOpts = {},
214
+ body,
215
+ idempotencyKey,
216
+ httpOpts = {}
217
+ ) {
218
+ const fetchImpl = httpOpts.fetchImpl || fetch;
219
+
220
+ const creds = loadCredentials(credOpts);
221
+ if (!creds || !creds.access_token) {
222
+ throw new Error('还没登录。请先运行:linger auth login');
223
+ }
224
+
61
225
  const idemKey = idempotencyKey || randomUUID();
62
- const resp = await fetchImpl(`${baseUrl}${path}`, {
226
+
227
+ const makePostReq = (token) => fetchImpl(`${baseUrl}${pathStr}`, {
63
228
  method: 'POST',
64
229
  headers: {
65
230
  Authorization: `Bearer ${token}`,
@@ -68,35 +233,63 @@ async function authedPost(baseUrl, path, token, body, idempotencyKey, { fetchImp
68
233
  },
69
234
  body: JSON.stringify(body),
70
235
  });
71
- const text = await resp.text();
72
- if (!resp.ok) {
236
+
237
+ const firstResp = await makePostReq(creds.access_token);
238
+
239
+ if (firstResp.ok) {
240
+ return JSON.parse(await firstResp.text());
241
+ }
242
+
243
+ if (firstResp.status !== 401) {
244
+ const text = await firstResp.text();
73
245
  let msg = text;
74
- try {
75
- const j = JSON.parse(text);
76
- msg = (j.detail && (j.detail.message || j.detail.error)) || j.message || j.error || text;
77
- } catch {
78
- /* JSON 错误体 */
79
- }
80
- if (resp.status === 401) {
81
- throw new Error(`登录凭证无效或已过期(401):${msg}。请重新执行 linger auth login。`);
246
+ try { msg = JSON.parse(text)?.detail?.message || msg; } catch { /* ignore */ }
247
+ throw new Error(`请求失败(${firstResp.status}):${msg}`);
248
+ }
249
+
250
+ // 命中 401 refresh + 重试
251
+ const credDir = credOpts.dir || process.env.LINGER_CONFIG_DIR || (await import('node:os')).homedir() + '/.linger';
252
+ const newTokens = await refreshWithDedup(credDir, baseUrl, creds.refresh_token, fetchImpl);
253
+ persistRefreshedCreds(creds, newTokens, credOpts);
254
+
255
+ const retryResp = await makePostReq(newTokens.access_token);
256
+ if (!retryResp.ok) {
257
+ const text = await retryResp.text();
258
+ let msg = text;
259
+ try { msg = JSON.parse(text)?.detail?.message || msg; } catch { /* ignore */ }
260
+ if (retryResp.status === 401) {
261
+ throw new Error(`登录凭证已失效,无法自动续期。\n请重新执行 linger auth login 完成登录。`);
82
262
  }
83
- throw new Error(`请求失败(${resp.status}):${msg}`);
263
+ throw new Error(`请求失败(${retryResp.status}):${msg}`);
84
264
  }
85
- return JSON.parse(text);
265
+
266
+ return JSON.parse(await retryResp.text());
86
267
  }
87
268
 
88
269
  // ============================================================
89
- // 已有(M3 · 不动接口)
270
+ // 内部辅助(含自动续期能力)
271
+ //
272
+ // v0.4.4 M2 Iter 2:所有 wrapper 函数(getWallet/createTask 等)现在
273
+ // 接受 credOpts(凭证目录选项,同 credentials.js 的 opts)而不是裸 token。
274
+ // 内部统一走 authedGetWithRefresh / authedPostWithRefresh,
275
+ // 所有命令天然获得续期能力,零遗漏。
276
+ //
277
+ // 向后兼容注意:如果调用方传的 credOpts 是一个字符串(旧版 token 直传方式),
278
+ // 会被视为凭证目录路径字符串,不会 crash,但建议迁移到 { dir } 对象形式。
279
+ // ============================================================
280
+
281
+ // ============================================================
282
+ // 已有(M3 · 签名更新为 credOpts)
90
283
  // ============================================================
91
284
 
92
285
  /** 查钱包:余额 + 冻结 + 最近流水。 */
93
- export async function getWallet(baseUrl, token, opts = {}) {
94
- return authedGet(baseUrl, '/api/v1/wallet', token, opts);
286
+ export async function getWallet(baseUrl, credOpts = {}, opts = {}) {
287
+ return authedGetWithRefresh(baseUrl, '/api/v1/wallet', credOpts, opts);
95
288
  }
96
289
 
97
290
  /** 浏览任务大厅:默认招募中(created)任务列表。 */
98
- export async function getHall(baseUrl, token, opts = {}) {
99
- return authedGet(baseUrl, '/api/v1/tasks/hall', token, opts);
291
+ export async function getHall(baseUrl, credOpts = {}, opts = {}) {
292
+ return authedGetWithRefresh(baseUrl, '/api/v1/tasks/hall', credOpts, opts);
100
293
  }
101
294
 
102
295
  // ============================================================
@@ -105,31 +298,34 @@ export async function getHall(baseUrl, token, opts = {}) {
105
298
 
106
299
  /**
107
300
  * 发布任务(买方)。
108
- * @param {object} taskData { title, description, bounty_amount_cents, delivery_hours, tags, accept_deadline_minutes? }
301
+ * @param {object} credOpts 凭证目录选项 { dir? }
302
+ * @param {object} taskData { title, description, bounty_amount_cents, delivery_hours, tags, accept_deadline_minutes? }
109
303
  */
110
- export async function createTask(baseUrl, token, taskData, idempotencyKey, opts = {}) {
111
- return authedPost(baseUrl, '/api/v1/tasks', token, taskData, idempotencyKey, opts);
304
+ export async function createTask(baseUrl, credOpts = {}, taskData, idempotencyKey, opts = {}) {
305
+ return authedPostWithRefresh(baseUrl, '/api/v1/tasks', credOpts, taskData, idempotencyKey, opts);
112
306
  }
113
307
 
114
308
  /**
115
309
  * 申请接单(卖方)。
116
- * @param {string} taskId 任务 ID
310
+ * @param {object} credOpts 凭证目录选项 { dir? }
311
+ * @param {string} taskId 任务 ID
117
312
  * @param {object} applyData { message? } 竞选理由(可选)
118
313
  */
119
- export async function applyTask(baseUrl, token, taskId, applyData, idempotencyKey, opts = {}) {
120
- return authedPost(baseUrl, `/api/v1/tasks/${taskId}/applications`, token, applyData, idempotencyKey, opts);
314
+ export async function applyTask(baseUrl, credOpts = {}, taskId, applyData, idempotencyKey, opts = {}) {
315
+ return authedPostWithRefresh(baseUrl, `/api/v1/tasks/${taskId}/applications`, credOpts, applyData, idempotencyKey, opts);
121
316
  }
122
317
 
123
318
  /**
124
319
  * 买方选中某申请人。
125
- * @param {string} taskId 任务 ID
126
- * @param {string} applicationId 被选中的申请 ID
320
+ * @param {object} credOpts 凭证目录选项 { dir? }
321
+ * @param {string} taskId 任务 ID
322
+ * @param {string} applicationId 被选中的申请 ID
127
323
  */
128
- export async function selectApplication(baseUrl, token, taskId, applicationId, idempotencyKey, opts = {}) {
129
- return authedPost(
324
+ export async function selectApplication(baseUrl, credOpts = {}, taskId, applicationId, idempotencyKey, opts = {}) {
325
+ return authedPostWithRefresh(
130
326
  baseUrl,
131
327
  `/api/v1/tasks/${taskId}/select`,
132
- token,
328
+ credOpts,
133
329
  { application_id: applicationId },
134
330
  idempotencyKey,
135
331
  opts
@@ -142,14 +338,15 @@ export async function selectApplication(baseUrl, token, taskId, applicationId, i
142
338
 
143
339
  /**
144
340
  * 调用 runtime/act,通用函数。
145
- * @param {string} action ACTION_MAP 里的 action name
146
- * @param {object} payload 含 job_id + action 专属字段
341
+ * @param {object} credOpts 凭证目录选项 { dir? }
342
+ * @param {string} action ACTION_MAP 里的 action name
343
+ * @param {object} payload 含 job_id + action 专属字段
147
344
  */
148
- export async function runtimeAct(baseUrl, token, action, payload, idempotencyKey, opts = {}) {
149
- return authedPost(
345
+ export async function runtimeAct(baseUrl, credOpts = {}, action, payload, idempotencyKey, opts = {}) {
346
+ return authedPostWithRefresh(
150
347
  baseUrl,
151
348
  '/api/v1/runtime/act',
152
- token,
349
+ credOpts,
153
350
  { action, payload },
154
351
  idempotencyKey,
155
352
  opts
@@ -164,13 +361,14 @@ export async function runtimeAct(baseUrl, token, action, payload, idempotencyKey
164
361
  * 上报技能清单(卖方·A 流程第一步)。
165
362
  * 把 agent 自己会的技能写进平台 agents.skill_catalog,供网页「AI 识别」挑能力卡。
166
363
  * 用 agent token 调用时后端从 token 推导 agent_id(agent 不必知道自己 UUID)。
364
+ * @param {object} credOpts 凭证目录选项 { dir? }
167
365
  * @param {Array<{name:string,description:string}>} skills 技能清单
168
366
  */
169
- export async function reportSkills(baseUrl, token, skills, idempotencyKey, opts = {}) {
170
- return authedPost(
367
+ export async function reportSkills(baseUrl, credOpts = {}, skills, idempotencyKey, opts = {}) {
368
+ return authedPostWithRefresh(
171
369
  baseUrl,
172
370
  '/api/v1/capabilities/skill_catalog',
173
- token,
371
+ credOpts,
174
372
  { skills },
175
373
  idempotencyKey,
176
374
  opts
@@ -179,21 +377,23 @@ export async function reportSkills(baseUrl, token, skills, idempotencyKey, opts
179
377
 
180
378
  /**
181
379
  * 创建能力卡草稿(卖方)。
182
- * @param {object} capData CreateCapabilityRequest 字段(agent_id + title 必填,其余草稿可空)
380
+ * @param {object} credOpts 凭证目录选项 { dir? }
381
+ * @param {object} capData CreateCapabilityRequest 字段(agent_id + title 必填,其余草稿可空)
183
382
  */
184
- export async function createCapability(baseUrl, token, capData, idempotencyKey, opts = {}) {
185
- return authedPost(baseUrl, '/api/v1/capabilities', token, capData, idempotencyKey, opts);
383
+ export async function createCapability(baseUrl, credOpts = {}, capData, idempotencyKey, opts = {}) {
384
+ return authedPostWithRefresh(baseUrl, '/api/v1/capabilities', credOpts, capData, idempotencyKey, opts);
186
385
  }
187
386
 
188
387
  /**
189
388
  * 上架能力(卖方)。后端会校验归属 owner + 上架前置字段完整。
190
- * @param {string} capabilityId 能力 ID
389
+ * @param {object} credOpts 凭证目录选项 { dir? }
390
+ * @param {string} capabilityId 能力 ID
191
391
  */
192
- export async function publishCapability(baseUrl, token, capabilityId, idempotencyKey, opts = {}) {
193
- return authedPost(
392
+ export async function publishCapability(baseUrl, credOpts = {}, capabilityId, idempotencyKey, opts = {}) {
393
+ return authedPostWithRefresh(
194
394
  baseUrl,
195
395
  `/api/v1/capabilities/${capabilityId}/publish`,
196
- token,
396
+ credOpts,
197
397
  {},
198
398
  idempotencyKey,
199
399
  opts
@@ -206,22 +406,24 @@ export async function publishCapability(baseUrl, token, capabilityId, idempotenc
206
406
 
207
407
  /**
208
408
  * 发问答留言(任意参与方)。
209
- * @param {string} taskId 任务 ID
210
- * @param {string} content 留言内容
409
+ * @param {object} credOpts 凭证目录选项 { dir? }
410
+ * @param {string} taskId 任务 ID
411
+ * @param {string} content 留言内容
211
412
  * @param {number|null} parentId 回复的目标留言 ID(顶层不传)
212
413
  */
213
- export async function postComment(baseUrl, token, taskId, content, parentId, idempotencyKey, opts = {}) {
414
+ export async function postComment(baseUrl, credOpts = {}, taskId, content, parentId, idempotencyKey, opts = {}) {
214
415
  const body = { content };
215
416
  if (parentId != null) body.parent_id = parentId;
216
- return authedPost(baseUrl, `/api/v1/tasks/${taskId}/comments`, token, body, idempotencyKey, opts);
417
+ return authedPostWithRefresh(baseUrl, `/api/v1/tasks/${taskId}/comments`, credOpts, body, idempotencyKey, opts);
217
418
  }
218
419
 
219
420
  /**
220
421
  * 查看任务问答留言列表。
221
- * @param {string} taskId 任务 ID
422
+ * @param {object} credOpts 凭证目录选项 { dir? }
423
+ * @param {string} taskId 任务 ID
222
424
  */
223
- export async function getComments(baseUrl, token, taskId, opts = {}) {
224
- return authedGet(baseUrl, `/api/v1/tasks/${taskId}/comments`, token, opts);
425
+ export async function getComments(baseUrl, credOpts = {}, taskId, opts = {}) {
426
+ return authedGetWithRefresh(baseUrl, `/api/v1/tasks/${taskId}/comments`, credOpts, opts);
225
427
  }
226
428
 
227
429
  // ============================================================
@@ -231,9 +433,10 @@ export async function getComments(baseUrl, token, taskId, opts = {}) {
231
433
  /**
232
434
  * 拉取巡航快照(autonomy tick)或轻量探活(ping)。
233
435
  * ping 和 tick 复用同一端点,响应 200 即说明鉴权 + 连通 OK。
436
+ * @param {object} credOpts 凭证目录选项 { dir? }
234
437
  */
235
- export async function getCruise(baseUrl, token, opts = {}) {
236
- return authedGet(baseUrl, '/api/v1/agent/cruise', token, opts);
438
+ export async function getCruise(baseUrl, credOpts = {}, opts = {}) {
439
+ return authedGetWithRefresh(baseUrl, '/api/v1/agent/cruise', credOpts, opts);
237
440
  }
238
441
 
239
442
  // ============================================================
@@ -246,10 +449,11 @@ export async function getCruise(baseUrl, token, opts = {}) {
246
449
  * 对应端点:GET /api/v1/tasks/{task_id}
247
450
  * 鉴权:Bearer JWT(OAuth access_token 可达·_require_auth 只校签名+exp)
248
451
  *
249
- * @param {string} taskId 任务 ID
452
+ * @param {object} credOpts 凭证目录选项 { dir? }
453
+ * @param {string} taskId 任务 ID
250
454
  */
251
- export async function getTaskDetail(baseUrl, token, taskId, opts = {}) {
252
- return authedGet(baseUrl, `/api/v1/tasks/${taskId}`, token, opts);
455
+ export async function getTaskDetail(baseUrl, credOpts = {}, taskId, opts = {}) {
456
+ return authedGetWithRefresh(baseUrl, `/api/v1/tasks/${taskId}`, credOpts, opts);
253
457
  }
254
458
 
255
459
  /**
@@ -258,10 +462,11 @@ export async function getTaskDetail(baseUrl, token, taskId, opts = {}) {
258
462
  * 对应端点:GET /api/v1/tasks/{task_id}/attachments
259
463
  * 返回:{ attachments: [{ file_id, mime_type, size_bytes, locked, ... }] }
260
464
  *
261
- * @param {string} taskId 任务 ID
465
+ * @param {object} credOpts 凭证目录选项 { dir? }
466
+ * @param {string} taskId 任务 ID
262
467
  */
263
- export async function getTaskFiles(baseUrl, token, taskId, opts = {}) {
264
- return authedGet(baseUrl, `/api/v1/tasks/${taskId}/attachments`, token, opts);
468
+ export async function getTaskFiles(baseUrl, credOpts = {}, taskId, opts = {}) {
469
+ return authedGetWithRefresh(baseUrl, `/api/v1/tasks/${taskId}/attachments`, credOpts, opts);
265
470
  }
266
471
 
267
472
  /**
@@ -271,10 +476,11 @@ export async function getTaskFiles(baseUrl, token, taskId, opts = {}) {
271
476
  * 返回:{ file_id, signed_get_url }
272
477
  * 薄包装铁律:只返回 signed_get_url,HTTP GET 由调用方(agent)自己做。
273
478
  *
274
- * @param {string} fileId 文件 ID
479
+ * @param {object} credOpts 凭证目录选项 { dir? }
480
+ * @param {string} fileId 文件 ID
275
481
  */
276
- export async function getDownloadUrl(baseUrl, token, fileId, opts = {}) {
277
- return authedGet(baseUrl, `/api/v1/downloads/${fileId}/url`, token, opts);
482
+ export async function getDownloadUrl(baseUrl, credOpts = {}, fileId, opts = {}) {
483
+ return authedGetWithRefresh(baseUrl, `/api/v1/downloads/${fileId}/url`, credOpts, opts);
278
484
  }
279
485
 
280
486
  /**
@@ -284,12 +490,12 @@ export async function getDownloadUrl(baseUrl, token, fileId, opts = {}) {
284
490
  * 返回:{ file_id, signed_put_url, oss_object_id }
285
491
  * 薄包装铁律:只返回 signed_put_url + file_id,HTTP PUT 由调用方(agent)自己做。
286
492
  *
493
+ * @param {object} credOpts 凭证目录选项 { dir? }
287
494
  * @param {object} uploadData { task_id, kind, mime_type, size_bytes }
288
495
  * kind = 'attachment'(需求附件)| 'deliverable'(交付物)
289
496
  */
290
- export async function requestUpload(baseUrl, token, uploadData, opts = {}) {
497
+ export async function requestUpload(baseUrl, credOpts = {}, uploadData, opts = {}) {
291
498
  // 上传申请走随机 UUID 幂等键(uploadData 里的字段相同可幂等,后端会返回相同 file_id)
292
499
  const { idempotencyKey, fetchImpl } = opts;
293
- // opts 其余字段传给 authedPost
294
- return authedPost(baseUrl, '/api/v1/uploads', token, uploadData, idempotencyKey || null, { fetchImpl });
500
+ return authedPostWithRefresh(baseUrl, '/api/v1/uploads', credOpts, uploadData, idempotencyKey || null, { fetchImpl });
295
501
  }
@@ -7,7 +7,7 @@
7
7
  // 图标用内联 SVG、动画包 prefers-reduced-motion 降级、文案极简(每页 1 句 caption)。
8
8
  //
9
9
  // 三个常量对应 oauth.js loopback handler 的三个 res.end:
10
- // SUCCESS — 拿到 code(绑定成功 · 带「打开 Linger 主页」按钮)
10
+ // SUCCESS — 拿到 code(绑定成功 · 带「去我的主页」按钮·指向 user-profile 旗下 Agent·非站点根/任务大厅)
11
11
  // ERROR — error(授权未完成 · 回命令行重试)
12
12
  // MISSING — 缺授权码(回调异常)
13
13
 
@@ -70,7 +70,7 @@ const CALLBACK_HTML_SUCCESS = `<!DOCTYPE html>
70
70
  </div>
71
71
  <h1>绑定成功 · 已连接 Linger</h1>
72
72
  <p>可以关闭此页面,回到命令行</p>
73
- <a class="btn" href="https://a2a.linger.chimap.cn">打开 Linger 主页</a>
73
+ <a class="btn" href="https://a2a.linger.chimap.cn/src/pages/user-profile/index.html#tab=agents">去我的主页</a>
74
74
  </main>
75
75
  </body>
76
76
  </html>`;
@@ -3,15 +3,31 @@
3
3
  // 设计要点:
4
4
  // - 文件权限 0600(仅本人可读写)——同机其它账户偷不到 token。
5
5
  // - 读损坏 / 不存在 → 返回 null(当作没登录),绝不抛异常炸掉命令。
6
- // - 目录可通过 opts.dir 覆盖(单测用临时目录,不污染真实 ~/.linger)。
6
+ // - 目录优先级:opts.dir > LINGER_CONFIG_DIR > ~/.linger(三级回退)。
7
+ // · opts.dir:测试注入,直接覆盖。
8
+ // · LINGER_CONFIG_DIR:同机多 Agent 凭证物理隔离——每个 Agent 启动时
9
+ // 设自己独立的目录,防止并发 refresh 互相作废对方的 refresh token。
10
+ // · ~/.linger:默认值,向后兼容。
11
+ //
12
+ // 凭证结构(v0.4.4 起含 agent_id):
13
+ // { access_token, refresh_token, token_type, expires_in, base_url, agent_id? }
14
+ // agent_id 来自首次授权 access token 的 JWT payload,由 runAuthLogin 落盘。
7
15
 
8
16
  import fs from 'node:fs';
9
17
  import os from 'node:os';
10
18
  import path from 'node:path';
11
19
 
12
- /** 凭证目录:默认 ~/.linger,可被 opts.dir 覆盖(测试用)。 */
20
+ /**
21
+ * 凭证目录解析(三级优先级)。
22
+ *
23
+ * 1. opts.dir — 单测注入 / 调用方显式指定
24
+ * 2. LINGER_CONFIG_DIR 环境变量 — 同机多 Agent 隔离的生产手段
25
+ * 3. ~/.linger — 默认(向后兼容)
26
+ */
13
27
  function credDir(opts = {}) {
14
- return opts.dir || path.join(os.homedir(), '.linger');
28
+ if (opts.dir) return opts.dir;
29
+ if (process.env.LINGER_CONFIG_DIR) return process.env.LINGER_CONFIG_DIR;
30
+ return path.join(os.homedir(), '.linger');
15
31
  }
16
32
 
17
33
  /** 凭证文件完整路径(~/.linger/credentials)。 */
package/src/index.js CHANGED
@@ -198,8 +198,19 @@ const HELP = `Linger 命令行工具(v0.4.2 · M7 全量命令)
198
198
  环境变量:
199
199
  LINGER_BASE_URL 覆盖平台 API 地址(默认 https://a2a.linger.chimap.cn)
200
200
  LINGER_WEB_BASE 覆盖任务详情页网页根域名(默认 https://a2a.linger.chimap.cn)
201
+ LINGER_CONFIG_DIR 凭证存储目录(默认 ~/.linger)
202
+ 同机多 Agent 时为每个 Agent 设不同目录,防止并发续期互相作废
203
+ 示例:LINGER_CONFIG_DIR=/home/agentA/.linger-a linger ping
201
204
  `;
202
205
 
206
+ /**
207
+ * 从凭证里读 agent_id(首次授权时由 runAuthLogin 落盘的 JWT sub 字段)。
208
+ * 不存在 / 凭证为空 → 返回 null(不 crash)。
209
+ */
210
+ function getAgentIdFromCreds(creds) {
211
+ return (creds && creds.agent_id) || null;
212
+ }
213
+
203
214
  /**
204
215
  * CLI 总入口。
205
216
  * @param {string[]} argv process.argv.slice(2)
@@ -236,30 +247,31 @@ export async function run(argv, deps = {}) {
236
247
 
237
248
  // ── 以下所有命令都需要已登录 ──────────────────────────────
238
249
 
239
- // _mockToken:测试注入用,绕过真实文件系统凭证读取
240
- let token;
241
- if (deps._mockToken) {
242
- token = deps._mockToken;
243
- } else {
244
- const creds = loadCredentials(credDir ? { dir: credDir } : {});
245
- if (!creds || !creds.access_token) {
246
- err('还没登录。请先运行:linger auth login');
247
- return 3;
248
- }
249
- token = creds.access_token;
250
+ // v0.4.4 M2 Iter 2:index.js 不再持有裸 token,所有 api 调用传 credOpts,
251
+ // 由 api 层(authedGetWithRefresh / authedPostWithRefresh)负责读凭证 + 自动续期。
252
+ // 先读一次凭证做「是否已登录」检查(不改 api 调用路径)。
253
+ const credOpts = credDir ? { dir: credDir } : {};
254
+ const creds = loadCredentials(credOpts);
255
+ if (!creds || !creds.access_token) {
256
+ err('还没登录。请先运行:linger auth login');
257
+ return 3;
250
258
  }
259
+ // agentId 从凭证读取,供 deliver 幂等键等处使用(可能为 null,旧版凭证无此字段)
260
+ const agentId = getAgentIdFromCreds(creds);
261
+ // deliver 幂等键用当前 access_token(JWT sub 字段),续期后新 token 由 api 层持有
262
+ const token = creds.access_token;
251
263
  const idemKey = flags.idempotencyKey || null; // null → api 层自动生成 UUID
252
264
 
253
265
  // ── 已有命令(M3)────────────────────────────────────────
254
266
 
255
267
  if (command === 'wallet') {
256
- const data = await getWallet(baseUrl, token, { fetchImpl });
268
+ const data = await getWallet(baseUrl, credOpts, { fetchImpl });
257
269
  log(formatWallet(data, { json: flags.json }));
258
270
  return 0;
259
271
  }
260
272
 
261
273
  if (command === 'hall:browse') {
262
- const data = await getHall(baseUrl, token, { fetchImpl });
274
+ const data = await getHall(baseUrl, credOpts, { fetchImpl });
263
275
  log(formatHall(data, { json: flags.json }));
264
276
  return 0;
265
277
  }
@@ -267,7 +279,7 @@ export async function run(argv, deps = {}) {
267
279
  // ── 探活 ─────────────────────────────────────────────────
268
280
 
269
281
  if (command === 'ping') {
270
- const data = await getCruise(baseUrl, token, { fetchImpl });
282
+ const data = await getCruise(baseUrl, credOpts, { fetchImpl });
271
283
  log(formatPing(data, { json: flags.json }));
272
284
  return 0;
273
285
  }
@@ -288,7 +300,7 @@ export async function run(argv, deps = {}) {
288
300
  delivery_hours: flags.deliveryHours,
289
301
  tags: flags.tags,
290
302
  };
291
- const data = await createTask(baseUrl, token, taskData, idemKey, { fetchImpl });
303
+ const data = await createTask(baseUrl, credOpts, taskData, idemKey, { fetchImpl });
292
304
  log(formatTaskCreate(data, { json: flags.json }));
293
305
  return 0;
294
306
  }
@@ -299,7 +311,7 @@ export async function run(argv, deps = {}) {
299
311
  if (!taskId) { err('缺少 task_id 参数。用法:linger task apply <task_id>'); return 2; }
300
312
  const applyData = {};
301
313
  if (flags.message) applyData.message = flags.message;
302
- const data = await applyTask(baseUrl, token, taskId, applyData, idemKey, { fetchImpl });
314
+ const data = await applyTask(baseUrl, credOpts, taskId, applyData, idemKey, { fetchImpl });
303
315
  log(formatTaskApply(data, { json: flags.json }));
304
316
  return 0;
305
317
  }
@@ -310,7 +322,7 @@ export async function run(argv, deps = {}) {
310
322
  const appId = positionals[3];
311
323
  if (!taskId) { err('缺少 task_id 参数。用法:linger task select <task_id> <application_id>'); return 2; }
312
324
  if (!appId) { err('缺少 application_id 参数。用法:linger task select <task_id> <application_id>'); return 2; }
313
- const data = await selectApplication(baseUrl, token, taskId, appId, idemKey, { fetchImpl });
325
+ const data = await selectApplication(baseUrl, credOpts, taskId, appId, idemKey, { fetchImpl });
314
326
  log(formatTaskSelect(data, { json: flags.json }));
315
327
  return 0;
316
328
  }
@@ -327,7 +339,7 @@ export async function run(argv, deps = {}) {
327
339
  return 2;
328
340
  }
329
341
  const data = await runtimeAct(
330
- baseUrl, token, 'accept_job',
342
+ baseUrl, credOpts, 'accept_job',
331
343
  { job_id: taskId, application_id: flags.applicationId },
332
344
  idemKey, { fetchImpl }
333
345
  );
@@ -339,7 +351,7 @@ export async function run(argv, deps = {}) {
339
351
  const taskId = positionals[2];
340
352
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
341
353
  const data = await runtimeAct(
342
- baseUrl, token, 'start',
354
+ baseUrl, credOpts, 'start',
343
355
  { job_id: taskId },
344
356
  idemKey, { fetchImpl }
345
357
  );
@@ -353,7 +365,7 @@ export async function run(argv, deps = {}) {
353
365
  if (flags.progress == null) { err('缺少 --progress 参数(0-100)'); return 2; }
354
366
  const payload = { job_id: taskId, progress: flags.progress };
355
367
  if (flags.note) payload.progress_note = flags.note;
356
- const data = await runtimeAct(baseUrl, token, 'heartbeat_job', payload, idemKey, { fetchImpl });
368
+ const data = await runtimeAct(baseUrl, credOpts, 'heartbeat_job', payload, idemKey, { fetchImpl });
357
369
  log(formatRuntimeAct(data, `进度已更新(${flags.progress}%)`, { json: flags.json }));
358
370
  return 0;
359
371
  }
@@ -389,7 +401,7 @@ export async function run(argv, deps = {}) {
389
401
  // 用户可手动传 --idempotency-key 覆盖(重试同一次交付时复用)
390
402
  const deliverIdemKey = idemKey || buildDeliverIdemKey(taskId, token, flags.result, flags.fileId);
391
403
 
392
- const data = await runtimeAct(baseUrl, token, 'deliver_job', payload, deliverIdemKey, { fetchImpl });
404
+ const data = await runtimeAct(baseUrl, credOpts, 'deliver_job', payload, deliverIdemKey, { fetchImpl });
393
405
  log(formatRuntimeAct(data, '已提交交付!(系统自动进入待验收)', { json: flags.json }));
394
406
  return 0;
395
407
  }
@@ -401,7 +413,7 @@ export async function run(argv, deps = {}) {
401
413
  if (flags.reason) payload.reason = flags.reason;
402
414
  if (flags.errorCode) payload.error_code = flags.errorCode;
403
415
  if (flags.errorMessage) payload.error_message = flags.errorMessage;
404
- const data = await runtimeAct(baseUrl, token, 'fail_job', payload, idemKey, { fetchImpl });
416
+ const data = await runtimeAct(baseUrl, credOpts, 'fail_job', payload, idemKey, { fetchImpl });
405
417
  log(formatRuntimeAct(data, '任务已标记失败。', { json: flags.json }));
406
418
  return 0;
407
419
  }
@@ -410,7 +422,7 @@ export async function run(argv, deps = {}) {
410
422
  const taskId = positionals[2];
411
423
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
412
424
  const data = await runtimeAct(
413
- baseUrl, token, 'confirm',
425
+ baseUrl, credOpts, 'confirm',
414
426
  { job_id: taskId },
415
427
  idemKey, { fetchImpl }
416
428
  );
@@ -423,7 +435,7 @@ export async function run(argv, deps = {}) {
423
435
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
424
436
  const payload = { job_id: taskId };
425
437
  if (flags.reason) payload.reason = flags.reason;
426
- const data = await runtimeAct(baseUrl, token, 'reject_job', payload, idemKey, { fetchImpl });
438
+ const data = await runtimeAct(baseUrl, credOpts, 'reject_job', payload, idemKey, { fetchImpl });
427
439
  log(formatRuntimeAct(data, '已拒收,任务进入仲裁流程。', { json: flags.json }));
428
440
  return 0;
429
441
  }
@@ -433,7 +445,7 @@ export async function run(argv, deps = {}) {
433
445
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
434
446
  if (!flags.note) { err('缺少 --note 参数(改稿要求)'); return 2; }
435
447
  const data = await runtimeAct(
436
- baseUrl, token, 'request_revision',
448
+ baseUrl, credOpts, 'request_revision',
437
449
  { job_id: taskId, revision_note: flags.note },
438
450
  idemKey, { fetchImpl }
439
451
  );
@@ -446,7 +458,7 @@ export async function run(argv, deps = {}) {
446
458
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
447
459
  if (flags.refundPct == null) { err('缺少 --refund-pct 参数(退款百分比 0-100)'); return 2; }
448
460
  const data = await runtimeAct(
449
- baseUrl, token, 'accept_partial',
461
+ baseUrl, credOpts, 'accept_partial',
450
462
  { job_id: taskId, refund_pct: flags.refundPct },
451
463
  idemKey, { fetchImpl }
452
464
  );
@@ -459,7 +471,7 @@ export async function run(argv, deps = {}) {
459
471
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
460
472
  const payload = { job_id: taskId };
461
473
  if (flags.reason) payload.reason = flags.reason;
462
- const data = await runtimeAct(baseUrl, token, 'cancel', payload, idemKey, { fetchImpl });
474
+ const data = await runtimeAct(baseUrl, credOpts, 'cancel', payload, idemKey, { fetchImpl });
463
475
  log(formatRuntimeAct(data, '任务已取消,已托管赏金将退回。', { json: flags.json }));
464
476
  return 0;
465
477
  }
@@ -490,7 +502,7 @@ export async function run(argv, deps = {}) {
490
502
  if (!Array.isArray(skills) || skills.length === 0) {
491
503
  err('技能清单必须是非空 JSON 数组:[{"name":"...","description":"..."}]'); return 2;
492
504
  }
493
- const data = await reportSkills(baseUrl, token, skills, idemKey, { fetchImpl });
505
+ const data = await reportSkills(baseUrl, credOpts, skills, idemKey, { fetchImpl });
494
506
  log(formatReportSkills(data, { json: flags.json }));
495
507
  return 0;
496
508
  }
@@ -513,7 +525,7 @@ export async function run(argv, deps = {}) {
513
525
  if (priceCents != null) capData.price_cents = priceCents;
514
526
  // M5:交付物边界说明(草稿可空,上架前平台要求必填)
515
527
  if (flags.deliverableBoundary) capData.deliverable_boundary = flags.deliverableBoundary;
516
- const data = await createCapability(baseUrl, token, capData, idemKey, { fetchImpl });
528
+ const data = await createCapability(baseUrl, credOpts, capData, idemKey, { fetchImpl });
517
529
  log(formatCapabilityCreate(data, { json: flags.json }));
518
530
  return 0;
519
531
  }
@@ -522,7 +534,7 @@ export async function run(argv, deps = {}) {
522
534
  // positionals = ['capability', 'publish', '<cap_id>']
523
535
  const capId = positionals[2];
524
536
  if (!capId) { err('缺少 capability_id 参数。用法:linger capability publish <cap_id>'); return 2; }
525
- const data = await publishCapability(baseUrl, token, capId, idemKey, { fetchImpl });
537
+ const data = await publishCapability(baseUrl, credOpts, capId, idemKey, { fetchImpl });
526
538
  log(formatCapabilityPublish(data, { json: flags.json }));
527
539
  return 0;
528
540
  }
@@ -535,7 +547,7 @@ export async function run(argv, deps = {}) {
535
547
  const content = positionals[2] || flags.message;
536
548
  if (!taskId) { err('缺少 task_id 参数。用法:linger qa <task_id> <消息>'); return 2; }
537
549
  if (!content) { err('缺少消息内容。用法:linger qa <task_id> <消息>'); return 2; }
538
- const data = await postComment(baseUrl, token, taskId, content, null, idemKey, { fetchImpl });
550
+ const data = await postComment(baseUrl, credOpts, taskId, content, null, idemKey, { fetchImpl });
539
551
  log(formatPostComment(data, { json: flags.json }));
540
552
  return 0;
541
553
  }
@@ -543,7 +555,7 @@ export async function run(argv, deps = {}) {
543
555
  if (command === 'qa:list') {
544
556
  const taskId = positionals[1];
545
557
  if (!taskId) { err('缺少 task_id 参数。用法:linger qa <task_id> --list'); return 2; }
546
- const data = await getComments(baseUrl, token, taskId, { fetchImpl });
558
+ const data = await getComments(baseUrl, credOpts, taskId, { fetchImpl });
547
559
  log(formatComments(data, { json: flags.json }));
548
560
  return 0;
549
561
  }
@@ -554,7 +566,7 @@ export async function run(argv, deps = {}) {
554
566
  // positionals = ['task', 'show', '<task_id>'] → [2] 是 task_id
555
567
  const taskId = positionals[2];
556
568
  if (!taskId) { err('缺少 task_id 参数。用法:linger task show <task_id>'); return 2; }
557
- const data = await getTaskDetail(baseUrl, token, taskId, { fetchImpl });
569
+ const data = await getTaskDetail(baseUrl, credOpts, taskId, { fetchImpl });
558
570
  log(formatTaskDetail(data, { json: flags.json }));
559
571
  return 0;
560
572
  }
@@ -563,7 +575,7 @@ export async function run(argv, deps = {}) {
563
575
  // positionals = ['task', 'files', '<task_id>'] → [2] 是 task_id
564
576
  const taskId = positionals[2];
565
577
  if (!taskId) { err('缺少 task_id 参数。用法:linger task files <task_id>'); return 2; }
566
- const data = await getTaskFiles(baseUrl, token, taskId, { fetchImpl });
578
+ const data = await getTaskFiles(baseUrl, credOpts, taskId, { fetchImpl });
567
579
  log(formatTaskFiles(data, { json: flags.json }));
568
580
  return 0;
569
581
  }
@@ -572,7 +584,7 @@ export async function run(argv, deps = {}) {
572
584
  // positionals = ['task', 'download', '<file_id>'] → [2] 是 file_id
573
585
  const fileId = positionals[2];
574
586
  if (!fileId) { err('缺少 file_id 参数。用法:linger task download <file_id>'); return 2; }
575
- const data = await getDownloadUrl(baseUrl, token, fileId, { fetchImpl });
587
+ const data = await getDownloadUrl(baseUrl, credOpts, fileId, { fetchImpl });
576
588
  log(formatDownloadUrl(data, { json: flags.json }));
577
589
  return 0;
578
590
  }
@@ -592,7 +604,7 @@ export async function run(argv, deps = {}) {
592
604
  // v0.4.4 · 真实文件名(可选 · 由调用方传 --filename 或留空)
593
605
  ...(flags.filename ? { filename: flags.filename } : {}),
594
606
  };
595
- const data = await requestUpload(baseUrl, token, uploadData, { fetchImpl });
607
+ const data = await requestUpload(baseUrl, credOpts, uploadData, { fetchImpl });
596
608
  log(formatRequestUpload(data, { json: flags.json }));
597
609
  return 0;
598
610
  }
@@ -660,7 +672,7 @@ export async function run(argv, deps = {}) {
660
672
  };
661
673
  let uploadResp;
662
674
  try {
663
- uploadResp = await requestUpload(baseUrl, token, uploadData, { fetchImpl });
675
+ uploadResp = await requestUpload(baseUrl, credOpts, uploadData, { fetchImpl });
664
676
  } catch (e) {
665
677
  // 终态任务后端返回 409,提示友好信息
666
678
  const msg = e && e.message ? e.message : String(e);
@@ -696,7 +708,7 @@ export async function run(argv, deps = {}) {
696
708
  // ── 自治巡航 ─────────────────────────────────────────────
697
709
 
698
710
  if (command === 'autonomy:tick') {
699
- const data = await getCruise(baseUrl, token, { fetchImpl });
711
+ const data = await getCruise(baseUrl, credOpts, { fetchImpl });
700
712
  log(formatCruise(data, { json: flags.json }));
701
713
  return 0;
702
714
  }
package/src/oauth.js CHANGED
@@ -16,9 +16,29 @@ import http from 'node:http';
16
16
  import { spawn } from 'node:child_process';
17
17
 
18
18
  import { generateVerifier, challengeFromVerifier, generateState } from './pkce.js';
19
- import { saveCredentials } from './credentials.js';
19
+ import { saveCredentials, loadCredentials } from './credentials.js';
20
20
  import { CALLBACK_HTML_SUCCESS, CALLBACK_HTML_ERROR, CALLBACK_HTML_MISSING } from './callback-pages.js';
21
21
 
22
+ /**
23
+ * 从 JWT access token 解出 agent_id(无需验签·只取 payload)。
24
+ *
25
+ * M1 契约:access token JWT payload 里 agent_id 字段名 = `agent_id`。
26
+ * 解析失败 → 返回 null,不 crash(首次接入时 token 可能不含此字段)。
27
+ *
28
+ * @param {string} token JWT 字符串
29
+ * @returns {string|null}
30
+ */
31
+ export function decodeAgentId(token) {
32
+ try {
33
+ const payloadB64 = token.split('.')[1];
34
+ if (!payloadB64) return null;
35
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
36
+ return payload.agent_id || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
22
42
  // M5 预注册 client_id(已在平台 oauth_clients 表预注册,无需动态注册)。
23
43
  export const POC_CLIENT_ID = 'linger-cli';
24
44
 
@@ -29,7 +49,8 @@ const CALLBACK_PATH = '/callback';
29
49
  * 拼装 Linger 授权确认页地址(/oauth/authorize)。纯字符串,无副作用。
30
50
  *
31
51
  * @param {string} baseUrl 平台基础地址(已去尾斜杠)
32
- * @param {object} p { clientId, redirectUri, codeChallenge, state, scope? }
52
+ * @param {object} p { clientId, redirectUri, codeChallenge, state, scope?, agentId? }
53
+ * agentId: 本机已有 agent_id 时传入,让后端复用该 agent(续命流程,不新建)。
33
54
  * @returns {string} 完整授权页 URL
34
55
  */
35
56
  export function buildAuthorizeUrl(baseUrl, p) {
@@ -41,6 +62,10 @@ export function buildAuthorizeUrl(baseUrl, p) {
41
62
  url.searchParams.set('code_challenge_method', 'S256');
42
63
  url.searchParams.set('state', p.state);
43
64
  url.searchParams.set('scope', p.scope || 'platform');
65
+ // M1 契约:带回本机已知的 agent_id,后端校验归属后复用(不新建)
66
+ if (p.agentId) {
67
+ url.searchParams.set('agent_id', p.agentId);
68
+ }
44
69
  return url.toString();
45
70
  }
46
71
 
@@ -213,6 +238,12 @@ export async function runAuthLogin(opts = {}) {
213
238
  const challenge = challengeFromVerifier(verifier);
214
239
  const state = generateState();
215
240
 
241
+ // M1 契约:读取本机已有 agent_id,带入授权请求让后端复用该 agent(续命流程)。
242
+ // 首次接入 / 凭证不存在 → agentId 为 null → 后端新建 agent(接新流程)。
243
+ const credOpts = credDir ? { dir: credDir } : {};
244
+ const existingCreds = loadCredentials(credOpts);
245
+ const existingAgentId = existingCreds?.agent_id || null;
246
+
216
247
  const server = await startLoopbackServer();
217
248
  const authUrl = buildAuthorizeUrl(baseUrl, {
218
249
  clientId,
@@ -220,6 +251,7 @@ export async function runAuthLogin(opts = {}) {
220
251
  codeChallenge: challenge,
221
252
  state,
222
253
  scope: 'platform',
254
+ agentId: existingAgentId, // null 时 buildAuthorizeUrl 不会加此参数
223
255
  });
224
256
 
225
257
  if (noWait) {
@@ -274,8 +306,17 @@ export async function runAuthLogin(opts = {}) {
274
306
  clientId,
275
307
  });
276
308
 
309
+ // M1 契约:从 access token JWT payload 解出 agent_id,落盘到凭证文件。
310
+ // - 首次接入:后端新建 agent → access token 含新 agent_id
311
+ // - 续命:后端复用旧 agent → access token 含同一 agent_id
312
+ // 解析失败(非标准 JWT 或旧版本后端)→ 保留 existingAgentId(不覆盖)。
313
+ const decodedAgentId = decodeAgentId(tokenObj.access_token);
314
+ const agentId = decodedAgentId || existingAgentId;
315
+
277
316
  const creds = { ...tokenObj, base_url: baseUrl };
278
- const file = saveCredentials(creds, credDir ? { dir: credDir } : {});
317
+ if (agentId) creds.agent_id = agentId; // agent_id 才写入(不写 null)
318
+
319
+ const file = saveCredentials(creds, credOpts);
279
320
  log('✓ 绑定成功,你的 Agent 已接入 Linger。');
280
321
  log(` 在主页查看你的 Agent:${baseUrl}`);
281
322
  log(` 凭证已保存到 ${file}`);