@lingerai/cli 0.3.1 → 0.3.3
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 +1 -1
- package/src/api.js +293 -87
- package/src/credentials.js +19 -3
- package/src/index.js +57 -39
- package/src/oauth.js +44 -3
package/package.json
CHANGED
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(`请求失败(${
|
|
191
|
+
throw new Error(`请求失败(${retryResp.status}):${msg}`);
|
|
48
192
|
}
|
|
49
|
-
|
|
193
|
+
|
|
194
|
+
return JSON.parse(await retryResp.text());
|
|
50
195
|
}
|
|
51
196
|
|
|
52
197
|
/**
|
|
53
|
-
*
|
|
198
|
+
* 带 401→refresh→重试 的 POST 拦截器(M2 核心)。
|
|
199
|
+
*
|
|
200
|
+
* 与原 authedPost 接口兼容,新增 credOpts 参数。
|
|
54
201
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(`请求失败(${
|
|
263
|
+
throw new Error(`请求失败(${retryResp.status}):${msg}`);
|
|
84
264
|
}
|
|
85
|
-
|
|
265
|
+
|
|
266
|
+
return JSON.parse(await retryResp.text());
|
|
86
267
|
}
|
|
87
268
|
|
|
88
269
|
// ============================================================
|
|
89
|
-
//
|
|
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,
|
|
94
|
-
return
|
|
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,
|
|
99
|
-
return
|
|
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}
|
|
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,
|
|
111
|
-
return
|
|
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 {
|
|
310
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
311
|
+
* @param {string} taskId 任务 ID
|
|
117
312
|
* @param {object} applyData { message? } 竞选理由(可选)
|
|
118
313
|
*/
|
|
119
|
-
export async function applyTask(baseUrl,
|
|
120
|
-
return
|
|
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 {
|
|
126
|
-
* @param {string}
|
|
320
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
321
|
+
* @param {string} taskId 任务 ID
|
|
322
|
+
* @param {string} applicationId 被选中的申请 ID
|
|
127
323
|
*/
|
|
128
|
-
export async function selectApplication(baseUrl,
|
|
129
|
-
return
|
|
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
|
-
|
|
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 {
|
|
146
|
-
* @param {
|
|
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,
|
|
149
|
-
return
|
|
345
|
+
export async function runtimeAct(baseUrl, credOpts = {}, action, payload, idempotencyKey, opts = {}) {
|
|
346
|
+
return authedPostWithRefresh(
|
|
150
347
|
baseUrl,
|
|
151
348
|
'/api/v1/runtime/act',
|
|
152
|
-
|
|
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,
|
|
170
|
-
return
|
|
367
|
+
export async function reportSkills(baseUrl, credOpts = {}, skills, idempotencyKey, opts = {}) {
|
|
368
|
+
return authedPostWithRefresh(
|
|
171
369
|
baseUrl,
|
|
172
370
|
'/api/v1/capabilities/skill_catalog',
|
|
173
|
-
|
|
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}
|
|
380
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
381
|
+
* @param {object} capData CreateCapabilityRequest 字段(agent_id + title 必填,其余草稿可空)
|
|
183
382
|
*/
|
|
184
|
-
export async function createCapability(baseUrl,
|
|
185
|
-
return
|
|
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 {
|
|
389
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
390
|
+
* @param {string} capabilityId 能力 ID
|
|
191
391
|
*/
|
|
192
|
-
export async function publishCapability(baseUrl,
|
|
193
|
-
return
|
|
392
|
+
export async function publishCapability(baseUrl, credOpts = {}, capabilityId, idempotencyKey, opts = {}) {
|
|
393
|
+
return authedPostWithRefresh(
|
|
194
394
|
baseUrl,
|
|
195
395
|
`/api/v1/capabilities/${capabilityId}/publish`,
|
|
196
|
-
|
|
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 {
|
|
210
|
-
* @param {string}
|
|
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,
|
|
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
|
|
417
|
+
return authedPostWithRefresh(baseUrl, `/api/v1/tasks/${taskId}/comments`, credOpts, body, idempotencyKey, opts);
|
|
217
418
|
}
|
|
218
419
|
|
|
219
420
|
/**
|
|
220
421
|
* 查看任务问答留言列表。
|
|
221
|
-
* @param {
|
|
422
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
423
|
+
* @param {string} taskId 任务 ID
|
|
222
424
|
*/
|
|
223
|
-
export async function getComments(baseUrl,
|
|
224
|
-
return
|
|
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,
|
|
236
|
-
return
|
|
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 {
|
|
452
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
453
|
+
* @param {string} taskId 任务 ID
|
|
250
454
|
*/
|
|
251
|
-
export async function getTaskDetail(baseUrl,
|
|
252
|
-
return
|
|
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 {
|
|
465
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
466
|
+
* @param {string} taskId 任务 ID
|
|
262
467
|
*/
|
|
263
|
-
export async function getTaskFiles(baseUrl,
|
|
264
|
-
return
|
|
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 {
|
|
479
|
+
* @param {object} credOpts 凭证目录选项 { dir? }
|
|
480
|
+
* @param {string} fileId 文件 ID
|
|
275
481
|
*/
|
|
276
|
-
export async function getDownloadUrl(baseUrl,
|
|
277
|
-
return
|
|
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,
|
|
497
|
+
export async function requestUpload(baseUrl, credOpts = {}, uploadData, opts = {}) {
|
|
291
498
|
// 上传申请走随机 UUID 幂等键(uploadData 里的字段相同可幂等,后端会返回相同 file_id)
|
|
292
499
|
const { idempotencyKey, fetchImpl } = opts;
|
|
293
|
-
|
|
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
|
}
|
package/src/credentials.js
CHANGED
|
@@ -3,15 +3,31 @@
|
|
|
3
3
|
// 设计要点:
|
|
4
4
|
// - 文件权限 0600(仅本人可读写)——同机其它账户偷不到 token。
|
|
5
5
|
// - 读损坏 / 不存在 → 返回 null(当作没登录),绝不抛异常炸掉命令。
|
|
6
|
-
// -
|
|
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
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* 凭证目录解析(三级优先级)。
|
|
22
|
+
*
|
|
23
|
+
* 1. opts.dir — 单测注入 / 调用方显式指定
|
|
24
|
+
* 2. LINGER_CONFIG_DIR 环境变量 — 同机多 Agent 隔离的生产手段
|
|
25
|
+
* 3. ~/.linger — 默认(向后兼容)
|
|
26
|
+
*/
|
|
13
27
|
function credDir(opts = {}) {
|
|
14
|
-
|
|
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
|
@@ -82,7 +82,13 @@ function buildDeliverIdemKey(taskId, token, resultText, fileId) {
|
|
|
82
82
|
return `deliver-${taskId}-${callerSub}-${digest}`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// 真实 CLI 版本号读自 package.json(单一事实源·避免硬编码里程碑标签漂移误报·曾把 0.3.x 答成 v0.4.2)
|
|
86
|
+
let PKG_VERSION = '?';
|
|
87
|
+
try {
|
|
88
|
+
PKG_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
89
|
+
} catch { /* 读不到版本不影响命令运行 */ }
|
|
90
|
+
|
|
91
|
+
const HELP = `Linger CLI v${PKG_VERSION}
|
|
86
92
|
|
|
87
93
|
用法:linger <命令> [参数] [--选项]
|
|
88
94
|
|
|
@@ -198,8 +204,19 @@ const HELP = `Linger 命令行工具(v0.4.2 · M7 全量命令)
|
|
|
198
204
|
环境变量:
|
|
199
205
|
LINGER_BASE_URL 覆盖平台 API 地址(默认 https://a2a.linger.chimap.cn)
|
|
200
206
|
LINGER_WEB_BASE 覆盖任务详情页网页根域名(默认 https://a2a.linger.chimap.cn)
|
|
207
|
+
LINGER_CONFIG_DIR 凭证存储目录(默认 ~/.linger)
|
|
208
|
+
同机多 Agent 时为每个 Agent 设不同目录,防止并发续期互相作废
|
|
209
|
+
示例:LINGER_CONFIG_DIR=/home/agentA/.linger-a linger ping
|
|
201
210
|
`;
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* 从凭证里读 agent_id(首次授权时由 runAuthLogin 落盘的 JWT sub 字段)。
|
|
214
|
+
* 不存在 / 凭证为空 → 返回 null(不 crash)。
|
|
215
|
+
*/
|
|
216
|
+
function getAgentIdFromCreds(creds) {
|
|
217
|
+
return (creds && creds.agent_id) || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
/**
|
|
204
221
|
* CLI 总入口。
|
|
205
222
|
* @param {string[]} argv process.argv.slice(2)
|
|
@@ -236,30 +253,31 @@ export async function run(argv, deps = {}) {
|
|
|
236
253
|
|
|
237
254
|
// ── 以下所有命令都需要已登录 ──────────────────────────────
|
|
238
255
|
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return 3;
|
|
248
|
-
}
|
|
249
|
-
token = creds.access_token;
|
|
256
|
+
// v0.4.4 M2 Iter 2:index.js 不再持有裸 token,所有 api 调用传 credOpts,
|
|
257
|
+
// 由 api 层(authedGetWithRefresh / authedPostWithRefresh)负责读凭证 + 自动续期。
|
|
258
|
+
// 先读一次凭证做「是否已登录」检查(不改 api 调用路径)。
|
|
259
|
+
const credOpts = credDir ? { dir: credDir } : {};
|
|
260
|
+
const creds = loadCredentials(credOpts);
|
|
261
|
+
if (!creds || !creds.access_token) {
|
|
262
|
+
err('还没登录。请先运行:linger auth login');
|
|
263
|
+
return 3;
|
|
250
264
|
}
|
|
265
|
+
// agentId 从凭证读取,供 deliver 幂等键等处使用(可能为 null,旧版凭证无此字段)
|
|
266
|
+
const agentId = getAgentIdFromCreds(creds);
|
|
267
|
+
// deliver 幂等键用当前 access_token(JWT sub 字段),续期后新 token 由 api 层持有
|
|
268
|
+
const token = creds.access_token;
|
|
251
269
|
const idemKey = flags.idempotencyKey || null; // null → api 层自动生成 UUID
|
|
252
270
|
|
|
253
271
|
// ── 已有命令(M3)────────────────────────────────────────
|
|
254
272
|
|
|
255
273
|
if (command === 'wallet') {
|
|
256
|
-
const data = await getWallet(baseUrl,
|
|
274
|
+
const data = await getWallet(baseUrl, credOpts, { fetchImpl });
|
|
257
275
|
log(formatWallet(data, { json: flags.json }));
|
|
258
276
|
return 0;
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
if (command === 'hall:browse') {
|
|
262
|
-
const data = await getHall(baseUrl,
|
|
280
|
+
const data = await getHall(baseUrl, credOpts, { fetchImpl });
|
|
263
281
|
log(formatHall(data, { json: flags.json }));
|
|
264
282
|
return 0;
|
|
265
283
|
}
|
|
@@ -267,7 +285,7 @@ export async function run(argv, deps = {}) {
|
|
|
267
285
|
// ── 探活 ─────────────────────────────────────────────────
|
|
268
286
|
|
|
269
287
|
if (command === 'ping') {
|
|
270
|
-
const data = await getCruise(baseUrl,
|
|
288
|
+
const data = await getCruise(baseUrl, credOpts, { fetchImpl });
|
|
271
289
|
log(formatPing(data, { json: flags.json }));
|
|
272
290
|
return 0;
|
|
273
291
|
}
|
|
@@ -288,7 +306,7 @@ export async function run(argv, deps = {}) {
|
|
|
288
306
|
delivery_hours: flags.deliveryHours,
|
|
289
307
|
tags: flags.tags,
|
|
290
308
|
};
|
|
291
|
-
const data = await createTask(baseUrl,
|
|
309
|
+
const data = await createTask(baseUrl, credOpts, taskData, idemKey, { fetchImpl });
|
|
292
310
|
log(formatTaskCreate(data, { json: flags.json }));
|
|
293
311
|
return 0;
|
|
294
312
|
}
|
|
@@ -299,7 +317,7 @@ export async function run(argv, deps = {}) {
|
|
|
299
317
|
if (!taskId) { err('缺少 task_id 参数。用法:linger task apply <task_id>'); return 2; }
|
|
300
318
|
const applyData = {};
|
|
301
319
|
if (flags.message) applyData.message = flags.message;
|
|
302
|
-
const data = await applyTask(baseUrl,
|
|
320
|
+
const data = await applyTask(baseUrl, credOpts, taskId, applyData, idemKey, { fetchImpl });
|
|
303
321
|
log(formatTaskApply(data, { json: flags.json }));
|
|
304
322
|
return 0;
|
|
305
323
|
}
|
|
@@ -310,7 +328,7 @@ export async function run(argv, deps = {}) {
|
|
|
310
328
|
const appId = positionals[3];
|
|
311
329
|
if (!taskId) { err('缺少 task_id 参数。用法:linger task select <task_id> <application_id>'); return 2; }
|
|
312
330
|
if (!appId) { err('缺少 application_id 参数。用法:linger task select <task_id> <application_id>'); return 2; }
|
|
313
|
-
const data = await selectApplication(baseUrl,
|
|
331
|
+
const data = await selectApplication(baseUrl, credOpts, taskId, appId, idemKey, { fetchImpl });
|
|
314
332
|
log(formatTaskSelect(data, { json: flags.json }));
|
|
315
333
|
return 0;
|
|
316
334
|
}
|
|
@@ -327,7 +345,7 @@ export async function run(argv, deps = {}) {
|
|
|
327
345
|
return 2;
|
|
328
346
|
}
|
|
329
347
|
const data = await runtimeAct(
|
|
330
|
-
baseUrl,
|
|
348
|
+
baseUrl, credOpts, 'accept_job',
|
|
331
349
|
{ job_id: taskId, application_id: flags.applicationId },
|
|
332
350
|
idemKey, { fetchImpl }
|
|
333
351
|
);
|
|
@@ -339,7 +357,7 @@ export async function run(argv, deps = {}) {
|
|
|
339
357
|
const taskId = positionals[2];
|
|
340
358
|
if (!taskId) { err('缺少 task_id 参数'); return 2; }
|
|
341
359
|
const data = await runtimeAct(
|
|
342
|
-
baseUrl,
|
|
360
|
+
baseUrl, credOpts, 'start',
|
|
343
361
|
{ job_id: taskId },
|
|
344
362
|
idemKey, { fetchImpl }
|
|
345
363
|
);
|
|
@@ -353,7 +371,7 @@ export async function run(argv, deps = {}) {
|
|
|
353
371
|
if (flags.progress == null) { err('缺少 --progress 参数(0-100)'); return 2; }
|
|
354
372
|
const payload = { job_id: taskId, progress: flags.progress };
|
|
355
373
|
if (flags.note) payload.progress_note = flags.note;
|
|
356
|
-
const data = await runtimeAct(baseUrl,
|
|
374
|
+
const data = await runtimeAct(baseUrl, credOpts, 'heartbeat_job', payload, idemKey, { fetchImpl });
|
|
357
375
|
log(formatRuntimeAct(data, `进度已更新(${flags.progress}%)`, { json: flags.json }));
|
|
358
376
|
return 0;
|
|
359
377
|
}
|
|
@@ -389,7 +407,7 @@ export async function run(argv, deps = {}) {
|
|
|
389
407
|
// 用户可手动传 --idempotency-key 覆盖(重试同一次交付时复用)
|
|
390
408
|
const deliverIdemKey = idemKey || buildDeliverIdemKey(taskId, token, flags.result, flags.fileId);
|
|
391
409
|
|
|
392
|
-
const data = await runtimeAct(baseUrl,
|
|
410
|
+
const data = await runtimeAct(baseUrl, credOpts, 'deliver_job', payload, deliverIdemKey, { fetchImpl });
|
|
393
411
|
log(formatRuntimeAct(data, '已提交交付!(系统自动进入待验收)', { json: flags.json }));
|
|
394
412
|
return 0;
|
|
395
413
|
}
|
|
@@ -401,7 +419,7 @@ export async function run(argv, deps = {}) {
|
|
|
401
419
|
if (flags.reason) payload.reason = flags.reason;
|
|
402
420
|
if (flags.errorCode) payload.error_code = flags.errorCode;
|
|
403
421
|
if (flags.errorMessage) payload.error_message = flags.errorMessage;
|
|
404
|
-
const data = await runtimeAct(baseUrl,
|
|
422
|
+
const data = await runtimeAct(baseUrl, credOpts, 'fail_job', payload, idemKey, { fetchImpl });
|
|
405
423
|
log(formatRuntimeAct(data, '任务已标记失败。', { json: flags.json }));
|
|
406
424
|
return 0;
|
|
407
425
|
}
|
|
@@ -410,7 +428,7 @@ export async function run(argv, deps = {}) {
|
|
|
410
428
|
const taskId = positionals[2];
|
|
411
429
|
if (!taskId) { err('缺少 task_id 参数'); return 2; }
|
|
412
430
|
const data = await runtimeAct(
|
|
413
|
-
baseUrl,
|
|
431
|
+
baseUrl, credOpts, 'confirm',
|
|
414
432
|
{ job_id: taskId },
|
|
415
433
|
idemKey, { fetchImpl }
|
|
416
434
|
);
|
|
@@ -423,7 +441,7 @@ export async function run(argv, deps = {}) {
|
|
|
423
441
|
if (!taskId) { err('缺少 task_id 参数'); return 2; }
|
|
424
442
|
const payload = { job_id: taskId };
|
|
425
443
|
if (flags.reason) payload.reason = flags.reason;
|
|
426
|
-
const data = await runtimeAct(baseUrl,
|
|
444
|
+
const data = await runtimeAct(baseUrl, credOpts, 'reject_job', payload, idemKey, { fetchImpl });
|
|
427
445
|
log(formatRuntimeAct(data, '已拒收,任务进入仲裁流程。', { json: flags.json }));
|
|
428
446
|
return 0;
|
|
429
447
|
}
|
|
@@ -433,7 +451,7 @@ export async function run(argv, deps = {}) {
|
|
|
433
451
|
if (!taskId) { err('缺少 task_id 参数'); return 2; }
|
|
434
452
|
if (!flags.note) { err('缺少 --note 参数(改稿要求)'); return 2; }
|
|
435
453
|
const data = await runtimeAct(
|
|
436
|
-
baseUrl,
|
|
454
|
+
baseUrl, credOpts, 'request_revision',
|
|
437
455
|
{ job_id: taskId, revision_note: flags.note },
|
|
438
456
|
idemKey, { fetchImpl }
|
|
439
457
|
);
|
|
@@ -446,7 +464,7 @@ export async function run(argv, deps = {}) {
|
|
|
446
464
|
if (!taskId) { err('缺少 task_id 参数'); return 2; }
|
|
447
465
|
if (flags.refundPct == null) { err('缺少 --refund-pct 参数(退款百分比 0-100)'); return 2; }
|
|
448
466
|
const data = await runtimeAct(
|
|
449
|
-
baseUrl,
|
|
467
|
+
baseUrl, credOpts, 'accept_partial',
|
|
450
468
|
{ job_id: taskId, refund_pct: flags.refundPct },
|
|
451
469
|
idemKey, { fetchImpl }
|
|
452
470
|
);
|
|
@@ -459,7 +477,7 @@ export async function run(argv, deps = {}) {
|
|
|
459
477
|
if (!taskId) { err('缺少 task_id 参数'); return 2; }
|
|
460
478
|
const payload = { job_id: taskId };
|
|
461
479
|
if (flags.reason) payload.reason = flags.reason;
|
|
462
|
-
const data = await runtimeAct(baseUrl,
|
|
480
|
+
const data = await runtimeAct(baseUrl, credOpts, 'cancel', payload, idemKey, { fetchImpl });
|
|
463
481
|
log(formatRuntimeAct(data, '任务已取消,已托管赏金将退回。', { json: flags.json }));
|
|
464
482
|
return 0;
|
|
465
483
|
}
|
|
@@ -490,7 +508,7 @@ export async function run(argv, deps = {}) {
|
|
|
490
508
|
if (!Array.isArray(skills) || skills.length === 0) {
|
|
491
509
|
err('技能清单必须是非空 JSON 数组:[{"name":"...","description":"..."}]'); return 2;
|
|
492
510
|
}
|
|
493
|
-
const data = await reportSkills(baseUrl,
|
|
511
|
+
const data = await reportSkills(baseUrl, credOpts, skills, idemKey, { fetchImpl });
|
|
494
512
|
log(formatReportSkills(data, { json: flags.json }));
|
|
495
513
|
return 0;
|
|
496
514
|
}
|
|
@@ -513,7 +531,7 @@ export async function run(argv, deps = {}) {
|
|
|
513
531
|
if (priceCents != null) capData.price_cents = priceCents;
|
|
514
532
|
// M5:交付物边界说明(草稿可空,上架前平台要求必填)
|
|
515
533
|
if (flags.deliverableBoundary) capData.deliverable_boundary = flags.deliverableBoundary;
|
|
516
|
-
const data = await createCapability(baseUrl,
|
|
534
|
+
const data = await createCapability(baseUrl, credOpts, capData, idemKey, { fetchImpl });
|
|
517
535
|
log(formatCapabilityCreate(data, { json: flags.json }));
|
|
518
536
|
return 0;
|
|
519
537
|
}
|
|
@@ -522,7 +540,7 @@ export async function run(argv, deps = {}) {
|
|
|
522
540
|
// positionals = ['capability', 'publish', '<cap_id>']
|
|
523
541
|
const capId = positionals[2];
|
|
524
542
|
if (!capId) { err('缺少 capability_id 参数。用法:linger capability publish <cap_id>'); return 2; }
|
|
525
|
-
const data = await publishCapability(baseUrl,
|
|
543
|
+
const data = await publishCapability(baseUrl, credOpts, capId, idemKey, { fetchImpl });
|
|
526
544
|
log(formatCapabilityPublish(data, { json: flags.json }));
|
|
527
545
|
return 0;
|
|
528
546
|
}
|
|
@@ -535,7 +553,7 @@ export async function run(argv, deps = {}) {
|
|
|
535
553
|
const content = positionals[2] || flags.message;
|
|
536
554
|
if (!taskId) { err('缺少 task_id 参数。用法:linger qa <task_id> <消息>'); return 2; }
|
|
537
555
|
if (!content) { err('缺少消息内容。用法:linger qa <task_id> <消息>'); return 2; }
|
|
538
|
-
const data = await postComment(baseUrl,
|
|
556
|
+
const data = await postComment(baseUrl, credOpts, taskId, content, null, idemKey, { fetchImpl });
|
|
539
557
|
log(formatPostComment(data, { json: flags.json }));
|
|
540
558
|
return 0;
|
|
541
559
|
}
|
|
@@ -543,7 +561,7 @@ export async function run(argv, deps = {}) {
|
|
|
543
561
|
if (command === 'qa:list') {
|
|
544
562
|
const taskId = positionals[1];
|
|
545
563
|
if (!taskId) { err('缺少 task_id 参数。用法:linger qa <task_id> --list'); return 2; }
|
|
546
|
-
const data = await getComments(baseUrl,
|
|
564
|
+
const data = await getComments(baseUrl, credOpts, taskId, { fetchImpl });
|
|
547
565
|
log(formatComments(data, { json: flags.json }));
|
|
548
566
|
return 0;
|
|
549
567
|
}
|
|
@@ -554,7 +572,7 @@ export async function run(argv, deps = {}) {
|
|
|
554
572
|
// positionals = ['task', 'show', '<task_id>'] → [2] 是 task_id
|
|
555
573
|
const taskId = positionals[2];
|
|
556
574
|
if (!taskId) { err('缺少 task_id 参数。用法:linger task show <task_id>'); return 2; }
|
|
557
|
-
const data = await getTaskDetail(baseUrl,
|
|
575
|
+
const data = await getTaskDetail(baseUrl, credOpts, taskId, { fetchImpl });
|
|
558
576
|
log(formatTaskDetail(data, { json: flags.json }));
|
|
559
577
|
return 0;
|
|
560
578
|
}
|
|
@@ -563,7 +581,7 @@ export async function run(argv, deps = {}) {
|
|
|
563
581
|
// positionals = ['task', 'files', '<task_id>'] → [2] 是 task_id
|
|
564
582
|
const taskId = positionals[2];
|
|
565
583
|
if (!taskId) { err('缺少 task_id 参数。用法:linger task files <task_id>'); return 2; }
|
|
566
|
-
const data = await getTaskFiles(baseUrl,
|
|
584
|
+
const data = await getTaskFiles(baseUrl, credOpts, taskId, { fetchImpl });
|
|
567
585
|
log(formatTaskFiles(data, { json: flags.json }));
|
|
568
586
|
return 0;
|
|
569
587
|
}
|
|
@@ -572,7 +590,7 @@ export async function run(argv, deps = {}) {
|
|
|
572
590
|
// positionals = ['task', 'download', '<file_id>'] → [2] 是 file_id
|
|
573
591
|
const fileId = positionals[2];
|
|
574
592
|
if (!fileId) { err('缺少 file_id 参数。用法:linger task download <file_id>'); return 2; }
|
|
575
|
-
const data = await getDownloadUrl(baseUrl,
|
|
593
|
+
const data = await getDownloadUrl(baseUrl, credOpts, fileId, { fetchImpl });
|
|
576
594
|
log(formatDownloadUrl(data, { json: flags.json }));
|
|
577
595
|
return 0;
|
|
578
596
|
}
|
|
@@ -592,7 +610,7 @@ export async function run(argv, deps = {}) {
|
|
|
592
610
|
// v0.4.4 · 真实文件名(可选 · 由调用方传 --filename 或留空)
|
|
593
611
|
...(flags.filename ? { filename: flags.filename } : {}),
|
|
594
612
|
};
|
|
595
|
-
const data = await requestUpload(baseUrl,
|
|
613
|
+
const data = await requestUpload(baseUrl, credOpts, uploadData, { fetchImpl });
|
|
596
614
|
log(formatRequestUpload(data, { json: flags.json }));
|
|
597
615
|
return 0;
|
|
598
616
|
}
|
|
@@ -660,7 +678,7 @@ export async function run(argv, deps = {}) {
|
|
|
660
678
|
};
|
|
661
679
|
let uploadResp;
|
|
662
680
|
try {
|
|
663
|
-
uploadResp = await requestUpload(baseUrl,
|
|
681
|
+
uploadResp = await requestUpload(baseUrl, credOpts, uploadData, { fetchImpl });
|
|
664
682
|
} catch (e) {
|
|
665
683
|
// 终态任务后端返回 409,提示友好信息
|
|
666
684
|
const msg = e && e.message ? e.message : String(e);
|
|
@@ -696,7 +714,7 @@ export async function run(argv, deps = {}) {
|
|
|
696
714
|
// ── 自治巡航 ─────────────────────────────────────────────
|
|
697
715
|
|
|
698
716
|
if (command === 'autonomy:tick') {
|
|
699
|
-
const data = await getCruise(baseUrl,
|
|
717
|
+
const data = await getCruise(baseUrl, credOpts, { fetchImpl });
|
|
700
718
|
log(formatCruise(data, { json: flags.json }));
|
|
701
719
|
return 0;
|
|
702
720
|
}
|
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
|
-
|
|
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}`);
|