import { authHeaders, requestJsonOrText, waitForWsEvent, wsOrigin } from './verify-hf-space-network.mjs'; export const DEFAULT_TIMEOUT_MS = 10000; const FALLBACK_TEXT = 'Build the PWA with: npm run build'; const SENSITIVE_KEY_PATTERN = /secret|token|authorization|cookie|pairingCode/i; const SAFE_STATUS_FIELD_NAMES = new Set([ 'browserTokenRequestsPerMinute', 'browserTokenRequestWindowMs', 'browserTokenRequestsTotal', 'browserTokenRateLimitedTotal' ]); const REALTIME_AVAILABILITY_ERRORS = new Set(['mac_offline', 'mac_local_offline']); export function normalizeSpaceBaseUrl(input) { const value = String(input || '').trim(); if (!value) throw new Error('Missing Space URL.'); const url = new URL(value); if (url.protocol === 'wss:') url.protocol = 'https:'; else if (url.protocol === 'ws:') url.protocol = 'http:'; else if (url.protocol !== 'https:' && url.protocol !== 'http:') { throw new Error(`Unsupported Space URL protocol: ${url.protocol}`); } if (url.pathname === '/relay/mac') { url.pathname = '/'; url.search = ''; } url.hash = ''; return url.origin; } export function parseArgs(argv, env = process.env) { const options = defaultOptions(env); for (let index = 0; index < argv.length; index += 1) { const item = argv[index]; if (item === '--url') options.spaceUrl = requireValue(argv, ++index, item); else if (item.startsWith('--url=')) options.spaceUrl = item.slice(6); else if (item === '--token') options.token = requireValue(argv, ++index, item); else if (item.startsWith('--token=')) options.token = item.slice(8); else if (item === '--pair-code') options.pairCode = requireValue(argv, ++index, item); else if (item.startsWith('--pair-code=')) options.pairCode = item.slice(12); else if (item === '--device-name') options.deviceName = requireValue(argv, ++index, item); else if (item.startsWith('--device-name=')) options.deviceName = item.slice(14); else if (item === '--project-id') options.projectId = requireValue(argv, ++index, item); else if (item.startsWith('--project-id=')) options.projectId = item.slice(13); else if (item === '--chat-message') options.chatMessage = requireValue(argv, ++index, item); else if (item.startsWith('--chat-message=')) options.chatMessage = item.slice(15); else if (item === '--timeout-ms') options.timeoutMs = positiveInt(requireValue(argv, ++index, item), item); else if (item.startsWith('--timeout-ms=')) options.timeoutMs = positiveInt(item.slice(13), '--timeout-ms'); else if (item === '--require-mac') options.requireMac = true; else if (item === '--check-realtime') options.checkRealtime = true; else if (item === '--require-realtime-ready') { options.checkRealtime = true; options.requireRealtimeReady = true; } else if (item === '--json') options.json = true; else throw new Error(`Unknown argument: ${item}`); } options.spaceUrl = normalizeSpaceBaseUrl(options.spaceUrl); return options; } export async function verifySpace(options) { const context = { token: String(options.token || '').trim(), projects: [] }; const report = createReport(options); await runPublicChecks(report, context, options); if (!context.token && options.pairCode) { await runCheck(report, 'pair', 'Pair through Space and receive Mac token', async () => { context.token = await pairThroughSpace(report.spaceUrl, options.pairCode, options.deviceName, report.timeoutMs); return 'token received'; }); } if (!context.token) { skipAuthChecks(report, 'missing browser token or pair code'); return finalize(report); } await runAuthenticatedChecks(report, context, options); return finalize(report); } function defaultOptions(env) { return { spaceUrl: env.CODEXMOBILE_SPACE_URL || env.CODEXMOBILE_RELAY_URL || '', token: env.CODEXMOBILE_DEVICE_TOKEN || '', pairCode: env.CODEXMOBILE_PAIRING_CODE || '', deviceName: env.CODEXMOBILE_VERIFY_DEVICE_NAME || 'space-verify', chatMessage: '', projectId: '', timeoutMs: DEFAULT_TIMEOUT_MS, requireMac: false, checkRealtime: false, requireRealtimeReady: false, json: false }; } function createReport(options) { const spaceUrl = normalizeSpaceBaseUrl(options.spaceUrl); return { ok: true, spaceUrl, timeoutMs: positiveInt(options.timeoutMs || DEFAULT_TIMEOUT_MS, 'timeoutMs'), requireMac: Boolean(options.requireMac), checkRealtime: Boolean(options.checkRealtime || options.requireRealtimeReady), requireRealtimeReady: Boolean(options.requireRealtimeReady), checks: [] }; } async function runPublicChecks(report, context, options) { await runCheck(report, 'pwa', 'PWA loads from Space', () => verifyPwa(report.spaceUrl, report.timeoutMs)); await runCheck(report, 'status', 'Relay status is safe and valid', async () => { const status = await verifyStatus(report.spaceUrl, report.timeoutMs, options.requireMac); context.status = status; return `relayState=${status.relayState} macConnected=${Boolean(status.macConnected)}`; }); await runCheck(report, 'realtimeHttpFallback', 'Realtime WebSocket HTTP fallback is explicit', () => { return verifyRealtimeHttpFallback(report.spaceUrl, report.timeoutMs); }); await runCheck(report, 'unauthenticatedProjects', 'Unauthenticated projects requires pairing', () => { return verifyUnauthenticatedProjects(report.spaceUrl, report.timeoutMs); }); } async function runAuthenticatedChecks(report, context, options) { await runCheck(report, 'authenticatedProjects', 'Authenticated project list', async () => { context.projects = await verifyAuthenticatedProjects(report.spaceUrl, context.token, report.timeoutMs); return `projects=${context.projects.length}`; }); await runCheck(report, 'browserWebSocket', 'Browser WebSocket receives connected event', () => { return verifyBrowserWebSocket(report.spaceUrl, context.token, report.timeoutMs); }); if (options.checkRealtime || options.requireRealtimeReady) { await runCheck(report, 'realtimeWebSocket', 'Realtime WebSocket tunnel', () => { return verifyRealtimeWebSocket(report.spaceUrl, context.token, report.timeoutMs, options.requireRealtimeReady); }); } else { skip(report, 'realtimeWebSocket', 'Realtime WebSocket tunnel', 'missing --check-realtime'); } if (!options.chatMessage) { skip(report, 'chatSend', 'Chat send', 'missing --chat-message'); return; } await runCheck(report, 'chatSend', 'Chat reaches terminal success', () => { return verifyChatSend(report.spaceUrl, context, options, report.timeoutMs); }); } function skipAuthChecks(report, detail) { skip(report, 'authenticatedProjects', 'Authenticated project list', detail); skip(report, 'browserWebSocket', 'Browser WebSocket', detail); skip(report, 'realtimeWebSocket', 'Realtime WebSocket tunnel', detail); skip(report, 'chatSend', 'Chat send', detail); } async function verifyPwa(spaceUrl, timeoutMs) { const result = await requestJsonOrText(`${spaceUrl}/`, { timeoutMs }); if (result.status !== 200) throw new Error(`expected 200, got ${result.status}`); if (result.text.includes(FALLBACK_TEXT)) throw new Error('Space is serving fallback text instead of built PWA.'); if (!/]/i.test(result.text) || !/id=["']root["']/i.test(result.text)) { throw new Error('Space root response does not look like the built PWA.'); } return 'built PWA HTML'; } async function verifyStatus(spaceUrl, timeoutMs, requireMac) { const result = await requestJsonOrText(`${spaceUrl}/api/status`, { timeoutMs }); if (result.status !== 200 || result.data?.mode !== 'relay') throw new Error(`expected relay status 200, got ${result.status}`); if (typeof result.data.relayState !== 'string') throw new Error('relayState is missing.'); const sensitivePath = findSensitiveKey(result.data); if (sensitivePath) throw new Error(`status exposes sensitive field: ${sensitivePath}`); if (requireMac && (!result.data.macConnected || result.data.localStatus?.reachable !== true)) { throw new Error(`Mac connector is not ready: ${result.data.relayState}`); } return result.data; } async function verifyRealtimeHttpFallback(spaceUrl, timeoutMs) { const result = await requestJsonOrText(`${spaceUrl}/ws/realtime`, { timeoutMs }); if (result.status !== 501 || result.data?.error !== 'relay_realtime_http_upgrade_required') { throw new Error(`expected 501 relay_realtime_http_upgrade_required, got ${result.status}`); } return 'relay_realtime_http_upgrade_required'; } async function verifyUnauthenticatedProjects(spaceUrl, timeoutMs) { const result = await requestJsonOrText(`${spaceUrl}/api/projects`, { timeoutMs }); if (result.status !== 401 || result.data?.error !== 'pairing_required') { throw new Error(`expected 401 pairing_required, got ${result.status}`); } return 'pairing_required'; } async function pairThroughSpace(spaceUrl, pairCode, deviceName, timeoutMs) { const result = await requestJsonOrText(`${spaceUrl}/api/pair`, { timeoutMs, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code: String(pairCode), deviceName: String(deviceName || 'space-verify') }) }); if (result.status !== 200 || typeof result.data?.token !== 'string' || !result.data.token) { throw new Error(`expected pair token, got ${result.status}`); } return result.data.token; } async function verifyAuthenticatedProjects(spaceUrl, token, timeoutMs) { const result = await requestJsonOrText(`${spaceUrl}/api/projects`, { timeoutMs, headers: authHeaders(token) }); if (result.status !== 200 || !Array.isArray(result.data?.projects)) { throw new Error(`expected projects array, got ${result.status}`); } return result.data.projects; } async function verifyBrowserWebSocket(spaceUrl, token, timeoutMs) { const wsUrl = `${wsOrigin(spaceUrl)}/ws?token=${encodeURIComponent(token)}`; const event = await waitForWsEvent(wsUrl, timeoutMs, (payload) => payload.type === 'connected'); if (event.status?.mode !== 'relay') throw new Error('connected event did not include relay status.'); return `relayState=${event.status.relayState}`; } async function verifyRealtimeWebSocket(spaceUrl, token, timeoutMs, requireReady = false) { const wsUrl = `${wsOrigin(spaceUrl)}/ws/realtime?token=${encodeURIComponent(token)}`; const event = await waitForWsEvent(wsUrl, timeoutMs, (payload) => { return payload.type === 'voice.realtime.ready' || payload.type === 'voice.realtime.error' || payload.type === 'error'; }); const eventType = event.type || 'message'; const errorCode = String(event.error || event.code || ''); if (REALTIME_AVAILABILITY_ERRORS.has(errorCode)) { throw new Error(`realtime tunnel unavailable: ${errorCode}`); } if (requireReady && eventType !== 'voice.realtime.ready') { throw new Error(`expected voice.realtime.ready, got ${eventType}${errorCode ? `: ${errorCode}` : ''}`); } if (eventType === 'voice.realtime.ready') { return 'voice.realtime.ready'; } return `tunnel event=${eventType}${errorCode ? ` error=${errorCode.slice(0, 120)}` : ''}`; } async function verifyChatSend(spaceUrl, context, options, timeoutMs) { const projectId = options.projectId || context.projects[0]?.id; if (!projectId) throw new Error('missing project id for chat send.'); const wsUrl = `${wsOrigin(spaceUrl)}/ws?token=${encodeURIComponent(context.token)}`; const event = await waitForWsEvent(wsUrl, timeoutMs, isChatTerminalEvent, async () => { const result = await requestJsonOrText(`${spaceUrl}/api/chat/send`, { timeoutMs, method: 'POST', headers: { ...authHeaders(context.token), 'content-type': 'application/json' }, body: JSON.stringify({ projectId, message: options.chatMessage }) }); if (result.status !== 202) throw new Error(`expected 202, got ${result.status}`); }); if (!event?.type) throw new Error('chat websocket event was not received.'); if (event.type === 'chat-error') { throw new Error(event.error || 'chat-error'); } return `projectId=${projectId} terminal=${event.type}`; } function isChatTerminalEvent(payload) { return payload.type === 'chat-complete' || payload.type === 'chat-error'; } function runCheck(report, id, label, action) { return Promise.resolve() .then(action) .then((detail) => pass(report, id, label, detail)) .catch((error) => fail(report, id, label, error.message || String(error))); } function pass(report, id, label, detail = '') { report.checks.push({ id, label, status: 'passed', detail: String(detail || '') }); } function fail(report, id, label, detail = '') { report.ok = false; report.checks.push({ id, label, status: 'failed', detail: String(detail || '') }); } function skip(report, id, label, detail = '') { report.checks.push({ id, label, status: 'skipped', detail: String(detail || '') }); } function finalize(report) { report.summary = { passed: report.checks.filter((check) => check.status === 'passed').length, failed: report.checks.filter((check) => check.status === 'failed').length, skipped: report.checks.filter((check) => check.status === 'skipped').length }; return report; } function findSensitiveKey(value, prefix = '') { if (!value || typeof value !== 'object') return ''; for (const [key, child] of Object.entries(value)) { const path = prefix ? `${prefix}.${key}` : key; if (path.startsWith('secrets.') && path !== 'secrets.previousConfigured') return path; if (SENSITIVE_KEY_PATTERN.test(key) && path !== 'secrets' && !SAFE_STATUS_FIELD_NAMES.has(key)) return path; const nested = findSensitiveKey(child, path); if (nested) return nested; } return ''; } function requireValue(argv, index, name) { const value = argv[index]; if (!value) throw new Error(`${name} requires a value.`); return value; } function positiveInt(value, name) { const number = Number(value); if (!Number.isFinite(number) || number <= 0) throw new Error(`${name} must be a positive integer.`); return Math.floor(number); }