fix: support gateway defaults for spawn and Docker Hub publish

- make /api/spawn compatible with gateway-managed default models
- add regression coverage for gateway dashboard registration
- publish official multi-arch images to Docker Hub when configured
This commit is contained in:
nyk 2026-03-18 19:36:04 +07:00 committed by GitHub
parent fc4384bba9
commit 8517d5e896
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 107 additions and 11 deletions

View File

@ -20,8 +20,9 @@ permissions:
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/builderz-labs/mission-control
DOCKERHUB_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
jobs:
publish:
@ -48,15 +49,24 @@ jobs:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: |
${{ env.GHCR_IMAGE }}
name=${{ env.DOCKERHUB_IMAGE }},enable=${{ env.DOCKERHUB_ENABLED }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch

View File

@ -23,7 +23,7 @@ RUN pnpm build
FROM node:22.22.0-slim AS runtime
ARG MC_VERSION=dev
LABEL org.opencontainers.image.source="https://github.com/openclaw/mission-control"
LABEL org.opencontainers.image.source="https://github.com/builderz-labs/mission-control"
LABEL org.opencontainers.image.description="Mission Control - operations dashboard"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.version="${MC_VERSION}"

View File

@ -123,6 +123,10 @@ docker compose up
No `.env` file needed. The container auto-generates `AUTH_SECRET` and `API_KEY` on first boot and persists them across restarts. Visit `http://localhost:3000` to create your admin account.
Release automation publishes multi-arch images to:
- `ghcr.io/builderz-labs/mission-control`
- `docker.io/builderz-labs/mission-control` when `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are configured in GitHub Actions secrets
### Docker Hardening (Production)
For production deployments, use the hardened compose overlay:

View File

@ -54,8 +54,8 @@ export async function POST(request: NextRequest) {
// Using OpenClaw's sessions_spawn function via clawdbot CLI
const spawnPayload = {
task,
model,
label,
...(model ? { model } : {}),
runTimeoutSeconds: timeout,
tools: {
profile: getPreferredToolsProfile(),
@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
actor_id: auth.user.id,
detail: {
spawnId,
model,
model: model ?? null,
label,
task_summary: task.length > 120 ? task.slice(0, 120) + '...' : task,
toolsProfile: getPreferredToolsProfile(),
@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
spawnId,
sessionInfo,
task,
model,
model: model ?? null,
label,
timeoutSeconds: timeout,
createdAt: Date.now(),
@ -124,7 +124,7 @@ export async function POST(request: NextRequest) {
spawnId,
error: execError.message || 'Failed to spawn agent',
task,
model,
model: model ?? null,
label,
timeoutSeconds: timeout,
createdAt: Date.now()

View File

@ -0,0 +1,75 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/config', () => ({
config: { openclawConfigPath: '' },
}))
vi.mock('@/lib/logger', () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}))
describe('registerMcAsDashboard', () => {
const originalEnv = { ...process.env }
let tempDir = ''
let configPath = ''
beforeEach(async () => {
tempDir = mkdtempSync(path.join(os.tmpdir(), 'mc-gateway-runtime-'))
configPath = path.join(tempDir, 'openclaw.json')
process.env = { ...originalEnv }
const { config } = await import('@/lib/config')
config.openclawConfigPath = configPath
})
afterEach(() => {
process.env = { ...originalEnv }
rmSync(tempDir, { recursive: true, force: true })
vi.resetModules()
})
it('adds the Mission Control origin without disabling device auth', async () => {
writeFileSync(configPath, JSON.stringify({
gateway: {
controlUi: {
allowedOrigins: ['https://existing.example.com'],
dangerouslyDisableDeviceAuth: false,
},
},
}, null, 2) + '\n', 'utf-8')
const { registerMcAsDashboard } = await import('@/lib/gateway-runtime')
const result = registerMcAsDashboard('https://mc.example.com/dashboard')
expect(result).toEqual({ registered: true, alreadySet: false })
const updated = JSON.parse(readFileSync(configPath, 'utf-8'))
expect(updated.gateway.controlUi.allowedOrigins).toEqual([
'https://existing.example.com',
'https://mc.example.com',
])
expect(updated.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false)
})
it('does not rewrite config when the origin is already present', async () => {
writeFileSync(configPath, JSON.stringify({
gateway: {
controlUi: {
allowedOrigins: ['https://mc.example.com'],
dangerouslyDisableDeviceAuth: false,
},
},
}, null, 2) + '\n', 'utf-8')
const before = readFileSync(configPath, 'utf-8')
const { registerMcAsDashboard } = await import('@/lib/gateway-runtime')
const result = registerMcAsDashboard('https://mc.example.com/sessions')
const after = readFileSync(configPath, 'utf-8')
expect(result).toEqual({ registered: false, alreadySet: true })
expect(after).toBe(before)
})
})

View File

@ -145,7 +145,6 @@ describe('createAlertSchema', () => {
describe('spawnAgentSchema', () => {
const validSpawn = {
task: 'Do something',
model: 'sonnet',
label: 'worker-1',
}
@ -157,6 +156,14 @@ describe('spawnAgentSchema', () => {
}
})
it('accepts an explicit model when provided', () => {
const result = spawnAgentSchema.safeParse({ ...validSpawn, model: 'sonnet' })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.model).toBe('sonnet')
}
})
it('rejects timeout below minimum (10)', () => {
const result = spawnAgentSchema.safeParse({ ...validSpawn, timeoutSeconds: 5 })
expect(result.success).toBe(false)

View File

@ -157,7 +157,7 @@ export const qualityReviewSchema = z.object({
export const spawnAgentSchema = z.object({
task: z.string().min(1, 'Task is required'),
model: z.string().min(1, 'Model is required'),
model: z.string().min(1, 'Model is required').optional(),
label: z.string().min(1, 'Label is required'),
timeoutSeconds: z.number().min(10).max(3600).default(300),
})