@dreamor/atlas-cli 0.7.22 → 0.7.23
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/README.md +5 -1
- package/dist/adapters/atlas/auth/index.js +2 -1
- package/dist/adapters/atlas/auth/login.js +44 -4
- package/dist/adapters/atlas/auth/portable.js +193 -0
- package/dist/adapters/atlas/auth/session.js +62 -10
- package/dist/adapters/atlas/cli.js +50 -1
- package/dist/adapters/atlas/commands/auth.js +1 -1
- package/dist/adapters/atlas/daemon/index.js +95 -14
- package/dist/adapters/atlas/util/cidr.js +114 -0
- package/dist/adapters/atlas/util/environment.js +152 -0
- package/dist/adapters/atlas/util/portable-store.js +153 -0
- package/dist/adapters/atlas/util/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ atlas <cmd> --describe
|
|
|
36
36
|
### 3. 鉴权
|
|
37
37
|
|
|
38
38
|
- `atlas auth status` 返回 `data.loggedIn: boolean`
|
|
39
|
+
- `atlas auth refresh` — 无头模式静默刷新 access_token(依赖 SSO_REFRESH_TOKEN);session 过期时 agent 先试此命令
|
|
39
40
|
- agent **不能自动执行** `atlas auth login`(弹浏览器做 SSO,非终端环境报 exit 64)
|
|
40
41
|
|
|
41
42
|
### 4. 退出码速查
|
|
@@ -43,10 +44,13 @@ atlas <cmd> --describe
|
|
|
43
44
|
| 码 | 含义 | agent 应对 |
|
|
44
45
|
|----|------|-----------|
|
|
45
46
|
| 0 | 成功 | 继续 |
|
|
46
|
-
| 2 | 会话过期 |
|
|
47
|
+
| 2 | 会话过期 | 先试 `atlas auth refresh`,失败再让用户手动 login |
|
|
47
48
|
| 3 | API 错误 | 不重试 |
|
|
49
|
+
| 4 | 项目匹配歧义 | 用 `atlas find project` 让用户挑 |
|
|
50
|
+
| 5 | 项目未找到 | 改关键词重试 1 次后报告 |
|
|
48
51
|
| 6 | 限流 | 退避 10s 重试 ≤1 次 |
|
|
49
52
|
| 7 | 网络错误 | 退避 5s 重试 ≤1 次 |
|
|
53
|
+
| 8 | 升级异常 | 忽略 |
|
|
50
54
|
| 64 | 配置/未实现 | 不重试 |
|
|
51
55
|
| 65 | 输出超限 | 改用 `--out <file>` |
|
|
52
56
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { loginCmd, statusCmd } from './login.js';
|
|
1
|
+
export { loginCmd, statusCmd, sandboxLoginGuide } from './login.js';
|
|
2
2
|
export { refreshCmd, refreshSession } from './refresh.js';
|
|
3
3
|
export { getSessionToken, getBanmaIdentity, readBanmaIdentity } from './session.js';
|
|
4
|
+
export { exportCmd, importCmd, doctorCmd, recommendPath } from './portable.js';
|
|
@@ -111,8 +111,35 @@ export async function loginStatus() {
|
|
|
111
111
|
}
|
|
112
112
|
return { loggedIn: false };
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* 沙盒登录降级文案(对齐 dws 三条路径)。
|
|
116
|
+
* 纯函数,便于测试。
|
|
117
|
+
*/
|
|
118
|
+
export function sandboxLoginGuide(agentCode, reason) {
|
|
119
|
+
const head = reason ?? '无法拉起浏览器完成 SSO';
|
|
120
|
+
const agent = agentCode ? `(agent=${agentCode}, no DISPLAY/TTY)` : '(无浏览器环境)';
|
|
121
|
+
return `检测到当前是沙盒环境${agent},${head}。请任选一种方式完成登录:
|
|
122
|
+
|
|
123
|
+
[推荐] 从本地宿主机导出登录态:
|
|
124
|
+
(本地) atlas auth export --base64
|
|
125
|
+
(沙盒) export ATLAS_COOKIES_B64=<粘贴上一步输出>
|
|
126
|
+
atlas auth doctor # 确认识别成功
|
|
127
|
+
|
|
128
|
+
[网络可达宿主机] 复用 daemon:
|
|
129
|
+
(本地) atlas daemon
|
|
130
|
+
(沙盒) export ATLAS_DAEMON_URL=http://host.docker.internal:8765
|
|
131
|
+
export ATLAS_DAEMON_TOKEN=<~/.atlas/daemon.token 内容>
|
|
132
|
+
atlas auth status # 走 daemon 透明鉴权
|
|
133
|
+
|
|
134
|
+
[文件传输] 直接拷贝 bundle:
|
|
135
|
+
(本地) atlas auth export -o /tmp/atlas-auth.tar.gz
|
|
136
|
+
(沙盒) atlas auth import -i /tmp/atlas-auth.tar.gz
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
114
139
|
export async function loginCmd(opts) {
|
|
115
|
-
|
|
140
|
+
const { detectEnvironment } = await import('../util/environment.js');
|
|
141
|
+
const env = detectEnvironment();
|
|
142
|
+
// 优先复用 daemon(已存在逻辑)
|
|
116
143
|
const daemonCookies = await fetchCookiesFromDaemon();
|
|
117
144
|
if (daemonCookies && daemonCookies.length > 0) {
|
|
118
145
|
await writeCookies(daemonCookies);
|
|
@@ -122,10 +149,23 @@ export async function loginCmd(opts) {
|
|
|
122
149
|
}
|
|
123
150
|
return;
|
|
124
151
|
}
|
|
125
|
-
|
|
126
|
-
|
|
152
|
+
// 沙盒环境:直接走引导路径,不尝试拉起 Firefox
|
|
153
|
+
if (!env.canLaunchBrowser) {
|
|
154
|
+
throw new AtlasError(sandboxLoginGuide(env.agentCode, env.isContainer ? '容器环境无法拉起 GUI' : '无 TTY/DISPLAY'), 'INTERACTIVE_REQUIRED');
|
|
155
|
+
}
|
|
156
|
+
// 本地环境:尝试拉起浏览器;失败降级为引导提示而非硬崩
|
|
157
|
+
try {
|
|
158
|
+
await login();
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
log(sandboxLoginGuide(env.agentCode, '浏览器拉起失败'));
|
|
162
|
+
if (opts.json || isJsonMode()) {
|
|
163
|
+
// 已在 stderr 给出引导,仍抛出错误让上层以 INTERACTIVE_REQUIRED 退出
|
|
164
|
+
}
|
|
165
|
+
throw e instanceof Error
|
|
166
|
+
? new AtlasError(`浏览器登录失败:${e.message}\n${sandboxLoginGuide(env.agentCode, '浏览器拉起失败')}`, 'INTERACTIVE_REQUIRED')
|
|
167
|
+
: e;
|
|
127
168
|
}
|
|
128
|
-
await login();
|
|
129
169
|
if (opts.json || isJsonMode()) {
|
|
130
170
|
jsonOk({ status: 'logged_in' });
|
|
131
171
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atlas auth export / import / doctor 命令。
|
|
3
|
+
*
|
|
4
|
+
* 完全对齐 dws 参数:
|
|
5
|
+
* atlas auth export [-o <path>] [--base64] [--refresh-only] [--include-access-token]
|
|
6
|
+
* atlas auth import [-i <path>] [--base64] [--force] [--persist]
|
|
7
|
+
* atlas auth doctor
|
|
8
|
+
*
|
|
9
|
+
* 安全护栏详见 docs/sandbox-login-plan.md §4.3/§4.4 与 §8。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import { buildBundle, serializeBundleBase64, writeBundleToFile, parseBundleBase64, parseBundleFile, } from '../util/portable-store.js';
|
|
13
|
+
import { detectEnvironment, resolveDaemonConfig, probeDaemonReachable, } from '../util/environment.js';
|
|
14
|
+
import { readCookies, writeCookies } from './session.js';
|
|
15
|
+
import { isJsonMode, jsonOk, log, printError } from '../util/output.js';
|
|
16
|
+
import { ConfigError } from '../util/errors.js';
|
|
17
|
+
import { getCookieFile } from '../util/paths.js';
|
|
18
|
+
const REFRESH_TOKEN_WARNING = '⚠️ 即将输出的 refresh token 是长期凭证,请仅在受信任的环境粘贴,且仅用于一次性导入。';
|
|
19
|
+
/** 当前 ISO 时间戳(命令层调用,纯逻辑层不依赖) */
|
|
20
|
+
function nowIso() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
export async function exportCmd(opts) {
|
|
24
|
+
// refresh-only 默认开启;--include-access-token 显式关闭
|
|
25
|
+
const refreshOnly = opts.includeAccessToken ? false : opts.refreshOnly !== false;
|
|
26
|
+
const cookies = await readCookies();
|
|
27
|
+
if (!cookies || cookies.length === 0) {
|
|
28
|
+
throw new ConfigError('本地无登录态,无法导出。请先执行:atlas auth login');
|
|
29
|
+
}
|
|
30
|
+
const bundle = buildBundle(cookies, {
|
|
31
|
+
refreshOnly,
|
|
32
|
+
exportedAt: nowIso(),
|
|
33
|
+
});
|
|
34
|
+
// 输出到 stdout(base64 模式无 -o,明显是给 agent 管道用的,先打 stderr 警告)
|
|
35
|
+
if (opts.base64 && !opts.out) {
|
|
36
|
+
log(REFRESH_TOKEN_WARNING);
|
|
37
|
+
const b64 = await serializeBundleBase64(bundle);
|
|
38
|
+
process.stdout.write(b64 + '\n');
|
|
39
|
+
if (opts.json || isJsonMode()) {
|
|
40
|
+
// JSON 模式下也输出信封,但 stdout 已被 b64 占用——只在 stderr 给提示
|
|
41
|
+
log('(JSON 模式下 base64 优先写 stdout;信封省略)');
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// base64 模式 + -o:写到文件
|
|
46
|
+
if (opts.base64 && opts.out) {
|
|
47
|
+
const resolved = await writeBundleToFile(bundle, opts.out, true);
|
|
48
|
+
if (refreshOnly)
|
|
49
|
+
log(REFRESH_TOKEN_WARNING);
|
|
50
|
+
log(`已导出到 ${resolved}(refresh-only=${refreshOnly},base64 文本)`);
|
|
51
|
+
if (opts.json || isJsonMode()) {
|
|
52
|
+
jsonOk({ exported: true, path: resolved, refreshOnly, base64: true, count: bundle.cookies.length });
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// 无 base64 写二进制 gzip 文件(需 -o)
|
|
57
|
+
if (opts.out) {
|
|
58
|
+
const resolved = await writeBundleToFile(bundle, opts.out, false);
|
|
59
|
+
if (refreshOnly)
|
|
60
|
+
log(REFRESH_TOKEN_WARNING);
|
|
61
|
+
log(`已导出到 ${resolved}(refresh-only=${refreshOnly},gzip 二进制)`);
|
|
62
|
+
if (opts.json || isJsonMode()) {
|
|
63
|
+
jsonOk({ exported: true, path: resolved, refreshOnly, base64: false, count: bundle.cookies.length });
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// 既无 base64 也无 -o:默认走 base64 stdout(对齐 dws `export` 无参行为)
|
|
68
|
+
log(REFRESH_TOKEN_WARNING);
|
|
69
|
+
const b64 = await serializeBundleBase64(bundle);
|
|
70
|
+
process.stdout.write(b64 + '\n');
|
|
71
|
+
}
|
|
72
|
+
export async function importCmd(opts) {
|
|
73
|
+
const persist = opts.persist !== false; // 默认落盘
|
|
74
|
+
let bundle;
|
|
75
|
+
let osMismatch = false;
|
|
76
|
+
// 从 stdin 读 base64:--base64 且无 -i
|
|
77
|
+
if (opts.base64 && !opts.input) {
|
|
78
|
+
const b64 = readFileSync(0, 'utf-8').trim(); // fd 0 = stdin
|
|
79
|
+
const result = await parseBundleBase64(b64);
|
|
80
|
+
bundle = result.bundle;
|
|
81
|
+
osMismatch = result.warning.osMismatch;
|
|
82
|
+
}
|
|
83
|
+
else if (opts.input) {
|
|
84
|
+
const result = await parseBundleFile(opts.input);
|
|
85
|
+
bundle = result.bundle;
|
|
86
|
+
osMismatch = result.warning.osMismatch;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw new ConfigError('请指定导入源:-i <文件> 或 --base64 从 stdin 读取(如 ATLAS_COOKIES_B64 环境变量自动导入请使用环境变量方式)');
|
|
90
|
+
}
|
|
91
|
+
if (bundle.cookies.length === 0) {
|
|
92
|
+
throw new ConfigError('导入的 bundle 不含任何 cookies,请确认导出源已登录');
|
|
93
|
+
}
|
|
94
|
+
// 跨 OS 警告(不阻断)
|
|
95
|
+
if (osMismatch) {
|
|
96
|
+
log(`⚠️ bundle 在 ${bundle.manifest.os} 导出,当前为 ${process.platform},跨平台 cookies 可能失效`);
|
|
97
|
+
}
|
|
98
|
+
// 已有 cookies 且未 --force 时拒绝覆盖
|
|
99
|
+
const existing = await readCookies();
|
|
100
|
+
if (existing && existing.length > 0 && !opts.force) {
|
|
101
|
+
throw new ConfigError('本地已存在登录态,拒绝覆盖。使用 --force 强制覆盖(将丢失旧 cookies)');
|
|
102
|
+
}
|
|
103
|
+
if (persist) {
|
|
104
|
+
await writeCookies(bundle.cookies);
|
|
105
|
+
log(`已导入 ${bundle.cookies.length} 个 cookies 到 ${getCookieFile()}`);
|
|
106
|
+
if (opts.json || isJsonMode()) {
|
|
107
|
+
jsonOk({
|
|
108
|
+
imported: true,
|
|
109
|
+
count: bundle.cookies.length,
|
|
110
|
+
osMismatch,
|
|
111
|
+
refreshOnly: bundle.manifest.refreshOnly,
|
|
112
|
+
persisted: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// 仅内存缓存(不落盘),用于一次性会话
|
|
118
|
+
// readCookies 走 cachedCookies,需手动注入缓存
|
|
119
|
+
const { __setCachedCookiesForImport } = await import('./session.js');
|
|
120
|
+
__setCachedCookiesForImport(bundle.cookies);
|
|
121
|
+
log(`已内存导入 ${bundle.cookies.length} 个 cookies(未落盘)`);
|
|
122
|
+
if (opts.json || isJsonMode()) {
|
|
123
|
+
jsonOk({
|
|
124
|
+
imported: true,
|
|
125
|
+
count: bundle.cookies.length,
|
|
126
|
+
osMismatch,
|
|
127
|
+
refreshOnly: bundle.manifest.refreshOnly,
|
|
128
|
+
persisted: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** 推导 recommendedPath(纯函数,便于测试) */
|
|
134
|
+
export function recommendPath(opts) {
|
|
135
|
+
if (opts.canLaunchBrowser)
|
|
136
|
+
return 'browser';
|
|
137
|
+
if (opts.daemonReachable)
|
|
138
|
+
return 'daemon';
|
|
139
|
+
if (opts.cookiesReady)
|
|
140
|
+
return 'manual_export_hint';
|
|
141
|
+
return 'import';
|
|
142
|
+
}
|
|
143
|
+
export async function doctorCmd(opts) {
|
|
144
|
+
const env = detectEnvironment();
|
|
145
|
+
const daemonCfg = resolveDaemonConfig();
|
|
146
|
+
const daemonReachable = await probeDaemonReachable(daemonCfg.url ?? undefined, daemonCfg.token ?? undefined);
|
|
147
|
+
const recommendedPath = recommendPath({
|
|
148
|
+
canLaunchBrowser: env.canLaunchBrowser,
|
|
149
|
+
cookiesReady: env.cookiesReady,
|
|
150
|
+
daemonReachable,
|
|
151
|
+
daemonVia: daemonCfg.via,
|
|
152
|
+
});
|
|
153
|
+
const result = {
|
|
154
|
+
agentCode: env.agentCode,
|
|
155
|
+
agentSignal: env.agentSignal,
|
|
156
|
+
sandbox: !env.canLaunchBrowser,
|
|
157
|
+
canLaunchBrowser: env.canLaunchBrowser,
|
|
158
|
+
cookies: { path: getCookieFile(), present: env.cookiesReady, expired: null },
|
|
159
|
+
daemon: {
|
|
160
|
+
url: daemonCfg.url,
|
|
161
|
+
reachable: daemonReachable,
|
|
162
|
+
via: daemonCfg.via,
|
|
163
|
+
},
|
|
164
|
+
recommendedPath,
|
|
165
|
+
};
|
|
166
|
+
if (opts.json || isJsonMode()) {
|
|
167
|
+
jsonOk(result);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// 人类可读输出
|
|
171
|
+
log('Atlas auth 诊断');
|
|
172
|
+
log(` agent: ${env.agentCode || '(未识别)'} ${env.agentSignal ? `[${env.agentSignal}]` : ''}`);
|
|
173
|
+
log(` 沙盒环境: ${result.sandbox ? '是' : '否'}`);
|
|
174
|
+
log(` 可拉起浏览器: ${env.canLaunchBrowser ? '是' : '否'} (TTY=${env.hasTTY}, DISPLAY=${env.hasDisplay}, container=${env.isContainer})`);
|
|
175
|
+
log(` 本地 cookies: ${env.cookiesReady ? '已就绪' : '缺失'} (${getCookieFile()})`);
|
|
176
|
+
log(` Daemon: ${daemonCfg.url ? `${daemonCfg.url} (${daemonCfg.via})` : '未配置'} - ${daemonReachable ? '可达' : '不可达'}`);
|
|
177
|
+
log(` 推荐路径: ${recommendedPath}`);
|
|
178
|
+
if (result.sandbox && !env.cookiesReady && !daemonReachable) {
|
|
179
|
+
log('');
|
|
180
|
+
log('沙盒登录引导:');
|
|
181
|
+
log(' [推荐] 从本地导出 → 沙盒导入:');
|
|
182
|
+
log(' (本地) atlas auth export --base64');
|
|
183
|
+
log(' (沙盒) export ATLAS_COOKIES_B64=<粘贴上一步输出> && atlas auth doctor');
|
|
184
|
+
log(' [或] 复用宿主机 daemon:');
|
|
185
|
+
log(' (本地) atlas daemon');
|
|
186
|
+
log(' (沙盒) export ATLAS_DAEMON_URL=http://host.docker.internal:8765');
|
|
187
|
+
log(' export ATLAS_DAEMON_TOKEN=<~/.atlas/daemon.token 内容>');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** 兼容错误打印 helper(cli.ts handleError 用不到,保留给子命令内部用) */
|
|
191
|
+
export function reportError(err, json) {
|
|
192
|
+
printError(err, { json });
|
|
193
|
+
}
|
|
@@ -8,13 +8,27 @@ let cachedCookies = null;
|
|
|
8
8
|
/**
|
|
9
9
|
* 读取持久化的 cookies(从本地文件)
|
|
10
10
|
* Playwright SSO 登录后将 cookies 写入此文件
|
|
11
|
+
*
|
|
12
|
+
* 沙盒免命令登录:本地文件缺失时若设置了 ATLAS_COOKIES_B64,则内存中一次性
|
|
13
|
+
* import(不落盘),让 agent 只注入 secret 就能用上。这条路径在 startAuthRefresh
|
|
14
|
+
* 之外的成本几乎为零,避免每次调用都重新解 base64/gzip。
|
|
11
15
|
*/
|
|
12
16
|
export async function readCookies() {
|
|
13
17
|
if (cachedCookies)
|
|
14
18
|
return cachedCookies;
|
|
15
19
|
try {
|
|
16
|
-
if (!existsSync(COOKIE_FILE))
|
|
20
|
+
if (!existsSync(COOKIE_FILE)) {
|
|
21
|
+
// 沙盒自动导入:ATLAS_COOKIES_B64 内存注入(不落盘)
|
|
22
|
+
const b64 = process.env.ATLAS_COOKIES_B64;
|
|
23
|
+
if (b64 && b64.trim().length > 0) {
|
|
24
|
+
const imported = await importFromEnvBase64(b64);
|
|
25
|
+
if (imported && imported.length > 0) {
|
|
26
|
+
cachedCookies = imported;
|
|
27
|
+
return imported;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
17
30
|
return null;
|
|
31
|
+
}
|
|
18
32
|
const data = await readFile(COOKIE_FILE, 'utf-8');
|
|
19
33
|
const cookies = JSON.parse(data);
|
|
20
34
|
cachedCookies = cookies;
|
|
@@ -24,6 +38,34 @@ export async function readCookies() {
|
|
|
24
38
|
return null;
|
|
25
39
|
}
|
|
26
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* 解 ATLAS_COOKIES_B64 环境变量为新版 portable bundle(gzip+base64 JSON)。
|
|
43
|
+
* 兼容旧格式:纯 JSON 数组 cookies(无 manifest 包装)。
|
|
44
|
+
* 失败返回 null,绝不抛——避免阻塞主流程。
|
|
45
|
+
*/
|
|
46
|
+
async function importFromEnvBase64(b64) {
|
|
47
|
+
try {
|
|
48
|
+
const { parseBundleBase64 } = await import('../util/portable-store.js');
|
|
49
|
+
const { bundle } = await parseBundleBase64(b64);
|
|
50
|
+
return bundle.cookies;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// 兼容:直接是纯 JSON 数组字符串(非 gzip)
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(b64);
|
|
56
|
+
if (Array.isArray(parsed))
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// fallthrough
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** 仅内存导入(importCmd --persist=false 用),不落盘 */
|
|
66
|
+
export function __setCachedCookiesForImport(cookies) {
|
|
67
|
+
cachedCookies = cookies;
|
|
68
|
+
}
|
|
27
69
|
export async function writeCookies(cookies) {
|
|
28
70
|
cachedCookies = cookies;
|
|
29
71
|
await secureMkdir(SESSION_DIR, { recursive: true });
|
|
@@ -39,22 +81,32 @@ export async function clearCookies() {
|
|
|
39
81
|
}
|
|
40
82
|
}
|
|
41
83
|
/**
|
|
42
|
-
* 从 daemon 获取 cookies(附带
|
|
84
|
+
* 从 daemon 获取 cookies(附带 Bearer token 鉴权)。
|
|
85
|
+
*
|
|
86
|
+
* 连接配置走 resolveDaemonConfig():优先 ATLAS_DAEMON_URL + ATLAS_DAEMON_TOKEN
|
|
87
|
+
* (沙盒连宿主机 daemon),回退 localhost + 本机 ~/.atlas/daemon.token(本机复用)。
|
|
88
|
+
* 失败返回 null,绝不抛——调用方据此降级到本地 cookies。
|
|
43
89
|
*/
|
|
44
|
-
export async function fetchCookiesFromDaemon(
|
|
90
|
+
export async function fetchCookiesFromDaemon() {
|
|
45
91
|
try {
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
const { resolveDaemonConfig } = await import('../util/environment.js');
|
|
93
|
+
const cfg = resolveDaemonConfig();
|
|
94
|
+
if (!cfg.url)
|
|
95
|
+
return null;
|
|
96
|
+
// token:env 优先(ATLAS_DAEMON_TOKEN),回退本地 token 文件(default 分支)
|
|
97
|
+
let token = cfg.token ?? '';
|
|
98
|
+
if (!token) {
|
|
99
|
+
const { getDaemonTokenFile } = await import('../util/paths.js');
|
|
100
|
+
const tokenFile = getDaemonTokenFile();
|
|
101
|
+
if (existsSync(tokenFile)) {
|
|
102
|
+
token = (await readFile(tokenFile, 'utf-8')).trim();
|
|
103
|
+
}
|
|
52
104
|
}
|
|
53
105
|
const { request } = await import('undici');
|
|
54
106
|
const headers = {};
|
|
55
107
|
if (token)
|
|
56
108
|
headers['Authorization'] = `Bearer ${token}`;
|
|
57
|
-
const resp = await request(
|
|
109
|
+
const resp = await request(`${cfg.url.replace(/\/$/, '')}/api/cookies`, {
|
|
58
110
|
headers,
|
|
59
111
|
headersTimeout: 5000,
|
|
60
112
|
bodyTimeout: 5000,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
// Auth
|
|
4
|
-
import { authLoginCmd, authStatusCmd, authRefreshCmd } from './commands/auth.js';
|
|
4
|
+
import { authLoginCmd, authStatusCmd, authRefreshCmd, authExportCmd, authImportCmd, authDoctorCmd } from './commands/auth.js';
|
|
5
5
|
// Project commands (find, projects, link, unlink)
|
|
6
6
|
import { findCmd, projectsCmd, linkCmd, linkStatusCmd, unlinkCmd, } from './commands/project/index.js';
|
|
7
7
|
// Baseline commands (month, summary, export)
|
|
@@ -143,6 +143,50 @@ function registerAuthCommands(program) {
|
|
|
143
143
|
handleError(e);
|
|
144
144
|
}
|
|
145
145
|
});
|
|
146
|
+
auth
|
|
147
|
+
.command('export')
|
|
148
|
+
.description('导出登录态为便携 bundle(默认 --refresh-only 剥离短命 token)')
|
|
149
|
+
.option('-o, --out <path>', '输出文件路径(省略时 --base64 写 stdout)')
|
|
150
|
+
.option('--base64', 'base64 文本格式(gzip+JSON),方便管道/env 传输')
|
|
151
|
+
.option('--refresh-only', '仅长期凭证(默认开启)', true)
|
|
152
|
+
.option('--include-access-token', '包含短命 access_token(默认剥离)')
|
|
153
|
+
.option('--json', '输出 JSON 信封')
|
|
154
|
+
.action(async (opts) => {
|
|
155
|
+
try {
|
|
156
|
+
await authExportCmd(opts);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
handleError(e);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
auth
|
|
163
|
+
.command('import')
|
|
164
|
+
.description('从便携 bundle 导入登录态(覆盖本地 cookies)')
|
|
165
|
+
.option('-i, --input <path>', '导入文件路径(省略 --base64 时从 stdin 读)')
|
|
166
|
+
.option('--base64', '输入为 base64 文本格式')
|
|
167
|
+
.option('--force', '本地已有登录态时强制覆盖')
|
|
168
|
+
.option('--persist', '落盘到 ~/.atlas/cookies.json(默认开启)', true)
|
|
169
|
+
.option('--json', '输出 JSON 信封')
|
|
170
|
+
.action(async (opts) => {
|
|
171
|
+
try {
|
|
172
|
+
await authImportCmd(opts);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
handleError(e);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
auth
|
|
179
|
+
.command('doctor')
|
|
180
|
+
.description('诊断当前环境的登录路径(沙盒 agent 自省用)')
|
|
181
|
+
.option('--json', '输出 JSON 信封')
|
|
182
|
+
.action(async (opts) => {
|
|
183
|
+
try {
|
|
184
|
+
await authDoctorCmd(opts);
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
handleError(e);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
146
190
|
}
|
|
147
191
|
function registerProjectCommands(program) {
|
|
148
192
|
// atlas find
|
|
@@ -394,6 +438,11 @@ function registerUtilityCommands(program) {
|
|
|
394
438
|
.command('daemon')
|
|
395
439
|
.description('启动本地守护进程(沙盒环境使用,保持浏览器会话)')
|
|
396
440
|
.option('--port <n>', '监听端口(默认 8765,也可用 ATLAS_DAEMON_PORT 环境变量)')
|
|
441
|
+
.option('--host <host>', '监听地址(默认 127.0.0.1;非 loopback 需配 --allow-ip 或 --insecure)')
|
|
442
|
+
.option('--allow-ip <cidr>', 'IP 白名单(CIDR 或单 IP,可多次传)', (v, p) => [...p, v], [])
|
|
443
|
+
.option('--tls-cert <path>', 'TLS 证书文件路径(需与 --tls-key 同时指定)')
|
|
444
|
+
.option('--tls-key <path>', 'TLS 私钥文件路径')
|
|
445
|
+
.option('--insecure', '显式允许明文监听非 loopback 接口(仅本机开发)')
|
|
397
446
|
.option('--json', '输出 JSON 信封')
|
|
398
447
|
.action(async (opts) => {
|
|
399
448
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { loginCmd as authLoginCmd, statusCmd as authStatusCmd, refreshCmd as authRefreshCmd, } from '../auth/index.js';
|
|
1
|
+
export { loginCmd as authLoginCmd, statusCmd as authStatusCmd, refreshCmd as authRefreshCmd, exportCmd as authExportCmd, importCmd as authImportCmd, doctorCmd as authDoctorCmd, } from '../auth/index.js';
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { createServer } from 'http';
|
|
1
|
+
import { createServer as httpCreateServer } from 'http';
|
|
2
|
+
import { createServer as httpsCreateServer } from 'https';
|
|
2
3
|
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
3
5
|
import { dirname } from 'path';
|
|
4
6
|
import { log } from '../util/output.js';
|
|
5
7
|
import { getDaemonTokenFile } from '../util/paths.js';
|
|
6
8
|
import { secureMkdir, secureWriteFile } from '../util/secure-fs.js';
|
|
7
9
|
import { AtlasError } from '../util/errors.js';
|
|
10
|
+
import { parseCidrList, isIpAllowed, isLoopback, } from '../util/cidr.js';
|
|
8
11
|
/** 生成 32 字节 hex token */
|
|
9
12
|
function generateToken() {
|
|
10
13
|
return randomBytes(32).toString('hex');
|
|
@@ -22,6 +25,50 @@ function isAuthorized(req, token) {
|
|
|
22
25
|
}
|
|
23
26
|
return timingSafeEqual(a, b);
|
|
24
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* 构造 daemon request handler。抽出为纯函数便于测试:
|
|
30
|
+
* 测试可直接用返回的 handler 起临时 http/https server。
|
|
31
|
+
*
|
|
32
|
+
* 路由:
|
|
33
|
+
* - GET /api/health → 公开,{ok, pid}
|
|
34
|
+
* - GET /api/cookies → 需 Bearer + IP 白名单,{ok, cookies}
|
|
35
|
+
* - 其他 → 404
|
|
36
|
+
*/
|
|
37
|
+
export function createDaemonHandler(deps) {
|
|
38
|
+
return (req, res) => {
|
|
39
|
+
const sendJson = (status, body) => {
|
|
40
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
41
|
+
res.end(JSON.stringify(body));
|
|
42
|
+
};
|
|
43
|
+
const url = req.url ?? '/';
|
|
44
|
+
const isHealth = url === '/api/health' || url === '/';
|
|
45
|
+
// health 端点不鉴权、不限 IP(用于探测)
|
|
46
|
+
if (isHealth) {
|
|
47
|
+
sendJson(200, { ok: true, pid: process.pid });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// IP 白名单(非 health 端点都校验)
|
|
51
|
+
if (!isIpAllowed(req.socket.remoteAddress, deps.allowList)) {
|
|
52
|
+
sendJson(403, { ok: false, error: 'forbidden: ip not allowed' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Bearer 鉴权
|
|
56
|
+
if (!isAuthorized(req, deps.token)) {
|
|
57
|
+
sendJson(401, { ok: false, error: 'unauthorized' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (url === '/api/cookies') {
|
|
61
|
+
void deps
|
|
62
|
+
.readCookies()
|
|
63
|
+
.then((cookies) => sendJson(200, { ok: true, cookies: cookies ?? [] }))
|
|
64
|
+
.catch(() => sendJson(500, { ok: false, error: 'read cookies failed' }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
sendJson(404, { ok: false, error: 'not found' });
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** 默认端口号 */
|
|
71
|
+
const DEFAULT_PORT = 8765;
|
|
25
72
|
export async function daemonCmd(opts) {
|
|
26
73
|
const envPort = Number(process.env.ATLAS_DAEMON_PORT);
|
|
27
74
|
const optsPort = opts.port !== undefined ? Number(opts.port) : NaN;
|
|
@@ -29,24 +76,57 @@ export async function daemonCmd(opts) {
|
|
|
29
76
|
? optsPort
|
|
30
77
|
: Number.isFinite(envPort) && envPort > 0
|
|
31
78
|
? envPort
|
|
32
|
-
:
|
|
79
|
+
: DEFAULT_PORT;
|
|
80
|
+
const host = opts.host ?? '127.0.0.1';
|
|
81
|
+
const hostLoopback = isLoopback(host);
|
|
82
|
+
const hasTls = Boolean(opts.tlsCert) && Boolean(opts.tlsKey);
|
|
83
|
+
// 护栏 1:TLS cert/key 必须同时指定
|
|
84
|
+
if ((opts.tlsCert && !opts.tlsKey) || (opts.tlsKey && !opts.tlsCert)) {
|
|
85
|
+
throw new AtlasError('--tls-cert 与 --tls-key 必须同时指定', 'CONFIG_ERROR');
|
|
86
|
+
}
|
|
87
|
+
// 护栏 2:非 loopback 监听必须有 IP 白名单或显式 --insecure
|
|
88
|
+
if (!hostLoopback && !opts.insecure && (!opts.allowIp || opts.allowIp.length === 0)) {
|
|
89
|
+
throw new AtlasError('非 loopback 监听必须指定 --allow-ip 或显式 --insecure(明文传输 token 仅建议本机开发)', 'CONFIG_ERROR');
|
|
90
|
+
}
|
|
91
|
+
// 护栏 3:非 loopback + 无 TLS + 明文 → stderr 大红警告
|
|
92
|
+
if (!hostLoopback && !hasTls && opts.insecure) {
|
|
93
|
+
log('⚠⚠⚠ 明文监听非 loopback 接口,Bearer token 将以明文传输,仅建议本机开发使用 ⚠⚠⚠');
|
|
94
|
+
}
|
|
95
|
+
// 解析 IP 白名单(非法 CIDR 在此抛 ConfigError,启动期失败而非运行期)
|
|
96
|
+
const allowList = parseCidrList(opts.allowIp);
|
|
33
97
|
// 生成一次性 token,写入 ~/.atlas/daemon.token(0600)
|
|
34
98
|
const token = generateToken();
|
|
35
99
|
const tokenFile = getDaemonTokenFile();
|
|
36
100
|
await secureMkdir(dirname(tokenFile), { recursive: true });
|
|
37
101
|
await secureWriteFile(tokenFile, token);
|
|
38
102
|
log(`Daemon token 已写入 ${tokenFile}`);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return;
|
|
103
|
+
// 动态 import readCookies 避免与 session 模块的循环依赖
|
|
104
|
+
const handlerDeps = {
|
|
105
|
+
token,
|
|
106
|
+
allowList,
|
|
107
|
+
readCookies: async () => {
|
|
108
|
+
const { readCookies } = await import('../auth/session.js');
|
|
109
|
+
return readCookies();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const handler = createDaemonHandler(handlerDeps);
|
|
113
|
+
// 创建 server(http 或 https)
|
|
114
|
+
let server;
|
|
115
|
+
if (hasTls) {
|
|
116
|
+
let cert;
|
|
117
|
+
let key;
|
|
118
|
+
try {
|
|
119
|
+
cert = readFileSync(opts.tlsCert);
|
|
120
|
+
key = readFileSync(opts.tlsKey);
|
|
46
121
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
122
|
+
catch (e) {
|
|
123
|
+
throw new AtlasError(`TLS 证书读取失败:${e.message}`, 'CONFIG_ERROR');
|
|
124
|
+
}
|
|
125
|
+
server = httpsCreateServer({ cert, key }, handler);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
server = httpCreateServer(handler);
|
|
129
|
+
}
|
|
50
130
|
await new Promise((resolve, reject) => {
|
|
51
131
|
server.on('error', (e) => {
|
|
52
132
|
if (e.code === 'EADDRINUSE') {
|
|
@@ -56,9 +136,10 @@ export async function daemonCmd(opts) {
|
|
|
56
136
|
reject(e);
|
|
57
137
|
}
|
|
58
138
|
});
|
|
59
|
-
server.listen(port,
|
|
139
|
+
server.listen(port, host, resolve);
|
|
60
140
|
});
|
|
61
|
-
|
|
141
|
+
const scheme = hasTls ? 'https' : 'http';
|
|
142
|
+
log(`守护进程已启动,监听 ${scheme}://${host}:${port}${!hostLoopback && !hasTls && opts.insecure ? '(明文)' : ''}`);
|
|
62
143
|
// Keep running
|
|
63
144
|
await new Promise(() => { });
|
|
64
145
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ConfigError } from './errors.js';
|
|
2
|
+
const IPV4_OCTET = '(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
|
|
3
|
+
const IPV4_RE = new RegExp(`^${IPV4_OCTET}(\\.${IPV4_OCTET}){3}$`);
|
|
4
|
+
const IPV6_FULL_RE = /^[0-9a-fA-F:]+$/;
|
|
5
|
+
/** 是否为 loopback 监听地址(127.0.0.1 / ::1 / localhost) */
|
|
6
|
+
export function isLoopback(host) {
|
|
7
|
+
if (!host)
|
|
8
|
+
return false;
|
|
9
|
+
const h = host.toLowerCase().trim();
|
|
10
|
+
if (h === 'localhost' || h === '::1')
|
|
11
|
+
return true;
|
|
12
|
+
if (IPV4_RE.test(h) && h.startsWith('127.'))
|
|
13
|
+
return true;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
/** 规范化对端地址:去 IPv4-mapped IPv6 前缀 ::ffff: */
|
|
17
|
+
export function normalizeRemoteAddr(addr) {
|
|
18
|
+
if (!addr)
|
|
19
|
+
return '';
|
|
20
|
+
const trimmed = addr.trim();
|
|
21
|
+
// ::ffff:1.2.3.4 或 ::ffff:a.b.c.d
|
|
22
|
+
const mapped = trimmed.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
23
|
+
if (mapped)
|
|
24
|
+
return mapped[1];
|
|
25
|
+
// 也可能出现 ::ffff:xxx.xxx.xxx.xxx 之外的形式,如 2002:... 退回原值
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
/** IPv4 点分十进制 → 32 位无符号整数;非法返回 null */
|
|
29
|
+
function ipv4ToInt(ip) {
|
|
30
|
+
if (!IPV4_RE.test(ip))
|
|
31
|
+
return null;
|
|
32
|
+
const parts = ip.split('.').map(Number);
|
|
33
|
+
// 防止前导零或多段异常(正则已挡,此处二次保险)
|
|
34
|
+
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
|
|
35
|
+
}
|
|
36
|
+
function intToIpv4(n) {
|
|
37
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 解析 CIDR/单 IP 列表为结构化条目。
|
|
41
|
+
* - IPv4 单 IP 视作 /32,IPv6 单 IP 视作 /128
|
|
42
|
+
* - IPv6 CIDR 暂不支持(抛 ConfigError),仅允许 IPv6 单 IP
|
|
43
|
+
* - 非法格式抛 ConfigError,message 含原始输入
|
|
44
|
+
*/
|
|
45
|
+
export function parseCidrList(input) {
|
|
46
|
+
if (!input || input.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
const entries = [];
|
|
49
|
+
for (const raw of input) {
|
|
50
|
+
const item = raw.trim();
|
|
51
|
+
if (!item)
|
|
52
|
+
continue;
|
|
53
|
+
if (item.includes(':')) {
|
|
54
|
+
// IPv6:仅支持单 IP 全等比较
|
|
55
|
+
if (item.includes('/')) {
|
|
56
|
+
throw new ConfigError(`暂不支持 IPv6 CIDR(仅单 IP):${raw}`);
|
|
57
|
+
}
|
|
58
|
+
if (!IPV6_FULL_RE.test(item)) {
|
|
59
|
+
throw new ConfigError(`非法 IPv6 地址:${raw}`);
|
|
60
|
+
}
|
|
61
|
+
entries.push({ raw, family: 'ipv6', network: item.toLowerCase(), prefix: 128 });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// IPv4:CIDR 或单 IP
|
|
65
|
+
if (item.includes('/')) {
|
|
66
|
+
const [ip, prefixStr] = item.split('/');
|
|
67
|
+
const ipInt = ipv4ToInt(ip);
|
|
68
|
+
const prefix = Number(prefixStr);
|
|
69
|
+
if (ipInt === null)
|
|
70
|
+
throw new ConfigError(`非法 IPv4 网络地址:${raw}`);
|
|
71
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
|
|
72
|
+
throw new ConfigError(`非法 IPv4 前缀长度:${raw}`);
|
|
73
|
+
}
|
|
74
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
75
|
+
const netInt = (ipInt & mask) >>> 0;
|
|
76
|
+
entries.push({ raw, family: 'ipv4', network: intToIpv4(netInt), prefix });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const ipInt = ipv4ToInt(item);
|
|
80
|
+
if (ipInt === null)
|
|
81
|
+
throw new ConfigError(`非法 IPv4 地址:${raw}`);
|
|
82
|
+
entries.push({ raw, family: 'ipv4', network: intToIpv4(ipInt), prefix: 32 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 判断对端地址是否命中白名单。
|
|
89
|
+
* - allowList 为空 → 放行(向后兼容 loopback 默认)
|
|
90
|
+
* - 未提供地址 → 拒绝(无法判定时安全优先)
|
|
91
|
+
*/
|
|
92
|
+
export function isIpAllowed(remoteAddr, allowList) {
|
|
93
|
+
if (allowList.length === 0)
|
|
94
|
+
return true;
|
|
95
|
+
const addr = normalizeRemoteAddr(remoteAddr);
|
|
96
|
+
if (!addr)
|
|
97
|
+
return false;
|
|
98
|
+
for (const entry of allowList) {
|
|
99
|
+
if (entry.family === 'ipv6') {
|
|
100
|
+
// IPv6 单 IP 全等比较
|
|
101
|
+
if (addr.toLowerCase() === entry.network)
|
|
102
|
+
return true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// IPv4 CIDR 匹配
|
|
106
|
+
const ipInt = ipv4ToInt(addr);
|
|
107
|
+
if (ipInt === null)
|
|
108
|
+
continue;
|
|
109
|
+
const mask = entry.prefix === 0 ? 0 : (0xffffffff << (32 - entry.prefix)) >>> 0;
|
|
110
|
+
if (((ipInt & mask) >>> 0) === (ipv4ToInt(entry.network) >>> 0))
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 沙盒环境识别(对齐 dws agent_code_detect)。
|
|
3
|
+
*
|
|
4
|
+
* 四级信号优先级(高 → 低),命中即返回,绝不猜:
|
|
5
|
+
* T0 ATLAS_AGENT_CODE 环境变量(显式声明,最高优先级)
|
|
6
|
+
* T1 CLI 内嵌签名(claudecode/openclaw/codex/hermes/wukong)
|
|
7
|
+
* T2 VSCODE_BRAND(覆盖 Cursor/Qoder/Trae 等 VS Code fork)
|
|
8
|
+
* T3 macOS __CFBundleIdentifier(qoder/cursor/vscode/wukong)
|
|
9
|
+
* T4 未识别 → 空字符串
|
|
10
|
+
*
|
|
11
|
+
* 同时探测沙盒能力(TTY / DISPLAY / 容器 / 浏览器可拉起 / cookies 就绪 / daemon
|
|
12
|
+
* 可达)。daemon 探测涉及网络 IO,由 detectEnvironmentAsync 单独暴露。
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from 'fs';
|
|
15
|
+
import { hostname, platform } from 'os';
|
|
16
|
+
import { getCookieFile, getDaemonTokenFile } from './paths.js';
|
|
17
|
+
/** 已验证的 T1 CLI 签名(envKeys 任一存在即命中) */
|
|
18
|
+
const KNOWN_SIGNATURES = [
|
|
19
|
+
{ code: 'claudecode', envKeys: ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'] },
|
|
20
|
+
{ code: 'openclaw', envKeys: ['OPENCLAW_BUNDLE_ROOT', 'OPENCLAW_RUNTIME_ROLE'] },
|
|
21
|
+
{ code: 'codex', envKeys: ['CODEX_SANDBOX'] },
|
|
22
|
+
{ code: 'hermes', envKeys: ['HERMES_HOME'] },
|
|
23
|
+
{ code: 'wukong', envKeys: ['WUKONG_SANDBOX', 'WUKONG_RUNTIME'] },
|
|
24
|
+
];
|
|
25
|
+
/** T3: macOS bundle id → agent_code 映射(仅取已验证条目) */
|
|
26
|
+
const BUNDLE_ID_MAP = [
|
|
27
|
+
{ code: 'cursor', bundlePrefix: 'com.todesktop.' }, // cursor 包名为 com.todesktop.230313mzl4w4u92
|
|
28
|
+
{ code: 'qoder', bundlePrefix: 'com.qoder.' },
|
|
29
|
+
{ code: 'vscode', bundlePrefix: 'com.microsoft.VSCode' },
|
|
30
|
+
{ code: 'wukong', bundlePrefix: 'com.antgroup.wukong' },
|
|
31
|
+
];
|
|
32
|
+
/** 同步检测 agent_code(不触碰网络/磁盘可变状态,便于单测)。 */
|
|
33
|
+
export function detectAgentCode(env = process.env) {
|
|
34
|
+
// T0: 显式声明最高优先级
|
|
35
|
+
const explicit = env.ATLAS_AGENT_CODE;
|
|
36
|
+
if (typeof explicit === 'string' && explicit.trim()) {
|
|
37
|
+
return { agentCode: explicit.trim(), agentSignal: 'env:ATLAS_AGENT_CODE' };
|
|
38
|
+
}
|
|
39
|
+
// T1: CLI 内嵌签名
|
|
40
|
+
for (const sig of KNOWN_SIGNATURES) {
|
|
41
|
+
for (const k of sig.envKeys) {
|
|
42
|
+
const v = env[k];
|
|
43
|
+
if (typeof v === 'string' && v.length > 0) {
|
|
44
|
+
return { agentCode: sig.code, agentSignal: `env:${k}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// T2: VSCODE_BRAND(覆盖所有 VS Code fork:Cursor/Qoder/Trae 等)
|
|
49
|
+
const brand = env.VSCODE_BRAND;
|
|
50
|
+
if (typeof brand === 'string' && brand.trim()) {
|
|
51
|
+
return { agentCode: brand.trim().toLowerCase(), agentSignal: 'env:VSCODE_BRAND' };
|
|
52
|
+
}
|
|
53
|
+
// T3: macOS __CFBundleIdentifier
|
|
54
|
+
const bundle = env.__CFBundleIdentifier;
|
|
55
|
+
if (typeof bundle === 'string' && bundle.length > 0) {
|
|
56
|
+
for (const m of BUNDLE_ID_MAP) {
|
|
57
|
+
if (bundle.startsWith(m.bundlePrefix) || bundle === m.bundlePrefix) {
|
|
58
|
+
return { agentCode: m.code, agentSignal: `bundle:${bundle}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// T4: 未识别 → 空
|
|
63
|
+
return { agentCode: '', agentSignal: '' };
|
|
64
|
+
}
|
|
65
|
+
/** 判断是否处于 Linux 容器内(/.dockerenv 或 $container 变量) */
|
|
66
|
+
export function detectContainer(env = process.env) {
|
|
67
|
+
if (existsSync('/.dockerenv'))
|
|
68
|
+
return true;
|
|
69
|
+
const c = env.container;
|
|
70
|
+
if (typeof c === 'string' && c.trim() && c !== 'off')
|
|
71
|
+
return true;
|
|
72
|
+
// Kubernetes 常见信号
|
|
73
|
+
if (env.KUBERNETES_SERVICE_HOST)
|
|
74
|
+
return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/** 是否存在 DISPLAY(Linux X11/Wayland)/ macOS 原生 GUI */
|
|
78
|
+
export function detectHasDisplay(env = process.env) {
|
|
79
|
+
if (typeof env.DISPLAY === 'string' && env.DISPLAY.length > 0)
|
|
80
|
+
return true;
|
|
81
|
+
if (typeof env.WAYLAND_DISPLAY === 'string' && env.WAYLAND_DISPLAY.length > 0)
|
|
82
|
+
return true;
|
|
83
|
+
// macOS 即使无 DISPLAY 变量也有原生 GUI
|
|
84
|
+
if (platform() === 'darwin')
|
|
85
|
+
return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/** ATLAS_SANDBOX=1 可显式强制沙盒行为(测试/调试用) */
|
|
89
|
+
function isForcedSandbox(env) {
|
|
90
|
+
return env.ATLAS_SANDBOX === '1' || env.ATLAS_SANDBOX === 'true';
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 同步环境能力快照(不探测 daemon,避免阻塞)。
|
|
94
|
+
* daemon 可达性见 detectEnvironmentAsync()。
|
|
95
|
+
*/
|
|
96
|
+
export function detectEnvironment(env = process.env) {
|
|
97
|
+
const { agentCode, agentSignal } = detectAgentCode(env);
|
|
98
|
+
const hasTTY = Boolean(process.stdout.isTTY);
|
|
99
|
+
const hasDisplay = detectHasDisplay(env);
|
|
100
|
+
const isContainer = detectContainer(env);
|
|
101
|
+
const forcedSandbox = isForcedSandbox(env);
|
|
102
|
+
// 受信任的 GUI 拉起:需要 TTY + DISPLAY 且非容器;ATLAS_SANDBOX=1 强制视为不可拉起
|
|
103
|
+
const canLaunchBrowser = !forcedSandbox && hasDisplay && !isContainer && hasTTY;
|
|
104
|
+
const cookiesReady = existsSync(getCookieFile());
|
|
105
|
+
return {
|
|
106
|
+
agentCode,
|
|
107
|
+
agentSignal,
|
|
108
|
+
hasTTY,
|
|
109
|
+
hasDisplay,
|
|
110
|
+
isContainer: isContainer || forcedSandbox,
|
|
111
|
+
canLaunchBrowser,
|
|
112
|
+
cookiesReady,
|
|
113
|
+
daemonReachable: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** 异步探测 daemon 是否可达(带超时)。失败返回 false,绝不抛。 */
|
|
117
|
+
export async function probeDaemonReachable(url, token, timeoutMs = 1500) {
|
|
118
|
+
if (!url)
|
|
119
|
+
return false;
|
|
120
|
+
try {
|
|
121
|
+
const { request } = await import('undici');
|
|
122
|
+
const headers = {};
|
|
123
|
+
if (token)
|
|
124
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
125
|
+
const resp = await request(`${url.replace(/\/$/, '')}/api/health`, {
|
|
126
|
+
headers,
|
|
127
|
+
headersTimeout: timeoutMs,
|
|
128
|
+
bodyTimeout: timeoutMs,
|
|
129
|
+
});
|
|
130
|
+
return resp.statusCode === 200;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** 从环境变量 + 默认 token 文件解析 daemon 连接配置(不发起请求) */
|
|
137
|
+
export function resolveDaemonConfig(env = process.env) {
|
|
138
|
+
const envUrl = env.ATLAS_DAEMON_URL;
|
|
139
|
+
const envToken = env.ATLAS_DAEMON_TOKEN;
|
|
140
|
+
if (envUrl) {
|
|
141
|
+
return { url: envUrl, token: envToken ?? null, via: 'env' };
|
|
142
|
+
}
|
|
143
|
+
// 回退默认 localhost + 本机 token 文件
|
|
144
|
+
const tokenFileExists = existsSync(getDaemonTokenFile());
|
|
145
|
+
if (tokenFileExists) {
|
|
146
|
+
const envPort = Number(env.ATLAS_DAEMON_PORT);
|
|
147
|
+
const port = Number.isFinite(envPort) && envPort > 0 ? envPort : 8765;
|
|
148
|
+
return { url: `http://localhost:${port}`, token: null, via: 'default' };
|
|
149
|
+
}
|
|
150
|
+
return { url: null, token: null, via: 'none' };
|
|
151
|
+
}
|
|
152
|
+
export const __DEV__ = { hostname, getCookieFile, getDaemonTokenFile };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 便携登录态 bundle(对齐 dws portableAuthBundleManifest)。
|
|
3
|
+
*
|
|
4
|
+
* bundle 结构(gzip + JSON 明文,复用 cookies 已明文的事实):
|
|
5
|
+
*
|
|
6
|
+
* atlas-auth.tar.gz(实际是 gzip(JSON) 单文件,非真 tar — 简化实现)
|
|
7
|
+
* └── { manifest: {...}, cookies: [...] }
|
|
8
|
+
*
|
|
9
|
+
* base64 模式 = base64(gzip(JSON)),方便通过 env / 管道传输。
|
|
10
|
+
*
|
|
11
|
+
* 安全:--refresh-only 默认开启,剥离短命 token(access_token* / refresh_token*),
|
|
12
|
+
* 只保留 SSO_REFRESH_TOKEN + buc_* + 身份相关 cookies。导出文件权限 0600
|
|
13
|
+
* (secureWriteFile)。
|
|
14
|
+
*
|
|
15
|
+
* 零运行时依赖:仅用 node:zlib + secure-fs。
|
|
16
|
+
*/
|
|
17
|
+
import { gzip, gunzip } from 'zlib';
|
|
18
|
+
import { promisify } from 'util';
|
|
19
|
+
import { readFile, mkdir } from 'fs/promises';
|
|
20
|
+
import { dirname, resolve } from 'path';
|
|
21
|
+
import { existsSync } from 'fs';
|
|
22
|
+
import { secureWriteFile } from './secure-fs.js';
|
|
23
|
+
import { ConfigError } from './errors.js';
|
|
24
|
+
import { platform } from 'os';
|
|
25
|
+
const gzipAsync = promisify(gzip);
|
|
26
|
+
const gunzipAsync = promisify(gunzip);
|
|
27
|
+
export const BUNDLE_SCHEMA = 'atlas.auth.bundle/v1';
|
|
28
|
+
export const BUNDLE_VERSION = 1;
|
|
29
|
+
/** 短命 token cookie 名集合(--refresh-only 时剥离) */
|
|
30
|
+
const EPHEMERAL_TOKEN_NAMES = new Set([
|
|
31
|
+
'access_token',
|
|
32
|
+
'access_token.sig',
|
|
33
|
+
'refresh_token',
|
|
34
|
+
'refresh_token.sig',
|
|
35
|
+
]);
|
|
36
|
+
/** 构造 bundle(纯函数) */
|
|
37
|
+
export function buildBundle(cookies, opts) {
|
|
38
|
+
const filtered = opts.refreshOnly === false
|
|
39
|
+
? cookies
|
|
40
|
+
: cookies.filter((c) => !EPHEMERAL_TOKEN_NAMES.has(c.name));
|
|
41
|
+
return {
|
|
42
|
+
manifest: {
|
|
43
|
+
schema: BUNDLE_SCHEMA,
|
|
44
|
+
version: BUNDLE_VERSION,
|
|
45
|
+
os: opts.os ?? platform(),
|
|
46
|
+
exportedAt: opts.exportedAt,
|
|
47
|
+
refreshOnly: opts.refreshOnly,
|
|
48
|
+
},
|
|
49
|
+
cookies: filtered,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** 序列化为 gzip + base64 字符串(适合 --base64 stdout) */
|
|
53
|
+
export async function serializeBundleBase64(bundle) {
|
|
54
|
+
const json = JSON.stringify(bundle);
|
|
55
|
+
const zipped = await gzipAsync(Buffer.from(json, 'utf-8'));
|
|
56
|
+
return zipped.toString('base64');
|
|
57
|
+
}
|
|
58
|
+
/** 序列化为 gzip Buffer(适合写文件) */
|
|
59
|
+
export async function serializeBundleBuffer(bundle) {
|
|
60
|
+
const json = JSON.stringify(bundle);
|
|
61
|
+
return gzipAsync(Buffer.from(json, 'utf-8'));
|
|
62
|
+
}
|
|
63
|
+
/** 校验 manifest schema/version,失败抛 ConfigError */
|
|
64
|
+
function assertManifest(m) {
|
|
65
|
+
if (!m || typeof m !== 'object') {
|
|
66
|
+
throw new ConfigError('无效 bundle:manifest 缺失');
|
|
67
|
+
}
|
|
68
|
+
const o = m;
|
|
69
|
+
if (o.schema !== BUNDLE_SCHEMA) {
|
|
70
|
+
throw new ConfigError(`无效 bundle:schema 不匹配(期望 ${BUNDLE_SCHEMA},实际 ${String(o.schema)})`);
|
|
71
|
+
}
|
|
72
|
+
if (typeof o.version !== 'number' || o.version > BUNDLE_VERSION) {
|
|
73
|
+
throw new ConfigError(`无效 bundle:版本不支持(当前 ${BUNDLE_VERSION},bundle ${String(o.version)})`);
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(o.cookies) === false && !o.cookies === undefined) {
|
|
76
|
+
// bundles with cookies at top handled below; manifest-only ok
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** 解析 base64 字符串 → AuthBundle */
|
|
80
|
+
export async function parseBundleBase64(b64) {
|
|
81
|
+
const buf = Buffer.from(b64.trim(), 'base64');
|
|
82
|
+
let json;
|
|
83
|
+
try {
|
|
84
|
+
json = (await gunzipAsync(buf)).toString('utf-8');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// 兼容未压缩的纯 base64 JSON(少数手动构造场景)
|
|
88
|
+
try {
|
|
89
|
+
json = buf.toString('utf-8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new ConfigError('无效 bundle:base64 解码失败');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return parseBundleJson(json);
|
|
96
|
+
}
|
|
97
|
+
/** 解析文件(gzip 二进制 或 base64 文本)→ AuthBundle */
|
|
98
|
+
export async function parseBundleFile(path) {
|
|
99
|
+
if (!existsSync(path)) {
|
|
100
|
+
throw new ConfigError(`导入文件不存在:${path}`);
|
|
101
|
+
}
|
|
102
|
+
const raw = await readFile(path);
|
|
103
|
+
// 二进制 gzip 文件(前两字节 0x1f 0x8b)
|
|
104
|
+
if (raw.length >= 2 && raw[0] === 0x1f && raw[1] === 0x8b) {
|
|
105
|
+
const json = (await gunzipAsync(raw)).toString('utf-8');
|
|
106
|
+
return parseBundleJson(json);
|
|
107
|
+
}
|
|
108
|
+
// 否则按 base64 文本处理
|
|
109
|
+
return parseBundleBase64(raw.toString('utf-8'));
|
|
110
|
+
}
|
|
111
|
+
function parseBundleJson(json) {
|
|
112
|
+
let obj;
|
|
113
|
+
try {
|
|
114
|
+
obj = JSON.parse(json);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new ConfigError('无效 bundle:JSON 解析失败');
|
|
118
|
+
}
|
|
119
|
+
if (!obj || typeof obj !== 'object') {
|
|
120
|
+
throw new ConfigError('无效 bundle:根节点非对象');
|
|
121
|
+
}
|
|
122
|
+
const o = obj;
|
|
123
|
+
assertManifest(o.manifest);
|
|
124
|
+
if (!Array.isArray(o.cookies)) {
|
|
125
|
+
throw new ConfigError('无效 bundle:cookies 字段非数组');
|
|
126
|
+
}
|
|
127
|
+
const bundle = {
|
|
128
|
+
manifest: o.manifest,
|
|
129
|
+
cookies: o.cookies,
|
|
130
|
+
};
|
|
131
|
+
const currentOs = platform();
|
|
132
|
+
return {
|
|
133
|
+
bundle,
|
|
134
|
+
warning: {
|
|
135
|
+
osMismatch: bundle.manifest.os !== currentOs,
|
|
136
|
+
refreshOnly: bundle.manifest.refreshOnly,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 写 bundle 到文件路径(secureWriteFile 0600)。
|
|
142
|
+
*
|
|
143
|
+
* 与 baseline/actual export 的 --out 白名单(resolveSecureExportPath)不同:
|
|
144
|
+
* auth export 输出的是便携凭证 bundle,用户主动指定任意路径(如 /tmp 方便传输
|
|
145
|
+
* 到沙盒)是其核心用例,因此放宽为绝对/相对路径均可,但仍以 0600 写入。
|
|
146
|
+
*/
|
|
147
|
+
export async function writeBundleToFile(bundle, outPath, base64) {
|
|
148
|
+
const resolved = resolve(process.cwd(), outPath);
|
|
149
|
+
const content = base64 ? await serializeBundleBase64(bundle) : await serializeBundleBuffer(bundle);
|
|
150
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
151
|
+
await secureWriteFile(resolved, content);
|
|
152
|
+
return resolved;
|
|
153
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const ATLAS_VERSION = '0.7.
|
|
1
|
+
export const ATLAS_VERSION = '0.7.23';
|