@goodandready/dsh-moa 0.2.52 → 0.2.55
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/README.md +4 -1
- package/README.ru.md +3 -0
- package/lib/access.js +203 -0
- package/lib/file-workspace.js +2 -2
- package/lib/routes.js +34 -23
- package/lib/updater.js +1 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -302,6 +302,9 @@ dsh-moa:
|
|
|
302
302
|
|
|
303
303
|
## 📊 REST API & Endpoints
|
|
304
304
|
|
|
305
|
+
> [!IMPORTANT]
|
|
306
|
+
> **Authentication & Access Control**: All HTTP endpoints enforce fail-closed authentication via the DeepSeek Harness Connection Service (`ctx.connection.requestRejection`). Unauthenticated remote callers over LAN/WAN receive HTTP 401 (`Authentication required`) or 403 (`Forbidden`), strictly preventing disclosure of prompts, workspace structures, and candidate files. Mutation endpoints additionally require loopback origin and CSRF validation (`sec-fetch-site`).
|
|
307
|
+
|
|
305
308
|
| Endpoint | Method | Description |
|
|
306
309
|
|:---|:---|:---|
|
|
307
310
|
| `/dsh-moa/status` | `GET` | Health/enablement snapshot used by the settings card status chip |
|
|
@@ -332,7 +335,7 @@ npm test
|
|
|
332
335
|
## 🛠️ Internal Tooling & Development
|
|
333
336
|
|
|
334
337
|
For local verification and package integrity validation:
|
|
335
|
-
- `npm test`: runs the full test suite (
|
|
338
|
+
- `npm test`: runs the full test suite (288 tests)
|
|
336
339
|
- `./deploy.sh`: local infrastructure validation script (verifies package size, identity parity in package.json/cordis.patch.yml/client.js, and tests). Excluded from the published npm package.
|
|
337
340
|
|
|
338
341
|
## 📄 License
|
package/README.ru.md
CHANGED
|
@@ -302,6 +302,9 @@ dsh-moa:
|
|
|
302
302
|
|
|
303
303
|
## 📊 REST API эндпоинты
|
|
304
304
|
|
|
305
|
+
> [!IMPORTANT]
|
|
306
|
+
> **Аутентификация и контроль доступа**: Все HTTP-эндпоинты плагина защищены каноническим сервисом соединений DeepSeek Harness (`ctx.connection.requestRejection`). Неаутентифицированные удалённые запросы из сети LAN/WAN отклоняются с кодом HTTP 401 (`Authentication required`) или 403 (`Forbidden`), предотвращая утечку промптов, структуры рабочей директории и файлов кандидатов. Мутирующие эндпоинты дополнительно требуют loopback-вызова и проверки CSRF (`sec-fetch-site`).
|
|
307
|
+
|
|
305
308
|
| Эндпоинт | Метод | Описание |
|
|
306
309
|
|:---|:---|:---|
|
|
307
310
|
| `/dsh-moa/status` | `GET` | Снимок здоровья/включенности для статус-бейджа карточки настроек |
|
package/lib/access.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness Mixture of Agents (MoA) — Access Control & Route Guard (#257)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { assertPathContained } from './file-workspace.js'
|
|
7
|
+
|
|
8
|
+
export function writeJson(res, status, body) {
|
|
9
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
10
|
+
res.end(JSON.stringify(body))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readBody(req) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let acc = ''
|
|
16
|
+
req.on('data', (chunk) => {
|
|
17
|
+
acc += chunk
|
|
18
|
+
if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
|
|
19
|
+
})
|
|
20
|
+
req.on('end', () => resolve(acc))
|
|
21
|
+
req.on('error', reject)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isAllowedWorkspace(targetCwd, allowedRoots) {
|
|
26
|
+
if (!targetCwd || typeof targetCwd !== 'string') return false
|
|
27
|
+
for (const root of allowedRoots) {
|
|
28
|
+
if (!root) continue
|
|
29
|
+
try {
|
|
30
|
+
assertPathContained(root, targetCwd)
|
|
31
|
+
return true
|
|
32
|
+
} catch {
|
|
33
|
+
// not within root
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getConnection(ctx) {
|
|
40
|
+
try {
|
|
41
|
+
const fromCtx = ctx?.reflect?.get
|
|
42
|
+
? (ctx.reflect.get('connection') || ctx?.connection)
|
|
43
|
+
: (ctx?.get ? (ctx.get('connection') || ctx?.connection) : Reflect.get(ctx || {}, 'connection'))
|
|
44
|
+
if (fromCtx) return fromCtx
|
|
45
|
+
if (ctx?.root) return getConnection(ctx.root)
|
|
46
|
+
return undefined
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isLoopbackIp(ip) {
|
|
53
|
+
if (!ip || typeof ip !== 'string') return false
|
|
54
|
+
const clean = ip.trim().toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
|
|
55
|
+
return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function cookieNameForAuthority(authority) {
|
|
59
|
+
if (!authority || typeof authority !== 'string') return ''
|
|
60
|
+
const hash = crypto.createHash('sha256').update(authority.trim()).digest('base64url')
|
|
61
|
+
return 'dsh-auth-' + hash
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isLoopbackCookieName(name, host = '') {
|
|
65
|
+
if (!name || typeof name !== 'string' || !name.startsWith('dsh-auth-')) return false
|
|
66
|
+
const authorities = [
|
|
67
|
+
host,
|
|
68
|
+
'127.0.0.1:3000',
|
|
69
|
+
'127.0.0.1:3080',
|
|
70
|
+
'127.0.0.1:3082',
|
|
71
|
+
'127.0.0.1',
|
|
72
|
+
'localhost:3000',
|
|
73
|
+
'localhost:3080',
|
|
74
|
+
'localhost:3082',
|
|
75
|
+
'localhost',
|
|
76
|
+
'[::1]:3000',
|
|
77
|
+
'[::1]',
|
|
78
|
+
]
|
|
79
|
+
for (const auth of authorities) {
|
|
80
|
+
if (auth && isLoopbackIp(auth.split(':')[0])) {
|
|
81
|
+
if (cookieNameForAuthority(auth) === name) return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasClientCredentials(req) {
|
|
88
|
+
if (!req || !req.headers) return false
|
|
89
|
+
const cookie = req.headers.cookie
|
|
90
|
+
if (!cookie || typeof cookie !== 'string') return false
|
|
91
|
+
if (
|
|
92
|
+
cookie.includes('dsh_token=') ||
|
|
93
|
+
cookie.includes('dsh_lan_pin=') ||
|
|
94
|
+
cookie.includes('dsh_auth_session=')
|
|
95
|
+
) {
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const parts = cookie.split(';')
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
const trimmed = part.trim()
|
|
102
|
+
const eqIdx = trimmed.indexOf('=')
|
|
103
|
+
if (eqIdx === -1) continue
|
|
104
|
+
const name = trimmed.slice(0, eqIdx).trim()
|
|
105
|
+
if (name.startsWith('dsh-auth-')) {
|
|
106
|
+
if (!isLoopbackCookieName(name, req.headers.host)) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validates whether an incoming HTTP request is authenticated and admitted (#257).
|
|
116
|
+
*
|
|
117
|
+
* Requirements:
|
|
118
|
+
* 1. CSRF protection: rejects sec-fetch-site: cross-site with 403.
|
|
119
|
+
* 2. Reverse proxy inspection: if request is forwarded with non-loopback x-forwarded-for,
|
|
120
|
+
* verify that the client itself supplied authentic credentials (not just injected loopback proxy cookie).
|
|
121
|
+
* 3. Authoritative DSH connection gate: if connection.requestRejection is present,
|
|
122
|
+
* its verdict (401, 403, or undefined) is authoritative.
|
|
123
|
+
* 4. Network boundary: non-loopback callers without connection service authentication
|
|
124
|
+
* fail closed with 401. Loopback callers are admitted.
|
|
125
|
+
*
|
|
126
|
+
* @param {import('node:http').IncomingMessage} req
|
|
127
|
+
* @param {any} [ctx]
|
|
128
|
+
* @returns {number|undefined} Rejection status (401, 403) or undefined if admitted.
|
|
129
|
+
*/
|
|
130
|
+
export function getReadRejectionStatus(req, ctx = null) {
|
|
131
|
+
if (!req || !req.headers) return 403
|
|
132
|
+
|
|
133
|
+
// 1. Strict CSRF protection: reject cross-site requests
|
|
134
|
+
const site = req.headers['sec-fetch-site']
|
|
135
|
+
if (site === 'cross-site') {
|
|
136
|
+
return 403
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Reverse-proxy / forwarded client verification (#257)
|
|
140
|
+
const xff = req.headers['x-forwarded-for']
|
|
141
|
+
let isForwardedRemote = false
|
|
142
|
+
if (xff && typeof xff === 'string') {
|
|
143
|
+
const firstIp = xff.split(',')[0].trim()
|
|
144
|
+
if (firstIp && !isLoopbackIp(firstIp)) {
|
|
145
|
+
isForwardedRemote = true
|
|
146
|
+
if (!hasClientCredentials(req)) {
|
|
147
|
+
return 401
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Authoritative DSH connection service check (#257)
|
|
153
|
+
const conn = getConnection(ctx) || req?.connectionService || ctx?.connection
|
|
154
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
155
|
+
let rejection
|
|
156
|
+
try {
|
|
157
|
+
rejection = conn.requestRejection(req)
|
|
158
|
+
} catch {
|
|
159
|
+
rejection = 403
|
|
160
|
+
}
|
|
161
|
+
if (rejection !== undefined) {
|
|
162
|
+
if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
|
|
163
|
+
return rejection
|
|
164
|
+
}
|
|
165
|
+
return rejection === 401 ? 401 : 403
|
|
166
|
+
}
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
|
|
174
|
+
if (cleanRemote && !isLoopbackIp(cleanRemote)) {
|
|
175
|
+
return 401
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isForwardedRemote) {
|
|
179
|
+
return 401
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const host = String(req.headers.host || '').trim().toLowerCase()
|
|
183
|
+
if (!cleanRemote && host) {
|
|
184
|
+
const hostName = host.split(':')[0]
|
|
185
|
+
if (!isLoopbackIp(hostName)) {
|
|
186
|
+
return 401
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function assertAuthorizedRequest(req, res, ctx = null) {
|
|
194
|
+
const status = getReadRejectionStatus(req, ctx)
|
|
195
|
+
if (status !== undefined) {
|
|
196
|
+
writeJson(res, status, {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: status === 401 ? 'Authentication required' : 'Forbidden',
|
|
199
|
+
})
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
return true
|
|
203
|
+
}
|
package/lib/file-workspace.js
CHANGED
|
@@ -403,9 +403,9 @@ export async function cleanMoaWorkspaces(baseDir, options = {}) {
|
|
|
403
403
|
if (!baseDir) return
|
|
404
404
|
const rawRunId = typeof options === 'string' ? options : options?.runId
|
|
405
405
|
const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
|
|
406
|
-
const moaRoot =
|
|
406
|
+
const moaRoot = assertPathContained(baseDir, '.moa')
|
|
407
407
|
if (safeRunId) {
|
|
408
|
-
await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(
|
|
408
|
+
await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(assertPathContained(moaRoot, safeRunId), { recursive: true, force: true }))
|
|
409
409
|
await bestEffort('clean empty moaRoot', async () => {
|
|
410
410
|
if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
|
|
411
411
|
})
|
package/lib/routes.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory, isTestEnvironment } from './history.js'
|
|
7
8
|
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath, isLoopbackUrl } from './file-workspace.js'
|
|
9
|
+
import { writeJson, readBody, isAllowedWorkspace, assertAuthorizedRequest } from './access.js'
|
|
8
10
|
import { isSafeWriteRequest } from './updater.js'
|
|
9
11
|
import { runMoAPipeline } from './moa-runner.js'
|
|
10
12
|
import { generateMoABenchmarkReport } from './moa-report.js'
|
|
@@ -13,30 +15,14 @@ import { resolvePresetForPrompt } from './moa-router.js'
|
|
|
13
15
|
let _modelsCache = { timestamp: 0, models: [] }
|
|
14
16
|
const MODELS_CACHE_TTL = 60 * 1000
|
|
15
17
|
|
|
16
|
-
function
|
|
17
|
-
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
18
|
-
res.end(JSON.stringify(body))
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function readBody(req) {
|
|
22
|
-
return new Promise((resolve, reject) => {
|
|
23
|
-
let acc = ''
|
|
24
|
-
req.on('data', (chunk) => {
|
|
25
|
-
acc += chunk
|
|
26
|
-
if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
|
|
27
|
-
})
|
|
28
|
-
req.on('end', () => resolve(acc))
|
|
29
|
-
req.on('error', reject)
|
|
30
|
-
})
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine }) {
|
|
18
|
+
export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine, workspaceRoot }) {
|
|
34
19
|
|
|
35
20
|
ctx.effect(() => {
|
|
36
21
|
return ctx.webServer.register({
|
|
37
22
|
kind: 'exact',
|
|
38
23
|
path: '/dsh-moa/status',
|
|
39
24
|
handler: (req, res) => {
|
|
25
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
40
26
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
41
27
|
try {
|
|
42
28
|
const cfg = live()
|
|
@@ -57,6 +43,7 @@ ctx.effect(() => {
|
|
|
57
43
|
kind: 'exact',
|
|
58
44
|
path: '/dsh-moa/models',
|
|
59
45
|
handler: async (req, res) => {
|
|
46
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
60
47
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
61
48
|
if (_modelsCache.models.length > 0 && Date.now() - _modelsCache.timestamp < MODELS_CACHE_TTL) {
|
|
62
49
|
writeJson(res, 200, { ok: true, models: _modelsCache.models, cached: true })
|
|
@@ -153,6 +140,7 @@ ctx.effect(() => {
|
|
|
153
140
|
kind: 'exact',
|
|
154
141
|
path: '/dsh-moa/presets',
|
|
155
142
|
handler: async (req, res) => {
|
|
143
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
156
144
|
const cfg = live()
|
|
157
145
|
if (req.method === 'GET') {
|
|
158
146
|
writeJson(res, 200, {
|
|
@@ -222,6 +210,7 @@ ctx.effect(() => {
|
|
|
222
210
|
kind: 'exact',
|
|
223
211
|
path: '/dsh-moa/history',
|
|
224
212
|
handler: async (req, res) => {
|
|
213
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
225
214
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
226
215
|
try {
|
|
227
216
|
const u = new URL(req.url, 'http://localhost')
|
|
@@ -240,6 +229,7 @@ ctx.effect(() => {
|
|
|
240
229
|
kind: 'exact',
|
|
241
230
|
path: '/dsh-moa/leaderboard',
|
|
242
231
|
handler: async (req, res) => {
|
|
232
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
243
233
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
244
234
|
try {
|
|
245
235
|
const url = new URL(req.url || '', 'http://127.0.0.1')
|
|
@@ -269,6 +259,7 @@ ctx.effect(() => {
|
|
|
269
259
|
kind: 'exact',
|
|
270
260
|
path: '/dsh-moa/promote',
|
|
271
261
|
handler: async (req, res) => {
|
|
262
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
272
263
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
|
|
273
264
|
if (!isSafeWriteRequest(req)) {
|
|
274
265
|
writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
|
|
@@ -321,6 +312,7 @@ ctx.effect(() => {
|
|
|
321
312
|
kind: 'exact',
|
|
322
313
|
path: '/dsh-moa/diff',
|
|
323
314
|
handler: async (req, res) => {
|
|
315
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
324
316
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
325
317
|
try {
|
|
326
318
|
const u = new URL(req.url, 'http://localhost')
|
|
@@ -393,6 +385,7 @@ ctx.effect(() => {
|
|
|
393
385
|
kind: 'exact',
|
|
394
386
|
path: '/dsh-moa/history/export',
|
|
395
387
|
handler: async (req, res) => {
|
|
388
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
396
389
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
397
390
|
try {
|
|
398
391
|
const url = new URL(req.url || '', 'http://127.0.0.1')
|
|
@@ -415,8 +408,9 @@ ctx.effect(() => {
|
|
|
415
408
|
ctx.effect(() => {
|
|
416
409
|
return ctx.webServer.register({
|
|
417
410
|
kind: 'prefix',
|
|
418
|
-
path: '/dsh-moa/runs
|
|
411
|
+
path: '/dsh-moa/runs',
|
|
419
412
|
handler: (req, res) => {
|
|
413
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
420
414
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
421
415
|
const urlPath = req.url.split('?')[0]
|
|
422
416
|
const parts = urlPath.split('/').filter(Boolean)
|
|
@@ -462,6 +456,7 @@ ctx.effect(() => {
|
|
|
462
456
|
kind: 'exact',
|
|
463
457
|
path: '/dsh-moa/route-preset',
|
|
464
458
|
handler: async (req, res) => {
|
|
459
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
465
460
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
|
|
466
461
|
if (!isSafeWriteRequest(req)) {
|
|
467
462
|
writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
|
|
@@ -502,6 +497,7 @@ ctx.effect(() => {
|
|
|
502
497
|
kind: 'exact',
|
|
503
498
|
path: '/dsh-moa/run',
|
|
504
499
|
handler: async (req, res) => {
|
|
500
|
+
if (!assertAuthorizedRequest(req, res, ctx)) return
|
|
505
501
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
|
|
506
502
|
if (!isSafeWriteRequest(req)) {
|
|
507
503
|
writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
|
|
@@ -535,13 +531,28 @@ ctx.effect(() => {
|
|
|
535
531
|
const presetName = payload.preset || cfg.default_preset || 'default'
|
|
536
532
|
const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
|
|
537
533
|
|
|
534
|
+
const baseWorkspaceRoot = workspaceRoot || ctx.baseDir || ctx.workspaceDir || process.cwd()
|
|
535
|
+
const allowedRoots = [
|
|
536
|
+
baseWorkspaceRoot,
|
|
537
|
+
process.cwd(),
|
|
538
|
+
cfg.workspace_root,
|
|
539
|
+
...(Array.isArray(cfg.allowed_workspaces) ? cfg.allowed_workspaces : (cfg.allowed_workspaces ? [cfg.allowed_workspaces] : [])),
|
|
540
|
+
...(isTestEnvironment() ? [os.tmpdir()] : []),
|
|
541
|
+
].filter(Boolean)
|
|
542
|
+
|
|
543
|
+
const targetCwd = payload.cwd ? path.resolve(payload.cwd) : baseWorkspaceRoot
|
|
544
|
+
if (payload.cwd && !isAllowedWorkspace(targetCwd, allowedRoots)) {
|
|
545
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: cwd escapes allowed workspace boundary' })
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
538
549
|
const result = await runMoAPipeline({
|
|
539
550
|
userPrompt: prompt, messages: payload.messages || [], preset, callLlm,
|
|
540
|
-
cwd:
|
|
551
|
+
cwd: targetCwd, prices: cfg.prices || {}, liveCanvas,
|
|
541
552
|
webServerPort: webServerPort || ctx.webServer?.port,
|
|
542
553
|
timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
|
|
543
|
-
timeMachineUrl: payload.timeMachineUrl, force:
|
|
544
|
-
checkpoint:
|
|
554
|
+
timeMachineUrl: payload.timeMachineUrl, force: false,
|
|
555
|
+
checkpoint: true,
|
|
545
556
|
runId: payload.runId,
|
|
546
557
|
})
|
|
547
558
|
|
package/lib/updater.js
CHANGED
|
@@ -44,8 +44,7 @@ export function isTrustedUpdateRequest(request) {
|
|
|
44
44
|
/** General safe write check for plugin write endpoints (CSRF / Cross-Site protection, #234) */
|
|
45
45
|
export function isSafeWriteRequest(request) {
|
|
46
46
|
const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress;
|
|
47
|
-
|
|
48
|
-
if (!isLoop) return false;
|
|
47
|
+
if (!remote || !isLoopback(remote)) return false;
|
|
49
48
|
|
|
50
49
|
const site = header(request, 'sec-fetch-site');
|
|
51
50
|
if (site === 'cross-site') return false;
|