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:
parent
fc4384bba9
commit
8517d5e896
|
|
@ -20,8 +20,9 @@ permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
DOCKERHUB_IMAGE: docker.io/builderz-labs/mission-control
|
||||||
|
DOCKERHUB_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
|
|
@ -48,15 +49,24 @@ jobs:
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: |
|
||||||
|
${{ env.GHCR_IMAGE }}
|
||||||
|
name=${{ env.DOCKERHUB_IMAGE }},enable=${{ env.DOCKERHUB_ENABLED }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ RUN pnpm build
|
||||||
FROM node:22.22.0-slim AS runtime
|
FROM node:22.22.0-slim AS runtime
|
||||||
|
|
||||||
ARG MC_VERSION=dev
|
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.description="Mission Control - operations dashboard"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
LABEL org.opencontainers.image.version="${MC_VERSION}"
|
LABEL org.opencontainers.image.version="${MC_VERSION}"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
### Docker Hardening (Production)
|
||||||
|
|
||||||
For production deployments, use the hardened compose overlay:
|
For production deployments, use the hardened compose overlay:
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ export async function POST(request: NextRequest) {
|
||||||
// Using OpenClaw's sessions_spawn function via clawdbot CLI
|
// Using OpenClaw's sessions_spawn function via clawdbot CLI
|
||||||
const spawnPayload = {
|
const spawnPayload = {
|
||||||
task,
|
task,
|
||||||
model,
|
|
||||||
label,
|
label,
|
||||||
|
...(model ? { model } : {}),
|
||||||
runTimeoutSeconds: timeout,
|
runTimeoutSeconds: timeout,
|
||||||
tools: {
|
tools: {
|
||||||
profile: getPreferredToolsProfile(),
|
profile: getPreferredToolsProfile(),
|
||||||
|
|
@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
|
||||||
actor_id: auth.user.id,
|
actor_id: auth.user.id,
|
||||||
detail: {
|
detail: {
|
||||||
spawnId,
|
spawnId,
|
||||||
model,
|
model: model ?? null,
|
||||||
label,
|
label,
|
||||||
task_summary: task.length > 120 ? task.slice(0, 120) + '...' : task,
|
task_summary: task.length > 120 ? task.slice(0, 120) + '...' : task,
|
||||||
toolsProfile: getPreferredToolsProfile(),
|
toolsProfile: getPreferredToolsProfile(),
|
||||||
|
|
@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||||
spawnId,
|
spawnId,
|
||||||
sessionInfo,
|
sessionInfo,
|
||||||
task,
|
task,
|
||||||
model,
|
model: model ?? null,
|
||||||
label,
|
label,
|
||||||
timeoutSeconds: timeout,
|
timeoutSeconds: timeout,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
@ -124,7 +124,7 @@ export async function POST(request: NextRequest) {
|
||||||
spawnId,
|
spawnId,
|
||||||
error: execError.message || 'Failed to spawn agent',
|
error: execError.message || 'Failed to spawn agent',
|
||||||
task,
|
task,
|
||||||
model,
|
model: model ?? null,
|
||||||
label,
|
label,
|
||||||
timeoutSeconds: timeout,
|
timeoutSeconds: timeout,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -145,7 +145,6 @@ describe('createAlertSchema', () => {
|
||||||
describe('spawnAgentSchema', () => {
|
describe('spawnAgentSchema', () => {
|
||||||
const validSpawn = {
|
const validSpawn = {
|
||||||
task: 'Do something',
|
task: 'Do something',
|
||||||
model: 'sonnet',
|
|
||||||
label: 'worker-1',
|
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)', () => {
|
it('rejects timeout below minimum (10)', () => {
|
||||||
const result = spawnAgentSchema.safeParse({ ...validSpawn, timeoutSeconds: 5 })
|
const result = spawnAgentSchema.safeParse({ ...validSpawn, timeoutSeconds: 5 })
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export const qualityReviewSchema = z.object({
|
||||||
|
|
||||||
export const spawnAgentSchema = z.object({
|
export const spawnAgentSchema = z.object({
|
||||||
task: z.string().min(1, 'Task is required'),
|
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'),
|
label: z.string().min(1, 'Label is required'),
|
||||||
timeoutSeconds: z.number().min(10).max(3600).default(300),
|
timeoutSeconds: z.number().min(10).max(3600).default(300),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue