@goodandready/dsh-agent-orchestrator 0.1.10 → 0.1.12

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/http-guard.js CHANGED
@@ -3,9 +3,10 @@
3
3
  * against drive-by CSRF, unauthorized cross-network access, and cross-origin tampering.
4
4
  *
5
5
  * Implements Issue #113:
6
- * 1. Loopback / Origin / Host validation
7
- * 2. Sec-Fetch-Site and Referer verification
8
- * 3. Body size limiter (default 1MB) with fail-closed rejection
6
+ * 1. Loopback / Origin / Host validation with LAN reverse proxy (X-Forwarded-Host) support
7
+ * 2. Sec-Fetch-Site and Referer verification (Fetch Metadata is not authentication)
8
+ * 3. Explicit Bearer token authorization support (bridge path)
9
+ * 4. Body size limiter (default 1MB) with fail-closed rejection
9
10
  */
10
11
 
11
12
  export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024 // 1MB
@@ -32,56 +33,97 @@ export function getClientIp(req) {
32
33
  /**
33
34
  * Checks whether an incoming HTTP request is safe for mutation or sensitive dispatch.
34
35
  *
35
- * Requirements:
36
- * 1. If Origin header is present:
37
- * - must not be "null" or empty
38
- * - parsed Origin host must match Host header
39
- * 2. If Referer header is present:
40
- * - parsed Referer host must match Host header
41
- * 3. If Sec-Fetch-Site header is present:
42
- * - must be "same-origin" or "none" (cross-site and same-site are rejected)
43
- * 4. If neither Origin nor Sec-Fetch-Site is present:
44
- * - only local loopback clients (127.0.0.1, ::1) are permitted.
36
+ * Requirements (Issue #113):
37
+ * 1. Authorization header:
38
+ * - Bearer token accepted if matching server tokens or valid non-empty token when no static secret enforced.
39
+ * 2. Sec-Fetch-Site header:
40
+ * - Must NOT be "cross-site" or "same-site" (fail-closed rejection).
41
+ * 3. Origin / Referer:
42
+ * - If present, must not be "null" or empty.
43
+ * - Parsed Origin host must match Host or X-Forwarded-Host.
44
+ * 4. Browser Same-Origin:
45
+ * - When Origin is present and matches Host / X-Forwarded-Host, request is permitted.
46
+ * 5. Non-browser / header-only callers:
47
+ * - Sec-Fetch-Site alone without matching Origin is NOT auth.
48
+ * - Without Origin or Authorization, only local loopback (127.0.0.1, ::1) callers are permitted.
45
49
  */
46
50
  export function isTrustedWriteRequest(req) {
47
51
  const headers = req.headers || {}
48
- const host = headers.host || ''
52
+ const rawHost = headers.host || ''
53
+ const forwardedHost = headers['x-forwarded-host'] || ''
54
+ const effectiveHost = forwardedHost || rawHost
55
+
56
+ // 1. Explicit Authorization header (Bridge / CLI / API token)
57
+ const authHeader = headers.authorization
58
+ const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN || process.env.ORCHESTRATOR_API_KEY
59
+ if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
60
+ const token = authHeader.slice(7).trim()
61
+ if (token && (!expectedToken || token === expectedToken)) {
62
+ return true
63
+ }
64
+ }
49
65
 
66
+ // 2. Fetch Metadata check: reject cross-site and same-site requests upfront
67
+ const site = headers['sec-fetch-site']
68
+ if (site !== undefined && site) {
69
+ if (site !== 'same-origin' && site !== 'none') {
70
+ return false
71
+ }
72
+ }
73
+
74
+ // 3. Origin check if present
50
75
  const origin = headers.origin
51
76
  if (origin !== undefined) {
52
77
  if (origin === 'null' || !origin) return false
53
78
  try {
54
79
  const parsed = new URL(origin)
55
- if (parsed.host && host && parsed.host !== host) return false
80
+ const originHost = parsed.host
81
+ if (!originHost) return false
82
+ if (originHost !== effectiveHost && originHost !== rawHost) {
83
+ return false
84
+ }
56
85
  } catch {
57
86
  return false
58
87
  }
59
88
  }
60
89
 
90
+ // 4. Referer check if present
61
91
  const referer = headers.referer
62
92
  if (referer !== undefined && referer) {
63
93
  try {
64
94
  const parsed = new URL(referer)
65
- if (parsed.host && host && parsed.host !== host) return false
95
+ const refHost = parsed.host
96
+ if (!refHost) return false
97
+ if (refHost !== effectiveHost && refHost !== rawHost) {
98
+ return false
99
+ }
66
100
  } catch {
67
101
  return false
68
102
  }
69
103
  }
70
104
 
71
- const site = headers['sec-fetch-site']
72
- if (site !== undefined && site) {
73
- if (site !== 'same-origin' && site !== 'none') return false
74
- }
75
-
76
- // If both origin and sec-fetch-site are absent, permit only loopback callers
77
- if (origin === undefined && site === undefined) {
78
- const ip = getClientIp(req)
79
- if (!isLoopbackAddress(ip)) {
105
+ // 5. If origin is present and matches effectiveHost/rawHost:
106
+ if (origin && (effectiveHost || rawHost)) {
107
+ try {
108
+ const parsed = new URL(origin)
109
+ if (parsed.host === effectiveHost || parsed.host === rawHost) {
110
+ return true
111
+ }
112
+ } catch {
80
113
  return false
81
114
  }
82
115
  }
83
116
 
84
- return true
117
+ // 6. If origin is absent, check for local loopback:
118
+ // Fetch metadata alone (e.g. Sec-Fetch-Site: same-origin) is NOT authentication.
119
+ // Header-only remote callers without Origin or Authorization are rejected.
120
+ const ip = getClientIp(req)
121
+ if (isLoopbackAddress(ip)) {
122
+ // Local loopback caller without origin (CLI, server scripts) is allowed.
123
+ return true
124
+ }
125
+
126
+ return false
85
127
  }
86
128
 
87
129
  export function rejectUntrustedRequest(req, res) {
package/lib/index.js CHANGED
@@ -8,10 +8,16 @@
8
8
  import { randomUUID } from 'crypto'
9
9
  // Safe peerDependency resolution with standalone fallback
10
10
  function createSchemaStub(target = {}) {
11
- return new Proxy(target, {
11
+ const fn = (...args) => createSchemaStub({ ...target, args })
12
+ fn.shape = target.shape || {}
13
+ fn.meta = target.meta || {}
14
+ fn.volatile = () => createSchemaStub({ ...target, meta: { ...(target.meta || {}), volatile: true } })
15
+ return new Proxy(fn, {
12
16
  get(t, prop) {
13
17
  if (prop === 'shape') return t.shape || {}
14
- return (...args) => createSchemaStub({ ...t, [prop]: args[0] })
18
+ if (prop === 'meta') return t.meta || {}
19
+ if (prop === 'volatile') return () => createSchemaStub({ ...target, meta: { ...(target.meta || {}), volatile: true } })
20
+ return (...args) => createSchemaStub({ ...target, [prop]: args[0] })
15
21
  }
16
22
  })
17
23
  }
@@ -28,6 +34,18 @@ try {
28
34
  number: () => createSchemaStub({ type: 'number' }),
29
35
  union: () => createSchemaStub({ type: 'union' }),
30
36
  array: () => createSchemaStub({ type: 'array' }),
37
+ any: () => createSchemaStub({ type: 'any' }),
38
+ }
39
+ }
40
+
41
+ // DSH SettingsForms describe only publishes fields marked volatile. Without them the
42
+ // namespace is omitted and the configuration page stays unavailable.
43
+ if (typeof z.prototype?.volatile !== 'function') {
44
+ if (z.prototype) {
45
+ z.prototype.volatile = function volatile() {
46
+ if (this.meta && this.meta.volatile) return this
47
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
48
+ }
31
49
  }
32
50
  }
33
51
 
@@ -57,20 +75,43 @@ import { syncDefaultPresets } from './pipeline/preset-sync.js'
57
75
  export const name = '@goodandready/dsh-agent-orchestrator'
58
76
  export const inject = ['settings', 'webServer', 'llm', 'tools', 'commands', 'systemPrompt', 'subagents']
59
77
 
78
+ export function isVolatileRef(value) {
79
+ return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
80
+ }
81
+
82
+ export function plainConfig(cfg) {
83
+ while (typeof cfg === 'function') {
84
+ cfg = cfg()
85
+ }
86
+ if (isVolatileRef(cfg)) return plainConfig(cfg.get())
87
+ if (!cfg || typeof cfg !== 'object') return cfg
88
+ if (Array.isArray(cfg)) {
89
+ return cfg.map((item) => plainConfig(item))
90
+ }
91
+ const out = {}
92
+ for (const key of Object.keys(cfg)) {
93
+ out[key] = plainConfig(cfg[key])
94
+ }
95
+ return out
96
+ }
97
+
60
98
  export const Config = z.object({
61
- enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher'),
62
- defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)'),
63
- disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)'),
64
- maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)'),
65
- subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)'),
66
- smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)'),
67
- allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)'),
68
- failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)'),
69
- maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)'),
70
- sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)'),
99
+ enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher').volatile(),
100
+ defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)').volatile(),
101
+ disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)').volatile(),
102
+ maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)').volatile(),
103
+ subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)').volatile(),
104
+ smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)').volatile(),
105
+ allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)').volatile(),
106
+ failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)').volatile(),
107
+ maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)').volatile(),
108
+ sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)').volatile(),
109
+ roles: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom specialist roles roster').volatile(),
110
+ scenarios: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom execution scenarios DAG dictionary').volatile(),
111
+ kanbanSync: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Kanban board synchronization configuration').volatile(),
71
112
  })
72
113
 
73
- export function apply(ctx, config = {}) {
114
+ export function apply(ctx, rawConfig = {}) {
74
115
  const NS = 'dsh-agent-orchestrator'
75
116
  const logger = ctx?.logger || {
76
117
  debug: () => {},
@@ -78,6 +119,7 @@ export function apply(ctx, config = {}) {
78
119
  warn: () => {},
79
120
  error: () => {},
80
121
  }
122
+ const config = plainConfig(rawConfig)
81
123
  let currentSettings = {
82
124
  ...config,
83
125
  disableNestedDelegation: Boolean(config.disableNestedDelegation),
@@ -96,45 +138,145 @@ export function apply(ctx, config = {}) {
96
138
  },
97
139
  }
98
140
 
99
- let settingsScope = null
141
+ const applyLoadedConfig = (rawSaved) => {
142
+ const saved = plainConfig(rawSaved)
143
+ if (!saved || typeof saved !== 'object') return
144
+ const mergedRoles = saved.roles || currentSettings.roles || getDefaultRoles()
145
+ const mergedScenarios = saved.scenarios || currentSettings.scenarios || getDefaultScenarios()
146
+ const syncRes = syncDefaultPresets({
147
+ currentRoles: mergedRoles,
148
+ currentScenarios: mergedScenarios,
149
+ logger,
150
+ })
151
+ currentSettings = {
152
+ ...currentSettings,
153
+ ...saved,
154
+ disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
155
+ smartModelRouting: saved.smartModelRouting ?? currentSettings.smartModelRouting,
156
+ allowedProviders: Array.isArray(saved.allowedProviders) ? saved.allowedProviders : currentSettings.allowedProviders,
157
+ failFastModelValidation: saved.failFastModelValidation ?? currentSettings.failFastModelValidation,
158
+ maxStoredSessions: saved.maxStoredSessions ?? currentSettings.maxStoredSessions,
159
+ sessionRetentionMs: saved.sessionRetentionMs ?? currentSettings.sessionRetentionMs,
160
+ roles: syncRes.roles,
161
+ scenarios: syncRes.scenarios,
162
+ kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
163
+ }
164
+ }
100
165
 
101
- // Register settings scope
102
- ctx.inject(['settings'], (sctx) => {
103
- try {
104
- const scope = sctx.settings.register(NS, Config, { base: config })
105
- if (scope) {
106
- settingsScope = scope
107
- const saved = scope.get()
108
- if (saved) {
109
- currentSettings = {
110
- ...currentSettings,
111
- ...saved,
112
- disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
113
- roles: saved.roles || getDefaultRoles(),
114
- scenarios: saved.scenarios || getDefaultScenarios(),
115
- kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
166
+ let settingsAdapter = null
167
+
168
+ const createSettingsAdapter = (svc) => {
169
+ if (!svc) return null
170
+
171
+ // Legacy DSH settings service (v0.1.6 and earlier)
172
+ if (typeof svc.register === 'function') {
173
+ const scope = svc.register(NS, Config, { base: config })
174
+ return {
175
+ type: 'legacy',
176
+ get: () => scope?.get?.(),
177
+ persist: async (fullConfig) => {
178
+ if (!scope?.set) return
179
+ for (const [k, v] of Object.entries(fullConfig)) {
180
+ try {
181
+ await scope.set(k, v)
182
+ } catch (err) {
183
+ logger.debug('[dsh-agent-orchestrator] Legacy setting set warning:', k, err?.message || err)
184
+ }
116
185
  }
186
+ },
187
+ watch: (cb) => {
188
+ if (typeof scope?.watch === 'function') return scope.watch(cb)
189
+ return () => {}
190
+ },
191
+ }
192
+ }
193
+
194
+ // Modern DSH SettingsForms service (v0.1.7-rc.1+)
195
+ if (typeof svc.replace === 'function' || typeof svc.update === 'function') {
196
+ const resolveNsAndRevision = () => {
197
+ try {
198
+ const list = typeof svc.describe === 'function' ? svc.describe() : []
199
+ const entry = list.find((row) => row.ns === name || row.ns === NS || row.ns?.includes('dsh-agent-orchestrator'))
200
+ if (entry) {
201
+ return { ns: entry.ns, revision: entry.revision, value: entry.value }
202
+ }
203
+ } catch (err) {
204
+ logger.debug('[dsh-agent-orchestrator] Settings describe error:', err?.message || err)
117
205
  }
206
+ return { ns: NS, revision: undefined, value: undefined }
207
+ }
208
+
209
+ return {
210
+ type: 'modern',
211
+ get: () => {
212
+ const { value } = resolveNsAndRevision()
213
+ return value
214
+ },
215
+ persist: async (fullConfig) => {
216
+ const { ns: targetNs, revision } = resolveNsAndRevision()
217
+ const payload = plainConfig(fullConfig)
218
+ if (typeof svc.replace === 'function') {
219
+ await svc.replace(targetNs, payload, revision)
220
+ } else if (typeof svc.update === 'function') {
221
+ await svc.update(targetNs, payload, revision)
222
+ } else {
223
+ throw new Error('Settings service does not support replace or update')
224
+ }
225
+ },
226
+ watch: (cb) => {
227
+ if (typeof ctx.on === 'function') {
228
+ return ctx.on('settings/document-updated', (id) => {
229
+ if (id === name || id === NS || id?.includes('dsh-agent-orchestrator')) {
230
+ const { value } = resolveNsAndRevision()
231
+ cb(value)
232
+ }
233
+ })
234
+ }
235
+ return () => {}
236
+ },
118
237
  }
119
- } catch (e) {
120
- logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
121
238
  }
122
- })
123
239
 
124
- const getConfig = () => currentSettings
125
- const updateConfig = async (partial) => {
126
- currentSettings = { ...currentSettings, ...partial }
127
- if (settingsScope) {
240
+ return null
241
+ }
242
+
243
+ // Register settings scope or adapter
244
+ if (typeof ctx.inject === 'function') {
245
+ ctx.inject(['settings'], (sctx) => {
128
246
  try {
129
- for (const [k, v] of Object.entries(partial)) {
130
- if (k === 'enabled' || k === 'defaultScenario' || k === 'disableNestedDelegation') {
131
- await settingsScope.set(k, v)
247
+ settingsAdapter = createSettingsAdapter(sctx.settings)
248
+ if (settingsAdapter) {
249
+ const saved = settingsAdapter.get()
250
+ if (saved) {
251
+ applyLoadedConfig(saved)
252
+ }
253
+ if (typeof sctx.effect === 'function') {
254
+ sctx.effect(() => settingsAdapter.watch((next) => {
255
+ if (next) applyLoadedConfig(next)
256
+ }), 'dsh-agent-orchestrator: settings watch')
132
257
  }
133
258
  }
134
- } catch (err) {
135
- logger.debug('[dsh-agent-orchestrator] Settings update warning:', err?.message || err)
259
+ } catch (e) {
260
+ logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
136
261
  }
262
+ })
263
+ }
264
+
265
+ const getConfig = () => currentSettings
266
+ const updateConfig = async (rawPartial) => {
267
+ if (!settingsAdapter) {
268
+ throw new Error('Settings service is unavailable')
137
269
  }
270
+ const partial = plainConfig(rawPartial)
271
+ const nextSettings = plainConfig({
272
+ ...currentSettings,
273
+ ...partial,
274
+ kanbanSync: partial?.kanbanSync
275
+ ? { ...currentSettings.kanbanSync, ...partial.kanbanSync }
276
+ : currentSettings.kanbanSync,
277
+ })
278
+ await settingsAdapter.persist(nextSettings)
279
+ currentSettings = nextSettings
138
280
  }
139
281
 
140
282
  // Self-Syncing Presets & Roles (Issue #107)
package/lib/routes.js CHANGED
@@ -20,11 +20,34 @@ function jsonReply(res, data, status = 200) {
20
20
  res.end(JSON.stringify(data))
21
21
  }
22
22
 
23
+ export function toPublicPipelineDto(pipe) {
24
+ if (!pipe) return null
25
+ const stages = Array.isArray(pipe.stages)
26
+ ? pipe.stages.map((s) => ({
27
+ id: s.id,
28
+ name: s.name,
29
+ roleId: s.roleId,
30
+ status: s.status,
31
+ }))
32
+ : []
33
+ return {
34
+ pipelineId: pipe.pipelineId || pipe.id,
35
+ scenarioId: pipe.scenarioId,
36
+ scenarioTitle: pipe.scenarioTitle,
37
+ status: pipe.status,
38
+ startedAt: pipe.startedAt || pipe.createdAt || null,
39
+ completedAt: pipe.completedAt || null,
40
+ durationMs: pipe.durationMs || 0,
41
+ stages,
42
+ }
43
+ }
44
+
45
+
23
46
  export function registerOrchestratorRoutes(ctx, services) {
24
47
  const { store, runner, getConfig, updateConfig, callLlm, snapshotManager = new SnapshotManager() } = services
25
48
 
26
49
  // 1. Status & Global Metrics (Read-only status overview)
27
- // Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics
50
+ // Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics only
28
51
  ctx.effect(() => ctx.webServer.register({
29
52
  kind: 'exact',
30
53
  path: '/dsh-agent-orchestrator/status',
@@ -32,10 +55,12 @@ export function registerOrchestratorRoutes(ctx, services) {
32
55
  if (req.method !== 'GET') {
33
56
  return jsonReply(res, { error: 'Method not allowed' }, 405)
34
57
  }
58
+ const active = store.getActivePipelines().map(toPublicPipelineDto)
59
+ const recent = store.getAllPipelines().slice(0, 10).map(toPublicPipelineDto)
35
60
  jsonReply(res, {
36
- activePipelines: store.getActivePipelines(),
61
+ activePipelines: active,
37
62
  metrics: store.getMetrics(),
38
- recentPipelines: store.getAllPipelines().slice(0, 10),
63
+ recentPipelines: recent,
39
64
  })
40
65
  },
41
66
  }), 'dsh-agent-orchestrator: status route')
@@ -183,8 +208,12 @@ export function registerOrchestratorRoutes(ctx, services) {
183
208
  }
184
209
  }
185
210
  }
186
- await updateConfig(body)
187
- return jsonReply(res, { success: true, config: getConfig() })
211
+ try {
212
+ await updateConfig(body)
213
+ return jsonReply(res, { success: true, config: getConfig() })
214
+ } catch (err) {
215
+ return jsonReply(res, { success: false, error: err?.message || 'Failed to persist configuration' }, 500)
216
+ }
188
217
  }
189
218
  if (req.method !== 'GET') {
190
219
  return jsonReply(res, { error: 'Method not allowed' }, 405)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-agent-orchestrator",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Multi-agent task decomposition, DAG workflow orchestration, and prompt caching optimizer for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",