Spaces:
Runtime error
Runtime error
nyk commited on
fix: healthcheck auth, secure cookie auto-detect, model object crash (#84)
Browse filesBug 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.
- Dockerfile +1 -1
- src/app/api/auth/google/route.ts +4 -1
- src/app/api/auth/login/route.ts +4 -1
- src/components/dashboard/agent-network.tsx +1 -1
- src/lib/session-cookie.ts +4 -2
- src/lib/sessions.ts +1 -1
- src/lib/utils.ts +7 -0
- src/lib/websocket.ts +4 -3
Dockerfile
CHANGED
|
@@ -29,5 +29,5 @@ EXPOSE 3000
|
|
| 29 |
ENV PORT=3000
|
| 30 |
ENV HOSTNAME=0.0.0.0
|
| 31 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 32 |
-
CMD curl -f http://localhost:3000/
|
| 33 |
CMD ["node", "server.js"]
|
|
|
|
| 29 |
ENV PORT=3000
|
| 30 |
ENV HOSTNAME=0.0.0.0
|
| 31 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 32 |
+
CMD curl -f http://localhost:3000/login || exit 1
|
| 33 |
CMD ["node", "server.js"]
|
src/app/api/auth/google/route.ts
CHANGED
|
@@ -92,8 +92,11 @@ export async function POST(request: Request) {
|
|
| 92 |
},
|
| 93 |
})
|
| 94 |
|
|
|
|
|
|
|
|
|
|
| 95 |
response.cookies.set('mc-session', token, {
|
| 96 |
-
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000) }),
|
| 97 |
})
|
| 98 |
|
| 99 |
return response
|
|
|
|
| 92 |
},
|
| 93 |
})
|
| 94 |
|
| 95 |
+
const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
|
| 96 |
+
|| new URL(request.url).protocol === 'https:'
|
| 97 |
+
|
| 98 |
response.cookies.set('mc-session', token, {
|
| 99 |
+
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
|
| 100 |
})
|
| 101 |
|
| 102 |
return response
|
src/app/api/auth/login/route.ts
CHANGED
|
@@ -41,8 +41,11 @@ export async function POST(request: Request) {
|
|
| 41 |
},
|
| 42 |
})
|
| 43 |
|
|
|
|
|
|
|
|
|
|
| 44 |
response.cookies.set('mc-session', token, {
|
| 45 |
-
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000) }),
|
| 46 |
})
|
| 47 |
|
| 48 |
return response
|
|
|
|
| 41 |
},
|
| 42 |
})
|
| 43 |
|
| 44 |
+
const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
|
| 45 |
+
|| new URL(request.url).protocol === 'https:'
|
| 46 |
+
|
| 47 |
response.cookies.set('mc-session', token, {
|
| 48 |
+
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
|
| 49 |
})
|
| 50 |
|
| 51 |
return response
|
src/components/dashboard/agent-network.tsx
CHANGED
|
@@ -86,7 +86,7 @@ function AgentNode({ data }: { data: any }) {
|
|
| 86 |
</div>
|
| 87 |
|
| 88 |
<div className="text-xs text-muted-foreground truncate">
|
| 89 |
-
{agent.model.split('/').pop()}
|
| 90 |
</div>
|
| 91 |
|
| 92 |
{agent.session && (
|
|
|
|
| 86 |
</div>
|
| 87 |
|
| 88 |
<div className="text-xs text-muted-foreground truncate">
|
| 89 |
+
{(typeof agent.model === 'string' ? agent.model : '').split('/').pop() || 'unknown'}
|
| 90 |
</div>
|
| 91 |
|
| 92 |
{agent.session && (
|
src/lib/session-cookie.ts
CHANGED
|
@@ -9,9 +9,11 @@ function envFlag(name: string): boolean | undefined {
|
|
| 9 |
return undefined
|
| 10 |
}
|
| 11 |
|
| 12 |
-
export function getMcSessionCookieOptions(input: { maxAgeSeconds: number }): Partial<ResponseCookie> {
|
| 13 |
const secureEnv = envFlag('MC_COOKIE_SECURE')
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
|
| 16 |
// Strict is safest for this app (same-site UI + API), but allow override for edge cases.
|
| 17 |
const sameSiteRaw = (process.env.MC_COOKIE_SAMESITE || 'strict').toLowerCase()
|
|
|
|
| 9 |
return undefined
|
| 10 |
}
|
| 11 |
|
| 12 |
+
export function getMcSessionCookieOptions(input: { maxAgeSeconds: number; isSecureRequest?: boolean }): Partial<ResponseCookie> {
|
| 13 |
const secureEnv = envFlag('MC_COOKIE_SECURE')
|
| 14 |
+
// Explicit env wins. Otherwise auto-detect: only set secure if request came over HTTPS.
|
| 15 |
+
// Falls back to NODE_ENV=production when no request hint is available.
|
| 16 |
+
const secure = secureEnv ?? input.isSecureRequest ?? process.env.NODE_ENV === 'production'
|
| 17 |
|
| 18 |
// Strict is safest for this app (same-site UI + API), but allow override for edge cases.
|
| 19 |
const sameSiteRaw = (process.env.MC_COOKIE_SAMESITE || 'strict').toLowerCase()
|
src/lib/sessions.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|
| 62 |
updatedAt,
|
| 63 |
chatType: s.chatType || 'unknown',
|
| 64 |
channel: s.deliveryContext?.channel || s.lastChannel || s.channel || '',
|
| 65 |
-
model: s.model || '',
|
| 66 |
totalTokens: s.totalTokens || 0,
|
| 67 |
inputTokens: s.inputTokens || 0,
|
| 68 |
outputTokens: s.outputTokens || 0,
|
|
|
|
| 62 |
updatedAt,
|
| 63 |
chatType: s.chatType || 'unknown',
|
| 64 |
channel: s.deliveryContext?.channel || s.lastChannel || s.channel || '',
|
| 65 |
+
model: typeof s.model === 'object' && s.model?.primary ? String(s.model.primary) : String(s.model || ''),
|
| 66 |
totalTokens: s.totalTokens || 0,
|
| 67 |
inputTokens: s.inputTokens || 0,
|
| 68 |
outputTokens: s.outputTokens || 0,
|
src/lib/utils.ts
CHANGED
|
@@ -59,6 +59,13 @@ export function getStatusBadgeColor(status: AgentStatus['status']): string {
|
|
| 59 |
}
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
export function sessionToAgent(session: Session): Agent {
|
| 63 |
const getStatusFromSession = (session: Session): AgentStatus['status'] => {
|
| 64 |
if (session.age === 'just now' || session.age.includes('m ago')) return 'active'
|
|
|
|
| 59 |
}
|
| 60 |
}
|
| 61 |
|
| 62 |
+
/** Normalize model field — OpenClaw 2026.3.x may send {primary: "model-name"} instead of a string */
|
| 63 |
+
export function normalizeModel(model: unknown): string {
|
| 64 |
+
if (typeof model === 'string') return model
|
| 65 |
+
if (model && typeof model === 'object' && 'primary' in model) return String((model as any).primary)
|
| 66 |
+
return ''
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
export function sessionToAgent(session: Session): Agent {
|
| 70 |
const getStatusFromSession = (session: Session): AgentStatus['status'] => {
|
| 71 |
if (session.age === 'just now' || session.age.includes('m ago')) return 'active'
|
src/lib/websocket.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useCallback, useRef, useEffect } from 'react'
|
| 4 |
import { useMissionControl } from '@/store'
|
|
|
|
| 5 |
|
| 6 |
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
| 7 |
const PROTOCOL_VERSION = 3
|
|
@@ -172,7 +173,7 @@ export function useWebSocket() {
|
|
| 172 |
key: session.key || '',
|
| 173 |
kind: session.kind || 'unknown',
|
| 174 |
age: session.age || '',
|
| 175 |
-
model: session.model
|
| 176 |
tokens: session.tokens || '',
|
| 177 |
flags: session.flags || [],
|
| 178 |
active: session.active || false,
|
|
@@ -219,7 +220,7 @@ export function useWebSocket() {
|
|
| 219 |
// Handle various gateway events
|
| 220 |
if (message.data?.type === 'token_usage') {
|
| 221 |
addTokenUsage({
|
| 222 |
-
model: message.data.model,
|
| 223 |
sessionId: message.data.sessionId,
|
| 224 |
date: new Date().toISOString(),
|
| 225 |
inputTokens: message.data.inputTokens || 0,
|
|
@@ -291,7 +292,7 @@ export function useWebSocket() {
|
|
| 291 |
key: session.key || '',
|
| 292 |
kind: session.kind || 'unknown',
|
| 293 |
age: formatAge(session.updatedAt),
|
| 294 |
-
model: session.model
|
| 295 |
tokens: `${session.totalTokens || 0}/${session.contextTokens || 35000}`,
|
| 296 |
flags: [],
|
| 297 |
active: isActive(session.updatedAt),
|
|
|
|
| 2 |
|
| 3 |
import { useCallback, useRef, useEffect } from 'react'
|
| 4 |
import { useMissionControl } from '@/store'
|
| 5 |
+
import { normalizeModel } from '@/lib/utils'
|
| 6 |
|
| 7 |
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
| 8 |
const PROTOCOL_VERSION = 3
|
|
|
|
| 173 |
key: session.key || '',
|
| 174 |
kind: session.kind || 'unknown',
|
| 175 |
age: session.age || '',
|
| 176 |
+
model: normalizeModel(session.model),
|
| 177 |
tokens: session.tokens || '',
|
| 178 |
flags: session.flags || [],
|
| 179 |
active: session.active || false,
|
|
|
|
| 220 |
// Handle various gateway events
|
| 221 |
if (message.data?.type === 'token_usage') {
|
| 222 |
addTokenUsage({
|
| 223 |
+
model: normalizeModel(message.data.model),
|
| 224 |
sessionId: message.data.sessionId,
|
| 225 |
date: new Date().toISOString(),
|
| 226 |
inputTokens: message.data.inputTokens || 0,
|
|
|
|
| 292 |
key: session.key || '',
|
| 293 |
kind: session.kind || 'unknown',
|
| 294 |
age: formatAge(session.updatedAt),
|
| 295 |
+
model: normalizeModel(session.model),
|
| 296 |
tokens: `${session.totalTokens || 0}/${session.contextTokens || 35000}`,
|
| 297 |
flags: [],
|
| 298 |
active: isActive(session.updatedAt),
|