@goodandready/dsh-moa 0.2.59 → 0.2.61

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/lib/access.js CHANGED
@@ -136,6 +136,19 @@ export function getReadRejectionStatus(req, ctx = null) {
136
136
  return 403
137
137
  }
138
138
 
139
+ const host = String(req.headers.host || '').trim().toLowerCase()
140
+ const origin = req.headers.origin
141
+ if (origin && origin !== 'null') {
142
+ try {
143
+ const u = new URL(origin)
144
+ if (host && u.host.toLowerCase() !== host) {
145
+ return 403
146
+ }
147
+ } catch {
148
+ return 403
149
+ }
150
+ }
151
+
139
152
  // 2. Reverse-proxy / forwarded client verification (#257)
140
153
  const xff = req.headers['x-forwarded-for']
141
154
  let isForwardedRemote = false
@@ -149,7 +162,18 @@ export function getReadRejectionStatus(req, ctx = null) {
149
162
  }
150
163
  }
151
164
 
152
- // 3. Authoritative DSH connection service check (#257)
165
+ // 3. Genuine loopback / in-process callers are admitted (#2, #271)
166
+ // Direct loopback traffic (127.0.0.1 / ::1 / localhost) not forwarded from a remote proxy
167
+ // cannot originate from the external network. In-process callers, CLI commands, and
168
+ // adjacent DSH plugins do not carry browser session cookies.
169
+ const rawRemote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''
170
+ const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '')
171
+
172
+ if (cleanRemote && isLoopbackIp(cleanRemote) && !isForwardedRemote) {
173
+ return undefined
174
+ }
175
+
176
+ // 4. Authoritative DSH connection service check (#257)
153
177
  const conn = getConnection(ctx) || req?.connectionService || ctx?.connection
154
178
  if (conn && typeof conn.requestRejection === 'function') {
155
179
  let rejection
@@ -167,10 +191,7 @@ export function getReadRejectionStatus(req, ctx = null) {
167
191
  return undefined
168
192
  }
169
193
 
170
- // 4. Network boundary evaluation when connection service is absent
171
- const rawRemote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''
172
- const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '')
173
-
194
+ // 5. Network boundary evaluation when connection service is absent
174
195
  if (cleanRemote && !isLoopbackIp(cleanRemote)) {
175
196
  return 401
176
197
  }
@@ -179,7 +200,6 @@ export function getReadRejectionStatus(req, ctx = null) {
179
200
  return 401
180
201
  }
181
202
 
182
- const host = String(req.headers.host || '').trim().toLowerCase()
183
203
  if (!cleanRemote && host) {
184
204
  const hostName = host.split(':')[0]
185
205
  if (!isLoopbackIp(hostName)) {
@@ -1,594 +1,34 @@
1
- import { bestEffort } from './best-effort.js'
2
- import { logger } from './logger.js'
3
- import vm from 'node:vm'
4
- import fs from 'node:fs/promises'
5
- import fsSync from 'node:fs'
6
- import path from 'node:path'
7
1
  /**
8
- * In-memory serialization mutex to prevent interleaved promotions to the same baseDir.
9
- */
10
- const promotionLocks = new Map()
11
-
12
- /**
13
- * Validates that an untrusted relative path is safe and does not traverse upwards.
14
- */
15
- export function isSafeRelativePath(relPath) {
16
- if (!relPath || typeof relPath !== 'string') return false
17
- const trimmed = relPath.trim()
18
- if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
19
- const normalized = path.normalize(trimmed)
20
- return !(!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized))
21
- }
22
-
23
- /**
24
- * Asserts that a resolved path is strictly contained within baseDir, verifying
25
- * both lexical boundaries and physical canonical symlink resolution.
26
- */
27
- export function assertPathContained(baseDir, subPath) {
28
- const resolvedBase = path.resolve(baseDir)
29
- const resolvedTarget = path.resolve(resolvedBase, subPath)
30
- if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
31
- throw new Error(`Path traversal violation: "${subPath}" escapes base directory "${baseDir}"`)
32
- }
33
-
34
- try {
35
- const realBase = fsSync.realpathSync(resolvedBase)
36
- try {
37
- const realTarget = fsSync.realpathSync(resolvedTarget)
38
- if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
39
- throw new Error(`Path traversal violation: symlink "${subPath}" resolves outside "${baseDir}"`)
40
- }
41
- } catch (targetErr) {
42
- if (targetErr?.code === 'ENOENT') {
43
- let curr = path.dirname(resolvedTarget)
44
- while (curr === resolvedBase || curr.startsWith(resolvedBase + path.sep)) {
45
- try {
46
- const realCurr = fsSync.realpathSync(curr)
47
- if (realCurr !== realBase && !realCurr.startsWith(realBase + path.sep)) {
48
- throw new Error(`Path traversal violation: symlink ancestor in "${subPath}" resolves outside "${baseDir}"`)
49
- }
50
- break
51
- } catch (ancestorErr) {
52
- if (ancestorErr?.code === 'ENOENT') {
53
- const parent = path.dirname(curr)
54
- if (parent === curr) break
55
- curr = parent
56
- continue
57
- }
58
- throw ancestorErr
59
- }
60
- }
61
- } else {
62
- throw targetErr
63
- }
64
- }
65
- } catch (baseErr) {
66
- if (baseErr?.message?.startsWith('Path traversal violation')) throw baseErr
67
- }
68
-
69
- return resolvedTarget
70
- }
71
-
72
- /**
73
- * Resolves directory paths for candidate sandboxes with optional runId scoping.
74
- */
75
- export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
76
- const targetStr = String(candidateTarget ?? 1).trim()
77
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
78
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
79
- const rawRunId = typeof options === 'string' ? options : options?.runId
80
- const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
81
- const moaRoot = assertPathContained(baseDir, '.moa')
82
- const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
83
- return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
84
- }
85
-
86
- /**
87
- * Parses markdown code blocks that specify file paths.
88
- */
89
- export function extractFileBlocks(text) {
90
- if (!text || typeof text !== 'string') return []
91
- const files = []
92
- const seenPaths = new Set()
93
- const addFile = (rawPath, fileContent) => {
94
- if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
95
- seenPaths.add(rawPath)
96
- files.push({ relativePath: rawPath, content: fileContent.trimEnd() + '\n' })
97
- }
98
- }
99
-
100
- const patterns = [
101
- /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
102
- /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
103
- /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
104
- /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
105
- ]
106
- for (const regex of patterns) {
107
- let match
108
- while ((match = regex.exec(text)) !== null) addFile(match[1].trim(), match[2])
109
- }
110
-
111
- if (files.length === 0) {
112
- const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
113
- let htmlMatch
114
- while ((htmlMatch = htmlBlockRegex.exec(text)) !== null) {
115
- const candidateHtml = htmlMatch[1]
116
- if (/<(!DOCTYPE\s+)?html/i.test(candidateHtml) || /<body/i.test(candidateHtml)) {
117
- files.push({ relativePath: 'index.html', content: candidateHtml.trimEnd() + '\n' })
118
- break
119
- }
120
- }
121
- }
122
- return files
123
- }
124
-
125
- /**
126
- * Formats collected project files into a structured markdown context string.
127
- */
128
- export function formatProjectContext(projectFiles = [], options = {}) {
129
- if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
130
- const { skippedFiles = 0, skippedList = [] } = options
131
- const fence = '```'
132
- let formatted = projectFiles
133
- .map((f) => `### File: ${f.relativePath}\n${fence}\n${f.content}\n${fence}`)
134
- .join('\n\n')
135
-
136
- if (skippedFiles > 0) {
137
- const preview = skippedList.slice(0, 5).join(', ')
138
- const more = skippedList.length > 5 ? ` and ${skippedList.length - 5} more` : ''
139
- const warning = `> ⚠️ **Workspace Scan Notice:** ${skippedFiles} workspace file(s) were skipped or truncated due to size/budget limits or read errors (${preview}${more}). The workspace context below is partial.`
140
- formatted = `${warning}\n\n${formatted}`
141
- }
142
- return formatted
143
- }
144
-
145
- export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
146
- if (!baseDir) return { files: [], totalChars: 0, skippedFiles: 0, skippedList: [] }
147
- const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
148
- const codeExts = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml'])
149
- const collected = []
150
- let totalChars = 0
151
- let skippedFiles = 0
152
- const skippedList = []
153
-
154
- async function scan(currentDir, relPrefix = '') {
155
- if (totalChars >= maxCharBudget) return
156
- let list
157
- try {
158
- list = await fs.readdir(currentDir, { withFileTypes: true })
159
- } catch {
160
- skippedFiles++
161
- skippedList.push(relPrefix || '.')
162
- return
163
- }
164
-
165
- for (const item of list) {
166
- if (item.name.startsWith('.')) continue
167
- const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
168
- const full = path.join(currentDir, item.name)
169
-
170
- if (totalChars >= maxCharBudget) {
171
- if (item.isFile() && codeExts.has(path.extname(item.name).toLowerCase())) {
172
- skippedFiles++
173
- skippedList.push(`${rel} (budget limit)`)
174
- }
175
- continue
176
- }
177
-
178
- if (item.isDirectory()) {
179
- if (!ignoreDirs.has(item.name)) await scan(full, rel)
180
- } else if (item.isFile()) {
181
- const ext = path.extname(item.name).toLowerCase()
182
- if (codeExts.has(ext)) {
183
- try {
184
- const stat = await fs.stat(full)
185
- if (stat.size > 100000) {
186
- skippedFiles++
187
- skippedList.push(`${rel} (>100KB)`)
188
- continue
189
- }
190
- const content = await fs.readFile(full, 'utf8')
191
- const budgetRemaining = maxCharBudget - totalChars
192
- const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
193
- totalChars += truncated.length
194
- collected.push({ relativePath: rel, content: truncated, size: stat.size })
195
- } catch (err) {
196
- skippedFiles++
197
- skippedList.push(`${rel} (read error: ${err?.message || String(err)})`)
198
- }
199
- }
200
- }
201
- }
202
- }
203
-
204
- await scan(baseDir)
205
- return { files: collected, totalChars, skippedFiles, skippedList }
206
- }
207
-
208
- /**
209
- * Checks if a task prompt is an iterative refinement / modification.
210
- */
211
- export function isRefinementTask(userPrompt = '', projectFiles = null) {
212
- if ((Array.isArray(projectFiles) && projectFiles.length === 0) || !userPrompt?.trim()) return false
213
- const p = userPrompt.toLowerCase().trim()
214
- const fresh = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
215
- if (fresh.some((phrase) => p.includes(phrase))) return false
216
- const mod = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
217
- return mod.some((kw) => p.includes(kw))
218
- }
219
-
220
- /**
221
- * Writes candidate files into an isolated candidate workspace.
222
- * Uses atomic staging and swap to prevent stale file accumulation and dirty snapshots.
223
- */
224
- export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [], options = {}) {
225
- if (!baseDir || !Array.isArray(files) || files.length === 0) return []
226
- const { runRoot, sub, candidateDir } = resolveCandidateFolder(baseDir, candidateIndex, options)
227
- await fs.mkdir(runRoot, { recursive: true })
228
-
229
- const tempSub = `.tmp-${sub}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
230
- const tempCandidateDir = assertPathContained(runRoot, tempSub)
231
- await fs.mkdir(tempCandidateDir, { recursive: true })
232
-
233
- try {
234
- const written = []
235
- for (const file of files) {
236
- if (!isSafeRelativePath(file.relativePath)) continue
237
- const fullPath = assertPathContained(tempCandidateDir, file.relativePath)
238
- await fs.mkdir(path.dirname(fullPath), { recursive: true })
239
- await fs.writeFile(fullPath, file.content, 'utf8')
240
- written.push({
241
- relativePath: file.relativePath,
242
- fullPath: path.join(candidateDir, file.relativePath),
243
- size: Buffer.byteLength(file.content, 'utf8'),
244
- })
245
- }
246
-
247
- if (written.length === 0 && files.length > 0) throw new Error('No valid files could be written to candidate workspace')
248
- await fs.rm(candidateDir, { recursive: true, force: true })
249
- await fs.rename(tempCandidateDir, candidateDir)
250
- return written
251
- } catch (err) {
252
- await bestEffort('clean tempCandidateDir on error', () => fs.rm(tempCandidateDir, { recursive: true, force: true }))
253
- throw err
254
- }
255
- }
256
-
257
- export function isLoopbackUrl(urlStr) {
258
- if (!urlStr || typeof urlStr !== 'string') return false
259
- try {
260
- const u = new URL(urlStr)
261
- if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
262
- const h = u.hostname.replace(/^\[|\]$/g, '')
263
- return h === 'localhost' || h === '127.0.0.1' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h)
264
- } catch {
265
- return false
266
- }
267
- }
268
-
269
- /**
270
- * Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
271
- * before candidate promotion overwrites files in the project workspace.
272
- */
273
- export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
274
- if (!baseDir || options.checkpoint === false) return null
275
- if (typeof options.checkpointFn === 'function') {
276
- return (await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))) || null
277
- }
278
-
279
- const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
280
- if (options.timeMachineEngine && typeof options.timeMachineEngine.createSnapshot === 'function') {
281
- const snap = await bestEffort('dsh-time-machine direct service createSnapshot', async () => {
282
- return await options.timeMachineEngine.createSnapshot(label, { cwd: baseDir })
283
- })
284
- if (snap) return snap
285
- }
286
-
287
- const port = options.port || options.webServerPort || process.env.DSH_PORT || process.env.PORT
288
- if (!port && !options.timeMachineUrl) {
289
- logger.warn('[dsh-moa] Cannot create pre-promotion checkpoint: webServerPort is unknown and no timeMachineUrl provided')
290
- return null
291
- }
292
-
293
- if (options.timeMachineUrl && !isLoopbackUrl(options.timeMachineUrl)) {
294
- logger.warn(`[dsh-moa] Blocked non-loopback timeMachineUrl: ${options.timeMachineUrl}`)
295
- return null
296
- }
297
-
298
- const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
299
- const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
300
- const res = await fetch(endpoint, {
301
- method: 'POST',
302
- headers: { 'Content-Type': 'application/json' },
303
- body: JSON.stringify({ label, cwd: baseDir }),
304
- signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
305
- redirect: 'error',
306
- })
307
- if (res.ok) {
308
- const data = await res.json()
309
- return data?.snapshot || data || null
310
- }
311
- return null
312
- })
313
- return snap || null
314
- }
315
-
316
- /**
317
- * Promotes winning candidate workspace files into the base workspace.
318
- * Serialized per baseDir and verifies symlink boundaries on promotion.
319
- */
320
- export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
321
- if (!baseDir || winningIndex === null || winningIndex === undefined) return []
322
-
323
- const key = path.resolve(baseDir)
324
- const prevLock = promotionLocks.get(key) || Promise.resolve()
325
- let releaseLock
326
- const currentLock = new Promise((resolve) => { releaseLock = resolve })
327
- promotionLocks.set(key, currentLock)
328
-
329
- try {
330
- await prevLock
331
- return await doPromoteCandidateWorkspace(baseDir, winningIndex, options)
332
- } finally {
333
- releaseLock()
334
- if (promotionLocks.get(key) === currentLock) {
335
- promotionLocks.delete(key)
336
- }
337
- }
338
- }
339
-
340
- async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
341
- const { runRoot, moaRoot, safeRunId, candidateDir: winningDir } = resolveCandidateFolder(baseDir, winningIndex, options)
342
- const cleanupTarget = safeRunId ? runRoot : moaRoot
343
-
344
- try {
345
- const entries = await getFilesRecursively(winningDir)
346
- if (entries.length === 0) {
347
- if (!options.keepMoa) await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
348
- return []
349
- }
350
-
351
- const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
352
- if (options.checkpoint !== false && !checkpoint && !options.force) {
353
- const errMsg = `[dsh-moa] Pre-promotion checkpoint failed for candidate-${winningIndex} in ${baseDir}. File overwrite blocked to prevent uncheckpointed changes (pass force: true to override).`
354
- logger.warn(errMsg)
355
- throw new Error(errMsg)
356
- }
357
- if (options.signal?.aborted) {
358
- throw (options.signal.reason instanceof Error ? options.signal.reason : new Error('Turn aborted'))
359
- }
360
-
361
- const promoted = []
362
- const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
363
-
364
- for (const rel of entries) {
365
- if (!isSafeRelativePath(rel)) continue
366
- const srcPath = assertPathContained(winningDir, rel)
367
- const destPath = assertPathContained(baseDir, rel)
368
-
369
- try {
370
- const destLstat = await fs.lstat(destPath)
371
- if (destLstat.isSymbolicLink()) {
372
- const destReal = await fs.realpath(destPath)
373
- if (destReal !== baseReal && !destReal.startsWith(baseReal + path.sep)) {
374
- throw new Error(`Path traversal violation: promotion destination symlink "${rel}" resolves outside "${baseDir}"`)
375
- }
376
- await fs.unlink(destPath)
377
- }
378
- } catch (lstatErr) {
379
- if (lstatErr?.code !== 'ENOENT') throw lstatErr
380
- }
381
-
382
- await fs.mkdir(path.dirname(destPath), { recursive: true })
383
- await fs.copyFile(srcPath, destPath)
384
- promoted.push(rel)
385
- }
386
-
387
- if (checkpoint) promoted.checkpoint = checkpoint
388
- if (!options.keepMoa) await fs.rm(cleanupTarget, { recursive: true, force: true })
389
-
390
- return promoted
391
- } catch (err) {
392
- if (!options.keepMoa) {
393
- await bestEffort('clean candidate on promote error', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
394
- }
395
- throw err
396
- }
397
- }
398
-
399
- /**
400
- * Cleans up candidate workspaces, optionally scoped to a single runId.
401
- */
402
- export async function cleanMoaWorkspaces(baseDir, options = {}) {
403
- if (!baseDir) return
404
- const rawRunId = typeof options === 'string' ? options : options?.runId
405
- const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
406
- const moaRoot = assertPathContained(baseDir, '.moa')
407
- if (safeRunId) {
408
- await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(assertPathContained(moaRoot, safeRunId), { recursive: true, force: true }))
409
- await bestEffort('clean empty moaRoot', async () => {
410
- if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
411
- })
412
- } else {
413
- await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
414
- }
415
- }
416
-
417
- async function getFilesRecursively(dir) {
418
- const results = []
419
- try {
420
- const stat = await fs.stat(dir)
421
- if (!stat.isDirectory()) return []
422
- } catch {
423
- return []
424
- }
425
-
426
- let realDir
427
- try {
428
- realDir = await fs.realpath(dir)
429
- } catch {
430
- realDir = path.resolve(dir)
431
- }
432
-
433
- async function scan(current, relPrefix = '') {
434
- let list
435
- try {
436
- list = await fs.readdir(current, { withFileTypes: true })
437
- for (const item of list) {
438
- const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
439
- const full = path.join(current, item.name)
440
-
441
- if (item.isSymbolicLink()) {
442
- try {
443
- const realTarget = await fs.realpath(full)
444
- if (realTarget === realDir || realTarget.startsWith(realDir + path.sep)) {
445
- const st = await fs.stat(full)
446
- if (st.isFile()) results.push(rel)
447
- }
448
- } catch (err) { logger.debug?.('[dsh-moa] symlink inspect skipped', err) }
449
- continue
450
- }
451
-
452
- if (item.isDirectory()) await scan(full, rel)
453
- else if (item.isFile()) results.push(rel)
454
- }
455
- } catch {
456
- return
457
- }
458
- }
459
- await scan(dir)
460
- return results
461
- }
462
-
463
- /**
464
- * Verifies the syntactic validity of generated code files.
465
- */
466
- export function verifyFileSyntax(files = []) {
467
- if (!Array.isArray(files)) return []
468
- const warnings = []
469
-
470
- for (const file of files) {
471
- if (!file || typeof file.content !== 'string' || !file.relativePath) continue
472
- const ext = path.extname(file.relativePath).toLowerCase()
473
-
474
- if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
475
- try {
476
- const transformed = file.content
477
- .replace(/^([ \t]*)export\s+default\s+/gm, '$1')
478
- .replace(/^([ \t]*)export\s*\{[^}]*\}\s*;?/gm, '$1')
479
- .replace(/^([ \t]*)export\s+(const|let|var|function|class|async\s+function)\s+/gm, '$1$2 ')
480
- .replace(/^([ \t]*)import\s+[^;\n]+;?/gm, '$1')
481
- .replace(/^([ \t]*)export\s+\*\s+from\s+[^;\n]+;?/gm, '$1')
482
- new vm.Script(transformed)
483
- } catch (err) {
484
- warnings.push({ file: file.relativePath, error: err?.message || String(err), line: err?.lineNumber || 1 })
485
- }
486
- } else if (ext === '.json') {
487
- try {
488
- JSON.parse(file.content)
489
- } catch (err) {
490
- warnings.push({
491
- file: file.relativePath,
492
- error: err?.message || String(err),
493
- line: 1,
494
- })
495
- }
496
- }
497
- }
498
- return warnings
499
- }
500
-
501
- /**
502
- * Reads all files from a candidate workspace (.moa/candidate-N, .moa/curator-synthesis, or scoped under runId).
503
- */
504
- export async function readCandidateFiles(baseDir, candidateTarget, options = {}) {
505
- if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
506
- let targetDir
507
- try {
508
- targetDir = resolveCandidateFolder(baseDir, candidateTarget, options).candidateDir
509
- } catch {
510
- return []
511
- }
512
-
513
- try {
514
- const relFiles = await getFilesRecursively(targetDir)
515
- const result = []
516
- const realTarget = await fs.realpath(targetDir).catch(() => targetDir)
517
-
518
- for (const rel of relFiles) {
519
- if (!isSafeRelativePath(rel)) continue
520
- try {
521
- const fullPath = assertPathContained(targetDir, rel)
522
- const st = await fs.lstat(fullPath)
523
- if (st.isSymbolicLink()) {
524
- const realFile = await fs.realpath(fullPath)
525
- if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) continue
526
- }
527
- const content = await fs.readFile(fullPath, 'utf8')
528
- result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
529
- } catch {
530
- // file read error best-effort
531
- }
532
- }
533
- return result
534
- } catch {
535
- return []
536
- }
537
- }
538
-
539
- /**
540
- * Computes a line-by-line diff between two text strings using LCS with
541
- * common prefix and suffix trimming for optimal O(delta) performance.
542
- */
543
- export function computeLineDiff(oldText = '', newText = '') {
544
- const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
545
- const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
546
- const n = oldLines.length, m = newLines.length
547
- if (n === 0 && m === 0) return []
548
- if (oldText === newText) {
549
- return oldLines.map((line, idx) => ({ type: 'same', line, oldNum: idx + 1, newNum: idx + 1 }))
550
- }
551
-
552
- let start = 0
553
- while (start < n && start < m && oldLines[start] === newLines[start]) start++
554
- let oldEnd = n - 1, newEnd = m - 1
555
- while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
556
- oldEnd--
557
- newEnd--
558
- }
559
-
560
- const prefixItems = oldLines.slice(0, start).map((line, k) => ({ type: 'same', line, oldNum: k + 1, newNum: k + 1 }))
561
- const suffixLen = (n - 1) - oldEnd
562
- const suffixItems = oldLines.slice(oldEnd + 1, oldEnd + 1 + suffixLen).map((line, k) => ({ type: 'same', line, oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k }))
563
-
564
- const subOld = oldLines.slice(start, oldEnd + 1)
565
- const subNew = newLines.slice(start, newEnd + 1)
566
- const subN = subOld.length, subM = subNew.length
567
- let middleItems
568
- if (subN === 0) {
569
- middleItems = subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 }))
570
- } else if (subM === 0) {
571
- middleItems = subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 }))
572
- } else if (subN > 2000 || subM > 2000) {
573
- middleItems = new Array(subN + subM)
574
- for (let i = 0; i < subN; i++) middleItems[i] = { type: 'removed', line: subOld[i], oldNum: start + i + 1 }
575
- for (let j = 0; j < subM; j++) middleItems[subN + j] = { type: 'added', line: subNew[j], newNum: start + j + 1 }
576
- } else {
577
- const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
578
- for (let i = 0; i < subN; i++) {
579
- for (let j = 0; j < subM; j++) dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
580
- }
581
- let i = subN, j = subM; const stack = []
582
- while (i > 0 || j > 0) {
583
- if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
584
- stack.push({ type: 'same', line: subOld[--i], oldNum: start + i + 1, newNum: start + j }); j--
585
- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
586
- stack.push({ type: 'added', line: subNew[--j], newNum: start + j + 1 })
587
- } else if (i > 0) {
588
- stack.push({ type: 'removed', line: subOld[--i], oldNum: start + i + 1 })
589
- }
590
- }
591
- middleItems = stack.reverse()
592
- }
593
- return prefixItems.concat(middleItems, suffixItems)
594
- }
2
+ * File Workspace Management and Path Safety
3
+ * Modularized into lib/workspace/ for maintainability and line budget compliance.
4
+ */
5
+
6
+ export {
7
+ isSafeRelativePath,
8
+ assertPathContained,
9
+ isLoopbackUrl,
10
+ } from './workspace/security.js'
11
+
12
+ export {
13
+ verifyFileSyntax,
14
+ computeLineDiff,
15
+ } from './workspace/diff.js'
16
+
17
+ export {
18
+ formatProjectContext,
19
+ collectProjectContext,
20
+ isRefinementTask,
21
+ } from './workspace/scanner.js'
22
+
23
+ export {
24
+ createPrePromotionCheckpoint,
25
+ } from './workspace/checkpoint.js'
26
+
27
+ export {
28
+ resolveCandidateFolder,
29
+ extractFileBlocks,
30
+ writeCandidateWorkspace,
31
+ promoteCandidateWorkspace,
32
+ cleanMoaWorkspaces,
33
+ readCandidateFiles,
34
+ } from './workspace/fs.js'