@dreamor/atlas-cli 0.7.21 → 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 +55 -5
- package/dist/adapters/atlas/commands/actual/_logic.js +62 -27
- package/dist/adapters/atlas/commands/actual/index.js +13 -10
- package/dist/adapters/atlas/commands/auth.js +1 -1
- package/dist/adapters/atlas/commands/baseline/index.js +30 -33
- package/dist/adapters/atlas/commands/compare/index.js +23 -21
- 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
|
@@ -3,30 +3,30 @@ import { isJsonMode, jsonOk, log } from '../../util/output.js';
|
|
|
3
3
|
import { enforceOutputLimit } from '../../util/output-limit.js';
|
|
4
4
|
import { expandMonths, expandMonthsDefault } from '../../util/months.js';
|
|
5
5
|
import { resolveSecureExportPath, secureWriteFile } from '../../util/secure-fs.js';
|
|
6
|
-
import { annotateWithMonth, aggregateByAxis, filterByStaff, flattenTree, } from './_logic.js';
|
|
6
|
+
import { annotateWithMonth, aggregateByAxis, filterByStaff, flattenTree, normalizeStatusFilter, } from './_logic.js';
|
|
7
7
|
import { resolveProjectId } from '../../util/env.js';
|
|
8
8
|
import { readBanmaIdentity } from '../../auth/session.js';
|
|
9
9
|
function getProjectId(opts) {
|
|
10
10
|
return resolveProjectId(opts.projectId);
|
|
11
11
|
}
|
|
12
|
-
async function fetchActual(pid, month) {
|
|
12
|
+
async function fetchActual(pid, month, statusFilter) {
|
|
13
13
|
const client = createClient();
|
|
14
14
|
const identity = await readBanmaIdentity();
|
|
15
15
|
const loginStaffId = identity?.staffId ?? '';
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
const raw = await client.post('/yuntu-service/manpower/weekly/
|
|
16
|
+
// 项目视图:使用 summaryByProject 获取全项目维度的实际工时。
|
|
17
|
+
// API 返回全部状态的数据,statusFilter 在客户端按 status 字段过滤(与网站一致)。
|
|
18
|
+
const raw = await client.post('/yuntu-service/manpower/weekly/summaryByProject.json', { month, staffIds: [], projectIds: [pid], isConfirm: false, loginStaffId });
|
|
19
19
|
const tree = (Array.isArray(raw) ? raw : []);
|
|
20
20
|
// 根节点为当前用户,children 为团队成员
|
|
21
21
|
if (tree.length > 0 && tree[0].children) {
|
|
22
|
-
return flattenTree(tree[0].children, pid);
|
|
22
|
+
return flattenTree(tree[0].children, pid, statusFilter);
|
|
23
23
|
}
|
|
24
24
|
return [];
|
|
25
25
|
}
|
|
26
26
|
export async function showCmd(staffId, opts) {
|
|
27
27
|
const pid = getProjectId(opts);
|
|
28
28
|
const m = opts.month ?? expandMonthsDefault()[0];
|
|
29
|
-
const data = await fetchActual(pid, m);
|
|
29
|
+
const data = await fetchActual(pid, m, normalizeStatusFilter(opts.status));
|
|
30
30
|
const filtered = filterByStaff(data, staffId);
|
|
31
31
|
if (opts.json || isJsonMode()) {
|
|
32
32
|
jsonOk({ staffId, month: m, personnel: filtered });
|
|
@@ -44,11 +44,12 @@ export async function monthCmd(opts) {
|
|
|
44
44
|
const pid = getProjectId(opts);
|
|
45
45
|
// 不传月份时默认最近 12 个月,与 baseline month 行为一致
|
|
46
46
|
const months = opts.month ? [opts.month] : (opts.from || opts.to ? expandMonths(opts.from, opts.to) : expandMonthsDefault());
|
|
47
|
+
const statusFilter = normalizeStatusFilter(opts.status);
|
|
47
48
|
const all = [];
|
|
48
49
|
const failedMonths = [];
|
|
49
50
|
for (const m of months) {
|
|
50
51
|
try {
|
|
51
|
-
const data = await fetchActual(pid, m);
|
|
52
|
+
const data = await fetchActual(pid, m, statusFilter);
|
|
52
53
|
all.push(...annotateWithMonth(data, m));
|
|
53
54
|
}
|
|
54
55
|
catch (e) {
|
|
@@ -76,11 +77,12 @@ export async function monthCmd(opts) {
|
|
|
76
77
|
export async function summaryCmd(opts) {
|
|
77
78
|
const pid = getProjectId(opts);
|
|
78
79
|
const months = opts.month ? [opts.month] : (opts.from || opts.to ? expandMonths(opts.from, opts.to) : expandMonthsDefault());
|
|
80
|
+
const statusFilter = normalizeStatusFilter(opts.status);
|
|
79
81
|
const all = [];
|
|
80
82
|
const failedMonths = [];
|
|
81
83
|
for (const m of months) {
|
|
82
84
|
try {
|
|
83
|
-
all.push(...annotateWithMonth(await fetchActual(pid, m), m));
|
|
85
|
+
all.push(...annotateWithMonth(await fetchActual(pid, m, statusFilter), m));
|
|
84
86
|
}
|
|
85
87
|
catch (e) {
|
|
86
88
|
// 实际工时 API 对无数据月份也返回 501("当前token已失效"),
|
|
@@ -105,11 +107,12 @@ export async function summaryCmd(opts) {
|
|
|
105
107
|
export async function exportCmd(opts) {
|
|
106
108
|
const pid = getProjectId(opts);
|
|
107
109
|
const months = expandMonths(opts.from, opts.to);
|
|
110
|
+
const statusFilter = normalizeStatusFilter(opts.status);
|
|
108
111
|
const rows = [];
|
|
109
112
|
const failedMonths = [];
|
|
110
113
|
for (const m of months) {
|
|
111
114
|
try {
|
|
112
|
-
const data = await fetchActual(pid, m);
|
|
115
|
+
const data = await fetchActual(pid, m, statusFilter);
|
|
113
116
|
rows.push(...annotateWithMonth(data, m));
|
|
114
117
|
}
|
|
115
118
|
catch (e) {
|
|
@@ -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';
|
|
@@ -36,25 +36,24 @@ function filterBaseline(details, opts) {
|
|
|
36
36
|
}
|
|
37
37
|
export async function monthCmd(opts) {
|
|
38
38
|
const pid = getProjectId(opts);
|
|
39
|
+
// 基线 API 一次返回项目全部月份数据,不在月份循环内调用(B1)
|
|
39
40
|
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
41
|
+
const data = await fetchBaselineMonth(pid, months[0]);
|
|
40
42
|
const allDetails = [];
|
|
41
|
-
for (const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
areaCode,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
43
|
+
for (const item of (data ?? [])) {
|
|
44
|
+
const details = (item.linePlanMonthDetailList ?? []);
|
|
45
|
+
const mpType = String(item.mpType ?? '');
|
|
46
|
+
const areaCode = item.areaCode;
|
|
47
|
+
for (const d of details) {
|
|
48
|
+
if (isMonthKey(d)) {
|
|
49
|
+
allDetails.push({
|
|
50
|
+
month: monthTsToKey(d.month),
|
|
51
|
+
manpower: d.manpower ?? 0,
|
|
52
|
+
role: item.role,
|
|
53
|
+
departmentName: item.departmentName,
|
|
54
|
+
mpType,
|
|
55
|
+
areaCode,
|
|
56
|
+
});
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
}
|
|
@@ -79,17 +78,16 @@ export async function monthCmd(opts) {
|
|
|
79
78
|
}
|
|
80
79
|
export async function summaryCmd(opts) {
|
|
81
80
|
const pid = getProjectId(opts);
|
|
81
|
+
// 基线 API 一次返回项目全部月份数据,不在月份循环内调用(B1)
|
|
82
82
|
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
83
|
+
const data = await fetchBaselineMonth(pid, months[0]);
|
|
83
84
|
const allDetails = [];
|
|
84
|
-
for (const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (isMonthKey(d)) {
|
|
91
|
-
allDetails.push({ month: monthTsToKey(d.month), manpower: d.manpower ?? 0, role: item.role, departmentName: item.departmentName, mpType, areaCode });
|
|
92
|
-
}
|
|
85
|
+
for (const item of (data ?? [])) {
|
|
86
|
+
const mpType = String(item.mpType ?? '');
|
|
87
|
+
const areaCode = item.areaCode;
|
|
88
|
+
for (const d of (item.linePlanMonthDetailList ?? [])) {
|
|
89
|
+
if (isMonthKey(d)) {
|
|
90
|
+
allDetails.push({ month: monthTsToKey(d.month), manpower: d.manpower ?? 0, role: item.role, departmentName: item.departmentName, mpType, areaCode });
|
|
93
91
|
}
|
|
94
92
|
}
|
|
95
93
|
}
|
|
@@ -113,15 +111,14 @@ export async function summaryCmd(opts) {
|
|
|
113
111
|
}
|
|
114
112
|
export async function exportCmd(opts) {
|
|
115
113
|
const pid = getProjectId(opts);
|
|
114
|
+
// 基线 API 一次返回项目全部月份数据,不在月份循环内调用(B1)
|
|
116
115
|
const months = expandMonths(opts.from, opts.to);
|
|
116
|
+
const data = await fetchBaselineMonth(pid, months[0]);
|
|
117
117
|
const rows = [];
|
|
118
|
-
for (const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (isMonthKey(d)) {
|
|
123
|
-
rows.push({ projectId: pid, month: monthTsToKey(d.month), role: item.role ?? '', manpower: d.manpower ?? 0 });
|
|
124
|
-
}
|
|
118
|
+
for (const item of (data ?? [])) {
|
|
119
|
+
for (const d of (item.linePlanMonthDetailList ?? [])) {
|
|
120
|
+
if (isMonthKey(d)) {
|
|
121
|
+
rows.push({ projectId: pid, month: monthTsToKey(d.month), role: item.role ?? '', manpower: d.manpower ?? 0 });
|
|
125
122
|
}
|
|
126
123
|
}
|
|
127
124
|
}
|
|
@@ -6,49 +6,51 @@ import { monthTsToKey } from '../../util/time.js';
|
|
|
6
6
|
import { groupByAxis, mergeBaselineActual, } from './_logic.js';
|
|
7
7
|
import { resolveProjectId, resolveProjectInfo } from '../../util/env.js';
|
|
8
8
|
import { readBanmaIdentity } from '../../auth/session.js';
|
|
9
|
-
import { flattenTree as flattenActualTree } from '../actual/_logic.js';
|
|
9
|
+
import { flattenTree as flattenActualTree, normalizeStatusFilter } from '../actual/_logic.js';
|
|
10
10
|
function getProjectId(opts) {
|
|
11
11
|
return resolveProjectId(opts.projectId);
|
|
12
12
|
}
|
|
13
13
|
export async function compareCmd(opts) {
|
|
14
14
|
const pid = getProjectId(opts);
|
|
15
15
|
const months = opts.month ? [opts.month] : (opts.from || opts.to ? expandMonths(opts.from, opts.to) : expandMonthsDefault());
|
|
16
|
+
const statusFilter = normalizeStatusFilter(opts.status);
|
|
16
17
|
const allBl = [];
|
|
17
18
|
const allAc = [];
|
|
18
19
|
const client = createClient();
|
|
19
20
|
const failedMonths = [];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
21
|
+
// 基线 API 一次返回项目全部月份数据,在循环外只调用一次(B1)
|
|
22
|
+
try {
|
|
23
|
+
const blData = await client.post('/yuntu-service/line/plan/month/select.json', { projectId: pid, month: months[0] ?? '' });
|
|
24
|
+
for (const item of (blData ?? [])) {
|
|
25
|
+
const details = (item.linePlanMonthDetailList ?? []);
|
|
26
|
+
for (const d of details) {
|
|
27
|
+
if (d && typeof d === 'object') {
|
|
28
|
+
const monthVal = d.month;
|
|
29
|
+
const monthKey = typeof monthVal === 'number'
|
|
30
|
+
? monthTsToKey(monthVal)
|
|
31
|
+
: months[0];
|
|
32
|
+
allBl.push({ month: monthKey, manpower: Number(d.manpower ?? 0), role: item.role, departmentName: item.departmentName });
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof SessionExpiredError)
|
|
39
|
+
throw e;
|
|
40
|
+
log(`基线数据拉取失败: ${e instanceof Error ? e.message : String(e)}`);
|
|
41
|
+
}
|
|
42
|
+
for (const m of months) {
|
|
41
43
|
// 指数退避:避免连续触发限流(B2)
|
|
42
44
|
try {
|
|
43
45
|
const identity = await readBanmaIdentity();
|
|
44
46
|
const loginStaffId = identity?.staffId ?? '';
|
|
45
|
-
const raw = await client.post('/yuntu-service/manpower/weekly/
|
|
47
|
+
const raw = await client.post('/yuntu-service/manpower/weekly/summaryByProject.json', {
|
|
46
48
|
month: m, staffIds: [], projectIds: [pid], isConfirm: false, loginStaffId,
|
|
47
49
|
});
|
|
48
50
|
const tree = (Array.isArray(raw) ? raw : []);
|
|
49
51
|
const results = [];
|
|
50
52
|
if (tree.length > 0 && tree[0].children) {
|
|
51
|
-
const entries = flattenActualTree(tree[0].children, pid);
|
|
53
|
+
const entries = flattenActualTree(tree[0].children, pid, statusFilter);
|
|
52
54
|
for (const p of entries) {
|
|
53
55
|
results.push({ month: m, manpower: p.manpower ?? 0, role: p.role, departmentName: p.departmentName });
|
|
54
56
|
}
|
|
@@ -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 };
|