@goodandready/dsh-moa 0.2.37 → 0.2.39

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.
@@ -3,32 +3,47 @@ import fs from 'node:fs/promises'
3
3
  import fsSync from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
- import { assertPathContained } from './file-workspace.js'
6
+ import { resolveCandidateFolder } from './file-workspace.js'
7
7
  import { bestEffort } from './best-effort.js'
8
8
 
9
+ const MAX_CAPTURE_BYTES = 256 * 1024
10
+
11
+ /**
12
+ * Returns a sanitized environment stripping sensitive tokens and secrets (#161).
13
+ */
14
+ export function getScrubbedEnv(overrides = {}) {
15
+ const safeVars = new Set([
16
+ 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TEMP', 'TMP',
17
+ 'USER', 'LOGNAME', 'HOME', 'SHELL', 'TERM', 'NODE_ENV', 'SYSTEMROOT',
18
+ 'WINDIR', 'COMSPEC', 'PATHEXT',
19
+ ])
20
+ const scrubbed = {}
21
+ for (const [key, val] of Object.entries(process.env)) {
22
+ if (!val || key.startsWith('MOA_')) continue
23
+ if (/(API_KEY|TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|KEY|ACCESS)/i.test(key)) {
24
+ continue
25
+ }
26
+ if (safeVars.has(key) || key.startsWith('LC_')) {
27
+ scrubbed[key] = val
28
+ }
29
+ }
30
+ return { ...scrubbed, ...overrides }
31
+ }
32
+
9
33
  /**
10
34
  * Resolves the directory for candidate sandbox.
11
35
  */
12
- export function resolveCandidateDir(baseDir, candidateIndex) {
36
+ export function resolveCandidateDir(baseDir, candidateIndex, options = {}) {
13
37
  if (!baseDir || candidateIndex === null || candidateIndex === undefined) return null
14
- const targetStr = String(candidateIndex).trim()
15
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
16
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
17
- const moaRoot = path.join(baseDir, '.moa')
18
- return assertPathContained(moaRoot, sub)
38
+ try {
39
+ return resolveCandidateFolder(baseDir, candidateIndex, options).candidateDir
40
+ } catch {
41
+ return null
42
+ }
19
43
  }
20
44
 
21
45
  /**
22
46
  * Executes a test command in a candidate sandbox or ephemeral test staging directory.
23
- *
24
- * @param {object} params
25
- * @param {string} params.cwd - Project root workspace directory
26
- * @param {number|string} params.candidateIndex - Index of candidate (1, 2, ...)
27
- * @param {string} params.testCommand - Shell command to execute (e.g. "npm test", "node --test")
28
- * @param {number} [params.timeoutMs=15000] - Execution timeout in ms
29
- * @param {number} [params.maxOutputChars=2000] - Maximum captured output chars
30
- * @param {function} [params.execFn] - Optional mock/test runner function
31
- * @returns {Promise<object|null>} Test result object or null if skipped
32
47
  */
33
48
  export async function runCandidateTestGate({
34
49
  cwd,
@@ -37,18 +52,14 @@ export async function runCandidateTestGate({
37
52
  timeoutMs = 15000,
38
53
  maxOutputChars = 2000,
39
54
  execFn = null,
55
+ runId = null,
40
56
  }) {
41
- if (!cwd || !testCommand || typeof testCommand !== 'string') {
42
- return null
43
- }
44
-
57
+ if (!cwd || !testCommand || typeof testCommand !== 'string') return null
45
58
  const trimmedCmd = testCommand.trim()
46
59
  if (!trimmedCmd) return null
47
60
 
48
- const candidateDir = resolveCandidateDir(cwd, candidateIndex)
49
- if (!candidateDir || !fsSync.existsSync(candidateDir)) {
50
- return null
51
- }
61
+ const candidateDir = resolveCandidateDir(cwd, candidateIndex, { runId })
62
+ if (!candidateDir || !fsSync.existsSync(candidateDir)) return null
52
63
 
53
64
  const startTime = Date.now()
54
65
  let stageDir = null
@@ -66,13 +77,19 @@ export async function runCandidateTestGate({
66
77
  const rel = path.relative(cwd, src)
67
78
  if (!rel) return true
68
79
  const parts = rel.split(path.sep)
69
- if (parts[0] === 'node_modules' || parts[0] === '.git' || parts[0] === '.moa') {
70
- return false
71
- }
72
- return true
80
+ return parts[0] !== 'node_modules' && parts[0] !== '.git' && parts[0] !== '.moa'
73
81
  },
74
82
  })
75
83
 
84
+ // #162: Preserve ESM & CJS resolution by symlinking project node_modules
85
+ const baseNodeModules = path.join(cwd, 'node_modules')
86
+ const stageNodeModules = path.join(stageDir, 'node_modules')
87
+ if (fsSync.existsSync(baseNodeModules)) {
88
+ await bestEffort('symlink node_modules in test stage', () =>
89
+ fs.symlink(baseNodeModules, stageNodeModules, process.platform === 'win32' ? 'junction' : 'dir')
90
+ )
91
+ }
92
+
76
93
  await fs.cp(candidateDir, stageDir, { recursive: true })
77
94
  executionDir = stageDir
78
95
  }
@@ -98,22 +115,25 @@ export async function runCandidateTestGate({
98
115
  stderr = customRes?.stderr || ''
99
116
  } else {
100
117
  const nodePath = path.join(cwd, 'node_modules')
101
- const env = {
102
- ...process.env,
118
+ const env = getScrubbedEnv({
103
119
  MOA_CANDIDATE_INDEX: String(candidateIndex),
104
120
  MOA_CANDIDATE_DIR: candidateDir,
105
121
  MOA_BASE_DIR: cwd,
122
+ ...(runId ? { MOA_RUN_ID: String(runId) } : {}),
106
123
  NODE_PATH: process.env.NODE_PATH ? `${nodePath}:${process.env.NODE_PATH}` : nodePath,
107
- }
124
+ })
108
125
 
109
126
  const runPromise = new Promise((resolve) => {
110
127
  let child
128
+ let timeoutHandle = null
129
+ let killEscalateHandle = null
130
+
111
131
  try {
112
132
  child = spawn(resolvedCmd, {
113
133
  cwd: executionDir,
114
134
  shell: true,
135
+ detached: process.platform !== 'win32',
115
136
  env,
116
- timeout: timeoutMs,
117
137
  })
118
138
  } catch (spawnErr) {
119
139
  resolve({ exitCode: 1, stdout: '', stderr: spawnErr.message || String(spawnErr) })
@@ -122,15 +142,50 @@ export async function runCandidateTestGate({
122
142
 
123
143
  const outChunks = []
124
144
  const errChunks = []
145
+ let capturedBytes = 0
146
+
147
+ child.stdout?.on('data', (d) => {
148
+ if (capturedBytes < MAX_CAPTURE_BYTES) {
149
+ outChunks.push(d)
150
+ capturedBytes += d.length
151
+ }
152
+ })
153
+ child.stderr?.on('data', (d) => {
154
+ if (capturedBytes < MAX_CAPTURE_BYTES) {
155
+ errChunks.push(d)
156
+ capturedBytes += d.length
157
+ }
158
+ })
159
+
160
+ const killChild = (sig = 'SIGTERM') => {
161
+ if (!child || !child.pid) return
162
+ bestEffort('test-gate kill child process group', () => {
163
+ if (process.platform !== 'win32') {
164
+ process.kill(-child.pid, sig)
165
+ } else {
166
+ child.kill(sig)
167
+ }
168
+ })
169
+ }
125
170
 
126
- child.stdout?.on('data', (d) => outChunks.push(d))
127
- child.stderr?.on('data', (d) => errChunks.push(d))
171
+ if (timeoutMs > 0) {
172
+ timeoutHandle = setTimeout(() => {
173
+ killChild('SIGTERM')
174
+ killEscalateHandle = setTimeout(() => {
175
+ killChild('SIGKILL')
176
+ }, 1000)
177
+ }, timeoutMs)
178
+ }
128
179
 
129
180
  child.on('error', (err) => {
181
+ if (timeoutHandle) clearTimeout(timeoutHandle)
182
+ if (killEscalateHandle) clearTimeout(killEscalateHandle)
130
183
  resolve({ exitCode: 1, stdout: Buffer.concat(outChunks).toString('utf8'), stderr: err.message || String(err) })
131
184
  })
132
185
 
133
186
  child.on('close', (code, sig) => {
187
+ if (timeoutHandle) clearTimeout(timeoutHandle)
188
+ if (killEscalateHandle) clearTimeout(killEscalateHandle)
134
189
  const combinedErr = Buffer.concat(errChunks).toString('utf8')
135
190
  const finalErr = sig ? `${combinedErr}\nProcess terminated by signal ${sig}`.trim() : combinedErr
136
191
  resolve({
@@ -154,9 +209,7 @@ export async function runCandidateTestGate({
154
209
  : combinedOutput
155
210
 
156
211
  const passed = exitCode === 0
157
- const summary = passed
158
- ? `PASS (${durationMs}ms)`
159
- : `FAIL (code ${exitCode}, ${durationMs}ms)`
212
+ const summary = passed ? `PASS (${durationMs}ms)` : `FAIL (code ${exitCode}, ${durationMs}ms)`
160
213
 
161
214
  return {
162
215
  candidateIndex,
@@ -199,12 +252,11 @@ export async function executeTestGateForCandidates({
199
252
  }) {
200
253
  const isEnabled = Boolean(options.testGateEnabled ?? preset.test_gate_enabled)
201
254
  const testCmd = options.testCommand || preset.test_command
202
- if (!isEnabled || !testCmd || !cwd) {
203
- return []
204
- }
255
+ if (!isEnabled || !testCmd || !cwd) return []
205
256
 
206
257
  const timeoutMs = (preset.test_gate_timeout_sec || 15) * 1000
207
258
  const maxOutputChars = preset.test_gate_max_output_chars || 2000
259
+ const runId = options.runId || preset.runId
208
260
 
209
261
  if (typeof onProgress === 'function') {
210
262
  onProgress('🧪 *Test Execution Gate: Running test suite against candidate sandboxes...*\n')
@@ -221,6 +273,7 @@ export async function executeTestGateForCandidates({
221
273
  timeoutMs,
222
274
  maxOutputChars,
223
275
  execFn: options.execFn,
276
+ runId,
224
277
  })
225
278
 
226
279
  if (res) {
@@ -234,4 +287,4 @@ export async function executeTestGateForCandidates({
234
287
  }
235
288
 
236
289
  return results
237
- }
290
+ }
package/lib/routes.js CHANGED
@@ -40,7 +40,7 @@ function readBody(req) {
40
40
  })
41
41
  }
42
42
 
43
- export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig }) {
43
+ export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine }) {
44
44
 
45
45
  ctx.effect(() => {
46
46
  return ctx.webServer.register({
@@ -220,7 +220,12 @@ ctx.effect(() => {
220
220
 
221
221
  // Persist via saveConfig (in-memory and DSH profile settings)
222
222
  if (typeof saveConfig === 'function') {
223
- await saveConfig(validated)
223
+ try {
224
+ await saveConfig(validated)
225
+ } catch (perr) {
226
+ writeJson(res, 500, { ok: false, error: 'Settings persistence failed: ' + (perr?.message || String(perr)) })
227
+ return
228
+ }
224
229
  }
225
230
 
226
231
  const unwrapped = typeof plainConfig === 'function' ? plainConfig(validated) : validated
@@ -323,7 +328,9 @@ ctx.effect(() => {
323
328
  return
324
329
  }
325
330
  const cwd = body?.cwd || process.cwd()
331
+ const runId = body?.runId
326
332
  const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
333
+ runId,
327
334
  keepMoa: true,
328
335
  port: ctx.webServer?.port,
329
336
  timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
@@ -359,8 +366,9 @@ ctx.effect(() => {
359
366
  const rawFile = u.searchParams.get('file') || ''
360
367
  const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
361
368
 
362
- const fromFiles = await readCandidateFiles(cwd, from)
363
- const toFiles = await readCandidateFiles(cwd, to)
369
+ const runId = u.searchParams.get('runId') || undefined
370
+ const fromFiles = await readCandidateFiles(cwd, from, { runId })
371
+ const toFiles = await readCandidateFiles(cwd, to, { runId })
364
372
 
365
373
  // Collect all unique file paths
366
374
  const allPaths = Array.from(new Set([
@@ -560,13 +568,12 @@ ctx.effect(() => {
560
568
  const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
561
569
 
562
570
  const result = await runMoAPipeline({
563
- userPrompt: prompt,
564
- messages: payload.messages || [],
565
- preset,
566
- callLlm,
567
- cwd: payload.cwd || process.cwd(),
568
- prices: cfg.prices || {},
569
- liveCanvas,
571
+ userPrompt: prompt, messages: payload.messages || [], preset, callLlm,
572
+ cwd: payload.cwd || process.cwd(), prices: cfg.prices || {}, liveCanvas,
573
+ webServerPort: webServerPort || ctx.webServer?.port,
574
+ timeMachineEngine: timeMachineEngine || ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
575
+ timeMachineUrl: payload.timeMachineUrl, force: payload.force,
576
+ checkpoint: payload.checkpoint, checkpointFn: payload.checkpointFn,
570
577
  })
571
578
 
572
579
  if (result?.skippedFiles > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",