diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 57100f5e..a06aee9f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 997d7213..e598c394 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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}" diff --git a/README.md b/README.md index 303ba77f..b0445fdb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/app/api/spawn/route.ts b/src/app/api/spawn/route.ts index 3c8ec64c..bba0f2b5 100644 --- a/src/app/api/spawn/route.ts +++ b/src/app/api/spawn/route.ts @@ -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() diff --git a/src/lib/__tests__/gateway-runtime.test.ts b/src/lib/__tests__/gateway-runtime.test.ts new file mode 100644 index 00000000..1518afcc --- /dev/null +++ b/src/lib/__tests__/gateway-runtime.test.ts @@ -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) + }) +}) diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index 0183d654..63c8fc90 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -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) diff --git a/src/lib/validation.ts b/src/lib/validation.ts index c85301cb..23ca987d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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), })