@actiondock/core 2.8.0 → 2.9.1

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.
Files changed (53) hide show
  1. package/README.md +21 -1
  2. package/dist/errors.d.ts +5 -1
  3. package/dist/errors.js +2 -0
  4. package/dist/host/host.d.ts +2 -0
  5. package/dist/host/host.js +10 -0
  6. package/dist/host/types.d.ts +3 -0
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.js +3 -1
  9. package/dist/ipc/host.js +1 -0
  10. package/dist/ipc/service.js +3 -0
  11. package/dist/package/runtime.d.ts +3 -0
  12. package/dist/package/runtime.js +8 -0
  13. package/dist/package/types.d.ts +4 -0
  14. package/dist/platform/node.js +1 -0
  15. package/dist/platform/types.d.ts +3 -1
  16. package/dist/profile/client-runs.d.ts +10 -1
  17. package/dist/profile/client-runs.js +14 -1
  18. package/dist/profile/client.d.ts +1 -1
  19. package/dist/profile/client.js +1 -1
  20. package/dist/project/init.js +2 -2
  21. package/dist/project/types.d.ts +5 -0
  22. package/dist/server/index.d.ts +1 -0
  23. package/dist/server/index.js +1 -0
  24. package/dist/server/policy.d.ts +138 -0
  25. package/dist/server/policy.js +368 -0
  26. package/dist/server/routes/actions.js +86 -42
  27. package/dist/server/routes/common.d.ts +14 -25
  28. package/dist/server/routes/common.js +25 -21
  29. package/dist/server/routes/config.d.ts +1 -1
  30. package/dist/server/routes/config.js +8 -8
  31. package/dist/server/routes/doctor.js +4 -4
  32. package/dist/server/routes/health.js +2 -2
  33. package/dist/server/routes/info.js +6 -12
  34. package/dist/server/routes/playbooks.js +8 -8
  35. package/dist/server/routes/runs.js +111 -30
  36. package/dist/server/routes/state.d.ts +1 -1
  37. package/dist/server/routes/state.js +7 -7
  38. package/dist/server/security.d.ts +11 -0
  39. package/dist/server/security.js +36 -19
  40. package/dist/server/server.js +215 -30
  41. package/dist/server/types.d.ts +60 -2
  42. package/dist/service/local.js +3 -0
  43. package/dist/service/remote.js +16 -1
  44. package/dist/service/types.d.ts +4 -0
  45. package/dist/storage/index.d.ts +1 -0
  46. package/dist/storage/index.js +6 -1
  47. package/dist/storage/sqlite.d.ts +18 -1
  48. package/dist/storage/sqlite.js +159 -2
  49. package/dist/storage/types.d.ts +29 -0
  50. package/dist/storage/types.js +12 -0
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +2 -2
@@ -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
- * 校验 HTTP 请求中的鉴权令牌是否有效。
23
+ * 从 HTTP 请求中提取 Bearer Token。
22
24
  *
23
25
  * 默认仅接受 HTTP 请求头: `Authorization: Bearer <token>`。
24
- * 仅当 options.allowQueryToken 为 true 时才允许解析 URL 查询参数 `?token=<token>`。
26
+ * 仅当 allowQueryToken 为 true 时才允许解析 URL 查询参数 `?token=<token>`。
25
27
  *
26
28
  * @param req 传入的 HTTP Request 对象
27
- * @param expectedToken 服务端预期的正确 Token(若未配置 Token 则默认放行)
28
- * @param options 校验选项或布尔值 allowQueryToken 开关
29
- * @returns 是否鉴权成功
29
+ * @param allowQueryToken 是否允许从 URL 查询参数解析 Token
30
+ * @returns 提取出的 Token 字符串,若未携带则返回 null
30
31
  */
31
- export function verifyBearerToken(req, expectedToken, options) {
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 (safeEqual(token, trimmedExpected)) {
41
- return true;
42
- }
37
+ if (token)
38
+ return token;
43
39
  }
44
40
  // 2. URL 查询参数: ?token=<token>(仅当显式开启 allowQueryToken 时允许)
45
- const allowQuery = typeof options === "boolean" ? options : options?.allowQueryToken === true;
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 && safeEqual(tokenParam.trim(), trimmedExpected)) {
51
- return true;
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 false;
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 白名单解析并返回跨域响应头。
@@ -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
- if (!isLoopbackHost(host) && !token && !options.allowInsecureNoAuth) {
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
- if (options.packageAllowlist && options.packageAllowlist.length > 0) {
77
- for (const pkgId of options.packageAllowlist) {
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
- const mcpEndpointHandler = options.mcpHandler
96
- ? createMcpEndpointHandler(options.mcpHandler, {
97
- token,
98
- allowQueryToken: options.allowQueryToken,
99
- corsOrigins: options.corsOrigins,
100
- maxBodyBytes: options.maxBodyBytes,
101
- })
102
- : undefined;
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 pathname = url.pathname;
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
- // 1. 健康检查路由(内部处理独立鉴权逻辑)
285
+ // 2. 健康检查路由(内部处理独立鉴权逻辑,使用 activePolicy.token)
125
286
  const healthResponse = await handleHealthRoute(ctx);
126
287
  if (healthResponse) {
127
288
  return healthResponse;
128
289
  }
129
- // 2. MCP 统一网关端点(委托共享端点处理器)
130
- if (options.enableMcp !== false &&
131
- mcpEndpointHandler &&
132
- (pathname === "/mcp" || pathname.startsWith("/mcp/"))) {
133
- const mcpRes = await mcpEndpointHandler(req);
134
- if (mcpRes)
135
- return mcpRes;
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
- // 3. 全局 API 认证鉴权拦截
138
- if (!verifyBearerToken(req, token, options)) {
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
- // 4. 业务领域路由分发(完全委托 Service)
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
- // 5. 404 路由兜底
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} ${pathname}`,
333
+ message: `Route not found: ${req.method} ${rawPathname}`,
164
334
  },
165
335
  }, 404, corsHeaders);
166
336
  };
@@ -170,6 +340,17 @@ export async function startActionDockServer(options = {}) {
170
340
  }
171
341
  const actualHost = host === "0.0.0.0" ? "127.0.0.1" : host;
172
342
  const protocol = options.tls ? "https" : "http";
343
+ // 启动后台运行记录定期自动清理巡检定时器(默认 1 小时巡检一次)
344
+ const runsCleanupIntervalMs = 3600_000;
345
+ let runsCleanupTimer;
346
+ if (serviceInstance.runs?.cleanExpired) {
347
+ runsCleanupTimer = setInterval(() => {
348
+ serviceInstance?.runs?.cleanExpired?.().catch((err) => {
349
+ console.warn(`[ActionDockServer] Periodic runs cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
350
+ });
351
+ }, runsCleanupIntervalMs);
352
+ runsCleanupTimer.unref();
353
+ }
173
354
  const instance = {
174
355
  get port() {
175
356
  return server.port ?? port;
@@ -183,6 +364,10 @@ export async function startActionDockServer(options = {}) {
183
364
  },
184
365
  ready: Promise.resolve(),
185
366
  stop: async (stopOptions) => {
367
+ if (runsCleanupTimer) {
368
+ clearInterval(runsCleanupTimer);
369
+ runsCleanupTimer = undefined;
370
+ }
186
371
  await server.stop(true);
187
372
  if (serviceInstance) {
188
373
  try {
@@ -18,6 +18,40 @@ export interface ServerTlsOptions {
18
18
  /** 私钥密码口令(若私钥被密码加密) */
19
19
  passphrase?: string;
20
20
  }
21
+ /**
22
+ * 单个虚拟投影视图(Virtual View)的配置选项。
23
+ */
24
+ export interface ServerViewOptions {
25
+ /** 视图唯一标识名称(若在 views 对象中配置则键名作为默认名称) */
26
+ name?: string;
27
+ /** 该视图独立的 Bearer Token 鉴权令牌 */
28
+ token?: string;
29
+ /** 该视图允许访问执行的 Package ID 白名单列表(为空允许全部) */
30
+ packageAllowlist?: string[];
31
+ /** 该视图允许访问执行的 Action 标识白名单列表(为空允许全部,支持短名或 packageId/actionId) */
32
+ actionAllowlist?: string[];
33
+ /** 该视图是否开启配置与状态管理路由能力(默认关闭) */
34
+ enableManagement?: boolean;
35
+ /** 该视图是否启用一体化 MCP 协议支持(默认开启) */
36
+ enableMcp?: boolean;
37
+ /** 该视图自定义的 MCP 请求处理器钩子(可选,支持请求处理函数或接收视图策略的工厂函数) */
38
+ mcpHandler?: ((req: Request, view?: EffectiveServerPolicy) => Promise<Response | null | undefined> | Response | null | undefined) | ((view: EffectiveServerPolicy) => ((req: Request) => Promise<Response | null | undefined> | Response | null | undefined));
39
+ }
40
+ /**
41
+ * 当前请求上下文生效的统一服务端安全策略。
42
+ */
43
+ export interface EffectiveServerPolicy {
44
+ /** 当前生效的视图名称(默认视图为 "default") */
45
+ viewName?: string;
46
+ /** 当前生效的 Bearer Token 鉴权令牌 */
47
+ token?: string;
48
+ /** 当前生效的 Package ID 白名单列表 */
49
+ packageAllowlist?: string[];
50
+ /** 当前生效的 Action 标识白名单列表 */
51
+ actionAllowlist?: string[];
52
+ /** 当前生效的管理能力开关 */
53
+ enableManagement?: boolean;
54
+ }
21
55
  /**
22
56
  * 启动 ActionDock HTTP Runner 服务端的配置选项。
23
57
  */
@@ -48,14 +82,18 @@ export interface ServerOptions {
48
82
  corsOrigins?: string[];
49
83
  /** 允许访问执行的 Package ID 白名单列表(为空允许全部) */
50
84
  packageAllowlist?: string[];
85
+ /** 允许访问执行的 Action 标识白名单列表(为空允许全部,支持短名或 packageId/actionId) */
86
+ actionAllowlist?: string[];
51
87
  /** 最大允许的请求体字节限制(默认 1MB,防 DoS) */
52
88
  maxBodyBytes?: number;
53
89
  /** 是否在 health 和 info 接口中透传本地 projectRoot 等调试路径 */
54
90
  exposeDebugInfo?: boolean;
55
91
  /** 是否启用一体化 MCP 协议支持(默认开启) */
56
92
  enableMcp?: boolean;
57
- /** 自定义 MCP 请求处理器钩子(若挂载则 /mcp 路由交由其处理) */
58
- mcpHandler?: (req: Request) => Promise<Response | null | undefined> | Response | null | undefined;
93
+ /** 自定义 MCP 请求处理器钩子(若挂载则 /mcp 路由交由其处理,支持处理函数或接收视图策略的工厂函数) */
94
+ mcpHandler?: ((req: Request, view?: EffectiveServerPolicy) => Promise<Response | null | undefined> | Response | null | undefined) | ((view: EffectiveServerPolicy) => ((req: Request) => Promise<Response | null | undefined> | Response | null | undefined));
95
+ /** 自定义 MCP 处理器工厂函数(可选,为每个视图创建独立处理器) */
96
+ mcpHandlerFactory?: (view: EffectiveServerPolicy) => ((req: Request) => Promise<Response | null | undefined> | Response | null | undefined);
59
97
  /** 可选注入的统一运行时底层平台 */
60
98
  platform?: import("../platform/types.js").RuntimePlatform;
61
99
  /** 是否显式开启配置与状态管理路由能力(默认关闭,关闭时返回 403) */
@@ -64,6 +102,26 @@ export interface ServerOptions {
64
102
  scanLinkedPackages?: boolean;
65
103
  /** 服务端 TLS/HTTPS 安全传输选项 */
66
104
  tls?: ServerTlsOptions;
105
+ /**
106
+ * 虚拟投影视图配置。
107
+ * 支持对象字典形式 Record<string, ServerViewOptions> 或数组形式 ServerViewOptions[]。
108
+ * 单端口上支持多视图独立隔离 Token、白名单与管理权限。
109
+ */
110
+ views?: Record<string, ServerViewOptions> | ServerViewOptions[];
111
+ }
112
+ /**
113
+ * 路由处理统一上下文对象。
114
+ */
115
+ export interface RouteContext {
116
+ req: Request;
117
+ url: URL;
118
+ pathname: string;
119
+ corsHeaders: Record<string, string>;
120
+ projectRoot: string | null;
121
+ customHome?: string;
122
+ service: ActionDockService;
123
+ options: ServerOptions;
124
+ activePolicy: EffectiveServerPolicy;
67
125
  }
68
126
  /**
69
127
  * 已启动的 ActionDock HTTP Runner 实例句柄。
@@ -67,6 +67,9 @@ export class LocalActionDockService {
67
67
  async clear(opts) {
68
68
  return self.host.clearRuns(opts);
69
69
  },
70
+ async cleanExpired(policy) {
71
+ return self.host.cleanExpiredRuns(policy);
72
+ },
70
73
  };
71
74
  this.events = {
72
75
  events(runId, opts) {
@@ -1,6 +1,6 @@
1
1
  import { parseActionRef } from "../catalog/resolve-action.js";
2
2
  import { ActionDockError, ACTION_CANCELLED, CAPABILITY_UNAVAILABLE, INVALID_ARGUMENT, TIMEOUT, } from "../errors.js";
3
- import { cancelRemoteRun, clearRemoteRuns, clearRemoteState, deleteRemoteConfig, deleteRemoteStateKey, executeRemoteAction, fetchRemoteActionShow, fetchRemoteActions, fetchRemoteConfig, fetchRemoteInfo, fetchRemotePlaybookShow, fetchRemotePlaybooks, fetchRemoteRun, fetchRemoteRuns, fetchRemoteStateList, getRemoteStateKey, setRemoteConfig, setRemoteStateKey, } from "../profile/client.js";
3
+ import { cancelRemoteRun, clearRemoteRuns, cleanExpiredRemoteRuns, clearRemoteState, deleteRemoteConfig, deleteRemoteStateKey, executeRemoteAction, fetchRemoteActionShow, fetchRemoteActions, fetchRemoteConfig, fetchRemoteInfo, fetchRemotePlaybookShow, fetchRemotePlaybooks, fetchRemoteRun, fetchRemoteRuns, fetchRemoteStateList, getRemoteStateKey, setRemoteConfig, setRemoteStateKey, } from "../profile/client.js";
4
4
  import { isRemoteStateKeyNotFound, wrapRemoteError } from "./remote-errors.js";
5
5
  import { formatTerminalRunResult, pollRunCompletion } from "./remote-polling.js";
6
6
  import { streamRemoteEvents } from "./sse-stream.js";
@@ -306,6 +306,21 @@ export class RemoteActionDockService {
306
306
  wrapRemoteError(err);
307
307
  }
308
308
  },
309
+ async cleanExpired(policy) {
310
+ self.assertNotClosed();
311
+ try {
312
+ const res = await cleanExpiredRemoteRuns(self.serverUrl, self.token, {
313
+ ...policy,
314
+ allowInsecureHttp: self.allowInsecureHttp,
315
+ insecure: self.insecure,
316
+ dispatcher: self.dispatcher,
317
+ });
318
+ return res.cleanedCount ?? 0;
319
+ }
320
+ catch (err) {
321
+ wrapRemoteError(err);
322
+ }
323
+ },
309
324
  };
310
325
  this.events = {
311
326
  events(runId, opts) {
@@ -170,7 +170,11 @@ export interface RunsPort {
170
170
  packageId?: string;
171
171
  actionId?: string;
172
172
  status?: string;
173
+ olderThanMs?: number;
174
+ keep?: number;
173
175
  }): Promise<number>;
176
+ /** 按保留策略清理过期及超额的终态运行记录(基于时间与数量策略) */
177
+ cleanExpired?(policy?: import("../storage/types.js").RunsRetentionPolicy): Promise<number>;
174
178
  }
175
179
  /**
176
180
  * 配置项管理服务端口。
@@ -42,6 +42,7 @@ export declare function createStorage(packageId: string, options?: {
42
42
  inMemory?: boolean;
43
43
  customHome?: string;
44
44
  recoverOrphans?: boolean;
45
+ retentionPolicy?: import("./types.js").RunsRetentionPolicy;
45
46
  }): RuntimeStorage;
46
47
  /**
47
48
  * 解析全局共享数据库(global.db)文件的绝对路径。
@@ -59,7 +59,12 @@ export function resolveDatabasePath(packageId, options = {}) {
59
59
  */
60
60
  export function createStorage(packageId, options = {}) {
61
61
  const dbPath = resolveDatabasePath(packageId, options);
62
- return new SqliteRuntimeStorage({ dbPath, packageId, recoverOrphans: options.recoverOrphans });
62
+ return new SqliteRuntimeStorage({
63
+ dbPath,
64
+ packageId,
65
+ recoverOrphans: options.recoverOrphans,
66
+ retentionPolicy: options.retentionPolicy,
67
+ });
63
68
  }
64
69
  /**
65
70
  * 解析全局共享数据库(global.db)文件的绝对路径。
@@ -1,5 +1,5 @@
1
1
  import { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment, type RuntimeError, type RunRecord } from "@actiondock/sdk";
2
- import { type IdempotencyCheckResult, type IdempotencyRecord, type RuntimeStorage, type StateEntry, type StorageOptions, type TerminalRunStatus } from "./types.js";
2
+ import { type IdempotencyCheckResult, type IdempotencyRecord, type RunsRetentionPolicy, type RuntimeStorage, type StateEntry, type StorageOptions, type TerminalRunStatus } from "./types.js";
3
3
  export { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment, };
4
4
  /**
5
5
  * 统一 SQLite 运行时存储实现。
@@ -25,6 +25,11 @@ export declare class SqliteRuntimeStorage implements RuntimeStorage {
25
25
  private statementCache;
26
26
  private dbPath;
27
27
  private recoverOrphans;
28
+ private retentionPolicy?;
29
+ private lastRunsCleanupAt;
30
+ private runInsertCount;
31
+ private static readonly RUNS_CLEANUP_INTERVAL_MS;
32
+ private static readonly RUNS_CLEANUP_BATCH_THRESHOLD;
28
33
  get isOpen(): boolean;
29
34
  get closed(): boolean;
30
35
  constructor(options: StorageOptions);
@@ -84,7 +89,19 @@ export declare class SqliteRuntimeStorage implements RuntimeStorage {
84
89
  clearRuns(options?: {
85
90
  actionId?: string;
86
91
  status?: string;
92
+ olderThanMs?: number;
93
+ keep?: number;
87
94
  }): number;
95
+ /**
96
+ * 按保留策略清理过期的终态运行记录与超出容量限制的最旧运行记录。
97
+ *
98
+ * 包含:
99
+ * - 基于时间:删除早于 maxAgeMs 的终态记录(保留 minRetainRuns 保底数量)。
100
+ * - 基于数量:若终态记录总数超过 maxRuns,按时间由旧至新淘汰超出部分。
101
+ * - 级联清理孤立的幂等去重记录。
102
+ */
103
+ cleanExpiredRuns(policy?: RunsRetentionPolicy): number;
104
+ private resolveRetentionPolicy;
88
105
  checkAndRecordIdempotency(record: IdempotencyRecord): IdempotencyCheckResult;
89
106
  getIdempotencyRecord(ownerId: string, actionRef: string, requestId: string): IdempotencyRecord | undefined;
90
107
  private mapRunRecord;