@actiondock/core 2.8.0 → 2.9.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/README.md +20 -0
- package/dist/errors.d.ts +5 -1
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/project/init.js +2 -2
- package/dist/project/types.d.ts +5 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/policy.d.ts +138 -0
- package/dist/server/policy.js +368 -0
- package/dist/server/routes/actions.js +86 -42
- package/dist/server/routes/common.d.ts +14 -25
- package/dist/server/routes/common.js +25 -21
- package/dist/server/routes/config.d.ts +1 -1
- package/dist/server/routes/config.js +8 -8
- package/dist/server/routes/doctor.js +4 -4
- package/dist/server/routes/health.js +2 -2
- package/dist/server/routes/info.js +6 -12
- package/dist/server/routes/playbooks.js +8 -8
- package/dist/server/routes/runs.js +61 -29
- package/dist/server/routes/state.d.ts +1 -1
- package/dist/server/routes/state.js +7 -7
- package/dist/server/security.d.ts +11 -0
- package/dist/server/security.js +36 -19
- package/dist/server/server.js +200 -30
- package/dist/server/types.d.ts +60 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { CAPABILITY_UNAVAILABLE, EVENT_BACKPRESSURE_LIMIT, EVENT_CURSOR_EXPIRED, EXECUTION_FAILED, PACKAGE_FORBIDDEN, RUN_ALREADY_FINISHED, RUN_INTERRUPTED, RUN_NOT_FOUND, RUNS_CLEAR_ERROR, RUNS_LIST_ERROR, } from "../../errors.js";
|
|
1
|
+
import { ACTION_FORBIDDEN, CAPABILITY_UNAVAILABLE, EVENT_BACKPRESSURE_LIMIT, EVENT_CURSOR_EXPIRED, EXECUTION_FAILED, PACKAGE_FORBIDDEN, RUN_ALREADY_FINISHED, RUN_INTERRUPTED, RUN_NOT_FOUND, RUNS_CLEAR_ERROR, RUNS_LIST_ERROR, } from "../../errors.js";
|
|
2
2
|
import { isTerminalRunStatus } from "../../storage/types.js";
|
|
3
3
|
import { readJsonBody } from "../body.js";
|
|
4
|
-
import { getSubPath, jsonResponse } from "./common.js";
|
|
4
|
+
import { getSubPath, isActionAllowedByPolicy, isPackageAllowedByPolicy, jsonResponse } from "./common.js";
|
|
5
5
|
/**
|
|
6
6
|
* 处理历史运行记录查询、清理、详情及 SSE 流式日志接口。
|
|
7
7
|
*/
|
|
8
8
|
export async function handleRunsRoutes(ctx) {
|
|
9
|
-
const { req, url, pathname, corsHeaders, options, service } = ctx;
|
|
9
|
+
const { req, url, pathname, corsHeaders, options, service, activePolicy: policy } = ctx;
|
|
10
10
|
const subpath = getSubPath(pathname);
|
|
11
11
|
// 1. Runs List: GET /api/v2/runs, GET /runs
|
|
12
12
|
if (subpath === "/runs" && req.method === "GET") {
|
|
@@ -20,10 +20,7 @@ export async function handleRunsRoutes(ctx) {
|
|
|
20
20
|
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
|
21
21
|
? Math.min(parsedLimit, 500)
|
|
22
22
|
: 50;
|
|
23
|
-
if (packageId &&
|
|
24
|
-
options.packageAllowlist &&
|
|
25
|
-
options.packageAllowlist.length > 0 &&
|
|
26
|
-
!options.packageAllowlist.includes(packageId)) {
|
|
23
|
+
if (packageId && !isPackageAllowedByPolicy(packageId, policy)) {
|
|
27
24
|
return jsonResponse({
|
|
28
25
|
ok: false,
|
|
29
26
|
error: {
|
|
@@ -32,6 +29,15 @@ export async function handleRunsRoutes(ctx) {
|
|
|
32
29
|
},
|
|
33
30
|
}, 403, corsHeaders);
|
|
34
31
|
}
|
|
32
|
+
if (actionId && !isActionAllowedByPolicy({ packageId, actionId }, policy)) {
|
|
33
|
+
return jsonResponse({
|
|
34
|
+
ok: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: ACTION_FORBIDDEN,
|
|
37
|
+
message: `Action '${packageId ? `${packageId}/${actionId}` : actionId}' is not in the allowed action list`,
|
|
38
|
+
},
|
|
39
|
+
}, 403, corsHeaders);
|
|
40
|
+
}
|
|
35
41
|
let allRuns = await service.runs.list({
|
|
36
42
|
actionId,
|
|
37
43
|
status,
|
|
@@ -39,8 +45,11 @@ export async function handleRunsRoutes(ctx) {
|
|
|
39
45
|
intent,
|
|
40
46
|
limit,
|
|
41
47
|
});
|
|
42
|
-
if (
|
|
43
|
-
allRuns = allRuns.filter((r) =>
|
|
48
|
+
if (policy.packageAllowlist && policy.packageAllowlist.length > 0) {
|
|
49
|
+
allRuns = allRuns.filter((r) => isPackageAllowedByPolicy(r.packageId, policy));
|
|
50
|
+
}
|
|
51
|
+
if (policy.actionAllowlist && policy.actionAllowlist.length > 0) {
|
|
52
|
+
allRuns = allRuns.filter((r) => isActionAllowedByPolicy({ packageId: r.packageId, actionId: r.actionId }, policy));
|
|
44
53
|
}
|
|
45
54
|
const sliced = allRuns.slice(0, limit);
|
|
46
55
|
return jsonResponse({ ok: true, total: allRuns.length, items: sliced }, 200, corsHeaders);
|
|
@@ -60,10 +69,7 @@ export async function handleRunsRoutes(ctx) {
|
|
|
60
69
|
const packageId = url.searchParams.get("packageId") || body.packageId || undefined;
|
|
61
70
|
const actionId = url.searchParams.get("actionId") || body.actionId || undefined;
|
|
62
71
|
const status = url.searchParams.get("status") || body.status || undefined;
|
|
63
|
-
if (packageId &&
|
|
64
|
-
options.packageAllowlist &&
|
|
65
|
-
options.packageAllowlist.length > 0 &&
|
|
66
|
-
!options.packageAllowlist.includes(packageId)) {
|
|
72
|
+
if (packageId && !isPackageAllowedByPolicy(packageId, policy)) {
|
|
67
73
|
return jsonResponse({
|
|
68
74
|
ok: false,
|
|
69
75
|
error: {
|
|
@@ -90,14 +96,21 @@ export async function handleRunsRoutes(ctx) {
|
|
|
90
96
|
if (!run) {
|
|
91
97
|
return jsonResponse({ ok: false, error: { code: RUN_NOT_FOUND, message: `Run '${runId}' not found` } }, 404, corsHeaders);
|
|
92
98
|
}
|
|
93
|
-
if (
|
|
94
|
-
options.packageAllowlist.length > 0 &&
|
|
95
|
-
(!run.packageId || !options.packageAllowlist.includes(run.packageId))) {
|
|
99
|
+
if (!isPackageAllowedByPolicy(run.packageId, policy)) {
|
|
96
100
|
return jsonResponse({
|
|
97
101
|
ok: false,
|
|
98
102
|
error: {
|
|
99
103
|
code: PACKAGE_FORBIDDEN,
|
|
100
|
-
message: `Package '${run.packageId}' is not in the allowed package list`,
|
|
104
|
+
message: `Package '${run.packageId || "unknown"}' is not in the allowed package list`,
|
|
105
|
+
},
|
|
106
|
+
}, 403, corsHeaders);
|
|
107
|
+
}
|
|
108
|
+
if (!isActionAllowedByPolicy({ packageId: run.packageId, actionId: run.actionId }, policy)) {
|
|
109
|
+
return jsonResponse({
|
|
110
|
+
ok: false,
|
|
111
|
+
error: {
|
|
112
|
+
code: ACTION_FORBIDDEN,
|
|
113
|
+
message: `Action '${run.packageId ? `${run.packageId}/${run.actionId}` : run.actionId}' is not in the allowed action list`,
|
|
101
114
|
},
|
|
102
115
|
}, 403, corsHeaders);
|
|
103
116
|
}
|
|
@@ -258,9 +271,7 @@ export async function handleRunsRoutes(ctx) {
|
|
|
258
271
|
},
|
|
259
272
|
}, 404, corsHeaders);
|
|
260
273
|
}
|
|
261
|
-
if (
|
|
262
|
-
options.packageAllowlist.length > 0 &&
|
|
263
|
-
(!run.packageId || !options.packageAllowlist.includes(run.packageId))) {
|
|
274
|
+
if (!isPackageAllowedByPolicy(run.packageId, policy)) {
|
|
264
275
|
return jsonResponse({
|
|
265
276
|
ok: false,
|
|
266
277
|
error: {
|
|
@@ -269,22 +280,43 @@ export async function handleRunsRoutes(ctx) {
|
|
|
269
280
|
},
|
|
270
281
|
}, 403, corsHeaders);
|
|
271
282
|
}
|
|
283
|
+
if (!isActionAllowedByPolicy({ packageId: run.packageId, actionId: run.actionId }, policy)) {
|
|
284
|
+
return jsonResponse({
|
|
285
|
+
ok: false,
|
|
286
|
+
error: {
|
|
287
|
+
code: ACTION_FORBIDDEN,
|
|
288
|
+
message: `Action '${run.packageId ? `${run.packageId}/${run.actionId}` : run.actionId}' is not in the allowed action list`,
|
|
289
|
+
},
|
|
290
|
+
}, 403, corsHeaders);
|
|
291
|
+
}
|
|
272
292
|
return jsonResponse(run, 200, corsHeaders);
|
|
273
293
|
}
|
|
274
294
|
// 5. Run Cancel: POST /api/v2/runs/:runId/cancel, POST /runs/:runId/cancel
|
|
275
295
|
const runCancelMatch = subpath.match(/^\/runs\/([^/]+)\/cancel$/);
|
|
276
296
|
if (runCancelMatch && req.method === "POST") {
|
|
277
297
|
const runId = decodeURIComponent(runCancelMatch[1]);
|
|
278
|
-
if (
|
|
298
|
+
if ((policy.packageAllowlist && policy.packageAllowlist.length > 0) ||
|
|
299
|
+
(policy.actionAllowlist && policy.actionAllowlist.length > 0)) {
|
|
279
300
|
const run = await service.runs.get(runId);
|
|
280
|
-
if (run
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
if (run) {
|
|
302
|
+
if (!isPackageAllowedByPolicy(run.packageId, policy)) {
|
|
303
|
+
return jsonResponse({
|
|
304
|
+
ok: false,
|
|
305
|
+
error: {
|
|
306
|
+
code: PACKAGE_FORBIDDEN,
|
|
307
|
+
message: `Package '${run.packageId || "unknown"}' is not in the allowed package list`,
|
|
308
|
+
},
|
|
309
|
+
}, 403, corsHeaders);
|
|
310
|
+
}
|
|
311
|
+
if (!isActionAllowedByPolicy({ packageId: run.packageId, actionId: run.actionId }, policy)) {
|
|
312
|
+
return jsonResponse({
|
|
313
|
+
ok: false,
|
|
314
|
+
error: {
|
|
315
|
+
code: ACTION_FORBIDDEN,
|
|
316
|
+
message: `Action '${run.packageId ? `${run.packageId}/${run.actionId}` : run.actionId}' is not in the allowed action list`,
|
|
317
|
+
},
|
|
318
|
+
}, 403, corsHeaders);
|
|
319
|
+
}
|
|
288
320
|
}
|
|
289
321
|
}
|
|
290
322
|
let body = {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { decodeStateKey } from "../../storage/index.js";
|
|
2
2
|
import { AMBIGUOUS_STATE_KEY, CAPABILITY_UNAVAILABLE, INVALID_ARGUMENT, INVALID_PACKAGE_ID, PACKAGE_NOT_ALLOWED, PACKAGE_NOT_FOUND, PATH_TRAVERSAL, STATE_CLEAR_ERROR, STATE_KEY_ERROR, STATE_KEY_NOT_FOUND, STATE_LIST_ERROR, } from "../../errors.js";
|
|
3
3
|
import { readJsonBody } from "../body.js";
|
|
4
|
-
import { getSubPath, jsonResponse, resolveTargetPackageId } from "./common.js";
|
|
4
|
+
import { getSubPath, isManagementAllowedByPolicy, jsonResponse, resolveTargetPackageId, } from "./common.js";
|
|
5
5
|
function isClientStateError(err) {
|
|
6
6
|
return (err?.code === PACKAGE_NOT_FOUND ||
|
|
7
7
|
err?.code === INVALID_PACKAGE_ID ||
|
|
@@ -13,10 +13,10 @@ function isClientStateError(err) {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* 处理状态键名列表、读取、写入、删除及清空接口。
|
|
16
|
-
*
|
|
16
|
+
* 在未显式开启管理功能时返回 403 拒绝。
|
|
17
17
|
*/
|
|
18
18
|
export async function handleStateRoutes(ctx) {
|
|
19
|
-
const { req, url, pathname, corsHeaders, options, service } = ctx;
|
|
19
|
+
const { req, url, pathname, corsHeaders, options, service, activePolicy: policy } = ctx;
|
|
20
20
|
const subpath = getSubPath(pathname);
|
|
21
21
|
const isStateRoute = subpath === "/state" ||
|
|
22
22
|
subpath.startsWith("/state/") ||
|
|
@@ -25,7 +25,7 @@ export async function handleStateRoutes(ctx) {
|
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
27
27
|
// 校验管理能力开关
|
|
28
|
-
if (
|
|
28
|
+
if (!isManagementAllowedByPolicy(policy) || !service.management?.state) {
|
|
29
29
|
return jsonResponse({
|
|
30
30
|
ok: false,
|
|
31
31
|
error: {
|
|
@@ -42,7 +42,7 @@ export async function handleStateRoutes(ctx) {
|
|
|
42
42
|
const nsParam = url.searchParams.get("namespace") ?? undefined;
|
|
43
43
|
const prefix = url.searchParams.get("prefix") || "";
|
|
44
44
|
const pkgs = await service.discovery.listPackages();
|
|
45
|
-
const targetPackageId = resolveTargetPackageId(pkgs, pkgParam,
|
|
45
|
+
const targetPackageId = resolveTargetPackageId(pkgs, pkgParam, policy);
|
|
46
46
|
const keys = await service.management.state.list(targetPackageId, actionParam, {
|
|
47
47
|
namespace: nsParam,
|
|
48
48
|
prefix,
|
|
@@ -71,7 +71,7 @@ export async function handleStateRoutes(ctx) {
|
|
|
71
71
|
const actionParam = url.searchParams.get("action") || url.searchParams.get("actionId") || body.action || body.actionId || "";
|
|
72
72
|
const baseNs = body.namespace ?? (url.searchParams.get("namespace") || undefined);
|
|
73
73
|
const pkgs = await service.discovery.listPackages();
|
|
74
|
-
const targetPackageId = resolveTargetPackageId(pkgs, pkgParam,
|
|
74
|
+
const targetPackageId = resolveTargetPackageId(pkgs, pkgParam, policy);
|
|
75
75
|
const clearedCount = await service.management.state.clear(targetPackageId, actionParam, {
|
|
76
76
|
namespace: baseNs,
|
|
77
77
|
all: Boolean(body.all ?? url.searchParams.get("all") === "true"),
|
|
@@ -102,7 +102,7 @@ export async function handleStateRoutes(ctx) {
|
|
|
102
102
|
const actionParam = url.searchParams.get("action") || url.searchParams.get("actionId") || "";
|
|
103
103
|
const nsParam = url.searchParams.get("namespace") || undefined;
|
|
104
104
|
const pkgs = await service.discovery.listPackages();
|
|
105
|
-
const targetPackageId = resolveTargetPackageId(pkgs, pkgParam,
|
|
105
|
+
const targetPackageId = resolveTargetPackageId(pkgs, pkgParam, policy);
|
|
106
106
|
if (req.method === "GET") {
|
|
107
107
|
const entry = await service.management.state.get(targetPackageId, actionParam, key, {
|
|
108
108
|
namespace: nsParam,
|
|
@@ -15,6 +15,17 @@ export interface VerifyBearerTokenOptions {
|
|
|
15
15
|
/** 是否允许通过 URL 查询参数携带 Token(默认 false) */
|
|
16
16
|
allowQueryToken?: boolean;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* 从 HTTP 请求中提取 Bearer Token。
|
|
20
|
+
*
|
|
21
|
+
* 默认仅接受 HTTP 请求头: `Authorization: Bearer <token>`。
|
|
22
|
+
* 仅当 allowQueryToken 为 true 时才允许解析 URL 查询参数 `?token=<token>`。
|
|
23
|
+
*
|
|
24
|
+
* @param req 传入的 HTTP Request 对象
|
|
25
|
+
* @param allowQueryToken 是否允许从 URL 查询参数解析 Token
|
|
26
|
+
* @returns 提取出的 Token 字符串,若未携带则返回 null
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractBearerToken(req: Request, allowQueryToken?: boolean): string | null;
|
|
18
29
|
/**
|
|
19
30
|
* 校验 HTTP 请求中的鉴权令牌是否有效。
|
|
20
31
|
*
|
package/dist/server/security.js
CHANGED
|
@@ -13,49 +13,66 @@ export function safeEqual(a, b) {
|
|
|
13
13
|
const aa = Buffer.from(a);
|
|
14
14
|
const bb = Buffer.from(b);
|
|
15
15
|
if (aa.length !== bb.length) {
|
|
16
|
+
// 长度不等时对自身做一次恒定时间比较,消除因长度不等提前退出带来的时序差
|
|
17
|
+
timingSafeEqual(aa, aa);
|
|
16
18
|
return false;
|
|
17
19
|
}
|
|
18
20
|
return timingSafeEqual(aa, bb);
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
|
-
*
|
|
23
|
+
* 从 HTTP 请求中提取 Bearer Token。
|
|
22
24
|
*
|
|
23
25
|
* 默认仅接受 HTTP 请求头: `Authorization: Bearer <token>`。
|
|
24
|
-
* 仅当
|
|
26
|
+
* 仅当 allowQueryToken 为 true 时才允许解析 URL 查询参数 `?token=<token>`。
|
|
25
27
|
*
|
|
26
28
|
* @param req 传入的 HTTP Request 对象
|
|
27
|
-
* @param
|
|
28
|
-
* @
|
|
29
|
-
* @returns 是否鉴权成功
|
|
29
|
+
* @param allowQueryToken 是否允许从 URL 查询参数解析 Token
|
|
30
|
+
* @returns 提取出的 Token 字符串,若未携带则返回 null
|
|
30
31
|
*/
|
|
31
|
-
export function
|
|
32
|
-
if (!expectedToken || !expectedToken.trim()) {
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
35
|
-
const trimmedExpected = expectedToken.trim();
|
|
32
|
+
export function extractBearerToken(req, allowQueryToken) {
|
|
36
33
|
// 1. 请求头 Authorization: Bearer <token>
|
|
37
34
|
const authHeader = req.headers.get("authorization");
|
|
38
35
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
39
36
|
const token = authHeader.slice(7).trim();
|
|
40
|
-
if (
|
|
41
|
-
return
|
|
42
|
-
}
|
|
37
|
+
if (token)
|
|
38
|
+
return token;
|
|
43
39
|
}
|
|
44
40
|
// 2. URL 查询参数: ?token=<token>(仅当显式开启 allowQueryToken 时允许)
|
|
45
|
-
|
|
46
|
-
if (allowQuery) {
|
|
41
|
+
if (allowQueryToken) {
|
|
47
42
|
try {
|
|
48
43
|
const url = new URL(req.url);
|
|
49
44
|
const tokenParam = url.searchParams.get("token");
|
|
50
|
-
if (tokenParam &&
|
|
51
|
-
return
|
|
45
|
+
if (tokenParam && tokenParam.trim()) {
|
|
46
|
+
return tokenParam.trim();
|
|
52
47
|
}
|
|
53
48
|
}
|
|
54
49
|
catch {
|
|
55
|
-
// 畸形 URL
|
|
50
|
+
// 畸形 URL 忽略
|
|
56
51
|
}
|
|
57
52
|
}
|
|
58
|
-
return
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 校验 HTTP 请求中的鉴权令牌是否有效。
|
|
57
|
+
*
|
|
58
|
+
* 默认仅接受 HTTP 请求头: `Authorization: Bearer <token>`。
|
|
59
|
+
* 仅当 options.allowQueryToken 为 true 时才允许解析 URL 查询参数 `?token=<token>`。
|
|
60
|
+
*
|
|
61
|
+
* @param req 传入的 HTTP Request 对象
|
|
62
|
+
* @param expectedToken 服务端预期的正确 Token(若未配置 Token 则默认放行)
|
|
63
|
+
* @param options 校验选项或布尔值 allowQueryToken 开关
|
|
64
|
+
* @returns 是否鉴权成功
|
|
65
|
+
*/
|
|
66
|
+
export function verifyBearerToken(req, expectedToken, options) {
|
|
67
|
+
if (!expectedToken || !expectedToken.trim()) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const allowQuery = typeof options === "boolean" ? options : options?.allowQueryToken === true;
|
|
71
|
+
const token = extractBearerToken(req, allowQuery);
|
|
72
|
+
if (!token) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return safeEqual(token, expectedToken.trim());
|
|
59
76
|
}
|
|
60
77
|
/**
|
|
61
78
|
* 根据配置的 CORS 白名单解析并返回跨域响应头。
|
package/dist/server/server.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { posix, resolve } from "node:path";
|
|
2
2
|
import { NodeHttpServer } from "./http-server.js";
|
|
3
3
|
import { createActionDock } from "../service/factory.js";
|
|
4
4
|
import { NOT_FOUND, UNAUTHORIZED } from "../errors.js";
|
|
5
|
+
import { parseActionRef } from "../catalog/resolve-action.js";
|
|
5
6
|
import { ensureDependencyClosure } from "../project/closure.js";
|
|
6
7
|
import { findProjectRoot } from "../project/loader.js";
|
|
7
8
|
import { listLinkedPackages, resolvePackageRoot } from "../registry/registry.js";
|
|
8
9
|
import { handleActionsRoutes, handleConfigRoutes, handleDoctorRoute, handleHealthRoute, handleInfoRoute, handlePlaybooksRoutes, handleRunsRoutes, handleStateRoutes, jsonResponse, } from "./routes/index.js";
|
|
9
10
|
import { isLoopbackHost, resolveCorsHeaders, verifyBearerToken } from "./security.js";
|
|
10
11
|
import { createMcpEndpointHandler } from "./mcp-endpoint.js";
|
|
12
|
+
import { extractBearerToken, matchViewByToken, normalizeServerViews, } from "./policy.js";
|
|
11
13
|
/**
|
|
12
14
|
* 规范化主机地址用于拼接 URL。
|
|
13
15
|
* 若 host 包含冒号 : 且不以 [ 开头,则添加中括号包裹(IPv6 标准格式)。
|
|
@@ -48,8 +50,13 @@ export async function startActionDockServer(options = {}) {
|
|
|
48
50
|
const projectRoot = options.projectRoot
|
|
49
51
|
? resolve(options.projectRoot)
|
|
50
52
|
: findProjectRoot(process.cwd());
|
|
53
|
+
// 1. 归一化解析视图配置集合(含默认视图与自定义视图)
|
|
54
|
+
const { defaultView, views } = normalizeServerViews(options);
|
|
51
55
|
// 非回环地址强制要求配置 Token 鉴权(防裸奔)
|
|
52
|
-
|
|
56
|
+
const hasTokenConfigured = Boolean(token ||
|
|
57
|
+
defaultView.policy.token ||
|
|
58
|
+
Array.from(views.values()).some((v) => Boolean(v.policy.token)));
|
|
59
|
+
if (!isLoopbackHost(host) && !hasTokenConfigured && !options.allowInsecureNoAuth) {
|
|
53
60
|
throw new Error("Authentication token is required when binding to a non-loopback address. Use --allow-insecure-no-auth to override.");
|
|
54
61
|
}
|
|
55
62
|
let serviceInstance = options.service;
|
|
@@ -69,12 +76,27 @@ export async function startActionDockServer(options = {}) {
|
|
|
69
76
|
if (!serviceInstance) {
|
|
70
77
|
throw new Error("Failed to initialize ActionDockService for server");
|
|
71
78
|
}
|
|
79
|
+
// 2. 聚合所有 views 声明的依赖包与动作,确保依赖闭包满足
|
|
72
80
|
const roots = [];
|
|
73
81
|
if (projectRoot) {
|
|
74
82
|
roots.push(projectRoot);
|
|
75
83
|
}
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
const aggregatedPackageAllowlist = new Set();
|
|
85
|
+
const aggregatedActionAllowlist = new Set();
|
|
86
|
+
for (const v of views.values()) {
|
|
87
|
+
if (v.policy.packageAllowlist) {
|
|
88
|
+
for (const pkg of v.policy.packageAllowlist) {
|
|
89
|
+
aggregatedPackageAllowlist.add(pkg);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (v.policy.actionAllowlist) {
|
|
93
|
+
for (const act of v.policy.actionAllowlist) {
|
|
94
|
+
aggregatedActionAllowlist.add(act);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (aggregatedPackageAllowlist.size > 0) {
|
|
99
|
+
for (const pkgId of aggregatedPackageAllowlist) {
|
|
78
100
|
const r = resolvePackageRoot(pkgId, projectRoot || undefined, customHome);
|
|
79
101
|
if (r && !roots.includes(r)) {
|
|
80
102
|
roots.push(r);
|
|
@@ -88,18 +110,85 @@ export async function startActionDockServer(options = {}) {
|
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
112
|
}
|
|
113
|
+
if (aggregatedActionAllowlist.size > 0) {
|
|
114
|
+
for (const actRef of aggregatedActionAllowlist) {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = parseActionRef(actRef);
|
|
117
|
+
if (parsed.packageId) {
|
|
118
|
+
const r = resolvePackageRoot(parsed.packageId, projectRoot || undefined, customHome);
|
|
119
|
+
if (r && !roots.includes(r)) {
|
|
120
|
+
roots.push(r);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
91
127
|
if (roots.length > 0) {
|
|
92
128
|
await ensureDependencyClosure(roots, { customHome });
|
|
93
129
|
}
|
|
94
|
-
// MCP
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
// 3. 为每个启用 MCP 的视图独立装配 MCP 处理器
|
|
131
|
+
if (options.enableMcp !== false) {
|
|
132
|
+
for (const v of views.values()) {
|
|
133
|
+
if (v.enableMcp !== false) {
|
|
134
|
+
let delegate;
|
|
135
|
+
if (v.rawOptions.mcpHandler) {
|
|
136
|
+
if (v.rawOptions.mcpHandler.length >= 2) {
|
|
137
|
+
delegate = (req) => v.rawOptions.mcpHandler(req, v.policy);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
let factoryResult;
|
|
141
|
+
try {
|
|
142
|
+
factoryResult = v.rawOptions.mcpHandler(v.policy);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
factoryResult = null;
|
|
146
|
+
}
|
|
147
|
+
if (typeof factoryResult === "function") {
|
|
148
|
+
delegate = factoryResult;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
delegate = (req) => v.rawOptions.mcpHandler(req, v.policy);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (options.mcpHandlerFactory) {
|
|
156
|
+
delegate = options.mcpHandlerFactory(v.policy);
|
|
157
|
+
}
|
|
158
|
+
else if (options.mcpHandler) {
|
|
159
|
+
if (options.mcpHandler.length >= 2) {
|
|
160
|
+
delegate = (req) => options.mcpHandler(req, v.policy);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
let factoryResult;
|
|
164
|
+
try {
|
|
165
|
+
factoryResult = options.mcpHandler(v.policy);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
factoryResult = null;
|
|
169
|
+
}
|
|
170
|
+
if (typeof factoryResult === "function") {
|
|
171
|
+
delegate = factoryResult;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
if (factoryResult && typeof factoryResult.catch === "function") {
|
|
175
|
+
factoryResult.catch(() => { });
|
|
176
|
+
}
|
|
177
|
+
delegate = (req) => options.mcpHandler(req, v.policy);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (delegate) {
|
|
182
|
+
v.mcpEndpointHandler = createMcpEndpointHandler(delegate, {
|
|
183
|
+
token: v.policy.token,
|
|
184
|
+
allowQueryToken: options.allowQueryToken,
|
|
185
|
+
corsOrigins: options.corsOrigins,
|
|
186
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
103
192
|
const fetchHandler = async (req) => {
|
|
104
193
|
const origin = req.headers.get("origin");
|
|
105
194
|
const corsHeaders = resolveCorsHeaders(origin, options.corsOrigins);
|
|
@@ -110,32 +199,113 @@ export async function startActionDockServer(options = {}) {
|
|
|
110
199
|
});
|
|
111
200
|
}
|
|
112
201
|
const url = new URL(req.url);
|
|
113
|
-
const
|
|
202
|
+
const rawPathname = url.pathname;
|
|
203
|
+
let activeView;
|
|
204
|
+
let effectivePathname;
|
|
205
|
+
let effectiveReq = req;
|
|
206
|
+
// 1. 检查是否为 /views/:viewName/... 命名空间路由
|
|
207
|
+
const viewMatch = rawPathname.match(/^\/views\/([^/]+)(\/.*)?$/);
|
|
208
|
+
if (viewMatch) {
|
|
209
|
+
let viewName;
|
|
210
|
+
try {
|
|
211
|
+
viewName = decodeURIComponent(viewMatch[1]);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return jsonResponse({
|
|
215
|
+
ok: false,
|
|
216
|
+
error: {
|
|
217
|
+
code: NOT_FOUND,
|
|
218
|
+
message: `Invalid view name encoding: '${viewMatch[1]}'`,
|
|
219
|
+
},
|
|
220
|
+
}, 400, corsHeaders);
|
|
221
|
+
}
|
|
222
|
+
const matched = views.get(viewName);
|
|
223
|
+
if (!matched) {
|
|
224
|
+
return jsonResponse({
|
|
225
|
+
ok: false,
|
|
226
|
+
error: {
|
|
227
|
+
code: NOT_FOUND,
|
|
228
|
+
message: `View '${viewName}' not found`,
|
|
229
|
+
},
|
|
230
|
+
}, 404, corsHeaders);
|
|
231
|
+
}
|
|
232
|
+
activeView = matched;
|
|
233
|
+
// 规范化子路径,防范路径穿透与多斜杠混淆
|
|
234
|
+
let rawEffective = viewMatch[2] || "/";
|
|
235
|
+
if (!rawEffective.startsWith("/")) {
|
|
236
|
+
rawEffective = "/" + rawEffective;
|
|
237
|
+
}
|
|
238
|
+
const normalizedPath = posix.normalize(rawEffective);
|
|
239
|
+
effectivePathname = normalizedPath.startsWith("/") ? normalizedPath : "/" + normalizedPath;
|
|
240
|
+
// 剥离 /views/:viewName 前缀后重构 URL 与 Request
|
|
241
|
+
const rewrittenUrl = new URL(req.url);
|
|
242
|
+
rewrittenUrl.pathname = effectivePathname;
|
|
243
|
+
try {
|
|
244
|
+
const init = {
|
|
245
|
+
method: req.method,
|
|
246
|
+
headers: req.headers,
|
|
247
|
+
signal: req.signal,
|
|
248
|
+
};
|
|
249
|
+
if (req.body && req.method !== "GET" && req.method !== "HEAD") {
|
|
250
|
+
init.body = req.body;
|
|
251
|
+
init.duplex = "half";
|
|
252
|
+
}
|
|
253
|
+
effectiveReq = new Request(rewrittenUrl.toString(), init);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
effectiveReq = req;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// 根路径请求:智能根据 Bearer Token 匹配视图或回退默认视图(遍历全量视图以防范时序差异)
|
|
261
|
+
effectivePathname = rawPathname;
|
|
262
|
+
const clientToken = extractBearerToken(req, options.allowQueryToken);
|
|
263
|
+
if (clientToken) {
|
|
264
|
+
const matchedByToken = matchViewByToken(clientToken, views.values());
|
|
265
|
+
activeView = matchedByToken ?? defaultView;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
activeView = defaultView;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const effectivePolicy = activeView.policy;
|
|
272
|
+
const effectiveUrl = new URL(effectiveReq.url);
|
|
273
|
+
effectiveUrl.pathname = effectivePathname;
|
|
114
274
|
const ctx = {
|
|
115
|
-
req,
|
|
116
|
-
url,
|
|
117
|
-
pathname,
|
|
275
|
+
req: effectiveReq,
|
|
276
|
+
url: effectiveUrl,
|
|
277
|
+
pathname: effectivePathname,
|
|
118
278
|
corsHeaders,
|
|
119
279
|
projectRoot,
|
|
120
280
|
customHome,
|
|
121
281
|
service: serviceInstance,
|
|
122
282
|
options,
|
|
283
|
+
activePolicy: effectivePolicy,
|
|
123
284
|
};
|
|
124
|
-
//
|
|
285
|
+
// 2. 健康检查路由(内部处理独立鉴权逻辑,使用 activePolicy.token)
|
|
125
286
|
const healthResponse = await handleHealthRoute(ctx);
|
|
126
287
|
if (healthResponse) {
|
|
127
288
|
return healthResponse;
|
|
128
289
|
}
|
|
129
|
-
//
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
290
|
+
// 3. MCP 统一网关端点(委托当前视图的端点处理器)
|
|
291
|
+
if (effectivePathname === "/mcp" || effectivePathname.startsWith("/mcp/")) {
|
|
292
|
+
if (activeView.enableMcp === false || options.enableMcp === false) {
|
|
293
|
+
return jsonResponse({
|
|
294
|
+
ok: false,
|
|
295
|
+
error: {
|
|
296
|
+
code: NOT_FOUND,
|
|
297
|
+
message: `MCP endpoint is disabled for view '${activeView.name}'`,
|
|
298
|
+
},
|
|
299
|
+
}, 404, corsHeaders);
|
|
300
|
+
}
|
|
301
|
+
if (activeView.mcpEndpointHandler) {
|
|
302
|
+
const mcpRes = await activeView.mcpEndpointHandler(effectiveReq);
|
|
303
|
+
if (mcpRes)
|
|
304
|
+
return mcpRes;
|
|
305
|
+
}
|
|
136
306
|
}
|
|
137
|
-
//
|
|
138
|
-
if (!verifyBearerToken(
|
|
307
|
+
// 4. API 认证鉴权拦截(基于当前生效策略中的 Token)
|
|
308
|
+
if (!verifyBearerToken(effectiveReq, effectivePolicy.token, options)) {
|
|
139
309
|
return jsonResponse({
|
|
140
310
|
ok: false,
|
|
141
311
|
error: {
|
|
@@ -144,7 +314,7 @@ export async function startActionDockServer(options = {}) {
|
|
|
144
314
|
},
|
|
145
315
|
}, 401, corsHeaders);
|
|
146
316
|
}
|
|
147
|
-
//
|
|
317
|
+
// 5. 业务领域路由分发(完全委托 Service 与 RouteContext 中的 activePolicy)
|
|
148
318
|
const routeResponse = (await handleInfoRoute(ctx)) ||
|
|
149
319
|
(await handleDoctorRoute(ctx)) ||
|
|
150
320
|
(await handleActionsRoutes(ctx)) ||
|
|
@@ -155,12 +325,12 @@ export async function startActionDockServer(options = {}) {
|
|
|
155
325
|
if (routeResponse) {
|
|
156
326
|
return routeResponse;
|
|
157
327
|
}
|
|
158
|
-
//
|
|
328
|
+
// 6. 404 路由兜底
|
|
159
329
|
return jsonResponse({
|
|
160
330
|
ok: false,
|
|
161
331
|
error: {
|
|
162
332
|
code: NOT_FOUND,
|
|
163
|
-
message: `Route not found: ${req.method} ${
|
|
333
|
+
message: `Route not found: ${req.method} ${rawPathname}`,
|
|
164
334
|
},
|
|
165
335
|
}, 404, corsHeaders);
|
|
166
336
|
};
|