Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/spawn/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
spawnId,
sessionInfo,
task,
model,
model: model ?? null,
label,
timeoutSeconds: timeout,
createdAt: Date.now(),
Expand All @@ -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()
Expand Down
75 changes: 75 additions & 0 deletions src/lib/__tests__/gateway-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
9 changes: 8 additions & 1 deletion src/lib/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ describe('createAlertSchema', () => {
describe('spawnAgentSchema', () => {
const validSpawn = {
task: 'Do something',
model: 'sonnet',
label: 'worker-1',
}

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
Loading