fix: healthcheck auth, secure cookie auto-detect, model object crash (#84)

Bug 1 (#78): Dockerfile HEALTHCHECK curled authenticated /api/status,
always got 401 in production. Changed to /login which is public.

Bug 2 (#78): Login hangs on HTTP deployments because secure=true cookie
is silently rejected. Now auto-detects protocol from x-forwarded-proto
header, only sets secure when request actually came over HTTPS.

Bug 3 (#78): Agent model field from OpenClaw 2026.3.x is {primary: "name"}
object instead of string, causing React error #31. Added normalizeModel()
helper and applied it in all WebSocket/session mapping code paths.
This commit is contained in:
nyk 2026-03-03 14:19:34 +07:00 committed by GitHub
parent d826435401
commit f0f22129be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 26 additions and 10 deletions

View File

@ -29,5 +29,5 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/api/status || exit 1 CMD curl -f http://localhost:3000/login || exit 1
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@ -92,8 +92,11 @@ export async function POST(request: Request) {
}, },
}) })
const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
|| new URL(request.url).protocol === 'https:'
response.cookies.set('mc-session', token, { response.cookies.set('mc-session', token, {
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000) }), ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
}) })
return response return response

View File

@ -41,8 +41,11 @@ export async function POST(request: Request) {
}, },
}) })
const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
|| new URL(request.url).protocol === 'https:'
response.cookies.set('mc-session', token, { response.cookies.set('mc-session', token, {
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000) }), ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
}) })
return response return response

View File

@ -86,7 +86,7 @@ function AgentNode({ data }: { data: any }) {
</div> </div>
<div className="text-xs text-muted-foreground truncate"> <div className="text-xs text-muted-foreground truncate">
{agent.model.split('/').pop()} {(typeof agent.model === 'string' ? agent.model : '').split('/').pop() || 'unknown'}
</div> </div>
{agent.session && ( {agent.session && (

View File

@ -9,9 +9,11 @@ function envFlag(name: string): boolean | undefined {
return undefined return undefined
} }
export function getMcSessionCookieOptions(input: { maxAgeSeconds: number }): Partial<ResponseCookie> { export function getMcSessionCookieOptions(input: { maxAgeSeconds: number; isSecureRequest?: boolean }): Partial<ResponseCookie> {
const secureEnv = envFlag('MC_COOKIE_SECURE') const secureEnv = envFlag('MC_COOKIE_SECURE')
const secure = secureEnv ?? process.env.NODE_ENV === 'production' // Explicit env wins. Otherwise auto-detect: only set secure if request came over HTTPS.
// Falls back to NODE_ENV=production when no request hint is available.
const secure = secureEnv ?? input.isSecureRequest ?? process.env.NODE_ENV === 'production'
// Strict is safest for this app (same-site UI + API), but allow override for edge cases. // Strict is safest for this app (same-site UI + API), but allow override for edge cases.
const sameSiteRaw = (process.env.MC_COOKIE_SAMESITE || 'strict').toLowerCase() const sameSiteRaw = (process.env.MC_COOKIE_SAMESITE || 'strict').toLowerCase()

View File

@ -62,7 +62,7 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
updatedAt, updatedAt,
chatType: s.chatType || 'unknown', chatType: s.chatType || 'unknown',
channel: s.deliveryContext?.channel || s.lastChannel || s.channel || '', channel: s.deliveryContext?.channel || s.lastChannel || s.channel || '',
model: s.model || '', model: typeof s.model === 'object' && s.model?.primary ? String(s.model.primary) : String(s.model || ''),
totalTokens: s.totalTokens || 0, totalTokens: s.totalTokens || 0,
inputTokens: s.inputTokens || 0, inputTokens: s.inputTokens || 0,
outputTokens: s.outputTokens || 0, outputTokens: s.outputTokens || 0,

View File

@ -59,6 +59,13 @@ export function getStatusBadgeColor(status: AgentStatus['status']): string {
} }
} }
/** Normalize model field — OpenClaw 2026.3.x may send {primary: "model-name"} instead of a string */
export function normalizeModel(model: unknown): string {
if (typeof model === 'string') return model
if (model && typeof model === 'object' && 'primary' in model) return String((model as any).primary)
return ''
}
export function sessionToAgent(session: Session): Agent { export function sessionToAgent(session: Session): Agent {
const getStatusFromSession = (session: Session): AgentStatus['status'] => { const getStatusFromSession = (session: Session): AgentStatus['status'] => {
if (session.age === 'just now' || session.age.includes('m ago')) return 'active' if (session.age === 'just now' || session.age.includes('m ago')) return 'active'

View File

@ -2,6 +2,7 @@
import { useCallback, useRef, useEffect } from 'react' import { useCallback, useRef, useEffect } from 'react'
import { useMissionControl } from '@/store' import { useMissionControl } from '@/store'
import { normalizeModel } from '@/lib/utils'
// Gateway protocol version (v3 required by OpenClaw 2026.x) // Gateway protocol version (v3 required by OpenClaw 2026.x)
const PROTOCOL_VERSION = 3 const PROTOCOL_VERSION = 3
@ -172,7 +173,7 @@ export function useWebSocket() {
key: session.key || '', key: session.key || '',
kind: session.kind || 'unknown', kind: session.kind || 'unknown',
age: session.age || '', age: session.age || '',
model: session.model || '', model: normalizeModel(session.model),
tokens: session.tokens || '', tokens: session.tokens || '',
flags: session.flags || [], flags: session.flags || [],
active: session.active || false, active: session.active || false,
@ -219,7 +220,7 @@ export function useWebSocket() {
// Handle various gateway events // Handle various gateway events
if (message.data?.type === 'token_usage') { if (message.data?.type === 'token_usage') {
addTokenUsage({ addTokenUsage({
model: message.data.model, model: normalizeModel(message.data.model),
sessionId: message.data.sessionId, sessionId: message.data.sessionId,
date: new Date().toISOString(), date: new Date().toISOString(),
inputTokens: message.data.inputTokens || 0, inputTokens: message.data.inputTokens || 0,
@ -291,7 +292,7 @@ export function useWebSocket() {
key: session.key || '', key: session.key || '',
kind: session.kind || 'unknown', kind: session.kind || 'unknown',
age: formatAge(session.updatedAt), age: formatAge(session.updatedAt),
model: session.model || '', model: normalizeModel(session.model),
tokens: `${session.totalTokens || 0}/${session.contextTokens || 35000}`, tokens: `${session.totalTokens || 0}/${session.contextTokens || 35000}`,
flags: [], flags: [],
active: isActive(session.updatedAt), active: isActive(session.updatedAt),