Skip to content

Commit 8517d5e

Browse files
authored
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
1 parent fc4384b commit 8517d5e

7 files changed

Lines changed: 107 additions & 11 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ permissions:
2020
id-token: write
2121

2222
env:
23-
REGISTRY: ghcr.io
24-
IMAGE_NAME: ${{ github.repository }}
23+
GHCR_IMAGE: ghcr.io/${{ github.repository }}
24+
DOCKERHUB_IMAGE: docker.io/builderz-labs/mission-control
25+
DOCKERHUB_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
2526

2627
jobs:
2728
publish:
@@ -48,15 +49,24 @@ jobs:
4849
- name: Log in to GHCR
4950
uses: docker/login-action@v3
5051
with:
51-
registry: ${{ env.REGISTRY }}
52+
registry: ghcr.io
5253
username: ${{ github.actor }}
5354
password: ${{ secrets.GITHUB_TOKEN }}
5455

56+
- name: Log in to Docker Hub
57+
if: env.DOCKERHUB_ENABLED == 'true'
58+
uses: docker/login-action@v3
59+
with:
60+
username: ${{ secrets.DOCKERHUB_USERNAME }}
61+
password: ${{ secrets.DOCKERHUB_TOKEN }}
62+
5563
- name: Docker metadata
5664
id: meta
5765
uses: docker/metadata-action@v5
5866
with:
59-
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
67+
images: |
68+
${{ env.GHCR_IMAGE }}
69+
name=${{ env.DOCKERHUB_IMAGE }},enable=${{ env.DOCKERHUB_ENABLED }}
6070
tags: |
6171
type=sha,prefix=sha-
6272
type=ref,event=branch

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ RUN pnpm build
2323
FROM node:22.22.0-slim AS runtime
2424

2525
ARG MC_VERSION=dev
26-
LABEL org.opencontainers.image.source="https://github.com/openclaw/mission-control"
26+
LABEL org.opencontainers.image.source="https://github.com/builderz-labs/mission-control"
2727
LABEL org.opencontainers.image.description="Mission Control - operations dashboard"
2828
LABEL org.opencontainers.image.licenses="MIT"
2929
LABEL org.opencontainers.image.version="${MC_VERSION}"

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ docker compose up
123123

124124
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.
125125

126+
Release automation publishes multi-arch images to:
127+
- `ghcr.io/builderz-labs/mission-control`
128+
- `docker.io/builderz-labs/mission-control` when `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are configured in GitHub Actions secrets
129+
126130
### Docker Hardening (Production)
127131

128132
For production deployments, use the hardened compose overlay:

src/app/api/spawn/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export async function POST(request: NextRequest) {
5454
// Using OpenClaw's sessions_spawn function via clawdbot CLI
5555
const spawnPayload = {
5656
task,
57-
model,
5857
label,
58+
...(model ? { model } : {}),
5959
runTimeoutSeconds: timeout,
6060
tools: {
6161
profile: getPreferredToolsProfile(),
@@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
9191
actor_id: auth.user.id,
9292
detail: {
9393
spawnId,
94-
model,
94+
model: model ?? null,
9595
label,
9696
task_summary: task.length > 120 ? task.slice(0, 120) + '...' : task,
9797
toolsProfile: getPreferredToolsProfile(),
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
105105
spawnId,
106106
sessionInfo,
107107
task,
108-
model,
108+
model: model ?? null,
109109
label,
110110
timeoutSeconds: timeout,
111111
createdAt: Date.now(),
@@ -124,7 +124,7 @@ export async function POST(request: NextRequest) {
124124
spawnId,
125125
error: execError.message || 'Failed to spawn agent',
126126
task,
127-
model,
127+
model: model ?? null,
128128
label,
129129
timeoutSeconds: timeout,
130130
createdAt: Date.now()
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/config', () => ({
7+
config: { openclawConfigPath: '' },
8+
}))
9+
10+
vi.mock('@/lib/logger', () => ({
11+
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
12+
}))
13+
14+
describe('registerMcAsDashboard', () => {
15+
const originalEnv = { ...process.env }
16+
let tempDir = ''
17+
let configPath = ''
18+
19+
beforeEach(async () => {
20+
tempDir = mkdtempSync(path.join(os.tmpdir(), 'mc-gateway-runtime-'))
21+
configPath = path.join(tempDir, 'openclaw.json')
22+
process.env = { ...originalEnv }
23+
24+
const { config } = await import('@/lib/config')
25+
config.openclawConfigPath = configPath
26+
})
27+
28+
afterEach(() => {
29+
process.env = { ...originalEnv }
30+
rmSync(tempDir, { recursive: true, force: true })
31+
vi.resetModules()
32+
})
33+
34+
it('adds the Mission Control origin without disabling device auth', async () => {
35+
writeFileSync(configPath, JSON.stringify({
36+
gateway: {
37+
controlUi: {
38+
allowedOrigins: ['https://existing.example.com'],
39+
dangerouslyDisableDeviceAuth: false,
40+
},
41+
},
42+
}, null, 2) + '\n', 'utf-8')
43+
44+
const { registerMcAsDashboard } = await import('@/lib/gateway-runtime')
45+
const result = registerMcAsDashboard('https://mc.example.com/dashboard')
46+
47+
expect(result).toEqual({ registered: true, alreadySet: false })
48+
49+
const updated = JSON.parse(readFileSync(configPath, 'utf-8'))
50+
expect(updated.gateway.controlUi.allowedOrigins).toEqual([
51+
'https://existing.example.com',
52+
'https://mc.example.com',
53+
])
54+
expect(updated.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false)
55+
})
56+
57+
it('does not rewrite config when the origin is already present', async () => {
58+
writeFileSync(configPath, JSON.stringify({
59+
gateway: {
60+
controlUi: {
61+
allowedOrigins: ['https://mc.example.com'],
62+
dangerouslyDisableDeviceAuth: false,
63+
},
64+
},
65+
}, null, 2) + '\n', 'utf-8')
66+
67+
const before = readFileSync(configPath, 'utf-8')
68+
const { registerMcAsDashboard } = await import('@/lib/gateway-runtime')
69+
const result = registerMcAsDashboard('https://mc.example.com/sessions')
70+
const after = readFileSync(configPath, 'utf-8')
71+
72+
expect(result).toEqual({ registered: false, alreadySet: true })
73+
expect(after).toBe(before)
74+
})
75+
})

src/lib/__tests__/validation.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ describe('createAlertSchema', () => {
145145
describe('spawnAgentSchema', () => {
146146
const validSpawn = {
147147
task: 'Do something',
148-
model: 'sonnet',
149148
label: 'worker-1',
150149
}
151150

@@ -157,6 +156,14 @@ describe('spawnAgentSchema', () => {
157156
}
158157
})
159158

159+
it('accepts an explicit model when provided', () => {
160+
const result = spawnAgentSchema.safeParse({ ...validSpawn, model: 'sonnet' })
161+
expect(result.success).toBe(true)
162+
if (result.success) {
163+
expect(result.data.model).toBe('sonnet')
164+
}
165+
})
166+
160167
it('rejects timeout below minimum (10)', () => {
161168
const result = spawnAgentSchema.safeParse({ ...validSpawn, timeoutSeconds: 5 })
162169
expect(result.success).toBe(false)

src/lib/validation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export const qualityReviewSchema = z.object({
157157

158158
export const spawnAgentSchema = z.object({
159159
task: z.string().min(1, 'Task is required'),
160-
model: z.string().min(1, 'Model is required'),
160+
model: z.string().min(1, 'Model is required').optional(),
161161
label: z.string().min(1, 'Label is required'),
162162
timeoutSeconds: z.number().min(10).max(3600).default(300),
163163
})

0 commit comments

Comments
 (0)