@axhub/genie 0.2.8 → 0.2.10

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 (106) hide show
  1. package/LICENSE +21 -675
  2. package/dist/api-docs.html +2 -2
  3. package/dist/assets/App-CYCCsgwf.js +264 -0
  4. package/dist/assets/ReviewApp-0srHIXwb.js +1 -0
  5. package/dist/assets/{_basePickBy-CqJbRZ9y.js → _basePickBy-DVVb07UV.js} +1 -1
  6. package/dist/assets/{_baseUniq-BS8YH8jO.js → _baseUniq-BtbziL5G.js} +1 -1
  7. package/dist/assets/{arc-BBmKEN-S.js → arc-BsCC8yBD.js} +1 -1
  8. package/dist/assets/{architectureDiagram-2XIMDMQ5-N5lcb82R.js → architectureDiagram-2XIMDMQ5-woFp6eNI.js} +1 -1
  9. package/dist/assets/{blockDiagram-WCTKOSBZ-DTMwHuLn.js → blockDiagram-WCTKOSBZ-ya8VAc2k.js} +1 -1
  10. package/dist/assets/{c4Diagram-IC4MRINW-BTKlkXI9.js → c4Diagram-IC4MRINW-CY1dZmIZ.js} +1 -1
  11. package/dist/assets/channel-BMhScXFe.js +1 -0
  12. package/dist/assets/{chunk-4BX2VUAB-DUdoTxAc.js → chunk-4BX2VUAB-CR1lAd74.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-Bm_92xe4.js → chunk-55IACEB6-CP98WcFC.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-CGW0g62g.js → chunk-FMBD7UC4-D9c7ijAB.js} +1 -1
  15. package/dist/assets/{chunk-JSJVCQXG-DYkTH3w1.js → chunk-JSJVCQXG-DQAGYOn-.js} +1 -1
  16. package/dist/assets/{chunk-KX2RTZJC-C9oTlISU.js → chunk-KX2RTZJC-BbTXiDq7.js} +1 -1
  17. package/dist/assets/{chunk-NQ4KR5QH-CM50ygWP.js → chunk-NQ4KR5QH-BI6AX0dr.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-7dzpYeNJ.js → chunk-QZHKN3VN-DB3V2Ifo.js} +1 -1
  19. package/dist/assets/{chunk-WL4C6EOR-Cm9nQrsr.js → chunk-WL4C6EOR-DhzTthv6.js} +1 -1
  20. package/dist/assets/classDiagram-VBA2DB6C-CMIxlWcT.js +1 -0
  21. package/dist/assets/classDiagram-v2-RAHNMMFH-CMIxlWcT.js +1 -0
  22. package/dist/assets/clone-BPqOt4r3.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-Ccp_p0JZ.js → cose-bilkent-S5V4N54A-BQ09ZE2j.js} +1 -1
  24. package/dist/assets/{dagre-KLK3FWXG-fBwTLUp9.js → dagre-KLK3FWXG-Dc2ueD_R.js} +1 -1
  25. package/dist/assets/{diagram-E7M64L7V-CeNVmFUp.js → diagram-E7M64L7V-DP-LsQoL.js} +1 -1
  26. package/dist/assets/{diagram-IFDJBPK2-CtavyLGa.js → diagram-IFDJBPK2-Cg6r42cB.js} +1 -1
  27. package/dist/assets/{diagram-P4PSJMXO-CpQTjQwc.js → diagram-P4PSJMXO-aHsfoUZE.js} +1 -1
  28. package/dist/assets/{erDiagram-INFDFZHY-B8R5vwhd.js → erDiagram-INFDFZHY-qBXJ4aAz.js} +1 -1
  29. package/dist/assets/{flowDiagram-PKNHOUZH-BvkVVwIQ.js → flowDiagram-PKNHOUZH-D_13emJM.js} +1 -1
  30. package/dist/assets/{ganttDiagram-A5KZAMGK-DOu3hSNa.js → ganttDiagram-A5KZAMGK-BvIcOLwz.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-K3NZZRJ6-C7zT67YE.js → gitGraphDiagram-K3NZZRJ6-ad0vvNcU.js} +1 -1
  32. package/dist/assets/{graph-D11wiwHo.js → graph-CeJCMjan.js} +1 -1
  33. package/dist/assets/{highlighted-body-TPN3WLV5-Babpthg-.js → highlighted-body-TPN3WLV5-B_novwSz.js} +1 -1
  34. package/dist/assets/index-C514cLyb.js +2 -0
  35. package/dist/assets/index-h1DBl_g3.css +1 -0
  36. package/dist/assets/{infoDiagram-LFFYTUFH-BmA7IpQG.js → infoDiagram-LFFYTUFH-lOxAqb3m.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-PHBUUO56-BEquZd3E.js → ishikawaDiagram-PHBUUO56-DIr-51gj.js} +1 -1
  38. package/dist/assets/{journeyDiagram-4ABVD52K-BfemGz7f.js → journeyDiagram-4ABVD52K-CYcIW0ZU.js} +1 -1
  39. package/dist/assets/{kanban-definition-K7BYSVSG-CWja3mln.js → kanban-definition-K7BYSVSG-C1ZK616a.js} +1 -1
  40. package/dist/assets/{layout-BLUNf-PJ.js → layout-CI2RM-v6.js} +1 -1
  41. package/dist/assets/{linear-DukIV_Xv.js → linear-DE7bISck.js} +1 -1
  42. package/dist/assets/{mermaid-O7DHMXV3-SgtM28qI.js → mermaid-O7DHMXV3-XxAJo8EK.js} +6 -6
  43. package/dist/assets/{mindmap-definition-YRQLILUH-4UjqXITU.js → mindmap-definition-YRQLILUH-Dz6EFjmn.js} +1 -1
  44. package/dist/assets/{pieDiagram-SKSYHLDU-8AxqJd0M.js → pieDiagram-SKSYHLDU-DPpEzUed.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-337W2JSQ-D60m8V8r.js → quadrantDiagram-337W2JSQ-xdoXNet7.js} +1 -1
  46. package/dist/assets/{requirementDiagram-Z7DCOOCP-zqh9jBVf.js → requirementDiagram-Z7DCOOCP-DUq8H3CL.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-WA2Y5GQK-CDZILTLI.js → sankeyDiagram-WA2Y5GQK-CmqEUxRu.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-2WXFIKYE-7BReFd0L.js → sequenceDiagram-2WXFIKYE-DhtXRNiH.js} +1 -1
  49. package/dist/assets/{stateDiagram-RAJIS63D-HPTVdIG4.js → stateDiagram-RAJIS63D-Dj0HOlbN.js} +1 -1
  50. package/dist/assets/stateDiagram-v2-FVOUBMTO-C9utf5gv.js +1 -0
  51. package/dist/assets/{timeline-definition-YZTLITO2-CTVllFgr.js → timeline-definition-YZTLITO2-DUuJzZB5.js} +1 -1
  52. package/dist/assets/{treemap-KZPCXAKY-BtyxboJZ.js → treemap-KZPCXAKY-DpYBQ0qr.js} +1 -1
  53. package/dist/assets/vendor-codemirror-CMHSJ_9p.js +9 -0
  54. package/dist/assets/{vendor-react-Cpt6D04s.js → vendor-react-xmA_f8ig.js} +1 -1
  55. package/dist/assets/{vennDiagram-LZ73GAT5-D96ZI6Mg.js → vennDiagram-LZ73GAT5-DpePUyOd.js} +1 -1
  56. package/dist/assets/{xychartDiagram-JWTSCODW-eRk-39YO.js → xychartDiagram-JWTSCODW-Cfp1I4_U.js} +1 -1
  57. package/dist/index.html +5 -5
  58. package/package.json +8 -7
  59. package/server/acp-runtime/client.js +129 -16
  60. package/server/acp-runtime/index.js +54 -0
  61. package/server/acp-runtime/registry.js +2 -2
  62. package/server/acp-runtime/session-store.js +79 -5
  63. package/server/cli.js +55 -10
  64. package/server/database/db.js +20 -0
  65. package/server/external-agent/service.js +24 -6
  66. package/server/external-agent/ws.js +540 -27
  67. package/server/index.js +112 -151
  68. package/server/lan-access/core.js +79 -0
  69. package/server/lan-access/state.js +102 -0
  70. package/server/middleware/auth.js +57 -14
  71. package/server/projects.js +930 -667
  72. package/server/routes/auth.js +24 -4
  73. package/server/routes/cli-auth.js +21 -25
  74. package/server/routes/codex.js +84 -298
  75. package/server/routes/commands.js +322 -407
  76. package/server/routes/lan-access.js +231 -0
  77. package/server/routes/projects.js +154 -158
  78. package/server/routes/session-core.js +160 -91
  79. package/server/routes/settings.js +113 -99
  80. package/server/session-core/eventStore.js +60 -20
  81. package/server/session-core/providerAdapters.js +75 -38
  82. package/server/session-core/runtimeState.js +8 -0
  83. package/server/session-core/sessionListMerge.js +47 -0
  84. package/shared/conversationEvents.js +174 -15
  85. package/shared/modelConstants.js +79 -99
  86. package/dist/assets/App-CTKZtqB1.js +0 -460
  87. package/dist/assets/ReviewApp-DM6BNAzR.js +0 -1
  88. package/dist/assets/channel-1oJBvF-0.js +0 -1
  89. package/dist/assets/classDiagram-VBA2DB6C-d5TeKFM4.js +0 -1
  90. package/dist/assets/classDiagram-v2-RAHNMMFH-d5TeKFM4.js +0 -1
  91. package/dist/assets/clone-CinxIlEu.js +0 -1
  92. package/dist/assets/index-DFxzgWoO.js +0 -2
  93. package/dist/assets/index-YCFGDVKw.css +0 -1
  94. package/dist/assets/stateDiagram-v2-FVOUBMTO-DTUf5_gC.js +0 -1
  95. package/dist/assets/vendor-codemirror-Dz7_EqNA.js +0 -39
  96. package/server/_legacy-providers/README.md +0 -30
  97. package/server/_legacy-providers/claude-sdk.js +0 -956
  98. package/server/_legacy-providers/gemini-cli.js +0 -368
  99. package/server/_legacy-providers/openai-codex.js +0 -705
  100. package/server/_legacy-providers/opencode-cli.js +0 -674
  101. package/server/routes/git.js +0 -1110
  102. package/server/routes/mcp-utils.js +0 -48
  103. package/server/routes/mcp.js +0 -536
  104. package/server/routes/taskmaster.js +0 -1963
  105. package/server/utils/mcp-detector.js +0 -198
  106. package/server/utils/taskmaster-websocket.js +0 -129
@@ -0,0 +1,231 @@
1
+ import express from 'express';
2
+ import QRCode from 'qrcode';
3
+
4
+ import { authenticateToken, generateToken, verifySignedToken } from '../middleware/auth.js';
5
+ import { userDb } from '../database/db.js';
6
+ import { collectLanIPv4Addresses } from '../lan-access/core.js';
7
+ import {
8
+ getLanSessionVersion,
9
+ hasLanAccessPassword,
10
+ setLanAccessPassword,
11
+ verifyLanAccessPassword,
12
+ } from '../lan-access/state.js';
13
+
14
+ const apiRouter = express.Router();
15
+ const pageRouter = express.Router();
16
+
17
+ function getDefaultUser() {
18
+ return userDb.ensureDefaultUser();
19
+ }
20
+
21
+ function getRequestProtocol(req) {
22
+ const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim();
23
+ if (forwardedProto) {
24
+ return forwardedProto.split(',')[0].trim() || 'http';
25
+ }
26
+
27
+ return req.protocol || 'http';
28
+ }
29
+
30
+ function buildAppUrl(req, address) {
31
+ const protocol = getRequestProtocol(req);
32
+ const requestUrl = new URL(`${protocol}://${req.headers.host || 'localhost'}`);
33
+ requestUrl.hostname = address;
34
+ requestUrl.pathname = '/';
35
+ requestUrl.search = '';
36
+ requestUrl.hash = '';
37
+ return requestUrl.toString();
38
+ }
39
+
40
+ function buildExchangeUrl(req, address, exchangeToken) {
41
+ const protocol = getRequestProtocol(req);
42
+ const requestUrl = new URL(`${protocol}://${req.headers.host || 'localhost'}`);
43
+ requestUrl.hostname = address;
44
+ requestUrl.pathname = '/lan-access/exchange';
45
+ requestUrl.search = '';
46
+ requestUrl.searchParams.set('token', exchangeToken);
47
+ return requestUrl.toString();
48
+ }
49
+
50
+ function renderExchangeDocument(token) {
51
+ const serializedToken = JSON.stringify(String(token || ''));
52
+ return `<!DOCTYPE html>
53
+ <html lang="zh-CN">
54
+ <head>
55
+ <meta charset="utf-8" />
56
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
57
+ <title>Axhub Genie</title>
58
+ <style>
59
+ body {
60
+ margin: 0;
61
+ min-height: 100vh;
62
+ display: grid;
63
+ place-items: center;
64
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
65
+ background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
66
+ color: #0f172a;
67
+ }
68
+ .card {
69
+ width: min(92vw, 420px);
70
+ border-radius: 24px;
71
+ padding: 28px;
72
+ background: rgba(255, 255, 255, 0.92);
73
+ box-shadow: 0 30px 80px rgba(15, 23, 42, 0.12);
74
+ text-align: center;
75
+ }
76
+ h1 {
77
+ margin: 0 0 10px;
78
+ font-size: 22px;
79
+ }
80
+ p {
81
+ margin: 0;
82
+ color: #475569;
83
+ font-size: 14px;
84
+ }
85
+ .dots {
86
+ margin: 18px auto 0;
87
+ display: inline-flex;
88
+ gap: 8px;
89
+ }
90
+ .dot {
91
+ width: 8px;
92
+ height: 8px;
93
+ border-radius: 999px;
94
+ background: #2563eb;
95
+ animation: bounce 1s infinite ease-in-out;
96
+ }
97
+ .dot:nth-child(2) { animation-delay: 0.12s; }
98
+ .dot:nth-child(3) { animation-delay: 0.24s; }
99
+ @keyframes bounce {
100
+ 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
101
+ 40% { transform: translateY(-4px); opacity: 1; }
102
+ }
103
+ </style>
104
+ </head>
105
+ <body>
106
+ <div class="card">
107
+ <h1>正在进入 Axhub Genie</h1>
108
+ <p>验证通过后会自动跳转。</p>
109
+ <div class="dots" aria-hidden="true">
110
+ <span class="dot"></span>
111
+ <span class="dot"></span>
112
+ <span class="dot"></span>
113
+ </div>
114
+ </div>
115
+ <script>
116
+ try {
117
+ localStorage.setItem('auth-token', ${serializedToken});
118
+ window.location.replace('/');
119
+ } catch (error) {
120
+ document.body.innerHTML = '<div class="card"><h1>进入失败</h1><p>无法写入本地访问凭证,请检查浏览器隐私设置后重试。</p></div>';
121
+ }
122
+ </script>
123
+ </body>
124
+ </html>`;
125
+ }
126
+
127
+ apiRouter.get('/summary', authenticateToken, async (_req, res) => {
128
+ res.json({
129
+ success: true,
130
+ hasPassword: hasLanAccessPassword(),
131
+ addresses: collectLanIPv4Addresses(),
132
+ sessionVersion: getLanSessionVersion(),
133
+ });
134
+ });
135
+
136
+ apiRouter.post('/password', authenticateToken, async (req, res) => {
137
+ try {
138
+ const password = String(req.body?.password || '');
139
+ const state = await setLanAccessPassword(password);
140
+ res.json({
141
+ success: true,
142
+ hasPassword: true,
143
+ sessionVersion: state.sessionVersion,
144
+ message: '局域网访问密码已更新。'
145
+ });
146
+ } catch (error) {
147
+ const message = error?.message || 'Failed to update LAN access password';
148
+ const statusCode = message.includes('required') ? 400 : 500;
149
+ res.status(statusCode).json({ success: false, error: message });
150
+ }
151
+ });
152
+
153
+ apiRouter.post('/token', authenticateToken, async (req, res) => {
154
+ try {
155
+ const address = String(req.body?.address || '').trim();
156
+ if (!address) {
157
+ return res.status(400).json({ success: false, error: 'Address is required' });
158
+ }
159
+
160
+ if (!hasLanAccessPassword()) {
161
+ return res.status(412).json({ success: false, error: '请先设置局域网访问密码。' });
162
+ }
163
+
164
+ const user = getDefaultUser();
165
+ const exchangeToken = generateToken(user, {
166
+ expiresIn: '8m',
167
+ purpose: 'lan-exchange',
168
+ });
169
+ const appUrl = buildAppUrl(req, address);
170
+ const exchangeUrl = buildExchangeUrl(req, address, exchangeToken);
171
+ const qrCodeDataUrl = await QRCode.toDataURL(exchangeUrl, {
172
+ margin: 1,
173
+ width: 256,
174
+ });
175
+
176
+ res.json({
177
+ success: true,
178
+ address,
179
+ accessUrl: appUrl,
180
+ exchangeUrl,
181
+ qrCodeDataUrl,
182
+ expiresInSeconds: 8 * 60,
183
+ });
184
+ } catch (error) {
185
+ console.error('LAN access token error:', error);
186
+ res.status(500).json({ success: false, error: 'Failed to generate LAN access link' });
187
+ }
188
+ });
189
+
190
+ apiRouter.post('/login', async (req, res) => {
191
+ try {
192
+ if (!hasLanAccessPassword()) {
193
+ return res.status(412).json({ success: false, error: '局域网访问密码尚未设置。' });
194
+ }
195
+
196
+ const password = String(req.body?.password || '');
197
+ const isValidPassword = await verifyLanAccessPassword(password);
198
+ if (!isValidPassword) {
199
+ return res.status(401).json({ success: false, error: '访问密码不正确。' });
200
+ }
201
+
202
+ const user = getDefaultUser();
203
+ const token = generateToken(user);
204
+ res.json({ success: true, token, user });
205
+ } catch (error) {
206
+ console.error('LAN access login error:', error);
207
+ res.status(500).json({ success: false, error: 'Failed to sign in with LAN access password' });
208
+ }
209
+ });
210
+
211
+ pageRouter.get('/exchange', async (req, res) => {
212
+ try {
213
+ const exchangeToken = String(req.query?.token || '');
214
+ if (!exchangeToken) {
215
+ return res.status(400).send(renderExchangeDocument(''));
216
+ }
217
+
218
+ const decoded = verifySignedToken(exchangeToken, { expectedPurpose: 'lan-exchange' });
219
+ const user = userDb.getUserById(decoded.userId) || getDefaultUser();
220
+ const token = generateToken(user);
221
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
222
+ res.send(renderExchangeDocument(token));
223
+ } catch (error) {
224
+ res.status(403).send(`<!DOCTYPE html><html lang="zh-CN"><body style="font-family: ui-sans-serif, sans-serif; padding: 32px;"><h1 style="margin:0 0 12px;">链接已失效</h1><p style="margin:0;color:#475569;">请重新生成局域网访问链接或二维码。</p></body></html>`);
225
+ }
226
+ });
227
+
228
+ export {
229
+ apiRouter as lanAccessApiRoutes,
230
+ pageRouter as lanAccessPageRoutes,
231
+ };
@@ -19,39 +19,116 @@ export {
19
19
 
20
20
  const router = express.Router();
21
21
 
22
- // System-critical paths that should never be used as workspace directories
23
- export const FORBIDDEN_PATHS = [
24
- // Unix
25
- '/',
26
- '/etc',
27
- '/bin',
28
- '/sbin',
29
- '/usr',
30
- '/dev',
31
- '/proc',
32
- '/sys',
33
- '/var',
34
- '/boot',
35
- '/root',
36
- '/lib',
37
- '/lib64',
38
- '/opt',
39
- '/tmp',
40
- '/run',
41
- // Windows
42
- 'C:\\Windows',
43
- 'C:\\Program Files',
44
- 'C:\\Program Files (x86)',
45
- 'C:\\ProgramData',
46
- 'C:\\System Volume Information',
47
- 'C:\\$Recycle.Bin'
22
+ const SYSTEM_PATH_RULES = [
23
+ { label: '/', kind: 'exact' },
24
+ { label: '/etc', kind: 'prefix' },
25
+ { label: '/bin', kind: 'prefix' },
26
+ { label: '/sbin', kind: 'prefix' },
27
+ { label: '/usr', kind: 'prefix' },
28
+ { label: '/dev', kind: 'prefix' },
29
+ { label: '/proc', kind: 'prefix' },
30
+ { label: '/sys', kind: 'prefix' },
31
+ { label: '/var', kind: 'prefix' },
32
+ { label: '/boot', kind: 'prefix' },
33
+ { label: '/root', kind: 'prefix' },
34
+ { label: '/lib', kind: 'prefix' },
35
+ { label: '/lib64', kind: 'prefix' },
36
+ { label: '/opt', kind: 'prefix' },
37
+ { label: '/tmp', kind: 'exact' },
38
+ { label: '/run', kind: 'prefix' },
39
+ { label: 'C:\\Windows', kind: 'prefix' },
40
+ { label: 'C:\\Program Files', kind: 'prefix' },
41
+ { label: 'C:\\Program Files (x86)', kind: 'prefix' },
42
+ { label: 'C:\\ProgramData', kind: 'prefix' },
43
+ { label: 'C:\\System Volume Information', kind: 'prefix' },
44
+ { label: 'C:\\$Recycle.Bin', kind: 'prefix' }
48
45
  ];
49
46
 
50
- /**
51
- * Validates that a path is safe for workspace operations
52
- * @param {string} requestedPath - The path to validate
53
- * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
54
- */
47
+ export const FORBIDDEN_PATHS = Object.freeze(SYSTEM_PATH_RULES.map((rule) => rule.label));
48
+
49
+ function normalizeCandidatePath(candidatePath) {
50
+ return path.normalize(path.resolve(candidatePath));
51
+ }
52
+
53
+ function isVarException(candidatePath) {
54
+ return candidatePath.startsWith('/var/tmp') || candidatePath.startsWith('/var/folders');
55
+ }
56
+
57
+ function findSystemPathViolation(candidatePath) {
58
+ for (const rule of SYSTEM_PATH_RULES) {
59
+ if (rule.label === '/var' && isVarException(candidatePath)) {
60
+ continue;
61
+ }
62
+
63
+ if (rule.kind === 'exact' && candidatePath === rule.label) {
64
+ return rule.label;
65
+ }
66
+
67
+ if (rule.kind === 'prefix' && (candidatePath === rule.label || candidatePath.startsWith(`${rule.label}${path.sep}`))) {
68
+ return rule.label;
69
+ }
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ async function resolvePathForValidation(candidatePath, fsImpl) {
76
+ try {
77
+ await fsImpl.access(candidatePath);
78
+ return await fsImpl.realpath(candidatePath);
79
+ } catch (error) {
80
+ if (error.code !== 'ENOENT') {
81
+ throw error;
82
+ }
83
+
84
+ const parentPath = path.dirname(candidatePath);
85
+
86
+ try {
87
+ const resolvedParent = await fsImpl.realpath(parentPath);
88
+ return path.join(resolvedParent, path.basename(candidatePath));
89
+ } catch (parentError) {
90
+ if (parentError.code === 'ENOENT') {
91
+ return candidatePath;
92
+ }
93
+ throw parentError;
94
+ }
95
+ }
96
+ }
97
+
98
+ async function assertSymlinkDoesNotEscape(candidatePath, options) {
99
+ const {
100
+ fsImpl,
101
+ hasWorkspaceRootRestriction,
102
+ allowedWorkspaceRoots,
103
+ resolvedWorkspaceRoots
104
+ } = options;
105
+
106
+ if (!hasWorkspaceRootRestriction || allowedWorkspaceRoots.length === 0) {
107
+ return null;
108
+ }
109
+
110
+ try {
111
+ const stat = await fsImpl.lstat(candidatePath);
112
+ if (!stat.isSymbolicLink()) {
113
+ return null;
114
+ }
115
+
116
+ const linkTarget = await fsImpl.readlink(candidatePath);
117
+ const resolvedTarget = path.resolve(path.dirname(candidatePath), linkTarget);
118
+ const realTarget = await fsImpl.realpath(resolvedTarget);
119
+
120
+ if (!isPathWithinAllowedRoots(realTarget, resolvedWorkspaceRoots)) {
121
+ return 'Symlink target is outside the allowed workspace root';
122
+ }
123
+ } catch (error) {
124
+ if (error.code !== 'ENOENT') {
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
131
+
55
132
  export async function validateWorkspacePath(requestedPath, options = {}) {
56
133
  try {
57
134
  const {
@@ -60,73 +137,29 @@ export async function validateWorkspacePath(requestedPath, options = {}) {
60
137
  allowedWorkspaceRoots = WORKSPACES_ROOTS
61
138
  } = options;
62
139
 
63
- // Resolve to absolute path
64
- let absolutePath = path.resolve(requestedPath);
140
+ const absoluteCandidate = normalizeCandidatePath(requestedPath);
141
+ const violation = findSystemPathViolation(absoluteCandidate);
65
142
 
66
- // Check if path is a forbidden system directory
67
- const normalizedPath = path.normalize(absolutePath);
68
- if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
143
+ if (violation) {
69
144
  return {
70
145
  valid: false,
71
- error: 'Cannot use system-critical directories as workspace locations'
146
+ error: violation === '/'
147
+ ? 'Cannot use system-critical directories as workspace locations'
148
+ : `Cannot create workspace in system directory: ${violation}`
72
149
  };
73
150
  }
74
151
 
75
- // Additional check for paths starting with forbidden directories
76
- for (const forbidden of FORBIDDEN_PATHS) {
77
- if (normalizedPath === forbidden ||
78
- normalizedPath.startsWith(forbidden + path.sep)) {
79
- // Exception: /var/tmp and similar user-accessible paths might be allowed
80
- // but /var itself and most /var subdirectories should be blocked
81
- if (forbidden === '/var' &&
82
- (normalizedPath.startsWith('/var/tmp') ||
83
- normalizedPath.startsWith('/var/folders'))) {
84
- continue; // Allow these specific cases
85
- }
86
-
87
- return {
88
- valid: false,
89
- error: `Cannot create workspace in system directory: ${forbidden}`
90
- };
91
- }
92
- }
93
-
94
- // Try to resolve the real path (following symlinks)
95
- let realPath;
96
- try {
97
- // Check if path exists to resolve real path
98
- await fsImpl.access(absolutePath);
99
- realPath = await fsImpl.realpath(absolutePath);
100
- } catch (error) {
101
- if (error.code === 'ENOENT') {
102
- // Path doesn't exist yet - check parent directory
103
- let parentPath = path.dirname(absolutePath);
104
- try {
105
- const parentRealPath = await fsImpl.realpath(parentPath);
106
-
107
- // Reconstruct the full path with real parent
108
- realPath = path.join(parentRealPath, path.basename(absolutePath));
109
- } catch (parentError) {
110
- if (parentError.code === 'ENOENT') {
111
- // Parent doesn't exist either - use the absolute path as-is
112
- // We'll validate it's within allowed root
113
- realPath = absolutePath;
114
- } else {
115
- throw parentError;
116
- }
117
- }
118
- } else {
119
- throw error;
120
- }
121
- }
152
+ const resolvedPath = await resolvePathForValidation(absoluteCandidate, fsImpl);
153
+ const normalizedResolvedPath = normalizeCandidatePath(resolvedPath);
122
154
 
155
+ let resolvedWorkspaceRoots = [];
123
156
  if (hasWorkspaceRootRestriction && allowedWorkspaceRoots.length > 0) {
124
- const resolvedWorkspaceRoots = await resolveAllowedWorkspaceRoots({
157
+ resolvedWorkspaceRoots = await resolveAllowedWorkspaceRoots({
125
158
  fsImpl,
126
159
  allowedWorkspaceRoots
127
160
  });
128
161
 
129
- if (!isPathWithinAllowedRoots(realPath, resolvedWorkspaceRoots)) {
162
+ if (!isPathWithinAllowedRoots(normalizedResolvedPath, resolvedWorkspaceRoots)) {
130
163
  return {
131
164
  valid: false,
132
165
  error: `Workspace path must be within one of the allowed workspace roots: ${formatAllowedWorkspaceRoots(allowedWorkspaceRoots)}`
@@ -134,40 +167,24 @@ export async function validateWorkspacePath(requestedPath, options = {}) {
134
167
  }
135
168
  }
136
169
 
137
- // Additional symlink check for existing paths
138
- try {
139
- await fsImpl.access(absolutePath);
140
- const stats = await fsImpl.lstat(absolutePath);
141
-
142
- if (stats.isSymbolicLink() && hasWorkspaceRootRestriction && allowedWorkspaceRoots.length > 0) {
143
- const resolvedWorkspaceRoots = await resolveAllowedWorkspaceRoots({
144
- fsImpl,
145
- allowedWorkspaceRoots
146
- });
147
- // Verify symlink target is also within allowed root
148
- const linkTarget = await fsImpl.readlink(absolutePath);
149
- const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
150
- const realTarget = await fsImpl.realpath(resolvedTarget);
151
-
152
- if (!isPathWithinAllowedRoots(realTarget, resolvedWorkspaceRoots)) {
153
- return {
154
- valid: false,
155
- error: 'Symlink target is outside the allowed workspace root'
156
- };
157
- }
158
- }
159
- } catch (error) {
160
- if (error.code !== 'ENOENT') {
161
- throw error;
162
- }
163
- // Path doesn't exist - that's fine for new workspace creation
170
+ const symlinkViolation = await assertSymlinkDoesNotEscape(absoluteCandidate, {
171
+ fsImpl,
172
+ hasWorkspaceRootRestriction,
173
+ allowedWorkspaceRoots,
174
+ resolvedWorkspaceRoots
175
+ });
176
+
177
+ if (symlinkViolation) {
178
+ return {
179
+ valid: false,
180
+ error: symlinkViolation
181
+ };
164
182
  }
165
183
 
166
184
  return {
167
185
  valid: true,
168
- resolvedPath: realPath
186
+ resolvedPath: normalizedResolvedPath
169
187
  };
170
-
171
188
  } catch (error) {
172
189
  return {
173
190
  valid: false,
@@ -176,38 +193,30 @@ export async function validateWorkspacePath(requestedPath, options = {}) {
176
193
  }
177
194
  }
178
195
 
179
- /**
180
- * Create a new workspace
181
- * POST /api/projects/create-workspace
182
- *
183
- * Body:
184
- * - workspaceType: 'existing' | 'new'
185
- * - path: string (workspace path)
186
- */
187
196
  router.post('/create-workspace', async (req, res) => {
188
197
  try {
189
- const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
198
+ const {
199
+ workspaceType,
200
+ path: workspacePath,
201
+ githubUrl,
202
+ githubTokenId,
203
+ newGithubToken
204
+ } = req.body;
190
205
 
191
- // Validate required fields
192
206
  if (!workspaceType || !workspacePath) {
193
207
  return res.status(400).json({ error: 'workspaceType and path are required' });
194
208
  }
195
209
 
196
- if (
197
- githubUrl !== undefined
198
- || githubTokenId !== undefined
199
- || newGithubToken !== undefined
200
- ) {
210
+ if (githubUrl !== undefined || githubTokenId !== undefined || newGithubToken !== undefined) {
201
211
  return res.status(400).json({
202
212
  error: 'GitHub-based workspace creation has been removed. Create an empty workspace or add an existing local workspace instead.'
203
213
  });
204
214
  }
205
215
 
206
- if (!['existing', 'new'].includes(workspaceType)) {
216
+ if (workspaceType !== 'existing' && workspaceType !== 'new') {
207
217
  return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
208
218
  }
209
219
 
210
- // Validate path safety before any operations
211
220
  const validation = await validateWorkspacePath(workspacePath);
212
221
  if (!validation.valid) {
213
222
  return res.status(400).json({
@@ -216,16 +225,12 @@ router.post('/create-workspace', async (req, res) => {
216
225
  });
217
226
  }
218
227
 
219
- const absolutePath = validation.resolvedPath;
228
+ const targetPath = validation.resolvedPath;
220
229
 
221
- // Handle existing workspace
222
230
  if (workspaceType === 'existing') {
223
- // Check if the path exists
224
231
  try {
225
- await fs.access(absolutePath);
226
- const stats = await fs.stat(absolutePath);
227
-
228
- if (!stats.isDirectory()) {
232
+ const stat = await fs.stat(targetPath);
233
+ if (!stat.isDirectory()) {
229
234
  return res.status(400).json({ error: 'Path exists but is not a directory' });
230
235
  }
231
236
  } catch (error) {
@@ -235,9 +240,7 @@ router.post('/create-workspace', async (req, res) => {
235
240
  throw error;
236
241
  }
237
242
 
238
- // Add the existing workspace to the project list
239
- const project = await addProjectManually(absolutePath);
240
-
243
+ const project = await addProjectManually(targetPath);
241
244
  return res.json({
242
245
  success: true,
243
246
  project,
@@ -245,26 +248,19 @@ router.post('/create-workspace', async (req, res) => {
245
248
  });
246
249
  }
247
250
 
248
- // Handle new workspace creation
249
- if (workspaceType === 'new') {
250
- // Create the directory if it doesn't exist
251
- await fs.mkdir(absolutePath, { recursive: true });
252
-
253
- // Add the new workspace to the project list (no clone)
254
- const project = await addProjectManually(absolutePath);
255
-
256
- return res.json({
257
- success: true,
258
- project,
259
- message: 'New workspace created successfully'
260
- });
261
- }
251
+ await fs.mkdir(targetPath, { recursive: true });
252
+ const project = await addProjectManually(targetPath);
262
253
 
254
+ res.json({
255
+ success: true,
256
+ project,
257
+ message: 'New workspace created successfully'
258
+ });
263
259
  } catch (error) {
264
260
  console.error('Error creating workspace:', error);
265
261
  res.status(500).json({
266
- error: error.message || 'Failed to create workspace',
267
- details: process.env.NODE_ENV === 'development' ? error.stack : undefined
262
+ error: 'Failed to create workspace',
263
+ details: error.message
268
264
  });
269
265
  }
270
266
  });