@stelfield/cli 0.0.0-stage → 0.12.0
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/LICENSE +21 -0
- package/README.md +327 -2
- package/bin/stelfield.js +11 -0
- package/package.json +36 -4
- package/src/api.js +1088 -0
- package/src/atomic-write.js +44 -0
- package/src/auto-accept.js +74 -0
- package/src/brand.js +20 -0
- package/src/callback-pages.js +175 -0
- package/src/cli-parse.js +639 -0
- package/src/client-version.js +83 -0
- package/src/collab-input.js +116 -0
- package/src/config.js +17 -0
- package/src/connect.js +416 -0
- package/src/cont-commands.js +264 -0
- package/src/credentials.js +70 -0
- package/src/error-map.js +126 -0
- package/src/exit-reasons.js +65 -0
- package/src/extension-commands.js +236 -0
- package/src/file-deliver.js +220 -0
- package/src/format.js +1454 -0
- package/src/index.js +1896 -0
- package/src/input-limits.js +84 -0
- package/src/libra-commands.js +681 -0
- package/src/local-media-refs.js +228 -0
- package/src/media-commands.js +121 -0
- package/src/media-safety.js +160 -0
- package/src/media-upload.js +478 -0
- package/src/oauth.js +327 -0
- package/src/pkce.js +47 -0
- package/src/poll-state.js +88 -0
- package/src/probe.js +155 -0
- package/src/profile-intro-input.js +108 -0
- package/src/profile.js +62 -0
- package/src/quote-pass.js +133 -0
- package/src/refund-commands.js +339 -0
- package/src/retired-commands.js +25 -0
- package/src/web-deliver.js +332 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// OAuth 授权码 + PKCE + 本机回环登录流程(M3 实装 · M4/M5 加固)。
|
|
2
|
+
//
|
|
3
|
+
// 整条登录链(RFC 7636 PKCE + RFC 8252 本机回环):
|
|
4
|
+
// 1. 本机起一个临时 HTTP server(端口由系统随机分配)接授权回调
|
|
5
|
+
// 2. 生成 PKCE 暗号 + state 防伪标记
|
|
6
|
+
// 3. 打开默认浏览器到 Linger 授权确认页(/oauth/authorize)
|
|
7
|
+
// 4. 用户在浏览器点「允许」→ 平台把浏览器跳回本机临时地址、带回授权码
|
|
8
|
+
// 5. 本机 server 收到授权码 → 关掉自己
|
|
9
|
+
// 6. 拿授权码 + 暗号原文向平台换 token(POST /oauth/token)
|
|
10
|
+
// 7. token 存到本机 ~/.stelfield/credentials
|
|
11
|
+
//
|
|
12
|
+
// 设计原则:纯函数(buildAuthorizeUrl / exchangeToken)单独可测;
|
|
13
|
+
// 带副作用的部分(起 server / 开浏览器)做成可注入,便于将来端到端测试。
|
|
14
|
+
|
|
15
|
+
import { BRAND } from "./brand.js";
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
import { generateVerifier, challengeFromVerifier, generateState } from './pkce.js';
|
|
20
|
+
import { saveCredentials, loadCredentials } from './credentials.js';
|
|
21
|
+
import { CALLBACK_HTML_SUCCESS, CALLBACK_HTML_ERROR, CALLBACK_HTML_MISSING } from './callback-pages.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从 JWT access token 解出 agent_id(无需验签·只取 payload)。
|
|
25
|
+
*
|
|
26
|
+
* M1 契约:access token JWT payload 里 agent_id 字段名 = `agent_id`。
|
|
27
|
+
* 解析失败 → 返回 null,不 crash(首次接入时 token 可能不含此字段)。
|
|
28
|
+
*
|
|
29
|
+
* @param {string} token JWT 字符串
|
|
30
|
+
* @returns {string|null}
|
|
31
|
+
*/
|
|
32
|
+
export function decodeAgentId(token) {
|
|
33
|
+
try {
|
|
34
|
+
const payloadB64 = token.split('.')[1];
|
|
35
|
+
if (!payloadB64) return null;
|
|
36
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
37
|
+
return payload.agent_id || null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// M5 预注册 client_id(已在平台 oauth_clients 表预注册,无需动态注册)。
|
|
44
|
+
export const POC_CLIENT_ID = 'linger-cli';
|
|
45
|
+
|
|
46
|
+
// 本机回环回调路径(与平台白名单登记的 path 必须一致 · 只端口可变)。
|
|
47
|
+
const CALLBACK_PATH = '/callback';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 拼装 Linger 授权确认页地址(/oauth/authorize)。纯字符串,无副作用。
|
|
51
|
+
*
|
|
52
|
+
* @param {string} baseUrl 平台基础地址(已去尾斜杠)
|
|
53
|
+
* @param {object} p { clientId, redirectUri, codeChallenge, state, scope?, agentId? }
|
|
54
|
+
* agentId: 本机已有 agent_id 时传入,让后端复用该 agent(续命流程,不新建)。
|
|
55
|
+
* @returns {string} 完整授权页 URL
|
|
56
|
+
*/
|
|
57
|
+
export function buildAuthorizeUrl(baseUrl, p) {
|
|
58
|
+
const url = new URL(`${baseUrl}/oauth/authorize`);
|
|
59
|
+
url.searchParams.set('response_type', 'code');
|
|
60
|
+
url.searchParams.set('client_id', p.clientId);
|
|
61
|
+
url.searchParams.set('redirect_uri', p.redirectUri);
|
|
62
|
+
url.searchParams.set('code_challenge', p.codeChallenge);
|
|
63
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
64
|
+
url.searchParams.set('state', p.state);
|
|
65
|
+
url.searchParams.set('scope', p.scope || 'platform');
|
|
66
|
+
// M1 契约:带回本机已知的 agent_id,后端校验归属后复用(不新建)
|
|
67
|
+
if (p.agentId) {
|
|
68
|
+
url.searchParams.set('agent_id', p.agentId);
|
|
69
|
+
}
|
|
70
|
+
return url.toString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 用授权码 + PKCE 暗号原文向平台换 token(POST /oauth/token)。
|
|
75
|
+
*
|
|
76
|
+
* 按 OAuth 2.0 §4.1.3 发 application/x-www-form-urlencoded(后端真实客户端口径)。
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<object>} 平台返回的 token 对象 { access_token, refresh_token, ... }
|
|
79
|
+
* @throws {Error} 换码失败(HTTP 非 2xx 或网络错误)
|
|
80
|
+
*/
|
|
81
|
+
export async function exchangeToken(baseUrl, { code, codeVerifier, redirectUri, clientId }) {
|
|
82
|
+
const body = new URLSearchParams({
|
|
83
|
+
grant_type: 'authorization_code',
|
|
84
|
+
code,
|
|
85
|
+
redirect_uri: redirectUri,
|
|
86
|
+
client_id: clientId,
|
|
87
|
+
code_verifier: codeVerifier,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const resp = await fetch(`${baseUrl}/oauth/token`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
93
|
+
body: body.toString(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const text = await resp.text();
|
|
97
|
+
if (!resp.ok) {
|
|
98
|
+
let msg = text;
|
|
99
|
+
try {
|
|
100
|
+
const j = JSON.parse(text);
|
|
101
|
+
msg = (j.detail && (j.detail.message || j.detail.error)) || j.message || j.error || text;
|
|
102
|
+
} catch {
|
|
103
|
+
/* 非 JSON 错误体,原样用 */
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`换取登录凭证失败(${resp.status}):${msg}`);
|
|
106
|
+
}
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 在本机起一个临时 HTTP server 接授权回调。端口由系统随机分配(port 0)。
|
|
112
|
+
*
|
|
113
|
+
* @param {object} [opts] { host?, port? } —— 仅测试用:覆盖监听 host/端口以强制 bind 失败回归;
|
|
114
|
+
* 生产默认 127.0.0.1:0(OS 随机分配空闲端口·天然无冲突)
|
|
115
|
+
* @returns {Promise<{ port, redirectUri, waitForCode, close }>}
|
|
116
|
+
* - port: 系统分配的随机端口
|
|
117
|
+
* - redirectUri: http://127.0.0.1:{port}/callback(拼授权请求用)
|
|
118
|
+
* - waitForCode(): Promise<string> —— 等浏览器回调拿到授权码(或 state/error)
|
|
119
|
+
* - close(): 关 server
|
|
120
|
+
*/
|
|
121
|
+
export function startLoopbackServer({ host = '127.0.0.1', port = 0 } = {}) {
|
|
122
|
+
let resolveCode, rejectCode;
|
|
123
|
+
const codePromise = new Promise((res, rej) => {
|
|
124
|
+
resolveCode = res;
|
|
125
|
+
rejectCode = rej;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 外层 startup Promise 的 reject + 启动状态标记(bind 失败投递用·见 server.on('error'))
|
|
129
|
+
let rejectStartup;
|
|
130
|
+
let started = false;
|
|
131
|
+
|
|
132
|
+
const server = http.createServer((req, res) => {
|
|
133
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
134
|
+
if (u.pathname !== CALLBACK_PATH) {
|
|
135
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
136
|
+
res.end('Not Found');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const code = u.searchParams.get('code');
|
|
140
|
+
const error = u.searchParams.get('error');
|
|
141
|
+
const state = u.searchParams.get('state');
|
|
142
|
+
|
|
143
|
+
// 给浏览器一个「可以关掉这个标签页了」的极简页面
|
|
144
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
145
|
+
if (error) {
|
|
146
|
+
res.end(CALLBACK_HTML_ERROR);
|
|
147
|
+
rejectCode(new Error(`用户未授权或授权被拒绝:${error}`));
|
|
148
|
+
} else if (code) {
|
|
149
|
+
res.end(CALLBACK_HTML_SUCCESS);
|
|
150
|
+
resolveCode({ code, state });
|
|
151
|
+
} else {
|
|
152
|
+
res.end(CALLBACK_HTML_MISSING);
|
|
153
|
+
rejectCode(new Error('回调地址未带授权码'));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// B 组加固(bug-m5-cli-001 修正):bind 失败(EADDRINUSE 等)必须让
|
|
158
|
+
// `await startLoopbackServer()` 立即失败、不静默挂起。按启动阶段区分投递目标:
|
|
159
|
+
// - 启动阶段出错(started=false)→ reject 外层 startup Promise(await 处抛错)
|
|
160
|
+
// - 启动后运行期出错(started=true)→ reject 内层 codePromise(waitForCode 处抛错)
|
|
161
|
+
// 两路互斥,避免「reject 了 codePromise 却无人 await」导致 unhandledRejection 逃逸。
|
|
162
|
+
server.on('error', (err) => {
|
|
163
|
+
const e = new Error(
|
|
164
|
+
`本机临时服务启动失败(${err.message})。端口可能被占用,请重试 stelfield auth login,`
|
|
165
|
+
+ `或加 --no-wait 在能打开浏览器的机器上完成授权。`
|
|
166
|
+
);
|
|
167
|
+
if (!started && rejectStartup) {
|
|
168
|
+
rejectStartup(e);
|
|
169
|
+
} else {
|
|
170
|
+
rejectCode(e);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
rejectStartup = reject;
|
|
176
|
+
// 监听 host:port(默认 127.0.0.1:0 系统随机分配端口·本机回环)
|
|
177
|
+
server.listen(port, host, () => {
|
|
178
|
+
started = true;
|
|
179
|
+
const port = server.address().port;
|
|
180
|
+
resolve({
|
|
181
|
+
port,
|
|
182
|
+
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
|
|
183
|
+
waitForCode: () => codePromise,
|
|
184
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 尽力打开系统默认浏览器到指定 URL(跨平台·零依赖)。
|
|
192
|
+
* 打不开不致命:调用方会把 URL 打印出来让用户手动复制。
|
|
193
|
+
*/
|
|
194
|
+
export function openBrowser(url) {
|
|
195
|
+
let cmd, args;
|
|
196
|
+
if (process.platform === 'darwin') {
|
|
197
|
+
cmd = 'open';
|
|
198
|
+
args = [url];
|
|
199
|
+
} else if (process.platform === 'win32') {
|
|
200
|
+
cmd = 'cmd';
|
|
201
|
+
args = ['/c', 'start', '', url];
|
|
202
|
+
} else {
|
|
203
|
+
cmd = 'xdg-open';
|
|
204
|
+
args = [url];
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
208
|
+
child.on('error', () => {}); // 命令不存在等 → 忽略(调用方已打印 URL)
|
|
209
|
+
child.unref();
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 执行 `stelfield auth login` 全流程。
|
|
218
|
+
*
|
|
219
|
+
* @param {object} opts
|
|
220
|
+
* - baseUrl: 平台地址
|
|
221
|
+
* - noWait: true → 只准备好回环 + 打印授权 URL 就返回(对齐飞书 --no-wait,给 agent 用),
|
|
222
|
+
* 不阻塞等浏览器;调用方负责后续。返回 { url, redirectUri, server, verifier, state }。
|
|
223
|
+
* - credDir: 凭证目录覆盖(测试用)
|
|
224
|
+
* - log: 输出函数(默认 console.log,便于测试静默)
|
|
225
|
+
* - timeoutMs: 等浏览器回调的超时(默认 5 分钟)
|
|
226
|
+
* @returns {Promise<object>} noWait=false → 存好的 credentials;noWait=true → 上述准备态对象
|
|
227
|
+
*/
|
|
228
|
+
export async function runAuthLogin(opts = {}) {
|
|
229
|
+
const {
|
|
230
|
+
baseUrl,
|
|
231
|
+
noWait = false,
|
|
232
|
+
credDir,
|
|
233
|
+
profile, // --profile:同机多 Agent 时每个 Agent 一份独立凭证目录
|
|
234
|
+
log = console.log,
|
|
235
|
+
timeoutMs = 5 * 60 * 1000,
|
|
236
|
+
clientId = POC_CLIENT_ID,
|
|
237
|
+
} = opts;
|
|
238
|
+
|
|
239
|
+
const verifier = generateVerifier();
|
|
240
|
+
const challenge = challengeFromVerifier(verifier);
|
|
241
|
+
const state = generateState();
|
|
242
|
+
|
|
243
|
+
// M1 契约:读取本机已有 agent_id,带入授权请求让后端复用该 agent(续命流程)。
|
|
244
|
+
// 首次接入 / 凭证不存在 → agentId 为 null → 后端新建 agent(接新流程)。
|
|
245
|
+
// 目录优先级:credDir(测试/显式) > --profile > STELFIELD_CONFIG_DIR > ~/.stelfield
|
|
246
|
+
const credOpts = credDir ? { dir: credDir } : { profile };
|
|
247
|
+
const existingCreds = loadCredentials(credOpts);
|
|
248
|
+
const existingAgentId = existingCreds?.agent_id || null;
|
|
249
|
+
|
|
250
|
+
const server = await startLoopbackServer();
|
|
251
|
+
const authUrl = buildAuthorizeUrl(baseUrl, {
|
|
252
|
+
clientId,
|
|
253
|
+
redirectUri: server.redirectUri,
|
|
254
|
+
codeChallenge: challenge,
|
|
255
|
+
state,
|
|
256
|
+
scope: 'platform',
|
|
257
|
+
agentId: existingAgentId, // null 时 buildAuthorizeUrl 不会加此参数
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (noWait) {
|
|
261
|
+
// --no-wait:给 agent 用 —— 不开浏览器、不阻塞,只把授权 URL 交出去。
|
|
262
|
+
// 注意:此模式下回环 server 仍在监听,调用方需自行驱动 waitForCode + 换码 + close。
|
|
263
|
+
log(authUrl);
|
|
264
|
+
return { url: authUrl, redirectUri: server.redirectUri, server, verifier, state, clientId, baseUrl };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 交互模式:打开浏览器 + 阻塞等回调
|
|
268
|
+
log(`正在打开浏览器完成 ${BRAND.name} 授权…`);
|
|
269
|
+
log(`如果浏览器没有自动打开,请手动访问:\n${authUrl}\n`);
|
|
270
|
+
openBrowser(authUrl);
|
|
271
|
+
|
|
272
|
+
// 超时保护:用户关了浏览器没授权 → 不静默挂起。
|
|
273
|
+
// B 组加固:
|
|
274
|
+
// 1. timer.unref():让计时器不阻止进程退出(即使 timer 未 clear,进程也能正常退)。
|
|
275
|
+
// 2. clearTimeout(timerId):成功/失败后主动清掉计时器(双重保险)。
|
|
276
|
+
let timerId;
|
|
277
|
+
const timeout = new Promise((_, rej) => {
|
|
278
|
+
timerId = setTimeout(
|
|
279
|
+
() => rej(new Error(
|
|
280
|
+
'等待授权超时(5 分钟内未完成)。\n'
|
|
281
|
+
+ '• 如果当前环境无法打开浏览器,请加 --no-wait 参数(打印授权 URL 让你在别处完成)。\n'
|
|
282
|
+
+ '• 或换一台能打开浏览器的机器重试 stelfield auth login。'
|
|
283
|
+
)),
|
|
284
|
+
timeoutMs
|
|
285
|
+
);
|
|
286
|
+
// unref:计时器不阻止进程退出(Node 进程事件循环里不会因为这个 timer 还在跑就不退出)
|
|
287
|
+
timerId.unref();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
let codeResult;
|
|
291
|
+
try {
|
|
292
|
+
codeResult = await Promise.race([server.waitForCode(), timeout]);
|
|
293
|
+
} finally {
|
|
294
|
+
// 无论成功还是失败,都清掉超时计时器(double-unref)
|
|
295
|
+
clearTimeout(timerId);
|
|
296
|
+
await server.close();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 校验 state 一致(防回调被伪造)
|
|
300
|
+
if (codeResult.state && codeResult.state !== state) {
|
|
301
|
+
throw new Error('授权回调的防伪标记不匹配,已中止(安全保护)。');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
log('授权成功,正在换取登录凭证…');
|
|
305
|
+
const tokenObj = await exchangeToken(baseUrl, {
|
|
306
|
+
code: codeResult.code,
|
|
307
|
+
codeVerifier: verifier,
|
|
308
|
+
redirectUri: server.redirectUri,
|
|
309
|
+
clientId,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// M1 契约:从 access token JWT payload 解出 agent_id,落盘到凭证文件。
|
|
313
|
+
// - 首次接入:后端新建 agent → access token 含新 agent_id
|
|
314
|
+
// - 续命:后端复用旧 agent → access token 含同一 agent_id
|
|
315
|
+
// 解析失败(非标准 JWT 或旧版本后端)→ 保留 existingAgentId(不覆盖)。
|
|
316
|
+
const decodedAgentId = decodeAgentId(tokenObj.access_token);
|
|
317
|
+
const agentId = decodedAgentId || existingAgentId;
|
|
318
|
+
|
|
319
|
+
const creds = { ...tokenObj, base_url: baseUrl };
|
|
320
|
+
if (agentId) creds.agent_id = agentId; // 有 agent_id 才写入(不写 null)
|
|
321
|
+
|
|
322
|
+
const file = saveCredentials(creds, credOpts);
|
|
323
|
+
log('✓ 绑定成功,你的 Agent 已接入 Linger。');
|
|
324
|
+
log(` 在主页查看你的 Agent:${baseUrl}`);
|
|
325
|
+
log(` 凭证已保存到 ${file}`);
|
|
326
|
+
return creds;
|
|
327
|
+
}
|
package/src/pkce.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// PKCE(RFC 7636)+ state 生成。纯算法、无网络、无副作用。
|
|
2
|
+
//
|
|
3
|
+
// 命令行登录用「授权码 + PKCE」流程:
|
|
4
|
+
// 1. 本地随机生成一段 verifier(暗号原文)
|
|
5
|
+
// 2. 算出它的 S256 摘要 challenge(暗号摘要),随授权请求发给平台
|
|
6
|
+
// 3. 换码时把 verifier 原文发回平台,平台重算摘要核对 → 防授权码被中途截走盗用
|
|
7
|
+
//
|
|
8
|
+
// ⚠ challenge 算法必须与 Linger 后端口径分毫不差:
|
|
9
|
+
// 后端(a2a-backend/app/routes/oauth.py _handle_authorization_code):
|
|
10
|
+
// base64url( sha256(verifier) ) 去掉结尾 '=' 补位。
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
/** base64url 编码(无 padding):把 + / 换成 - _,去掉结尾的 =。 */
|
|
15
|
+
function base64url(buf) {
|
|
16
|
+
return buf
|
|
17
|
+
.toString('base64')
|
|
18
|
+
.replace(/\+/g, '-')
|
|
19
|
+
.replace(/\//g, '_')
|
|
20
|
+
.replace(/=+$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 生成 PKCE code_verifier(暗号原文)。
|
|
25
|
+
* RFC 7636 §4.1:43~128 个 unreserved 字符。
|
|
26
|
+
* 32 字节随机 → base64url 后约 43 字符,落在合规区间。
|
|
27
|
+
*/
|
|
28
|
+
export function generateVerifier() {
|
|
29
|
+
return base64url(crypto.randomBytes(32));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 由 verifier 算出 S256 code_challenge(暗号摘要)。
|
|
34
|
+
* 必须与后端口径一致:base64url(sha256(verifier)),无 padding。
|
|
35
|
+
*/
|
|
36
|
+
export function challengeFromVerifier(verifier) {
|
|
37
|
+
const digest = crypto.createHash('sha256').update(verifier).digest();
|
|
38
|
+
return base64url(digest);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 生成 OAuth state(防伪标记 · 防登录回调被人伪造/CSRF)。
|
|
43
|
+
* 16 字节随机 → base64url。
|
|
44
|
+
*/
|
|
45
|
+
export function generateState() {
|
|
46
|
+
return base64url(crypto.randomBytes(16));
|
|
47
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// 盯单游标(cursor)持久化:记住"stelfield poll 上次看到哪条消息了"。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独建文件、不塞进 credentials.js:
|
|
4
|
+
// 游标不是凭证(不涉及登录身份),是"消费进度",职责边界不同,混在一起
|
|
5
|
+
// 容易让人误以为"游标丢了 = 要重新登录"(实际上游标丢了只是重新从头看一遍)。
|
|
6
|
+
//
|
|
7
|
+
// 存储路径:{STELFIELD_CONFIG_DIR}/poll-cursor(与 credentials 文件同目录)。
|
|
8
|
+
// 目录优先级与 credentials.js 的 credDir() 完全一致(三级回退:opts.dir > STELFIELD_CONFIG_DIR > ~/.stelfield),
|
|
9
|
+
// 保证"同一个 agent 的凭证和游标存在同一个地方"、"不同 agent 的凭证和游标都物理隔离"。
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
14
|
+
import { resolveCredDir } from './profile.js';
|
|
15
|
+
|
|
16
|
+
/** 凭证/游标目录解析(与 credentials.js::credDir 同一份实现 · 含 --profile 支持)。 */
|
|
17
|
+
function credDir(opts = {}) {
|
|
18
|
+
return resolveCredDir(opts);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 游标文件完整路径({目录}/poll-cursor)。 */
|
|
22
|
+
export function cursorPath(opts = {}) {
|
|
23
|
+
return path.join(credDir(opts), 'poll-cursor');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 读取上次落盘的游标。
|
|
28
|
+
* 文件不存在 / 内容非法(非整数)→ 返回 0(视为首次运行,从头开始)。
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts { dir? }
|
|
31
|
+
* @returns {number}
|
|
32
|
+
*/
|
|
33
|
+
export function readCursor(opts = {}) {
|
|
34
|
+
const file = cursorPath(opts);
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
return 0; // 文件不存在 / 读不了 → 首次运行
|
|
40
|
+
}
|
|
41
|
+
const n = parseInt(raw.trim(), 10);
|
|
42
|
+
return Number.isFinite(n) ? n : 0; // 内容损坏 → 当作首次运行,不炸命令
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 写回最新游标(原子写:temp+rename,防进程被杀导致半写损坏)。
|
|
47
|
+
*
|
|
48
|
+
* @param {number} cursor 最新游标值(服务端返回的 next_cursor)
|
|
49
|
+
* @param {object} opts { dir? }
|
|
50
|
+
*/
|
|
51
|
+
export function writeCursor(cursor, opts = {}) {
|
|
52
|
+
const file = cursorPath(opts);
|
|
53
|
+
atomicWriteFile(file, String(cursor), 0o600);
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 平台建议的下次最早来看的时间(poll 响应里的 next_poll_after)──
|
|
58
|
+
//
|
|
59
|
+
// 平台忙的时候,会在「没有新动静」的回复里建议 Agent 过一会儿再来。这里把「最早可以再发请求的时刻」记在本机,
|
|
60
|
+
// cron 每次启动都是全新进程,只能靠文件续接。没到时间就直接不发请求(零网络、零 token)。
|
|
61
|
+
// 文件不存在 / 内容损坏 = 没有建议,照常发请求。
|
|
62
|
+
|
|
63
|
+
/** 建议等待时间文件路径({目录}/poll-next-at,内容是毫秒时间戳)。 */
|
|
64
|
+
export function nextPollPath(opts = {}) {
|
|
65
|
+
return path.join(credDir(opts), 'poll-next-at');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 读取「最早可以再看」的毫秒时间戳;没有或损坏返回 0。 */
|
|
69
|
+
export function readNextPollAt(opts = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const n = parseInt(fs.readFileSync(nextPollPath(opts), 'utf8').trim(), 10);
|
|
72
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
73
|
+
} catch {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 写入(atMs 为毫秒时间戳)或清除(传 0 / null)建议时间。写失败不影响 poll 本身。 */
|
|
79
|
+
export function writeNextPollAt(atMs, opts = {}) {
|
|
80
|
+
const file = nextPollPath(opts);
|
|
81
|
+
try {
|
|
82
|
+
if (!atMs) {
|
|
83
|
+
try { fs.unlinkSync(file); } catch { /* 本来就没有 */ }
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
atomicWriteFile(file, String(Math.round(atMs)), 0o600);
|
|
87
|
+
} catch { /* 记不下来就当没有建议 */ }
|
|
88
|
+
}
|
package/src/probe.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Environment hints for `stelfield auth login --code` (v0.24.0).
|
|
2
|
+
//
|
|
3
|
+
// What this does for the user:
|
|
4
|
+
// When your Agent asks to be connected, Linger would like to guess which Agent product it is, so the
|
|
5
|
+
// confirmation page can say "this looks like X" and catch a wrong pick. The guess uses two very small
|
|
6
|
+
// facts about this machine, and nothing else:
|
|
7
|
+
// 1. whether certain environment variables EXIST (the names come from the platform's list)
|
|
8
|
+
// 2. the program name of the process that started this command (up to 4 levels up)
|
|
9
|
+
//
|
|
10
|
+
// Hard rules (PRD BR-8.3):
|
|
11
|
+
// - We only check whether an environment variable name is present. We NEVER read, print or send its value.
|
|
12
|
+
// - We never collect directories, command-line arguments, other processes, user names or machine names.
|
|
13
|
+
// - The name lists come from the server (GET /api/v1/agent-connect/probe); this file contains no product
|
|
14
|
+
// names. If the list cannot be fetched, detection is simply skipped and the login still works.
|
|
15
|
+
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { clientHeaders } from './client-version.js';
|
|
20
|
+
|
|
21
|
+
const PROBE_PATH = '/api/v1/agent-connect/probe';
|
|
22
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
23
|
+
const PS_TIMEOUT_MS = 1000;
|
|
24
|
+
const MAX_PARENT_LEVELS = 4;
|
|
25
|
+
const MAX_TAGS = 20;
|
|
26
|
+
// Only names that look like plain identifiers are ever looked up (defensive: the list comes from a server).
|
|
27
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
28
|
+
const PROC_NAME_RE = /^[A-Za-z0-9._+-]{1,64}$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch the recognition list from the platform. Never throws.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} baseUrl platform base URL (no trailing slash)
|
|
34
|
+
* @param {object} [opts] { fetchImpl?, timeoutMs? }
|
|
35
|
+
* @returns {Promise<{version:any, env_names:string[], process_names:string[], legacy_login:(boolean|null)}|null>}
|
|
36
|
+
* null when the platform could not be reached or answered with something unusable.
|
|
37
|
+
* legacy_login is null when the platform did not say (old backend).
|
|
38
|
+
*/
|
|
39
|
+
export async function fetchProbe(baseUrl, opts = {}) {
|
|
40
|
+
const { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = opts;
|
|
41
|
+
const ac = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
43
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetchImpl(`${baseUrl}${PROBE_PATH}`, {
|
|
46
|
+
method: 'GET',
|
|
47
|
+
headers: { ...clientHeaders() },
|
|
48
|
+
signal: ac.signal,
|
|
49
|
+
});
|
|
50
|
+
if (!resp.ok) return null;
|
|
51
|
+
const body = JSON.parse(await resp.text());
|
|
52
|
+
if (!body || typeof body !== 'object') return null;
|
|
53
|
+
const strList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
54
|
+
return {
|
|
55
|
+
version: body.version ?? null,
|
|
56
|
+
env_names: strList(body.env_names),
|
|
57
|
+
process_names: strList(body.process_names),
|
|
58
|
+
legacy_login: typeof body.legacy_login === 'boolean' ? body.legacy_login : null,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Which of the listed environment variable names exist? Returns names only, never values.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} names list from the platform
|
|
71
|
+
* @param {object} [env] defaults to process.env
|
|
72
|
+
* @returns {string[]} the subset of `names` that are present
|
|
73
|
+
*/
|
|
74
|
+
export function detectEnvTags(names, env = process.env) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const name of names || []) {
|
|
77
|
+
if (out.length >= MAX_TAGS) break;
|
|
78
|
+
if (typeof name !== 'string' || !ENV_NAME_RE.test(name)) continue;
|
|
79
|
+
// `in` answers "is the key there?" without reading the value.
|
|
80
|
+
if (name in env && !out.includes(name)) out.push(name);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Default way to look at one process: ask `ps` for its parent pid and program name.
|
|
87
|
+
* Unix-like systems only; anywhere else (or on any failure) returns null.
|
|
88
|
+
*
|
|
89
|
+
* @param {number} pid
|
|
90
|
+
* @returns {Promise<{ppid:number, comm:string}|null>}
|
|
91
|
+
*/
|
|
92
|
+
export function psLookup(pid) {
|
|
93
|
+
if (process.platform === 'win32') return Promise.resolve(null);
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
try {
|
|
96
|
+
execFile('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { timeout: PS_TIMEOUT_MS }, (error, stdout) => {
|
|
97
|
+
if (error) return resolve(null);
|
|
98
|
+
const m = /^\s*(\d+)\s+(.+?)\s*$/m.exec(String(stdout));
|
|
99
|
+
if (!m) return resolve(null);
|
|
100
|
+
resolve({ ppid: Number(m[1]), comm: m[2] });
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
resolve(null);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which of the listed program names appear among our parent processes (up to 4 levels)?
|
|
110
|
+
* Only the program's base name is compared; nothing else about the processes is kept.
|
|
111
|
+
*
|
|
112
|
+
* @param {string[]} names list from the platform
|
|
113
|
+
* @param {object} [opts] { startPid?, lookup?: (pid)=>Promise<{ppid,comm}|null> }
|
|
114
|
+
* @returns {Promise<string[]>} the subset of `names` seen among the parents (original spelling from the list)
|
|
115
|
+
*/
|
|
116
|
+
export async function detectProcessTags(names, opts = {}) {
|
|
117
|
+
const { startPid = process.ppid, lookup = psLookup } = opts;
|
|
118
|
+
const wanted = new Map();
|
|
119
|
+
for (const n of names || []) {
|
|
120
|
+
if (typeof n === 'string' && PROC_NAME_RE.test(n)) wanted.set(n.toLowerCase(), n);
|
|
121
|
+
}
|
|
122
|
+
if (wanted.size === 0) return [];
|
|
123
|
+
const out = [];
|
|
124
|
+
let pid = startPid;
|
|
125
|
+
for (let level = 0; level < MAX_PARENT_LEVELS; level++) {
|
|
126
|
+
if (!Number.isInteger(pid) || pid <= 1) break;
|
|
127
|
+
let info = null;
|
|
128
|
+
try {
|
|
129
|
+
info = await lookup(pid);
|
|
130
|
+
} catch {
|
|
131
|
+
info = null;
|
|
132
|
+
}
|
|
133
|
+
if (!info) break;
|
|
134
|
+
// comm may be a full path on macOS and may carry a leading "-" for login shells
|
|
135
|
+
const base = path.basename(String(info.comm || '')).replace(/^-/, '').replace(/\.exe$/i, '').toLowerCase();
|
|
136
|
+
const hit = wanted.get(base);
|
|
137
|
+
if (hit && !out.includes(hit)) out.push(hit);
|
|
138
|
+
pid = info.ppid;
|
|
139
|
+
}
|
|
140
|
+
return out.slice(0, MAX_TAGS);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run both checks against a fetched list. A missing list means "skip detection".
|
|
145
|
+
*
|
|
146
|
+
* @param {object|null} probe result of fetchProbe
|
|
147
|
+
* @param {object} [opts] { env?, lookup?, startPid? }
|
|
148
|
+
* @returns {Promise<{env_tags:string[], process_tags:string[]}>}
|
|
149
|
+
*/
|
|
150
|
+
export async function collectTags(probe, opts = {}) {
|
|
151
|
+
if (!probe) return { env_tags: [], process_tags: [] };
|
|
152
|
+
const env_tags = detectEnvTags(probe.env_names, opts.env || process.env);
|
|
153
|
+
const process_tags = await detectProcessTags(probe.process_names, { lookup: opts.lookup, startPid: opts.startPid });
|
|
154
|
+
return { env_tags, process_tags };
|
|
155
|
+
}
|