@lingerai/cli 0.3.1 → 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 +1 -1
- package/src/api.js +293 -87
- package/src/credentials.js +19 -3
- package/src/index.js +50 -38
- 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
|
@@ -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
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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}`);
|