fix(conductor): sanitize mission goals before spawn (#335)

Co-authored-by: shoveller <shoveller@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
This commit is contained in:
cinos
2026-05-08 09:11:00 +09:00
committed by GitHub
parent 16bc6e82a7
commit 19eadb66c8
4 changed files with 248 additions and 29 deletions

View File

@@ -26,7 +26,9 @@ if (isNonLoopbackHost(host)) {
// Honor HERMES_PASSWORD (current name) with CLAUDE_PASSWORD as a back-compat
// fallback for deployments configured pre-rename.
const password = (
process.env.HERMES_PASSWORD || process.env.CLAUDE_PASSWORD || ''
process.env.HERMES_PASSWORD ||
process.env.CLAUDE_PASSWORD ||
''
).trim()
if (!password) {
console.error(
@@ -46,7 +48,11 @@ if (isNonLoopbackHost(host)) {
)
.trim()
.toLowerCase()
if (allowInsecure !== '1' && allowInsecure !== 'true' && allowInsecure !== 'yes') {
if (
allowInsecure !== '1' &&
allowInsecure !== 'true' &&
allowInsecure !== 'yes'
) {
process.exit(1)
}
console.warn(
@@ -58,8 +64,13 @@ if (isNonLoopbackHost(host)) {
// sets the Secure flag on session cookies, which browsers silently drop
// over http://. Operators must set COOKIE_SECURE=0 for plain-HTTP LAN
// deployments. See #149.
const cookieSecureOverride = (process.env.COOKIE_SECURE || '').trim().toLowerCase()
const cookieSecureExplicit = cookieSecureOverride === '0' || cookieSecureOverride === 'false' || cookieSecureOverride === 'no'
const cookieSecureOverride = (process.env.COOKIE_SECURE || '')
.trim()
.toLowerCase()
const cookieSecureExplicit =
cookieSecureOverride === '0' ||
cookieSecureOverride === 'false' ||
cookieSecureOverride === 'no'
if (!cookieSecureExplicit && process.env.NODE_ENV === 'production') {
console.warn(
'\n[workspace] warning: plain-HTTP LAN deployment detected.\n' +

View File

@@ -5,7 +5,11 @@ import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
import { dashboardFetch, ensureGatewayProbed } from '../../server/gateway-capabilities'
import {
dashboardFetch,
ensureGatewayProbed,
} from '../../server/gateway-capabilities'
import { sanitizeConductorMissionGoal } from '../../server/conductor-mission-sanitize'
let cachedSkill: string | null = null
@@ -33,8 +37,17 @@ function loadDispatchSkill(): string {
const candidates = [
resolve(repoRoot(), 'skills/workspace-dispatch/SKILL.md'),
resolve(process.cwd(), 'skills/workspace-dispatch/SKILL.md'),
...(home ? [resolve(home, '.hermes/skills/workspace-dispatch/SKILL.md')] : []),
...(home ? [resolve(home, '.openclaw/workspace/skills/workspace-dispatch/SKILL.md')] : []),
...(home
? [resolve(home, '.hermes/skills/workspace-dispatch/SKILL.md')]
: []),
...(home
? [
resolve(
home,
'.openclaw/workspace/skills/workspace-dispatch/SKILL.md',
),
]
: []),
]
for (const p of candidates) {
try {
@@ -67,23 +80,39 @@ function buildOrchestratorPrompt(
},
): string {
const outputBase = options.projectsDir || '/tmp'
const outputPrefix = outputBase === '/tmp' ? '/tmp/dispatch-<slug>' : `${outputBase}/dispatch-<slug>`
const outputPrefix =
outputBase === '/tmp'
? '/tmp/dispatch-<slug>'
: `${outputBase}/dispatch-<slug>`
return [
'You are a mission orchestrator. Execute this mission autonomously.',
'',
'## Dispatch Skill Instructions',
'',
skill || '(workspace-dispatch skill not found locally; proceed using create_task to spawn workers)',
skill ||
'(workspace-dispatch skill not found locally; proceed using create_task to spawn workers)',
'',
'## Mission',
'',
`Goal: ${goal}`,
...(options.orchestratorModel ? ['', `Use model: ${options.orchestratorModel} for the orchestrator`] : []),
...(options.workerModel ? ['', `Use model: ${options.workerModel} for all workers`] : []),
...(options.orchestratorModel
? ['', `Use model: ${options.orchestratorModel} for the orchestrator`]
: []),
...(options.workerModel
? ['', `Use model: ${options.workerModel} for all workers`]
: []),
...(options.maxParallel > 1
? ['', `Run up to ${options.maxParallel} workers in parallel when tasks are independent`]
: ['', 'Spawn workers one at a time. Do NOT wait for workers to finish — the UI handles tracking.']),
...(options.supervised ? ['', 'Supervised mode is enabled. Require approval before each task.'] : []),
? [
'',
`Run up to ${options.maxParallel} workers in parallel when tasks are independent`,
]
: [
'',
'Spawn workers one at a time. Do NOT wait for workers to finish — the UI handles tracking.',
]),
...(options.supervised
? ['', 'Supervised mode is enabled. Require approval before each task.']
: []),
'',
'## Critical Rules',
'- Use create_task / delegate_task to create worker agents for each task',
@@ -98,7 +127,10 @@ function buildOrchestratorPrompt(
].join('\n')
}
async function createDashboardConductorMission(payload: { name: string; prompt: string }): Promise<{
async function createDashboardConductorMission(payload: {
name: string
prompt: string
}): Promise<{
id?: string
name?: string
sessionKey?: string
@@ -110,7 +142,13 @@ async function createDashboardConductorMission(payload: { name: string; prompt:
body: JSON.stringify({ name: payload.name, prompt: payload.prompt }),
})
const text = await res.text()
let data: { id?: string; name?: string; session_id?: string; error?: string; detail?: string } = {}
let data: {
id?: string
name?: string
session_id?: string
error?: string
detail?: string
} = {}
try {
data = JSON.parse(text)
} catch {
@@ -126,12 +164,19 @@ export const Route = createFileRoute('/api/conductor-spawn')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
if (!isAuthenticated(request))
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const missionId = url.searchParams.get('missionId')?.trim()
const requestedLines = Number(url.searchParams.get('lines') || '200')
const lines = Number.isFinite(requestedLines) ? Math.min(2000, Math.max(1, requestedLines)) : 200
if (!missionId) return json({ ok: false, error: 'missionId required' }, { status: 400 })
const lines = Number.isFinite(requestedLines)
? Math.min(2000, Math.max(1, requestedLines))
: 200
if (!missionId)
return json(
{ ok: false, error: 'missionId required' },
{ status: 400 },
)
const capabilities = await ensureGatewayProbed()
if (!capabilities.conductor) {
@@ -145,34 +190,59 @@ export const Route = createFileRoute('/api/conductor-spawn')({
)
}
const res = await dashboardFetch(`/api/conductor/missions/${encodeURIComponent(missionId)}?lines=${lines}`)
const res = await dashboardFetch(
`/api/conductor/missions/${encodeURIComponent(missionId)}?lines=${lines}`,
)
const text = await res.text()
let mission: Record<string, unknown> = {}
try {
mission = JSON.parse(text) as Record<string, unknown>
} catch {
return json({ ok: false, error: text || `HTTP ${res.status}` }, { status: res.ok ? 502 : res.status })
return json(
{ ok: false, error: text || `HTTP ${res.status}` },
{ status: res.ok ? 502 : res.status },
)
}
if (!res.ok) {
const error = typeof mission.detail === 'string' ? mission.detail : typeof mission.error === 'string' ? mission.error : `HTTP ${res.status}`
const error =
typeof mission.detail === 'string'
? mission.detail
: typeof mission.error === 'string'
? mission.error
: `HTTP ${res.status}`
return json({ ok: false, error }, { status: res.status })
}
return json({ ok: true, mission })
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
if (!isAuthenticated(request))
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
try {
const body = (await request.json().catch(() => ({}))) as ConductorSpawnBody
const goal = readOptionalString(body.goal)
const body = (await request
.json()
.catch(() => ({}))) as ConductorSpawnBody
const rawGoal = readOptionalString(body.goal)
const goalSanitization = sanitizeConductorMissionGoal(rawGoal)
const goal = goalSanitization.goal
const orchestratorModel = readOptionalString(body.orchestratorModel)
const workerModel = readOptionalString(body.workerModel)
const projectsDir = readOptionalString(body.projectsDir)
const maxParallel = readMaxParallel(body.maxParallel)
const supervised = body.supervised === true
if (!goal) return json({ ok: false, error: 'goal required' }, { status: 400 })
if (!goal)
return json(
{
ok: false,
error: goalSanitization.removedCloudflareErrorPage
? 'mission goal only contained a Cloudflare 5xx HTML error page; enter the original mission goal and retry'
: 'goal required',
warnings: goalSanitization.warnings,
},
{ status: 400 },
)
const prompt = buildOrchestratorPrompt(goal, loadDispatchSkill(), {
orchestratorModel,
@@ -195,11 +265,16 @@ export const Route = createFileRoute('/api/conductor-spawn')({
jobId: null,
jobName: missionName,
runId: null,
warnings: goalSanitization.warnings,
})
}
const result = await createDashboardConductorMission({ name: missionName, prompt })
if (result.error) return json({ ok: false, error: result.error }, { status: 502 })
const result = await createDashboardConductorMission({
name: missionName,
prompt,
})
if (result.error)
return json({ ok: false, error: result.error }, { status: 502 })
const missionId = result.id ?? missionName
return json({
ok: true,
@@ -211,9 +286,16 @@ export const Route = createFileRoute('/api/conductor-spawn')({
jobId: missionId,
jobName: result.name ?? missionName,
runId: null,
warnings: goalSanitization.warnings,
})
} catch (error) {
return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, { status: 500 })
return json(
{
ok: false,
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
)
}
},
},

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { sanitizeConductorMissionGoal } from './conductor-mission-sanitize'
describe('sanitizeConductorMissionGoal', () => {
it('removes public hermes-workspace self URLs', () => {
const result = sanitizeConductorMissionGoal(
'Research this via https://hermes-workspace.illuwa.click/conductor and summarize.',
)
expect(result.goal).toBe(
'Research this via [workspace public URL removed] and summarize.',
)
expect(result.removedSelfWorkspaceUrls).toBe(true)
expect(result.warnings).toContain(
'Removed public hermes-workspace URL(s) from the mission goal to avoid self-fetching through Cloudflare Access.',
)
})
it('strips embedded Cloudflare 5xx HTML pages', () => {
const result = sanitizeConductorMissionGoal(`Research: do the thing
<!DOCTYPE html>
<html><head><title>illuwa.click | 502: Bad gateway</title></head>
<body>
<div id="cf-error-details">
<span>Bad gateway</span>
<span>Error code 502</span>
<span>Cloudflare Ray ID: <strong>abc123</strong></span>
<div id="cf-browser-status">Browser Working</div>
<div id="cf-cloudflare-status">Cloudflare Working</div>
<div id="cf-host-status">Host Error</div>
</body></html>`)
expect(result.goal).toBe('Research: do the thing')
expect(result.removedCloudflareErrorPage).toBe(true)
expect(result.goal).not.toContain('<!DOCTYPE html>')
expect(result.goal).not.toContain('Cloudflare Ray ID')
})
it('leaves ordinary goals unchanged', () => {
const goal =
'Research functional programming principles and clean-code combinations.'
const result = sanitizeConductorMissionGoal(goal)
expect(result.goal).toBe(goal)
expect(result.removedCloudflareErrorPage).toBe(false)
expect(result.removedSelfWorkspaceUrls).toBe(false)
expect(result.warnings).toEqual([])
})
})

View File

@@ -0,0 +1,76 @@
const CLOUDFLARE_5XX_MARKERS = [
/<title>[^<]*\b(?:cloudflare|illuwa\.click)[^<]*\b5\d\d\b[^<]*<\/title>/i,
/<span[^>]*>\s*Bad gateway\s*<\/span>/i,
/Error code\s*5\d\d/i,
/Cloudflare Ray ID\s*:/i,
/cf-error-details/i,
/cf-browser-status/i,
/cf-cloudflare-status/i,
/cf-host-status/i,
]
const SELF_WORKSPACE_URL_PATTERN =
/https?:\/\/hermes-workspace\.[^\s<>)"']+(?:\/[^\s<>)"']*)?/gi
export type ConductorGoalSanitization = {
goal: string
removedCloudflareErrorPage: boolean
removedSelfWorkspaceUrls: boolean
warnings: Array<string>
}
function looksLikeCloudflare5xxPage(value: string): boolean {
const markerHits = CLOUDFLARE_5XX_MARKERS.reduce(
(count, marker) => count + (marker.test(value) ? 1 : 0),
0,
)
return /<!doctype html/i.test(value) && markerHits >= 2
}
function stripCloudflare5xxPages(value: string): {
value: string
removed: boolean
} {
if (!looksLikeCloudflare5xxPage(value)) return { value, removed: false }
const withoutHtmlDocument = value
.replace(/<!doctype html[\s\S]*?<\/html>/gi, '')
.replace(/❌\s*$/gm, '')
.trim()
return { value: withoutHtmlDocument, removed: true }
}
export function sanitizeConductorMissionGoal(
rawGoal: string,
): ConductorGoalSanitization {
const warnings: Array<string> = []
let goal = rawGoal.trim()
const cloudflareStripped = stripCloudflare5xxPages(goal)
goal = cloudflareStripped.value
if (cloudflareStripped.removed) {
warnings.push(
'Removed an embedded Cloudflare 5xx HTML error page from the mission goal.',
)
}
const withoutSelfWorkspaceUrls = goal.replace(
SELF_WORKSPACE_URL_PATTERN,
'[workspace public URL removed]',
)
const removedSelfWorkspaceUrls = withoutSelfWorkspaceUrls !== goal
goal = withoutSelfWorkspaceUrls.trim()
if (removedSelfWorkspaceUrls) {
warnings.push(
'Removed public hermes-workspace URL(s) from the mission goal to avoid self-fetching through Cloudflare Access.',
)
}
return {
goal,
removedCloudflareErrorPage: cloudflareStripped.removed,
removedSelfWorkspaceUrls,
warnings,
}
}