@dyyz1993/create-agent 2.0.0 → 2.1.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.
Files changed (165) hide show
  1. package/package.json +1 -1
  2. package/src/commands/create.ts +31 -31
  3. package/src/commands/workspace.ts +112 -105
  4. package/src/lib/copy.ts +24 -1
  5. package/templates/agent/electron/main.js +46 -0
  6. package/templates/agent/electron/preload.js +5 -0
  7. package/templates/agent/electron-builder.json +40 -0
  8. package/templates/agent/eslint.config.mjs +17 -2
  9. package/templates/agent/package.json +69 -3
  10. package/templates/agent/src/__tests__/hybrid-mode.test.ts +126 -0
  11. package/templates/agent/src/__tests__/server-config-security.test.ts +55 -0
  12. package/templates/agent/src/bun/index.ts +96 -41
  13. package/templates/agent/src/bun/three.d.ts +1 -0
  14. package/templates/agent/src/gateway/__tests__/ws-handler-token.test.ts +118 -0
  15. package/templates/agent/src/gateway/http-routes.ts +1 -1
  16. package/templates/agent/src/gateway/ws-handler.ts +84 -56
  17. package/templates/agent/src/mainview/App.tsx +29 -26
  18. package/templates/agent/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  19. package/templates/agent/src/mainview/__tests__/setup.ts +32 -29
  20. package/templates/agent/src/mainview/__tests__/theme-variables.test.ts +36 -0
  21. package/templates/agent/src/mainview/components/chat/ChatPanel.tsx +88 -88
  22. package/templates/agent/src/mainview/components/common/ErrorBoundary.tsx +1 -58
  23. package/templates/agent/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  24. package/templates/agent/src/mainview/components/common/ThemeToggle.tsx +1 -0
  25. package/templates/agent/src/mainview/components/feed/FeedPanel.tsx +232 -210
  26. package/templates/agent/src/mainview/components/file-preview/VirtualizedCodeView.tsx +105 -81
  27. package/templates/agent/src/mainview/components/search/SearchPanel.tsx +427 -378
  28. package/templates/agent/src/mainview/components/todo/TodoPanel.tsx +149 -122
  29. package/templates/agent/src/mainview/hooks/use-input-history.ts +70 -61
  30. package/templates/agent/src/mainview/lib/api-client.ts +179 -172
  31. package/templates/agent/src/mainview/main.tsx +4 -10
  32. package/templates/agent/src/mainview/stores/use-feed-store.ts +107 -107
  33. package/templates/agent/src/mainview/utils/drop-handler.ts +114 -115
  34. package/templates/agent/src/server-config.ts +34 -32
  35. package/templates/agent/src/server.ts +42 -54
  36. package/templates/agent/src/shared/handlers/__tests__/chat-concurrency.test.ts +127 -0
  37. package/templates/agent/src/shared/handlers/__tests__/handler-isolation.test.ts +244 -0
  38. package/templates/agent/src/shared/handlers/bash.ts +65 -65
  39. package/templates/agent/src/shared/handlers/chat.ts +189 -155
  40. package/templates/agent/src/shared/handlers/debug.ts +12 -0
  41. package/templates/agent/src/shared/handlers/feed.ts +31 -32
  42. package/templates/agent/src/shared/handlers/git.ts +286 -243
  43. package/templates/agent/src/shared/handlers/index.ts +1 -0
  44. package/templates/agent/src/shared/handlers/rules.ts +31 -31
  45. package/templates/agent/src/shared/handlers/todo.ts +31 -31
  46. package/templates/agent/src/shared/http-routes.ts +250 -0
  47. package/templates/agent/src/shared/lib/__tests__/web-server.test.ts +190 -0
  48. package/templates/agent/src/shared/lib/bash-security.ts +43 -43
  49. package/templates/agent/src/shared/lib/logger.ts +117 -95
  50. package/templates/agent/src/shared/lib/web-server.ts +128 -0
  51. package/templates/agent/src/shared/modules/debug.ts +8 -0
  52. package/templates/agent/src/shared/rpc-schema.ts +16 -3
  53. package/templates/agent/test-upload.txt +0 -0
  54. package/templates/agent/tsconfig.ipc.json +14 -0
  55. package/templates/agent/tsconfig.json +13 -3
  56. package/templates/chat/eslint.config.mjs +15 -2
  57. package/templates/chat/package.json +13 -2
  58. package/templates/chat/src/__tests__/hybrid-mode.test.ts +125 -0
  59. package/templates/chat/src/__tests__/server-config-security.test.ts +55 -0
  60. package/templates/chat/src/__tests__/server-config.test.ts +46 -44
  61. package/templates/chat/src/bun/index.ts +94 -44
  62. package/templates/chat/src/bun/three.d.ts +1 -0
  63. package/templates/chat/src/gateway/http-routes.ts +1 -1
  64. package/templates/chat/src/gateway/ws-handler.ts +84 -56
  65. package/templates/chat/src/mainview/App.tsx +6 -0
  66. package/templates/chat/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  67. package/templates/chat/src/mainview/__tests__/theme-variables.test.ts +36 -0
  68. package/templates/chat/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  69. package/templates/chat/src/mainview/components/common/ThemeToggle.tsx +1 -0
  70. package/templates/chat/src/mainview/hooks/use-input-history.ts +70 -61
  71. package/templates/chat/src/mainview/lib/api-client.ts +180 -156
  72. package/templates/chat/src/mainview/lib/rpc-cache.ts +84 -0
  73. package/templates/chat/src/mainview/main.tsx +10 -6
  74. package/templates/chat/src/server-config.ts +33 -31
  75. package/templates/chat/src/server.ts +42 -60
  76. package/templates/chat/src/shared/handlers/chat.ts +196 -162
  77. package/templates/chat/src/shared/handlers/debug.ts +12 -0
  78. package/templates/chat/src/shared/handlers/index.ts +1 -0
  79. package/templates/chat/src/shared/http-routes.ts +250 -0
  80. package/templates/chat/src/shared/lib/__tests__/logger.test.ts +92 -0
  81. package/templates/chat/src/shared/lib/logger.ts +108 -95
  82. package/templates/chat/src/shared/lib/web-server.ts +128 -0
  83. package/templates/chat/src/shared/modules/debug.ts +8 -0
  84. package/templates/chat/src/shared/rpc-schema.ts +3 -2
  85. package/templates/chat/test-upload.txt +0 -0
  86. package/templates/chat/tsconfig.ipc.json +14 -0
  87. package/templates/chat/tsconfig.json +12 -2
  88. package/templates/general/eslint.config.mjs +15 -2
  89. package/templates/general/package.json +13 -2
  90. package/templates/general/src/__tests__/hybrid-mode.test.ts +125 -0
  91. package/templates/general/src/__tests__/server-config-security.test.ts +55 -0
  92. package/templates/general/src/__tests__/server-config.test.ts +46 -44
  93. package/templates/general/src/bun/index.ts +94 -44
  94. package/templates/general/src/bun/three.d.ts +1 -0
  95. package/templates/general/src/gateway/http-routes.ts +1 -1
  96. package/templates/general/src/gateway/ws-handler.ts +84 -56
  97. package/templates/general/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  98. package/templates/general/src/mainview/__tests__/theme-variables.test.ts +36 -0
  99. package/templates/general/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  100. package/templates/general/src/mainview/components/common/ThemeToggle.tsx +1 -0
  101. package/templates/general/src/mainview/components/feed/FeedPanel.tsx +241 -219
  102. package/templates/general/src/mainview/components/file-preview/VirtualizedCodeView.tsx +101 -81
  103. package/templates/general/src/mainview/components/layout/AppLayout.tsx +6 -0
  104. package/templates/general/src/mainview/components/search/SearchPanel.tsx +429 -378
  105. package/templates/general/src/mainview/hooks/use-input-history.ts +70 -61
  106. package/templates/general/src/mainview/lib/api-client.ts +180 -156
  107. package/templates/general/src/mainview/lib/rpc-cache.ts +84 -0
  108. package/templates/general/src/mainview/main.tsx +10 -6
  109. package/templates/general/src/mainview/stores/use-feed-store.ts +107 -107
  110. package/templates/general/src/mainview/utils/drop-handler.ts +114 -115
  111. package/templates/general/src/server-config.ts +33 -31
  112. package/templates/general/src/server.ts +42 -60
  113. package/templates/general/src/shared/handlers/__tests__/chat.test.ts +165 -160
  114. package/templates/general/src/shared/handlers/chat.ts +189 -155
  115. package/templates/general/src/shared/handlers/debug.ts +12 -0
  116. package/templates/general/src/shared/handlers/feed.ts +38 -40
  117. package/templates/general/src/shared/handlers/git.ts +286 -243
  118. package/templates/general/src/shared/handlers/index.ts +1 -0
  119. package/templates/general/src/shared/http-routes.ts +250 -0
  120. package/templates/general/src/shared/lib/__tests__/logger.test.ts +92 -0
  121. package/templates/general/src/shared/lib/logger.ts +117 -95
  122. package/templates/general/src/shared/lib/web-server.ts +128 -0
  123. package/templates/general/src/shared/modules/debug.ts +8 -0
  124. package/templates/general/src/shared/rpc-schema.ts +12 -2
  125. package/templates/general/test-upload.txt +0 -0
  126. package/templates/general/tsconfig.ipc.json +14 -0
  127. package/templates/general/tsconfig.json +12 -2
  128. package/templates/shared/__tests__/cors-security.test.ts +165 -0
  129. package/templates/shared/__tests__/file-path-security.test.ts +260 -0
  130. package/templates/shared/__tests__/http-routes.test.ts +174 -0
  131. package/templates/shared/__tests__/logger.test.ts +186 -0
  132. package/templates/shared/components/ErrorBoundary.tsx +59 -0
  133. package/templates/shared/env.d.ts +11 -0
  134. package/templates/shared/http-routes.ts +250 -0
  135. package/templates/shared/logger.ts +129 -0
  136. package/templates/shared/test-upload.txt +0 -0
  137. package/templates/shared/vite-base.config.ts +92 -0
  138. package/templates/shared/vitest-base.config.ts +42 -0
  139. package/templates/agent/eslint-plugin-rpc/index.js +0 -50
  140. package/templates/agent/eslint-plugin-rpc/package.json +0 -6
  141. package/templates/agent/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  142. package/templates/agent/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  143. package/templates/agent/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  144. package/templates/agent/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  145. package/templates/agent/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  146. package/templates/agent/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  147. package/templates/agent/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  148. package/templates/chat/eslint-plugin-rpc/index.js +0 -50
  149. package/templates/chat/eslint-plugin-rpc/package.json +0 -6
  150. package/templates/chat/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  151. package/templates/chat/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  152. package/templates/chat/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  153. package/templates/chat/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  154. package/templates/chat/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  155. package/templates/chat/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  156. package/templates/chat/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  157. package/templates/general/eslint-plugin-rpc/index.js +0 -50
  158. package/templates/general/eslint-plugin-rpc/package.json +0 -6
  159. package/templates/general/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  160. package/templates/general/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  161. package/templates/general/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  162. package/templates/general/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  163. package/templates/general/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  164. package/templates/general/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  165. package/templates/general/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
@@ -0,0 +1,128 @@
1
+ import { createServer, type Server } from "http";
2
+ import { networkInterfaces } from "os";
3
+ import { createHttpHandler } from "../http-routes";
4
+ import { createWsHandler } from "../../gateway/ws-handler";
5
+ import { createLogger } from "./logger";
6
+
7
+ const log = createLogger("web-server");
8
+
9
+ export interface WebServerConfig {
10
+ port: number;
11
+ authToken: string;
12
+ maxUploadSize: number;
13
+ corsOrigin: string;
14
+ }
15
+
16
+ export interface WebServerResult {
17
+ httpServer: Server;
18
+ wss: ReturnType<typeof createWsHandler>;
19
+ port: number;
20
+ authToken: string;
21
+ close: () => Promise<void>;
22
+ }
23
+
24
+ export function getLocalIP(): string {
25
+ const nets = networkInterfaces();
26
+ for (const name of Object.keys(nets)) {
27
+ const interfaces = nets[name];
28
+ if (!interfaces) continue;
29
+ for (const net of interfaces) {
30
+ if (net.family === "IPv4" && !net.internal) {
31
+ return net.address;
32
+ }
33
+ }
34
+ }
35
+ return "127.0.0.1";
36
+ }
37
+
38
+ function checkPort(port: number): Promise<number> {
39
+ return new Promise((resolve, reject) => {
40
+ const testServer = createServer();
41
+ testServer.once("error", (err: NodeJS.ErrnoException) => {
42
+ testServer.close();
43
+ reject(err);
44
+ });
45
+ testServer.once("listening", () => {
46
+ testServer.close();
47
+ resolve(port);
48
+ });
49
+ testServer.listen(port);
50
+ });
51
+ }
52
+
53
+ export async function findAvailablePort(startPort: number, maxRetries = 10): Promise<number> {
54
+ for (let port = startPort; port < startPort + maxRetries; port++) {
55
+ try {
56
+ await checkPort(port);
57
+ return port;
58
+ } catch {
59
+ continue;
60
+ }
61
+ }
62
+ throw new Error(`No available port found after ${maxRetries} retries starting from ${startPort}`);
63
+ }
64
+
65
+ function getFreePort(): Promise<number> {
66
+ return new Promise((resolve, reject) => {
67
+ const s = createServer();
68
+ s.listen(0, () => {
69
+ const addr = s.address();
70
+ s.close(() => {
71
+ if (typeof addr === "object" && addr) {
72
+ resolve(addr.port);
73
+ } else {
74
+ reject(new Error("Failed to allocate port"));
75
+ }
76
+ });
77
+ });
78
+ s.on("error", reject);
79
+ });
80
+ }
81
+
82
+ export async function createWebServer(config: WebServerConfig): Promise<WebServerResult> {
83
+ const port = config.port === 0 ? await getFreePort() : await findAvailablePort(config.port);
84
+
85
+ const httpServer = createServer();
86
+
87
+ const wss = createWsHandler(httpServer, {
88
+ config: {
89
+ port,
90
+ authToken: config.authToken,
91
+ maxUploadSize: config.maxUploadSize,
92
+ },
93
+ });
94
+
95
+ httpServer.on(
96
+ "request",
97
+ createHttpHandler({
98
+ config: {
99
+ port,
100
+ authToken: config.authToken,
101
+ maxUploadSize: config.maxUploadSize,
102
+ corsOrigin: config.corsOrigin,
103
+ },
104
+ getWebSocketClientCount: () => wss.clients.size,
105
+ })
106
+ );
107
+
108
+ return new Promise((resolve, reject) => {
109
+ httpServer.listen(port, () => {
110
+ log.info("Web server started", { port, corsOrigin: config.corsOrigin });
111
+
112
+ resolve({
113
+ httpServer,
114
+ wss,
115
+ port,
116
+ authToken: config.authToken,
117
+ close: () =>
118
+ new Promise<void>((res) => {
119
+ wss.clients.forEach((ws) => ws.close());
120
+ wss.close(() => {
121
+ httpServer.close(() => res());
122
+ });
123
+ }),
124
+ });
125
+ });
126
+ httpServer.on("error", reject);
127
+ });
128
+ }
@@ -0,0 +1,8 @@
1
+ export interface DebugMethods {
2
+ "debug.subscriptions": {
3
+ params: {};
4
+ result: {
5
+ subscriptions: Array<{ id: string; eventType: string; filter: Record<string, unknown> }>;
6
+ };
7
+ };
8
+ }
@@ -5,6 +5,7 @@ import type { TimerMethods, TimerEvents } from "./modules/timer";
5
5
  import type { ChatMethods, ChatEvents } from "./modules/chat";
6
6
  import type { GitMethods } from "./modules/git";
7
7
  import type { FeedMethods, FeedEvents } from "./modules/feed";
8
+ import type { DebugMethods } from "./modules/debug";
8
9
 
9
10
  /**
10
11
  * RPC 方法定义 — 前后端共享的唯一类型入口
@@ -16,7 +17,16 @@ import type { FeedMethods, FeedEvents } from "./modules/feed";
16
17
  * handlers/ 自动发现,无需修改 register-all-handlers.ts
17
18
  * 入口文件(bun/index.ts, server.ts)无需修改
18
19
  */
19
- export interface RPCMethods extends AnyMethods, SystemMethods, FileMethods, TimerMethods, ChatMethods, GitMethods, FeedMethods {}
20
+ export interface RPCMethods
21
+ extends
22
+ AnyMethods,
23
+ SystemMethods,
24
+ FileMethods,
25
+ TimerMethods,
26
+ ChatMethods,
27
+ GitMethods,
28
+ FeedMethods,
29
+ DebugMethods {}
20
30
 
21
31
  /**
22
32
  * RPC 事件定义 — 合并所有模块事件
@@ -27,5 +37,5 @@ export interface RPCEvents extends TimerEvents, ChatEvents, FeedEvents {}
27
37
  * Handler 注册选项
28
38
  */
29
39
  export interface HandlerOptions {
30
- platform: "desktop" | "web";
40
+ platform: "desktop" | "web";
31
41
  }
File without changes
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noUnusedLocals": false,
5
+ "noUnusedParameters": false,
6
+ "skipLibCheck": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "noImplicitReturns": true,
9
+ "noFallthroughCasesInSwitch": true,
10
+ "strict": true
11
+ },
12
+ "include": ["src/bun", "src/gateway/ipc-transport.ts", "src/shared"],
13
+ "exclude": ["node_modules", "dist", "build", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
14
+ }
@@ -19,8 +19,18 @@
19
19
  "paths": {
20
20
  "@dyyz1993/rpc-core": ["../packages/rpc-core/src/index.ts"],
21
21
  "@shared/*": ["../shared/*"]
22
- }
22
+ },
23
+ "noUncheckedIndexedAccess": true,
24
+ "noImplicitReturns": true
23
25
  },
24
26
  "include": ["src", "../shared"],
25
- "exclude": ["node_modules", "dist", "build", "../../package/dist", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
27
+ "exclude": [
28
+ "node_modules",
29
+ "dist",
30
+ "build",
31
+ "../../package/dist",
32
+ "**/__tests__/**",
33
+ "**/*.test.ts",
34
+ "**/*.test.tsx"
35
+ ]
26
36
  }
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ function mockRes() {
4
+ const state = { statusCode: 200, body: "", headers: {} as Record<string, string> };
5
+ const res = {
6
+ setHeader(key: string, val: string) {
7
+ state.headers[key] = val;
8
+ },
9
+ writeHead(code: number, hdrs?: Record<string, string>) {
10
+ state.statusCode = code;
11
+ if (hdrs) Object.assign(state.headers, hdrs);
12
+ return res;
13
+ },
14
+ end(data?: string) {
15
+ if (data !== undefined) state.body = data;
16
+ },
17
+ };
18
+ return { res, state };
19
+ }
20
+
21
+ function mockReq(url: string, opts?: { method?: string; headers?: Record<string, string> }) {
22
+ return {
23
+ url,
24
+ method: opts?.method ?? "GET",
25
+ headers: {
26
+ "content-length": "0",
27
+ ...opts?.headers,
28
+ },
29
+ [Symbol.asyncIterator]() {
30
+ return { next: () => Promise.resolve({ done: true }) };
31
+ },
32
+ };
33
+ }
34
+
35
+ describe("CORS 安全", () => {
36
+ beforeEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("混合模式下默认 CORS 允许所有来源", async () => {
41
+ const { createHttpHandler } = await import("../http-routes");
42
+ const handler = createHttpHandler({
43
+ config: {
44
+ port: 3000,
45
+ authToken: "test-token",
46
+ maxUploadSize: 1024 * 1024,
47
+ corsOrigin: "*",
48
+ },
49
+ getWebSocketClientCount: () => 0,
50
+ });
51
+
52
+ const { res, state } = mockRes();
53
+ await handler(mockReq("/health"), res);
54
+
55
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("*");
56
+ });
57
+
58
+ it("显式设置 CORS_ORIGIN 时只允许指定来源", async () => {
59
+ const { createHttpHandler } = await import("../http-routes");
60
+ const handler = createHttpHandler({
61
+ config: {
62
+ port: 3000,
63
+ authToken: "test-token",
64
+ maxUploadSize: 1024 * 1024,
65
+ corsOrigin: "http://localhost:5173",
66
+ },
67
+ getWebSocketClientCount: () => 0,
68
+ });
69
+
70
+ const { res, state } = mockRes();
71
+ await handler(mockReq("/health"), res);
72
+
73
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:5173");
74
+ expect(state.headers["Access-Control-Allow-Origin"]).not.toBe("*");
75
+ });
76
+
77
+ it("OPTIONS 预检请求返回正确的 CORS headers", async () => {
78
+ const { createHttpHandler } = await import("../http-routes");
79
+ const handler = createHttpHandler({
80
+ config: {
81
+ port: 3000,
82
+ authToken: "test-token",
83
+ maxUploadSize: 1024 * 1024,
84
+ corsOrigin: "http://localhost:5173",
85
+ },
86
+ getWebSocketClientCount: () => 0,
87
+ });
88
+
89
+ const { res, state } = mockRes();
90
+ await handler(mockReq("/file/some/path", { method: "OPTIONS" }), res);
91
+
92
+ expect(state.statusCode).toBe(204);
93
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:5173");
94
+ expect(state.headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS");
95
+ expect(state.headers["Access-Control-Allow-Headers"]).toContain("Authorization");
96
+ expect(state.headers["Access-Control-Allow-Headers"]).toContain("Content-Type");
97
+ });
98
+
99
+ it("非预检请求也返回 CORS headers", async () => {
100
+ const { createHttpHandler } = await import("../http-routes");
101
+ const handler = createHttpHandler({
102
+ config: {
103
+ port: 3000,
104
+ authToken: "test-token",
105
+ maxUploadSize: 1024 * 1024,
106
+ corsOrigin: "http://localhost:5173",
107
+ },
108
+ getWebSocketClientCount: () => 0,
109
+ });
110
+
111
+ const { res, state } = mockRes();
112
+ await handler(
113
+ mockReq("/info/nonexistent", {
114
+ headers: { authorization: "Bearer test-token" },
115
+ }),
116
+ res
117
+ );
118
+
119
+ expect(state.statusCode).not.toBe(204);
120
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:5173");
121
+ });
122
+
123
+ it("401 响应也包含 CORS headers(浏览器需要)", async () => {
124
+ const { createHttpHandler } = await import("../http-routes");
125
+ const handler = createHttpHandler({
126
+ config: {
127
+ port: 3000,
128
+ authToken: "test-token",
129
+ maxUploadSize: 1024 * 1024,
130
+ corsOrigin: "http://localhost:5173",
131
+ },
132
+ getWebSocketClientCount: () => 0,
133
+ });
134
+
135
+ const { res, state } = mockRes();
136
+ await handler(mockReq("/file/test.txt"), res);
137
+
138
+ expect(state.statusCode).toBe(401);
139
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:5173");
140
+ });
141
+
142
+ it("CORS headers 在所有路径上均存在(包括 404)", async () => {
143
+ const { createHttpHandler } = await import("../http-routes");
144
+ const handler = createHttpHandler({
145
+ config: {
146
+ port: 3000,
147
+ authToken: "test-token",
148
+ maxUploadSize: 1024 * 1024,
149
+ corsOrigin: "http://localhost:5173",
150
+ },
151
+ getWebSocketClientCount: () => 0,
152
+ });
153
+
154
+ const { res, state } = mockRes();
155
+ await handler(
156
+ mockReq("/unknown", {
157
+ headers: { authorization: "Bearer test-token" },
158
+ }),
159
+ res
160
+ );
161
+
162
+ expect(state.statusCode).toBe(404);
163
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:5173");
164
+ });
165
+ });
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { resolve } from "path";
3
+
4
+ function mockRes() {
5
+ const state = { statusCode: 200, body: "", headers: {} as Record<string, string> };
6
+ const res = {
7
+ setHeader(key: string, val: string) {
8
+ state.headers[key] = val;
9
+ },
10
+ writeHead(code: number, hdrs?: Record<string, string>) {
11
+ state.statusCode = code;
12
+ if (hdrs) Object.assign(state.headers, hdrs);
13
+ return res;
14
+ },
15
+ end(data?: string) {
16
+ if (data !== undefined) state.body = data;
17
+ },
18
+ };
19
+ return { res, state };
20
+ }
21
+
22
+ function mockReq(url: string, opts?: { method?: string; headers?: Record<string, string> }) {
23
+ return {
24
+ url,
25
+ method: opts?.method ?? "GET",
26
+ headers: {
27
+ "content-length": "0",
28
+ ...opts?.headers,
29
+ },
30
+ [Symbol.asyncIterator]() {
31
+ return { next: () => Promise.resolve({ done: true }) };
32
+ },
33
+ };
34
+ }
35
+
36
+ const CWD = resolve(process.cwd());
37
+ const config = {
38
+ port: 3000,
39
+ authToken: "test-token",
40
+ maxUploadSize: 1024 * 1024 * 10,
41
+ corsOrigin: "*",
42
+ };
43
+
44
+ describe("文件路径安全", () => {
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ it("不允许访问项目根目录以外的文件(路径穿越 ../ 阻断)", async () => {
50
+ const { createHttpHandler } = await import("../http-routes");
51
+ const handler = createHttpHandler({
52
+ config,
53
+ getWebSocketClientCount: () => 0,
54
+ });
55
+
56
+ const traversalPaths = [
57
+ `/file/${encodeURIComponent(CWD + "/../../../etc/passwd")}`,
58
+ `/file/${encodeURIComponent("../../../etc/passwd")}`,
59
+ `/file/${encodeURIComponent(CWD + "/../../tmp/evil")}`,
60
+ ];
61
+
62
+ for (const path of traversalPaths) {
63
+ const { res, state } = mockRes();
64
+ await handler(
65
+ mockReq(path, {
66
+ headers: { authorization: "Bearer test-token" },
67
+ }),
68
+ res
69
+ );
70
+ expect(state.statusCode, `Expected 403 for path: ${path}`).toBe(403);
71
+ expect(JSON.parse(state.body).error).toBe("Path not allowed");
72
+ }
73
+ });
74
+
75
+ it("不允许访问绝对路径 /etc/passwd", async () => {
76
+ const { createHttpHandler } = await import("../http-routes");
77
+ const handler = createHttpHandler({
78
+ config,
79
+ getWebSocketClientCount: () => 0,
80
+ });
81
+
82
+ const { res, state } = mockRes();
83
+ await handler(
84
+ mockReq(`/file/${encodeURIComponent("/etc/passwd")}`, {
85
+ headers: { authorization: "Bearer test-token" },
86
+ }),
87
+ res
88
+ );
89
+
90
+ expect(state.statusCode).toBe(403);
91
+ });
92
+
93
+ it("不允许访问系统目录 /tmp", async () => {
94
+ const { createHttpHandler } = await import("../http-routes");
95
+ const handler = createHttpHandler({
96
+ config,
97
+ getWebSocketClientCount: () => 0,
98
+ });
99
+
100
+ const { res, state } = mockRes();
101
+ await handler(
102
+ mockReq(`/file/${encodeURIComponent("/tmp/evil-file")}`, {
103
+ headers: { authorization: "Bearer test-token" },
104
+ }),
105
+ res
106
+ );
107
+
108
+ expect(state.statusCode).toBe(403);
109
+ });
110
+
111
+ it("/info 端点同样阻止路径穿越", async () => {
112
+ const { createHttpHandler } = await import("../http-routes");
113
+ const handler = createHttpHandler({
114
+ config,
115
+ getWebSocketClientCount: () => 0,
116
+ });
117
+
118
+ const { res, state } = mockRes();
119
+ await handler(
120
+ mockReq(`/info/${encodeURIComponent("/etc/passwd")}`, {
121
+ headers: { authorization: "Bearer test-token" },
122
+ }),
123
+ res
124
+ );
125
+
126
+ expect(state.statusCode).toBe(403);
127
+ });
128
+
129
+ it("URL 编码绕过 %2e%2e%2f 被阻断", async () => {
130
+ const { createHttpHandler } = await import("../http-routes");
131
+ const handler = createHttpHandler({
132
+ config,
133
+ getWebSocketClientCount: () => 0,
134
+ });
135
+
136
+ const doubleEncodedPath = "%252e%252e%252f" + "etc%252fpasswd";
137
+
138
+ const { res, state } = mockRes();
139
+ await handler(
140
+ mockReq(`/file/${doubleEncodedPath}`, {
141
+ headers: { authorization: "Bearer test-token" },
142
+ }),
143
+ res
144
+ );
145
+
146
+ const isBlocked = state.statusCode === 403 || state.statusCode === 404;
147
+ expect(isBlocked, `Expected 403 or 404, got ${state.statusCode}`).toBe(true);
148
+ });
149
+
150
+ it("允许访问项目根目录内的文件", async () => {
151
+ const { createHttpHandler } = await import("../http-routes");
152
+ const handler = createHttpHandler({
153
+ config,
154
+ getWebSocketClientCount: () => 0,
155
+ });
156
+
157
+ const { res, state } = mockRes();
158
+ await handler(
159
+ mockReq(`/file/${encodeURIComponent(CWD + "/package.json")}`, {
160
+ headers: { authorization: "Bearer test-token" },
161
+ }),
162
+ res
163
+ );
164
+
165
+ expect(state.statusCode).not.toBe(403);
166
+ });
167
+
168
+ it("允许访问子目录内的文件", async () => {
169
+ const { createHttpHandler } = await import("../http-routes");
170
+ const handler = createHttpHandler({
171
+ config,
172
+ getWebSocketClientCount: () => 0,
173
+ });
174
+
175
+ const { res, state } = mockRes();
176
+ await handler(
177
+ mockReq(`/file/${encodeURIComponent(CWD + "/src/some-file.txt")}`, {
178
+ headers: { authorization: "Bearer test-token" },
179
+ }),
180
+ res
181
+ );
182
+
183
+ expect(state.statusCode).not.toBe(403);
184
+ });
185
+
186
+ it("上传文件路径也受 ALLOWED_ROOTS 限制", async () => {
187
+ const { createHttpHandler } = await import("../http-routes");
188
+ const handler = createHttpHandler({
189
+ config,
190
+ getWebSocketClientCount: () => 0,
191
+ });
192
+
193
+ const { res, state } = mockRes();
194
+ await handler(
195
+ mockReq(`/file/upload?path=${encodeURIComponent("/tmp/evil-upload.txt")}`, {
196
+ method: "POST",
197
+ headers: { authorization: "Bearer test-token" },
198
+ }),
199
+ res
200
+ );
201
+
202
+ expect(state.statusCode).toBe(403);
203
+ expect(JSON.parse(state.body).error).toBe("Path not allowed");
204
+ });
205
+
206
+ it("上传文件到项目目录内被允许(不会 403)", async () => {
207
+ const { createHttpHandler } = await import("../http-routes");
208
+ const handler = createHttpHandler({
209
+ config,
210
+ getWebSocketClientCount: () => 0,
211
+ });
212
+
213
+ const { res, state } = mockRes();
214
+ await handler(
215
+ mockReq(`/file/upload?path=${encodeURIComponent(CWD + "/test-upload.txt")}`, {
216
+ method: "POST",
217
+ headers: { authorization: "Bearer test-token" },
218
+ }),
219
+ res
220
+ );
221
+
222
+ expect(state.statusCode).not.toBe(403);
223
+ });
224
+
225
+ it("空路径被拒绝", async () => {
226
+ const { createHttpHandler } = await import("../http-routes");
227
+ const handler = createHttpHandler({
228
+ config,
229
+ getWebSocketClientCount: () => 0,
230
+ });
231
+
232
+ const { res, state } = mockRes();
233
+ await handler(
234
+ mockReq(`/file/`, {
235
+ headers: { authorization: "Bearer test-token" },
236
+ }),
237
+ res
238
+ );
239
+
240
+ expect([403, 404, 500].includes(state.statusCode)).toBe(true);
241
+ });
242
+
243
+ it("Windows 风格反斜杠路径被正确处理", async () => {
244
+ const { createHttpHandler } = await import("../http-routes");
245
+ const handler = createHttpHandler({
246
+ config,
247
+ getWebSocketClientCount: () => 0,
248
+ });
249
+
250
+ const { res, state } = mockRes();
251
+ await handler(
252
+ mockReq(`/file/${encodeURIComponent("..\\..\\etc\\passwd")}`, {
253
+ headers: { authorization: "Bearer test-token" },
254
+ }),
255
+ res
256
+ );
257
+
258
+ expect([403, 404].includes(state.statusCode)).toBe(true);
259
+ });
260
+ });