diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt b/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt index 5bae212db80..54ce5e7f532 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt @@ -2,10 +2,18 @@ package ai.openclaw.app import android.content.Intent +/** Android Assistant entry point used by manifest-declared app actions. */ const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW" + +/** Debug action that opens the Voice tab directly for Android E2E automation. */ const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E" + +/** Intent extra that carries an optional assistant prompt for app actions. */ const val extraAssistantPrompt = "prompt" +/** + * Top-level home destinations that external actions may request. + */ enum class HomeDestination { Connect, Chat, @@ -14,20 +22,30 @@ enum class HomeDestination { Settings, } +/** + * Normalized launch request from Android Assistant or explicit app actions. + */ data class AssistantLaunchRequest( val source: String, val prompt: String?, val autoSend: Boolean, ) +/** + * Parses app-owned navigation actions that should open a specific home tab. + */ fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? { val action = intent?.action ?: return null return when { + // Debug-only shortcut keeps E2E navigation out of release builds. BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice else -> null } } +/** + * Parse external assistant entry points without starting any UI side effects. + */ fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? { val action = intent?.action ?: return null return when (action) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt b/apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt index cd0ace8b76d..fb83dc8cecb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt @@ -1,5 +1,6 @@ package ai.openclaw.app +/** Camera HUD state categories shown over the Android UI during capture. */ enum class CameraHudKind { Photo, Recording, @@ -7,6 +8,7 @@ enum class CameraHudKind { Error, } +/** One-shot camera HUD message keyed by token so repeated text still replays. */ data class CameraHudState( val token: Long, val kind: CameraHudKind, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt b/apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt index 2f816fe3d19..a170036d0f5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt @@ -5,6 +5,7 @@ import android.os.Build import android.provider.Settings object DeviceNames { + /** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */ fun bestDefaultNodeName(context: Context): String { val deviceName = runCatching { @@ -15,6 +16,8 @@ object DeviceNames { if (deviceName.isNotEmpty()) return deviceName + // Manufacturer/model are best-effort platform fields; keep the final + // fallback stable so stored default names do not become blank. val model = listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() }) .joinToString(" ") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt index 003365659e1..b9127d66a5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt @@ -1,5 +1,8 @@ package ai.openclaw.app +/** + * Persisted location capture mode advertised to the gateway. + */ enum class LocationMode( val rawValue: String, ) { @@ -8,8 +11,10 @@ enum class LocationMode( ; companion object { + /** Parses persisted location mode text while migrating old always-on configs to while-using. */ fun fromRawValue(raw: String?): LocationMode { val normalized = raw?.trim()?.lowercase() + // Older configs used "always"; Android node currently exposes while-using location only. if (normalized == "always") return WhileUsing return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index 77c31eb8466..ef54fb4254d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -15,6 +15,9 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch +/** + * Main Android activity that owns Compose UI attachment and runtime UI wiring. + */ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester @@ -43,6 +46,7 @@ class MainActivity : ComponentActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.runtimeInitialized.collect { ready -> if (!ready || didAttachRuntimeUi) return@collect + // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) didAttachRuntimeUi = true if (!didStartNodeService) { @@ -78,6 +82,9 @@ class MainActivity : ComponentActivity() { handleAssistantIntent(intent) } + /** + * Routes assistant/app-action intents into ViewModel state without recreating the activity. + */ private fun handleAssistantIntent(intent: android.content.Intent?) { parseHomeDestinationIntent(intent)?.let { destination -> viewModel.requestHomeDestination(destination) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 654fc542db5..7ec397f31b9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -22,6 +22,9 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +/** + * UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows. + */ @OptIn(ExperimentalCoroutinesApi::class) class MainViewModel( app: Application, @@ -39,6 +42,9 @@ class MainViewModel( private val _pendingAssistantAutoSend = MutableStateFlow(null) val pendingAssistantAutoSend: StateFlow = _pendingAssistantAutoSend + /** + * Lazily starts NodeRuntime and preserves the current foreground bit across startup. + */ private fun ensureRuntime(): NodeRuntime { runtimeRef.value?.let { return it } val runtime = nodeApp.ensureRuntime() @@ -47,6 +53,9 @@ class MainViewModel( return runtime } + /** + * Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup. + */ private fun runtimeState( initial: T, selector: (NodeRuntime) -> StateFlow, @@ -185,6 +194,9 @@ class MainViewModel( val sms: SmsManager get() = ensureRuntime().sms + /** + * Attaches Activity-owned permission and lifecycle seams after runtime initialization. + */ fun attachRuntimeUi( owner: LifecycleOwner, permissionRequester: PermissionRequester, @@ -195,6 +207,9 @@ class MainViewModel( runtime.sms.attachPermissionRequester(permissionRequester) } + /** + * Starts runtime on foreground entry only after onboarding has completed. + */ fun setForeground(value: Boolean) { foreground = value val runtime = @@ -254,10 +269,12 @@ class MainViewModel( prefs.setGatewayPassword(value) } + /** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */ fun resetGatewaySetupAuth() { ensureRuntime().resetGatewaySetupAuth() } + /** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */ fun setOnboardingCompleted(value: Boolean) { if (value) { ensureRuntime() @@ -265,6 +282,7 @@ class MainViewModel( prefs.setOnboardingCompleted(value) } + /** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */ fun pairNewGateway() { runtimeRef.value?.disconnect() resetGatewaySetupAuth() @@ -272,6 +290,7 @@ class MainViewModel( prefs.setOnboardingCompleted(false) } + /** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */ fun clearGatewaySetupStartRequest() { _startOnboardingAtGatewaySetup.value = false } @@ -315,6 +334,7 @@ class MainViewModel( ensureRuntime().setVoiceScreenActive(active) } + /** Routes assistant intents into chat, either as a draft or queued auto-send prompt. */ fun handleAssistantLaunch(request: AssistantLaunchRequest) { _requestedHomeDestination.value = HomeDestination.Chat if (request.autoSend) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index 3982ff4c62c..b241b75f701 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -3,11 +3,17 @@ package ai.openclaw.app import android.app.Application import android.os.StrictMode +/** + * Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup. + */ class NodeApp : Application() { val prefs: SecurePrefs by lazy { SecurePrefs(this) } @Volatile private var runtimeInstance: NodeRuntime? = null + /** + * Returns the single NodeRuntime for this process, creating it on first use. + */ fun ensureRuntime(): NodeRuntime { runtimeInstance?.let { return it } return synchronized(this) { @@ -15,6 +21,9 @@ class NodeApp : Application() { } } + /** + * Reads the runtime without forcing startup, used by lifecycle probes and services. + */ fun peekRuntime(): NodeRuntime? = runtimeInstance override fun onCreate() { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index 8e016de83bf..e91b3e70a45 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */ class NodeForegroundService : Service() { private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var notificationJob: Job? = null @@ -36,6 +37,8 @@ class NodeForegroundService : Service() { stopSelf() return } + // Split connection and capture flows before combining so notification text + // can update without restarting runtime-owned connection work. notificationJob = scope.launch { combine( @@ -181,6 +184,7 @@ class NodeForegroundService : Service() { private fun startForegroundWithTypes(notification: Notification) { val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode) if (didStartForeground) { + // Re-issue startForeground when Talk mode toggles so Android sees the microphone service type. ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes) return } @@ -196,16 +200,19 @@ class NodeForegroundService : Service() { private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE" private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE" + /** Starts the persistent node foreground service from UI lifecycle code. */ fun start(context: Context) { val intent = Intent(context, NodeForegroundService::class.java) context.startForegroundService(intent) } + /** Requests disconnect through the service action path so notification actions and UI share behavior. */ fun stop(context: Context) { val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) context.startService(intent) } + /** Updates Android's foreground-service type before voice capture mode changes require microphone access. */ fun setVoiceCaptureMode( context: Context, mode: VoiceCaptureMode, @@ -215,6 +222,7 @@ class NodeForegroundService : Service() { .setAction(ACTION_SET_VOICE_CAPTURE_MODE) .putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name) if (mode == VoiceCaptureMode.TalkMode) { + // Microphone foreground service type must be declared before Talk capture starts. ContextCompat.startForegroundService(context, intent) } else { context.startService(intent) @@ -223,6 +231,9 @@ class NodeForegroundService : Service() { } } +/** + * Foreground-service type mask required by Android for the current voice capture mode. + */ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int { val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC return if (mode == VoiceCaptureMode.TalkMode) { @@ -232,6 +243,9 @@ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int { } } +/** + * Compact notification suffix for voice state; kept pure for service-notification tests. + */ internal fun voiceNotificationSuffix( mode: VoiceCaptureMode, manualMicEnabled: Boolean, @@ -260,6 +274,7 @@ private fun String?.toVoiceCaptureMode(): VoiceCaptureMode = it.name == this } ?: VoiceCaptureMode.Off +/** Connection fields that drive foreground notification title/body text. */ private data class VoiceNotificationBase( val status: String, val server: String?, @@ -267,6 +282,7 @@ private data class VoiceNotificationBase( val mode: VoiceCaptureMode, ) +/** Voice capture fields that affect foreground-service type and suffix. */ private data class VoiceNotificationCapture( val micEnabled: Boolean, val micListening: Boolean, @@ -274,6 +290,7 @@ private data class VoiceNotificationCapture( val talkSpeaking: Boolean, ) +/** Aggregated notification state from runtime flows. */ private data class VoiceNotificationState( val base: VoiceNotificationBase, val capture: VoiceNotificationCapture, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 7ec9a557b13..9d7d0c008bd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -75,11 +75,17 @@ import kotlinx.serialization.json.buildJsonObject import java.util.UUID import java.util.concurrent.atomic.AtomicLong +/** + * Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state. + */ class NodeRuntime( context: Context, val prefs: SecurePrefs = SecurePrefs(context.applicationContext), private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint, ) { + /** + * Authentication material supplied by setup/manual connect flows before gateway session routing. + */ data class GatewayConnectAuth( val token: String?, val bootstrapToken: String?, @@ -251,6 +257,9 @@ class NodeRuntime( motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, ) + /** + * Pending TLS trust decision when a gateway certificate is new or has changed. + */ data class GatewayTrustPrompt( val endpoint: GatewayEndpoint, val fingerprintSha256: String, @@ -282,6 +291,9 @@ class NodeRuntime( val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() private val connectAttemptSeq = AtomicLong(0) + /** + * Builds the node-owned session key from stable device identity plus optional active agent. + */ private fun resolveNodeMainSessionKey(agentId: String? = null): String { val deviceId = identityStore.loadOrCreate().deviceId return buildNodeMainSessionKey(deviceId, agentId) @@ -841,6 +853,7 @@ class NodeRuntime( fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) + /** Clears setup credentials plus paired device tokens for both Android gateway roles. */ fun resetGatewaySetupAuth() { prefs.clearGatewaySetupAuth() val deviceId = identityStore.loadOrCreate().deviceId @@ -848,6 +861,7 @@ class NodeRuntime( deviceAuthStore.clearToken(deviceId, "operator") } + /** Persists onboarding state; callers decide whether runtime startup is needed first. */ fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId @@ -917,6 +931,7 @@ class NodeRuntime( updateHomeCanvasState() } + /** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */ fun setForeground(value: Boolean) { _isForeground.value = value if (value) { @@ -1006,6 +1021,8 @@ class NodeRuntime( if (didAutoConnect) return if (_isConnected.value) return val endpoint = resolvePreferredGatewayEndpoint() ?: return + // Only attempt the stored preferred gateway once per runtime lifetime; users + // can still reconnect explicitly from the UI after a failed auto attempt. didAutoConnect = true connect(endpoint) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt b/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt index ca9c5423840..21ff2e2a6f3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt @@ -3,6 +3,7 @@ package ai.openclaw.app import java.time.Instant import java.time.ZoneId +/** Package-filter mode used before notification events are forwarded to the gateway. */ enum class NotificationPackageFilterMode( val rawValue: String, ) { @@ -11,10 +12,12 @@ enum class NotificationPackageFilterMode( ; companion object { + /** Parses persisted filter mode text, defaulting to blocklist for safer forwarding. */ fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist } } +/** Runtime policy used before forwarding notification events to a node session. */ internal data class NotificationForwardingPolicy( val enabled: Boolean, val mode: NotificationPackageFilterMode, @@ -26,6 +29,7 @@ internal data class NotificationForwardingPolicy( val sessionKey: String?, ) +/** Applies the operator-configured package allow/block list after trimming input. */ internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean { val normalized = packageName.trim() if (normalized.isEmpty()) { @@ -37,6 +41,7 @@ internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Bo } } +/** Returns true for both same-day and overnight quiet-hour windows. */ internal fun NotificationForwardingPolicy.isWithinQuietHours( nowEpochMs: Long, zoneId: ZoneId = ZoneId.systemDefault(), @@ -64,12 +69,14 @@ internal fun NotificationForwardingPolicy.isWithinQuietHours( private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""") +/** Normalizes persisted or user-entered local times to strict HH:mm form. */ internal fun normalizeLocalHourMinute(raw: String): String? { val trimmed = raw.trim() val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null return "${match.groupValues[1]}:${match.groupValues[2]}" } +/** Converts strict local HH:mm text to minutes since midnight for window checks. */ internal fun parseLocalHourMinute(raw: String): Int? { val normalized = normalizeLocalHourMinute(raw) ?: return null val parts = normalized.split(':') @@ -78,11 +85,13 @@ internal fun parseLocalHourMinute(raw: String): Int? { return hour * 60 + minute } +/** Fixed-window limiter that bounds notification bursts per wall-clock minute. */ internal class NotificationBurstLimiter { private val lock = Any() private var windowStartMs: Long = -1L private var eventsInWindow: Int = 0 + /** Returns true when the current minute bucket still has forwarding capacity. */ fun allow( nowEpochMs: Long, maxEventsPerMinute: Int, @@ -90,6 +99,8 @@ internal class NotificationBurstLimiter { if (maxEventsPerMinute <= 0) { return false } + // Align all callers to the same minute bucket so concurrent notifications + // share the quota even when they arrive with slightly different timestamps. val currentWindow = nowEpochMs - (nowEpochMs % 60_000L) synchronized(lock) { if (currentWindow != windowStartMs) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt index 1f1d6ea51c8..b26223106e2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt @@ -26,6 +26,9 @@ import kotlinx.coroutines.withTimeout import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.resume +/** + * Serializes Android runtime-permission prompts behind coroutine-friendly request calls. + */ class PermissionRequester internal constructor( private val activity: ComponentActivity, launcherFactory: ((Map) -> Unit) -> ActivityResultLauncher>, @@ -50,8 +53,12 @@ class PermissionRequester internal constructor( private val mutex = Mutex() private val requestSlotsLock = Any() private val mainHandler = Handler(Looper.getMainLooper()) + // ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows. private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) } + /** + * Request missing Android runtime permissions and return the final grant state for every requested permission. + */ suspend fun requestIfMissing( permissions: List, timeoutMs: Long = 20_000, @@ -93,6 +100,7 @@ class PermissionRequester internal constructor( try { withTimeout(timeoutMs) { deferred.await() } } catch (err: TimeoutCancellationException) { + // Late ActivityResult callbacks are ignored by completePermissionRequest. request.timedOut = true throw err } @@ -130,6 +138,7 @@ class PermissionRequester internal constructor( private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot = synchronized(requestSlotsLock) { + // The outer mutex serializes normal callers; this guard catches accidental concurrent launchers in tests. val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy") slot.request = request slot @@ -145,6 +154,7 @@ class PermissionRequester internal constructor( slot.request = null } } ?: return + // Timed-out requests have already resumed callers with failure; ignore any late platform callback. if (request.timedOut) return request.deferred.complete(result) } @@ -186,6 +196,7 @@ class PermissionRequester internal constructor( val actualObserver = LifecycleEventObserver { _, event -> if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver + // Do not resume a destroyed Activity with a positive result. finish(false) } observer = actualObserver diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index 6d9af3eebad..36172264156 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -15,6 +15,9 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import java.util.UUID +/** + * Reactive settings facade for Android node preferences and encrypted gateway credentials. + */ class SecurePrefs( context: Context, private val securePrefsOverride: SharedPreferences? = null, @@ -42,9 +45,11 @@ class SecurePrefs( private val appContext = context.applicationContext private val json = Json { ignoreUnknownKeys = true } + // Non-secret UI/runtime preferences stay readable for migration and backup behavior. private val plainPrefs: SharedPreferences = appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE) + // Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences. private val masterKey by lazy { MasterKey .Builder(appContext) @@ -253,6 +258,7 @@ class SecurePrefs( val configuredPackages = loadNotificationForwardingPackages() val normalizedAppPackage = appPackageName.trim() + // Always block OpenClaw's own notifications in blocklist mode to prevent forwarding loops. val defaultBlockedPackages = if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet() @@ -311,6 +317,7 @@ class SecurePrefs( .toSet() .toList() .sorted() + // Persist deterministic JSON so settings diffs and state restoration are stable. val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString() plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) } _notificationForwardingPackages.value = sanitized.toSet() @@ -355,6 +362,7 @@ class SecurePrefs( _notificationForwardingSessionKey.value = normalized } + /** Loads manual or instance-scoped gateway token material from encrypted preferences. */ fun loadGatewayToken(): String? { val manual = _gatewayToken.value.trim().ifEmpty { @@ -363,16 +371,19 @@ class SecurePrefs( stored } if (manual.isNotEmpty()) return manual + // Per-instance tokens keep reused Android installs from sharing stale gateway auth. val key = "gateway.token.${_instanceId.value}" val stored = securePrefs.getString(key, null)?.trim() return stored?.takeIf { it.isNotEmpty() } } + /** Saves the paired gateway token under the current Android instance id. */ fun saveGatewayToken(token: String) { val key = "gateway.token.${_instanceId.value}" securePrefs.edit { putString(key, token.trim()) } } + /** Loads the bootstrap token used during gateway setup and device-token handoff. */ fun loadGatewayBootstrapToken(): String? { val key = "gateway.bootstrapToken.${_instanceId.value}" val stored = @@ -404,9 +415,11 @@ class SecurePrefs( securePrefs.edit { putString(key, password.trim()) } } + /** Clears manual/setup credentials without removing persisted role-specific device tokens. */ fun clearGatewaySetupAuth() { val instanceId = _instanceId.value securePrefs.edit { + // Clear both current manual credentials and instance-scoped setup credentials after pairing/reset. remove("gateway.manual.token") remove("gateway.token.$instanceId") remove("gateway.bootstrapToken.$instanceId") @@ -416,11 +429,13 @@ class SecurePrefs( _gatewayBootstrapToken.value = "" } + /** Loads the pinned gateway TLS fingerprint for a discovered/manual stable endpoint id. */ fun loadGatewayTlsFingerprint(stableId: String): String? { val key = "gateway.tls.$stableId" return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } } + /** Persists the gateway TLS fingerprint captured through TOFU or explicit trust. */ fun saveGatewayTlsFingerprint( stableId: String, fingerprint: String, @@ -457,6 +472,7 @@ class SecurePrefs( private fun loadOrCreateInstanceId(): String { val existing = plainPrefs.getString("node.instanceId", null)?.trim() if (!existing.isNullOrBlank()) return existing + // Instance id is not secret; it scopes local credentials and survives display-name changes. val fresh = UUID.randomUUID().toString() plainPrefs.edit { putString("node.instanceId", fresh) } return fresh @@ -466,6 +482,7 @@ class SecurePrefs( val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty() if (existing.isNotEmpty() && existing != "Android Node") return existing + // Replace the historical generic name with a device-specific default once. val candidate = DeviceNames.bestDefaultNodeName(context).trim() val resolved = candidate.ifEmpty { "Android Node" } @@ -473,6 +490,7 @@ class SecurePrefs( return resolved } + /** Persists sanitized voice wake triggers and updates the reactive settings flow. */ fun setWakeWords(words: List) { val sanitized = WakeWords.sanitize(words, defaultWakeWords) val encoded = @@ -521,7 +539,7 @@ class SecurePrefs( val raw = plainPrefs.getString(voiceWakeModeKey, null) val resolved = VoiceWakeMode.fromRawValue(raw) - // Default ON (foreground) when unset. + // Default ON (foreground) when unset, but keep "always" opt-in through explicit settings. if (raw.isNullOrBlank()) { plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } } @@ -533,6 +551,7 @@ class SecurePrefs( val raw = plainPrefs.getString(locationModeKey, "off") val resolved = LocationMode.fromRawValue(raw) if (raw?.trim()?.lowercase() == "always") { + // Migrate old "always" configs to the current while-using contract. plainPrefs.edit { putString(locationModeKey, resolved.rawValue) } } return resolved diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt b/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt index 30067102624..0e6a6ba9a6d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt @@ -1,10 +1,12 @@ package ai.openclaw.app +/** Normalizes blank gateway session keys to the legacy main session alias. */ internal fun normalizeMainKey(raw: String?): String { val trimmed = raw?.trim() return if (!trimmed.isNullOrEmpty()) trimmed else "main" } +/** Accepts only gateway session keys that can represent the main chat stream. */ internal fun isCanonicalMainSessionKey(raw: String?): Boolean { val trimmed = raw?.trim().orEmpty() if (trimmed.isEmpty()) return false @@ -12,6 +14,7 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean { return trimmed.startsWith("agent:") } +/** Extracts the agent id from canonical agent-scoped main session keys. */ internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? { val trimmed = raw?.trim().orEmpty() if (!trimmed.startsWith("agent:")) return null @@ -22,6 +25,7 @@ internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? { .ifEmpty { null } } +/** Builds the node session key shape consumed by gateway chat and presence APIs. */ internal fun buildNodeMainSessionKey( deviceId: String, agentId: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt index 071d0df1970..31d3f4c2c90 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt @@ -1,5 +1,8 @@ package ai.openclaw.app +/** + * Persisted voice capture mode that controls foreground-service microphone requirements. + */ enum class VoiceCaptureMode { Off, ManualMic, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt index 3948b0cb0fa..b77be333f77 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt @@ -1,5 +1,8 @@ package ai.openclaw.app +/** + * Persisted wake-word mode; raw values are stored in secure preferences. + */ enum class VoiceWakeMode( val rawValue: String, ) { @@ -9,6 +12,9 @@ enum class VoiceWakeMode( ; companion object { + /** + * Invalid stored values fall back to foreground wake so hands-free behavior stays opt-in. + */ fun fromRawValue(raw: String?): VoiceWakeMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt b/apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt index 4d0dad8d124..ac3ff05a756 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt @@ -1,11 +1,16 @@ package ai.openclaw.app +/** + * Wake-word parsing limits and sanitizers shared by settings and voice runtime paths. + */ object WakeWords { const val maxWords: Int = 32 const val maxWordLength: Int = 64 + /** Splits comma-separated user input into non-empty wake-word entries. */ fun parseCommaSeparated(input: String): List = input.split(",").map { it.trim() }.filter { it.isNotEmpty() } + /** Returns null when edited text normalizes to the current wake-word list. */ fun parseIfChanged( input: String, current: List, @@ -14,6 +19,7 @@ object WakeWords { return if (parsed == current) null else parsed } + /** Applies persisted-list bounds and falls back to defaults when all entries are empty. */ fun sanitize( words: List, defaults: List, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 9c1cd07d76a..e477da25a65 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -61,12 +61,15 @@ class ChatController( private val pendingRuns = mutableSetOf() private val pendingRunTimeoutJobs = ConcurrentHashMap() + // Preserve sent messages locally until chat.history includes the gateway-confirmed copy. private val optimisticMessagesByRunId = LinkedHashMap() private val pendingRunTimeoutMs = 120_000L + // Drops stale history responses after session switches or refresh races. private val historyLoadGeneration = AtomicLong(0) private var lastHealthPollAtMs: Long? = null + /** Clears transient chat state when the operator gateway session disconnects. */ fun onDisconnected(message: String) { _healthOk.value = false _errorText.value = null @@ -78,6 +81,7 @@ class ChatController( _sessionId.value = null } + /** Loads a chat session, normalizing "main" to the current gateway-provided main session key. */ fun load(sessionKey: String) { val key = normalizeRequestedSessionKey(sessionKey) val generation = beginHistoryLoad(key, clearMessages = key != _sessionKey.value) @@ -86,6 +90,7 @@ class ChatController( } } + /** Rebinds chat to a new canonical main session key after gateway hello/agent changes. */ fun applyMainSessionKey(mainSessionKey: String) { val trimmed = mainSessionKey.trim() if (trimmed.isEmpty()) return @@ -108,6 +113,7 @@ class ChatController( } } + /** Refreshes current chat history and session list without clearing optimistic messages first. */ fun refresh() { val key = normalizeRequestedSessionKey(_sessionKey.value) val generation = beginHistoryLoad(key, clearMessages = false) @@ -120,12 +126,14 @@ class ChatController( scope.launch { fetchSessions(limit = limit) } } + /** Persists the normalized thinking level used for subsequent chat sends. */ fun setThinkingLevel(thinkingLevel: String) { val normalized = normalizeThinking(thinkingLevel) if (normalized == _thinkingLevel.value) return _thinkingLevel.value = normalized } + /** Switches to another gateway chat session and starts a fresh history load. */ fun switchSession(sessionKey: String) { val key = normalizeRequestedSessionKey(sessionKey) if (key.isEmpty()) return @@ -163,6 +171,7 @@ class ChatController( return key } + /** Queues a chat send without waiting for gateway acceptance. */ fun sendMessage( message: String, thinkingLevel: String, @@ -177,6 +186,7 @@ class ChatController( } } + /** Sends a chat message and returns once the gateway accepts or rejects the request. */ suspend fun sendMessageAwaitAcceptance( message: String, thinkingLevel: String, @@ -194,7 +204,7 @@ class ChatController( val sessionKey = _sessionKey.value val thinking = normalizeThinking(thinkingLevel) - // Optimistic user message. + // Optimistic user message keeps the composer responsive while chat.send and history refresh complete. val userContent = buildList { add(ChatMessageContent(type = "text", text = text)) @@ -257,6 +267,7 @@ class ChatController( val res = session.request("chat.send", params.toString()) val actualRunId = parseRunId(res) ?: runId if (actualRunId != runId) { + // Gateway may return a canonical run id; move all pending bookkeeping to that id. optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage clearPendingRun(runId) armPendingRunTimeout(actualRunId) @@ -274,6 +285,7 @@ class ChatController( } } + /** Sends best-effort abort requests for every currently pending gateway run. */ fun abort() { val runIds = synchronized(pendingRuns) { @@ -296,6 +308,7 @@ class ChatController( } } + /** Applies gateway chat/agent stream events to local transcript and pending-run state. */ fun handleGatewayEvent( event: String, payloadJson: String?, @@ -396,7 +409,7 @@ class ChatController( val state = payload["state"].asStringOrNull() when (state) { "delta" -> { - // Only show streaming text for runs we initiated + // Only show streaming text for runs we initiated in this controller. if (!isPending) return val text = parseAssistantDeltaText(payload) if (!text.isNullOrEmpty()) { @@ -637,6 +650,9 @@ internal fun isCurrentHistoryLoad( activeGeneration: Long, ): Boolean = requestedSessionKey == currentSessionKey && requestGeneration == activeGeneration +/** + * Convert gateway chat content parts into Android UI content parts. + */ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? { val obj = el.asObjectOrNull() ?: return null return when (obj["type"].asStringOrNull() ?: "text") { @@ -663,6 +679,9 @@ internal data class MainSessionState( val appliedMainSessionKey: String, ) +/** + * Rewrite only the active "main" alias when the gateway publishes a new canonical main session key. + */ internal fun applyMainSessionKey( currentSessionKey: String, appliedMainSessionKey: String, @@ -680,6 +699,9 @@ internal fun applyMainSessionKey( ) } +/** + * Keep Compose item identity stable across history refreshes by matching existing messages to incoming copies. + */ internal fun reconcileMessageIds( previous: List, incoming: List, @@ -729,6 +751,9 @@ internal fun mergeOptimisticMessages( return (incoming + missingOptimistic).sortedWith(compareBy { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id }) } +/** + * Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys. + */ internal fun messageIdentityKey(message: ChatMessage): String? { val contentKey = messageContentIdentityKey(message) ?: return null val timestamp = message.timestampMs?.toString().orEmpty() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index f6d08c535c5..30649a6d25d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -1,5 +1,8 @@ package ai.openclaw.app.chat +/** + * Chat transcript item as delivered by gateway chat history and live chat events. + */ data class ChatMessage( val id: String, val role: String, @@ -7,6 +10,9 @@ data class ChatMessage( val timestampMs: Long?, ) +/** + * One content part in a chat message; binary parts carry base64 plus their MIME metadata. + */ data class ChatMessageContent( val type: String = "text", val text: String? = null, @@ -15,6 +21,9 @@ data class ChatMessageContent( val base64: String? = null, ) +/** + * Tool call placeholder shown while a gateway run is still streaming. + */ data class ChatPendingToolCall( val toolCallId: String, val name: String, @@ -23,12 +32,18 @@ data class ChatPendingToolCall( val isError: Boolean? = null, ) +/** + * Stable session selector row; [key] is the gateway session key used in chat requests. + */ data class ChatSessionEntry( val key: String, val updatedAtMs: Long?, val displayName: String? = null, ) +/** + * Snapshot of one chat session, including optional thinking level selected on the gateway. + */ data class ChatHistory( val sessionKey: String, val sessionId: String?, @@ -36,6 +51,9 @@ data class ChatHistory( val messages: List, ) +/** + * User-selected attachment payload sent to the gateway as inline base64. + */ data class OutgoingAttachment( val type: String, val mimeType: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt index 2fa0befbb5c..dffda293062 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt @@ -1,6 +1,10 @@ package ai.openclaw.app.gateway +/** + * Decoder for Bonjour DNS-SD service names returned with decimal byte escapes. + */ object BonjourEscapes { + /** Decodes Bonjour DNS-SD decimal escapes while preserving ordinary UTF-8. */ fun decode(input: String): String { if (input.isEmpty()) return input @@ -15,6 +19,7 @@ object BonjourEscapes { val value = ((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code) if (value in 0..255) { + // Bonjour escape bytes are decimal octets, not Unicode code points. bytes.add(value.toByte()) i += 4 continue diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt index f556341e10a..ef038871025 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt @@ -1,6 +1,10 @@ package ai.openclaw.app.gateway +/** + * Canonical device-auth payload builder shared with gateway verification rules. + */ internal object DeviceAuthPayload { + /** Builds the canonical v3 auth string signed by device registration flows. */ fun buildV3( deviceId: String, clientId: String, @@ -32,6 +36,7 @@ internal object DeviceAuthPayload { ).joinToString("|") } + /** Normalizes signed metadata fields without locale-sensitive lowercasing. */ internal fun normalizeMetadataField(value: String?): String { val trimmed = value?.trim().orEmpty() if (trimmed.isEmpty()) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt index 70678adc4c0..94ba88dcbd5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +/** Stored gateway device-token material scoped by device id and role. */ data class DeviceAuthEntry( val token: String, val role: String, @@ -18,17 +19,21 @@ private data class PersistedDeviceAuthMetadata( val updatedAtMs: Long = 0L, ) +/** Persistence interface used by gateway pairing/session code for role tokens. */ interface DeviceAuthTokenStore { + /** Loads the stored token plus metadata for one device/role pair. */ fun loadEntry( deviceId: String, role: String, ): DeviceAuthEntry? + /** Loads only the bearer token when callers do not need scope metadata. */ fun loadToken( deviceId: String, role: String, ): String? = loadEntry(deviceId, role)?.token + /** Persists a role token and deterministic scope metadata under normalized keys. */ fun saveToken( deviceId: String, role: String, @@ -36,12 +41,14 @@ interface DeviceAuthTokenStore { scopes: List = emptyList(), ) + /** Removes both token and metadata for the normalized device/role pair. */ fun clearToken( deviceId: String, role: String, ) } +/** SecurePrefs-backed implementation of Android gateway device-token storage. */ class DeviceAuthStore( private val prefs: SecurePrefs, ) : DeviceAuthTokenStore { @@ -103,6 +110,8 @@ class DeviceAuthStore( ): String { val normalizedDevice = normalizeDeviceId(deviceId) val normalizedRole = normalizeRole(role) + // Keep key normalization shared with metadata keys so token and metadata + // are added/removed as one logical auth entry. return "gateway.deviceToken.$normalizedDevice.$normalizedRole" } @@ -115,14 +124,19 @@ class DeviceAuthStore( return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole" } + /** Normalizes device ids before they become encrypted preference key segments. */ private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase() + /** Normalizes role names so node/operator token slots are stable across callers. */ private fun normalizeRole(role: String): String = role.trim().lowercase() + /** Stores scopes in deterministic order for display and restart comparisons. */ private fun normalizeScopes(scopes: List): List = scopes .map { it.trim() } .filter { it.isNotEmpty() } + // Persist deterministic scope lists because they are displayed and may be + // compared across process restarts. .distinct() .sorted() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt index 808e2cd4454..c9d6543fb22 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import java.io.File import java.security.MessageDigest +/** Persistent Ed25519 identity used to register this Android node with gateways. */ @Serializable data class DeviceIdentity( val deviceId: String, @@ -15,6 +16,7 @@ data class DeviceIdentity( val createdAtMs: Long, ) +/** Owns device identity generation, persistence, and auth payload signatures. */ class DeviceIdentityStore( context: Context, ) { @@ -23,6 +25,7 @@ class DeviceIdentityStore( @Volatile private var cachedIdentity: DeviceIdentity? = null + /** Loads the persisted identity or creates one, repairing old device-id drift. */ @Synchronized fun loadOrCreate(): DeviceIdentity { cachedIdentity?.let { return it } @@ -44,12 +47,13 @@ class DeviceIdentityStore( return fresh } + /** Signs gateway connect payload text with the persisted Ed25519 private key. */ fun signPayload( payload: String, identity: DeviceIdentity, ): String? = try { - // Use BC lightweight API directly — JCA provider registration is broken by R8 + // Use BC lightweight API directly; R8 can break JCA provider registration. val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo @@ -74,6 +78,7 @@ class DeviceIdentityStore( null } + /** Verifies a signature against the persisted public key for debug diagnostics. */ fun verifySelfSignature( payload: String, signatureBase64Url: String, @@ -97,12 +102,16 @@ class DeviceIdentityStore( false } + /** Decodes gateway URL-safe base64 signatures, accepting unpadded input. */ private fun base64UrlDecode(input: String): ByteArray { val normalized = input.replace('-', '+').replace('_', '/') + // Android Base64 expects padded input; gateway signatures are URL-safe + // unpadded strings. val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4) return Base64.decode(padded, Base64.DEFAULT) } + /** Returns the public key in the gateway's unpadded URL-safe base64 format. */ fun publicKeyBase64Url(identity: DeviceIdentity): String? = try { val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) @@ -142,7 +151,7 @@ class DeviceIdentityStore( } private fun generate(): DeviceIdentity { - // Use BC lightweight API directly to avoid JCA provider issues with R8 + // Use BC lightweight API directly to avoid JCA provider issues with R8. val kpGen = org.bouncycastle.crypto.generators .Ed25519KeyPairGenerator() @@ -155,7 +164,8 @@ class DeviceIdentityStore( val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters val rawPublic = pubKey.encoded // 32 bytes val deviceId = sha256Hex(rawPublic) - // Encode private key as PKCS8 for storage + // Store private key as PKCS8 so signPayload can parse the same persisted + // shape after app restarts and upgrades. val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory .createPrivateKeyInfo(privKey) @@ -168,6 +178,7 @@ class DeviceIdentityStore( ) } + /** Re-derives the stable device id from the raw Ed25519 public key bytes. */ private fun deriveDeviceId(publicKeyRawBase64: String): String? = try { val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt index 8820e4b53bb..dea9d1eebdb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt @@ -49,6 +49,9 @@ import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +/** + * Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways. + */ class GatewayDiscovery( context: Context, private val scope: CoroutineScope, @@ -63,9 +66,11 @@ class GatewayDiscovery( private val localById = ConcurrentHashMap() private val unicastById = ConcurrentHashMap() private val _gateways = MutableStateFlow>(emptyList()) + /** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */ val gateways: StateFlow> = _gateways.asStateFlow() private val _statusText = MutableStateFlow("Searching…") + /** Short diagnostic text shown by connect UI while discovery is running. */ val statusText: StateFlow = _statusText.asStateFlow() private var unicastJob: Job? = null @@ -130,6 +135,8 @@ class GatewayDiscovery( val cm = connectivity ?: return cm.activeNetwork?.let(availableNetworks::add) try { + // Track all networks so wide-area DNS can prefer VPN/split-DNS answers + // even when Android's active network is not the VPN. cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback) } catch (_: Throwable) { // ignore (best-effort) @@ -168,6 +175,7 @@ class GatewayDiscovery( private fun resolve(serviceInfo: NsdServiceInfo) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Android 14+ streams service updates; older releases require one-shot resolve calls. resolveWithServiceInfoCallback(serviceInfo) } else { resolveLegacy(serviceInfo) @@ -255,6 +263,7 @@ class GatewayDiscovery( val tlsEnabled = txtBool(resolved, "gatewayTls") val tlsFingerprint = txt(resolved, "gatewayTlsSha256") val id = stableId(serviceName, "local.") + // Local NSD gives the socket host/port; TXT ports are retained as gateway metadata only. localById[id] = GatewayEndpoint( stableId = id, @@ -288,6 +297,7 @@ class GatewayDiscovery( private fun publish() { _gateways.value = + // Merge local and wide-area results deterministically for stable UI selection. (localById.values + unicastById.values).sortedBy { it.name.lowercase() } _statusText.value = buildStatusText() } @@ -369,6 +379,7 @@ class GatewayDiscovery( ?: resolveHostUnicast(targetFqdn) ?: continue + // Wide-area DNS-SD may put TXT in additional records; fall back to a direct TXT query. val txtFromPtr = recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)] .orEmpty() @@ -454,6 +465,7 @@ class GatewayDiscovery( val system = queryViaSystemDns(query) if (records(system, Section.ANSWER).any { it.type == type }) return system + // Android's DnsResolver can miss split-DNS answers; retry with dnsjava against network DNS servers. val direct = createDirectResolver() ?: return system return try { val msg = direct.send(query) @@ -548,6 +560,7 @@ class GatewayDiscovery( val candidateNetworks = buildList { + // Put VPN DNS first so Tailscale split-horizon names win over public DNS. trackedNetworks(cm) .firstOrNull { n -> val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt index 38b8a4888d3..15cecedf387 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.gateway +/** Resolved gateway address and optional metadata discovered from Bonjour/manual entry. */ data class GatewayEndpoint( val stableId: String, val name: String, @@ -13,6 +14,7 @@ data class GatewayEndpoint( val tlsFingerprintSha256: String? = null, ) { companion object { + /** Builds a stable manual endpoint key that survives display-name changes. */ fun manual( host: String, port: Int, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt index 9cf0015c0d6..24ee0e917ea 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt @@ -4,6 +4,7 @@ import android.os.Build import java.net.InetAddress import java.util.Locale +/** Returns true only for loopback hosts safe to treat as local gateway origins. */ internal fun isLoopbackGatewayHost( rawHost: String?, allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(), @@ -18,9 +19,12 @@ internal fun isLoopbackGatewayHost( host = host.dropLast(1) } val zoneIndex = host.indexOf('%') + // Scoped IPv6 literals are not stable origin identifiers; reject them for + // loopback trust instead of guessing which interface the zone names. if (zoneIndex >= 0) return false if (host.isEmpty()) return false if (host == "localhost") return true + // Android emulator maps host loopback through this bridge alias. if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true parseIpv4Address(host)?.let { ipv4 -> @@ -44,6 +48,7 @@ internal fun isLoopbackGatewayHost( return isMappedIpv4 && address[12] == 127.toByte() } +/** Allows cleartext only for loopback and private/link-local network ranges. */ internal fun isLocalCleartextGatewayHost( rawHost: String?, allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(), @@ -59,6 +64,8 @@ internal fun isLocalCleartextGatewayHost( } val zoneIndex = host.indexOf('%') if (zoneIndex >= 0) { + // Link-local cleartext policy is about the address range; strip the + // interface zone before InetAddress parsing rejects otherwise valid hosts. host = host.substring(0, zoneIndex) } if (host.isEmpty()) return false @@ -107,6 +114,7 @@ private fun isAndroidEmulatorRuntime(): Boolean { product.contains("simulator") } +/** Parses strict dotted-quad IPv4, rejecting shorthand and out-of-range octets. */ private fun parseIpv4Address(host: String): ByteArray? { val parts = host.split('.') if (parts.size != 4) return null @@ -119,4 +127,5 @@ private fun parseIpv4Address(host: String): ByteArray? { return bytes } +/** Cheap prefilter before handing potential IPv6 literals to InetAddress. */ private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.' diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 582aebe72e4..f25f03a8dbf 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -1,4 +1,7 @@ package ai.openclaw.app.gateway +/** Gateway protocol version emitted by Android node clients. */ const val GATEWAY_PROTOCOL_VERSION = 4 + +/** Oldest gateway protocol version this Android client can speak safely. */ const val GATEWAY_MIN_PROTOCOL_VERSION = 4 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 26b8fa5c066..a05b8044240 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -33,6 +33,9 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +/** + * Identity advertised during gateway connect; these fields become the device row users approve. + */ data class GatewayClientInfo( val id: String, val displayName: String?, @@ -44,6 +47,9 @@ data class GatewayClientInfo( val modelIdentifier: String?, ) +/** + * Role, scopes, commands, and permission snapshot sent with the connect frame. + */ data class GatewayConnectOptions( val role: String, val scopes: List, @@ -62,6 +68,9 @@ private enum class GatewayConnectAuthSource { NONE, } +/** + * Structured auth failure guidance from the gateway, preserved for reconnect and UI decisions. + */ data class GatewayConnectErrorDetails( val code: String?, val canRetryWithDeviceToken: Boolean, @@ -70,6 +79,9 @@ data class GatewayConnectErrorDetails( val reason: String? = null, ) +/** + * Server hello fields cached by the Android runtime after a successful connect. + */ data class GatewayHelloSummary( val serverName: String?, val remoteAddress: String?, @@ -99,6 +111,9 @@ private class GatewayConnectFailure( val gatewayError: GatewaySession.ErrorShape, ) : IllegalStateException(gatewayError.message) +/** + * WebSocket RPC session that maintains gateway connection lifecycle, auth, events, and node invokes. + */ class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, @@ -114,6 +129,9 @@ class GatewaySession( private const val CONNECT_RPC_TIMEOUT_MS = 12_000L } + /** + * Gateway node.invoke request routed to Android command handlers. + */ data class InvokeRequest( val id: String, val nodeId: String, @@ -143,6 +161,9 @@ class GatewaySession( val details: GatewayConnectErrorDetails? = null, ) + /** + * Structured RPC result used by callers that need error codes without exceptions. + */ data class RpcResult( val ok: Boolean, val payloadJson: String?, @@ -174,12 +195,15 @@ class GatewaySession( @Volatile private var currentConnection: Connection? = null + // One reconnect can retry a shared-token mismatch by pairing the shared token with the stored device token. @Volatile private var pendingDeviceTokenRetry = false + // Keep the mismatch retry single-shot so an invalid stored token cannot create an auth loop. @Volatile private var deviceTokenRetryBudgetUsed = false @Volatile private var reconnectPausedForAuthFailure = false + /** Starts or replaces the desired gateway connection and launches the reconnect loop. */ fun connect( endpoint: GatewayEndpoint, token: String?, @@ -202,6 +226,7 @@ class GatewaySession( connectionToClose?.closeQuietly() } + /** Clears desired connection state, closes the socket, and stops reconnect attempts. */ fun disconnect() { val jobToCancel: Job? val connectionToClose: Connection? @@ -225,6 +250,7 @@ class GatewaySession( } } + /** Forces the current socket closed so the loop reconnects to the current desired endpoint. */ fun reconnect() { reconnectPausedForAuthFailure = false currentConnection?.closeQuietly() @@ -232,6 +258,7 @@ class GatewaySession( fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"] + /** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */ suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? { val refreshed = refreshPluginSurfaceUrl( @@ -247,6 +274,7 @@ class GatewaySession( fun currentMainSessionKey(): String? = mainSessionKey + /** Sends a best-effort node.event and returns false instead of throwing on failure. */ suspend fun sendNodeEvent( event: String, payloadJson: String?, @@ -287,6 +315,7 @@ class GatewaySession( } } + /** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */ suspend fun sendNodeEventDetailed( event: String, payloadJson: String?, @@ -319,9 +348,11 @@ class GatewaySession( ): JsonObject = buildJsonObject { put("event", JsonPrimitive(event)) + // Gateway node events carry payloadJSON as a string for compatibility with non-JSON payload producers. put("payloadJSON", JsonPrimitive(payloadJson ?: "{}")) } + /** Sends an RPC request and throws a code-prefixed exception when the gateway returns an error. */ suspend fun request( method: String, paramsJson: String?, @@ -333,6 +364,7 @@ class GatewaySession( throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}") } + /** Sends an RPC request and returns the structured success/error payload. */ suspend fun requestDetailed( method: String, paramsJson: String?, @@ -349,6 +381,7 @@ class GatewaySession( return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error) } + /** Sends an RPC request frame and reports errors asynchronously through [onError]. */ suspend fun sendRequestFrame( method: String, paramsJson: String?, @@ -705,6 +738,7 @@ class GatewaySession( persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes) } if (shouldPersistBootstrapHandoffTokens(authSource)) { + // Bootstrap connects can mint role-specific device tokens; store only locally trusted handoffs. authObj ?.get("deviceTokens") .asArrayOrNull() @@ -725,6 +759,7 @@ class GatewaySession( val rawPluginSurfaceUrls = obj["pluginSurfaceUrls"].asObjectOrNull() val normalizedPluginSurfaceUrls = rawPluginSurfaceUrls?.mapNotNull { (surface, value) -> + // Canvas URLs may be loopback gateway metadata; normalize them to the reachable Android endpoint. normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null) ?.let { normalized -> surface to normalized } } ?: emptyList() @@ -797,6 +832,7 @@ class GatewaySession( val connectScopes = resolveConnectScopes(selectedAuth) val signedAtMs = System.currentTimeMillis() + // V3 signatures bind the auth token, nonce, role, and scopes so replayed connect frames fail. val payload = DeviceAuthPayload.buildV3( deviceId = identity.deviceId, @@ -966,6 +1002,7 @@ class GatewaySession( if (parsedPayload != null) { put("payload", parsedPayload) } else if (result.payloadJson != null) { + // Preserve malformed/non-object payloads as payloadJSON so the gateway can report handler output. put("payloadJSON", JsonPrimitive(result.payloadJson)) } result.error?.let { err -> @@ -1189,6 +1226,7 @@ class GatewaySession( if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false val detailCode = error.details?.code val recommendedNextStep = error.details?.recommendedNextStep + // New gateways set canRetryWithDeviceToken; older builds expose equivalent string codes. return error.details?.canRetryWithDeviceToken == true || recommendedNextStep == "retry_with_device_token" || detailCode == "AUTH_TOKEN_MISMATCH" @@ -1213,10 +1251,13 @@ class GatewaySession( tls: GatewayTlsParams?, ): Boolean { if (isLocalCleartextGatewayHost(endpoint.host)) return true + // Retrying a stored device token alongside a shared token is only safe for + // remote gateways when an existing TLS pin already identifies the endpoint. return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true } } +/** Decides whether auth failures should stop reconnect churn until the user changes credentials. */ internal fun shouldPauseGatewayReconnectAfterAuthFailure( error: GatewaySession.ErrorShape, hasBootstrapToken: Boolean, @@ -1249,6 +1290,7 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure( else -> false } +/** Builds the gateway WebSocket URL from endpoint authority and TLS policy. */ internal fun buildGatewayWebSocketUrl( host: String, port: Int, @@ -1258,6 +1300,7 @@ internal fun buildGatewayWebSocketUrl( return "$scheme://${formatGatewayAuthority(host, port)}" } +/** Formats host/port for gateway URLs, including IPv6 bracket wrapping. */ internal fun formatGatewayAuthority( host: String, port: Int, @@ -1308,6 +1351,7 @@ private fun parseJsonOrNull(payload: String): JsonElement? { } } +/** Keeps invoke-result ack waits inside the gateway-supported timeout window. */ internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long { val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L return normalized.coerceIn(15_000L, 120_000L) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt index 192f60285b9..877ce4229d2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt @@ -25,6 +25,7 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */ data class GatewayTlsParams( val required: Boolean, val expectedFingerprint: String?, @@ -32,22 +33,26 @@ data class GatewayTlsParams( val stableId: String, ) +/** SSL primitives installed into OkHttp when a gateway needs TLS pinning/TOFU. */ data class GatewayTlsConfig( val sslSocketFactory: SSLSocketFactory, val trustManager: X509TrustManager, val hostnameVerifier: HostnameVerifier, ) +/** Distinguishes non-TLS endpoints from unreachable endpoints during probing. */ enum class GatewayTlsProbeFailure { TLS_UNAVAILABLE, ENDPOINT_UNREACHABLE, } +/** Result of probing a gateway TLS endpoint for first-use fingerprint capture. */ data class GatewayTlsProbeResult( val fingerprintSha256: String? = null, val failure: GatewayTlsProbeFailure? = null, ) +/** Builds a TLS config that supports pinned fingerprints and trust-on-first-use. */ fun buildGatewayTlsConfig( params: GatewayTlsParams?, onStore: ((String) -> Unit)? = null, @@ -82,6 +87,9 @@ fun buildGatewayTlsConfig( return } if (params.allowTOFU) { + // Store only after the TLS stack presents a concrete server cert; the + // caller persists the fingerprint against the endpoint's stable id, + // and later connects must come back through the pinned branch above. onStore?.invoke(fingerprint) return } @@ -107,6 +115,7 @@ fun buildGatewayTlsConfig( ) } +/** Connects with a probe trust manager that captures the presented cert hash. */ suspend fun probeGatewayTlsFingerprint( host: String, port: Int, @@ -132,6 +141,7 @@ suspend fun probeGatewayTlsFingerprint( ) { if (chain.isEmpty()) throw CertificateException("empty certificate chain") fingerprintRef.set(sha256Hex(chain[0].encoded)) + // Abort validation after capture; the probe is not deciding trust. throw CertificateException("gateway TLS probe captured fingerprint") } @@ -154,7 +164,8 @@ suspend fun probeGatewayTlsFingerprint( socket.sslParameters = params } } catch (_: Throwable) { - // ignore + // SNI is only a probe hint. IP literals and odd Bonjour names should + // still be probed instead of failing before the TLS handshake. } socket.startHandshake() @@ -203,6 +214,7 @@ private fun sha256Hex(data: ByteArray): String { return out.toString() } +/** Normalizes user-visible fingerprint text to lowercase bare SHA-256 hex. */ fun normalizeGatewayTlsFingerprint(raw: String): String { val stripped = raw diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt index dae516a901c..5cda3235fb5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt @@ -5,10 +5,15 @@ data class ParsedInvokeError( val message: String, val hadExplicitCode: Boolean, ) { + /** Gateway-facing form expected by UI and retry copy. */ val prefixedMessage: String get() = "$code: $message" } +/** + * Parses gateway invoke errors encoded as CODE: message while preserving legacy + * plain-text errors as UNAVAILABLE. + */ fun parseInvokeErrorMessage(raw: String): ParsedInvokeError { val trimmed = raw.trim() if (trimmed.isEmpty()) { @@ -30,6 +35,7 @@ fun parseInvokeErrorMessage(raw: String): ParsedInvokeError { return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false) } +/** Extracts an invoke error from a throwable without exposing blank messages. */ fun parseInvokeErrorFromThrowable( err: Throwable, fallbackMessage: String = "error", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt index d225f2a4406..505c143be8b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt @@ -6,6 +6,9 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +/** + * Android bridge for applying gateway A2UI messages to the canvas WebView. + */ class A2UIHandler( private val canvas: CanvasController, private val json: Json, @@ -21,6 +24,7 @@ class A2UIHandler( fun resolveA2uiHostUrl(): String? { val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty() val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty() + // Prefer node-advertised canvas host; operator URL is a fallback for older hello payloads. val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw if (raw.isBlank()) return null val base = raw.trimEnd('/') @@ -36,6 +40,7 @@ class A2UIHandler( } canvas.navigate(a2uiUrl) + // A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command. repeat(50) { try { val ready = canvas.eval(a2uiReadyCheckJS) @@ -65,6 +70,7 @@ class A2UIHandler( if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) { val jsonl = jsonlField if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") + // JSONL keeps large A2UI streams model-friendly while still validating each message. val messages = jsonl .lineSequence() @@ -98,6 +104,7 @@ class A2UIHandler( lineNumber: Int, ) { if (msg.containsKey("createSurface")) { + // Android scaffold currently implements A2UI v0.8, not the v0.9 createSurface shape. throw IllegalArgumentException( "A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.", ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt index 13628c5d22c..9e6daae6d50 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt @@ -20,12 +20,18 @@ import java.util.TimeZone private const val DEFAULT_CALENDAR_LIMIT = 50 +/** + * Parsed calendar.events request; times are epoch millis for CalendarContract queries. + */ internal data class CalendarEventsRequest( val startMs: Long, val endMs: Long, val limit: Int, ) +/** + * Parsed calendar.add request before resolving the target Android calendar. + */ internal data class CalendarAddRequest( val title: String, val startMs: Long, @@ -37,6 +43,9 @@ internal data class CalendarAddRequest( val calendarTitle: String?, ) +/** + * Normalized calendar event returned through gateway calendar commands. + */ internal data class CalendarEventRecord( val identifier: String, val title: String, @@ -47,6 +56,9 @@ internal data class CalendarEventRecord( val calendarTitle: String?, ) +/** + * Injectable CalendarProvider facade for command tests and Android runtime access. + */ internal interface CalendarDataSource { fun hasReadPermission(context: Context): Boolean @@ -78,6 +90,7 @@ private object SystemCalendarDataSource : CalendarDataSource { ): List { val resolver = context.contentResolver val builder = CalendarContract.Instances.CONTENT_URI.buildUpon() + // Instances expands recurring events inside the requested time window. ContentUris.appendId(builder, request.startMs) ContentUris.appendId(builder, request.endMs) val projection = @@ -155,10 +168,12 @@ private object SystemCalendarDataSource : CalendarDataSource { calendarTitle: String?, ): Long { if (calendarId != null) { + // Explicit id wins over title/default selection and must already exist. if (calendarExists(resolver, calendarId)) return calendarId throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId") } if (!calendarTitle.isNullOrEmpty()) { + // Title lookup is exact to avoid adding events to a similarly named calendar. findCalendarByTitle(resolver, calendarTitle)?.let { return it } throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle") } @@ -209,6 +224,7 @@ private object SystemCalendarDataSource : CalendarDataSource { projection, "${CalendarContract.Calendars.VISIBLE}=1", null, + // Prefer Android's primary visible calendar, then lowest id for deterministic fallback. "${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC", ).use { cursor -> if (cursor == null || !cursor.moveToFirst()) return null @@ -342,6 +358,7 @@ class CalendarHandler private constructor( if (paramsJson.isNullOrBlank()) { val start = Instant.now() val end = start.plus(7, ChronoUnit.DAYS) + // Default calendar read is a one-week window, not the full calendar store. return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT) } val params = @@ -354,6 +371,7 @@ class CalendarHandler private constructor( val end = parseISO((params["endISO"] as? JsonPrimitive)?.content) val resolvedStart = start ?: Instant.now() val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS) + // Keep model-driven calendar reads bounded. val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500) return CalendarEventsRequest( startMs = resolvedStart.toEpochMilli(), @@ -390,6 +408,7 @@ class CalendarHandler private constructor( private fun parseISO(raw: String?): Instant? { val value = raw?.trim().orEmpty() if (value.isEmpty()) return null + // Gateway calendar payloads use UTC ISO-8601 instants for unambiguous Android storage. return try { Instant.parse(value) } catch (_: Throwable) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt index 8d857166282..e50542d0d95 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt @@ -41,19 +41,25 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.math.roundToInt +/** + * CameraX-backed capture service used by gateway camera commands. + */ class CameraCaptureManager( private val context: Context, ) { + /** Base64 JSON response for camera.snap after resize and JPEG budget enforcement. */ data class Payload( val payloadJson: String, ) + /** Temporary MP4 response for camera.clip before CameraHandler validates invoke size. */ data class FilePayload( val file: File, val durationMs: Long, val hasAudio: Boolean, ) + /** Camera device metadata exposed through camera.list. */ data class CameraDeviceInfo( val id: String, val name: String, @@ -65,14 +71,19 @@ class CameraCaptureManager( @Volatile private var permissionRequester: PermissionRequester? = null + /** Supplies the foreground Activity lifecycle required by CameraX use-case binding. */ fun attachLifecycleOwner(owner: LifecycleOwner) { + // CameraX binds use cases to an Activity lifecycle; background services cannot capture alone. lifecycleOwner = owner } + /** Supplies the Activity-owned permission launcher used by camera and microphone commands. */ fun attachPermissionRequester(requester: PermissionRequester) { + // Permission prompts must be launched by the Activity that owns the ActivityResult registry. permissionRequester = requester } + /** Lists CameraX devices with stable Camera2 ids where available. */ suspend fun listDevices(): List = withContext(Dispatchers.Main) { val provider = context.cameraProvider() @@ -107,6 +118,7 @@ class CameraCaptureManager( } } + /** Captures one still image and returns a gateway-sized JPEG payload. */ suspend fun snap(paramsJson: String?): Payload = withContext(Dispatchers.Main) { ensureCameraPermission() @@ -122,6 +134,7 @@ class CameraCaptureManager( val selector = resolveCameraSelector(provider, facing, deviceId) provider.unbindAll() + // Bind only the still capture use case; CameraX owns camera open/close through the lifecycle owner. provider.bindToLifecycle(owner, selector, capture) val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir) @@ -179,6 +192,7 @@ class CameraCaptureManager( } } + /** Records a short MP4 clip into a temporary cache file for the caller to encode/delete. */ @SuppressLint("MissingPermission") suspend fun clip(paramsJson: String?): FilePayload = withContext(Dispatchers.Main) { @@ -303,6 +317,7 @@ class CameraCaptureManager( orientation: Int, ): Bitmap { val matrix = Matrix() + // CameraX JPEG bytes keep sensor orientation in EXIF; normalize before resizing/encoding. when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) @@ -365,6 +380,7 @@ class CameraCaptureManager( } return CameraSelector .Builder() + // CameraX selectors are filters over CameraInfo; pin by Camera2 id for stable device selection. .addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } } .build() } @@ -419,7 +435,9 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider = ) } -/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */ +/** + * Returns JPEG bytes plus EXIF orientation so callers can normalize the decoded bitmap. + */ private suspend fun ImageCapture.takeJpegWithExif( executor: Executor, tempDir: File, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt index ba3b9c95826..6ada672e9f1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt @@ -16,8 +16,14 @@ import kotlinx.serialization.json.put internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L +/** + * Raw MP4 size guard before base64 encoding the clip into a node.invoke response. + */ internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES +/** + * Gateway camera command adapter that adds HUD feedback and payload-size enforcement. + */ class CameraHandler( private val appContext: Context, private val camera: CameraCaptureManager, @@ -26,6 +32,7 @@ class CameraHandler( private val triggerCameraFlash: () -> Unit, private val invokeErrorFromThrowable: (err: Throwable) -> Pair, ) { + /** Handles camera.list by exposing CameraX devices through gateway metadata. */ suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult = try { val devices = camera.listDevices() @@ -53,6 +60,7 @@ class CameraHandler( GatewaySession.InvokeResult.error(code = code, message = message) } + /** Handles camera.snap with HUD progress, flash feedback, and normalized invoke errors. */ suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult { val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null @@ -92,6 +100,7 @@ class CameraHandler( } } + /** Handles camera.clip and keeps external audio capture paused while camera audio is active. */ suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult { val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null @@ -124,6 +133,7 @@ class CameraHandler( val rawBytes = filePayload.file.length() if (!isCameraClipWithinPayloadLimit(rawBytes)) { clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES") + // Delete oversized clips before returning so cache files do not accumulate after failed invokes. withContext(Dispatchers.IO) { filePayload.file.delete() } showCameraHud("Clip too large", CameraHudKind.Error, 2400) return GatewaySession.InvokeResult.error( @@ -152,6 +162,7 @@ class CameraHandler( clipLog("stack: ${err.stackTraceToString().take(2000)}") return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed") } finally { + // Prevent talk/transcription capture from competing with camera audio after every exit path. if (includeAudio) externalAudioCaptureActive.value = false } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt index f6670a62e2f..6ff6f821d7c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt @@ -2,9 +2,14 @@ package ai.openclaw.app.node import java.net.URI +/** + * Trust helper for WebView-originated canvas/A2UI actions. + */ object CanvasActionTrust { + /** Local canvas scaffold is the only trusted file URL. */ const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html" + /** Accepts local scaffold or exact remote A2UI URLs advertised by the gateway. */ fun isTrustedCanvasActionUrl( rawUrl: String?, trustedA2uiUrls: List, @@ -28,11 +33,14 @@ object CanvasActionTrust { candidateUri: URI, trustedUrl: String, ): Boolean { + // Gateway-advertised URLs are capabilities. Treat malformed entries as + // absent instead of broadening trust to same-origin or prefix matches. val trustedUri = parseUri(trustedUrl) ?: return false val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false return candidateUri == normalizedTrusted } + /** Normalizes only the URL parts allowed to vary across trusted remote A2UI URLs. */ private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? { // Keep Android trust normalization aligned with iOS ScreenController: // exact remote URL match, scheme/host normalized, fragment ignored. @@ -52,6 +60,7 @@ object CanvasActionTrust { } } + /** Parses untrusted WebView/gateway URL text without throwing into UI event handlers. */ private fun parseUri(raw: String): URI? = try { URI(raw) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt index bb4fbe37435..120220f92f6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt @@ -23,6 +23,9 @@ import org.json.JSONObject import java.io.ByteArrayOutputStream import kotlin.coroutines.resume +/** + * Owns the Android WebView canvas surface used by canvas and A2UI commands. + */ class CanvasController { enum class SnapshotFormat( val rawValue: String, @@ -60,19 +63,23 @@ class CanvasController { return scale(maxWidth, scaledHeight) } + /** Attaches the active WebView and replays state that may have arrived before the view existed. */ fun attach(webView: WebView) { this.webView = webView + // Replay persisted state because WebView attachment can happen after gateway events arrive. reload() applyDebugStatus() applyHomeCanvasState() } + /** Detaches only the currently attached WebView instance. */ fun detach(webView: WebView) { if (this.webView === webView) { this.webView = null } } + /** Navigates the canvas to a remote URL or back to the bundled scaffold for blank/root input. */ fun navigate(url: String) { val trimmed = url.trim() this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed @@ -113,6 +120,7 @@ class CanvasController { if (Looper.myLooper() == Looper.getMainLooper()) { block(wv) } else { + // WebView APIs must run on the main thread. wv.post { block(wv) } } } @@ -178,6 +186,7 @@ class CanvasController { } } + /** Evaluates JavaScript against the attached WebView on the main thread. */ suspend fun eval(javaScript: String): String = withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") @@ -206,6 +215,7 @@ class CanvasController { } } + /** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */ suspend fun snapshotBase64( format: SnapshotFormat, quality: Double?, @@ -246,17 +256,22 @@ class CanvasController { } companion object { + /** + * Parsed canvas.snapshot options used by invoke dispatch. + */ data class SnapshotParams( val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?, ) + /** Parses canvas.navigate params and returns blank when the payload is missing or invalid. */ fun parseNavigateUrl(paramsJson: String?): String { val obj = parseParamsObject(paramsJson) ?: return "" return obj.string("url").trim() } + /** Parses non-blank JavaScript from canvas.eval params. */ fun parseEvalJs(paramsJson: String?): String? { val obj = parseParamsObject(paramsJson) ?: return null val js = obj.string("javaScript").trim() @@ -286,9 +301,11 @@ class CanvasController { if (!obj.containsKey("quality")) return null val q = obj.double("quality") ?: Double.NaN if (!q.isFinite()) return null + // Keep JPEG quality inside encoder-safe bounds; PNG ignores it. return q.coerceIn(0.1, 1.0) } + /** Parses canvas.snapshot params using JPEG defaults and encoder-safe bounds. */ fun parseSnapshotParams(paramsJson: String?): SnapshotParams = SnapshotParams( format = parseSnapshotFormat(paramsJson), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 87caad13b64..79b74fc78d3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -12,6 +12,9 @@ import ai.openclaw.app.gateway.isLocalCleartextGatewayHost import ai.openclaw.app.gateway.isLoopbackGatewayHost import android.os.Build +/** + * Builds gateway connect metadata from current Android permissions, settings, and device identity. + */ class ConnectionManager( private val prefs: SecurePrefs, private val cameraEnabled: () -> Boolean, @@ -28,6 +31,9 @@ class ConnectionManager( private val manualTls: () -> Boolean, ) { companion object { + /** + * Decide whether a discovered/manual endpoint must use pinned TLS or can stay local cleartext. + */ internal fun resolveTlsParamsForEndpoint( endpoint: GatewayEndpoint, storedFingerprint: String?, @@ -44,6 +50,7 @@ class ConnectionManager( } if (isManual) { + // Manual remote hosts default to TLS; only local manual hosts may honor the cleartext toggle. if (!manualTlsEnabled && cleartextAllowedHost) return null if (!stored.isNullOrBlank()) { return GatewayTlsParams( @@ -83,6 +90,7 @@ class ConnectionManager( } if (!cleartextAllowedHost) { + // Non-loopback discovered hosts require TLS even without TXT hints. return GatewayTlsParams( required = true, expectedFingerprint = null, @@ -110,10 +118,15 @@ class ConnectionManager( debugBuild = BuildConfig.DEBUG, ) + /** Builds the gateway-advertised node.invoke command list from current permission and feature state. */ fun buildInvokeCommands(): List = InvokeCommandRegistry.advertisedCommands(runtimeFlags()) + /** Builds the gateway-advertised capability list from current permission and feature state. */ fun buildCapabilities(): List = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags()) + /** + * Debug Android builds advertise a dev version so gateway logs do not look like release clients. + */ fun resolvedVersionName(): String { val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { @@ -123,12 +136,16 @@ class ConnectionManager( } } + /** Human-readable Android device model used in gateway client metadata. */ fun resolveModelIdentifier(): String? = listOfNotNull(Build.MANUFACTURER, Build.MODEL) .joinToString(" ") .trim() .ifEmpty { null } + /** + * User-Agent used for gateway telemetry and troubleshooting. + */ fun buildUserAgent(): String { val version = resolvedVersionName() val release = @@ -139,6 +156,7 @@ class ConnectionManager( return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})" } + /** Client identity block shared by node and operator gateway sessions. */ fun buildClientInfo( clientId: String, clientMode: String, @@ -154,6 +172,7 @@ class ConnectionManager( modelIdentifier = resolveModelIdentifier(), ) + /** Connect options for the Android node session that exposes phone capabilities. */ fun buildNodeConnectOptions(): GatewayConnectOptions = GatewayConnectOptions( role = "node", @@ -165,6 +184,7 @@ class ConnectionManager( userAgent = buildUserAgent(), ) + /** Connect options for the Android operator session that drives approvals and UI actions. */ fun buildOperatorConnectOptions(): GatewayConnectOptions = GatewayConnectOptions( role = "operator", @@ -181,6 +201,7 @@ class ConnectionManager( userAgent = buildUserAgent(), ) + /** Resolves persisted TLS pin policy for a concrete gateway endpoint. */ fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls()) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt index 3e6eea37210..2a6b1dea504 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt @@ -17,6 +17,9 @@ import kotlinx.serialization.json.put private const val DEFAULT_CONTACTS_LIMIT = 25 +/** + * Normalized Android contact row returned through the contacts commands. + */ internal data class ContactRecord( val identifier: String, val displayName: String, @@ -27,11 +30,17 @@ internal data class ContactRecord( val emails: List, ) +/** + * Parsed contacts.search request with bounded result count. + */ internal data class ContactsSearchRequest( val query: String?, val limit: Int, ) +/** + * Parsed contacts.add request before ContentProviderOperation batching. + */ internal data class ContactsAddRequest( val givenName: String?, val familyName: String?, @@ -41,6 +50,9 @@ internal data class ContactsAddRequest( val emails: List, ) +/** + * Injectable ContactsProvider facade for command tests and Android runtime access. + */ internal interface ContactsDataSource { fun hasReadPermission(context: Context): Boolean @@ -82,6 +94,7 @@ private object SystemContactsDataSource : ContactsDataSource { selection = null selectionArgs = null } else { + // Escape wildcard characters so user text remains a substring search, not a LIKE pattern. selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'" selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%") } @@ -119,6 +132,7 @@ private object SystemContactsDataSource : ContactsDataSource { .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null) .build() + // Subsequent Data rows use back-reference 0 to attach to the RawContact inserted above. if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) { operations += ContentProviderOperation @@ -168,6 +182,7 @@ private object SystemContactsDataSource : ContactsDataSource { rawContactUri.lastPathSegment?.toLongOrNull() ?: throw IllegalStateException("contact insert failed") val contactId = + // Android returns the RawContact id; resolve the aggregate Contact id used by search APIs. resolveContactIdForRawContact(resolver, rawContactId) ?: throw IllegalStateException("contact insert failed") return loadContactRecord( @@ -330,12 +345,16 @@ private object SystemContactsDataSource : ContactsDataSource { } } +/** + * Handles contacts.search and contacts.add gateway commands through Android ContactsProvider. + */ class ContactsHandler private constructor( private val appContext: Context, private val dataSource: ContactsDataSource, ) { constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource) + /** Searches contacts by optional display-name substring with bounded result count. */ fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult { if (!dataSource.hasReadPermission(appContext)) { return GatewaySession.InvokeResult.error( @@ -369,6 +388,7 @@ class ContactsHandler private constructor( } } + /** Adds a local contact after validating that at least one user-visible field is present. */ fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult { if (!dataSource.hasWritePermission(appContext)) { return GatewaySession.InvokeResult.error( @@ -418,6 +438,7 @@ class ContactsHandler private constructor( null } ?: return null val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null } + // Keep gateway-driven searches bounded even if the model asks for a large contact dump. val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200) return ContactsSearchRequest(query = query, limit = limit) } @@ -435,6 +456,7 @@ class ContactsHandler private constructor( organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }, displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }, phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray), + // Store emails case-normalized so repeated model calls do not create casing-only duplicates. emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() }, ) } @@ -458,6 +480,7 @@ class ContactsHandler private constructor( } companion object { + /** Creates a handler with an injected contacts source for parser and payload tests. */ internal fun forTesting( appContext: Context, dataSource: ContactsDataSource, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt index 8faa9daf4a1..b1025507c5c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt @@ -8,15 +8,21 @@ import kotlinx.serialization.json.JsonPrimitive private const val LOGCAT_PATH = "/system/bin/logcat" +/** + * Debug-only node.invoke commands for Android cryptography and log diagnostics. + */ class DebugHandler( private val appContext: Context, private val identityStore: DeviceIdentityStore, ) { + /** + * Runs an Ed25519 self-test and returns redacted diagnostics for debug builds. + */ fun handleEd25519(): GatewaySession.InvokeResult { if (!BuildConfig.DEBUG) { return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds") } - // Self-test Ed25519 signing and return diagnostic info + // Self-test Ed25519 signing without returning full private/public key material. try { val identity = identityStore.loadOrCreate() val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}" @@ -25,15 +31,14 @@ class DebugHandler( results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...") results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...") - // Test publicKeyBase64Url + // Public-key URL encoding must match the gateway device-auth payload contract. val pubKeyUrl = identityStore.publicKeyBase64Url(identity) results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}") - // Test signing + // Sign/verify through DeviceIdentityStore to catch provider and key-format failures together. val signature = identityStore.signPayload(testPayload, identity) results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}") - // Test self-verify if (signature != null) { val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity) results.add("verifySelfSignature: $verifyOk") @@ -74,6 +79,9 @@ class DebugHandler( } } + /** + * Returns a filtered logcat snapshot plus CameraX debug log for debug builds. + */ fun handleLogs(): GatewaySession.InvokeResult { if (!BuildConfig.DEBUG) { return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds") @@ -81,7 +89,7 @@ class DebugHandler( val pid = android.os.Process.myPid() val rt = Runtime.getRuntime() val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n" - // Run logcat on current dispatcher thread (no withContext) with file redirect + // Capture only this process and redirect through a temp file to avoid blocking on pipe backpressure. val logResult = try { val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt") @@ -123,6 +131,7 @@ class DebugHandler( if (line.isBlank()) continue if (spamPatterns.any { line.contains(it) }) continue if (sb.length + line.length > 16000) { + // Keep debug.invoke responses small enough for the gateway WebSocket frame budget. sb.append("\n(truncated)") break } @@ -133,7 +142,7 @@ class DebugHandler( } catch (e: Throwable) { "(logcat error: ${e::class.java.simpleName}: ${e.message})" } - // Also include camera debug log if it exists + // Camera capture writes a separate debug file because CameraX failures often happen off logcat's hot path. val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log") val camLog = if (camLogFile.exists() && camLogFile.length() > 0) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index 3bf3d1e08b2..06d97b69252 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -24,6 +24,9 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.util.Locale +/** + * Gateway device command adapter for Android status, info, permission, and health snapshots. + */ class DeviceHandler( private val appContext: Context, private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, @@ -31,6 +34,9 @@ class DeviceHandler( private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, ) { companion object { + /** + * SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align. + */ internal fun hasAnySmsCapability( smsEnabled: Boolean, telephonyAvailable: Boolean, @@ -38,6 +44,9 @@ class DeviceHandler( smsReadGranted: Boolean, ): Boolean = smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted) + /** + * Prompt only when Android can grant a missing SMS permission that this build can use. + */ internal fun isSmsPromptable( smsEnabled: Boolean, telephonyAvailable: Boolean, @@ -53,12 +62,16 @@ class DeviceHandler( val temperatureC: Double?, ) + /** Returns battery, storage, network, and uptime state for device.status. */ fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(statusPayloadJson()) + /** Returns stable Android hardware, OS, app, and locale metadata for device.info. */ fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(infoPayloadJson()) + /** Returns permission and promptability state for Android capabilities exposed to the gateway. */ fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(permissionsPayloadJson()) + /** Returns coarse device health for memory, power, thermal, battery, and security patch state. */ fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson()) private fun statusPayloadJson(): String { @@ -71,6 +84,7 @@ class DeviceHandler( val connectivity = appContext.getSystemService(ConnectivityManager::class.java) val activeNetwork = connectivity?.activeNetwork val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) } + // elapsedRealtime is monotonic device uptime, not wall-clock time. val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0 return buildJsonObject { @@ -154,6 +168,7 @@ class DeviceHandler( if (!photosEnabled) { false } else if (Build.VERSION.SDK_INT >= 33) { + // Android 13 split media permissions; earlier versions use external storage. hasPermission(Manifest.permission.READ_MEDIA_IMAGES) } else { hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -161,6 +176,7 @@ class DeviceHandler( val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION) val notificationsGranted = if (Build.VERSION.SDK_INT >= 33) { + // POST_NOTIFICATIONS exists only on Android 13+. hasPermission(Manifest.permission.POST_NOTIFICATIONS) } else { true @@ -295,6 +311,7 @@ class DeviceHandler( if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) { null } else { + // BatteryManager reports microamps; expose milliamps in the gateway payload. currentNowUa.toDouble() / 1_000.0 } @@ -349,6 +366,7 @@ class DeviceHandler( } private fun readBatterySnapshot(): BatterySnapshot { + // ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot. val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) @@ -410,6 +428,7 @@ class DeviceHandler( if (caps == null) return "unsatisfied" return when { caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied" + // Internet without validation mirrors iOS "requiresConnection" for captive or unproven networks. caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection" else -> "unsatisfied" } @@ -436,6 +455,7 @@ class DeviceHandler( if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown" if (lowMemory) return "critical" val freeRatio = availableBytes.toDouble() / totalBytes.toDouble() + // Thresholds intentionally mirror coarse OS health labels instead of exact memory pressure. return when { freeRatio <= 0.05 -> "critical" freeRatio <= 0.15 -> "high" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt index a5409f095e8..bacb99f81a0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt @@ -21,11 +21,18 @@ import kotlinx.serialization.json.put private const val MAX_NOTIFICATION_TEXT_CHARS = 512 private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed" +/** + * Trims notification text and caps payload size before it enters gateway-visible state. + */ internal fun sanitizeNotificationText(value: CharSequence?): String? { val normalized = value?.toString()?.trim().orEmpty() + // Notification extras can include long previews; cap before sending over node events. return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null } } +/** + * Stable notification snapshot entry exposed through the Android notifications command. + */ data class DeviceNotificationEntry( val key: String, val packageName: String, @@ -53,24 +60,36 @@ internal fun DeviceNotificationEntry.toJsonObject(): JsonObject = channelId?.let { put("channelId", JsonPrimitive(it)) } } +/** + * Listener state exposed to the gateway, including whether Android has connected the service. + */ data class DeviceNotificationSnapshot( val enabled: Boolean, val connected: Boolean, val notifications: List, ) +/** + * Gateway-supported notification actions mapped to Android listener operations. + */ enum class NotificationActionKind { Open, Dismiss, Reply, } +/** + * Gateway action request; [key] must match Android's StatusBarNotification key. + */ data class NotificationActionRequest( val key: String, val kind: NotificationActionKind, val replyText: String? = null, ) +/** + * Normalized notification action result returned through node.invoke. + */ data class NotificationActionResult( val ok: Boolean, val code: String? = null, @@ -79,6 +98,9 @@ data class NotificationActionResult( internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean = kind == NotificationActionKind.Dismiss +/** + * Process-local cache of active notifications mirrored from Android listener callbacks. + */ private object DeviceNotificationStore { private val lock = Any() private var connected = false @@ -109,6 +131,7 @@ private object DeviceNotificationStore { synchronized(lock) { connected = value if (!value) { + // Android invalidates activeNotifications when the listener disconnects. byKey.clear() } } @@ -127,6 +150,9 @@ private object DeviceNotificationStore { } } +/** + * Android notification listener that mirrors notification state and executes gateway actions. + */ class DeviceNotificationListenerService : NotificationListenerService() { private val securePrefs by lazy { SecurePrefs(applicationContext) } private val forwardingLimiter = NotificationBurstLimiter() @@ -226,6 +252,7 @@ class DeviceNotificationListenerService : NotificationListenerService() { if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) { return null } + // Apply burst limits after package/quiet-hour filters so blocked notifications do not consume quota. if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) { return null } @@ -288,6 +315,7 @@ class DeviceNotificationListenerService : NotificationListenerService() { private fun serviceComponent(context: Context): ComponentName = ComponentName(context, DeviceNotificationListenerService::class.java) + /** Installs the node event sink used to emit filtered notification change events. */ fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) { nodeEventSink = sink } @@ -299,6 +327,7 @@ class DeviceNotificationListenerService : NotificationListenerService() { val hasNew = prefs.contains(recentPackagesPref) val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty() if (!hasNew && legacy.isNotEmpty()) { + // Keep recent package suggestions across the preference-key rename. prefs.edit { putString(recentPackagesPref, legacy) remove(legacyRecentPackagesPref) @@ -308,6 +337,7 @@ class DeviceNotificationListenerService : NotificationListenerService() { } } + /** Returns recent third-party packages seen by the listener for settings suggestions. */ fun recentPackages(context: Context): List { migrateLegacyRecentPackagesIfNeeded(context) val prefs = recentPackagesPrefs(context) @@ -319,22 +349,26 @@ class DeviceNotificationListenerService : NotificationListenerService() { .distinct() } + /** Checks whether Android has granted listener access to this service component. */ fun isAccessEnabled(context: Context): Boolean { val manager = context.getSystemService(NotificationManager::class.java) ?: return false return manager.isNotificationListenerAccessGranted(serviceComponent(context)) } + /** Reads the current mirrored notification snapshot without forcing service startup. */ fun snapshot( context: Context, enabled: Boolean = isAccessEnabled(context), ): DeviceNotificationSnapshot = DeviceNotificationStore.snapshot(enabled = enabled) + /** Asks Android to rebind the listener after settings grant access but callbacks have not arrived. */ fun requestServiceRebind(context: Context) { runCatching { NotificationListenerService.requestRebind(serviceComponent(context)) } } + /** Executes an open, dismiss, or reply action through the active listener instance. */ fun executeAction( context: Context, request: NotificationActionRequest, @@ -376,6 +410,7 @@ class DeviceNotificationListenerService : NotificationListenerService() { .map { it.trim() } .filter { it.isNotEmpty() && it != normalized } .take(recentPackagesLimit - 1) + // Most recent package first keeps settings suggestions useful without storing notification content. val updated = listOf(normalized) + existing prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) } } @@ -449,6 +484,7 @@ class DeviceNotificationListenerService : NotificationListenerService() { val action = sbn.notification.actions ?.firstOrNull { candidate -> + // Android reply actions are identified by RemoteInput, not by a stable action title. candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty() } ?: return NotificationActionResult( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt index ebfd01b9253..5f0af044bbb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt @@ -9,6 +9,9 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +/** + * Handles gateway-originated events that need to update local Android preferences. + */ class GatewayEventHandler( private val scope: CoroutineScope, private val prefs: SecurePrefs, @@ -19,12 +22,14 @@ class GatewayEventHandler( private var suppressWakeWordsSync = false private var wakeWordsSyncJob: Job? = null + /** Applies gateway wake words locally without echoing the same change back to the gateway. */ fun applyWakeWordsFromGateway(words: List) { suppressWakeWordsSync = true prefs.setWakeWords(words) suppressWakeWordsSync = false } + /** Debounces local wake-word edits before sending voicewake.set to the operator session. */ fun scheduleWakeWordsSyncIfNeeded() { if (suppressWakeWordsSync) return if (!isConnected()) return @@ -44,6 +49,7 @@ class GatewayEventHandler( } } + /** Loads gateway wake words on connect so Android settings show server truth. */ suspend fun refreshWakeWordsFromGateway() { if (!isConnected()) return try { @@ -57,6 +63,7 @@ class GatewayEventHandler( } } + /** Applies voicewake.changed event payloads emitted by the gateway. */ fun handleVoiceWakeChangedEvent(payloadJson: String?) { if (payloadJson.isNullOrBlank()) return try { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt index 9c3578a2ee2..b2cc8d6ae49 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt @@ -16,6 +16,7 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand import ai.openclaw.app.protocol.OpenClawSystemCommand import ai.openclaw.app.protocol.OpenClawTalkCommand +/** Runtime feature flags used to decide which node tools are advertised. */ data class NodeRuntimeFlags( val cameraEnabled: Boolean, val locationEnabled: Boolean, @@ -30,6 +31,7 @@ data class NodeRuntimeFlags( val debugBuild: Boolean, ) +/** Per-command availability gates checked before advertising invoke methods. */ enum class InvokeCommandAvailability { Always, CameraEnabled, @@ -44,6 +46,7 @@ enum class InvokeCommandAvailability { DebugBuild, } +/** Per-capability availability gates for the node capabilities manifest. */ enum class NodeCapabilityAvailability { Always, CameraEnabled, @@ -55,11 +58,13 @@ enum class NodeCapabilityAvailability { MotionAvailable, } +/** Capability entry reported to the gateway when its availability gate passes. */ data class NodeCapabilitySpec( val name: String, val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always, ) +/** Invoke method entry advertised to gateway plus foreground routing metadata. */ data class InvokeCommandSpec( val name: String, val requiresForeground: Boolean = false, @@ -67,6 +72,7 @@ data class InvokeCommandSpec( ) object InvokeCommandRegistry { + /** Capabilities mirror gateway protocol ids and are filtered by device state. */ val capabilityManifest: List = listOf( NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue), @@ -106,6 +112,7 @@ object InvokeCommandRegistry { ), ) + /** Complete Android node command catalog before runtime availability filtering. */ val all: List = listOf( InvokeCommandSpec( @@ -240,8 +247,10 @@ object InvokeCommandRegistry { private val byNameInternal: Map = all.associateBy { it.name } + /** Finds the command metadata used by dispatch and advertised-method builders. */ fun find(command: String): InvokeCommandSpec? = byNameInternal[command] + /** Returns gateway capability ids the current Android device can actually serve. */ fun advertisedCapabilities(flags: NodeRuntimeFlags): List = capabilityManifest .filter { spec -> @@ -257,6 +266,7 @@ object InvokeCommandRegistry { } }.map { it.name } + /** Returns gateway invoke method ids available under current permissions/build flags. */ fun advertisedCommands(flags: NodeRuntimeFlags): List = all .filter { spec -> diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index ed7641b5b4c..15569f4f364 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -15,12 +15,16 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand import ai.openclaw.app.protocol.OpenClawSystemCommand import ai.openclaw.app.protocol.OpenClawTalkCommand +/** Runtime state for SMS search, split so permission prompts are not reported as hard unavailability. */ internal enum class SmsSearchAvailabilityReason { Available, PermissionRequired, Unavailable, } +/** + * Distinguish permanent SMS search unavailability from permission-gated search. + */ internal fun classifySmsSearchAvailability( readSmsAvailable: Boolean, smsFeatureEnabled: Boolean, @@ -53,6 +57,9 @@ internal fun smsSearchAvailabilityError( ) } +/** + * Gateway node.invoke command router for Android-owned capabilities. + */ class InvokeDispatcher( private val canvas: CanvasController, private val cameraHandler: CameraHandler, @@ -85,6 +92,7 @@ class InvokeDispatcher( private val motionActivityAvailable: () -> Boolean, private val motionPedometerAvailable: () -> Boolean, ) { + /** Dispatches one gateway node.invoke command after foreground and availability gates pass. */ suspend fun handleInvoke( command: String, paramsJson: String?, @@ -96,6 +104,7 @@ class InvokeDispatcher( message = "INVALID_REQUEST: unknown command", ) if (spec.requiresForeground && !isForeground()) { + // Canvas, camera, and screen-backed commands need an active Activity/WebView surface. return GatewaySession.InvokeResult.error( code = "NODE_BACKGROUND_UNAVAILABLE", message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", @@ -103,6 +112,7 @@ class InvokeDispatcher( } availabilityError(spec.availability)?.let { return it } + // Command strings come from OpenClawProtocolConstants; the registry above owns advertised availability. return when (command) { // Canvas commands OpenClawCanvasCommand.Present.rawValue -> { @@ -239,6 +249,7 @@ class InvokeDispatcher( ) val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl) if (!readyOnFirstCheck) { + // Gateway canvas host metadata can lag reconnects; refresh once before failing the command. refreshCanvasHostUrl() a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: a2uiUrl if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) { @@ -255,6 +266,7 @@ class InvokeDispatcher( try { block() } catch (_: Throwable) { + // WebView calls throw when the Activity is backgrounded between the foreground check and execution. GatewaySession.InvokeResult.error( code = "NODE_BACKGROUND_UNAVAILABLE", message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", @@ -312,6 +324,7 @@ class InvokeDispatcher( InvokeCommandAvailability.ReadSmsAvailable, InvokeCommandAvailability.RequestableSmsSearchAvailable, -> + // SMS search may still be advertised as promptable; runtime invoke fails only on permanent unavailability. smsSearchAvailabilityError( readSmsAvailable = readSmsAvailable(), smsFeatureEnabled = smsFeatureEnabled(), @@ -347,12 +360,19 @@ class InvokeDispatcher( } } +/** + * Talk-mode command adapter implemented by the voice subsystem. + */ interface TalkHandler { + /** Starts a push-to-talk capture session and keeps it open until stop or cancel. */ suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult + /** Finishes the active push-to-talk capture and submits recognized speech. */ suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult + /** Aborts the active push-to-talk capture without submitting speech. */ suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult + /** Runs a bounded one-shot push-to-talk capture. */ suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt index a65eb592be2..979e921a582 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt @@ -4,6 +4,9 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +/** + * Result of a JPEG compression attempt after quality and scale reductions. + */ internal data class JpegSizeLimiterResult( val bytes: ByteArray, val width: Int, @@ -11,7 +14,11 @@ internal data class JpegSizeLimiterResult( val quality: Int, ) +/** + * Utility that searches quality/scale combinations until a JPEG fits a byte budget. + */ internal object JpegSizeLimiter { + /** Compresses with the caller-provided encoder, reducing quality before image dimensions. */ fun compressToLimit( initialWidth: Int, initialHeight: Int, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt index 53fc6305b69..3659ff91fda 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt @@ -14,6 +14,9 @@ import kotlinx.coroutines.withTimeout import java.time.Instant import java.time.format.DateTimeFormatter +/** + * Android LocationManager-backed capture used by gateway location commands. + */ class LocationCaptureManager( private val context: Context, ) { @@ -35,6 +38,7 @@ class LocationCaptureManager( throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled") } + // Prefer a recent cached fix before waking GPS/network providers. val cached = bestLastKnown(manager, desiredProviders, maxAgeMs) val location = cached ?: requestCurrent(manager, desiredProviders, timeoutMs) @@ -81,6 +85,7 @@ class LocationCaptureManager( val candidates = providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) } val freshest = candidates.maxByOrNull { it.time } ?: return null + // maxAgeMs is a caller contract; stale cached fixes force a live provider request. if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null return freshest } @@ -102,6 +107,7 @@ class LocationCaptureManager( val resolved = providers.firstOrNull { manager.isProviderEnabled(it) } ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available") + // getCurrentLocation can return null; the handler maps timeout/null fixes to gateway error shapes. val location = withTimeout(timeoutMs.coerceAtLeast(1)) { suspendCancellableCoroutine { cont -> diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt index c5574809c22..d84c4d56759 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt @@ -10,6 +10,9 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive +/** + * Injectable location facade for command tests and Android runtime access. + */ internal interface LocationDataSource { fun hasFinePermission(context: Context): Boolean @@ -69,11 +72,14 @@ class LocationHandler private constructor( locationPreciseEnabled = locationPreciseEnabled, ) + /** Reports whether precise GPS-backed location can be requested from Android. */ fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext) + /** Reports whether network/coarse location can be requested from Android. */ fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext) companion object { + /** Creates a handler with injected location state for permission and payload tests. */ internal fun forTesting( appContext: Context, dataSource: LocationDataSource, @@ -90,8 +96,10 @@ class LocationHandler private constructor( ) } + /** Handles location.get with foreground, permission, and user precision gates applied. */ suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult { if (!isForeground()) { + // Android foreground restrictions and user expectation keep live location tied to the visible app. return GatewaySession.InvokeResult.error( code = "LOCATION_BACKGROUND_UNAVAILABLE", message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open", @@ -105,6 +113,8 @@ class LocationHandler private constructor( } val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) val preciseEnabled = locationPreciseEnabled() + // Gateway requests are advisory; Android permission and user settings decide + // whether precise capture is actually allowed for this invocation. val accuracy = when (desiredAccuracy) { "precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced" @@ -113,6 +123,7 @@ class LocationHandler private constructor( } val providers = when (accuracy) { + // Provider order is part of the accuracy policy: GPS first for precise, network first otherwise. "precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) "coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) @@ -151,6 +162,7 @@ class LocationHandler private constructor( val timeoutMs = (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L) ?: 10_000L + // desiredAccuracy is advisory; invalid values fall through to the default policy. val desiredAccuracy = (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase() return Triple(maxAgeMs, timeoutMs, desiredAccuracy) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt index c7fb0624d0c..a353acb3874 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt @@ -25,17 +25,20 @@ import kotlin.math.sqrt private const val ACCELEROMETER_SAMPLE_TARGET = 20 private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L +/** Gateway request for motion.activity after parsing and limit bounds. */ internal data class MotionActivityRequest( val startISO: String?, val endISO: String?, val limit: Int, ) +/** Gateway request for motion.pedometer. */ internal data class MotionPedometerRequest( val startISO: String?, val endISO: String?, ) +/** Motion activity sample returned in gateway-compatible boolean flags. */ internal data class MotionActivityRecord( val startISO: String, val endISO: String, @@ -48,6 +51,7 @@ internal data class MotionActivityRecord( val isUnknown: Boolean, ) +/** Pedometer sample returned from Android's cumulative step counter. */ internal data class PedometerRecord( val startISO: String, val endISO: String, @@ -57,6 +61,7 @@ internal data class PedometerRecord( val floorsDescended: Int?, ) +/** Motion data seam for Android sensors and tests. */ internal interface MotionDataSource { fun isActivityAvailable(context: Context): Boolean @@ -97,6 +102,8 @@ private object SystemMotionDataSource : MotionDataSource { request: MotionActivityRequest, ): MotionActivityRecord { if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) { + // Android does not expose historical activity samples here; fail with a + // stable gateway code instead of pretending the range is empty. throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android") } val sensorManager = @@ -130,6 +137,7 @@ private object SystemMotionDataSource : MotionDataSource { request: MotionPedometerRequest, ): PedometerRecord { if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) { + // TYPE_STEP_COUNTER is cumulative since boot, not a historical query API. throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android") } val sensorManager = @@ -216,6 +224,8 @@ private object SystemMotionDataSource : MotionDataSource { sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble()) count += 1 if (count >= ACCELEROMETER_SAMPLE_TARGET) { + // Average gravity-adjusted magnitude across a short window so + // one noisy sensor event cannot decide the activity label. val result = AccelerometerSample( samples = count, @@ -260,12 +270,14 @@ private object SystemMotionDataSource : MotionDataSource { } } +/** Handles Android motion-related node.invoke commands backed by live sensors. */ class MotionHandler private constructor( private val appContext: Context, private val dataSource: MotionDataSource, ) { constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource) + /** Classifies a short accelerometer sample into the gateway activity shape. */ suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult { if (!dataSource.hasPermission(appContext)) { return GatewaySession.InvokeResult.error( @@ -313,6 +325,7 @@ class MotionHandler private constructor( } } + /** Returns the current boot-scoped Android step-counter reading. */ suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult { if (!dataSource.hasPermission(appContext)) { return GatewaySession.InvokeResult.error( @@ -350,8 +363,10 @@ class MotionHandler private constructor( fun isAvailable(): Boolean = dataSource.isAvailable(appContext) + /** Returns true when live accelerometer classification can be sampled. */ fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext) + /** Returns true when Android exposes a cumulative step-counter sensor. */ fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext) private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? { @@ -364,6 +379,8 @@ class MotionHandler private constructor( } catch (_: Throwable) { null } ?: return null + // Keep the accepted gateway parameter even though Android can only return + // one live classification sample for now. val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000) return MotionActivityRequest( startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }, @@ -389,8 +406,10 @@ class MotionHandler private constructor( } companion object { + /** Static capability probe used before a MotionHandler instance is needed. */ fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context) + /** Creates a handler with an injected sensor source for parser and payload tests. */ internal fun forTesting( appContext: Context, dataSource: MotionDataSource, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/NodePresenceAliveBeacon.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/NodePresenceAliveBeacon.kt index 73671a1acb3..e2dfa09dea0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/NodePresenceAliveBeacon.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/NodePresenceAliveBeacon.kt @@ -6,10 +6,16 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject internal object NodePresenceAliveBeacon { + /** Gateway event emitted by Android when background execution confirms liveness. */ const val EVENT_NAME: String = "node.presence.alive" + + /** Avoids spamming presence when multiple background triggers fire together. */ const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000 private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024 + /** + * Source of the liveness event, serialized as gateway-stable wire values. + */ enum class Trigger( val rawValue: String, ) { @@ -21,6 +27,9 @@ internal object NodePresenceAliveBeacon { Connect("connect"), } + /** + * Minimal gateway response fields used to decide whether a liveness event was accepted. + */ data class ResponsePayload( val ok: Boolean?, val event: String?, @@ -30,6 +39,7 @@ internal object NodePresenceAliveBeacon { private val json = Json { ignoreUnknownKeys = true } + /** Skips sends after a recent successful presence update. */ fun shouldSkipRecentSuccess( nowMs: Long, lastSuccessAtMs: Long?, @@ -41,6 +51,7 @@ internal object NodePresenceAliveBeacon { return elapsed >= 0 && elapsed < minIntervalMs } + /** Human-readable Android version label included in presence payloads. */ fun androidPlatformLabel(): String { val release = Build.VERSION.RELEASE @@ -50,6 +61,7 @@ internal object NodePresenceAliveBeacon { return "Android $release (SDK ${Build.VERSION.SDK_INT})" } + /** Builds the compact JSON payload consumed by gateway node-presence handlers. */ fun makePayloadJson( trigger: Trigger, sentAtMs: Long, @@ -71,8 +83,11 @@ internal object NodePresenceAliveBeacon { pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) } }.toString() + /** Parses the gateway response while rejecting empty, oversized, or malformed payloads. */ fun decodeResponse(payloadJson: String?): ResponsePayload? { val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null + // Bound log/IPC responses before JSON parsing to avoid memory spikes from + // malformed gateway replies. if (raw.length > MAX_RESPONSE_JSON_CHARS) return null val obj = try { @@ -88,6 +103,7 @@ internal object NodePresenceAliveBeacon { ) } + /** Sanitizes gateway response reasons before writing them into Android logs. */ fun sanitizeReasonForLog(raw: String?): String { val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported" return value diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt index d7085ed6539..53c87d705c4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt @@ -8,8 +8,10 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull +/** Default canvas seam color used when gateway/user params omit a hex color. */ const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A +/** Small tuple used by Android node handlers that need four return values. */ data class Quad( val first: A, val second: B, @@ -17,6 +19,7 @@ data class Quad( val fourth: D, ) +/** Escapes a Kotlin string into a JSON string literal without building a JsonElement. */ fun String.toJsonString(): String { val escaped = this @@ -29,6 +32,7 @@ fun String.toJsonString(): String { fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject +/** Parses invoke params into a JSON object, returning null for absent/malformed input. */ fun parseJsonParamsObject(paramsJson: String?): JsonObject? { if (paramsJson.isNullOrBlank()) return null return try { @@ -38,26 +42,31 @@ fun parseJsonParamsObject(paramsJson: String?): JsonObject? { } } +/** Reads a primitive field from invoke params without accepting arrays/objects. */ fun readJsonPrimitive( params: JsonObject?, key: String, ): JsonPrimitive? = params?.get(key) as? JsonPrimitive +/** Parses an optional integer invoke param. */ fun parseJsonInt( params: JsonObject?, key: String, ): Int? = readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull() +/** Parses an optional decimal invoke param. */ fun parseJsonDouble( params: JsonObject?, key: String, ): Double? = readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull() +/** Parses an optional string invoke param. */ fun parseJsonString( params: JsonObject?, key: String, ): String? = readJsonPrimitive(params, key)?.contentOrNull +/** Parses strict true/false flags from string-like JSON primitives. */ fun parseJsonBooleanFlag( params: JsonObject?, key: String, @@ -70,6 +79,7 @@ fun parseJsonBooleanFlag( } } +/** Converts JSON null to Kotlin null while preserving primitive text content. */ fun JsonElement?.asStringOrNull(): String? = when (this) { is JsonNull -> null @@ -77,6 +87,7 @@ fun JsonElement?.asStringOrNull(): String? = else -> null } +/** Parses #RRGGBB or RRGGBB into opaque ARGB. */ fun parseHexColorArgb(raw: String?): Long? { val trimmed = raw?.trim().orEmpty() if (trimmed.isEmpty()) return null @@ -86,15 +97,18 @@ fun parseHexColorArgb(raw: String?): Long? { return 0xFF000000L or rgb } +/** Converts gateway invocation throwables into protocol code/message pairs. */ fun invokeErrorFromThrowable(err: Throwable): Pair { val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error") val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message return parsed.code to message } +/** Normalizes user/session keys while preserving main as the canonical session id. */ fun normalizeMainKey(raw: String?): String? { val trimmed = raw?.trim().orEmpty() return if (trimmed.isEmpty()) null else trimmed } +/** Returns true only for the canonical main-session key understood by gateway UI. */ fun isCanonicalMainSessionKey(key: String): Boolean = key == "main" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt index 17ddd96d754..0a43c0bf79b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt @@ -10,6 +10,9 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put +/** + * Injectable notification listener facade so command parsing can be tested without Android service state. + */ internal interface NotificationsStateProvider { fun readSnapshot(context: Context): DeviceNotificationSnapshot @@ -22,6 +25,7 @@ internal interface NotificationsStateProvider { } private object SystemNotificationsStateProvider : NotificationsStateProvider { + /** Reads listener state through Android APIs and returns a disabled snapshot when access is missing. */ override fun readSnapshot(context: Context): DeviceNotificationSnapshot { val enabled = DeviceNotificationListenerService.isAccessEnabled(context) if (!enabled) { @@ -34,27 +38,32 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider { return DeviceNotificationListenerService.snapshot(context, enabled = true) } + /** Requests a platform listener rebind after access has been granted. */ override fun requestServiceRebind(context: Context) { DeviceNotificationListenerService.requestServiceRebind(context) } + /** Delegates actions to the active listener service instance. */ override fun executeAction( context: Context, request: NotificationActionRequest, ): NotificationActionResult = DeviceNotificationListenerService.executeAction(context, request) } +/** Handles notification listing and actions via the Android listener service. */ class NotificationsHandler private constructor( private val appContext: Context, private val stateProvider: NotificationsStateProvider, ) { constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider) + /** Lists the current listener snapshot after nudging Android to reconnect if needed. */ suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { val snapshot = readSnapshotWithRebind() return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot)) } + /** Executes an action against a notification key from the current listener snapshot. */ suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult { readSnapshotWithRebind() @@ -76,6 +85,8 @@ class NotificationsHandler private constructor( code = "INVALID_REQUEST", message = "INVALID_REQUEST: action required (open|dismiss|reply)", ) + // Keep accepted action names aligned with the cross-platform notification + // command contract rather than Android-specific PendingIntent labels. val action = when (actionRaw) { "open" -> NotificationActionKind.Open @@ -123,6 +134,7 @@ class NotificationsHandler private constructor( private fun readSnapshotWithRebind(): DeviceNotificationSnapshot { val snapshot = stateProvider.readSnapshot(appContext) if (snapshot.enabled && !snapshot.connected) { + // Access can be granted while Android has not rebound the listener yet. stateProvider.requestServiceRebind(appContext) } return snapshot diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt index 0137a722948..1487c0ec9e8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt @@ -29,12 +29,14 @@ private const val DEFAULT_PHOTOS_QUALITY = 0.85 private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024 private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024 +/** Request shape for photos.latest after defaults and bounds are applied. */ internal data class PhotosLatestRequest( val limit: Int, val maxWidth: Int, val quality: Double, ) +/** Encoded photo payload returned to the gateway. */ internal data class EncodedPhotoPayload( val format: String, val base64: String, @@ -43,6 +45,7 @@ internal data class EncodedPhotoPayload( val createdAt: String?, ) +/** Photo access seam for Android MediaStore and tests. */ internal interface PhotosDataSource { fun hasPermission(context: Context): Boolean @@ -53,6 +56,7 @@ internal interface PhotosDataSource { } private object SystemPhotosDataSource : PhotosDataSource { + /** Checks the API-specific image read permission used by MediaStore image access. */ override fun hasPermission(context: Context): Boolean { val permission = if (Build.VERSION.SDK_INT >= 33) { @@ -77,6 +81,8 @@ private object SystemPhotosDataSource : PhotosDataSource { if (remainingBudget <= 0) break val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue try { + // Enforce both per-photo and total payload budgets before returning + // base64 data through the gateway invoke response. val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) if (encoded == null) continue if (encoded.base64.length > remainingBudget) break @@ -172,6 +178,8 @@ private object SystemPhotosDataSource : PhotosDataSource { } ?: return null if (decoded.width <= maxWidth) return decoded + // Decode sampling is power-of-two only; finish with exact scaling when the + // sampled bitmap is still wider than the requested max width. val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt()) return try { decoded.scale(maxWidth, targetHeight, true) @@ -215,6 +223,7 @@ private object SystemPhotosDataSource : PhotosDataSource { ) } if (jpegQuality > 35) { + // Try quality reduction before resizing so small images keep detail. jpegQuality = max(25, jpegQuality - 15) return@repeat } @@ -232,12 +241,14 @@ private object SystemPhotosDataSource : PhotosDataSource { } } +/** Handles photos.latest by querying MediaStore and returning bounded JPEG payloads. */ class PhotosHandler private constructor( private val appContext: Context, private val dataSource: PhotosDataSource, ) { constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource) + /** Returns the newest accessible photos as gateway-sized base64 JPEGs. */ fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult { if (!dataSource.hasPermission(appContext)) { return GatewaySession.InvokeResult.error( @@ -300,6 +311,7 @@ class PhotosHandler private constructor( val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull() val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull() + // Clamp model-supplied values to protect memory and response-size limits. val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20) val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096) val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0) @@ -307,6 +319,7 @@ class PhotosHandler private constructor( } companion object { + /** Creates a handler with an injected photo source for parser and payload tests. */ internal fun forTesting( appContext: Context, dataSource: PhotosDataSource, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt index 62b1f1d14ca..7e5b4fe5ef2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.json.contentOrNull private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify" +/** Parsed payload for system.notify invocations. */ internal data class SystemNotifyRequest( val title: String, val body: String, @@ -24,6 +25,7 @@ internal data class SystemNotifyRequest( val priority: String?, ) +/** Notification posting seam used by production Android and unit tests. */ internal interface SystemNotificationPoster { fun isAuthorized(): Boolean @@ -33,6 +35,7 @@ internal interface SystemNotificationPoster { private class AndroidSystemNotificationPoster( private val appContext: Context, ) : SystemNotificationPoster { + /** Checks both Android 13 runtime permission and app-level notification enablement. */ override fun isAuthorized(): Boolean { if (Build.VERSION.SDK_INT >= 33) { val granted = @@ -43,6 +46,7 @@ private class AndroidSystemNotificationPoster( return NotificationManagerCompat.from(appContext).areNotificationsEnabled() } + /** Posts through a priority-specific channel so Android's immutable channel importance is respected. */ override fun post(request: SystemNotifyRequest) { val channelId = ensureChannel(request.priority) val silent = isSilentSound(request.sound) @@ -69,6 +73,8 @@ private class AndroidSystemNotificationPoster( private fun ensureChannel(priority: String?): String { val normalizedPriority = priority.orEmpty().trim().lowercase() + // Android channel importance is immutable after creation, so priority maps + // to stable channel ids instead of mutating one shared channel. val (suffix, importance, name) = when (normalizedPriority) { "passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive") @@ -97,11 +103,13 @@ private class AndroidSystemNotificationPoster( } } +/** Handles system-level node.invoke commands implemented by Android services. */ class SystemHandler private constructor( private val poster: SystemNotificationPoster, ) { constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext)) + /** Posts an Android notification from the gateway system.notify command. */ fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult { val params = parseNotifyRequest(paramsJson) @@ -139,6 +147,8 @@ class SystemHandler private constructor( private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? { val params = parseParamsObject(paramsJson) ?: return null + // title/body are required by the gateway contract; optional fields only + // influence Android channel/silence behavior. val rawTitle = (params["title"] as? JsonPrimitive) ?.contentOrNull @@ -167,6 +177,7 @@ class SystemHandler private constructor( } companion object { + /** Creates a handler with a fake poster for parser and authorization tests. */ internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt index 61a0a344e6b..03499862883 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive object OpenClawCanvasA2UIAction { + /** Reads the agent-facing action name from either the modern name field or legacy action field. */ fun extractActionName(userAction: JsonObject): String? { val name = (userAction["name"] as? JsonPrimitive) @@ -19,6 +20,7 @@ object OpenClawCanvasA2UIAction { return action.ifEmpty { null } } + /** Normalizes prompt tag values so the compact CANVAS_A2UI envelope stays parser-friendly. */ fun sanitizeTagValue(value: String): String { val trimmed = value.trim().ifEmpty { "-" } val normalized = trimmed.replace(" ", "_") @@ -35,6 +37,7 @@ object OpenClawCanvasA2UIAction { return out.toString() } + /** Formats the compact text envelope sent to the agent when a canvas UI action fires. */ fun formatAgentMessage( actionName: String, sessionKey: String, @@ -57,6 +60,7 @@ object OpenClawCanvasA2UIAction { ).joinToString(separator = " ") } + /** Builds JS that reports an agent action result back to the canvas runtime. */ fun jsDispatchA2UIActionStatus( actionId: String, ok: Boolean, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt index 171f3c1d7ab..fcc53b1ac88 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.protocol +/** Capability ids advertised by the Android node to the OpenClaw gateway. */ enum class OpenClawCapability( val rawValue: String, ) { @@ -19,6 +20,7 @@ enum class OpenClawCapability( CallLog("callLog"), } +/** Canvas command ids mirrored from the gateway tool namespace. */ enum class OpenClawCanvasCommand( val rawValue: String, ) { @@ -34,6 +36,7 @@ enum class OpenClawCanvasCommand( } } +/** Streaming canvas commands sent from agents back into the Android UI. */ enum class OpenClawCanvasA2UICommand( val rawValue: String, ) { @@ -47,6 +50,7 @@ enum class OpenClawCanvasA2UICommand( } } +/** Camera command ids accepted by the Android node. */ enum class OpenClawCameraCommand( val rawValue: String, ) { @@ -60,6 +64,7 @@ enum class OpenClawCameraCommand( } } +/** SMS command ids accepted by the Android node. */ enum class OpenClawSmsCommand( val rawValue: String, ) { @@ -72,6 +77,7 @@ enum class OpenClawSmsCommand( } } +/** Push-to-talk command ids accepted by the Android node. */ enum class OpenClawTalkCommand( val rawValue: String, ) { @@ -86,6 +92,7 @@ enum class OpenClawTalkCommand( } } +/** Location command ids accepted by the Android node. */ enum class OpenClawLocationCommand( val rawValue: String, ) { @@ -97,6 +104,7 @@ enum class OpenClawLocationCommand( } } +/** Device status and metadata command ids accepted by the Android node. */ enum class OpenClawDeviceCommand( val rawValue: String, ) { @@ -111,6 +119,7 @@ enum class OpenClawDeviceCommand( } } +/** Notification command ids accepted by the Android node. */ enum class OpenClawNotificationsCommand( val rawValue: String, ) { @@ -123,6 +132,7 @@ enum class OpenClawNotificationsCommand( } } +/** System command ids accepted by the Android node. */ enum class OpenClawSystemCommand( val rawValue: String, ) { @@ -134,6 +144,7 @@ enum class OpenClawSystemCommand( } } +/** Photos command ids accepted by the Android node. */ enum class OpenClawPhotosCommand( val rawValue: String, ) { @@ -145,6 +156,7 @@ enum class OpenClawPhotosCommand( } } +/** Contacts command ids accepted by the Android node. */ enum class OpenClawContactsCommand( val rawValue: String, ) { @@ -157,6 +169,7 @@ enum class OpenClawContactsCommand( } } +/** Calendar command ids accepted by the Android node. */ enum class OpenClawCalendarCommand( val rawValue: String, ) { @@ -169,6 +182,7 @@ enum class OpenClawCalendarCommand( } } +/** Motion sensor command ids accepted by the Android node. */ enum class OpenClawMotionCommand( val rawValue: String, ) { @@ -181,6 +195,7 @@ enum class OpenClawMotionCommand( } } +/** Call-log command ids accepted by the Android node. */ enum class OpenClawCallLogCommand( val rawValue: String, ) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt b/apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt index 88f8e03a9c7..e85be642024 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt @@ -31,6 +31,7 @@ private data class ToolDisplayConfig( val tools: Map? = null, ) +/** Compact UI summary for a running or pending tool call. */ data class ToolDisplaySummary( val name: String, val emoji: String, @@ -39,6 +40,7 @@ data class ToolDisplaySummary( val verb: String?, val detail: String?, ) { + /** Optional second-line detail assembled from the action verb and best argument preview. */ val detailLine: String? get() { val parts = mutableListOf() @@ -47,10 +49,12 @@ data class ToolDisplaySummary( return if (parts.isEmpty()) null else parts.joinToString(" · ") } + /** Single-line fallback for compact tool rows that do not render detail separately. */ val summaryLine: String get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label" } +/** Resolves tool-call names and args into user-facing Android display text. */ object ToolDisplayRegistry { private const val CONFIG_ASSET = "tool-display.json" @@ -58,6 +62,7 @@ object ToolDisplayRegistry { @Volatile private var cachedConfig: ToolDisplayConfig? = null + /** Resolves a raw tool call into stable, bounded UI text for pending-tool surfaces. */ fun resolve( context: Context, name: String?, @@ -86,6 +91,8 @@ object ToolDisplayRegistry { detail = pathDetail(args) } + // Action-specific detail keys win over tool defaults so commands like + // read/write can surface the most useful argument for that action. val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList() if (detail == null) { detail = firstValue(args, detailKeys) @@ -122,6 +129,8 @@ object ToolDisplayRegistry { cachedConfig = decoded decoded } catch (_: Throwable) { + // The chat UI should still render pending tools if the asset is absent or + // malformed in debug builds. val fallback = ToolDisplayConfig() cachedConfig = fallback fallback diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt index 658c4d38cc3..3410bed4b3f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import kotlinx.coroutines.delay +/** Full-screen white flash keyed by camera capture tokens. */ @Composable fun CameraFlashOverlay( token: Long, @@ -29,6 +30,8 @@ private fun CameraFlash(token: Long) { var alpha by remember { mutableFloatStateOf(0f) } LaunchedEffect(token) { if (token == 0L) return@LaunchedEffect + // Token changes replay the animation even when consecutive captures use + // the same HUD message. alpha = 0.85f delay(110) alpha = 0f diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt index 72e612a2e13..16174db523f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt @@ -26,6 +26,7 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import java.util.concurrent.atomic.AtomicReference +/** Hosts the gateway canvas WebView and attaches it to the runtime canvas controller. */ @SuppressLint("SetJavaScriptEnabled") @Suppress("DEPRECATION") @Composable @@ -151,6 +152,9 @@ fun CanvasScreen( } } + // The listener accepts any WebView origin at registration time because + // gateway A2UI URLs are dynamic; CanvasActionTrust validates the live URL + // before forwarding each message. val bridge = CanvasA2UIActionBridge( isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) }, @@ -184,6 +188,7 @@ fun CanvasScreen( ) } +/** Filters WebView postMessage payloads before they enter the A2UI action handler. */ internal class CanvasA2UIActionBridge( private val isTrustedPage: () -> Boolean, private val onMessage: (String) -> Unit, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasSettingsScreen.kt index 03027d563f5..35b713e313d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasSettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +/** Settings detail surface for live canvas status, refresh, and embedded preview. */ @Composable internal fun CanvasSettingsScreen( viewModel: MainViewModel, @@ -47,6 +48,8 @@ internal fun CanvasSettingsScreen( LaunchedEffect(isConnected) { if (isConnected) { + // Refresh once when the gateway comes online so the settings preview is + // populated before the user manually asks for a rehydrate. viewModel.refreshHomeCanvasOverviewIfConnected() } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt index 8e2b9b3d9ab..1d5a4c59bf8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +/** Settings screen for gateway channel readiness and account status. */ @Composable internal fun ChannelsSettingsScreen( viewModel: MainViewModel, @@ -71,6 +72,8 @@ internal fun ChannelsSettingsScreen( } } if (summary.partial || summary.warnings.isNotEmpty()) { + // Partial channel scans still include useful rows; surface the warning + // without hiding successful channel status. ClawPanel { Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } @@ -156,4 +159,5 @@ private fun channelBadge(label: String): String = .joinToString("") .ifBlank { "C" } +/** Chooses the first gateway warning or a generic partial-scan message. */ private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete." diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt index 77e6b3564e0..7cf7ff3cb20 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt @@ -4,6 +4,7 @@ import ai.openclaw.app.MainViewModel import ai.openclaw.app.ui.chat.ChatSheetContent import androidx.compose.runtime.Composable +/** Keeps the public shell entry point stable while chat internals live under ui.chat. */ @Composable fun ChatSheet(viewModel: MainViewModel) { ChatSheetContent(viewModel = viewModel) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt index 3605a470439..04936f0e0c4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +/** Full-screen command palette for navigation and recent-session search. */ @Composable internal fun CommandPalette( viewModel: MainViewModel, @@ -158,6 +159,7 @@ private data class CommandItem( val icon: ImageVector, val onClick: () -> Unit, ) { + /** Matches palette queries against both action title and explanatory subtitle. */ fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query) } @@ -295,6 +297,7 @@ private fun CommandSectionLabel(title: String) { } } +/** Builds provider quick-action metadata from current gateway/catalog state. */ private fun providerCommandSubtitle( isConnected: Boolean, providers: List, @@ -307,8 +310,10 @@ private fun providerCommandSubtitle( return "Configure model access" } +/** Falls back to the canonical main-session label when gateway display names are blank. */ private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session" +/** Formats command-palette session timestamps for compact rows. */ private fun commandRelativeTime(updatedAtMs: Long): String { val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L) val minutes = deltaMs / 60_000L diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index d342b7fcb11..880c3a40e09 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -61,6 +61,7 @@ private enum class ConnectInputMode { Manual, } +/** Gateway connection screen for setup-code and manual endpoint pairing. */ @Composable fun ConnectTabScreen(viewModel: MainViewModel) { val context = LocalContext.current @@ -291,6 +292,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) { validationText = null if (inputMode == ConnectInputMode.SetupCode) { + // Setup-code auth should replace old bootstrap/shared credentials; + // manual reconnects keep existing typed credentials. viewModel.resetGatewaySetupAuth() } viewModel.setManualEnabled(true) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt index a9ad8605119..d5777acb133 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +/** Settings screen for gateway dreaming state and recent dream diary entries. */ @Composable internal fun DreamingSettingsScreen( viewModel: MainViewModel, @@ -187,6 +188,7 @@ private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) { } } +/** Formats the next dreaming cycle as a compact relative label. */ private fun formatDreamingNextRun(nextRunAtMs: Long?): String { val next = nextRunAtMs ?: return "Not scheduled" val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 92d9c20f04b..f944c3e940d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -10,6 +10,7 @@ import java.net.URI import java.util.Base64 import java.util.Locale +/** Parsed endpoint fields after URL validation and cleartext-safety checks. */ internal data class GatewayEndpointConfig( val host: String, val port: Int, @@ -17,6 +18,7 @@ internal data class GatewayEndpointConfig( val displayUrl: String, ) +/** Decoded setup-code payload; only one credential family is expected to be populated. */ internal data class GatewaySetupCode( val url: String, val bootstrapToken: String?, @@ -24,6 +26,7 @@ internal data class GatewaySetupCode( val password: String?, ) +/** Final gateway connection fields selected from setup-code or manual UI input. */ internal data class GatewayConnectConfig( val host: String, val port: Int, @@ -33,22 +36,26 @@ internal data class GatewayConnectConfig( val password: String, ) +/** Validation reason used by setup, QR, and manual endpoint copy. */ internal enum class GatewayEndpointValidationError { INVALID_URL, INSECURE_REMOTE_URL, } +/** User input source used to choose endpoint-validation wording. */ internal enum class GatewayEndpointInputSource { SETUP_CODE, MANUAL, QR_SCAN, } +/** Endpoint parse result that preserves the reason when no usable config exists. */ internal data class GatewayEndpointParseResult( val config: GatewayEndpointConfig? = null, val error: GatewayEndpointValidationError? = null, ) +/** QR scan result that separates a usable setup code from validation copy. */ internal data class GatewayScannedSetupCodeResult( val setupCode: String? = null, val error: GatewayEndpointValidationError? = null, @@ -60,6 +67,7 @@ private const val remoteGatewaySecurityRule = private const val remoteGatewaySecurityFix = "Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access." +/** Resolves setup-code or manual UI fields into a connection config. */ internal fun resolveGatewayConnectConfig( useSetupCode: Boolean, setupCode: String, @@ -77,6 +85,8 @@ internal fun resolveGatewayConnectConfig( val setup = decodeGatewaySetupCode(setupCode) ?: return null val parsed = parseGatewayEndpointResult(setup.url).config ?: return null val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty() + // Bootstrap setup codes intentionally suppress stale shared credentials; + // the bootstrap token owns the first authenticated pairing exchange. val sharedToken = when { !setup.token.isNullOrBlank() -> setup.token.trim() @@ -121,8 +131,10 @@ internal fun resolveGatewayConnectConfig( ) } +/** Parses an endpoint string and returns only the valid connection config. */ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config +/** Parses and validates gateway endpoint input with user-facing error reasons. */ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult { val raw = rawInput.trim() if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) @@ -166,6 +178,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR ) } +/** Decodes base64url setup-code payloads produced by gateway onboarding. */ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { val trimmed = rawInput.trim() if (trimmed.isEmpty()) return null @@ -193,8 +206,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { } } +/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */ internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode +/** Resolves QR scanner text to setup-code or validation error for UI copy. */ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult { val setupCode = resolveSetupCodeCandidate(rawInput) @@ -209,6 +224,7 @@ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetu return GatewayScannedSetupCodeResult(setupCode = setupCode) } +/** Converts endpoint validation errors into setup-source-specific UI copy. */ internal fun gatewayEndpointValidationMessage( error: GatewayEndpointValidationError, source: GatewayEndpointInputSource, @@ -231,6 +247,7 @@ internal fun gatewayEndpointValidationMessage( } } +/** Builds a URL from manual host/port/tls fields for shared endpoint parsing. */ internal fun composeGatewayManualUrl( hostInput: String, portInput: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt index 7d54644e23c..d41782324e3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt @@ -7,6 +7,7 @@ import android.content.Context import android.os.Build import android.widget.Toast +/** App version label shared by diagnostics and gateway-facing Android metadata. */ internal fun openClawAndroidVersionLabel(): String { val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { @@ -16,18 +17,22 @@ internal fun openClawAndroidVersionLabel(): String { } } +/** Normalizes blank gateway status text for display and diagnostics copy. */ internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { "Offline" } +/** Returns true when the status has enough signal to show diagnostics affordances. */ internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean { val lower = gatewayStatusForDisplay(statusText).lowercase() return lower != "offline" && !lower.contains("connecting") } +/** Detects pairing/approval status text so UI can offer pairing-specific actions. */ internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean { val lower = gatewayStatusForDisplay(statusText).lowercase() return lower.contains("pair") || lower.contains("approve") } +/** Builds the copyable support prompt with device, endpoint, and exact status context. */ internal fun buildGatewayDiagnosticsReport( screen: String, gatewayAddress: String, @@ -67,6 +72,7 @@ internal fun buildGatewayDiagnosticsReport( """.trimIndent() } +/** Copies the diagnostics report to Android clipboard and shows a short confirmation toast. */ internal fun copyGatewayDiagnosticsReport( context: Context, screen: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt index f0af089cd36..657aaf6d1be 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.delay internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L internal const val PAIRING_AUTO_RETRY_MS = 4_000L +/** Retries pairing-only gateway refreshes while the screen is visible and started. */ @Composable internal fun PairingAutoRetryEffect( enabled: Boolean, @@ -41,6 +42,8 @@ internal fun PairingAutoRetryEffect( if (!enabled || !lifecycleStarted) { return@LaunchedEffect } + // Give the gateway a short settling window before the first retry so an + // approval response is not immediately chased by a redundant reconnect. delay(PAIRING_INITIAL_AUTO_RETRY_MS) while (true) { onRetry() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt index 1971d0bfc34..8659c462b91 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +/** Settings health screen for gateway/node status and recent gateway logs. */ @Composable internal fun HealthLogsSettingsScreen( viewModel: MainViewModel, @@ -45,6 +46,8 @@ internal fun HealthLogsSettingsScreen( LaunchedEffect(isConnected) { if (isConnected) { + // Load logs when the gateway becomes available; manual refresh covers + // later updates so this screen does not poll. viewModel.refreshHealthLogs() } } @@ -202,6 +205,8 @@ private fun GatewayLogRow(entry: GatewayLogEntry) { private fun compactLogTime(value: String?): String { val raw = value?.trim().orEmpty() if (raw.isEmpty()) return "--:--" + // Gateway log timestamps may be ISO strings or already-compact fragments; + // keep only the HH:mm portion when present. val time = raw .substringAfter('T', raw) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt index 4f6d6e8052c..77f763f69cf 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt @@ -97,6 +97,8 @@ internal fun darkMobileColors() = chipBorderError = Color(0xFF3E1E1E), ) +// Defaulting to light tokens keeps previews/tests usable when a screen forgets to +// provide the app theme; production roots override this composition local. internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() } internal object MobileColorsAccessor { @@ -104,9 +106,8 @@ internal object MobileColorsAccessor { @Composable get() = LocalMobileColors.current } -// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc. -// without converting every file at once. Each resolves to the themed value. - +// Keep these accessors while screens migrate to `MobileColorsAccessor.current`. +// Each getter must stay composable so callers always read the active theme. internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface @@ -129,7 +130,8 @@ internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent -// Background gradient – light fades white→gray, dark fades near-black→dark-gray +// Build the page backdrop from semantic surfaces so light/dark palettes keep +// their contrast relationship without duplicating raw color stops. internal val mobileBackgroundGradient: Brush @Composable get() { val colors = LocalMobileColors.current diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt index 019c56e5f2d..4b23dc3de22 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +/** Settings screen for gateway nodes, paired devices, and pending pairing requests. */ @Composable internal fun NodesDevicesSettingsScreen( viewModel: MainViewModel, @@ -41,6 +42,8 @@ internal fun NodesDevicesSettingsScreen( LaunchedEffect(isConnected) { if (isConnected) { + // Refresh once on connection; user-triggered refresh handles later changes + // so device admin state is not polled from Compose. viewModel.refreshNodesDevices() } } @@ -195,6 +198,7 @@ private fun DeviceListRow( ) } +/** True when the gateway returned no node or device rows to render. */ private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty() private fun nodeSubtitle(node: GatewayNodeSummary): String { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 277ac117ff0..c33b4974866 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -113,6 +113,7 @@ private enum class OnboardingStep { private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L +/** First-run Android onboarding flow for gateway pairing and permission setup. */ @Composable fun OnboardingFlow( viewModel: MainViewModel, @@ -273,6 +274,8 @@ fun OnboardingFlow( setupError = null attemptedConnect = true connectAttemptStartedAtMs = SystemClock.elapsedRealtime() + // Setup-code pairing replaces any stale shared credentials before + // the bootstrap token is stored for the first authenticated connect. viewModel.resetGatewaySetupAuth() viewModel.setManualEnabled(true) viewModel.setManualHost(config.host) @@ -905,6 +908,7 @@ internal enum class GatewayRecoveryUiState( ), } +/** Derives recovery screen state from gateway/node readiness and transient status text. */ internal fun gatewayRecoveryUiState( ready: Boolean, statusText: String, @@ -918,6 +922,7 @@ internal fun gatewayRecoveryUiState( else -> GatewayRecoveryUiState.Failed } +/** Detects gateway-approved states where the Android node is still coming online. */ internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean { val lower = gatewayStatusForDisplay(statusText).lowercase() return lower.contains("operator offline") || lower.contains("node offline") @@ -932,6 +937,7 @@ private data class GatewayConfig( val password: String, ) +/** Resolves setup-code or manual fields into the gateway config used for first connect. */ private fun resolveGatewayConfig( setupCode: String, manualHost: String, @@ -944,6 +950,8 @@ private fun resolveGatewayConfig( if (setup != null) { val endpoint = parseGatewayEndpointResult(setup.url).config ?: return null val bootstrapToken = setup.bootstrapToken?.trim().orEmpty() + // Bootstrap setup codes own first-pairing auth; fall back to typed token or + // password only for non-bootstrap setup payloads. return GatewayConfig( host = endpoint.host, port = endpoint.port, @@ -974,6 +982,7 @@ private fun resolveGatewayConfig( ) } +/** Selects the recovery detail line from endpoint metadata and transient gateway status. */ private fun recoveryGatewayDetail( ready: Boolean, remoteAddress: String?, @@ -991,6 +1000,7 @@ private fun recoveryGatewayDetail( "Gateway unreachable" } +/** Copies the onboarding recovery snapshot for support without including credentials. */ private fun copyGatewayDiagnostic( context: Context, statusText: String, @@ -1011,6 +1021,7 @@ private fun copyGatewayDiagnostic( Toast.makeText(context, "Diagnostic copied", Toast.LENGTH_SHORT).show() } +/** One permission row plus launcher callback for onboarding's final setup step. */ private data class PermissionRowModel( val title: String, val subtitle: String, @@ -1019,16 +1030,19 @@ private data class PermissionRowModel( val onClick: () -> Unit, ) +/** Permission screen model plus a commit hook that persists granted feature toggles. */ private class PermissionState( val rows: List, val applyToViewModel: () -> Unit, ) +/** Onboarding can finish only after gateway and node channels are both ready. */ internal fun canFinishOnboarding( isConnected: Boolean, isNodeConnected: Boolean, ): Boolean = isConnected && isNodeConnected +/** Builds permission rows and applies granted feature toggles after onboarding. */ @Composable private fun rememberPermissionState( context: Context, @@ -1170,6 +1184,7 @@ private fun hasPermission( permission: String, ): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +/** Returns true when Android exposes any motion sensor that can back node motion commands. */ private fun hasMotionCapabilities(context: Context): Boolean { val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt index 5386cfc5341..9c022895dbc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt @@ -13,6 +13,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +/** + * App theme wrapper that installs dynamic Material colors and legacy mobile color tokens. + */ @Composable fun OpenClawTheme(content: @Composable () -> Unit) { val context = LocalContext.current @@ -35,6 +38,9 @@ fun OpenClawTheme(content: @Composable () -> Unit) { } } +/** + * Overlay background token tuned for panels floating over the mobile canvas. + */ @Composable fun overlayContainerColor(): Color { val scheme = MaterialTheme.colorScheme @@ -44,5 +50,8 @@ fun overlayContainerColor(): Color { return if (isDark) base else base.copy(alpha = 0.88f) } +/** + * Overlay icon token kept next to overlayContainerColor for callers outside the design package. + */ @Composable fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt index 9ade5146e40..bd331f34db9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt @@ -68,6 +68,7 @@ private enum class StatusVisual { Offline, } +/** Legacy tab scaffold used by the mobile post-onboarding experience. */ @Composable fun PostOnboardingTabs( viewModel: MainViewModel, @@ -150,6 +151,8 @@ fun PostOnboardingTabs( .background(mobileBackgroundGradient), ) { if (chatTabStarted) { + // Keep chat mounted after first use so session state and scroll position + // survive tab switches. Box( modifier = Modifier @@ -162,6 +165,8 @@ fun PostOnboardingTabs( } if (screenTabStarted) { + // Canvas can be expensive to initialize; keep it mounted once visited + // and hide it by alpha/z-order instead of destroying the view tree. ScreenTabScreen( viewModel = viewModel, visible = activeTab == HomeTab.Screen, @@ -184,6 +189,7 @@ fun PostOnboardingTabs( } } +/** Screen tab wrapper that refreshes canvas data once per gateway connection. */ @Composable private fun ScreenTabScreen( viewModel: MainViewModel, @@ -205,6 +211,7 @@ private fun ScreenTabScreen( } } +/** Top status chip derived from gateway connection text. */ @Composable private fun TopStatusBar( statusText: String, @@ -295,6 +302,7 @@ private fun TopStatusBar( } } +/** Bottom navigation for the legacy tab scaffold. */ @Composable private fun BottomTabBar( activeTab: HomeTab, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt index fa7f78aef64..9ebc97ff2da 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +/** Android providers/models browser backed by the gateway catalog. */ @Composable internal fun ProvidersModelsScreen( viewModel: MainViewModel, @@ -190,6 +191,7 @@ private data class ProviderRow( val modelCount: Int, ) +/** Combines auth-provider readiness rows with catalog-only providers. */ private fun providerRows( providers: List, models: List, @@ -206,6 +208,8 @@ private fun providerRows( modelCount = modelCounts[provider.id] ?: 0, ) } + // Static/catalog-only providers may expose models without a matching auth + // provider row; keep them visible as ready providers. val missingAuthRows = modelCounts.keys .filter { provider -> authRows.none { it.id == provider } } @@ -245,6 +249,7 @@ private fun providerSetupSubtitle( else -> "Add provider credentials on your Gateway" } +/** Normalizes gateway provider status strings into a ready/not-ready boolean. */ internal fun modelProviderReady(status: String): Boolean { val normalized = status.trim().lowercase() return normalized == "ok" || @@ -254,6 +259,7 @@ internal fun modelProviderReady(status: String): Boolean { normalized == "static" } +/** Groups models by provider using the same display priority as provider rows. */ private fun sortedModelGroups(models: List): List>> = models .groupBy { it.provider } @@ -483,6 +489,7 @@ private fun ModelRow(model: GatewayModelSummary) { } } +/** Derives compact capability chips for model catalog rows. */ private fun modelCapabilityLabels(model: GatewayModelSummary): List = buildList { if (model.supportsReasoning) add("Reasoning") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt index 60bc76565b6..54464b22ade 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +/** Chooses onboarding or the authenticated app shell from persisted app state. */ @Composable fun RootScreen(viewModel: MainViewModel) { val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt index a7d4dfd5505..dbbba87171d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +/** Session browser for recent and currently-live chat sessions. */ @Composable internal fun SessionsScreen( viewModel: MainViewModel, @@ -81,6 +82,8 @@ internal fun SessionsScreen( LaunchedEffect(isConnected) { if (isConnected) { + // Sessions are cheap to refresh on entry; subsequent sorting/filtering is + // local to avoid re-querying while the user explores the list. viewModel.refreshChatSessions(limit = 200) } } @@ -309,18 +312,21 @@ private enum class SessionFilter { Live, } +/** Empty-state title selected by the active session browser filter. */ private fun emptySessionTitle(filter: SessionFilter): String = when (filter) { SessionFilter.Recent -> "No sessions yet" SessionFilter.Live -> "No live session" } +/** Empty-state body selected by the active session browser filter. */ private fun emptySessionBody(filter: SessionFilter): String = when (filter) { SessionFilter.Recent -> "Start a new conversation and it will show up here." SessionFilter.Live -> "Open Chat to start or resume the current session." } +/** Formats session timestamps for compact mobile metadata. */ private fun relativeSessionTime(updatedAtMs: Long): String { val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L) val minutes = deltaMs / 60_000L @@ -331,4 +337,5 @@ private fun relativeSessionTime(updatedAtMs: Long): String { return "${hours / 24}d" } +/** Falls back to the canonical main-session label when gateway display names are blank. */ private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index f61ab27d341..f7a5d866a66 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -94,6 +94,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +/** + * Detail routes reachable from the Android settings home surface. + */ internal enum class SettingsRoute { Home, Profile, @@ -115,6 +118,9 @@ internal enum class SettingsRoute { About, } +/** + * Dispatches a selected settings route to its detail screen without changing navigation ownership. + */ @Composable internal fun SettingsDetailScreen( viewModel: MainViewModel, @@ -784,6 +790,7 @@ private fun AppearanceSettingsScreen(onBack: () -> Unit) { } } +/** Converts raw gateway connection text into stable settings metric labels. */ private fun gatewayStatusLabel( statusText: String, isConnected: Boolean, @@ -861,6 +868,7 @@ private fun AboutStatusRow( } } +/** Chooses about-screen copy based on whether the gateway advertises an update. */ private fun aboutUpdateText(latestVersion: String?): String = if (latestVersion == null) { "OpenClaw turns this phone into a clean mobile command surface for sessions, voice, providers, and Gateway." @@ -868,6 +876,9 @@ private fun aboutUpdateText(latestVersion: String?): String = "A Gateway update is available. Run the update from the Web UI or CLI when you are ready." } +/** + * Shared settings detail shell with back navigation, title, subtitle, and section content. + */ @Composable internal fun SettingsDetailFrame( title: String, @@ -900,6 +911,9 @@ internal fun SettingsDetailFrame( } } +/** + * Toggle row model reused by settings sections that render simple on/off controls. + */ private data class SettingsToggleRow( val title: String, val subtitle: String, @@ -908,6 +922,9 @@ private data class SettingsToggleRow( val onCheckedChange: (Boolean) -> Unit, ) +/** + * Compact metric row model for connected gateway summaries. + */ internal data class SettingsMetric( val title: String, val value: String, @@ -989,6 +1006,9 @@ private fun AgentListRow( ) } +/** + * Chooses a display name for the configured default agent, falling back to any available agent. + */ private fun defaultAgentName( agents: List, defaultAgentId: String?, @@ -998,6 +1018,9 @@ private fun defaultAgentName( return agent?.name?.takeIf { it.isNotBlank() } ?: agent?.id ?: "None" } +/** + * Builds a short stable badge from agent emoji/name/id for dense lists. + */ private fun agentBadge(agent: GatewayAgentSummary): String { agent.emoji ?.trim() @@ -1013,6 +1036,9 @@ private fun agentBadge(agent: GatewayAgentSummary): String { .ifBlank { "A" } } +/** + * Normalizes tool-call names into readable approval action labels. + */ private fun approvalActionName(name: String): String { val cleaned = name @@ -1027,6 +1053,7 @@ private fun approvalActionName(name: String): String { .ifBlank { "Action Request" } } +/** Builds approval row age/error copy without exposing raw tool arguments. */ private fun approvalSubtitle( toolCall: ChatPendingToolCall, hasIssue: Boolean, @@ -1037,8 +1064,10 @@ private fun approvalSubtitle( return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m" } +/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */ private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}" +/** Summarizes a provider plan and most-used quota window for usage rows. */ private fun usageProviderSubtitle(provider: GatewayUsageProviderSummary): String { provider.error?.let { return it } val window = provider.windows.maxByOrNull { it.usedPercent } @@ -1046,6 +1075,9 @@ private fun usageProviderSubtitle(provider: GatewayUsageProviderSummary): String return listOfNotNull(provider.plan, quota).joinToString(" · ").ifBlank { "No limits reported" } } +/** + * Converts usage timestamps into short relative labels for metric panels. + */ private fun formatUsageUpdated(updatedAtMs: Long?): String { val updated = updatedAtMs ?: return "Never" val deltaMs = (System.currentTimeMillis() - updated).coerceAtLeast(0L) @@ -1059,6 +1091,7 @@ private fun formatUsageUpdated(updatedAtMs: Long?): String { } } +/** Converts gateway cron status text into the short row badge label. */ private fun cronJobStatusText(job: GatewayCronJobSummary): String { if (!job.enabled) return "Off" return when (job.lastRunStatus?.lowercase()) { @@ -1069,6 +1102,7 @@ private fun cronJobStatusText(job: GatewayCronJobSummary): String { } } +/** Maps gateway cron status text to app status colors. */ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus { if (!job.enabled) return ClawStatus.Neutral return when (job.lastRunStatus?.lowercase()) { @@ -1078,6 +1112,9 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus { } } +/** + * Converts cron wake times into short relative labels for scheduled-work rows. + */ private fun formatCronWake(timeMs: Long?): String { val target = timeMs ?: return "None" val deltaMs = target - System.currentTimeMillis() @@ -1123,6 +1160,9 @@ private fun SettingsToggleListRow(row: SettingsToggleRow) { } } +/** + * Reusable metric panel for settings screens with compact title/value rows. + */ @Composable internal fun SettingsMetricPanel(rows: List) { ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)) { @@ -1159,11 +1199,15 @@ private fun SettingsIconMark(icon: ImageVector) { } } +/** + * Checks an exact Android runtime permission for settings enablement. + */ private fun hasPermission( context: Context, permission: String, ): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +/** Returns true when either fine or coarse location is available to settings callers. */ private fun hasLocationPermission(context: Context): Boolean = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index de2e63d4bba..355dbf73422 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -72,6 +72,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +/** Mobile settings surface for device permissions, forwarding, location, and app preferences. */ @Composable fun SettingsSheet(viewModel: MainViewModel) { val context = LocalContext.current @@ -131,6 +132,8 @@ fun SettingsSheet(viewModel: MainViewModel) { } } val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid + // Compare stored values against normalized drafts so equivalent HH:mm input + // does not keep the save button enabled. val quietHoursDraftDirty = notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) || notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim()) @@ -344,6 +347,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { + // Permission and role screens live outside Compose; refresh all derived + // toggles whenever Android returns to this settings surface. micPermissionGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED @@ -1217,12 +1222,14 @@ fun SettingsSheet(viewModel: MainViewModel) { } } +/** App entry shown in the notification-forwarding package picker. */ data class InstalledApp( val label: String, val packageName: String, val isSystemApp: Boolean, ) +/** Reads launcher, recent-notification, and configured packages for the picker. */ private fun queryInstalledApps( context: Context, configuredPackages: Set, @@ -1273,6 +1280,7 @@ private fun queryInstalledApps( .toList() } +/** Merges package sources while excluding OpenClaw from its own forwarding filter. */ internal fun resolveNotificationCandidatePackages( launcherPackages: Set, recentPackages: List, @@ -1290,6 +1298,7 @@ internal fun resolveNotificationCandidatePackages( .toSet() } +/** Shared Material text-field colors for the legacy mobile settings sheet. */ @Composable private fun settingsTextFieldColors() = OutlinedTextFieldDefaults.colors( @@ -1302,6 +1311,7 @@ private fun settingsTextFieldColors() = cursorColor = mobileAccent, ) +/** Applies the legacy mobile card border/background used by settings rows. */ @Composable private fun Modifier.settingsRowModifier() = this @@ -1309,6 +1319,7 @@ private fun Modifier.settingsRowModifier() = .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) .background(mobileCardSurface, RoundedCornerShape(14.dp)) +/** Primary button colors for the legacy mobile settings sheet. */ @Composable private fun settingsPrimaryButtonColors() = ButtonDefaults.buttonColors( @@ -1318,6 +1329,7 @@ private fun settingsPrimaryButtonColors() = disabledContentColor = Color.White.copy(alpha = 0.9f), ) +/** Destructive button colors for permission and capability settings actions. */ @Composable private fun settingsDangerButtonColors() = ButtonDefaults.buttonColors( @@ -1327,6 +1339,7 @@ private fun settingsDangerButtonColors() = disabledContentColor = Color.White.copy(alpha = 0.9f), ) +/** Opens this app's Android settings page for permissions that require system UI. */ private fun openAppSettings(context: Context) { val intent = Intent( @@ -1336,6 +1349,7 @@ private fun openAppSettings(context: Context) { context.startActivity(intent) } +/** Opens notification-listener settings, falling back to app settings if the intent is unavailable. */ private fun openNotificationListenerSettings(context: Context) { val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) runCatching { @@ -1345,14 +1359,17 @@ private fun openNotificationListenerSettings(context: Context) { } } +/** Android 13+ notification permission check; earlier versions grant posting at install time. */ private fun hasNotificationsPermission(context: Context): Boolean { if (Build.VERSION.SDK_INT < 33) return true return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } +/** Mirrors the notification listener service access check for UI enablement. */ private fun isNotificationListenerEnabled(context: Context): Boolean = DeviceNotificationListenerService.isAccessEnabled(context) +/** Checks whether the device exposes motion sensors needed by motion-related capabilities. */ private fun hasMotionCapabilities(context: Context): Boolean { val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 71c3c949891..cfe8df94e5d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -86,6 +86,7 @@ private enum class Tab( ProvidersModels(key = "providers-models", label = "Providers"), } +/** Main post-onboarding shell that owns top-level Android navigation state. */ @Composable fun ShellScreen( viewModel: MainViewModel, @@ -101,6 +102,8 @@ fun ShellScreen( LaunchedEffect(requestedHomeDestination) { val destination = requestedHomeDestination ?: return@LaunchedEffect + // HomeDestination is a one-shot command from launch intents and settings + // actions; consume it after translating to local shell state. activeTab = when (destination) { HomeDestination.Connect -> Tab.Overview @@ -232,6 +235,8 @@ fun ShellScreen( } pendingTrust?.let { prompt -> + // Gateway certificate trust is modal across the shell so navigation + // cannot hide a changed TLS identity prompt. GatewayTrustDialog( prompt = prompt, onAccept = viewModel::acceptGatewayTrustPrompt, @@ -242,6 +247,7 @@ fun ShellScreen( } } +/** Modal trust decision for first-seen or changed gateway TLS fingerprints. */ @Composable private fun GatewayTrustDialog( prompt: NodeRuntime.GatewayTrustPrompt, @@ -421,6 +427,7 @@ private data class ModuleRow( val settingsRoute: SettingsRoute? = null, ) +/** Floating overview shortcut that keeps chat one tap away from module lists. */ @Composable private fun OverviewChatButton( onClick: () -> Unit, @@ -561,6 +568,7 @@ private data class RecentSessionListItem( val metadata: String, ) +/** Recent sessions panel that preserves the session key behind display labels. */ @Composable private fun RecentSessionList( rows: List, @@ -780,6 +788,7 @@ private fun approvalsSummary(count: Int): String = private fun approvalsStatus(count: Int): Boolean? = if (count > 0) true else null +/** Summarizes scheduled gateway jobs for overview and settings rows. */ private fun cronJobsSummary(count: Int): String = when (count) { 0 -> "No scheduled jobs" @@ -787,6 +796,7 @@ private fun cronJobsSummary(count: Int): String = else -> "$count scheduled" } +/** Summarizes provider usage buckets without exposing detailed billing data. */ private fun usageSummaryText(count: Int): String = when (count) { 0 -> "No provider usage" @@ -794,11 +804,13 @@ private fun usageSummaryText(count: Int): String = else -> "$count providers" } +/** Reports how many gateway skills are enabled, eligible, and dependency-complete. */ private fun skillsSummaryText(skills: List): String { val ready = skills.count { !it.disabled && it.eligible && it.missingCount == 0 } return if (skills.isEmpty()) "No skills" else "$ready/${skills.size} ready" } +/** Converts gateway skill health into a tri-state settings status dot. */ private fun skillsStatus(skills: List): Boolean? = when { skills.isEmpty() -> null @@ -806,6 +818,7 @@ private fun skillsStatus(skills: List): Boolean? = else -> true } +/** Prioritizes pending pairings over online counts for compact node/device summaries. */ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String { val online = summary.nodes.count { it.connected } val devices = summary.pairedDevices.size @@ -817,6 +830,7 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String } } +/** Maps node/device state to a settings status dot, treating pending pairings as attention-needed. */ private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? = when { summary.pendingDevices.isNotEmpty() -> false @@ -825,6 +839,7 @@ private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? = else -> null } +/** Summarizes channel connection state, surfacing errors before connected counts. */ private fun channelsSummaryText(summary: GatewayChannelsSummary): String { val connected = summary.channels.count { it.connected } return when { @@ -834,6 +849,7 @@ private fun channelsSummaryText(summary: GatewayChannelsSummary): String { } } +/** Maps channel health to the settings status dot shown in the shell. */ private fun channelsStatus(summary: GatewayChannelsSummary): Boolean? = when { summary.channels.any { it.error != null } -> false @@ -842,6 +858,7 @@ private fun channelsStatus(summary: GatewayChannelsSummary): Boolean? = else -> null } +/** Summarizes dreaming memory health before enabled/off state. */ private fun dreamingSummaryText(summary: GatewayDreamingSummary): String = when { !summary.storeHealthy || !summary.phaseSignalHealthy -> "Needs attention" @@ -849,6 +866,7 @@ private fun dreamingSummaryText(summary: GatewayDreamingSummary): String = else -> "Off" } +/** Maps dreaming store/phase health and enabled state to a settings status dot. */ private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? = when { !summary.storeHealthy || !summary.phaseSignalHealthy -> false diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt index 6b0be2bb324..56a1e08a594 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +/** Settings screen for gateway skills and their readiness state. */ @Composable internal fun SkillsSettingsScreen( viewModel: MainViewModel, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt index 0aba5e91078..18945e1ea90 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt @@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +/** + * Full-screen talk-mode presence indicator with pulsing rings and status fallback text. + */ @Composable fun TalkOrbOverlay( seamColor: Color, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt index a2512e8fd64..87dba896dc2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +/** Voice home screen that routes between talk mode, dictation, and idle setup. */ @Composable fun VoiceScreen( viewModel: MainViewModel, @@ -113,6 +114,8 @@ fun VoiceScreen( pendingAction = null } + // Talk mode and dictation use different managers, so choose the transcript + // from the mode the user is actually seeing. val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation val voiceActive = micEnabled || micIsSending || talkModeEnabled val gatewayReady = gatewayStatus.isVoiceGatewayReady() @@ -141,6 +144,8 @@ fun VoiceScreen( } if (voiceCaptureMode == VoiceCaptureMode.ManualMic || micEnabled || micIsSending) { + // Manual mic mode owns the whole screen while a turn is being captured or + // delivered, even after the user releases the mic. DictationScreen( liveTranscript = micLiveTranscript, conversation = micConversation, @@ -222,6 +227,7 @@ fun VoiceScreen( } } +/** Full-screen dictation capture and send state. */ @Composable private fun DictationScreen( liveTranscript: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt index a658ddb7609..c06760beb18 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt @@ -75,6 +75,9 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import kotlin.math.max +/** + * Voice tab that switches between push-to-send mic capture and continuous Talk Mode. + */ @Composable fun VoiceTabScreen(viewModel: MainViewModel) { val context = LocalContext.current @@ -115,7 +118,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) { lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - // Manual mic is tied to the Voice tab; Talk Mode is explicit and can continue. + // Manual mic is tab-scoped; Talk Mode is user-enabled and can continue elsewhere. viewModel.setVoiceScreenActive(false) } } @@ -402,11 +405,17 @@ fun VoiceTabScreen(viewModel: MainViewModel) { } } +/** + * Permission continuation captured before Android's runtime permission dialog suspends the action. + */ private enum class PendingVoicePermissionAction { ManualMic, TalkMode, } +/** + * Renders one transcript turn, preserving side and color by speaker role. + */ @Composable private fun VoiceTurnBubble(entry: VoiceConversationEntry) { val isUser = entry.role == VoiceConversationRole.User @@ -439,6 +448,9 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) { } } +/** + * Placeholder assistant turn shown while a manual mic request is queued but not streaming yet. + */ @Composable private fun VoiceThinkingBubble() { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { @@ -460,6 +472,9 @@ private fun VoiceThinkingBubble() { } } +/** + * Static dot cluster used by VoiceThinkingBubble to avoid starting another animation loop. + */ @Composable private fun ThinkingDots(color: Color) { Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { @@ -481,12 +496,18 @@ private fun ThinkingDot( ) {} } +/** + * Checks RECORD_AUDIO using ContextCompat so wrapped activity contexts behave the same. + */ private fun Context.hasRecordAudioPermission(): Boolean = ( ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED ) +/** + * Walks ContextWrappers until an Activity is found for permission rationale checks. + */ private fun Context.findActivity(): Activity? = when (this) { is Activity -> this @@ -494,6 +515,9 @@ private fun Context.findActivity(): Activity? = else -> null } +/** + * Opens this app's settings page after Android reports the mic permission as blocked. + */ private fun openAppSettings(context: Context) { val intent = Intent( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt index 8180d24bbed..19a1f9ee949 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt @@ -11,11 +11,13 @@ import androidx.compose.ui.graphics.asImageBitmap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +/** Compose state for async base64 image decoding. */ internal data class Base64ImageState( val image: ImageBitmap?, val failed: Boolean, ) +/** Decodes a base64 image off the UI thread and reports failure state. */ @Composable internal fun rememberBase64ImageState(base64: String): Base64ImageState { var image by remember(base64) { mutableStateOf(null) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index bdc1340abd4..53404a5897e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -60,12 +60,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +/** Result of applying a stored chat draft to the current composer input. */ internal data class DraftApplication( val input: String, val lastAppliedDraft: String?, val consumed: Boolean, ) +/** Applies a draft exactly once so restored prompts do not overwrite user edits. */ internal fun applyDraftText( draftText: String?, currentInput: String, @@ -91,6 +93,7 @@ internal fun applyDraftText( ) } +/** Chat input surface for text, image attachments, thinking level, and run controls. */ @Composable fun ChatComposer( draftText: String?, @@ -115,10 +118,14 @@ fun ChatComposer( input = next.input lastAppliedDraft = next.lastAppliedDraft if (next.consumed) { + // Consume only after the composer state has accepted the draft so + // recomposition cannot reapply it over user edits. onDraftApplied() } } + // One in-flight run owns the composer actions; attachments alone are enough + // to send when the gateway is healthy. val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk val sendBusy = pendingRunCount > 0 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt index e242c7e3a15..4adf80cc06d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt @@ -26,6 +26,7 @@ private val decodedBitmapCache = ): Int = value.byteCount.coerceAtLeast(1) } +/** Loads a picked image URI into the bounded JPEG attachment shape sent to chat. */ internal fun loadSizedImageAttachment( resolver: ContentResolver, uri: Uri, @@ -36,6 +37,8 @@ internal fun loadSizedImageAttachment( throw IllegalStateException("unsupported attachment") } val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3 + // Reuse the node JPEG limiter so chat attachments and node photo payloads + // stay within the same gateway frame budget. val encoded = JpegSizeLimiter.compressToLimit( initialWidth = bitmap.width, @@ -72,6 +75,7 @@ internal fun loadSizedImageAttachment( ) } +/** Decodes chat image payloads into display-sized bitmaps with an LRU cache. */ internal fun decodeBase64Bitmap( base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION, @@ -101,6 +105,7 @@ internal fun decodeBase64Bitmap( return bitmap } +/** Computes Android's power-of-two bitmap sampling size for bounded decode. */ internal fun computeInSampleSize( width: Int, height: Int, @@ -117,6 +122,7 @@ internal fun computeInSampleSize( return sample.coerceAtLeast(1) } +/** Normalizes arbitrary picked-image names to the JPEG file name sent upstream. */ internal fun normalizeAttachmentFileName(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return "image.jpg" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt index ec218e1eb64..5c220408565 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt @@ -98,6 +98,7 @@ private val markdownParser: Parser by lazy { .build() } +/** Renders gateway/chat Markdown using the restricted mobile-safe feature set. */ @Composable fun ChatMarkdown( text: String, @@ -234,6 +235,7 @@ private fun RenderParagraph( ) { val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) } if (standaloneImage != null) { + // Render a paragraph that is only a data image as media, not as an inline alt label. InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType) return } @@ -551,6 +553,7 @@ private fun AnnotatedString.Builder.appendLinkNode( textDecoration = TextDecoration.Underline, ) if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) { + // Drop unsafe schemes while preserving visible link text. appendInlineNode( link.firstChild, inlineCodeBg = inlineCodeBg, @@ -575,9 +578,12 @@ private fun isSafeMarkdownLinkDestination(destination: String): Boolean { runCatching { URI(destination).scheme?.lowercase(Locale.US) } .getOrNull() ?: return false + // Chat markdown links are user/model supplied; keep navigation limited to + // browser-safe web URLs instead of custom Android intents or file URLs. return scheme == "http" || scheme == "https" } +/** Builds styled inline markdown for compact chat labels and preview text. */ internal fun buildChatInlineMarkdown( text: String, linkColor: Color = Color.Blue, @@ -615,9 +621,11 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? { return parseDataImageDestination(only.destination) } +/** Parses a data:image Markdown destination when it is safe to render inline. */ internal fun parseDataImageDestination(destination: String?): ParsedDataImage? { val raw = destination?.trim().orEmpty() if (raw.isEmpty()) return null + // Bound the full URI before regex parsing so pasted data images cannot allocate huge match buffers. if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null val match = dataImageRegex.matchEntire(raw) ?: return null val subtype = @@ -661,6 +669,9 @@ private data class TableRenderRow( val cells: List, ) +/** + * Parsed bounded data-image payload for chat markdown rendering. + */ internal data class ParsedDataImage( val mimeType: String, val base64: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt index 4db3eb3074e..aa352f95296 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +/** Renders chat history newest-first while preserving stable scroll behavior during streaming. */ @Composable fun ChatMessageListCard( messages: List, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 8c1dba5c9f1..f5dd654e7dc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -52,6 +52,7 @@ private data class ChatBubbleStyle( val roleColor: Color, ) +/** Renders one persisted chat message as text and image parts. */ @Composable fun ChatMessageBubble(message: ChatMessage) { val role = message.role.trim().lowercase(Locale.US) @@ -129,6 +130,7 @@ private fun ChatMessageBody( } } +/** Assistant placeholder shown while a run is active but no text has streamed yet. */ @Composable fun ChatTypingIndicatorBubble() { ChatBubbleContainer( @@ -145,6 +147,7 @@ fun ChatTypingIndicatorBubble() { } } +/** Tool progress bubble resolved through Android's tool display registry. */ @Composable fun ChatPendingToolsBubble(toolCalls: List) { val context = LocalContext.current @@ -188,6 +191,7 @@ fun ChatPendingToolsBubble(toolCalls: List) { } } +/** Live assistant stream bubble shown before the final message is committed. */ @Composable fun ChatStreamingAssistantBubble(text: String) { ChatBubbleContainer( @@ -281,6 +285,7 @@ private fun PulseDot( ) {} } +/** Shared code block renderer used by chat Markdown. */ @Composable fun ChatCodeBlock( code: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index dfb81a8d88f..4295101bf4d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -74,6 +74,7 @@ import java.text.DateFormat import java.util.Date import java.util.Locale +/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */ @Composable fun ChatScreen( viewModel: MainViewModel, @@ -452,6 +453,7 @@ private data class StarterPrompt( val message: String, ) +/** Default prompts shown only for an empty, connected session. */ private val starterPrompts = listOf( StarterPrompt(mark = "1", title = "Catch me up", subtitle = "Summarize recent sessions and next steps.", message = "Catch me up on my recent OpenClaw sessions and suggest next steps."), @@ -820,6 +822,7 @@ private fun userFacingChatError(error: String): String { } } +/** Normalizes persisted thinking values into compact UI labels. */ private fun thinkingDisplay(value: String): String = when (value.lowercase(Locale.US)) { "low" -> "Low" @@ -828,6 +831,7 @@ private fun thinkingDisplay(value: String): String = else -> "Off" } +/** Converts displayed thinking labels back to gateway request values. */ private fun thinkingValue(display: String): String = when (display.lowercase(Locale.US)) { "low" -> "low" @@ -836,6 +840,7 @@ private fun thinkingValue(display: String): String = else -> "off" } +/** Cycles through context budget presets from the compact composer control. */ private fun nextThinkingValue(value: String): String = when (value.lowercase(Locale.US)) { "off" -> "low" @@ -844,6 +849,7 @@ private fun nextThinkingValue(value: String): String = else -> "off" } +/** Maps thinking presets to the visual context meter fill fraction. */ private fun thinkingMeterWidth(value: String): Float = when (value.lowercase(Locale.US)) { "low" -> 0.34f @@ -856,6 +862,7 @@ private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 10 private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs)) +/** Quick markdown detector used to avoid routing plain chat text through the markdown renderer. */ private fun String.hasMarkdownSyntax(): Boolean = any { it == '#' || it == '*' || it == '`' || it == '[' || it == '|' } || contains("\n- ") || diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index aa8a27792f4..0f4cd29a474 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** Returns a pending assistant prompt only when chat can accept it immediately. */ internal fun resolvePendingAssistantAutoSend( pendingPrompt: String?, healthOk: Boolean, @@ -56,6 +57,7 @@ internal fun resolvePendingAssistantAutoSend( return prompt } +/** Dispatches a pending assistant prompt once and reports whether it was accepted. */ internal suspend fun dispatchPendingAssistantAutoSend( pendingPrompt: String?, healthOk: Boolean, @@ -71,6 +73,7 @@ internal suspend fun dispatchPendingAssistantAutoSend( return dispatch(prompt) } +/** Chooses the session key to load for initial chat hydration, if any. */ internal fun resolveInitialChatLoadSessionKey( sessionKey: String, mainSessionKey: String, @@ -81,6 +84,7 @@ internal fun resolveInitialChatLoadSessionKey( return main } +/** Main Android chat sheet content: session picker, message list, and composer. */ @Composable fun ChatSheetContent(viewModel: MainViewModel) { val messages by viewModel.chatMessages.collectAsState() @@ -105,6 +109,8 @@ fun ChatSheetContent(viewModel: MainViewModel) { } LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) { + // Assistant-launch prompts should wait for a healthy idle chat so they do + // not race an already-running turn. val accepted = dispatchPendingAssistantAutoSend( pendingPrompt = pendingAssistantAutoSend, @@ -131,6 +137,8 @@ fun ChatSheetContent(viewModel: MainViewModel) { rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult scope.launch(Dispatchers.IO) { + // Bound both count and encoded size before attachments enter Compose + // state; sending uses these already-compressed payloads directly. val next = uris.take(8).mapNotNull { uri -> try { @@ -265,6 +273,9 @@ private fun ChatErrorRail(errorText: String) { } } +/** + * Image selected in the composer and held in memory until the next chat.send call. + */ data class PendingImageAttachment( val id: String, val fileName: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt index a6d30707b8e..8b23ec49367 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt @@ -32,6 +32,7 @@ fun friendlySessionName(key: String): String { return result.ifBlank { key } } +/** Builds the selectable recent-session list while preserving the active session. */ fun resolveSessionChoices( currentSessionKey: String, sessions: List, @@ -46,6 +47,7 @@ fun resolveSessionChoices( val recent = mutableListOf() val seen = mutableSetOf() for (entry in sorted) { + // Hide the legacy main alias when the gateway has supplied a canonical main session key. if (aliasKey != null && entry.key == aliasKey) continue if (!seen.add(entry.key)) continue if ((entry.updatedAtMs ?: 0L) < cutoff) continue @@ -70,6 +72,7 @@ fun resolveSessionChoices( } if (current.isNotEmpty() && !included.contains(current)) { + // Keep the active session selectable even if it is old or missing from the recent list. result.add(ChatSessionEntry(key = current, updatedAtMs = null)) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt index bf6e07f1c03..29b71788860 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt @@ -56,6 +56,7 @@ internal enum class ClawStatus { Danger, } +/** Full-screen mobile scaffold that applies OpenClaw safe-area and canvas tokens. */ @Composable internal fun ClawScaffold( modifier: Modifier = Modifier, @@ -74,6 +75,7 @@ internal fun ClawScaffold( } } +/** Section title row with an optional trailing action slot. */ @Composable internal fun ClawSectionHeader( title: String, @@ -94,6 +96,7 @@ internal fun ClawSectionHeader( } } +/** Primary call-to-action button using the mobile design token set. */ @Composable internal fun ClawPrimaryButton( text: String, @@ -125,6 +128,7 @@ internal fun ClawPrimaryButton( } } +/** Secondary action button for non-default commands. */ @Composable internal fun ClawSecondaryButton( text: String, @@ -156,6 +160,7 @@ internal fun ClawSecondaryButton( } } +/** Fixed-size circular icon button for toolbar actions. */ @Composable internal fun ClawIconButton( icon: ImageVector, @@ -179,6 +184,7 @@ internal fun ClawIconButton( } } +/** Compact status chip with a semantic color dot. */ @Composable internal fun ClawStatusPill( text: String, @@ -217,6 +223,7 @@ internal fun ClawStatusPill( } } +/** Small optional-selectable pill used for filters and metadata chips. */ @Composable internal fun ClawPill( text: String, @@ -248,6 +255,7 @@ internal fun ClawPill( } } +/** Panel wrapper for homogeneous lists with standard row separators. */ @Composable internal fun ClawListPanel( items: List, @@ -259,6 +267,7 @@ internal fun ClawListPanel( } } +/** Column helper that inserts standard dividers between rendered rows. */ @Composable internal fun ClawSeparatedColumn( items: List, @@ -275,6 +284,7 @@ internal fun ClawSeparatedColumn( } } +/** Two-line settings/detail row with caller-provided leading and trailing slots. */ @Composable internal fun ClawDetailRow( title: String, @@ -301,6 +311,7 @@ internal fun ClawDetailRow( } } +/** Circular text badge used for compact numeric or initials-style row marks. */ @Composable internal fun ClawTextBadge( text: String, @@ -319,6 +330,7 @@ internal fun ClawTextBadge( } } +/** Circular icon badge used as a neutral leading marker in list rows. */ @Composable internal fun ClawIconBadge( icon: ImageVector, @@ -337,6 +349,7 @@ internal fun ClawIconBadge( } } +/** Reusable one-line list row with optional subtitle, metadata, slots, and click handling. */ @Composable internal fun ClawListItem( title: String, @@ -390,6 +403,7 @@ internal fun ClawListItem( } } +/** Equal-width segmented control for small mode/filter sets. */ @Composable internal fun ClawSegmentedControl( options: List, @@ -429,6 +443,7 @@ internal fun ClawSegmentedControl( } } +/** Token-styled text field used by settings and prototype screens. */ @Composable internal fun ClawTextField( value: String, @@ -461,6 +476,7 @@ internal fun ClawTextField( ) } +/** Local design-system preview surface for visual smoke checks. */ @Composable internal fun ClawComponentShowcase(modifier: Modifier = Modifier) { var selected by rememberSaveable { mutableStateOf("Chat") } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawNavigation.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawNavigation.kt index cdc7cb9a94a..3bbbd7f37ef 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawNavigation.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawNavigation.kt @@ -27,6 +27,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +/** + * Stable bottom-navigation destination descriptor. + */ @Immutable internal data class ClawNavItem( val key: String, @@ -34,6 +37,9 @@ internal data class ClawNavItem( val icon: ImageVector, ) +/** + * Compact app bar that keeps title, optional subtitle, navigation, and actions aligned. + */ @Composable internal fun ClawTopBar( title: String, @@ -73,6 +79,9 @@ internal fun ClawTopBar( } } +/** + * Bottom navigation shell that applies navigation-bar insets before laying out destinations. + */ @Composable internal fun ClawBottomNav( items: List, @@ -133,6 +142,9 @@ private fun ClawBottomNavItem( } } +/** + * Two-character identity mark for users, agents, or nodes in compact UI rows. + */ @Composable internal fun ClawAvatarMark( text: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawPreview.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawPreview.kt index bec9e3bc75a..97fdb994c2e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawPreview.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawPreview.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.tooling.preview.Preview ) @Composable private fun ClawComponentShowcasePreview() { + // Preview uses the design-system theme directly so token regressions show up in isolation. ClawDesignTheme { ClawComponentShowcase() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawSurfaces.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawSurfaces.kt index 59a87826a22..e6f440f1e6d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawSurfaces.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawSurfaces.kt @@ -15,6 +15,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +/** + * Standard inset panel for grouped Android app content. + */ @Composable internal fun ClawPanel( modifier: Modifier = Modifier, @@ -34,6 +37,9 @@ internal fun ClawPanel( } } +/** + * Bottom-sheet container with the app surface treatment and top-only rounding. + */ @Composable internal fun ClawSheetSurface( modifier: Modifier = Modifier, @@ -53,6 +59,9 @@ internal fun ClawSheetSurface( } } +/** + * Shared empty state used when a screen has no records but can still offer an action. + */ @Composable internal fun ClawEmptyState( title: String, @@ -73,6 +82,9 @@ internal fun ClawEmptyState( } } +/** + * Shared loading placeholder that keeps async screen states visually consistent. + */ @Composable internal fun ClawLoadingState( title: String, @@ -90,6 +102,9 @@ internal fun ClawLoadingState( } } +/** + * Shared recoverable error block with the app's attention styling. + */ @Composable internal fun ClawErrorState( title: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt index 2afdd4bc23a..9db3184e2b0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt @@ -20,6 +20,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +/** + * App color tokens consumed by ClawTheme and bridged into Material components. + */ @Immutable internal data class ClawColors( val canvas: Color, @@ -41,6 +44,9 @@ internal data class ClawColors( val dangerSoft: Color, ) +/** + * App spacing scale for Compose screens and shared controls. + */ @Immutable internal data class ClawSpacing( val xxxs: Dp = 4.dp, @@ -54,6 +60,9 @@ internal data class ClawSpacing( val touchTarget: Dp = 48.dp, ) +/** + * Radius scale for rows, panels, controls, sheets, and status pills. + */ @Immutable internal data class ClawRadii( val row: Dp = 4.dp, @@ -64,6 +73,9 @@ internal data class ClawRadii( val pill: Dp = 12.dp, ) +/** + * App text styles kept independent from Material typography names. + */ @Immutable internal data class ClawTypography( val display: TextStyle, @@ -122,6 +134,9 @@ private val LocalClawSpacing = staticCompositionLocalOf { ClawSpacing() } private val LocalClawRadii = staticCompositionLocalOf { ClawRadii() } private val LocalClawTypography = staticCompositionLocalOf { clawTypography(mobileFontFamily) } +/** + * Composition-local access point for OpenClaw Android design tokens. + */ internal object ClawTheme { val colors: ClawColors @Composable @@ -144,6 +159,9 @@ internal object ClawTheme { get() = LocalClawTypography.current } +/** + * Installs OpenClaw design tokens and maps them into MaterialTheme for Material3 controls. + */ @Composable internal fun ClawDesignTheme( dark: Boolean = true, @@ -167,6 +185,9 @@ internal fun ClawDesignTheme( } } +/** + * Returns the system dark-mode preference for callers that expose theme selection. + */ @Composable internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/ChatEventText.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/ChatEventText.kt index 81828910f23..232bfc22b0a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/ChatEventText.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/ChatEventText.kt @@ -6,8 +6,10 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive internal object ChatEventText { + /** Extracts assistant reply text from a gateway chat event payload. */ fun assistantTextFromPayload(payload: JsonObject): String? = assistantTextFromMessage(payload["message"]) + /** Extracts text from assistant messages while ignoring non-assistant roles. */ fun assistantTextFromMessage(messageEl: JsonElement?): String? { val message = messageEl.asObjectOrNull() ?: return null val role = message["role"].asStringOrNull() @@ -19,6 +21,8 @@ internal object ChatEventText { when (content) { is JsonPrimitive -> content.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } is JsonArray -> + // Gateway content can be either bare strings or text-part objects; + // preserve part ordering when composing the spoken reply. content .mapNotNull(::textFromContentPart) .filter { it.isNotEmpty() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt index d2ac0569c14..9124e86713a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt @@ -26,11 +26,15 @@ import kotlinx.serialization.json.JsonPrimitive import java.util.UUID import kotlin.coroutines.coroutineContext +/** + * UI transcript role emitted by microphone capture and assistant streaming. + */ enum class VoiceConversationRole { User, Assistant, } +/** UI transcript entry retained for recent voice turns. */ data class VoiceConversationEntry( val id: String, val role: VoiceConversationRole, @@ -38,6 +42,7 @@ data class VoiceConversationEntry( val isStreaming: Boolean = false, ) +/** Coordinates live mic transcription, queued sends, and assistant audio replies. */ class MicCaptureManager( private val context: Context, private val scope: CoroutineScope, @@ -99,6 +104,7 @@ class MicCaptureManager( private val messageQueue = ArrayDeque() private val messageQueueLock = Any() private var flushedPartialTranscript: String? = null + // Correlates chat events with the idempotency key generated before sendChat returns. private var pendingRunId: String? = null private var pendingAssistantEntryId: String? = null private var gatewayConnected = false @@ -146,6 +152,7 @@ class MicCaptureManager( messageQueue.size } + /** Toggles manual microphone capture, draining partial transcripts when capture turns off. */ fun setMicEnabled(enabled: Boolean) { if (_micEnabled.value == enabled) return _micEnabled.value = enabled @@ -186,6 +193,7 @@ class MicCaptureManager( } } + /** Immediately stops capture and drops any unsent partial transcript. */ fun cancelMicCapture() { transcriptionDrainJob?.cancel() transcriptionDrainJob = null @@ -195,6 +203,7 @@ class MicCaptureManager( stop() } + /** Pauses capture while local TTS plays so speaker output is not transcribed as user speech. */ suspend fun pauseForTts() { val shouldPause = synchronized(ttsPauseLock) { @@ -216,6 +225,7 @@ class MicCaptureManager( stopTranscription(preserveStatus = true) } + /** Resumes capture after all nested TTS playback pauses have completed. */ suspend fun resumeAfterTts() { val shouldResume = synchronized(ttsPauseLock) { @@ -241,6 +251,7 @@ class MicCaptureManager( sendQueuedIfIdle() } + /** Starts or stops gateway-dependent capture/send work when the operator session changes state. */ fun onGatewayConnectionChanged(connected: Boolean) { gatewayConnected = connected if (connected) { @@ -267,6 +278,7 @@ class MicCaptureManager( sendQueuedIfIdle() } + /** Handles transcription and chat events that update live voice transcript/reply state. */ fun handleGatewayEvent( event: String, payloadJson: String?, @@ -611,6 +623,8 @@ class MicCaptureManager( capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) + // Drop oldest frames under network backpressure so the live transcription + // session stays close to real time instead of replaying stale audio. transcriptionAppendJob = scope.launch(Dispatchers.IO) { for (frame in audioFrames) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt index 47e0126eaec..c26b8358716 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt @@ -13,11 +13,14 @@ import kotlinx.coroutines.withContext import java.io.File internal interface TalkAudioPlaying { + /** Plays one assistant reply, replacing any active playback. */ suspend fun play(audio: TalkSpeakAudio) + /** Cancels any active assistant reply playback. */ fun stop() } +/** Android playback adapter for remote talk.speak audio payloads. */ internal class TalkAudioPlayer( private val context: Context, ) : TalkAudioPlaying { @@ -38,6 +41,7 @@ internal class TalkAudioPlayer( } } + /** Resolves playback mode from the metadata carried with a talk.speak response. */ internal fun resolvePlaybackMode(audio: TalkSpeakAudio): TalkPlaybackMode = resolvePlaybackMode( outputFormat = audio.outputFormat, @@ -46,6 +50,7 @@ internal class TalkAudioPlayer( ) companion object { + /** Chooses PCM streaming or MediaPlayer-backed playback from provider metadata. */ internal fun resolvePlaybackMode( outputFormat: String?, mimeType: String?, @@ -173,6 +178,8 @@ internal class TalkAudioPlayer( bytes: ByteArray, fileExtension: String, ) { + // MediaPlayer needs a seekable data source for several compressed formats, + // so cache the response bytes briefly instead of streaming from memory. val tempFile = withContext(Dispatchers.IO) { File.createTempFile("talk-audio-", fileExtension, context.cacheDir).apply { @@ -246,10 +253,12 @@ internal class TalkAudioPlayer( } internal sealed interface TalkPlaybackMode { + /** Raw signed 16-bit mono PCM returned by providers that support low-latency output. */ data class Pcm( val sampleRate: Int, ) : TalkPlaybackMode + /** Compressed audio that Android decodes through MediaPlayer. */ data class Compressed( val fileExtension: String, ) : TalkPlaybackMode diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt index 2afe245c8e5..d8c155fc4d5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.voice +/** Shared Talk-mode timing defaults used by capture, parser, and UI fallback paths. */ internal object TalkDefaults { const val defaultSilenceTimeoutMs = 700L } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt index f70dc7a7b05..552bba2509d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt @@ -7,6 +7,9 @@ import kotlinx.serialization.json.JsonPrimitive private val directiveJson = Json { ignoreUnknownKeys = true } +/** + * Optional first-line JSON overrides for one Talk request. + */ data class TalkDirective( val voiceId: String? = null, val modelId: String? = null, @@ -24,6 +27,9 @@ data class TalkDirective( val once: Boolean? = null, ) +/** + * Parsed directive plus the utterance text after removing the directive line. + */ data class TalkDirectiveParseResult( val directive: TalkDirective?, val stripped: String, @@ -31,6 +37,7 @@ data class TalkDirectiveParseResult( ) object TalkDirectiveParser { + /** Parses optional first-line JSON directives while preserving normal speech text. */ fun parse(text: String): TalkDirectiveParseResult { val normalized = text.replace("\r\n", "\n") val lines = normalized.split("\n").toMutableList() @@ -40,6 +47,7 @@ object TalkDirectiveParser { if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList()) val head = lines[firstNonEmpty].trim() + // Directives are accepted only as a complete first-line JSON object; spoken text remains plain text. if (!head.startsWith("{") || !head.endsWith("}")) { return TalkDirectiveParseResult(null, text, emptyList()) } @@ -88,6 +96,7 @@ object TalkDirectiveParser { if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList()) + // Keep alias matching case-insensitive so dictated JSON can use snake/camel variants. val knownKeys = setOf( "voice", @@ -216,6 +225,7 @@ private fun JsonElement?.asLongOrNull(): Long? { private fun JsonElement?.asBooleanOrNull(): Boolean? { val primitive = this as? JsonPrimitive ?: return null val content = primitive.content.trim().lowercase() + // Accept dictated/config-style booleans in addition to strict JSON literals. return when (content) { "true", "yes", "1" -> true "false", "no", "0" -> false diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt index 4f701c1bea1..35f69cae441 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt @@ -14,6 +14,7 @@ internal data class TalkModeGatewayConfigState( ) internal object TalkModeGatewayConfigParser { + /** Reads gateway talk/session config into the runtime state TalkMode needs. */ fun parse(config: JsonObject?): TalkModeGatewayConfigState { val talk = config?.get("talk").asObjectOrNull() val sessionCfg = config?.get("session").asObjectOrNull() @@ -24,6 +25,7 @@ internal object TalkModeGatewayConfigParser { ) } + /** Accepts only numeric whole-millisecond silence timeouts; malformed config uses defaults. */ fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { val fallback = TalkDefaults.defaultSilenceTimeoutMs val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 58a1a587e3b..f08bb0e65e6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -53,12 +53,18 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.coroutineContext +/** + * Gateway payload returned when Android starts a push-to-talk capture. + */ data class TalkPttStartPayload( val captureId: String, ) { fun toJson(): String = """{"captureId":"$captureId"}""" } +/** + * Gateway payload returned when a push-to-talk capture ends or is cancelled. + */ data class TalkPttStopPayload( val captureId: String, val transcript: String?, @@ -162,6 +168,7 @@ class TalkModeManager internal constructor( @Volatile private var realtimeSessionId: String? = null private var realtimeCaptureJob: Job? = null private var realtimeAppendJob: Job? = null + // Realtime tool calls can complete before their chat final arrives; cache by call/run id until both sides meet. private val realtimeToolRuns = LinkedHashMap() private val pendingRealtimeToolCalls = LinkedHashSet() private val pendingRealtimeToolCompletions = LinkedHashMap() @@ -212,12 +219,14 @@ class TalkModeManager internal constructor( } } + /** Updates the chat session used for TalkMode turns and wake-command replies. */ fun setMainSessionKey(sessionKey: String?) { val trimmed = sessionKey?.trim().orEmpty() if (trimmed.isEmpty()) return mainSessionKey = trimmed } + /** Starts or stops continuous realtime TalkMode capture. */ fun setEnabled(enabled: Boolean) { if (_isEnabled.value == enabled) return _isEnabled.value = enabled @@ -230,11 +239,13 @@ class TalkModeManager internal constructor( } } + /** Starts a push-to-talk capture session for gateway node.invoke callers. */ suspend fun beginPushToTalk(): TalkPttStartPayload { if (!isConnected()) { _statusText.value = "Gateway not connected" throw IllegalStateException("UNAVAILABLE: Gateway not connected") } + // PTT begin is idempotent so gateway retries don't start multiple recognizers. activePttCaptureId?.let { return TalkPttStartPayload(captureId = it) } stopSpeaking(resetInterrupt = false) @@ -274,6 +285,7 @@ class TalkModeManager internal constructor( return TalkPttStartPayload(captureId = captureId) } + /** Stops push-to-talk capture and queues the transcript for gateway chat. */ suspend fun endPushToTalk(): TalkPttStopPayload { val captureId = activePttCaptureId ?: UUID.randomUUID().toString() if (activePttCaptureId == null) { @@ -308,6 +320,7 @@ class TalkModeManager internal constructor( return finishPushToTalk(TalkPttStopPayload(captureId = captureId, transcript = transcript, status = "queued")) } + /** Cancels push-to-talk capture without sending the current transcript. */ suspend fun cancelPushToTalk(): TalkPttStopPayload { val captureId = activePttCaptureId ?: UUID.randomUUID().toString() if (activePttCaptureId == null) { @@ -324,6 +337,7 @@ class TalkModeManager internal constructor( return finishPushToTalk(TalkPttStopPayload(captureId = captureId, transcript = null, status = "cancelled")) } + /** Runs a bounded one-shot PTT turn that auto-stops on silence or timeout. */ suspend fun runPushToTalkOnce(maxDurationMs: Long = 12_000L): TalkPttStopPayload { if (pttCompletion != null) { cancelPushToTalk() @@ -340,6 +354,7 @@ class TalkModeManager internal constructor( val completion = CompletableDeferred() pttCompletion = completion pttAutoStopEnabled = true + // One-shot PTT auto-stops on silence or timeout; manual PTT waits for an explicit stop call. startSilenceMonitor() pttTimeoutJob = scope.launch { @@ -390,6 +405,7 @@ class TalkModeManager internal constructor( /** When true, play TTS for all final chat responses (even ones we didn't initiate). */ @Volatile var ttsOnAllResponses = false + /** Plays one text response through the configured Android/TalkMode TTS output. */ fun playTtsForText(text: String) { val playbackToken = playbackGeneration.incrementAndGet() cancelActivePlayback() @@ -401,6 +417,7 @@ class TalkModeManager internal constructor( } } + /** Routes gateway talk/chat events into realtime playback, pending PTT turns, and TTS. */ fun handleGatewayEvent( event: String, payloadJson: String?, @@ -492,6 +509,7 @@ class TalkModeManager internal constructor( handleGatewayEvent("talk.event", realtimeTranscriptPayload(sessionId = sessionId, role = "assistant", text = assistantText)) } + /** Enables or disables local assistant audio playback and stops active audio when disabled. */ fun setPlaybackEnabled(enabled: Boolean) { if (playbackEnabled == enabled) return playbackEnabled = enabled @@ -501,10 +519,12 @@ class TalkModeManager internal constructor( } } + /** Reloads TalkMode voice/TTS settings from the gateway. */ suspend fun refreshConfig() { reloadConfig() } + /** Speaks a chat assistant reply when playback is enabled. */ suspend fun speakAssistantReply(text: String) { if (!playbackEnabled) return val playbackToken = playbackGeneration.incrementAndGet() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt index f2eb32cca94..f6a31e438ab 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +/** Decoded talk.speak audio bytes plus provider metadata needed for Android playback. */ internal data class TalkSpeakAudio( val bytes: ByteArray, val provider: String, @@ -14,27 +15,33 @@ internal data class TalkSpeakAudio( val fileExtension: String?, ) +/** Result of requesting remote speech synthesis through the gateway. */ internal sealed interface TalkSpeakResult { + /** Remote synthesis returned audio that Android can route to playback. */ data class Success( val audio: TalkSpeakAudio, ) : TalkSpeakResult + /** Provider or config absence allows Android local TTS to handle the reply. */ data class FallbackToLocal( val message: String, ) : TalkSpeakResult + /** Request, payload, or audio errors that should stay visible to the caller. */ data class Failure( val message: String, ) : TalkSpeakResult } internal interface TalkSpeechSynthesizing { + /** Synthesizes assistant text using optional per-utterance talk directives. */ suspend fun synthesize( text: String, directive: TalkDirective?, ): TalkSpeakResult } +/** Gateway RPC client for talk.speak with local-TTS fallback classification. */ internal class TalkSpeakClient( private val session: GatewaySession? = null, private val json: Json = Json { ignoreUnknownKeys = true }, @@ -93,6 +100,8 @@ internal class TalkSpeakClient( private fun isFallbackEligible(error: GatewaySession.ErrorShape?): Boolean { val reason = error?.details?.reason if (reason == null) return true + // Only provider/config absence should fall back to Android TTS; payload and + // transport errors should stay visible to the caller. return reason == "talk_unconfigured" || reason == "talk_provider_unsupported" || reason == "method_unavailable" @@ -109,6 +118,7 @@ internal class TalkSpeakClient( } } +/** Gateway talk.speak request payload assembled from text plus directive overrides. */ @Serializable internal data class TalkSpeakRequest( val text: String, @@ -127,6 +137,7 @@ internal data class TalkSpeakRequest( val latencyTier: Int? = null, ) { companion object { + /** Converts parsed inline talk directives into the gateway RPC payload shape. */ fun from( text: String, directive: TalkDirective?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt index 80c797dab6e..266e584f62a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.voice object VoiceWakeCommandExtractor { + /** Extracts the command text that follows a configured wake phrase. */ fun extractCommand( text: String, triggerWords: List, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt index 5621ee13c53..50515643103 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +/** Runs Android speech recognition in a restart loop and dispatches wake-word commands. */ class VoiceWakeManager( private val context: Context, private val scope: CoroutineScope, @@ -36,10 +37,12 @@ class VoiceWakeManager( private var lastCycleDispatched: String? = null private var stopRequested = false + /** Replaces the configured wake phrases checked against partial and final transcripts. */ fun setTriggerWords(words: List) { triggerWords = words } + /** Starts wake listening if Android's speech recognizer is available. */ fun start() { mainHandler.post { if (_isListening.value) return@post @@ -62,6 +65,7 @@ class VoiceWakeManager( } } + /** Stops listening, destroys the current recognizer, and publishes a terminal status. */ fun stop(statusText: String = "Off") { stopRequested = true restartJob?.cancel() @@ -99,6 +103,8 @@ class VoiceWakeManager( mainHandler.post { if (stopRequested) return@post try { + // SpeechRecognizer sessions end frequently after partial/final results; + // restart the same recognizer cycle instead of exposing that churn to UI. recognizer?.cancel() startListeningInternal() } catch (_: Throwable) { diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index 3b969425054..0e35a495681 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -1,8 +1,3 @@ -/** - * Agent loop that works with AgentMessage throughout. - * Transforms to Message[] only at the LLM call boundary. - */ - // Keep the runtime class on the package specifier so built agent-core shares // constructor identity with @openclaw/llm-core; source types keep SDK d.ts bundled. import { EventStream as LlmEventStream } from "@openclaw/llm-core"; @@ -26,6 +21,7 @@ import type { } from "./types.js"; import { validateToolArguments } from "./validation.js"; +/** Callback used by synchronous loop runners to publish agent lifecycle events. */ export type AgentEventSink = (event: AgentEvent) => Promise | void; const EMPTY_USAGE = { @@ -119,6 +115,7 @@ export function agentLoopContinue( return stream; } +/** Run a prompt-started loop and emit events through a caller-owned sink. */ export async function runAgentLoop( prompts: AgentMessage[], context: AgentContext, @@ -145,6 +142,7 @@ export async function runAgentLoop( return newMessages; } +/** Continue an existing loop context and emit only newly produced messages. */ export async function runAgentLoopContinue( context: AgentContext, config: AgentLoopConfig, @@ -326,10 +324,10 @@ async function runLoop( pendingMessages = (await config.getSteeringMessages?.()) || []; } - // Agent would stop here. Check for follow-up messages. const followUpMessages = (await config.getFollowUpMessages?.()) || []; if (followUpMessages.length > 0) { - // Set as pending so inner loop processes them + // Follow-up messages arrive after a turn would otherwise end; route them through the + // same pending-message path so event ordering matches steering messages. pendingMessages = followUpMessages; continue; } diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 31ef159163f..522df048cea 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -100,33 +100,51 @@ function createMutableAgentState( /** Options for constructing an {@link Agent}. */ export interface AgentOptions { + /** Initial transcript, tools, model, and prompt state. */ initialState?: Partial< Omit >; + /** Convert agent-owned transcript messages into provider-facing messages. */ convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + /** Optionally rewrite context before each provider request. */ transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + /** Injected stream runtime used when streamFn is not supplied. */ runtime?: AgentCoreStreamRuntimeDeps; + /** Explicit stream implementation, preferred over runtime.streamSimple. */ streamFn?: StreamFn; + /** Resolve provider API keys at request time. */ getApiKey?: (provider: string) => Promise | string | undefined; + /** Inspect the provider payload before it is sent. */ onPayload?: SimpleStreamOptions["onPayload"]; + /** Inspect the provider response after it returns. */ onResponse?: SimpleStreamOptions["onResponse"]; + /** Hook that may short-circuit or alter a tool call before execution. */ beforeToolCall?: ( context: BeforeToolCallContext, signal?: AbortSignal, ) => Promise; + /** Hook that may alter a tool result after execution. */ afterToolCall?: ( context: AfterToolCallContext, signal?: AbortSignal, ) => Promise; + /** Hook that may update model, reasoning, or context after a turn. */ prepareNextTurn?: ( signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; + /** Queue drain mode for steering messages injected before the next assistant response. */ steeringMode?: QueueMode; + /** Queue drain mode for follow-up messages injected after the agent would otherwise stop. */ followUpMode?: QueueMode; + /** Session identifier forwarded to cache-aware providers. */ sessionId?: string; + /** Optional per-thinking-level token budgets forwarded to providers. */ thinkingBudgets?: ThinkingBudgets; + /** Preferred provider transport. */ transport?: Transport; + /** Optional cap for provider-requested retry delays. */ maxRetryDelayMs?: number; + /** Default strategy for executing multiple tool calls in one assistant message. */ toolExecution?: ToolExecutionMode; } @@ -153,6 +171,7 @@ class PendingMessageQueue { return drained; } + // one-at-a-time preserves later queued messages for subsequent loop turns. const first = this.messages[0]; if (!first) { return []; diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.ts b/packages/agent-core/src/harness/compaction/branch-summarization.ts index ecf26a15a42..ea71aabc147 100644 --- a/packages/agent-core/src/harness/compaction/branch-summarization.ts +++ b/packages/agent-core/src/harness/compaction/branch-summarization.ts @@ -51,11 +51,15 @@ export interface CollectEntriesResult { commonAncestorId: string | null; } +/** Minimal tree entry shape needed to compare two session branches. */ export interface BranchPathEntry { + /** Stable entry id. */ id: string; + /** Parent entry id, or null for the session root. */ parentId: string | null; } +/** Branch entries selected after comparing old and target paths. */ export interface CollectBranchPathEntriesResult { /** Entries to summarize in chronological order. */ entries: TEntry[]; @@ -106,7 +110,7 @@ export function collectEntriesForBranchSummaryFromBranches 0 && totalTokens + tokens > tokenBudget) { + // Prefer already-compressed summaries when the budget is almost filled; they + // preserve older branch context better than dropping the whole prefix. if (entry.type === "compaction" || entry.type === "branch_summary") { if (totalTokens < tokenBudget * 0.9) { messages.unshift(message); diff --git a/packages/agent-core/src/harness/env/nodejs.ts b/packages/agent-core/src/harness/env/nodejs.ts index 5ba6fd0dcca..5811993c732 100644 --- a/packages/agent-core/src/harness/env/nodejs.ts +++ b/packages/agent-core/src/harness/env/nodejs.ts @@ -35,6 +35,7 @@ function resolvePath(cwd: string, path: string): string { return isAbsolute(path) ? path : resolve(cwd, path); } +/** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */ export function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined { if ( typeof timeoutSeconds !== "number" || @@ -241,6 +242,7 @@ function getShellEnv( }; } +/** Node-backed execution environment for agent harness filesystem and shell operations. */ export class NodeExecutionEnv implements ExecutionEnv { cwd: string; private shellPath?: string; diff --git a/packages/agent-core/src/harness/file-loader-utils.ts b/packages/agent-core/src/harness/file-loader-utils.ts index 5ef3791addb..668f35128df 100644 --- a/packages/agent-core/src/harness/file-loader-utils.ts +++ b/packages/agent-core/src/harness/file-loader-utils.ts @@ -12,6 +12,7 @@ interface FileInfoDiagnostics { push(diagnostic: FileInfoDiagnostic): unknown; } +/** Parse optional YAML frontmatter and return the normalized Markdown body. */ export function parseFrontmatter( content: string, ): Result<{ frontmatter: Record; body: string }, Error> { @@ -35,6 +36,7 @@ export function parseFrontmatter( } } +/** Resolve symlink or unknown file info into the concrete loadable file kind. */ export async function resolveFileInfoKind( env: ExecutionEnv, info: FileInfo, @@ -72,22 +74,26 @@ export async function resolveFileInfoKind( : undefined; } +/** Join harness environment paths without requiring Node path semantics. */ export function joinEnvPath(base: string, child: string): string { return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; } +/** Return the parent path for slash-separated harness environment paths. */ export function dirnameEnvPath(path: string): string { const normalized = path.replace(/\/+$/, ""); const slashIndex = normalized.lastIndexOf("/"); return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); } +/** Return the leaf name for slash-separated harness environment paths. */ export function basenameEnvPath(path: string): string { const normalized = path.replace(/\/+$/, ""); const slashIndex = normalized.lastIndexOf("/"); return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); } +/** Return a root-relative path when possible, otherwise a display-safe non-absolute path. */ export function relativeEnvPath(root: string, path: string): string { const normalizedRoot = root.replace(/\/+$/, ""); const normalizedPath = path.replace(/\/+$/, ""); diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts index bc0ffb6f93d..dfc2ed70490 100644 --- a/packages/agent-core/src/harness/messages.ts +++ b/packages/agent-core/src/harness/messages.ts @@ -15,6 +15,7 @@ export type { CustomMessage, } from "../types.js"; +/** Harness-only transcript entries that can be normalized into LLM messages. */ export type HarnessMessage = | AgentMessage | BashExecutionMessage @@ -52,6 +53,7 @@ export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch tha export const BRANCH_SUMMARY_SUFFIX = ``; +/** Render a shell execution record as user-visible context text for the model. */ export function bashExecutionToText(msg: BashExecutionMessage): string { let text = `Ran \`${msg.command}\`\n`; if (msg.output) { @@ -70,6 +72,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { return text; } +/** Build a persisted branch summary message from the repository timestamp string. */ export function createBranchSummaryMessage( summary: string, fromId: string, @@ -83,6 +86,7 @@ export function createBranchSummaryMessage( }; } +/** Build a persisted compaction summary message from the repository timestamp string. */ export function createCompactionSummaryMessage( summary: string, tokensBefore: number, @@ -96,6 +100,7 @@ export function createCompactionSummaryMessage( }; } +/** Build a custom transcript message that can be shown and replayed into context. */ export function createCustomMessage( customType: string, content: string | (TextContent | ImageContent)[], @@ -113,6 +118,7 @@ export function createCustomMessage( }; } +/** Convert harness transcript messages into the LLM-facing message sequence. */ export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | undefined => { diff --git a/packages/agent-core/src/harness/prompt-template-arguments.ts b/packages/agent-core/src/harness/prompt-template-arguments.ts index 42375be99f4..aa6f78fd5fe 100644 --- a/packages/agent-core/src/harness/prompt-template-arguments.ts +++ b/packages/agent-core/src/harness/prompt-template-arguments.ts @@ -33,7 +33,12 @@ function parseSafeNonNegativeInteger(raw: string): number | undefined { return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; } -/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ +/** + * Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. + * + * Unsafe integer placeholders resolve to empty text instead of throwing, so malformed templates cannot abort prompt + * loading or invocation. + */ export function substituteArgs(content: string, args: string[]): string { let result = content; result = result.replace(/\$(\d+)/g, (_, num: string) => { @@ -50,6 +55,8 @@ export function substituteArgs(content: string, args: string[]): string { if (parsedStart === undefined) { return ""; } + // Keep shell-style `${@:0:...}` compatibility: start 0 includes `$0` in shell, but + // prompt templates have no command name, so it maps to the first provided argument. let start = parsedStart - 1; if (start < 0) { start = 0; diff --git a/packages/agent-core/src/harness/session/jsonl-repo.ts b/packages/agent-core/src/harness/session/jsonl-repo.ts index cd1ceb3f4b3..87bc3506ac1 100644 --- a/packages/agent-core/src/harness/session/jsonl-repo.ts +++ b/packages/agent-core/src/harness/session/jsonl-repo.ts @@ -36,6 +36,7 @@ function encodeCwd(cwd: string): string { return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } +/** Repository for JSONL sessions grouped by working directory. */ export class JsonlSessionRepo implements JsonlSessionRepoApi { private readonly fs: JsonlSessionRepoFileSystem; private readonly sessionsRootInput: string; @@ -130,6 +131,8 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi { sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); } catch (error) { const cause = toError(error); + // Listing is best-effort across a sessions directory; corrupt session + // headers are skipped, while filesystem and unexpected errors still fail. if (!(cause instanceof SessionError) || cause.code !== "invalid_session") { throw cause; } diff --git a/packages/agent-core/src/harness/session/jsonl-storage.ts b/packages/agent-core/src/harness/session/jsonl-storage.ts index 8ed14ae5dce..5c426486a12 100644 --- a/packages/agent-core/src/harness/session/jsonl-storage.ts +++ b/packages/agent-core/src/harness/session/jsonl-storage.ts @@ -125,6 +125,7 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess }; } +/** Read only the JSONL session header and convert it to session metadata. */ export async function loadJsonlSessionMetadata( fs: JsonlSessionStorageFileSystem, filePath: string, @@ -168,6 +169,7 @@ async function loadJsonlStorage( return { header, entries, leafId }; } +/** Append-only JSONL-backed storage for one session tree. */ export class JsonlSessionStorage extends BaseSessionStorage { private readonly fs: JsonlSessionStorageFileSystem; private readonly filePath: string; @@ -192,6 +194,7 @@ export class JsonlSessionStorage extends BaseSessionStorage extends BaseSessionStorage { diff --git a/packages/agent-core/src/harness/session/session.ts b/packages/agent-core/src/harness/session/session.ts index 08f6429c459..02afb0bc82f 100644 --- a/packages/agent-core/src/harness/session/session.ts +++ b/packages/agent-core/src/harness/session/session.ts @@ -23,6 +23,7 @@ import type { } from "../types.js"; import { SessionError } from "../types.js"; +/** Build model context from the active session branch and its latest state markers. */ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; @@ -76,6 +77,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon const compactionIdx = pathEntries.findIndex( (e) => e.type === "compaction" && e.id === compaction.id, ); + // Replay only the compacted entry's retained tail plus newer branch entries; older + // transcript content is represented by the synthetic compaction summary above. let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = pathEntries[i]; @@ -98,6 +101,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon return { messages, thinkingLevel, model }; } +/** High-level session API backed by pluggable tree storage. */ export class Session { private storage: SessionStorage; @@ -199,6 +203,7 @@ export class Session { } satisfies CompactionEntry); } + /** Append a non-LLM transcript marker for harness-specific state. */ async appendCustomEntry(customType: string, data?: unknown): Promise { return this.appendTypedEntry({ type: "custom", @@ -210,6 +215,7 @@ export class Session { } satisfies CustomEntry); } + /** Append harness-specific content that can also be replayed into model context. */ async appendCustomMessageEntry( customType: string, content: string | (TextContent | ImageContent)[], @@ -228,6 +234,7 @@ export class Session { } satisfies CustomMessageEntry); } + /** Record or clear the display label for an existing session entry. */ async appendLabel(targetId: string, label: string | undefined): Promise { if (!(await this.storage.getEntry(targetId))) { throw new SessionError("not_found", `Entry ${targetId} not found`); @@ -252,6 +259,7 @@ export class Session { } satisfies SessionInfoEntry); } + /** Move the visible branch leaf and optionally attach a summary of the abandoned branch. */ async moveTo( entryId: string | null, summary?: { summary: string; details?: unknown; fromHook?: boolean }, diff --git a/packages/agent-core/src/harness/session/storage-base.ts b/packages/agent-core/src/harness/session/storage-base.ts index e8a311c81e0..66092d12172 100644 --- a/packages/agent-core/src/harness/session/storage-base.ts +++ b/packages/agent-core/src/harness/session/storage-base.ts @@ -37,6 +37,7 @@ function generateEntryId(byId: { has(id: string): boolean }): string { return uuidv7(); } +/** Return the effective branch leaf after applying a session tree entry. */ export function leafIdAfterEntry(entry: SessionTreeEntry): string | null { return entry.type === "leaf" ? entry.targetId : entry.id; } @@ -102,6 +103,8 @@ export abstract class BaseSessionStorage< } protected recordEntry(entry: SessionTreeEntry): void { + // Leaf and label entries are append-only state changes; keep derived indexes + // synchronized here so memory and JSONL storage expose identical behavior. this.entries.push(entry); this.byId.set(entry.id, entry); updateLabelCache(this.labelsById, entry); diff --git a/packages/agent-core/src/harness/system-prompt.ts b/packages/agent-core/src/harness/system-prompt.ts index 51327d510bb..7030e51fc30 100644 --- a/packages/agent-core/src/harness/system-prompt.ts +++ b/packages/agent-core/src/harness/system-prompt.ts @@ -1,6 +1,9 @@ import type { Skill } from "./types.js"; +/** Format model-visible skill metadata for inclusion in the harness system prompt. */ export function formatSkillsForSystemPrompt(skills: Skill[]): string { + // Hidden skills can still be invoked directly by host code, but should not be + // advertised to the model for autonomous selection. const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); if (visibleSkills.length === 0) { return ""; diff --git a/packages/agent-core/src/harness/types.ts b/packages/agent-core/src/harness/types.ts index 3ec8038e21a..b81e591d215 100644 --- a/packages/agent-core/src/harness/types.ts +++ b/packages/agent-core/src/harness/types.ts @@ -361,29 +361,38 @@ export interface Shell { /** Filesystem and process execution environment used by the harness. */ export interface ExecutionEnv extends FileSystem, Shell {} +/** Base fields shared by append-only session tree entries. */ export interface SessionTreeEntryBase { + /** Entry discriminator used for JSONL persistence and typed narrowing. */ type: string; + /** Stable entry id unique within a session file. */ id: string; + /** Parent entry id, or null for a root entry. */ parentId: string | null; + /** ISO timestamp string used for persistence and sorting. */ timestamp: string; } +/** Persisted transcript message entry. */ export interface MessageEntry extends SessionTreeEntryBase { type: "message"; message: AgentMessage; } +/** Persisted thinking-level selection marker. */ export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { type: "thinking_level_change"; thinkingLevel: string; } +/** Persisted model selection marker. */ export interface ModelChangeEntry extends SessionTreeEntryBase { type: "model_change"; provider: string; modelId: string; } +/** Persisted summary that replaces older transcript history in context. */ export interface CompactionEntry extends SessionTreeEntryBase { type: "compaction"; summary: string; @@ -393,6 +402,7 @@ export interface CompactionEntry extends SessionTreeEntryBase { fromHook?: boolean; } +/** Persisted summary of an abandoned branch when navigating the session tree. */ export interface BranchSummaryEntry extends SessionTreeEntryBase { type: "branch_summary"; fromId: string; @@ -401,12 +411,14 @@ export interface BranchSummaryEntry extends SessionTreeEntryBase { fromHook?: boolean; } +/** Persisted harness/application marker that is not replayed into model context. */ export interface CustomEntry extends SessionTreeEntryBase { type: "custom"; customType: string; data?: T; } +/** Persisted harness/application message that can be replayed into model context. */ export interface CustomMessageEntry extends SessionTreeEntryBase { type: "custom_message"; customType: string; @@ -415,22 +427,27 @@ export interface CustomMessageEntry extends SessionTreeEntryBase { display: boolean; } +/** Append-only label update for another session entry. */ export interface LabelEntry extends SessionTreeEntryBase { type: "label"; targetId: string; label: string | undefined; } +/** Persisted session metadata marker. */ export interface SessionInfoEntry extends SessionTreeEntryBase { - type: "session_info"; // legacy name, kept for backwards compatibility + // The persisted discriminator predates the public "session name" wording. + type: "session_info"; name?: string; } +/** Append-only marker that changes the active visible leaf. */ export interface LeafEntry extends SessionTreeEntryBase { type: "leaf"; targetId: string | null; } +/** All persisted session tree entry variants. */ export type SessionTreeEntry = | MessageEntry | ThinkingLevelChangeEntry @@ -679,28 +696,36 @@ export type AgentHarnessEvent< TPromptTemplate extends PromptTemplate = PromptTemplate, > = AgentEvent | AgentHarnessOwnEvent; +/** Hook result for mutating the initial prompt run before the agent starts. */ export interface BeforeAgentStartResult { + /** Replacement messages for the prompt run. */ messages?: AgentMessage[]; + /** Replacement system prompt for the prompt run. */ systemPrompt?: string; } +/** Hook result for replacing the full context message list before provider conversion. */ export interface ContextResult { messages: AgentMessage[]; } +/** Hook result for patching provider request options before payload construction. */ export interface BeforeProviderRequestResult { streamOptions?: AgentHarnessStreamOptionsPatch; } +/** Hook result for replacing the provider payload after construction. */ export interface BeforeProviderPayloadResult { payload: unknown; } +/** Hook result for blocking a tool call before execution. */ export interface ToolCallResult { block?: boolean; reason?: string; } +/** Hook patch for a completed tool result before it is persisted/emitted. */ export interface ToolResultPatch { content?: Array; details?: unknown; @@ -708,11 +733,13 @@ export interface ToolResultPatch { terminate?: boolean; } +/** Hook result for cancelling or replacing a planned compaction. */ export interface SessionBeforeCompactResult { cancel?: boolean; compaction?: CompactResult; } +/** Hook result for cancelling, labeling, or supplying branch-summary behavior before tree navigation. */ export interface SessionBeforeTreeResult { cancel?: boolean; summary?: { summary: string; details?: unknown }; @@ -721,6 +748,7 @@ export interface SessionBeforeTreeResult { label?: string; } +/** Typed return values expected from AgentHarness hook handlers by event type. */ export type AgentHarnessEventResultMap = { before_agent_start: BeforeAgentStartResult | undefined; context: ContextResult | undefined; @@ -742,15 +770,18 @@ export type AgentHarnessEventResultMap = { settled: undefined; }; +/** Options for a prompt submitted through AgentHarness. */ export interface AgentHarnessPromptOptions { images?: ImageContent[]; } +/** Queued messages removed by an abort operation. */ export interface AbortResult { clearedSteer: AgentMessage[]; clearedFollowUp: AgentMessage[]; } +/** Compaction data supplied by hooks or returned from compaction preparation. */ export interface CompactResult { summary: string; firstKeptEntryId: string; @@ -758,18 +789,21 @@ export interface CompactResult { details?: unknown; } +/** Result of moving the active session-tree leaf. */ export interface NavigateTreeResult { cancelled: boolean; editorText?: string; summaryEntry?: BranchSummaryEntry; } +/** Settings that control automatic context compaction. */ export interface CompactionSettings { enabled: boolean; reserveTokens: number; keepRecentTokens: number; } +/** Prepared compaction inputs exposed to hooks before a summary is generated. */ export interface CompactionPreparation { firstKeptEntryId: string; messagesToSummarize: AgentMessage[]; @@ -781,12 +815,14 @@ export interface CompactionPreparation { settings: CompactionSettings; } +/** File operations accumulated from summarized transcript ranges. */ export interface FileOperations { read: Set; written: Set; edited: Set; } +/** Prepared branch navigation inputs exposed to hooks before a summary is generated. */ export interface TreePreparation { targetId: string; oldLeafId: string | null; @@ -798,6 +834,7 @@ export interface TreePreparation { label?: string; } +/** Options for generating a branch summary. */ export interface GenerateBranchSummaryOptions { model: Model; apiKey: string; @@ -810,12 +847,14 @@ export interface GenerateBranchSummaryOptions { reserveTokens?: number; } +/** Generated branch summary text and file-operation metadata. */ export interface BranchSummaryResult { summary: string; readFiles: string[]; modifiedFiles: string[]; } +/** Construction options for AgentHarness. */ export interface AgentHarnessOptions< TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate, diff --git a/packages/agent-core/src/harness/utils/truncate.ts b/packages/agent-core/src/harness/utils/truncate.ts index c4740ce35f4..88fb3cdbdaf 100644 --- a/packages/agent-core/src/harness/utils/truncate.ts +++ b/packages/agent-core/src/harness/utils/truncate.ts @@ -1,17 +1,8 @@ -/** - * Shared truncation utilities for tool outputs. - * - * Truncation is based on two independent limits - whichever is hit first wins: - * - Line limit (default: 2000 lines) - * - Byte limit (default: 50KB) - * - * Never returns partial lines (except bash tail truncation edge case). - */ - export const DEFAULT_MAX_LINES = 2000; export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line +/** Result metadata for content truncated by line count, byte count, or both. */ export interface TruncationResult { /** The truncated content */ content: string; @@ -37,6 +28,7 @@ export interface TruncationResult { maxBytes: number; } +/** Byte and line ceilings used by the truncation helpers. */ export interface TruncationOptions { /** Maximum number of lines (default: 2000) */ maxLines?: number; @@ -134,7 +126,7 @@ function replaceUnpairedSurrogates(content: string): string { } /** - * Format bytes as human-readable size. + * Format byte counts for compact tool-output diagnostics. */ export function formatSize(bytes: number): string { if (bytes < 1024) { @@ -190,11 +182,10 @@ function buildTruncationResult( } /** - * Truncate content from the head (keep first N lines/bytes). - * Suitable for file reads where you want to see the beginning. + * Keep the beginning of content while respecting independent line and byte ceilings. * - * Never returns partial lines. If first line exceeds byte limit, - * returns empty content with firstLineExceedsLimit=true. + * Head truncation preserves complete lines; a first line that exceeds the byte + * ceiling produces empty output and sets firstLineExceedsLimit. */ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { const input = resolveTruncationInput(content, options); @@ -257,10 +248,10 @@ export function truncateHead(content: string, options: TruncationOptions = {}): } /** - * Truncate content from the tail (keep last N lines/bytes). - * Suitable for bash output where you want to see the end (errors, final results). + * Keep the end of content while respecting independent line and byte ceilings. * - * May return partial first line if the last line of original content exceeds byte limit. + * Tail truncation preserves recent output for command errors and may keep a + * partial first line when one final line alone exceeds the byte ceiling. */ export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { const input = resolveTruncationInput(content, options); @@ -366,8 +357,7 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { } /** - * Truncate a single line to max characters, adding [truncated] suffix. - * Used for grep match lines. + * Trim a single display line and mark it with the grep-style truncation suffix. */ export function truncateLine( line: string, diff --git a/packages/agent-core/src/runtime-deps.ts b/packages/agent-core/src/runtime-deps.ts index 5a1cd1d8556..01ce1131775 100644 --- a/packages/agent-core/src/runtime-deps.ts +++ b/packages/agent-core/src/runtime-deps.ts @@ -1,11 +1,16 @@ import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js"; +/** Runtime functions injected by host packages so agent-core stays provider-agnostic. */ export interface AgentCoreRuntimeDeps { + /** Streaming completion implementation used for normal agent turns. */ streamSimple: StreamFn; + /** Non-streaming completion implementation used by summarization helpers. */ completeSimple: CompleteSimpleFn; } +/** Runtime dependency subset required by streaming agent loops. */ export type AgentCoreStreamRuntimeDeps = Pick; +/** Runtime dependency subset required by summarization helpers. */ export type AgentCoreCompletionRuntimeDeps = Pick; function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error { @@ -14,6 +19,7 @@ function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error { ); } +/** Resolve the stream function, preferring an explicit override over injected runtime deps. */ export function resolveAgentCoreStreamFn( runtime: AgentCoreStreamRuntimeDeps | undefined, streamFn?: StreamFn, @@ -27,6 +33,7 @@ export function resolveAgentCoreStreamFn( throw missingRuntimeDep("streamSimple"); } +/** Resolve the completion function used by non-streaming helper flows. */ export function resolveAgentCoreCompleteFn( runtime: AgentCoreCompletionRuntimeDeps | undefined, ): CompleteSimpleFn { diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index 5e2e95b1937..7baf77299d1 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -288,40 +288,66 @@ export interface AgentLoopConfig extends SimpleStreamOptions { export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; export interface BashExecutionMessage { + /** Harness role for shell command transcripts. */ role: "bashExecution"; + /** Command line that was executed. */ command: string; + /** Captured command output, usually already truncated for context. */ output: string; + /** Process exit code when the command reached process exit. */ exitCode: number | undefined; + /** True when the command was interrupted before normal completion. */ cancelled: boolean; + /** True when output was shortened for transcript/context storage. */ truncated: boolean; + /** Optional path containing the complete output when truncation occurred. */ fullOutputPath?: string; + /** Millisecond timestamp for transcript ordering. */ timestamp: number; + /** Exclude this command transcript from model context while keeping it in session history. */ excludeFromContext?: boolean; } export interface CustomMessage { + /** Harness role for application-defined transcript content. */ role: "custom"; + /** Application-defined discriminator for rendering or handling this message. */ customType: string; + /** Content replayed into model context when this message is included. */ content: string | (TextContent | ImageContent)[]; + /** Whether UI surfaces should display this message. */ display: boolean; + /** Optional application-specific metadata. */ details?: T; + /** Millisecond timestamp for transcript ordering. */ timestamp: number; } export interface BranchSummaryMessage { + /** Harness role for summaries produced when returning from another branch. */ role: "branchSummary"; + /** Summary text inserted back into model context. */ summary: string; + /** Entry id of the branch root or source leaf being summarized. */ fromId: string; + /** Millisecond timestamp for transcript ordering. */ timestamp: number; } export interface CompactionSummaryMessage { + /** Harness role for summaries that replace compacted transcript history. */ role: "compactionSummary"; + /** Summary text inserted back into model context. */ summary: string; + /** Estimated context tokens before compaction. */ tokensBefore: number; + /** Timestamp may be numeric in memory or string when loaded from older persisted rows. */ timestamp: number | string; + /** Optional estimated context tokens after compaction. */ tokensAfter?: number; + /** Optional first retained entry id from the compaction range. */ firstKeptEntryId?: string; + /** Optional implementation-specific compaction metadata. */ details?: unknown; } diff --git a/packages/gateway-client/src/event-loop-ready.ts b/packages/gateway-client/src/event-loop-ready.ts index f5c97cb0b20..10c2c87f42e 100644 --- a/packages/gateway-client/src/event-loop-ready.ts +++ b/packages/gateway-client/src/event-loop-ready.ts @@ -1,5 +1,6 @@ import { resolveFiniteTimeoutDelayMs } from "./timeouts.js"; +/** Readiness probe outcome with timing data for diagnosing event-loop stalls. */ export type EventLoopReadyResult = { ready: boolean; elapsedMs: number; @@ -8,6 +9,7 @@ export type EventLoopReadyResult = { aborted: boolean; }; +/** Controls how aggressively the client waits for low-drift timer checks before starting IO. */ export type EventLoopReadyOptions = { maxWaitMs?: number; intervalMs?: number; @@ -25,6 +27,7 @@ function resolvePositiveInteger(value: number | undefined, fallback: number): nu return Number.isFinite(value) && value !== undefined ? Math.max(1, Math.floor(value)) : fallback; } +/** Waits until timer drift stays low for consecutive checks, or aborts/times out. */ export async function waitForEventLoopReady( options: EventLoopReadyOptions = {}, ): Promise { diff --git a/packages/gateway-client/src/readiness.ts b/packages/gateway-client/src/readiness.ts index c03255bb9cf..3ace314671d 100644 --- a/packages/gateway-client/src/readiness.ts +++ b/packages/gateway-client/src/readiness.ts @@ -10,10 +10,12 @@ export type GatewayClientStartable = { start(): void; }; +/** Injectable readiness waiter used by tests and alternate event-loop probes. */ export type EventLoopReadyWaiter = ( options?: EventLoopReadyOptions, ) => Promise; +/** Timeout and abort controls for delaying client start until the loop can process IO. */ export type GatewayClientStartReadinessOptions = { timeoutMs?: number; clientOptions?: Pick< @@ -43,6 +45,7 @@ function resolveGatewayClientStartReadinessTimeoutMs( }); } +/** Starts a gateway client only after the supplied readiness probe succeeds. */ export async function startGatewayClientWithReadinessWait( waitForReady: EventLoopReadyWaiter, client: GatewayClientStartable, @@ -58,6 +61,7 @@ export async function startGatewayClientWithReadinessWait( return readiness; } +/** Starts a gateway client after the default event-loop readiness probe succeeds. */ export async function startGatewayClientWhenEventLoopReady( client: GatewayClientStartable, options: GatewayClientStartReadinessOptions = {}, diff --git a/packages/gateway-client/src/timeouts.ts b/packages/gateway-client/src/timeouts.ts index bb98269a6fe..5e2c02a5902 100644 --- a/packages/gateway-client/src/timeouts.ts +++ b/packages/gateway-client/src/timeouts.ts @@ -7,11 +7,16 @@ function parseStrictPositiveInteger(value: string): number | undefined { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; } +/** Maximum delay Node timers can represent without overflow warnings. */ export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647; +/** Default server-side window for gateway preauth handshakes. */ export const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15_000; +/** Minimum client watchdog delay for connect challenge setup. */ export const MIN_CONNECT_CHALLENGE_TIMEOUT_MS = 250; +/** Default maximum client watchdog delay, aligned with the preauth server timeout. */ export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; +/** Clamps arbitrary timer delays to Node's safe range and an optional floor. */ export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number { const rawMinMs = opts?.minMs ?? 1; const minMs = Math.min( @@ -22,6 +27,7 @@ export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: numb return Math.min(MAX_SAFE_TIMEOUT_DELAY_MS, Math.max(minMs, candidateMs)); } +/** Adds grace time while preserving safe timer bounds if inputs overflow or are invalid. */ export function addSafeTimeoutDelayGraceMs( delayMs: number, graceMs: number, @@ -37,6 +43,7 @@ export function addSafeTimeoutDelayGraceMs( ); } +/** Resolves optional timeout values through a fallback and safe timer clamp. */ export function resolveFiniteTimeoutDelayMs( delayMs: number | null | undefined, fallbackMs: number, @@ -47,6 +54,7 @@ export function resolveFiniteTimeoutDelayMs( return resolveSafeTimeoutDelayMs(candidateMs, opts); } +/** Clamps connect challenge watchdog timeouts to the gateway-supported range. */ export function clampConnectChallengeTimeoutMs( timeoutMs: number, maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS, @@ -57,6 +65,7 @@ export function clampConnectChallengeTimeoutMs( ); } +/** Reads the connect challenge watchdog override from the process environment. */ export function getConnectChallengeTimeoutMsFromEnv( env: NodeJS.ProcessEnv = process.env, ): number | undefined { @@ -76,6 +85,7 @@ function normalizePositiveTimeoutMs(timeoutMs: unknown): number | undefined { : undefined; } +/** Resolves the client watchdog timeout using explicit, env, then preauth defaults. */ export function resolveConnectChallengeTimeoutMs( timeoutMs?: number | null, params?: { @@ -100,6 +110,7 @@ export function resolveConnectChallengeTimeoutMs( return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs); } +/** Reads the preauth handshake timeout override from environment variables. */ export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number { const configuredTimeout = env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || (env.VITEST && env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); @@ -112,6 +123,7 @@ export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = pro return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; } +/** Resolves the server preauth timeout from env, explicit config, or default. */ export function resolvePreauthHandshakeTimeoutMs(params?: { env?: NodeJS.ProcessEnv; configuredTimeoutMs?: number | null; diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index 6109f91e2c1..16d8cfa3c7e 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -22,10 +22,12 @@ export const GATEWAY_CLIENT_IDS = { PROBE: "openclaw-probe", } as const; +/** Stable gateway client ids used on the wire during hello/connect handshakes. */ export type GatewayClientId = (typeof GATEWAY_CLIENT_IDS)[keyof typeof GATEWAY_CLIENT_IDS]; // Back-compat naming (internal): these values are IDs, not display names. export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS; +/** Compatibility alias for internal callers that still use "name" terminology. */ export type GatewayClientName = GatewayClientId; export const GATEWAY_CLIENT_MODES = { @@ -38,8 +40,10 @@ export const GATEWAY_CLIENT_MODES = { TEST: "test", } as const; +/** Coarse client category used for gateway policy and diagnostics. */ export type GatewayClientMode = (typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEWAY_CLIENT_MODES]; +/** Client metadata sent during gateway connection setup. */ export type GatewayClientInfo = { id: GatewayClientId; displayName?: string; @@ -55,11 +59,13 @@ export const GATEWAY_CLIENT_CAPS = { TOOL_EVENTS: "tool-events", } as const; +/** Optional capability advertised by clients during gateway handshake. */ export type GatewayClientCap = (typeof GATEWAY_CLIENT_CAPS)[keyof typeof GATEWAY_CLIENT_CAPS]; const GATEWAY_CLIENT_ID_SET = new Set(Object.values(GATEWAY_CLIENT_IDS)); const GATEWAY_CLIENT_MODE_SET = new Set(Object.values(GATEWAY_CLIENT_MODES)); +/** Normalizes untrusted client ids and rejects unknown values. */ export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined { const normalized = normalizeOptionalLowercaseString(raw); if (!normalized) { @@ -70,10 +76,12 @@ export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | : undefined; } +/** Normalizes legacy client-name fields through the canonical client-id registry. */ export function normalizeGatewayClientName(raw?: string | null): GatewayClientName | undefined { return normalizeGatewayClientId(raw); } +/** Normalizes untrusted client modes and rejects unknown values. */ export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMode | undefined { const normalized = normalizeOptionalLowercaseString(raw); if (!normalized) { @@ -84,6 +92,7 @@ export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMo : undefined; } +/** Checks a client-advertised capability list without treating missing caps as errors. */ export function hasGatewayClientCap( caps: string[] | null | undefined, cap: GatewayClientCap, diff --git a/packages/gateway-protocol/src/secret-ref-contract.ts b/packages/gateway-protocol/src/secret-ref-contract.ts index b5aea66bc69..fcef569f814 100644 --- a/packages/gateway-protocol/src/secret-ref-contract.ts +++ b/packages/gateway-protocol/src/secret-ref-contract.ts @@ -1,7 +1,12 @@ +/** Canonical id for file secret providers that expose exactly one value. */ export const SINGLE_VALUE_FILE_REF_ID = "value"; +/** Shared alias grammar for env/file/exec secret provider names. */ export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; +/** JSON-schema fragment that rejects absolute file secret ref ids. */ export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/"; +/** JSON-schema fragment that rejects invalid JSON-pointer escape sequences. */ export const FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN = "~(?:[^01]|$)"; +/** JSON-schema pattern for exec secret ref ids, excluding dot-path traversal. */ export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN = "^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$"; diff --git a/packages/gateway-protocol/src/startup-unavailable.ts b/packages/gateway-protocol/src/startup-unavailable.ts index dffadcc7bcb..2c2705ff9bc 100644 --- a/packages/gateway-protocol/src/startup-unavailable.ts +++ b/packages/gateway-protocol/src/startup-unavailable.ts @@ -1,15 +1,22 @@ +/** Structured error reason used while gateway startup sidecars are still initializing. */ export const GATEWAY_STARTUP_UNAVAILABLE_REASON = "startup-sidecars"; +/** Internal close cause that distinguishes startup retry closes from generic disconnects. */ export const GATEWAY_STARTUP_PENDING_CLOSE_CAUSE = "startup-sidecars-pending"; +/** WebSocket close code for temporary gateway unavailability. */ export const GATEWAY_STARTUP_CLOSE_CODE = 1013; +/** Human-readable WebSocket close reason for temporary gateway startup unavailability. */ export const GATEWAY_STARTUP_CLOSE_REASON = "gateway starting"; +/** Default retry-after hint sent with startup-unavailable handshake errors. */ export const GATEWAY_STARTUP_RETRY_AFTER_MS = 500; const GATEWAY_STARTUP_RETRY_MIN_MS = 100; const GATEWAY_STARTUP_RETRY_MAX_MS = 2_000; +/** Details payload attached to retryable startup-unavailable gateway errors. */ export type GatewayStartupUnavailableDetails = { reason: typeof GATEWAY_STARTUP_UNAVAILABLE_REASON; }; +/** Builds the canonical startup-unavailable details payload. */ export function gatewayStartupUnavailableDetails(): GatewayStartupUnavailableDetails { return { reason: GATEWAY_STARTUP_UNAVAILABLE_REASON }; } @@ -24,6 +31,7 @@ function isGatewayStartupUnavailableDetails( ); } +/** Detects the structured retryable error emitted while startup sidecars are pending. */ export function isRetryableGatewayStartupUnavailableError(error: unknown): boolean { if (!error || typeof error !== "object") { return false; @@ -42,6 +50,7 @@ export function isRetryableGatewayStartupUnavailableError(error: unknown): boole ); } +/** Resolves a bounded retry-after delay from a startup-unavailable error. */ export function resolveGatewayStartupRetryAfterMs(error: unknown): number | null { if (!isRetryableGatewayStartupUnavailableError(error)) { return null; diff --git a/packages/gateway-protocol/src/version.ts b/packages/gateway-protocol/src/version.ts index d1c4dc8f9c2..f3ea0d30f78 100644 --- a/packages/gateway-protocol/src/version.ts +++ b/packages/gateway-protocol/src/version.ts @@ -1,3 +1,6 @@ +/** Current gateway protocol version emitted by modern clients and servers. */ export const PROTOCOL_VERSION = 4 as const; +/** Lowest client protocol version accepted by the gateway. */ export const MIN_CLIENT_PROTOCOL_VERSION = 4 as const; +/** Lowest lightweight probe protocol version accepted by the gateway. */ export const MIN_PROBE_PROTOCOL_VERSION = 4 as const; diff --git a/packages/llm-core/src/validation.ts b/packages/llm-core/src/validation.ts index 6d174d1976e..0ef65cd5ba9 100644 --- a/packages/llm-core/src/validation.ts +++ b/packages/llm-core/src/validation.ts @@ -281,6 +281,7 @@ function formatValidationPath(error: TLocalizedValidationError): string { return path || "root"; } +/** Finds the target tool and validates/coerces a model-emitted tool call. */ export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown { const tool = tools.find((t) => t.name === toolCall.name); if (!tool) { @@ -289,6 +290,7 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown { return validateToolArguments(tool, toolCall); } +/** Validates tool arguments against TypeBox or plain JSON-schema parameters. */ export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown { const args = structuredClone(toolCall.arguments); Value.Convert(tool.parameters, args); diff --git a/packages/llm-runtime/src/api-registry.ts b/packages/llm-runtime/src/api-registry.ts index 363f70b6fee..b6783f1708a 100644 --- a/packages/llm-runtime/src/api-registry.ts +++ b/packages/llm-runtime/src/api-registry.ts @@ -8,21 +8,21 @@ import type { StreamOptions, } from "../../llm-core/src/index.js"; -// Type-only source import keeps plugin SDK declarations self-contained; package -// runtime emits no llm-core import from this module. - +/** Runtime stream adapter signature stored in the API provider registry. */ export type ApiStreamFunction = ( model: Model, context: Context, options?: StreamOptions, ) => AssistantMessageEventStreamContract; +/** Runtime simple-stream adapter signature stored in the API provider registry. */ export type ApiStreamSimpleFunction = ( model: Model, context: Context, options?: SimpleStreamOptions, ) => AssistantMessageEventStreamContract; +/** Provider implementation registered by core or plugins for a specific model API. */ export interface ApiProvider< TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions, @@ -69,6 +69,7 @@ function wrapStreamSimple( }; } +/** Registers or replaces the provider implementation for an API id. */ export function registerApiProvider( provider: ApiProvider, sourceId?: string, @@ -83,14 +84,17 @@ export function registerApiProvider entry.provider); } +/** Removes all providers registered by a plugin/source id. */ export function unregisterApiProviders(sourceId: string): void { for (const [api, entry] of apiProviderRegistry.entries()) { if (entry.sourceId === sourceId) { @@ -99,6 +103,7 @@ export function unregisterApiProviders(sourceId: string): void { } } +/** Clears the registry for test teardown and runtime reset flows. */ export function clearApiProviders(): void { apiProviderRegistry.clear(); } diff --git a/packages/markdown-core/src/chunk-text.ts b/packages/markdown-core/src/chunk-text.ts index 105793ffc0b..71bfa94a3ca 100644 --- a/packages/markdown-core/src/chunk-text.ts +++ b/packages/markdown-core/src/chunk-text.ts @@ -39,6 +39,7 @@ function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhi return { lastNewline, lastWhitespace }; } +/** Splits plain text at readable boundaries while avoiding breaks inside parentheses. */ export function chunkText(text: string, limit: number): string[] { const early = resolveChunkEarlyReturn(text, limit); if (early) { diff --git a/packages/markdown-core/src/fences.ts b/packages/markdown-core/src/fences.ts index 3cb627b92ef..51aa7af80dd 100644 --- a/packages/markdown-core/src/fences.ts +++ b/packages/markdown-core/src/fences.ts @@ -17,6 +17,7 @@ export type FenceScanState = { }; }; +/** Scans fenced-code spans incrementally so chunking can carry an open fence forward. */ export function scanFenceSpans( buffer: string, state?: FenceScanState, @@ -102,10 +103,12 @@ export function scanFenceSpans( return { spans, state: nextState }; } +/** Parses all fenced-code spans in a complete markdown buffer. */ export function parseFenceSpans(buffer: string): FenceSpan[] { return scanFenceSpans(buffer).spans; } +/** Looks up the fence containing an offset; spans must be sorted by start offset. */ export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined { let low = 0; let high = spans.length - 1; @@ -130,6 +133,7 @@ export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | return undefined; } +/** True when a chunk boundary would not split a fenced-code block. */ export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean { return !findFenceSpanAt(spans, index); } diff --git a/packages/markdown-core/src/frontmatter.ts b/packages/markdown-core/src/frontmatter.ts index 7a421cf29ad..6fb320be800 100644 --- a/packages/markdown-core/src/frontmatter.ts +++ b/packages/markdown-core/src/frontmatter.ts @@ -195,6 +195,7 @@ function extractFrontmatterBlock(content: string): string | undefined { return normalized.slice(4, endIndex); } +/** Parses leading YAML frontmatter into string values used by skill and metadata loaders. */ export function parseFrontmatterBlock(content: string): ParsedFrontmatter { const block = extractFrontmatterBlock(content); if (!block) { diff --git a/packages/markdown-core/src/tables.ts b/packages/markdown-core/src/tables.ts index ffff2428315..5a560a6c472 100644 --- a/packages/markdown-core/src/tables.ts +++ b/packages/markdown-core/src/tables.ts @@ -10,6 +10,7 @@ const MARKDOWN_STYLE_MARKERS = { code_block: { open: "```\n", close: "```" }, } as const; +/** Converts markdown tables into the configured plaintext/code rendering mode. */ export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string { if (!markdown || mode === "off") { return markdown; diff --git a/packages/media-generation-core/src/capability-model-ref.ts b/packages/media-generation-core/src/capability-model-ref.ts index 23d218013dd..2f733efed79 100644 --- a/packages/media-generation-core/src/capability-model-ref.ts +++ b/packages/media-generation-core/src/capability-model-ref.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "./string.js"; +/** Provider catalog entry shape used when resolving capability-scoped model references. */ export type CapabilityModelProviderCandidate = { id: string; aliases?: readonly string[]; @@ -7,6 +8,7 @@ export type CapabilityModelProviderCandidate = { models?: readonly string[]; }; +/** Normalized provider/model reference selected for a media capability. */ export type CapabilityModelRef = { provider: string; model: string; @@ -22,6 +24,7 @@ function normalizeProviderForMatch( return normalized && normalizeProviderId ? normalizeProviderId(normalized) : normalized; } +/** Finds a provider by id or alias using the caller's provider-id normalization rules. */ export function findCapabilityProviderById(params: { providers: readonly T[]; providerId?: string; @@ -43,6 +46,7 @@ export function findCapabilityProviderById = { requested?: TValue; applied?: TValue; @@ -7,6 +9,7 @@ export type MediaNormalizationEntry = { supportedValues?: readonly TValue[]; }; +/** Normalization metadata shared by media generation responses. */ export type MediaGenerationNormalizationMetadataInput = { size?: MediaNormalizationEntry; aspectRatio?: MediaNormalizationEntry; @@ -14,6 +17,7 @@ export type MediaGenerationNormalizationMetadataInput = { durationSeconds?: MediaNormalizationEntry; }; +/** True when a normalization entry contains any user-visible normalization metadata. */ export function hasMediaNormalizationEntry( entry: MediaNormalizationEntry | undefined, ): entry is MediaNormalizationEntry { diff --git a/packages/media-understanding-common/src/format.ts b/packages/media-understanding-common/src/format.ts index b0542d1651c..8eb400c8260 100644 --- a/packages/media-understanding-common/src/format.ts +++ b/packages/media-understanding-common/src/format.ts @@ -3,6 +3,7 @@ import type { MediaUnderstandingOutput } from "./types.js"; const MEDIA_PLACEHOLDER_RE = /^]+>(\s*\([^)]*\))?$/i; const MEDIA_PLACEHOLDER_TOKEN_RE = /^]+>(\s*\([^)]*\))?\s*/i; +/** Extracts user-authored text while ignoring synthetic media placeholder tokens. */ export function extractMediaUserText(body?: string): string | undefined { const trimmed = body?.trim() ?? ""; if (!trimmed) { @@ -29,6 +30,7 @@ function formatSection( return lines.join("\n"); } +/** Formats media-understanding outputs into the chat body sent back to the model. */ export function formatMediaUnderstandingBody(params: { body?: string; outputs: MediaUnderstandingOutput[]; @@ -90,6 +92,7 @@ export function formatMediaUnderstandingBody(params: { return sections.join("\n\n").trim(); } +/** Formats one or more audio transcript outputs for legacy transcript-only callers. */ export function formatAudioTranscripts(outputs: MediaUnderstandingOutput[]): string { if (outputs.length === 1) { return outputs[0].text; diff --git a/packages/normalization-core/src/number-coercion.ts b/packages/normalization-core/src/number-coercion.ts index 9b7a23e1023..40de932f888 100644 --- a/packages/normalization-core/src/number-coercion.ts +++ b/packages/normalization-core/src/number-coercion.ts @@ -1,7 +1,9 @@ +/** Returns a number only when the input is already finite. */ export function asFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +/** Returns a finite number only when it satisfies the supplied inclusive/exclusive bounds. */ export function asFiniteNumberInRange( value: unknown, range: { @@ -28,6 +30,7 @@ export function asFiniteNumberInRange( return number; } +/** Returns a safe integer only when it satisfies the supplied inclusive bounds. */ export function asSafeIntegerInRange( value: unknown, range: { @@ -52,6 +55,7 @@ function normalizeNumericString(value: string): string | undefined { return trimmed ? trimmed : undefined; } +/** Parses finite numbers from number values or strict numeric string tokens. */ export function parseFiniteNumber(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -59,6 +63,7 @@ export function parseFiniteNumber(value: unknown): number | undefined { return parseStrictFiniteNumber(value); } +/** Parses only safe integer numbers or base-10 integer strings. */ export function parseStrictInteger(value: unknown): number | undefined { if (typeof value === "number") { return Number.isSafeInteger(value) ? value : undefined; @@ -74,6 +79,7 @@ export function parseStrictInteger(value: unknown): number | undefined { return Number.isSafeInteger(parsed) ? parsed : undefined; } +/** Parses only finite decimal/scientific string tokens, rejecting partial numbers. */ export function parseStrictFiniteNumber(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -89,15 +95,21 @@ export function parseStrictFiniteNumber(value: unknown): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +/** Returns positive safe integers without string coercion. */ export function asPositiveSafeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; } +/** Conservative upper bound for Node timer delays. */ export const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; +/** Timer bound expressed in whole seconds for env/config inputs. */ export const MAX_TIMER_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_TIMEOUT_MS / 1000); +/** Largest timestamp accepted by JavaScript Date. */ export const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; +/** Fallback ISO value for invalid timestamp inputs. */ export const UNIX_EPOCH_ISO_STRING = "1970-01-01T00:00:00.000Z"; +/** Returns a Date-valid millisecond timestamp. */ export function asDateTimestampMs(value: unknown): number | undefined { return asFiniteNumberInRange(value, { min: -MAX_DATE_TIMESTAMP_MS, @@ -105,6 +117,7 @@ export function asDateTimestampMs(value: unknown): number | undefined { }); } +/** Checks whether a Date-valid timestamp is after the supplied/current time. */ export function isFutureDateTimestampMs( value: unknown, opts: { nowMs?: number } = {}, @@ -114,11 +127,13 @@ export function isFutureDateTimestampMs( return timestampMs !== undefined && nowMs !== undefined && timestampMs > nowMs; } +/** Converts Date-valid millisecond timestamps to ISO strings. */ export function timestampMsToIsoString(value: unknown): string | undefined { const timestampMs = asDateTimestampMs(value); return timestampMs === undefined ? undefined : new Date(timestampMs).toISOString(); } +/** Resolves a Date-valid timestamp with a Date-valid fallback. */ export function resolveDateTimestampMs( value: unknown, fallbackValue: unknown = Date.now(), @@ -126,6 +141,7 @@ export function resolveDateTimestampMs( return asDateTimestampMs(value) ?? asDateTimestampMs(fallbackValue) ?? 0; } +/** Resolves a Date-valid timestamp to ISO, falling back to Unix epoch if needed. */ export function resolveTimestampMsToIsoString( value: unknown, fallbackValue: unknown = Date.now(), @@ -135,6 +151,7 @@ export function resolveTimestampMsToIsoString( ); } +/** Formats Date-valid timestamps for filenames by replacing colon separators. */ export function timestampMsToIsoFileStamp( value: unknown, fallbackValue: unknown = Date.now(), @@ -142,6 +159,7 @@ export function timestampMsToIsoFileStamp( return resolveTimestampMsToIsoString(value, fallbackValue).replaceAll(":", "-"); } +/** Clamps finite millisecond values into the Node-safe timer range. */ export function clampTimerTimeoutMs(valueMs: unknown, minMs = 1): number | undefined { const value = asFiniteNumber(valueMs); if (value === undefined) { @@ -151,6 +169,7 @@ export function clampTimerTimeoutMs(valueMs: unknown, minMs = 1): number | undef return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS); } +/** Clamps positive finite millisecond values into the Node-safe timer range. */ export function clampPositiveTimerTimeoutMs(valueMs: unknown): number | undefined { const value = asFiniteNumber(valueMs); if (value === undefined || value <= 0) { @@ -159,10 +178,12 @@ export function clampPositiveTimerTimeoutMs(valueMs: unknown): number | undefine return clampTimerTimeoutMs(value); } +/** Resolves a positive timer timeout or falls back through safe timer clamping. */ export function resolvePositiveTimerTimeoutMs(valueMs: unknown, fallbackMs: number): number { return clampPositiveTimerTimeoutMs(valueMs) ?? resolveTimerTimeoutMs(fallbackMs, 1); } +/** Resolves arbitrary timeout input with fallback and minimum timer bounds. */ export function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: number, minMs = 1): number { const value = asFiniteNumber(valueMs) ?? asFiniteNumber(fallbackMs); const min = Math.max(0, Math.floor(minMs)); @@ -172,6 +193,7 @@ export function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: number, minM return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS); } +/** Adds grace time to a finite timeout and clamps the result to Node-safe bounds. */ export function addTimerTimeoutGraceMs(timeoutMs: unknown, graceMs = 5_000): number | undefined { const timeout = asFiniteNumber(timeoutMs); const grace = asFiniteNumber(graceMs); @@ -182,6 +204,7 @@ export function addTimerTimeoutGraceMs(timeoutMs: unknown, graceMs = 5_000): num return Number.isFinite(withGrace) ? clampTimerTimeoutMs(withGrace) : MAX_TIMER_TIMEOUT_MS; } +/** Converts finite positive seconds to Node-safe milliseconds. */ export function finiteSecondsToTimerSafeMilliseconds( value: unknown, opts: { floorSeconds?: boolean } = {}, @@ -198,6 +221,7 @@ export function finiteSecondsToTimerSafeMilliseconds( return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS); } +/** Resolves an integer option from finite numeric input or fallback, then clamps bounds. */ export function resolveIntegerOption( value: unknown, fallback: number, @@ -212,6 +236,7 @@ export function resolveIntegerOption( return range.max === undefined ? minBounded : Math.min(range.max, minBounded); } +/** Resolves an optional integer option, returning undefined for non-finite input. */ export function resolveOptionalIntegerOption( value: unknown, range: { @@ -225,20 +250,24 @@ export function resolveOptionalIntegerOption( return resolveIntegerOption(value, value, range); } +/** Resolves an integer option with a non-negative lower bound. */ export function resolveNonNegativeIntegerOption(value: unknown, fallback: number): number { return resolveIntegerOption(value, fallback, { min: 0 }); } +/** Parses strict positive integer values from numbers or strings. */ export function parseStrictPositiveInteger(value: unknown): number | undefined { const parsed = parseStrictInteger(value); return parsed !== undefined && parsed > 0 ? parsed : undefined; } +/** Parses strict non-negative integer values from numbers or strings. */ export function parseStrictNonNegativeInteger(value: unknown): number | undefined { const parsed = parseStrictInteger(value); return parsed !== undefined && parsed >= 0 ? parsed : undefined; } +/** Converts strict positive seconds to safe millisecond counts. */ export function positiveSecondsToSafeMilliseconds(value: unknown): number | undefined { const seconds = parseStrictPositiveInteger(value); if (seconds === undefined) { @@ -248,6 +277,7 @@ export function positiveSecondsToSafeMilliseconds(value: unknown): number | unde return Number.isSafeInteger(milliseconds) ? milliseconds : undefined; } +/** Converts strict non-negative seconds to safe millisecond counts. */ export function nonNegativeSecondsToSafeMilliseconds(value: unknown): number | undefined { const seconds = parseStrictNonNegativeInteger(value); if (seconds === undefined) { @@ -257,6 +287,7 @@ export function nonNegativeSecondsToSafeMilliseconds(value: unknown): number | u return Number.isSafeInteger(milliseconds) ? milliseconds : undefined; } +/** Resolves an absolute expiration timestamp from a positive duration in milliseconds. */ export function resolveExpiresAtMsFromDurationMs( value: unknown, opts: { nowMs?: number; bufferMs?: number; minRemainingMs?: number } = {}, @@ -285,6 +316,7 @@ export function resolveExpiresAtMsFromDurationMs( return Math.max(expiresAt, minExpiresAt); } +/** Resolves an absolute expiration timestamp from a positive duration in seconds. */ export function resolveExpiresAtMsFromDurationSeconds( value: unknown, opts: { nowMs?: number; bufferMs?: number; minRemainingMs?: number } = {}, @@ -293,6 +325,7 @@ export function resolveExpiresAtMsFromDurationSeconds( return durationMs === undefined ? undefined : resolveExpiresAtMsFromDurationMs(durationMs, opts); } +/** Resolves an absolute expiration timestamp from Unix epoch seconds. */ export function resolveExpiresAtMsFromEpochSeconds( value: unknown, opts: { bufferMs?: number; maxMs?: number } = {}, @@ -315,6 +348,7 @@ export function resolveExpiresAtMsFromEpochSeconds( return maxMs === undefined || expiresAt <= maxMs ? expiresAt : undefined; } +/** Resolves expiration input that may be relative seconds, epoch seconds, or epoch milliseconds. */ export function resolveExpiresAtMsFromDurationOrEpoch( value: unknown, opts: { diff --git a/packages/normalization-core/src/record-coerce.ts b/packages/normalization-core/src/record-coerce.ts index 65b24290462..873f0d3bd56 100644 --- a/packages/normalization-core/src/record-coerce.ts +++ b/packages/normalization-core/src/record-coerce.ts @@ -1,12 +1,15 @@ // Keep this local so browser bundles do not pull in src/utils.ts and its Node-only side effects. +/** Type guard for non-array object records at browser-safe boundaries. */ export function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** Coerces object-like values to records, falling back to an empty record. */ export function asRecord(value: unknown): Record { return typeof value === "object" && value !== null ? (value as Record) : {}; } +/** Reads a field only when it exists as a string. */ export function readStringField( record: Record | null | undefined, key: string, @@ -15,18 +18,22 @@ export function readStringField( return typeof value === "string" ? value : undefined; } +/** Returns a non-array record or undefined. */ export function asOptionalRecord(value: unknown): Record | undefined { return isRecord(value) ? value : undefined; } +/** Returns a non-array record or null. */ export function asNullableRecord(value: unknown): Record | null { return isRecord(value) ? value : null; } +/** Returns any object-backed record, including arrays, or undefined. */ export function asOptionalObjectRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } +/** Returns any object-backed record, including arrays, or null. */ export function asNullableObjectRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } diff --git a/packages/normalization-core/src/string-coerce.ts b/packages/normalization-core/src/string-coerce.ts index cef13c22ec1..e00b1f8eb7b 100644 --- a/packages/normalization-core/src/string-coerce.ts +++ b/packages/normalization-core/src/string-coerce.ts @@ -1,7 +1,9 @@ +/** Reads a value only when it is already a string, preserving whitespace. */ export function readStringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +/** Trims string input and returns null for non-strings or empty strings. */ export function normalizeNullableString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -10,10 +12,12 @@ export function normalizeNullableString(value: unknown): string | null { return trimmed ? trimmed : null; } +/** Trims string input and returns undefined for non-strings or empty strings. */ export function normalizeOptionalString(value: unknown): string | undefined { return normalizeNullableString(value) ?? undefined; } +/** Stringifies primitive ids/flags before applying optional string normalization. */ export function normalizeStringifiedOptionalString(value: unknown): string | undefined { if (typeof value === "string") { return normalizeOptionalString(value); @@ -24,20 +28,24 @@ export function normalizeStringifiedOptionalString(value: unknown): string | und return undefined; } +/** Normalizes an optional array of primitive-ish values into non-empty strings. */ export function normalizeStringifiedEntries(values?: ReadonlyArray): string[] { return (values ?? []) .map((entry) => normalizeStringifiedOptionalString(entry)) .filter((entry): entry is string => Boolean(entry)); } +/** Lowercases a normalized optional string. */ export function normalizeOptionalLowercaseString(value: unknown): string | undefined { return normalizeOptionalString(value)?.toLowerCase(); } +/** Lowercases a normalized string or returns an empty string when absent. */ export function normalizeLowercaseStringOrEmpty(value: unknown): string { return normalizeOptionalLowercaseString(value) ?? ""; } +/** Parses loose boolean/fast-mode flags from strings or booleans. */ export function normalizeFastMode(raw?: string | boolean | null): boolean | undefined { if (typeof raw === "boolean") { return raw; @@ -55,14 +63,17 @@ export function normalizeFastMode(raw?: string | boolean | null): boolean | unde return undefined; } +/** Lowercases text while intentionally preserving surrounding whitespace. */ export function lowercasePreservingWhitespace(value: string): string { return value.toLowerCase(); } +/** Locale-aware lowercase helper that still preserves surrounding whitespace. */ export function localeLowercasePreservingWhitespace(value: string): string { return value.toLocaleLowerCase(); } +/** Reads a string directly or from an object's `primary` field. */ export function resolvePrimaryStringValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeOptionalString(value); @@ -73,6 +84,7 @@ export function resolvePrimaryStringValue(value: unknown): string | undefined { return normalizeOptionalString((value as { primary?: unknown }).primary); } +/** Normalizes thread ids that may be numeric or string-backed. */ export function normalizeOptionalThreadValue(value: unknown): string | number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? Math.trunc(value) : undefined; @@ -80,11 +92,13 @@ export function normalizeOptionalThreadValue(value: unknown): string | number | return normalizeOptionalString(value); } +/** Normalizes a thread/id value and stringifies finite numeric ids. */ export function normalizeOptionalStringifiedId(value: unknown): string | undefined { const normalized = normalizeOptionalThreadValue(value); return normalized == null ? undefined : String(normalized); } +/** Type guard for strings that remain non-empty after trimming. */ export function hasNonEmptyString(value: unknown): value is string { return normalizeOptionalString(value) !== undefined; } diff --git a/packages/normalization-core/src/string-normalization.ts b/packages/normalization-core/src/string-normalization.ts index c45b485f198..8007e8b507e 100644 --- a/packages/normalization-core/src/string-normalization.ts +++ b/packages/normalization-core/src/string-normalization.ts @@ -1,41 +1,50 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString } from "./string-coerce.js"; +/** Coerces entries to strings, trims them, and drops empty results. */ export function normalizeStringEntries(list?: ReadonlyArray) { return (list ?? []).map((entry) => normalizeOptionalString(String(entry)) ?? "").filter(Boolean); } +/** Normalizes string entries and lowercases each retained value. */ export function normalizeStringEntriesLower(list?: ReadonlyArray) { return normalizeStringEntries(list).map((entry) => normalizeOptionalLowercaseString(entry) ?? ""); } +/** Returns first-seen unique values while preserving insertion order. */ export function uniqueValues(values: Iterable): T[] { return [...new Set(values)]; } +/** Returns first-seen unique strings while preserving insertion order. */ export function uniqueStrings(values: Iterable): string[] { return uniqueValues(values); } +/** Returns unique strings sorted with stable ASCII comparison. */ export function sortUniqueStrings(values: Iterable): string[] { return uniqueStrings(values).toSorted((left, right) => left < right ? -1 : left > right ? 1 : 0, ); } +/** Normalizes entries, removes duplicates, and preserves first-seen order. */ export function normalizeUniqueStringEntries(values?: Iterable): string[] { return uniqueStrings(normalizeStringEntries(values ? [...values] : undefined)); } +/** Lowercases normalized entries, removes empties/duplicates, and preserves first-seen order. */ export function normalizeUniqueStringEntriesLower(values?: Iterable): string[] { return uniqueStrings( normalizeStringEntriesLower(values ? [...values] : undefined).filter(Boolean), ); } +/** Normalizes entries, removes duplicates, and returns sorted output. */ export function normalizeSortedUniqueStringEntries(values?: Iterable): string[] { return sortUniqueStrings(normalizeUniqueStringEntries(values)); } +/** Normalizes array-backed string lists and rejects non-array input as empty. */ export function normalizeTrimmedStringList(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -46,19 +55,23 @@ export function normalizeTrimmedStringList(value: unknown): string[] { }); } +/** Normalizes an array-backed string list and removes duplicates. */ export function normalizeUniqueTrimmedStringList(value: unknown): string[] { return uniqueStrings(normalizeTrimmedStringList(value)); } +/** Normalizes an array-backed string list, removes duplicates, and sorts it. */ export function normalizeSortedUniqueTrimmedStringList(value: unknown): string[] { return sortUniqueStrings(normalizeTrimmedStringList(value)); } +/** Returns undefined instead of an empty normalized array-backed string list. */ export function normalizeOptionalTrimmedStringList(value: unknown): string[] | undefined { const normalized = normalizeTrimmedStringList(value); return normalized.length > 0 ? normalized : undefined; } +/** Returns undefined for non-arrays but preserves an empty array for explicit arrays. */ export function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -66,6 +79,7 @@ export function normalizeArrayBackedTrimmedStringList(value: unknown): string[] return normalizeTrimmedStringList(value); } +/** Normalizes either a single string-like value or an array-backed string list. */ export function normalizeSingleOrTrimmedStringList(value: unknown): string[] { if (Array.isArray(value)) { return normalizeTrimmedStringList(value); @@ -74,10 +88,12 @@ export function normalizeSingleOrTrimmedStringList(value: unknown): string[] { return normalized ? [normalized] : []; } +/** Normalizes single-or-array string input and removes duplicates. */ export function normalizeUniqueSingleOrTrimmedStringList(value: unknown): string[] { return uniqueStrings(normalizeSingleOrTrimmedStringList(value)); } +/** Parses either array entries or comma-separated string entries into trimmed values. */ export function normalizeCsvOrLooseStringList(value: unknown): string[] { if (Array.isArray(value)) { return normalizeStringEntries(value); @@ -95,6 +111,7 @@ function normalizeSlugInput(raw?: string | null) { return (normalizeOptionalLowercaseString(raw) ?? "").normalize("NFC"); } +/** Normalizes user-facing names into permissive lowercase hyphen slugs. */ export function normalizeHyphenSlug(raw?: string | null) { const trimmed = normalizeSlugInput(raw); if (!trimmed) { @@ -105,6 +122,7 @@ export function normalizeHyphenSlug(raw?: string | null) { return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); } +/** Normalizes @/#-prefixed names into lowercase hyphen slugs without the prefix. */ export function normalizeAtHashSlug(raw?: string | null) { const trimmed = normalizeSlugInput(raw); if (!trimmed) { diff --git a/packages/tool-call-repair/src/grammar.ts b/packages/tool-call-repair/src/grammar.ts index 37b177bed4e..5d17d216603 100644 --- a/packages/tool-call-repair/src/grammar.ts +++ b/packages/tool-call-repair/src/grammar.ts @@ -1,20 +1,28 @@ +/** Legacy marker some models emit after a serialized JSON tool request. */ export const END_TOOL_REQUEST = "[END_TOOL_REQUEST]"; +/** Harmony stream marker that introduces the target channel before a tool call. */ export const HARMONY_CHANNEL_MARKER = "<|channel|>"; +/** Harmony stream marker that may separate the header from the JSON payload. */ export const HARMONY_MESSAGE_MARKER = "<|message|>"; +/** Harmony stream marker that may close a serialized tool-call payload. */ export const HARMONY_CALL_MARKER = "<|call|>"; +/** Accepts either a complete literal or a still-streaming prefix of that literal. */ export function matchesLiteralPrefix(text: string, literal: string): boolean { return literal.startsWith(text) || text.startsWith(literal); } +/** Tool names in bracket/plain-text repairs intentionally match provider-safe ids only. */ export function isPlainTextToolNameChar(char: string | undefined): boolean { return Boolean(char && /[A-Za-z0-9_-]/.test(char)); } +/** XML-ish function tags allow namespace punctuation used by some model families. */ export function isXmlishNameChar(char: string | undefined): boolean { return Boolean(char && /[A-Za-z0-9_.:-]/.test(char)); } +/** Skips spaces and tabs only, preserving line boundaries for grammar decisions. */ export function skipHorizontalWhitespace(text: string, start: number): number { let index = start; while (index < text.length && (text[index] === " " || text[index] === "\t")) { @@ -23,6 +31,7 @@ export function skipHorizontalWhitespace(text: string, start: number): number { return index; } +/** Skips all JavaScript whitespace when line structure is no longer meaningful. */ export function skipWhitespace(text: string, start: number): number { let index = start; while (index < text.length && /\s/.test(text[index] ?? "")) { @@ -31,6 +40,7 @@ export function skipWhitespace(text: string, start: number): number { return index; } +/** Consumes either Unix or Windows line endings and returns the first offset after them. */ export function consumeLineBreak(text: string, start: number): number | null { if (text[start] === "\r") { return text[start + 1] === "\n" ? start + 2 : start + 1; @@ -41,6 +51,7 @@ export function consumeLineBreak(text: string, start: number): number | null { return null; } +/** Finds the exclusive end offset of a balanced JSON object starting at `start`. */ export function findJsonObjectEnd( text: string, start: number, @@ -82,11 +93,13 @@ export function findJsonObjectEnd( return null; } +/** Consumes one optional line break after a repaired serialized tool-call fragment. */ export function skipSerializedToolCallTrailingLineBreak(text: string, cursor: number): number { const afterLineBreak = consumeLineBreak(text, cursor); return afterLineBreak ?? cursor; } +/** Accepts the legacy closing markers models append after JSON tool-call payloads. */ export function consumeJsonToolClosingMarker(text: string, cursor: number): number { let markerStart = cursor; while (markerStart < text.length && /\s/.test(text[markerStart] ?? "")) { @@ -106,6 +119,7 @@ export function consumeJsonToolClosingMarker(text: string, cursor: number): numb return skipSerializedToolCallTrailingLineBreak(text, cursor); } +/** Finds JSON after bracketed tool syntax such as `[tool_name]\n{...}`. */ export function findBracketedJsonPayloadStart(text: string): number | null { if (!text.startsWith("[")) { return null; @@ -121,6 +135,7 @@ export function findBracketedJsonPayloadStart(text: string): number | null { return text[cursor] === "{" ? cursor : null; } +/** Finds JSON after Harmony channel/tool headers while tolerating optional message markers. */ export function findHarmonyJsonPayloadStart(text: string): number | null { let cursor = 0; if (text.startsWith(HARMONY_CHANNEL_MARKER)) { @@ -158,6 +173,7 @@ export function findHarmonyJsonPayloadStart(text: string): number | null { return text[cursor] === "{" ? cursor : null; } +/** Case-insensitive marker compare for ASCII protocol tags without locale rules. */ export function startsWithAsciiMarkerIgnoreCase( text: string, cursor: number, @@ -166,6 +182,7 @@ export function startsWithAsciiMarkerIgnoreCase( return text.slice(cursor, cursor + marker.length).toLowerCase() === marker; } +/** Case-insensitive marker search for ASCII protocol tags without allocating regexes. */ export function indexOfAsciiMarkerIgnoreCase(text: string, marker: string, start: number): number { let cursor = start; while (cursor < text.length) { @@ -181,6 +198,7 @@ export function indexOfAsciiMarkerIgnoreCase(text: string, marker: string, start return -1; } +/** Returns the end offset for a complete XML-ish or bracketed plain-text tool call. */ export function findXmlishToolCallEnd(text: string): number | null { let cursor: number; const xmlFunction = /^/i.exec(text); diff --git a/packages/tool-call-repair/src/promote.ts b/packages/tool-call-repair/src/promote.ts index 7469895cc5f..3282dac8a10 100644 --- a/packages/tool-call-repair/src/promote.ts +++ b/packages/tool-call-repair/src/promote.ts @@ -1,15 +1,18 @@ import { parseStandalonePlainTextToolCallBlocks, type PlainTextToolCallBlock } from "./payload.js"; +/** Resolves model-emitted tool names to the exact names allowed by the provider request. */ export type ToolCallRepairNameResolver = ( rawName: string, allowedToolNames: Set, ) => string | null; +/** Builds a provider-native tool-call block from a repaired plain-text payload. */ export type PromotedPlainTextToolCallBlockFactory = ( block: PlainTextToolCallBlock, resolvedName: string, ) => Record; +/** Controls when standalone assistant text may be rewritten as tool-call content. */ export type PlainTextToolCallPromotionOptions = { allowedStopReasons?: ReadonlySet; allowedToolNames: Set; @@ -70,6 +73,8 @@ function createTextPartPromotionCandidates( textParts: readonly string[], exactText: string, ): string[] { + // Some providers split structural markers across text parts; try repaired and exact joins + // before falling back to newline joins so valid payloads promote without changing content. const repairedText = joinTextPartsWithStructuralLineBreaks(textParts).trim(); const newlineJoinedText = textParts.join("\n").trim(); return [...new Set([repairedText, exactText, newlineJoinedText].filter(Boolean))]; @@ -111,6 +116,7 @@ function shouldPromoteMessage(options: PlainTextToolCallPromotionOptions): boole return !options.allowedStopReasons || options.allowedStopReasons.has(messageRecord.stopReason); } +/** Extracts candidate standalone tool-call text while rejecting mixed unsafe content. */ export function extractStandalonePlainTextToolCallText(params: { allowOtherNonTextBlocks?: boolean; allowedStopReasons?: ReadonlySet; @@ -163,6 +169,7 @@ export function extractStandalonePlainTextToolCallText(params: { return text || undefined; } +/** Promotes standalone plain-text tool-call messages into provider-native content blocks. */ export function promoteStandalonePlainTextToolCallMessage( options: PlainTextToolCallPromotionOptions, ): Record | undefined { diff --git a/src/chat/tool-content.ts b/src/chat/tool-content.ts index 554a7ae2821..44775a3dcef 100644 --- a/src/chat/tool-content.ts +++ b/src/chat/tool-content.ts @@ -1,31 +1,38 @@ +/** Provider-agnostic chat content block shape used before SDK-specific narrowing. */ export type ToolContentBlock = Record; function normalizeToolContentType(value: unknown): string { return typeof value === "string" ? value.toLowerCase() : ""; } +/** Accepts tool-call content type spellings used by provider SDKs and persisted transcripts. */ export function isToolCallContentType(value: unknown): boolean { const type = normalizeToolContentType(value); return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use"; } +/** Accepts tool-result content type spellings used by provider SDKs and persisted transcripts. */ export function isToolResultContentType(value: unknown): boolean { const type = normalizeToolContentType(value); return type === "toolresult" || type === "tool_result"; } +/** Narrows unknown chat content blocks to provider-shaped tool-call blocks. */ export function isToolCallBlock(block: ToolContentBlock): boolean { return isToolCallContentType(block.type); } +/** Narrows unknown chat content blocks to provider-shaped tool-result blocks. */ export function isToolResultBlock(block: ToolContentBlock): boolean { return isToolResultContentType(block.type); } +/** Reads the argument payload across the common provider field names. */ export function resolveToolBlockArgs(block: ToolContentBlock): unknown { return block.args ?? block.arguments ?? block.input; } +/** Reads the stable tool-use id across snake_case and camelCase provider field names. */ export function resolveToolUseId(block: ToolContentBlock): string | undefined { const id = (typeof block.id === "string" && block.id.trim()) || diff --git a/src/gateway/auth-config-utils.ts b/src/gateway/auth-config-utils.ts index 4654437e867..ff16652991c 100644 --- a/src/gateway/auth-config-utils.ts +++ b/src/gateway/auth-config-utils.ts @@ -21,6 +21,7 @@ type GatewayAuthSecretRefResolutionParams = { hasTokenCandidate: boolean; }; +/** Check whether a local Gateway auth input is configured directly or through defaults. */ export function hasConfiguredGatewayAuthSecretInput( cfg: OpenClawConfig, path: GatewayAuthSecretInputPath, @@ -28,6 +29,7 @@ export function hasConfiguredGatewayAuthSecretInput( return hasConfiguredSecretInput(readGatewaySecretInputValue(cfg, path), cfg.secrets?.defaults); } +/** Decide whether a token/password secret ref can be active for the configured auth mode. */ function shouldResolveGatewayAuthSecretRef(params: { mode?: GatewayAuthConfig["mode"]; path: GatewayAuthSecretInputPath; @@ -48,6 +50,8 @@ function shouldResolveGatewayAuthSecretRef(params: { if (params.mode === "password") { return !isTokenPath; } + // With implicit mode, resolve the side that does not already have a concrete + // candidate so token and password defaults do not both get materialized. return isTokenPath ? !params.hasPasswordCandidate : !params.hasTokenCandidate; } @@ -94,6 +98,7 @@ async function resolveGatewayAuthSecretRefValue(params: { return value; } +/** Resolve the Gateway auth token ref only when token auth can use it. */ export async function resolveGatewayTokenSecretRefValue( params: GatewayAuthSecretRefResolutionParams, ): Promise { @@ -105,6 +110,7 @@ export async function resolveGatewayTokenSecretRefValue( }); } +/** Resolve the Gateway auth password ref only when password auth can use it. */ export async function resolveGatewayPasswordSecretRefValue( params: GatewayAuthSecretRefResolutionParams, ): Promise { @@ -152,6 +158,7 @@ async function resolveGatewayPasswordSecretRef(params: { }); } +/** Materialize active local Gateway auth secret refs on a cloned config. */ export async function materializeGatewayAuthSecretRefs( params: GatewayAuthSecretRefResolutionParams, ): Promise { diff --git a/src/gateway/auth-resolve.ts b/src/gateway/auth-resolve.ts index cf15c879179..699980045e5 100644 --- a/src/gateway/auth-resolve.ts +++ b/src/gateway/auth-resolve.ts @@ -6,7 +6,10 @@ import type { import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveGatewayCredentialsFromValues } from "./credentials.js"; +/** Authentication modes after config, override, and credential inputs are combined. */ export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; + +/** Records which input selected the effective Gateway auth mode. */ export type ResolvedGatewayAuthModeSource = | "override" | "config" @@ -14,6 +17,7 @@ export type ResolvedGatewayAuthModeSource = | "token" | "default"; +/** Fully resolved Gateway auth policy before startup validates required secrets. */ export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; modeSource?: ResolvedGatewayAuthModeSource; @@ -23,11 +27,13 @@ export type ResolvedGatewayAuth = { trustedProxy?: GatewayTrustedProxyConfig; }; +/** Shared-secret auth shape exposed to Gateway clients that support a single bearer secret. */ export type EffectiveSharedGatewayAuth = { mode: "token" | "password"; secret: string | undefined; }; +/** Resolve Gateway auth mode, credentials, trusted-proxy policy, and Tailscale allowance. */ export function resolveGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; authOverride?: GatewayAuthConfig | null; @@ -38,6 +44,9 @@ export function resolveGatewayAuth(params: { const authOverride = params.authOverride ?? undefined; const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; if (authOverride) { + // Runtime overrides are sparse field overlays; omitted fields keep the + // persisted config so callers can replace one auth knob without cloning all + // credential and proxy settings. if (authOverride.mode !== undefined) { authConfig.mode = authOverride.mode; } @@ -60,6 +69,8 @@ export function resolveGatewayAuth(params: { const env = params.env ?? process.env; const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref; const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref; + // Secret refs are not plaintext credentials here. Startup/runtime secret + // resolution validates active refs before request authorization sees them. const resolvedCredentials = resolveGatewayCredentialsFromValues({ configToken: tokenRef ? undefined : authConfig.token, configPassword: passwordRef ? undefined : authConfig.password, @@ -86,11 +97,15 @@ export function resolveGatewayAuth(params: { mode = "token"; modeSource = "token"; } else { + // Token remains the default so the config assertion can produce a clear + // missing-token diagnostic instead of silently disabling Gateway auth. mode = "token"; modeSource = "default"; } const allowTailscale = + // Tailscale serve can supply network-level access control, but password and + // trusted-proxy modes keep their stricter explicit auth boundary. authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); @@ -104,6 +119,7 @@ export function resolveGatewayAuth(params: { }; } +/** Return the effective token/password secret for clients that cannot model every auth mode. */ export function resolveEffectiveSharedGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; authOverride?: GatewayAuthConfig | null; diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index e6432544a94..b8342833aef 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -33,6 +33,7 @@ export { const LEGACY_OPENCLAW_ENV_NOTE = " Legacy CLAWDBOT_* and MOLTBOT_* environment variables are ignored; use OPENCLAW_* names."; +/** Normalized outcome for gateway shared-secret, Tailscale, device, and proxy auth. */ export type GatewayAuthResult = { ok: boolean; method?: @@ -58,6 +59,7 @@ type ConnectAuth = { export type GatewayAuthSurface = "http" | "ws-control-ui"; +/** Inputs needed to authorize one HTTP or websocket gateway connection. */ export type AuthorizeGatewayConnectParams = { auth: ResolvedGatewayAuth; connectAuth?: ConnectAuth | null; @@ -121,6 +123,7 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { }); } +/** Detect forwarded/proxy headers that make loopback requests ineligible for direct-local auth. */ export function hasForwardedRequestHeaders(req?: IncomingMessage): boolean { if (!req) { return false; @@ -136,6 +139,7 @@ export function hasForwardedRequestHeaders(req?: IncomingMessage): boolean { ); } +/** Return whether a request is a clean loopback request without forwarded identity headers. */ export function isLocalDirectRequest( req?: IncomingMessage, _trustedProxies?: string[], @@ -219,6 +223,7 @@ async function resolveVerifiedTailscaleUser(params: { }; } +/** Validate that the selected gateway auth mode has the required resolved credentials/config. */ export function assertGatewayAuthConfigured( auth: ResolvedGatewayAuth, rawAuthConfig?: GatewayAuthConfig | null, @@ -397,6 +402,7 @@ function authorizePasswordAuth(params: { return { ok: true, method: "password" }; } +/** Authorize a gateway connection, including rate-limit handling around shared-secret failures. */ export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -561,6 +567,7 @@ async function authorizeGatewayConnectCore( return { ok: false, reason: "unauthorized" }; } +/** Authorize an HTTP gateway request with Tailscale forwarded-header auth disabled. */ export async function authorizeHttpGatewayConnect( params: Omit, ): Promise { @@ -570,6 +577,7 @@ export async function authorizeHttpGatewayConnect( }); } +/** Authorize a Control UI websocket request with the WS-specific auth surface. */ export async function authorizeWsControlUiGatewayConnect( params: Omit, ): Promise { diff --git a/src/gateway/control-plane-audit.ts b/src/gateway/control-plane-audit.ts index 08b4acb562a..e44bd32e83a 100644 --- a/src/gateway/control-plane-audit.ts +++ b/src/gateway/control-plane-audit.ts @@ -1,5 +1,6 @@ import type { GatewayClient } from "./server-methods/types.js"; +/** Stable actor fields included in control-plane audit and rate-limit logs. */ export type ControlPlaneActor = { actor: string; deviceId: string; @@ -15,6 +16,7 @@ function normalizePart(value: unknown, fallback: string): string { return normalized.length > 0 ? normalized : fallback; } +/** Extracts audit identity from a possibly missing or partially connected client. */ export function resolveControlPlaneActor(client: GatewayClient | null): ControlPlaneActor { return { actor: normalizePart(client?.connect?.client?.id, "unknown-actor"), @@ -24,10 +26,12 @@ export function resolveControlPlaneActor(client: GatewayClient | null): ControlP }; } +/** Formats actor identity as compact key/value text for structured gateway logs. */ export function formatControlPlaneActor(actor: ControlPlaneActor): string { return `actor=${actor.actor} device=${actor.deviceId} ip=${actor.clientIp} conn=${actor.connId}`; } +/** Summarizes changed config/state paths without letting audit logs grow unbounded. */ export function summarizeChangedPaths(paths: string[], maxPaths = 8): string { if (paths.length === 0) { return ""; diff --git a/src/gateway/control-plane-rate-limit.ts b/src/gateway/control-plane-rate-limit.ts index 7b1605fac40..6cc98432ff1 100644 --- a/src/gateway/control-plane-rate-limit.ts +++ b/src/gateway/control-plane-rate-limit.ts @@ -6,6 +6,7 @@ const CONTROL_PLANE_BUCKET_MAX_STALE_MS = 5 * 60_000; /** Hard cap to prevent memory DoS from rapid unique-key injection (CWE-400). */ const CONTROL_PLANE_BUCKET_MAX_ENTRIES = 10_000; +/** Sliding-window counter keyed by device/IP identity for write-side control RPCs. */ type Bucket = { count: number; windowStartMs: number; @@ -21,6 +22,7 @@ function normalizePart(value: unknown, fallback: string): string { return normalized.length > 0 ? normalized : fallback; } +/** Builds a stable throttle key while avoiding shared fallback buckets for anonymous clients. */ export function resolveControlPlaneRateLimitKey(client: GatewayClient | null): string { const deviceId = normalizePart(client?.connect?.device?.id, "unknown-device"); const clientIp = normalizePart(client?.clientIp, "unknown-ip"); @@ -34,6 +36,7 @@ export function resolveControlPlaneRateLimitKey(client: GatewayClient | null): s return `${deviceId}|${clientIp}`; } +/** Consumes one write budget unit and reports retry state for gateway error responses. */ export function consumeControlPlaneWriteBudget(params: { client: GatewayClient | null; nowMs?: number; diff --git a/src/gateway/credential-planner.ts b/src/gateway/credential-planner.ts index 3bfe719aabb..bf73b83cd5b 100644 --- a/src/gateway/credential-planner.ts +++ b/src/gateway/credential-planner.ts @@ -17,6 +17,7 @@ type GatewayConfiguredCredentialInput = { hasSecretRef: boolean; }; +/** Precomputed Gateway credential surfaces used by startup, secret resolution, and clients. */ export type GatewayCredentialPlan = { configuredMode: "local" | "remote"; authMode?: string; @@ -43,6 +44,7 @@ export type GatewayCredentialPlan = { type GatewaySecretDefaults = NonNullable["defaults"]; +/** Normalize optional Gateway credential strings to nonempty values. */ export const trimToUndefined = normalizeOptionalString; /** @@ -60,14 +62,17 @@ export function trimCredentialToUndefined(value: unknown): string | undefined { return trimmed; } +/** True when the process env supplies a nonempty Gateway token candidate. */ export function hasGatewayTokenEnvCandidate(env: NodeJS.ProcessEnv = process.env): boolean { return Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN)); } +/** True when the process env supplies a nonempty Gateway password candidate. */ export function hasGatewayPasswordEnvCandidate(env: NodeJS.ProcessEnv = process.env): boolean { return Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD)); } +/** Classify one configured credential input without resolving secret refs. */ function resolveConfiguredGatewayCredentialInput(params: { value: unknown; defaults?: GatewaySecretDefaults; @@ -86,6 +91,7 @@ function resolveConfiguredGatewayCredentialInput(params: { }; } +/** Build the shared credential plan for Gateway startup, local auth, and remote client auth. */ export function createGatewayCredentialPlan(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -120,6 +126,8 @@ export function createGatewayCredentialPlan(params: { path: "gateway.remote.password", }); + // The local token surface is disabled by password/none/trusted-proxy modes so + // token refs do not get resolved for auth modes that cannot consume them. const localTokenCanWin = authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"; const tokenCanWin = Boolean(envToken || localToken.configured || remoteToken.configured); @@ -138,6 +146,8 @@ export function createGatewayCredentialPlan(params: { const tailscaleRemoteExposure = gateway?.tailscale?.mode === "serve" || gateway?.tailscale?.mode === "funnel"; const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure; + // Remote credentials may borrow local auth credentials only when the remote + // surface exists but no explicit remote/env candidate can satisfy the mode. const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localToken.configured; const remotePasswordFallbackActive = authMode !== "trusted-proxy" && !envPassword && !localPassword.configured && passwordCanWin; diff --git a/src/gateway/credentials-secret-inputs.ts b/src/gateway/credentials-secret-inputs.ts index 63354de1883..5ff81fa37d9 100644 --- a/src/gateway/credentials-secret-inputs.ts +++ b/src/gateway/credentials-secret-inputs.ts @@ -35,6 +35,7 @@ type GatewayCredentialSecretInputOptions = { remotePasswordFallback?: GatewayRemoteCredentialFallback; }; +/** Internal options after explicit auth has been trimmed to real credential values. */ type NormalizedGatewayCredentialSecretInputOptions = Omit< GatewayCredentialSecretInputOptions, "explicitAuth" @@ -159,6 +160,8 @@ function canGatewaySecretInputPathWin(params: { value: undefined, }); } + // Inject one path at a time so normal credential precedence decides whether + // that secret ref is on the active auth path without resolving real secrets. assignResolvedGatewaySecretInput({ config: probeConfig, path: params.path, @@ -186,6 +189,7 @@ function canGatewaySecretInputPathWin(params: { } } +/** Test whether resolving a configured secret-ref path could affect selected credentials. */ export function gatewaySecretInputPathCanWin( params: GatewayCredentialSecretInputOptions & { path: SupportedGatewaySecretInputPath }, ): boolean { @@ -254,6 +258,7 @@ async function resolvePreferredGatewaySecretInputs(params: { return nextConfig; } +/** Resolve only secret refs that can win, then select Gateway credentials. */ async function resolveGatewayCredentialsFromConfigWithSecretInputs(params: { options: NormalizedGatewayCredentialSecretInputOptions; env: NodeJS.ProcessEnv; @@ -284,6 +289,8 @@ async function resolveGatewayCredentialsFromConfigWithSecretInputs(params: { if (resolvedConfig === params.options.config) { resolvedConfig = structuredClone(params.options.config); } + // Resolve refs lazily on demand as a backstop for precedence cases the + // optimistic scan skipped, but stop if the same path loops. const resolvedValue = await resolveConfiguredGatewaySecretInput({ config: resolvedConfig, path, @@ -299,6 +306,7 @@ async function resolveGatewayCredentialsFromConfigWithSecretInputs(params: { } } +/** Resolve Gateway credentials after materializing winning configured secret refs. */ export async function resolveGatewayCredentialsWithSecretInputs( params: GatewayCredentialSecretInputOptions, ): Promise<{ token?: string; password?: string }> { diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index e739dbbfa9a..316853cd0ff 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -21,13 +21,21 @@ type ResolvedGatewayCredentials = { password?: string; }; +/** Selects local Gateway credentials or remote Gateway client credentials. */ export type GatewayCredentialMode = "local" | "remote"; + +/** Chooses whether environment credentials or config credentials win for local auth. */ export type GatewayCredentialPrecedence = "env-first" | "config-first"; + +/** Chooses whether remote config or environment credentials win for remote client auth. */ export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first"; + +/** Controls whether remote client auth may fall back to env/local credentials. */ export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only"; const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE"; // pragma: allowlist secret +/** Raised when a command path needs Gateway credentials before secret refs were resolved. */ export class GatewaySecretRefUnavailableError extends Error { readonly code = GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE; readonly path: string; @@ -45,6 +53,7 @@ export class GatewaySecretRefUnavailableError extends Error { } } +/** Type guard for unresolved Gateway secret-ref errors, optionally scoped to a config path. */ export function isGatewaySecretRefUnavailableError( error: unknown, expectedPath?: string, @@ -71,6 +80,7 @@ function throwUnresolvedGatewaySecretInput(path: string): never { throw new GatewaySecretRefUnavailableError(path); } +/** Resolve direct token/password values with caller-selected env-vs-config precedence. */ export function resolveGatewayCredentialsFromValues(params: { configToken?: unknown; configPassword?: unknown; @@ -130,6 +140,8 @@ function resolveLocalGatewayCredentials(params: { params.plan.authMode !== "trusted-proxy" && !localResolved.password); + // Config-first callers must not let an env fallback mask a configured but + // unresolved secret ref that would otherwise be the active local credential. if ( params.plan.localToken.refPath && params.localTokenPrecedence === "config-first" && @@ -208,6 +220,8 @@ function resolveRemoteGatewayCredentials(params: { const localPasswordFallback = params.remotePasswordFallback === "remote-only" ? undefined : params.plan.localPassword.value; // pragma: allowlist secret + // Remote-only probe paths intentionally ignore local fallback credentials; + // normal remote clients keep them as a last resort for older local config. if ( params.plan.remoteToken.refPath && !token && @@ -241,6 +255,7 @@ function resolveRemoteGatewayCredentials(params: { return { token, password }; } +/** Resolve Gateway credentials from config, explicit auth, URL overrides, and mode policy. */ export function resolveGatewayCredentialsFromConfig(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -261,9 +276,12 @@ export function resolveGatewayCredentialsFromConfig(params: { if (explicitToken || explicitPassword) { return { token: explicitToken, password: explicitPassword }; } + // A CLI URL override points at an ad-hoc Gateway, so stored credentials for + // the configured Gateway must not leak into that request. if (trimToUndefined(params.urlOverride) && params.urlOverrideSource !== "env") { return {}; } + // Env URL overrides keep env credentials paired with the same environment. if (trimToUndefined(params.urlOverride) && params.urlOverrideSource === "env") { return resolveGatewayCredentialsFromValues({ configToken: undefined, @@ -308,6 +326,7 @@ export function resolveGatewayCredentialsFromConfig(params: { }); } +/** Resolve the stricter credential view used by Gateway probe paths. */ export function resolveGatewayProbeCredentialsFromConfig(params: { cfg: OpenClawConfig; mode: GatewayCredentialMode; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index a7cbc8cb4b0..7290c6707be 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -28,6 +28,7 @@ export { type OperatorScope, }; +/** Default scopes granted to CLI/operator clients when no narrower local policy is known. */ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ ADMIN_SCOPE, READ_SCOPE, @@ -38,6 +39,8 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ ]; function resolveScopedMethod(method: string): OperatorScope | undefined { + // Core descriptors are authoritative, then reserved namespace policy, then active plugin + // descriptors. Node/dynamic sentinels are intentionally excluded from operator scopes. const explicitScope = resolveCoreOperatorGatewayMethodScope(method); if (explicitScope) { return explicitScope; @@ -53,30 +56,37 @@ function resolveScopedMethod(method: string): OperatorScope | undefined { return pluginScope === "node" || pluginScope === "dynamic" ? undefined : pluginScope; } +/** Returns true when a method requires the approvals operator scope. */ export function isApprovalMethod(method: string): boolean { return resolveScopedMethod(method) === APPROVALS_SCOPE; } +/** Returns true when a method requires the pairing operator scope. */ export function isPairingMethod(method: string): boolean { return resolveScopedMethod(method) === PAIRING_SCOPE; } +/** Returns true when a method can be satisfied by read or stronger write/admin scopes. */ export function isReadMethod(method: string): boolean { return resolveScopedMethod(method) === READ_SCOPE; } +/** Returns true when a method requires write or admin operator scope. */ export function isWriteMethod(method: string): boolean { return resolveScopedMethod(method) === WRITE_SCOPE; } +/** Returns true when a method is reserved for node-role clients instead of operators. */ export function isNodeRoleMethod(method: string): boolean { return isCoreNodeGatewayMethod(method); } +/** Returns true when a method requires admin operator scope. */ export function isAdminOnlyMethod(method: string): boolean { return resolveScopedMethod(method) === ADMIN_SCOPE; } +/** Resolves the required static operator scope for a gateway method, if one exists. */ export function resolveRequiredOperatorScopeForMethod(method: string): OperatorScope | undefined { return resolveScopedMethod(method); } @@ -123,12 +133,15 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod( method: string, params: unknown, ): OperatorScope[] { + // Dynamic methods derive authorization from params and live plugin registrations instead of + // a single static method scope. if (method === "plugins.sessionAction") { return resolveSessionActionLeastPrivilegeScopes(params); } return [WRITE_SCOPE]; } +/** Returns the narrowest known operator scopes needed to call a gateway method. */ export function resolveLeastPrivilegeOperatorScopesForMethod( method: string, params?: unknown, @@ -144,6 +157,7 @@ export function resolveLeastPrivilegeOperatorScopesForMethod( return []; } +/** Checks whether a presented operator scope set authorizes a gateway method call. */ export function authorizeOperatorScopesForMethod( method: string, scopes: readonly string[], @@ -158,6 +172,8 @@ export function authorizeOperatorScopesForMethod( const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId); const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId); if (!pluginId || !actionId) { + // Malformed dynamic params cannot be matched to a plugin action. Any valid operator scope + // may proceed so the handler can return the precise validation error. return scopes.some((scope) => isOperatorScope(scope)) ? { allowed: true } : { allowed: false, missingScope: WRITE_SCOPE }; @@ -182,6 +198,7 @@ export function authorizeOperatorScopesForMethod( return { allowed: false, missingScope: requiredScope }; } +/** Returns true when a method has any core, node, dynamic, reserved, or plugin scope policy. */ export function isGatewayMethodClassified(method: string): boolean { if (isNodeRoleMethod(method)) { return true; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 048f079af16..e9fec2a659e 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -15,6 +15,8 @@ type CoreGatewayMethodSpec = { controlPlaneWrite?: true; }; +// This is the canonical core method policy table: every core handler must appear here so +// listing, authorization, startup availability, and write throttling stay in sync. export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "health", scope: "operator.read" }, { name: "diagnostics.stability", scope: "operator.read" }, @@ -226,24 +228,29 @@ const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap [spec.name, spec]), ); +/** Core methods that are listed early but return retryable unavailable until sidecars are ready. */ export const STARTUP_UNAVAILABLE_GATEWAY_METHODS = CORE_GATEWAY_METHOD_SPECS.filter( (spec) => spec.startup === true, ).map((spec) => spec.name); +/** Returns the core methods that should be advertised to external gateway clients. */ export function listCoreAdvertisedGatewayMethodNames(): string[] { return CORE_GATEWAY_METHOD_SPECS.filter((spec) => spec.advertise !== false).map( (spec) => spec.name, ); } +/** Returns all registered core method names, including hidden/internal compatibility methods. */ export function listCoreGatewayMethodNames(): string[] { return CORE_GATEWAY_METHOD_SPECS.map((spec) => spec.name); } +/** Looks up the raw core method scope, including node and dynamic sentinel scopes. */ export function resolveCoreGatewayMethodScope(method: string): GatewayMethodScope | undefined { return CORE_GATEWAY_METHOD_SPEC_BY_NAME.get(method)?.scope; } +/** Looks up an operator-only core method scope, excluding node and dynamic methods. */ export function resolveCoreOperatorGatewayMethodScope(method: string): OperatorScope | undefined { const scope = resolveCoreGatewayMethodScope(method); return scope === NODE_GATEWAY_METHOD_SCOPE || scope === DYNAMIC_GATEWAY_METHOD_SCOPE @@ -251,18 +258,22 @@ export function resolveCoreOperatorGatewayMethodScope(method: string): OperatorS : scope; } +/** Returns true for core methods reserved for authenticated node clients. */ export function isCoreNodeGatewayMethod(method: string): boolean { return resolveCoreGatewayMethodScope(method) === NODE_GATEWAY_METHOD_SCOPE; } +/** Returns true for core methods whose required operator scope is resolved by the handler. */ export function isDynamicOperatorGatewayMethod(method: string): boolean { return resolveCoreGatewayMethodScope(method) === DYNAMIC_GATEWAY_METHOD_SCOPE; } +/** Returns true when a method name has an explicit core policy entry. */ export function isCoreGatewayMethodClassified(method: string): boolean { return CORE_GATEWAY_METHOD_SPEC_BY_NAME.has(method); } +/** Creates dispatch descriptors for core handlers and fails if any handler lacks policy. */ export function createCoreGatewayMethodDescriptors( handlers: Record, ): GatewayMethodDescriptorInput[] { @@ -286,6 +297,8 @@ export function createCoreGatewayMethodDescriptors( } for (const name of Object.keys(handlers)) { if (!specNames.has(name)) { + // Unclassified core handlers would bypass scope/startup/write metadata, so fail before the + // dispatcher can expose a method with missing policy. throw new Error(`gateway method handler is missing a descriptor: ${name}`); } } diff --git a/src/gateway/methods/descriptor.ts b/src/gateway/methods/descriptor.ts index 58a2627924e..aa66445012b 100644 --- a/src/gateway/methods/descriptor.ts +++ b/src/gateway/methods/descriptor.ts @@ -1,23 +1,29 @@ import type { OperatorScope } from "../operator-scopes.js"; +/** Scope marker for methods that only authenticated node clients may call. */ export const NODE_GATEWAY_METHOD_SCOPE = "node" as const; +/** Scope marker for methods whose handler derives the required operator scope at runtime. */ export const DYNAMIC_GATEWAY_METHOD_SCOPE = "dynamic" as const; +/** Authorization scope attached to a gateway method descriptor. */ export type GatewayMethodScope = | OperatorScope | typeof NODE_GATEWAY_METHOD_SCOPE | typeof DYNAMIC_GATEWAY_METHOD_SCOPE; +/** Owner metadata used to keep core, plugin, channel, and auxiliary methods distinguishable. */ export type GatewayMethodOwner = | { kind: "core"; area: string } | { kind: "plugin"; pluginId: string } | { kind: "channel"; channelId: string } | { kind: "aux"; area: string }; +/** Startup availability flag exposed to clients as retryable startup-unavailable errors. */ export type GatewayMethodStartupAvailability = "available" | "unavailable-until-sidecars"; export type GatewayMethodHandler = (opts: never) => unknown; +/** Complete metadata for one dispatchable gateway method. */ export type GatewayMethodDescriptor = { name: string; handler: GatewayMethodHandler; @@ -29,10 +35,12 @@ export type GatewayMethodDescriptor = { description?: string; }; +/** Input descriptor shape before registry normalization trims and validates the method name. */ export type GatewayMethodDescriptorInput = Omit & { name: string; }; +/** Read-only method registry view used by request dispatch and method listing. */ export type GatewayMethodRegistryView = { getHandler: (name: string) => GatewayMethodHandler | undefined; listMethods: () => string[]; diff --git a/src/gateway/methods/registry.ts b/src/gateway/methods/registry.ts index 418c752c0d5..72269a8ebaf 100644 --- a/src/gateway/methods/registry.ts +++ b/src/gateway/methods/registry.ts @@ -27,6 +27,8 @@ function normalizeDescriptor(input: GatewayMethodDescriptorInput): GatewayMethod if (!name) { throw new Error("gateway method descriptor name must not be empty"); } + // Plugin-owned methods pass through the plugin namespace policy so plugins cannot weaken + // protected core-looking method names by declaring a permissive scope. const normalizedScope = input.scope === NODE_GATEWAY_METHOD_SCOPE || input.scope === DYNAMIC_GATEWAY_METHOD_SCOPE ? input.scope @@ -48,12 +50,15 @@ function normalizeDescriptor(input: GatewayMethodDescriptorInput): GatewayMethod }; } +/** Creates a read-only registry for gateway method lookup, listing, and policy metadata. */ export function createGatewayMethodRegistry( inputs: readonly GatewayMethodDescriptorInput[], ): GatewayMethodRegistry { const descriptors = inputs.map(normalizeDescriptor); const byName = new Map(); for (const descriptor of descriptors) { + // Duplicate method names would make authorization and handler dispatch disagree about the + // owner/scope, so reject them before exposing any registry view. if (byName.has(descriptor.name)) { throw new Error(`gateway method already registered: ${descriptor.name}`); } @@ -73,6 +78,7 @@ export function createGatewayMethodRegistry( }; } +/** Converts a plain handler map into scoped descriptors owned by one gateway surface. */ export function createGatewayMethodDescriptorsFromHandlers(params: { handlers: Record; owner: GatewayMethodOwner; @@ -94,6 +100,7 @@ export function createGatewayMethodDescriptorsFromHandlers(params: { }); } +/** Creates a plugin-owned method descriptor with plugin namespace scope normalization. */ export function createPluginGatewayMethodDescriptor(params: { pluginId: string; name: string; @@ -109,6 +116,7 @@ export function createPluginGatewayMethodDescriptor(params: { }; } +/** Resolves plugin method descriptors, including the legacy handler-only registry shape. */ export function createPluginGatewayMethodDescriptors( registry: Pick & Partial>, @@ -117,6 +125,8 @@ export function createPluginGatewayMethodDescriptors( if (descriptors.length > 0) { return [...descriptors]; } + // Older plugin registries only carried handlers, so keep them callable but assign admin scope + // until the plugin can provide explicit descriptor metadata. return createGatewayMethodDescriptorsFromHandlers({ handlers: registry.gatewayHandlers, owner: { kind: "plugin", pluginId: "unknown" }, diff --git a/src/gateway/operator-scopes.ts b/src/gateway/operator-scopes.ts index 4e301d6f17b..b092119d5bc 100644 --- a/src/gateway/operator-scopes.ts +++ b/src/gateway/operator-scopes.ts @@ -5,6 +5,7 @@ export const APPROVALS_SCOPE = "operator.approvals" as const; export const PAIRING_SCOPE = "operator.pairing" as const; export const TALK_SECRETS_SCOPE = "operator.talk.secrets" as const; +/** Operator privileges advertised by gateway auth and checked by method policy. */ export type OperatorScope = | typeof ADMIN_SCOPE | typeof READ_SCOPE @@ -24,6 +25,7 @@ const KNOWN_OPERATOR_SCOPE_VALUES: readonly OperatorScope[] = [ const KNOWN_OPERATOR_SCOPES: ReadonlySet = new Set(KNOWN_OPERATOR_SCOPE_VALUES); +/** Narrows untrusted auth-token scope entries to the gateway's closed scope set. */ export function isOperatorScope(value: unknown): value is OperatorScope { return typeof value === "string" && KNOWN_OPERATOR_SCOPES.has(value as OperatorScope); } diff --git a/src/gateway/role-policy.ts b/src/gateway/role-policy.ts index 2f35920b17f..5e3d6942cdb 100644 --- a/src/gateway/role-policy.ts +++ b/src/gateway/role-policy.ts @@ -2,8 +2,10 @@ import { isNodeRoleMethod } from "./method-scopes.js"; const GATEWAY_ROLES = ["operator", "node"] as const; +/** Gateway connection roles used before method-level operator scope checks. */ export type GatewayRole = (typeof GATEWAY_ROLES)[number]; +/** Parses the untrusted role claim from connect params into the closed role set. */ export function parseGatewayRole(roleRaw: unknown): GatewayRole | null { if (roleRaw === "operator" || roleRaw === "node") { return roleRaw; @@ -11,10 +13,12 @@ export function parseGatewayRole(roleRaw: unknown): GatewayRole | null { return null; } +/** Operators using shared auth may connect before device identity is established. */ export function roleCanSkipDeviceIdentity(role: GatewayRole, sharedAuthOk: boolean): boolean { return role === "operator" && sharedAuthOk; } +/** Keeps node-originated notifications off the operator RPC surface, and vice versa. */ export function isRoleAuthorizedForMethod(role: GatewayRole, method: string): boolean { if (isNodeRoleMethod(method)) { return role === "node"; diff --git a/src/gateway/secret-input-paths.ts b/src/gateway/secret-input-paths.ts index 577c2b73bd5..a29491b79c1 100644 --- a/src/gateway/secret-input-paths.ts +++ b/src/gateway/secret-input-paths.ts @@ -1,11 +1,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Canonical Gateway config paths whose values may be plaintext or secret refs. */ export type SupportedGatewaySecretInputPath = | "gateway.auth.token" | "gateway.auth.password" | "gateway.remote.token" | "gateway.remote.password"; +/** Stable scan order for Gateway secret-ref credential selection. */ export const ALL_GATEWAY_SECRET_INPUT_PATHS: SupportedGatewaySecretInputPath[] = [ "gateway.auth.token", "gateway.auth.password", @@ -13,12 +15,14 @@ export const ALL_GATEWAY_SECRET_INPUT_PATHS: SupportedGatewaySecretInputPath[] = "gateway.remote.password", ]; +/** Narrow an arbitrary error/config path to one of the supported Gateway secret inputs. */ export function isSupportedGatewaySecretInputPath( path: string, ): path is SupportedGatewaySecretInputPath { return ALL_GATEWAY_SECRET_INPUT_PATHS.includes(path as SupportedGatewaySecretInputPath); } +/** Read a Gateway secret input without assuming whether it is plaintext, a ref, or absent. */ export function readGatewaySecretInputValue( config: OpenClawConfig, path: SupportedGatewaySecretInputPath, @@ -35,6 +39,7 @@ export function readGatewaySecretInputValue( return config.gateway?.remote?.password; } +/** Replace one Gateway secret input with its resolved plaintext value on a cloned config. */ export function assignResolvedGatewaySecretInput(params: { config: OpenClawConfig; path: SupportedGatewaySecretInputPath; @@ -64,6 +69,7 @@ export function assignResolvedGatewaySecretInput(params: { } } +/** Distinguish token paths from password paths for auth-mode precedence checks. */ export function isTokenGatewaySecretInputPath(path: SupportedGatewaySecretInputPath): boolean { return path === "gateway.auth.token" || path === "gateway.remote.token"; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index c0608b19ae0..7c8cb46cf40 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -245,6 +245,8 @@ async function canRevealReadinessDetails(params: { trustedProxies: string[]; allowRealIpFallback: boolean; }): Promise { + // Readiness details expose subsystem names; show them only to local direct callers or + // requests that prove gateway auth, while unauthenticated remote probes get a boolean. if (isLocalDirectRequest(params.req, params.trustedProxies, params.allowRealIpFallback)) { return true; } @@ -265,6 +267,7 @@ async function canRevealReadinessDetails(params: { return authResult.ok; } +/** Handles live/ready probe endpoints before normal gateway routing. */ async function handleGatewayProbeRequest( req: IncomingMessage, res: ServerResponse, @@ -413,6 +416,8 @@ function buildPluginRequestStages(params: { let pluginGatewayAuthSatisfied = false; let pluginGatewayRequestAuth: AuthorizedGatewayHttpRequest | undefined; let pluginRequestOperatorScopes: string[] | undefined; + // Plugin auth and plugin dispatch are separate stages so route handlers receive the + // gateway-auth context while plugin failures can still fall through to core/Control UI routes. return [ { name: "plugin-auth", @@ -429,6 +434,8 @@ function buildPluginRequestStages(params: { if ((await params.getGatewayAuthBypassPaths()).has(params.requestPath)) { return false; } + // Bypass paths are limited to bundled channel callbacks; all other protected plugin + // routes must produce an AuthorizedGatewayHttpRequest before runtime scopes are derived. const { authorizeGatewayHttpRequestOrReply } = await getHttpAuthUtilsModule(); const requestAuth = await authorizeGatewayHttpRequestOrReply({ req: params.req, @@ -470,6 +477,7 @@ function buildPluginRequestStages(params: { ]; } +/** Creates the gateway HTTP/HTTPS server and ordered request-stage router. */ export function createGatewayHttpServer(opts: { clients: Set; controlUiEnabled: boolean; @@ -566,6 +574,8 @@ export function createGatewayHttpServer(opts: { return; } if (scopedNodeCapability.rewrittenUrl) { + // Scoped capability URLs are normalized before auth/routing so built-in handlers, + // plugin route matching, and audit context all see the same canonical path. req.url = scopedNodeCapability.rewrittenUrl; } const scopedRequestPath = scopedNodeCapability.pathname; @@ -812,6 +822,7 @@ export function createGatewayHttpServer(opts: { return httpServer; } +/** Attaches WebSocket and plugin-upgrade routing to an already-created HTTP server. */ export function attachGatewayUpgradeHandler(opts: { httpServer: HttpServer; wss: WebSocketServer; @@ -859,6 +870,8 @@ export function attachGatewayUpgradeHandler(opts: { const pathContext = resolvePluginRoutePathContext(requestPath); const nodeCapability = resolvePluginNodeCapabilityRoute?.(pathContext); if (nodeCapability) { + // Node-capability WebSocket upgrades authenticate before plugin upgrade dispatch so + // plugin handlers never receive unauthorized scoped capability sockets. const { authorizePluginNodeCapabilityRequest } = await getPluginNodeCapabilityAuthModule(); const ok = await authorizePluginNodeCapabilityRequest({ req, @@ -933,6 +946,8 @@ export function attachGatewayUpgradeHandler(opts: { return; } let budgetTransferred = false; + // The socket owns the preauth budget until the WebSocket connection handler claims it; + // close/error paths release here to avoid leaking unauthenticated connection slots. const releaseUpgradeBudget = () => { if (budgetTransferred) { return; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 5220f06a663..ec58e680dca 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -8,6 +8,7 @@ type GatewayMethodChannelPlugin = { gatewayMethodDescriptors?: readonly { name: string }[]; }; +/** Lists core methods intentionally advertised to gateway clients. */ export function listCoreGatewayMethods(): string[] { return listCoreAdvertisedGatewayMethodNames(); } @@ -15,6 +16,8 @@ export function listCoreGatewayMethods(): string[] { function listChannelGatewayMethods(): string[] { const methods: string[] = []; for (const plugin of listLoadedChannelPlugins() as GatewayMethodChannelPlugin[]) { + // Plugins may still expose legacy names while newer plugins expose descriptors. + // Merge both so method discovery stays compatible during descriptor adoption. methods.push(...(plugin.gatewayMethods ?? [])); for (const descriptor of plugin.gatewayMethodDescriptors ?? []) { methods.push(descriptor.name); @@ -23,12 +26,14 @@ function listChannelGatewayMethods(): string[] { return methods; } +/** Returns the de-duplicated gateway method catalog advertised through method-list APIs. */ export function listGatewayMethods(): string[] { return Array.from( new Set([...listCoreGatewayMethods(), ...GATEWAY_AUX_METHODS, ...listChannelGatewayMethods()]), ); } +/** Gateway event names that clients can subscribe to or receive over the wire. */ export const GATEWAY_EVENTS = [ "connect.challenge", "agent", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index df5dd254200..ed21f04a8c0 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -29,6 +29,8 @@ function lazyHandlerModule( selectHandlers: (module: T) => GatewayRequestHandlers, ): () => Promise { let handlersPromise: Promise | null = null; + // Gateway starts advertise the method table before most handler modules are needed; cache the + // first import promise so concurrent calls to the same method family share one load. return () => (handlersPromise ??= loadModule().then(selectHandlers)); } @@ -43,6 +45,8 @@ function createLazyCoreHandlers(params: { const handlers = await params.loadHandlers(); const handler = handlers[method]; if (!handler) { + // Descriptor drift should fail loudly: advertised core methods must exist in the + // loaded family module once the lazy boundary resolves. throw new Error(`lazy gateway handler not found: ${method}`); } await handler(opts); @@ -217,6 +221,8 @@ function authorizeGatewayMethod( client: GatewayRequestOptions["client"], params: unknown, ) { + // Pre-connect and health requests are allowed through; role/scope checks require the + // authenticated connect metadata established by the gateway handshake. if (!client?.connect) { return null; } @@ -560,6 +566,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { }), }; +/** Builds the per-request method registry from core, plugin, and explicit extra handlers. */ function createRequestGatewayMethodRegistry( extraHandlers?: GatewayRequestHandlers, ): GatewayMethodRegistry { @@ -569,6 +576,8 @@ function createRequestGatewayMethodRegistry( const pluginMethodNames = new Set(Object.keys(activePluginHandlers)); const coreDescriptorHandlers = { ...coreGatewayHandlers }; for (const [method, extraHandler] of extraHandlerEntries) { + // Tests and local harnesses can override classified core methods, but plugin-provided + // methods win so a loaded plugin cannot be shadowed by a caller-local extra handler. if (!pluginMethodNames.has(method) && isCoreGatewayMethodClassified(method)) { coreDescriptorHandlers[method] = extraHandler; } @@ -597,6 +606,7 @@ function createRequestGatewayMethodRegistry( ]); } +/** Authorizes and dispatches one gateway JSON-RPC-style request. */ export async function handleGatewayRequest( opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers }, ): Promise { @@ -609,6 +619,8 @@ export async function handleGatewayRequest( return; } if (context.unavailableGatewayMethods?.has(req.method)) { + // During startup, methods can be listed before their runtime is ready. Return the protocol + // retry shape so clients can back off without treating startup as a permanent unknown method. respond( false, undefined, @@ -623,6 +635,8 @@ export async function handleGatewayRequest( if (methodRegistry.isControlPlaneWrite(req.method)) { const budget = consumeControlPlaneWriteBudget({ client }); if (!budget.allowed) { + // Control-plane writes mutate gateway-wide state; rate limit before handler lookup so + // plugin and aux write methods share the same protection. const actor = resolveControlPlaneActor(client); context.logGateway.warn( `control-plane write rate-limited method=${req.method} ${formatControlPlaneActor(actor)} retryAfterMs=${budget.retryAfterMs} key=${budget.key}`, @@ -667,5 +681,6 @@ export async function handleGatewayRequest( // All handlers run inside a request scope so that plugin runtime // subagent methods (e.g. context engine tools spawning sub-agents // during tool execution) can dispatch back into the gateway. + // The scope also carries caller identity into plugin-owned gateway methods. await withPluginRuntimeGatewayRequestScope({ context, client, isWebchatConnect }, invokeHandler); } diff --git a/src/gateway/server-methods/agent-wait-dedupe.ts b/src/gateway/server-methods/agent-wait-dedupe.ts index 44316b8e3fe..a7f8c033143 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.ts @@ -75,6 +75,8 @@ function addWaiter(runId: string, waiter: () => void): () => void { return () => removeWaiter(normalizedRunId, waiter); } +// Waiters are keyed only by run id so chat and agent dedupe entries can wake +// the same `agent.wait` request regardless of which path finishes first. function notifyWaiters(runId: string): void { const normalizedRunId = runId.trim(); if (!normalizedRunId) { @@ -204,6 +206,8 @@ export function readTerminalSnapshotFromGatewayDedupe(params: { runId: string; ignoreAgentTerminalSnapshot?: boolean; }): AgentWaitTerminalSnapshot | null { + // Agent and chat handlers both cache terminal state. Project them into one + // wait result while preserving stronger terminal outcomes such as hard timeout. if (params.ignoreAgentTerminalSnapshot) { const chatEntry = params.dedupe.get(`chat:${params.runId}`); if (!chatEntry) { @@ -255,6 +259,8 @@ export async function waitForTerminalGatewayDedupe(params: { return await new Promise((resolve) => { let settled = false; + // Always re-read from the dedupe map on wake; waiters are notifications, + // not carriers of terminal data, so stale callbacks cannot resolve a run. const finish = (snapshot: AgentWaitTerminalSnapshot | null) => { if (settled) { return; @@ -299,6 +305,8 @@ export function setGatewayDedupeEntry(params: { key: string; entry: DedupeEntry; }) { + // Preserve sticky terminal outcomes before publishing the new entry. This + // protects waiters from late accepted/in-flight rewrites for the same run id. const existing = params.dedupe.get(params.key); const existingSnapshot = existing ? readTerminalSnapshotFromDedupeEntry(existing) : null; const incomingSnapshot = readTerminalSnapshotFromDedupeEntry(params.entry); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index c8a1916b812..22253d14203 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -415,6 +415,7 @@ async function resolveBareSessionResetResult(params: { cfg: params.cfg, agentId: params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey), }); + // Main/global resets default to best-effort delivery because no caller session may remain. const bestEffortDeliver = typeof params.request.bestEffortDeliver === "boolean" ? params.request.bestEffortDeliver @@ -505,6 +506,7 @@ function resolveTrustedGroupMetadata(params: { inherited?: TrustedGroupMetadata; }): TrustedGroupMetadata { return { + // Group trust can be inherited from the parent run or recovered from conversation-shaped keys. groupId: params.stored.groupId ?? params.inherited?.groupId ?? @@ -521,6 +523,7 @@ function requestGroupMatchesTrusted(params: { }): boolean { const requestGroupId = params.requestGroupId?.trim(); if (!requestGroupId) { + // Missing group metadata is accepted so non-group channels keep the same send path. return true; } return Boolean(params.trustedGroupId && requestGroupId === params.trustedGroupId); @@ -545,6 +548,7 @@ function emitSessionsChanged( : undefined, ) : null; + // Unscoped global updates must not leak one agent's goal into another agent's UI row. const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId; context.broadcastToConnIds( "sessions.changed", diff --git a/src/gateway/server-methods/agents-config-mutations.ts b/src/gateway/server-methods/agents-config-mutations.ts index 0340bf61ab0..8924be48341 100644 --- a/src/gateway/server-methods/agents-config-mutations.ts +++ b/src/gateway/server-methods/agents-config-mutations.ts @@ -17,6 +17,7 @@ export type AgentDeleteMutationResult = { removedBindings: number; }; +/** Typed precondition failure surfaced by agent mutation handlers as gateway errors. */ export class AgentConfigPreconditionError extends Error { constructor( readonly kind: "already-exists" | "not-found", @@ -31,10 +32,12 @@ export class AgentConfigPreconditionError extends Error { } } +/** Checks the current config snapshot for a concrete agent entry. */ export function isConfiguredAgent(cfg: OpenClawConfig, agentId: string): boolean { return findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0; } +/** Adds a new agent entry through the retrying config mutation path. */ export async function createAgentConfigEntry(params: { agentId: string; name: string; @@ -62,6 +65,7 @@ export async function createAgentConfigEntry(params: { }); } +/** Updates an existing agent entry while preserving omitted fields. */ export async function updateAgentConfigEntry(params: { agentId: string; name?: string; @@ -87,6 +91,7 @@ export async function updateAgentConfigEntry(params: { }); } +/** Removes an agent entry and returns filesystem roots the caller should clean up. */ export async function deleteAgentConfigEntry(params: { agentId: string }): Promise<{ nextConfig: OpenClawConfig; result: AgentDeleteMutationResult | undefined; diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index c6ca0fa7d0b..f8c8b6610f9 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -94,6 +94,7 @@ export const testing = { const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME] as const; +// Gateway file mutations are intentionally capped to the workspace files the UI owns. const ALLOWED_FILE_NAMES = new Set([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]); function resolveAgentWorkspaceFileOrRespondError( @@ -142,6 +143,7 @@ function isRegularWorkspaceFileStat(stat: { const isFile = typeof stat.isFile === "function" ? stat.isFile() : stat.isFile; const isSymbolicLink = typeof stat.isSymbolicLink === "function" ? stat.isSymbolicLink() : stat.isSymbolicLink; + // Reject links even after path-root containment so workspace reads cannot follow shared files. return isFile && !isSymbolicLink && stat.nlink <= 1; } @@ -166,6 +168,7 @@ async function statWorkspaceFileSafely( return null; } try { + // fs-safe roots can reject fixtures that are still valid regular files for listing metadata. const stat = await fs.lstat(path.join(workspaceDir, name)); if (!isRegularWorkspaceFileStat(stat)) { return null; @@ -199,6 +202,7 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: const workspaceRoot = await openWorkspaceRootSafely(workspaceDir); if (!workspaceRoot) { + // Keep the UI shape stable when the workspace path is missing or unsafe. const missingNames = [ ...(options?.hideBootstrap ? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING : BOOTSTRAP_FILE_NAMES), DEFAULT_MEMORY_FILENAME, @@ -407,6 +411,7 @@ async function buildIdentityMarkdownForWrite(params: { }): Promise { let baseContent: string | undefined; if (params.preferFallbackWorkspaceContent && params.fallbackWorkspaceDir) { + // Workspace moves may create a blank identity file; merge into the previous user-edited file. baseContent = await readWorkspaceFileContent( params.fallbackWorkspaceDir, DEFAULT_IDENTITY_FILENAME, diff --git a/src/gateway/server-methods/approval-shared.ts b/src/gateway/server-methods/approval-shared.ts index cd27691bde9..c7bc5b91662 100644 --- a/src/gateway/server-methods/approval-shared.ts +++ b/src/gateway/server-methods/approval-shared.ts @@ -116,6 +116,7 @@ function normalizeApprovalIdentity(value: string | null | undefined): string | n return normalizeOptionalString(value) ?? null; } +/** Checks whether a client can observe or resolve an approval record. */ export function isApprovalRecordVisibleToClient(params: { record: ExecApprovalRecord; client: GatewayClient | null; @@ -132,6 +133,8 @@ export function isApprovalRecordVisibleToClient(params: { return true; } + // Device identity is the strongest requester binding; fall back to the + // live connection only for older callers that have not attached a device. if (requestedByDeviceId) { return requestedByDeviceId === normalizeApprovalIdentity(params.client?.connect?.device?.id); } @@ -145,9 +148,12 @@ export function isApprovalRecordVisibleToClient(params: { return false; } + // Unbound approvals predate requester metadata and remain visible so pending + // work can still be resolved after upgrades or gateway restarts. return true; } +/** Returns only pending approval requests the connected client is allowed to see. */ export function listVisiblePendingApprovalRequests(params: { manager: ExecApprovalManager; client?: GatewayClient | null; @@ -168,6 +174,7 @@ export function listVisiblePendingApprovalRequests(params: { })); } +/** Binds the current gateway client identity onto a newly-created approval record. */ export function bindApprovalRequesterMetadata(params: { record: ExecApprovalRecord; client?: GatewayClient | null; @@ -178,6 +185,7 @@ export function bindApprovalRequesterMetadata(params: { params.record.requestedByDeviceTokenAuth = params.client?.isDeviceTokenAuth === true; } +/** Registers an approval record and converts manager registration errors to gateway errors. */ export function registerPendingApprovalRecord(params: { manager: ExecApprovalManager; record: ExecApprovalRecord; @@ -196,6 +204,7 @@ export function registerPendingApprovalRecord(params: { } } +/** Builds the gateway event payload broadcast when an approval starts waiting. */ export function buildRequestedApprovalEvent( record: ExecApprovalRecord, ): RequestedApprovalEvent { @@ -207,6 +216,7 @@ export function buildRequestedApprovalEvent(params: { rawParams: unknown; validate: ApprovalResolveParamsValidator; @@ -235,6 +245,7 @@ export function resolveApprovalDecisionParams(params: { context: GatewayRequestContext; record: ExecApprovalRecord; @@ -253,6 +264,7 @@ export function resolveApprovalRequestRecipientConnIds(params: { ); } +/** Finds a pending approval by full id or prefix after applying client visibility rules. */ export function resolvePendingApprovalRecord(params: { manager: ExecApprovalManager; inputId: string; @@ -305,6 +317,7 @@ function resolveApprovalRecordForState( return { ok: true, approvalId: resolvedId.id, snapshot }; } +/** Sends the public lookup failure shape for missing, expired, or ambiguous approvals. */ export function respondPendingApprovalLookupError(params: { respond: RespondFn; response: PendingApprovalLookupError; @@ -316,6 +329,7 @@ export function respondPendingApprovalLookupError(params: { params.respond(false, undefined, errorShape(params.response.code, params.response.message)); } +/** Waits for an already-registered approval decision visible to the caller. */ export async function handleApprovalWaitDecision(params: { manager: ExecApprovalManager; inputId: unknown; @@ -364,6 +378,7 @@ export async function handleApprovalWaitDecision(params: { ); } +/** Broadcasts or routes a pending approval request, then responds after acceptance/decision. */ export async function handlePendingApprovalRequest< TPayload extends ApprovalTurnSourceFields, >(params: { @@ -417,6 +432,8 @@ export async function handlePendingApprovalRequest< : (params.context.hasExecApprovalClients?.(params.clientConnId) ?? false); const deliveredResult = suppressDelivery ? false : params.deliverRequest(); const delivered = isPromiseLike(deliveredResult) ? await deliveredResult : deliveredResult; + // A turn-source route can approve without an active approval client, so keep + // the record alive when the originating channel/account can still receive it. const hasTurnSourceRoute = !hasApprovalClients && !delivered && @@ -482,6 +499,7 @@ export async function handlePendingApprovalRequest< ); } +/** Resolves a pending approval and broadcasts the final decision exactly once. */ export async function handleApprovalResolve(params: { manager: ExecApprovalManager; inputId: string; @@ -526,6 +544,8 @@ export async function handleApprovalResolve { if (!assertValidParams(params, validateArtifactsListParams, "artifacts.list", respond)) { diff --git a/src/gateway/server-methods/attachment-normalize.ts b/src/gateway/server-methods/attachment-normalize.ts index a23320efcdb..de87671ec66 100644 --- a/src/gateway/server-methods/attachment-normalize.ts +++ b/src/gateway/server-methods/attachment-normalize.ts @@ -1,5 +1,6 @@ import type { ChatAttachment } from "../chat-attachments.js"; +/** RPC attachment payload shape accepted by chat-like gateway methods. */ export type RpcAttachmentInput = { type?: unknown; mimeType?: unknown; @@ -9,6 +10,8 @@ export type RpcAttachmentInput = { }; function normalizeAttachmentContent(content: unknown): string | undefined { + // RPC callers may send browser ArrayBuffers, typed-array slices, or base64 + // strings. Normalize all accepted forms to the chat attachment wire shape. if (typeof content === "string") { return content; } @@ -21,9 +24,12 @@ function normalizeAttachmentContent(content: unknown): string | undefined { return undefined; } +/** Convert permissive RPC attachment payloads into the bounded chat attachment shape. */ export function normalizeRpcAttachmentsToChatAttachments( attachments: RpcAttachmentInput[] | undefined, ): ChatAttachment[] { + // Accept both the OpenClaw attachment fields and Anthropic-style + // source:{type:"base64",media_type,data} payloads used by some clients. return ( attachments ?.map((a) => { diff --git a/src/gateway/server-methods/base-hash.ts b/src/gateway/server-methods/base-hash.ts index c4c3db54580..eed20669ebf 100644 --- a/src/gateway/server-methods/base-hash.ts +++ b/src/gateway/server-methods/base-hash.ts @@ -1,4 +1,7 @@ +/** Read the optional optimistic-write base hash from a gateway method payload. */ export function resolveBaseHashParam(params: unknown): string | null { + // Base hashes are optimistic-write guards. Treat missing, blank, and + // non-string values as absent so callers must opt in deliberately. const raw = (params as { baseHash?: unknown })?.baseHash; if (typeof raw !== "string") { return null; diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index f0b9fdccdfb..bc6a0053672 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -106,6 +106,8 @@ async function runChannelStatusHook(params: { run: () => Promise; }): Promise { const timeoutMs = Math.max(1, params.timeoutMs); + // Channel probes come from plugin code and external services. Convert slow or + // failing hooks into partial status data so one channel cannot block the UI. const result = await raceWithTimeout({ timeoutMs, run: params.run, @@ -193,6 +195,8 @@ function resolveChannelGatewayAccountId(params: { cfg: OpenClawConfig; accountId?: string | null; }): string { + // Runtime operations use the same account precedence as channel setup: + // explicit request, plugin default, first configured account, then fallback. return ( normalizeOptionalString(params.accountId) || params.plugin.config.defaultAccountId?.(params.cfg) || @@ -210,6 +214,8 @@ export async function logoutChannelAccount(params: { }): Promise { const resolvedAccountId = resolveChannelGatewayAccountId(params); const account = params.plugin.config.resolveAccount(params.cfg, resolvedAccountId); + // Stop the runtime before clearing channel-owned auth so no active watcher can + // immediately reconnect with credentials the user is trying to remove. await params.context.stopChannel(params.channelId, resolvedAccountId); const result = await params.plugin.gateway?.logoutAccount?.({ cfg: params.cfg, @@ -356,6 +362,8 @@ export const channelsHandlers: GatewayRequestHandlers = { let probeResult: unknown; let lastProbeAt: number | null = null; if (probe && enabled && plugin.status?.probeAccount) { + // Skip expensive probes for accounts that are not configured; the + // snapshot builder still reports the config state below. let configured = true; if (plugin.config.isConfigured) { configured = await plugin.config.isConfigured(account, cfg); diff --git a/src/gateway/server-methods/chat-reply-media.ts b/src/gateway/server-methods/chat-reply-media.ts index af6302e5d4e..60189c0caf1 100644 --- a/src/gateway/server-methods/chat-reply-media.ts +++ b/src/gateway/server-methods/chat-reply-media.ts @@ -20,6 +20,7 @@ function shouldPreserveDisplayMediaUrl(payload: ReplyPayload, mediaUrl: string): if (isPassThroughRemoteMediaSource(mediaUrl)) { return true; } + // Local audio is preserved only after the producer marks it as already trust-scoped. return payload.trustedLocalMedia === true; } @@ -48,6 +49,7 @@ export async function normalizeWebchatReplyMediaPathsForDisplay(params: { const normalized: ReplyPayload[] = []; for (const payload of params.payloads) { if (payload.sensitiveMedia === true) { + // Suppressed media must not be copied into managed outbound storage for display. normalized.push(payload); continue; } diff --git a/src/gateway/server-methods/chat-transcript-inject.ts b/src/gateway/server-methods/chat-transcript-inject.ts index cdb35b5236d..c428d78a8db 100644 --- a/src/gateway/server-methods/chat-transcript-inject.ts +++ b/src/gateway/server-methods/chat-transcript-inject.ts @@ -6,12 +6,14 @@ import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js type AppendMessageArg = Parameters[0]; +/** Metadata persisted on gateway-injected assistant messages that mark a stopped run. */ export type GatewayInjectedAbortMeta = { aborted: true; origin: "rpc" | "stop-command"; runId: string; }; +/** Result shape returned after appending an assistant row to a session transcript. */ export type GatewayInjectedTranscriptAppendResult = { ok: boolean; messageId?: string; @@ -19,6 +21,7 @@ export type GatewayInjectedTranscriptAppendResult = { error?: string; }; +/** Hash marker used to dedupe companion TTS text/audio supplements. */ export type GatewayInjectedTtsSupplementMarker = { textSha256: string; }; @@ -29,6 +32,8 @@ function resolveInjectedAssistantContent(params: { content?: Array>; }): Array> { const labelPrefix = params.label ? `[${params.label}]\n\n` : ""; + // Preserve rich content arrays when callers already prepared media blocks; + // only the first text block is rewritten so block ordering stays intact. if (params.content && params.content.length > 0) { if (!labelPrefix) { return params.content; @@ -47,6 +52,7 @@ function resolveInjectedAssistantContent(params: { return [{ type: "text", text: `${labelPrefix}${params.message}` }]; } +/** Append a gateway-authored assistant message while preserving transcript parent links. */ export async function appendInjectedAssistantMessageToTranscript(params: { transcriptPath: string; sessionKey?: string; diff --git a/src/gateway/server-methods/chat-webchat-media.ts b/src/gateway/server-methods/chat-webchat-media.ts index bc2d9bc7a77..989cca73687 100644 --- a/src/gateway/server-methods/chat-webchat-media.ts +++ b/src/gateway/server-methods/chat-webchat-media.ts @@ -87,6 +87,7 @@ async function readLocalAudioContentBlockForEmbedding( options: WebchatAudioEmbeddingOptions | undefined, ): Promise { if (payload.trustedLocalMedia !== true) { + // WebChat may embed local audio only after an upstream path normalizer grants trust. return null; } const resolved = resolveLocalMediaPathForEmbedding(raw); @@ -181,6 +182,7 @@ function resolveEmbeddableImageUrl(url: string): string | null { if (!ALLOWED_WEBCHAT_DATA_IMAGE_MEDIA_TYPES.has(mediaType)) { return null; } + // Size-check the decoded image, not just the data URL string length. if (estimateBase64DecodedBytes(base64Data) > MAX_WEBCHAT_IMAGE_DATA_BYTES) { return null; } @@ -281,6 +283,7 @@ export async function buildWebchatAssistantMessageFromReplyPayloads( payloadMediaBlocks.length > 0 && (!text || replyDirectivePrefix) && transcriptTextParts.length === 0; + // Media-only replies need stable transcript text so later context is readable. const syntheticText = needsSyntheticText ? payloadHasAudio && payloadHasImage ? "Media reply" diff --git a/src/gateway/server-methods/commands.ts b/src/gateway/server-methods/commands.ts index 15fbb8027e3..bf04ccaa331 100644 --- a/src/gateway/server-methods/commands.ts +++ b/src/gateway/server-methods/commands.ts @@ -90,6 +90,7 @@ function stripLeadingSlash(value: string): string { return value.startsWith("/") ? value.slice(1) : value; } +/** Resolves normalized text aliases, preserving slash-prefixed command names. */ function resolveTextAliases(cmd: ChatCommandDefinition): string[] { const seen = new Set(); const aliases: string[] = []; @@ -118,6 +119,7 @@ function resolvePrimaryTextName(cmd: ChatCommandDefinition): string { return stripLeadingSlash(resolveTextAliases(cmd)[0] ?? `/${cmd.key}`); } +/** Serializes a command argument into the bounded gateway protocol shape. */ function serializeArg(arg: CommandArgDefinition): SerializedArg { const isDynamic = typeof arg.choices === "function"; const staticChoices = Array.isArray(arg.choices) @@ -174,6 +176,7 @@ function mapCommand( }; } +/** Builds plugin command entries from text specs plus provider-native metadata. */ function buildPluginCommandEntries(params: { provider?: string; nameSurface: CommandNameSurface; @@ -206,6 +209,7 @@ function buildPluginCommandEntries(params: { return entries; } +/** Builds the public commands.list payload for an agent/provider/scope view. */ export function buildCommandsListResult(params: { cfg: OpenClawConfig; agentId: string; @@ -244,6 +248,7 @@ export function buildCommandsListResult(params: { return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) }; } +/** Gateway handler for enumerating available chat/native commands. */ export const commandsHandlers: GatewayRequestHandlers = { "commands.list": ({ params, respond, context }) => { if (!validateCommandsListParams(params)) { diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index c8d28a8abb9..846830a9de8 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -27,6 +27,7 @@ export type ConfigWriteOptions = Awaited< ReturnType >["writeOptions"]; +/** Resolves the on-disk config path used in config method responses. */ export function resolveGatewayConfigPath(snapshot?: Pick): string { return snapshot?.path ?? createConfigIO().configPath; } @@ -49,6 +50,7 @@ function normalizeTrustedProxyAuthForCompare(auth: ReturnType { + // Defer generation refresh/disconnect until after the RPC response so + // the writer receives the success payload before its connection is closed. queueSharedGatewayAuthGenerationRefresh(true, result.nextConfig, params.context); queueSharedGatewayAuthDisconnect(Boolean(params.disconnectSharedAuthClients), params.context); }, }; } +/** Builds restart sentinel/queue state for config.patch and config.apply writes. */ export async function resolveGatewayConfigRestartWriteResult(params: { requestParams: unknown; kind: RestartSentinelPayload["kind"]; diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 7f49fbda5bf..db670b5e2fd 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -218,6 +218,7 @@ function stripBundledProviderRuntimeDefaults(params: { let nextProviders: Record | undefined; for (const [providerId, provider] of Object.entries(models.providers)) { + // Runtime overlays can materialize empty defaults that should not become persisted config. if (!isBuiltInModelProviderOverlayId(providerId) || !isRecord(provider)) { continue; } @@ -277,6 +278,7 @@ function parseValidateConfigFromRawOrRespond( ); return null; } + // Validate against runtime shape, but write the source-shaped config the operator submitted. const projectedValidationCandidate = snapshot.valid ? applyMergePatch( projectSourceOntoRuntimeShape(snapshot.resolved, snapshot.config), @@ -387,6 +389,7 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { configSchemaResponseCache = null; } + // Plugin schema loading is process-local; short caching avoids repeated UI lookups per render. const response = loadGatewayRuntimeConfigSchema(); const expiresAtMs = resolveExpiresAtMsFromDurationMs(CONFIG_SCHEMA_RESPONSE_CACHE_TTL_MS); if (expiresAtMs !== undefined) { @@ -525,6 +528,7 @@ export const configHandlers: GatewayRequestHandlers = { return; } const merged = applyMergePatch(snapshot.config, parsedRes.parsed, { + // Arrays with stable ids behave like maps for partial control-plane edits. mergeObjectArraysById: true, }); const schemaPatch = loadSchemaWithPlugins(); diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 8a197dd93d0..4aa8a4e4f18 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -46,6 +46,8 @@ function assertConfiguredAnnounceChannel(params: { channel?: string; field: "delivery.channel" | "delivery.failureDestination.channel"; }) { + // `last` defers channel selection to runtime session context; every concrete + // announce channel must be one the gateway can actually deliver through. if (params.channel === "last") { return; } @@ -76,6 +78,8 @@ function resolveAnnounceValidationChannel(params: { channel?: string; to?: string; }): string | undefined { + // A target like `telegram:...` is enough to validate the announce channel + // even when the explicit channel field is omitted. if (params.channel && params.channel !== "last") { return params.channel; } @@ -151,6 +155,8 @@ function assertValidCronUpdateDelivery(params: { return; } + // Validate the post-patch job, not just the sparse patch, because delivery + // fields can be split across the existing job and the update payload. const nextJob = structuredClone(params.currentJob); applyJobPatch(nextJob, params.patch, { defaultAgentId: params.defaultAgentId, @@ -181,6 +187,8 @@ export const cronHandlers: GatewayRequestHandlers = { }; const sessionKey = p.sessionKey?.trim() || undefined; if (sessionKey && isSubagentSessionKey(sessionKey)) { + // Wake requests resume user-visible sessions only; subagent sessions are + // internal task execution targets and should not receive operator wakes. respond( false, undefined, diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 41f9c7b174d..383129b8411 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -45,6 +45,8 @@ const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied"; function redactPairedDevice( device: { tokens?: Record } & Record, ) { + // Pairing lists are visible to operators; expose token lifecycle metadata + // without returning raw token material or the internal approved-scope set. const { tokens, approvedScopes: _approvedScopes, ...rest } = device; return { ...rest, @@ -99,6 +101,8 @@ function resolveDeviceSessionAuthz(client: GatewayClient | null): DeviceSessionA const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; const rawCallerDeviceId = client?.connect?.device?.id; const callerDeviceId = + // Plain shared-auth connections may report device metadata, but only + // device-token auth proves ownership for self-service pairing actions. client?.isDeviceTokenAuth && typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim() ? rawCallerDeviceId.trim() : null; @@ -118,6 +122,8 @@ function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean { } function shouldReturnRotatedDeviceToken(authz: DeviceManagementAuthz): boolean { + // Admins can rotate any token, but only a device rotating itself receives + // the new token in-band; other rotations are notification/invalidations. return Boolean(authz.callerDeviceId && authz.callerDeviceId === authz.normalizedTargetDeviceId); } diff --git a/src/gateway/server-methods/diagnostics.ts b/src/gateway/server-methods/diagnostics.ts index 50404099bce..f8022596bb8 100644 --- a/src/gateway/server-methods/diagnostics.ts +++ b/src/gateway/server-methods/diagnostics.ts @@ -5,9 +5,12 @@ import { } from "../../logging/diagnostic-stability.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** Gateway handler for payload-free stability diagnostics. */ export const diagnosticsHandlers: GatewayRequestHandlers = { "diagnostics.stability": async ({ params, respond }) => { try { + // Normalization owns parameter bounds so malformed diagnostic requests + // return a client error instead of leaking logging internals. const query = normalizeDiagnosticStabilityQuery(params); respond(true, getDiagnosticStabilitySnapshot(query), undefined); } catch (err) { diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 0fe5e6d969c..a54c2a4f61d 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -347,6 +347,7 @@ function normalizeMemoryPathForWorkspace(workspaceDir: string, rawPath: string): function isShortTermMemoryPath(filePath: string): boolean { const normalized = normalizeMemoryPath(filePath); + // Status only counts short-term source shapes; promoted diary/report files stay out. if (/(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized)) { return true; } @@ -462,6 +463,7 @@ function trimDreamingEntries( ): DoctorMemoryDreamingEntryPayload[] { const selected: DoctorMemoryDreamingEntryPayload[] = []; for (const entry of entries) { + // Keep the public status payload bounded while preserving the comparator's best entries. let insertAt = selected.length; for (let index = 0; index < selected.length; index += 1) { if (compare(entry, selected[index]) < 0) { @@ -580,6 +582,7 @@ async function loadDreamingStoreStats( const phaseStore = asRecord(parsedPhase); const phaseEntries = asRecord(phaseStore?.entries) ?? {}; for (const [key, value] of Object.entries(phaseEntries)) { + // Phase signals are joined only to active short-term entries, not archived promotions. if (!activeKeys.has(key)) { continue; } @@ -769,6 +772,7 @@ function isManagedDreamingJob( if (description?.includes(params.tag)) { return true; } + // Older managed jobs may lack the tag, so fall back to the exact system-event signature. const name = normalizeTrimmedString(job.name); const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase(); const payloadText = normalizeTrimmedString(job.payload?.text); @@ -854,6 +858,7 @@ async function readDreamDiary( }; } if (stat.isSymbolicLink() || !stat.isFile()) { + // Ignore redirected diaries; doctor actions only operate on real workspace files. continue; } try { diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 1be468f87ab..0d8bba0ae50 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -25,7 +25,10 @@ function uniqueSortedStrings(...items: Array): st return normalizeSortedUniqueTrimmedStringList(items.flatMap((item) => item ?? [])); } +/** Converts a known node entry into the public environment summary shape. */ function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary { + // Expose both declared capabilities and command names so older node + // runtimes still advertise useful execution surfaces in one stable list. const capabilities = uniqueSortedStrings(node.caps, node.commands); return { id: `node:${node.nodeId}`, @@ -40,6 +43,7 @@ function listEnvironmentSummaries(nodes: readonly NodeListNode[]): EnvironmentSu return [GATEWAY_ENVIRONMENT, ...nodes.map(summarizeNodeEnvironment)]; } +/** Lists the local Gateway plus paired/connected node environments. */ async function listEnvironments(context: GatewayRequestContext) { const [devicePairing, nodePairing] = await Promise.all([listDevicePairing(), listNodePairing()]); const catalog = createKnownNodeCatalog({ @@ -50,6 +54,7 @@ async function listEnvironments(context: GatewayRequestContext) { return listEnvironmentSummaries(listKnownNodes(catalog)); } +/** Gateway handlers for querying local and node execution environments. */ export const environmentsHandlers: GatewayRequestHandlers = { "environments.list": async ({ params, respond, context }) => { if (!validateEnvironmentsListParams(params)) { diff --git a/src/gateway/server-methods/exec-approvals.ts b/src/gateway/server-methods/exec-approvals.ts index 3dfaf5f1592..de13a4ffa4f 100644 --- a/src/gateway/server-methods/exec-approvals.ts +++ b/src/gateway/server-methods/exec-approvals.ts @@ -29,6 +29,8 @@ function requireApprovalsBaseHash( snapshot: ExecApprovalsSnapshot, respond: RespondFn, ): boolean { + // Approval allowlists are admin-editable state. Require the caller's last + // observed hash before writing so stale UI tabs cannot overwrite changes. if (!snapshot.exists) { return true; } @@ -71,6 +73,8 @@ function requireApprovalsBaseHash( function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); + // The socket token/defaults are runtime-only; expose only the path needed by + // the editor so GET responses cannot leak connection material. return { ...file, socket: socketPath ? { path: socketPath } : undefined, @@ -153,6 +157,8 @@ export const execApprovalsHandlers: GatewayRequestHandlers = { if (!respondUnavailableOnNodeInvokeError(respond, res)) { return; } + // Node invocations can return structured payloads or JSON strings + // depending on the transport; normalize before echoing the RPC response. const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload; respond(true, payload, undefined); }); @@ -186,6 +192,8 @@ export const execApprovalsHandlers: GatewayRequestHandlers = { if (!respondUnavailableOnNodeInvokeError(respond, res)) { return; } + // node.set returns JSON on the command channel; keep the gateway response + // shape aligned with local exec.approvals.set. const payload = safeParseJson(res.payloadJSON ?? null); respond(true, payload, undefined); }); diff --git a/src/gateway/server-methods/health.ts b/src/gateway/server-methods/health.ts index 328548c0a69..407f031c6f4 100644 --- a/src/gateway/server-methods/health.ts +++ b/src/gateway/server-methods/health.ts @@ -39,6 +39,7 @@ function cachedLifecycleDiffersFromRuntime(params: { return false; } +/** Checks whether cached channel health is stale against the live runtime snapshot. */ function cachedHealthDiffersFromRuntime( cached: HealthSummary, runtime: ChannelRuntimeSnapshot, @@ -84,6 +85,7 @@ function cachedHealthDiffersFromRuntime( return false; } +/** Merges cheap live runtime facts into a cached health summary before responding. */ function mergeCachedHealthRuntimeState(params: { cached: HealthSummary; eventLoop?: HealthSummary["eventLoop"]; @@ -114,6 +116,7 @@ function mergeCachedHealthRuntimeState(params: { }; } +/** Gateway handlers for health snapshots and status summaries. */ export const healthHandlers: GatewayRequestHandlers = { health: async ({ respond, context, params, client }) => { const { getHealthCache, refreshHealthSnapshot, logHealth } = context; @@ -148,6 +151,8 @@ export const healthHandlers: GatewayRequestHandlers = { undefined, { cached: true }, ); + // Serve the fresh-enough cache immediately but still refresh in the + // background so the next caller sees updated expensive probe data. void refreshHealthSnapshot({ probe: false, includeSensitive }).catch((err) => logHealth.error(`background health refresh failed: ${formatError(err)}`), ); diff --git a/src/gateway/server-methods/logs.ts b/src/gateway/server-methods/logs.ts index 3eed2380653..bb3cc8a84b0 100644 --- a/src/gateway/server-methods/logs.ts +++ b/src/gateway/server-methods/logs.ts @@ -7,6 +7,7 @@ import { import { readConfiguredLogTail } from "../../logging/log-tail.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** Gateway handler for bounded reads from the configured gateway log. */ export const logsHandlers: GatewayRequestHandlers = { "logs.tail": async ({ params, respond }) => { if (!validateLogsTailParams(params)) { @@ -23,6 +24,8 @@ export const logsHandlers: GatewayRequestHandlers = { const p = params as { cursor?: number; limit?: number; maxBytes?: number }; try { + // The log-tail reader enforces cursor/byte limits and source selection; + // the handler only maps protocol params and failure shape. const result = await readConfiguredLogTail({ cursor: p.cursor, limit: p.limit, diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index b6ef68a3035..cc958394272 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -124,6 +124,9 @@ function createAuthLogoutAbortOps(context: GatewayRequestContext): ChatAbortOps }; } +// Auth profiles can be adopted by a provider-specific owner agent dir. Logout +// must remove every owning store or stale profiles reappear on the next status +// read and provider-auth warmup. async function removeProviderAuthProfilesAcrossOwnerStores(params: { provider: string; agentDir: string; @@ -150,6 +153,8 @@ async function removeProviderAuthProfilesAcrossOwnerStores(params: { return true; } +// UI expiry fields are emitted only when both timestamp and remaining duration +// are valid, keeping profile/provider expiry shapes all-or-nothing. function buildExpiry( remainingMs: number | undefined, expiresAt: number | undefined, @@ -420,6 +425,8 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { try { const cfg = context.getRuntimeConfig(); const agentDir = resolveDefaultAgentDir(cfg); + // Use the external-profile-aware store for status reads so the dashboard + // reflects CLI-discovered credentials without persisting them here. const store = ensureAuthProfileStore(agentDir, { externalCli: externalCliDiscoveryForConfigStatus({ cfg }), }); diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 9be914f9cf7..a7027eb0d9e 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -19,10 +19,14 @@ type ModelsListView = ModelCatalogBrowseView; let loggedSlowModelsListCatalog = false; +// Unknown views are rejected by protocol validation first; this helper keeps the +// handler default explicit for older clients that omit the field. function resolveModelsListView(params: Record): ModelsListView { return typeof params.view === "string" ? (params.view as ModelsListView) : "default"; } +// Runtime-only model params are useful inside provider routing, but exposing +// them here would leak provider invocation details into the Control UI API. function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry { const { params: _params, ...rest } = entry as ModelCatalogEntry & { params?: Record; @@ -34,6 +38,9 @@ function omitRuntimeModelParamsFromCatalog(catalog: ModelCatalogEntry[]): ModelC return catalog.map(omitRuntimeModelParams); } +// The gateway model list is a browse API, not an auth probe. It reuses the +// current runtime catalog snapshot and applies visibility rules without doing +// extra runtime discovery on each request. export const modelsHandlers: GatewayRequestHandlers = { "models.list": async ({ params, respond, context }) => { if (!validateModelsListParams(params)) { diff --git a/src/gateway/server-methods/native-hook-relay.ts b/src/gateway/server-methods/native-hook-relay.ts index 1a7a9ec0fb7..2abbf6a048f 100644 --- a/src/gateway/server-methods/native-hook-relay.ts +++ b/src/gateway/server-methods/native-hook-relay.ts @@ -8,6 +8,9 @@ import type { GatewayRequestHandlers } from "./types.js"; export const nativeHookRelayHandlers: GatewayRequestHandlers = { "nativeHook.invoke": async ({ params, respond }) => { try { + // Relay invocations are one-shot bridges into a live native harness. + // Require the current generation so stale clients cannot post into a + // newly registered relay with the same id. const result: NativeHookRelayProcessResponse = await invokeNativeHookRelay({ provider: params.provider, relayId: params.relayId, diff --git a/src/gateway/server-methods/nodes-pending.ts b/src/gateway/server-methods/nodes-pending.ts index 91e5fbe2fce..08a5b05ba38 100644 --- a/src/gateway/server-methods/nodes-pending.ts +++ b/src/gateway/server-methods/nodes-pending.ts @@ -28,6 +28,7 @@ function resolveClientNodeId( return trimmed.length > 0 ? trimmed : null; } +/** Gateway handlers for queueing work until a paired node reconnects. */ export const nodePendingHandlers: GatewayRequestHandlers = { "node.pending.drain": async ({ params, respond, client }) => { if (!validateNodePendingDrainParams(params)) { @@ -99,6 +100,8 @@ export const nodePendingHandlers: GatewayRequestHandlers = { ); wakeTriggered = wake.available; if (wake.available) { + // Give the first wake a short reconnect window before forcing a + // second wake; this keeps normal APNs delivery cheap and quiet. const reconnected = await waitForNodeReconnect({ nodeId: p.nodeId, context, @@ -110,6 +113,8 @@ export const nodePendingHandlers: GatewayRequestHandlers = { ); } if (!context.nodeRegistry.get(p.nodeId) && wake.available) { + // A forced retry is only useful after the first wake was deliverable + // but the node still has not reattached to the Gateway. const retryWake = await maybeWakeNodeWithApns(p.nodeId, { force: true, wakeReason: "node.pending", diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 46065103c84..962c68a203d 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -252,6 +252,8 @@ function shouldQueueAsPendingForegroundAction(params: { command: string; error: unknown; }): boolean { + // iOS cannot run camera/screen/Talk commands in the background. Queue only + // those foreground-only commands when the node explicitly reports that state. const platform = normalizeLowercaseStringOrEmpty(params.platform); if (!platform.startsWith("ios") && !platform.startsWith("ipados")) { return false; @@ -290,6 +292,8 @@ function enqueuePendingNodeAction(params: { const queue = prunePendingNodeActions(params.nodeId, nowMs); const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey); if (existing) { + // Keep retries idempotent so callers do not create duplicate foreground + // actions while the node is still backgrounded. return existing; } const entry: PendingNodeAction = { @@ -349,6 +353,8 @@ function resolveAllowedPendingNodeActions(params: { if (pending.length === 0) { return pending; } + // Re-filter queued actions against the node's current declared commands and + // allowlist; app upgrades or permission changes can make old actions unsafe. const connect = params.client?.connect; const declaredCommands = Array.isArray(connect?.commands) ? connect.commands : []; const allowlist = resolveNodeCommandAllowlist(params.cfg, { @@ -1234,6 +1240,8 @@ export const nodeHandlers: GatewayRequestHandlers = { idempotencyKey: p.idempotencyKey, }); if (policyResult) { + // Plugin policies can satisfy an invocation without crossing the raw + // node command channel; still emit mirrored Talk events for UI state. if (!policyResult.ok) { const errorCode = policyResult.unavailable ? ErrorCodes.UNAVAILABLE @@ -1287,6 +1295,8 @@ export const nodeHandlers: GatewayRequestHandlers = { error: res.error, }) ) { + // Foreground-only iOS commands become pullable pending actions instead + // of failing permanently while the device is locked/backgrounded. const paramsJSON = toPendingParamsJSON(forwardedParams.params); const queued = enqueuePendingNodeAction({ nodeId, diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts index 21ec85ba63b..e7516b1dd1d 100644 --- a/src/gateway/server-methods/plugin-host-hooks.ts +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -26,6 +26,7 @@ function formatSessionActionPayloadSchemaErrors(errors: JsonSchemaValidationErro return errors.map((error) => error.text).join("; "); } +/** Ensures plugin action result extension fields stay JSON-compatible on the wire. */ function validatePluginSessionActionJsonFields( result: Record, ): string | undefined { @@ -37,6 +38,7 @@ function validatePluginSessionActionJsonFields( return undefined; } +/** Gateway handlers for plugin-declared Control UI descriptors and session actions. */ export const pluginHostHookHandlers: GatewayRequestHandlers = { "plugins.uiDescriptors": ({ params, respond }) => { if (!validatePluginsUiDescriptorsParams(params)) { @@ -108,6 +110,8 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = { registration.action.requiredScopes && registration.action.requiredScopes.length > 0 ? registration.action.requiredScopes : [WRITE_SCOPE]; + // Plugin actions default to write access, while read-only actions can opt + // down. Admin bypasses all checks and write includes read for UI callers. const missingScope = requiredScopes.find( (scope) => !hasAdmin && @@ -149,6 +153,8 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = { ); return; } + // Schemas are plugin-provided data; validate their shape before passing + // them into the shared schema evaluator so malformed plugins fail cleanly. const validation = validateJsonSchemaValue({ schema: registration.action.schema as JsonSchemaValue, cacheKey: `plugin-session-action:${pluginId}:${actionId}`, diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts index 74be95a402e..004c0e88eaa 100644 --- a/src/gateway/server-methods/push.ts +++ b/src/gateway/server-methods/push.ts @@ -65,6 +65,8 @@ export const pushHandlers: GatewayRequestHandlers = { const result = registration.transport === "direct" ? await (async () => { + // Direct registrations require local APNs signing material at + // send time; relay registrations must not touch those secrets. const auth = await resolveApnsAuthConfigFromEnv(process.env); if (!auth.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error)); @@ -82,6 +84,8 @@ export const pushHandlers: GatewayRequestHandlers = { }); })() : await (async () => { + // Relay registrations carry a grant from the node, so the gateway + // only needs relay config plus the origin bound at registration. const relay = resolveApnsRelayConfigFromEnv( process.env, context.getRuntimeConfig().gateway, @@ -109,6 +113,8 @@ export const pushHandlers: GatewayRequestHandlers = { overrideEnvironment, }) ) { + // Clear only the exact registration we tested; a reconnect may have + // written a newer token while the push request was in flight. await clearApnsRegistrationIfCurrent({ nodeId, registration, diff --git a/src/gateway/server-methods/restart-request.ts b/src/gateway/server-methods/restart-request.ts index 713723bb85e..c4ccd43d318 100644 --- a/src/gateway/server-methods/restart-request.ts +++ b/src/gateway/server-methods/restart-request.ts @@ -34,6 +34,9 @@ function parseRestartDeliveryContext(params: unknown): { return { deliveryContext: normalizedContext, threadId }; } +// Restart sentinels can resume a channel turn after the gateway comes back. +// Keep only routable delivery fields plus a normalized thread id so malformed +// UI/tool payloads do not leak arbitrary data into the sentinel file. export function parseRestartRequestParams(params: unknown): { sessionKey: string | undefined; deliveryContext: RestartDeliveryContext | undefined; diff --git a/src/gateway/server-methods/restart.ts b/src/gateway/server-methods/restart.ts index 5267ef3c125..5e13bbb1ee4 100644 --- a/src/gateway/server-methods/restart.ts +++ b/src/gateway/server-methods/restart.ts @@ -5,10 +5,14 @@ import { import type { GatewayRequestHandlers } from "./types.js"; function normalizeReason(value: unknown): string | undefined { + // Restart reasons are operator-visible log context, not payload storage. + // Trim and cap them before passing through to the coordinator. return typeof value === "string" && value.trim() ? value.trim().slice(0, 200) : undefined; } function normalizeSkipDeferral(value: unknown): boolean { + // Only an explicit boolean may bypass deferral; truthy strings from loose + // clients must not skip the safe-restart preflight queue. return value === true; } diff --git a/src/gateway/server-methods/secrets.ts b/src/gateway/server-methods/secrets.ts index 7d9c9874350..0e4bdd9272d 100644 --- a/src/gateway/server-methods/secrets.ts +++ b/src/gateway/server-methods/secrets.ts @@ -21,6 +21,8 @@ function invalidSecretsResolveField( | "optionalActivePaths" | "providerOverrides" | "targetIds" { + // Return the offending top-level field only. Detailed validator output can + // include paths and schema internals that are not useful for callers here. for (const issue of errors ?? []) { const instancePath = issue.instancePath ?? ""; if ( @@ -105,6 +107,8 @@ export function createSecretsHandlers(params: { const targetIds = requestParams.targetIds .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); + // Normalize allow/force/optional path lists before resolving so secrets + // code receives policy paths, not UI whitespace artifacts. const allowedPaths = requestParams.allowedPaths ?.map((entry) => entry.trim()) .filter((entry) => entry.length > 0); @@ -123,6 +127,8 @@ export function createSecretsHandlers(params: { : {}), }; + // Target ids are a closed registry. Reject unknown ids before resolving + // so callers cannot probe arbitrary config paths through this method. for (const targetId of targetIds) { if (!isKnownSecretTargetId(targetId)) { respond( @@ -153,6 +159,8 @@ export function createSecretsHandlers(params: { inactiveRefPaths: result.inactiveRefPaths, }; if (!validateSecretsResolveResult(payload)) { + // Validate the returned shape as a final boundary check before any + // secret assignment payload leaves the gateway. throw new Error("secrets.resolve returned invalid payload."); } respond(true, payload); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index ce9523bb820..b0c9812b52b 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -82,6 +82,7 @@ function resolveGatewayInflightMap(params: { context: GatewayRequestContext; ded kind: "ready"; inflightMap: Map>; } { + // Persistent dedupe wins before process-local in-flight joins for idempotent retries. const cached = params.context.dedupe.get(params.dedupeKey); if (cached) { return { kind: "cached", cached }; @@ -234,6 +235,7 @@ function resolveMessageActionRuntimeConfig(params: { runtimeConfig, runtimeSourceConfig, }); + // Message actions must use the hot runtime snapshot when it matches the caller's source config. if (selected === runtimeConfig && selected !== params.cfg) { return resolveGatewayPluginConfig({ config: selected }); } @@ -349,6 +351,7 @@ const sourceReplyTranscriptMirrorQueues = new Map>(); function resolveSourceReplyTranscriptMirrorQueueKey( mirror: Parameters[0], ): string { + // Missing session keys are serialized together so global mirrors preserve delivery order. return mirror.sessionKey?.trim() || "__global__"; } @@ -642,6 +645,7 @@ export const sendHandlers: GatewayRequestHandlers = { normalizeOptionalLowercaseString(derivedRoute?.baseSessionKey) === normalizeOptionalLowercaseString(providedSessionBaseKey) && normalizeOptionalLowercaseString(derivedRoute?.sessionKey) !== providedSessionKey; + // Slack replies can refine an existing base session into a thread session after target lookup. const outboundRoute = derivedRoute ? providedSessionKey ? shouldUseDerivedThreadSessionKey @@ -765,6 +769,7 @@ export const sendHandlers: GatewayRequestHandlers = { typeof request.durationSeconds === "number" && outbound?.supportsPollDurationSeconds !== true ) { + // Duration support is channel-specific; reject before normalizing to avoid silent truncation. return { ok: false, error: errorShape( diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 370a1bc9e60..16c0ec752a4 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -150,6 +150,7 @@ function filterSessionStoreToConfiguredAgents( if (isConfiguredSessionKey(key)) { return true; } + // Keep spawned child sessions visible when their parent belongs to a configured agent. return ( isConfiguredSessionKey(entry?.spawnedBy) || isConfiguredSessionKey(entry?.parentSessionKey) ); @@ -799,6 +800,7 @@ function resolveSessionMessageSubscriptionKey(params: { : params.canonicalKey === "global" && params.defaultAgentId ? normalizeAgentId(params.defaultAgentId) : undefined; + // Global session message subscriptions need per-agent channels to avoid cross-agent fanout. return params.canonicalKey === "global" && agentId ? `agent:${agentId}:global` : params.canonicalKey; @@ -923,6 +925,7 @@ async function interruptSessionRunIfActive(params: { abortEmbeddedAgentRun(params.sessionId); } + // Clear queued follow-up work for both requested aliases and the canonical session id. clearSessionQueues([params.requestedKey, params.canonicalKey, params.sessionId]); if (hasEmbeddedRun && params.sessionId) { @@ -988,6 +991,7 @@ async function handleSessionSend(params: { return; } if (!entry?.sessionId && !params.interruptIfActive && isAgentMainSessionKey(cfg, canonicalKey)) { + // Sending to an empty agent main session should create it; steering still requires an active row. const created = await createAgentMainSessionForSend({ req: params.req, canonicalKey, diff --git a/src/gateway/server-methods/skills-upload.ts b/src/gateway/server-methods/skills-upload.ts index 4260a223f35..9fcdf12e7ee 100644 --- a/src/gateway/server-methods/skills-upload.ts +++ b/src/gateway/server-methods/skills-upload.ts @@ -33,6 +33,7 @@ function mapUploadError(err: unknown): ErrorShape { return errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(err)); } +/** Gateway handlers for the staged uploaded-skill archive flow. */ export const skillsUploadHandlers: GatewayRequestHandlers = { "skills.upload.begin": makeUploadHandler( "skills.upload.begin", @@ -51,6 +52,7 @@ export const skillsUploadHandlers: GatewayRequestHandlers = { ), }; +/** Wraps each upload stage with feature gating, protocol validation, and error mapping. */ function makeUploadHandler( name: string, validator: ProtocolValidator

, diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index ec4f40504f1..dee1140cad2 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -67,6 +67,8 @@ function resolveSkillsAgentWorkspace(params: unknown, context: GatewayRequestCon : undefined; const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg); if (agentIdRaw) { + // Explicit agent routing must name a configured agent; otherwise a typo + // could create or inspect skills under an unintended workspace. const knownAgents = listAgentIds(cfg); if (!knownAgents.includes(agentId)) { return { @@ -89,6 +91,8 @@ type ResolvedSkillsWorkspace = Extract< >; function buildRemoteAwareWorkspaceSkillStatus(resolved: ResolvedSkillsWorkspace) { + // Remote skill availability depends on the agent's executable-node surface, + // not only the workspace contents, so status reports include live eligibility. return buildWorkspaceSkillStatus(resolved.workspaceDir, { config: resolved.cfg, eligibility: { @@ -493,6 +497,8 @@ export const skillsHandlers: GatewayRequestHandlers = { } const cfg = context.getRuntimeConfig(); const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + // Skill installs are intentionally routed by source; each source owns its + // validation, provenance checks, and result payload shape. if (params && typeof params === "object" && "source" in params && params.source === "clawhub") { const p = params as { source: "clawhub"; diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts index 3c87ba4f482..124db7a0623 100644 --- a/src/gateway/server-methods/system.ts +++ b/src/gateway/server-methods/system.ts @@ -16,6 +16,7 @@ import { listSystemPresence, updateSystemPresence } from "../../infra/system-pre import { broadcastPresenceSnapshot } from "../server/presence-events.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** Gateway handlers for identity, heartbeat toggles, and system presence events. */ export const systemHandlers: GatewayRequestHandlers = { "gateway.identity.get": ({ respond }) => { const identity = loadOrCreateDeviceIdentity(); @@ -52,6 +53,8 @@ export const systemHandlers: GatewayRequestHandlers = { respond(true, presence, undefined); }, "system-event": ({ params, respond, context }) => { + // System events come from mixed RPC clients; normalize fields before + // presence state decides whether this event should fan out or be elided. const text = normalizeOptionalString(params.text) ?? ""; if (!text) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required")); @@ -103,6 +106,8 @@ export const systemHandlers: GatewayRequestHandlers = { }); const isNodePresenceLine = text.startsWith("Node:"); if (isNodePresenceLine) { + // Node presence heartbeats are noisy; only enqueue user-visible system + // events when routing context or meaningful node metadata changes. const next = presenceUpdate.next; const changed = new Set(presenceUpdate.changedKeys); const reasonValue = next.reason ?? reason; @@ -118,6 +123,8 @@ export const systemHandlers: GatewayRequestHandlers = { if (hasChanges) { const contextChanged = isSystemEventContextChanged(sessionKey, presenceUpdate.key); const parts: string[] = []; + // Re-state node identity only when the line would otherwise lose + // routing context or the host/IP changed. if (contextChanged || hostChanged || ipChanged) { const hostLabel = normalizeOptionalString(next.host) ?? "Unknown"; const ipLabel = normalizeOptionalString(next.ip); @@ -143,6 +150,8 @@ export const systemHandlers: GatewayRequestHandlers = { } else { enqueueSystemEvent(text, { sessionKey }); } + // Presence changes are observable even when noisy node heartbeat text is + // suppressed from the transcript-style system event queue. broadcastPresenceSnapshot({ broadcast: context.broadcast, incrementPresenceVersion: context.incrementPresenceVersion, diff --git a/src/gateway/server-methods/talk-shared.ts b/src/gateway/server-methods/talk-shared.ts index 2209615509b..d64e464dbd0 100644 --- a/src/gateway/server-methods/talk-shared.ts +++ b/src/gateway/server-methods/talk-shared.ts @@ -179,6 +179,8 @@ export function buildTalkRealtimeConfig(config: OpenClawConfig, requestedProvide const configuredProvider = explicitProvider ?? singleConfiguredProvider ?? voiceCallRealtime.provider; const selectedProvider = configuredProvider ?? singleConfiguredProvider; + // Talk-local realtime config wins over the legacy voice-call plugin config, + // while the legacy config remains a bridge for existing installations. const providerConfigs = { ...voiceCallRealtime.providers, ...talkRealtimeProviderConfigs, @@ -240,6 +242,8 @@ export function resolveConfiguredRealtimeTranscriptionProvider(params: { }) { const providers = listRealtimeTranscriptionProviders(params.config); const normalizedConfigured = normalizeOptionalLowercaseString(params.configuredProviderId); + // An explicit provider is authoritative; automatic selection is stable by + // provider order so the same config picks the same transcription backend. const orderedProviders = normalizedConfigured ? providers.filter( (provider) => @@ -288,6 +292,8 @@ export function buildRealtimeInstructions(configuredInstructions?: string): stri if (!extra) { return DEFAULT_REALTIME_INSTRUCTIONS; } + // Keep the tool-use contract first, then append operator customization so + // provider sessions preserve the same control-tool behavior. return `${DEFAULT_REALTIME_INSTRUCTIONS}\n\nAdditional realtime instructions:\n${extra}`; } @@ -314,6 +320,8 @@ export function buildRealtimeVoiceLaunchOptions(params: { defaults: RealtimeVoiceLaunchOptions; }): RealtimeVoiceLaunchOptions { const options = pickRealtimeVoiceLaunchOptions(params.defaults); + // Per-request browser controls override config defaults, but only when they + // are valid primitive values the realtime provider can consume. return { ...options, ...pickRealtimeVoiceLaunchOptions(params.requested), @@ -380,5 +388,7 @@ function pickRealtimeVoiceLaunchOptions( export function isUnsupportedBrowserWebRtcSession(session: RealtimeVoiceBrowserSession): boolean { const provider = normalizeLowercaseStringOrEmpty(session.provider); const transport = (session as { transport?: string }).transport ?? "webrtc"; + // Google browser WebRTC sessions are exposed in provider types but not usable + // through the current client-owned Talk flow. return provider === "google" && transport === "webrtc"; } diff --git a/src/gateway/server-methods/tasks.ts b/src/gateway/server-methods/tasks.ts index 943dc837b6d..084bce43451 100644 --- a/src/gateway/server-methods/tasks.ts +++ b/src/gateway/server-methods/tasks.ts @@ -25,6 +25,8 @@ const MAX_TASKS_LIST_LIMIT = 500; type TaskLedgerStatus = TaskSummary["status"]; +// Gateway task APIs preserve the older ledger status vocabulary while the +// runtime registry tracks finer-grained task states such as `lost`. const TASK_STATUS_TO_LEDGER_STATUS: Record = { queued: "queued", running: "running", @@ -48,6 +50,8 @@ function taskUpdatedAt(task: TaskRecord): number { return task.lastEventAt ?? task.endedAt ?? task.startedAt ?? task.createdAt; } +// Status text can originate from providers, shells, and subprocesses. Keep the +// public task shape bounded before it reaches control-plane clients. function sanitizeOptionalTaskText( value: unknown, opts?: { errorContext?: boolean }, @@ -96,6 +100,8 @@ function normalizeTaskStatusFilter(status: TasksListParams["status"]): Set LEDGER_STATUS_TO_TASK_STATUSES[value] ?? [])); } +// Session filtering needs all ownership keys because detached child runs may be +// queried from the requester, child session, or owner/control-plane view. function taskMatchesSession(task: TaskRecord, sessionKey: string | undefined): boolean { const normalized = normalizeOptionalString(sessionKey); if (!normalized) { @@ -106,6 +112,8 @@ function taskMatchesSession(task: TaskRecord, sessionKey: string | undefined): b ); } +// Some records predate a direct `agentId`, so task listings still recover the +// owning agent from session-style keys instead of hiding those tasks. function taskMatchesAgent(task: TaskRecord, agentId: string | undefined): boolean { const normalized = normalizeOptionalString(agentId); if (!normalized) { @@ -119,6 +127,8 @@ function taskMatchesAgent(task: TaskRecord, agentId: string | undefined): boolea ); } +// Cursor strings are offsets, not opaque tokens; reject malformed values so a +// client cannot silently restart pagination at the first page. function parseCursor(cursor: string | undefined): number | null { if (!cursor) { return 0; @@ -130,6 +140,8 @@ function parseCursor(cursor: string | undefined): number | null { return Number.isSafeInteger(parsed) ? parsed : null; } +// Control UI task methods expose the stable gateway protocol shape; helpers +// above keep runtime registry details out of the wire result. export const tasksHandlers: GatewayRequestHandlers = { "tasks.list": ({ params, respond }) => { if (!validateTasksListParams(params)) { diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index c2ca3233f74..973565400ee 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -68,6 +68,8 @@ function resolveAgentIdOrRespondError( } function buildCoreGroups(): ToolCatalogGroup[] { + // Core catalog rows come from static tool sections so profile chips remain + // stable even before any runtime agent session exists. return listCoreToolSections().map((section) => ({ id: section.id, label: section.label, @@ -100,6 +102,8 @@ function buildPluginGroups(params: { toolAllowlist: ["group:plugins"], allowGatewaySubagentBinding: true, }); + // Resolve tools through the same plugin registry path used at runtime so the + // catalog respects conflicts, optional tools, and subagent binding rules. const pluginTools = resolvePluginTools({ context: toolContext, existingToolNames: params.existingToolNames, @@ -165,6 +169,8 @@ function buildPluginGroups(params: { continue; } const groupId = `plugin:${entry.pluginId}`; + // Declared-but-unresolved plugin tools still appear so operators can see + // optional capabilities that may need config before they bind at runtime. const existing = groups.get(groupId) ?? ({ diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index f1bc57a3bcd..06755e98168 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -165,6 +165,9 @@ function cacheToolsEffectiveResult(key: string, value: EffectiveToolInventoryRes trimToolsEffectiveCache(); } +// Base inventory resolution is pure CPU work, but it can still fan through +// config/model policy. Coalesce identical refreshes so UI polling does not +// recompute the same session inventory in parallel. function scheduleBaseToolsEffectiveRefresh( key: string, context: TrustedToolsEffectiveContext, @@ -219,6 +222,8 @@ async function resolveCachedBaseToolsEffective(params: { return cached.value; } if (ageMs < TOOLS_EFFECTIVE_STALE_TTL_MS) { + // Stale-while-revalidate keeps the tools panel responsive while a new + // registry/config snapshot is rebuilt in the background. refreshBaseToolsEffectiveInBackground(key, params.context); return cached.value; } @@ -251,6 +256,8 @@ function appendMcpInventoryGroups(params: { base: EffectiveToolInventoryResult; mcpInventory: ReturnType; }): EffectiveToolInventoryResult { + // MCP notices apply even when no tools are projectable; only source=mcp + // entries become new groups beside the base runtime inventory. const mcpEntries = params.mcpInventory.entries.filter((entry) => entry.source === "mcp"); const notices = [...(params.base.notices ?? []), ...params.mcpInventory.notices]; const base = notices.length > 0 ? { ...params.base, notices } : params.base; @@ -447,6 +454,8 @@ function resolveTrustedToolsEffectiveContext(params: { requestedAgentId?: string; respond: RespondFn; }) { + // The effective tools request is read-only but security-sensitive. Derive + // routing/account/model context from the persisted session, not client params. const loaded = loadSessionEntry(params.sessionKey); if (!loaded.entry) { params.respond( diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index 63a5571e017..c52ee2bec7a 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -37,6 +37,8 @@ export const ttsHandlers: GatewayRequestHandlers = { const fallbackProviders = resolveTtsProviderOrder(provider, cfg) .slice(1) .filter((candidate) => isTtsProviderConfigured(config, candidate, cfg)); + // Report configured state per provider so the UI can explain why fallback + // order differs from the complete provider registry. const providerStates = listSpeechProviders(cfg).map((candidate) => ({ id: candidate.id, label: candidate.label, @@ -106,6 +108,8 @@ export const ttsHandlers: GatewayRequestHandlers = { const voiceId = normalizeOptionalString(params.voiceId); let overrides; try { + // Explicit provider/model/voice requests are validated before synthesis + // and disable fallback so preview calls fail against the requested target. overrides = resolveExplicitTtsOverrides({ cfg, provider: providerRaw, @@ -213,6 +217,8 @@ export const ttsHandlers: GatewayRequestHandlers = { ); return; } + // Persist only the canonical configured id; labels/aliases stay in config + // so preference files remain stable across copy changes. setTtsPersona(prefsPath, persona.id); respond(true, { persona: persona.id }); } catch (err) { diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index cf0d134d1ad..6994baf2feb 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -56,6 +56,8 @@ function resolveManagedServiceHandoffRestartDelayMs( if (supervisor !== "systemd") { return restartDelayMs; } + // systemd needs a short grace period after the handoff process starts before + // the gateway exits, otherwise the service can restart before handoff state is durable. return Math.max( restartDelayMs ?? SYSTEMD_HANDOFF_RESTART_GRACE_MS, SYSTEMD_HANDOFF_RESTART_GRACE_MS, @@ -126,6 +128,9 @@ export const updateHandlers: GatewayRequestHandlers = { }); supervisor = detectRespawnSupervisor(process.env, process.platform); if (!isRestartEnabled(config) && !supervisor) { + // Package updates need a restart path to finish safely. Dev/git installs + // can report the disabled restart directly, but global installs must not + // mutate files if this process cannot come back. const beforeVersion = installSurface.root ? await readPackageVersion(installSurface.root) : null; @@ -145,6 +150,8 @@ export const updateHandlers: GatewayRequestHandlers = { const startedAt = Date.now(); const handoffId = randomUUID(); sentinelMeta.handoffId = handoffId; + // Managed services update from a detached helper so the running + // gateway does not replace its own package while still serving RPCs. const started = await startManagedServiceUpdateHandoff({ root, timeoutMs, @@ -255,6 +262,8 @@ export const updateHandlers: GatewayRequestHandlers = { ? 0 : restartDelayMs, reason: "update.run", + // Package swaps and managed handoffs should restart without waiting + // for normal deferral/cooldown windows; the new code is already staged. skipDeferral: updateWasPackageSwap || handoff?.status === "started", skipCooldown: updateWasPackageSwap || handoff?.status === "started", audit: { diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 2b6071b5bac..0c90bc6fe43 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -106,6 +106,7 @@ function addCostUsageTotals(target: CostUsageTotals, source: CostUsageTotals): v function findCostUsageCacheEvictionKey(): string | undefined { for (const [key, entry] of costUsageCache) { + // Prefer evicting settled entries so duplicate callers can still join active loads. if (!entry.inFlight) { return key; } @@ -374,6 +375,7 @@ function buildStoreBySessionId( const storeBySessionId = new Map(); for (const [sessionId, matches] of matchesBySessionId) { + // Multiple store keys can point at one transcript; choose the UI-facing canonical key. const preferredKey = resolvePreferredSessionKeyForSessionIdMatches(matches, sessionId); if (!preferredKey) { continue; @@ -468,6 +470,7 @@ function maybeMergeFamilyEntry(params: { params.base.storeEntry, params.base.sessionId, ); + // Family rows keep historical transcript ids so usage survives session resets. const sessionFamilyKey = resolveUsageFamilyKey({ key: params.base.key, entry: params.base.storeEntry, @@ -799,6 +802,7 @@ async function loadCostUsageSummaryCached(params: { }) .catch((err) => { if (entry.summary) { + // Serve the stale summary if background refresh fails; callers asked for usage, not repair. return entry.summary; } throw err; @@ -1118,6 +1122,7 @@ export const usageHandlers: GatewayRequestHandlers = { }); } else { if (groupingMode === "family" && storeFamilySessionIds.has(discovered.sessionId)) { + // The current store row will load this historical transcript through included ids. continue; } // Unnamed session - use session ID as key, no label diff --git a/src/gateway/server-methods/validation.ts b/src/gateway/server-methods/validation.ts index 3627098bff4..d7feb06c08a 100644 --- a/src/gateway/server-methods/validation.ts +++ b/src/gateway/server-methods/validation.ts @@ -6,10 +6,12 @@ import { import type { ValidationError } from "../../../packages/gateway-protocol/src/index.js"; import type { RespondFn } from "./types.js"; +/** Type guard function shape produced by gateway-protocol validators. */ export type Validator = ((params: unknown) => params is T) & { errors?: ValidationError[] | null; }; +/** Validate params and emit the standard INVALID_REQUEST response on failure. */ export function assertValidParams( params: unknown, validate: Validator, diff --git a/src/gateway/server-methods/voicewake-routing.ts b/src/gateway/server-methods/voicewake-routing.ts index 34dccc283e5..e7f8eef9a02 100644 --- a/src/gateway/server-methods/voicewake-routing.ts +++ b/src/gateway/server-methods/voicewake-routing.ts @@ -35,6 +35,8 @@ export const voicewakeRoutingHandlers: GatewayRequestHandlers = { return; } try { + // Validate first for caller-friendly errors, then normalize before + // persistence so broadcasts carry the canonical routing shape. const normalized = normalizeVoiceWakeRoutingConfig(params.config); const config = await setVoiceWakeRoutingConfig(normalized); context.broadcastVoiceWakeRoutingChanged(config); diff --git a/src/gateway/server-methods/voicewake.ts b/src/gateway/server-methods/voicewake.ts index ff92fc5f7b1..13f45fb2b73 100644 --- a/src/gateway/server-methods/voicewake.ts +++ b/src/gateway/server-methods/voicewake.ts @@ -24,6 +24,8 @@ export const voicewakeHandlers: GatewayRequestHandlers = { } try { const triggers = normalizeVoiceWakeTriggers(params.triggers); + // Persist the normalized trigger list before broadcasting so connected + // nodes and future gateway starts observe the same wake phrases. const cfg = await setVoiceWakeTriggers(triggers); context.broadcastVoiceWakeChanged(cfg.triggers); respond(true, { triggers: cfg.triggers }); diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index 17edcb59ce1..36b378bbc1a 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -12,6 +12,7 @@ import { assertValidParams } from "./validation.js"; const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]); +/** Resolves the channel plugin that currently owns web QR-login methods. */ const resolveWebLoginProvider = () => listChannelPlugins().find((plugin) => [ @@ -46,6 +47,7 @@ function respondProviderUnsupported(respond: RespondFn, providerId: string) { ); } +/** Resolves a concrete provider gateway login method or sends the public error. */ function resolveWebLoginRequest(params: { rawParams: unknown; respond: RespondFn; @@ -70,6 +72,7 @@ function resolveWebLoginRequest(params: { return { accountId, provider, run: run.bind(gateway) as NonNullable }; } +/** Checks whether the matching channel/account should be restored after login start. */ function wasChannelRunning(params: { context: Parameters[0]["context"]; channelId: ChannelId; @@ -89,6 +92,7 @@ function wasChannelRunning(params: { return defaultRuntime?.accountId === params.accountId && defaultRuntime.running === true; } +/** Gateway handlers for plugin-owned web QR-login flows. */ export const webHandlers: GatewayRequestHandlers = { "web.login.start": async ({ params, respond, context }) => { if (!assertValidParams(params, validateWebLoginStartParams, "web.login.start", respond)) { @@ -119,6 +123,8 @@ export const webHandlers: GatewayRequestHandlers = { if (result.connected) { await context.startChannel(provider.id, accountId); } else if (wasRunning && !result.qrDataUrl) { + // When start fails before producing a QR code, restore the previously + // running channel/account so a transient login failure does not stop it. await context.startChannel(provider.id, accountId); } respond(true, result, undefined); diff --git a/src/gateway/server-methods/wizard.ts b/src/gateway/server-methods/wizard.ts index d8017eb7117..ad825f5cd06 100644 --- a/src/gateway/server-methods/wizard.ts +++ b/src/gateway/server-methods/wizard.ts @@ -21,6 +21,7 @@ function readWizardStatus(session: WizardSession) { }; } +/** Resolves a live wizard session or sends the public not-found error. */ function findWizardSessionOrRespond(params: { context: GatewayRequestContext; respond: RespondFn; @@ -34,6 +35,7 @@ function findWizardSessionOrRespond(params: { return session; } +/** Gateway handlers for the interactive setup wizard session lifecycle. */ export const wizardHandlers: GatewayRequestHandlers = { "wizard.start": async ({ params, respond, context }) => { if (!assertValidParams(params, validateWizardStartParams, "wizard.start", respond)) { @@ -55,6 +57,8 @@ export const wizardHandlers: GatewayRequestHandlers = { context.wizardSessions.set(sessionId, session); const result = await session.next(); if (result.done) { + // Completed sessions cannot accept later answers; purge immediately so + // clients get a clean not-found response for stale session ids. context.purgeWizardSession(sessionId); } respond(true, { sessionId, ...result }, undefined); @@ -83,6 +87,8 @@ export const wizardHandlers: GatewayRequestHandlers = { } const result = await session.next(); if (result.done) { + // The final step may be reached after an answer, so cleanup mirrors + // wizard.start's immediate-completion path. context.purgeWizardSession(sessionId); } respond(true, result, undefined); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index c262b7a1789..44aedaa465c 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -41,6 +41,7 @@ type GatewayRuntimeConfig = { hooksConfig: ReturnType; }; +/** Resolves bind, auth, HTTP, Tailscale, and hook settings for one gateway start. */ export async function resolveGatewayRuntimeConfig(params: { cfg: OpenClawConfig; port: number; @@ -95,6 +96,8 @@ export async function resolveGatewayRuntimeConfig(params: { const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false; const strictTransportSecurityConfig = params.cfg.gateway?.http?.securityHeaders?.strictTransportSecurity; + // HSTS is opt-in and must stay absent for blank strings; local HTTP and reverse-proxy + // setups rely on not emitting a malformed or accidentally inherited header. const strictTransportSecurityHeader = strictTransportSecurityConfig === false ? undefined @@ -122,6 +125,8 @@ export async function resolveGatewayRuntimeConfig(params: { const hasToken = typeof resolvedAuth.token === "string" && resolvedAuth.token.trim().length > 0; const hasPassword = typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0; + // Non-loopback binds need a concrete shared secret unless auth is delegated to a + // trusted proxy; mode alone is not enough because env/config resolution may be empty. const hasSharedSecret = (authMode === "token" && hasToken) || (authMode === "password" && hasPassword); const hooksConfig = resolveHooksConfig(params.cfg); @@ -155,12 +160,16 @@ export async function resolveGatewayRuntimeConfig(params: { controlUiAllowedOrigins.length === 0 && !dangerouslyAllowHostHeaderOriginFallback ) { + // Remote Control UI must use explicit origins unless the operator deliberately accepts + // Host-header fallback; otherwise any reachable host name can become a browser origin. throw new Error( "non-loopback Control UI requires gateway.controlUi.allowedOrigins (set explicit origins), or set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true to use Host-header origin fallback mode", ); } if (authMode === "trusted-proxy") { + // Trusted-proxy auth trusts headers only after the request has matched an allowed proxy IP. + // Starting without that list would convert the mode into unauthenticated header spoofing. if (trustedProxies.length === 0) { throw new Error( "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP", diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index 044da1a34ed..d7e4c63a903 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -32,6 +32,7 @@ function createNoopHeartbeatRunner(): HeartbeatRunner { }; } +/** Starts cron without making gateway startup wait for cron initialization. */ export function startGatewayCronWithLogging(params: { cron: { start: () => Promise }; logCron: { error: (message: string) => void }; @@ -51,6 +52,7 @@ function clearGatewayMaintenanceHandles(maintenance: GatewayMaintenanceHandles | } } +/** Runs maintenance that is intentionally delayed until after the gateway is ready. */ export async function runGatewayPostReadyMaintenance(params: { startMaintenance: () => Promise; applyMaintenance: (maintenance: GatewayMaintenanceHandles) => void; @@ -79,6 +81,7 @@ export async function runGatewayPostReadyMaintenance(params: { params.recordPostReadyMemory(); } +/** Schedules post-ready maintenance and cancels/cleans handles if shutdown wins the race. */ export function scheduleGatewayPostReadyMaintenance(params: { delayMs: number; isClosing: () => boolean; @@ -104,6 +107,8 @@ export function scheduleGatewayPostReadyMaintenance(params: { } const maintenance = await params.startMaintenance(); if (params.isClosing()) { + // Maintenance can allocate intervals before shutdown is observed; clear them here + // instead of handing live timers to a closing gateway. clearGatewayMaintenanceHandles(maintenance); return null; } @@ -136,6 +141,8 @@ function recoverPendingOutboundDeliveries(params: { cfg: OpenClawConfig; log: GatewayRuntimeServiceLogger; }): void { + // Recovery is best-effort background work; startup must continue even if outbound modules fail + // to import or queued delivery replay fails. void (async () => { const { recoverPendingDeliveries } = await import("../infra/outbound/delivery-queue.js"); const { deliverOutboundPayloadsInternal } = await import("../infra/outbound/deliver.js"); @@ -153,6 +160,8 @@ function recoverPendingSessionDeliveries(params: { log: GatewayRuntimeServiceLogger; maxEnqueuedAt: number; }): void { + // Delay session continuation recovery so the gateway has time to publish ready state and + // request routing before replaying restart-sentinel deliveries. const timer = setTimeout(() => { void (async () => { const { recoverPendingRestartContinuationDeliveries } = @@ -178,6 +187,8 @@ function startGatewayModelPricingRefreshOnDemand(params: { } let stopped = false; let stopRefresh: (() => void) | undefined; + // Import pricing refresh lazily; many gateway starts never use model-pricing metadata. + // The stopped flag closes the race where shutdown happens before the import resolves. void (async () => { const { startGatewayModelPricingRefresh } = await import("./model-pricing-cache.js"); if (stopped) { @@ -199,6 +210,7 @@ function startGatewayModelPricingRefreshOnDemand(params: { }; } +/** Activates background gateway services after core runtime startup is ready. */ export function activateGatewayScheduledServices(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; @@ -211,6 +223,8 @@ export function activateGatewayScheduledServices(params: { pluginLookUpTable?: PluginMetadataRegistryView; }): { heartbeatRunner: HeartbeatRunner; stopModelPricingRefresh: () => void } { if (params.minimalTestGateway) { + // Minimal gateways keep handles callable but inert so tests can share shutdown paths with + // production starts without launching background loops. return { heartbeatRunner: createNoopHeartbeatRunner(), stopModelPricingRefresh: () => {} }; } const heartbeatRunner = startHeartbeatRunner({ cfg: params.cfgAtStart }); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 373e46060f4..bea9e1dfeef 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -66,6 +66,7 @@ type GatewayPluginUpgradeHandler = ( const loadGatewayPluginsHttpModule = async () => await import("./server/plugins-http.js"); +/** Creates the HTTP/WebSocket runtime state and pinned plugin registries for one gateway start. */ export async function createGatewayRuntimeState(params: { cfg: import("../config/config.js").OpenClawConfig; bindHost: string; @@ -144,6 +145,8 @@ export async function createGatewayRuntimeState(params: { return false; } if (!loadedHooksRequestHandler) { + // Hooks are cold for most gateway starts; create the handler only after a request + // matches the configured base path so startup avoids importing hook runtime code. const { createGatewayHooksRequestHandler } = await import("./server/hooks.js"); loadedHooksRequestHandler = createGatewayHooksRequestHandler({ deps: params.deps, @@ -170,6 +173,8 @@ export async function createGatewayRuntimeState(params: { return false; } if (!loadedPluginRequestHandler) { + // Route registries can be re-pinned after bootstrap; keep the handler lazy and route + // lookup dynamic so plugin HTTP routes follow the active registry snapshot. const { createGatewayPluginRequestHandler } = await loadGatewayPluginsHttpModule(); loadedPluginRequestHandler = createGatewayPluginRequestHandler({ registry: params.pluginRegistry, @@ -192,6 +197,8 @@ export async function createGatewayRuntimeState(params: { return false; } if (!loadedPluginUpgradeHandler) { + // WebSocket upgrades share the same dynamic route registry as HTTP requests; this keeps + // reloads from serving stale plugin upgrade handlers. const { createGatewayPluginUpgradeHandler } = await loadGatewayPluginsHttpModule(); loadedPluginUpgradeHandler = createGatewayPluginUpgradeHandler({ registry: params.pluginRegistry, @@ -206,6 +213,8 @@ export async function createGatewayRuntimeState(params: { return shouldEnforceGatewayAuthForPluginPath(resolvePluginRouteRegistry(), pathContext); }; const resolvePluginNodeCapabilityRoute = (pathContext: PluginRoutePathContext) => + // Capability routes are selected from the current pinned registry so auth decisions and + // node-capability dispatch agree when plugin routes are reloaded. findMatchingPluginNodeCapabilityRoute(resolvePluginRouteRegistry(), pathContext) ?.nodeCapability; @@ -280,6 +289,8 @@ export async function createGatewayRuntimeState(params: { await startListeningPromise; return; } + // Listening is idempotent for callers racing startup; reset the promise only on failure so + // a transient bind error can be retried after the caller handles it. startListeningPromise = (async () => { for (const [index, host] of bindHosts.entries()) { const server = httpServers[index]; @@ -359,6 +370,8 @@ export async function createGatewayRuntimeState(params: { toolEventRecipients, }; } catch (err) { + // If state creation fails after pins are installed, release them immediately so later + // in-process gateway starts do not inherit a half-created plugin runtime. releasePinnedPluginHttpRouteRegistry(); releasePinnedPluginChannelRegistry(); throw err; diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index e02ec137bfa..8eb42cfe115 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -59,6 +59,7 @@ type RuntimeSecretsActivationParams = { activate: boolean; }; +/** Gateway startup hook that prepares secrets and optionally activates the prepared snapshot. */ export type ActivateRuntimeSecrets = (( config: OpenClawConfig, params: RuntimeSecretsActivationParams, @@ -80,6 +81,7 @@ type GatewayStartupConfigMeasure = ( options?: { omitErrorMessage?: boolean }, ) => Promise; +/** Timeline attributes kept small and deterministic for startup secret preparation spans. */ function secretsPrepareTimelineAttributes( config: OpenClawConfig, activationParams: RuntimeSecretsActivationParams, @@ -91,12 +93,14 @@ function secretsPrepareTimelineAttributes( }; } +/** Config snapshot plus optional plugin metadata loaded before Gateway startup auth. */ export type GatewayStartupConfigSnapshotLoadResult = { snapshot: ConfigFileSnapshot; wroteConfig: boolean; pluginMetadataSnapshot?: PluginMetadataSnapshot; }; +/** Load and validate the config snapshot, applying runtime-only plugin auto-enable changes. */ export async function loadGatewayStartupConfigSnapshot(params: { minimalTestGateway: boolean; log: GatewayStartupLog; @@ -162,6 +166,7 @@ function withRuntimeConfig( }; } +/** Create the serialized secrets activation function used by startup and reload paths. */ export function createRuntimeSecretsActivator(params: { logSecrets: GatewayStartupLog; emitStateEvent: ( @@ -190,6 +195,8 @@ export function createRuntimeSecretsActivator(params: { }; const runWithSecretsActivationLock = async (operation: () => Promise): Promise => { + // Secret refresh mutates process-wide active snapshot state, so activation + // requests are serialized even when reload and startup probes overlap. const run = secretsActivationTail.then(operation, operation); secretsActivationTail = run.then( () => undefined, @@ -275,6 +282,8 @@ export function createRuntimeSecretsActivator(params: { ...(startupManifestRegistry ? { manifestRegistry: startupManifestRegistry } : {}), }); if (fastPath) { + // The startup fast path avoids importing the full secrets runtime + // until refresh/preflight needs dynamic provider or auth-store work. const coercePreflightSnapshot = ( value: unknown, sourceConfig: OpenClawConfig, @@ -336,6 +345,8 @@ export function createRuntimeSecretsActivator(params: { : { loadAuthStore: fastPath.refreshContext.loadAuthStore }), })); if (oneShotSkipAuthStoreRefs && activeSnapshot) { + // Preserve live auth-store handles across a one-shot + // preflight that intentionally skipped auth-store refs. refreshed.authStores = getLiveSecretsRuntimeAuthStores(); setPreparedSecretsRuntimeSnapshotRefreshContext( refreshed, @@ -396,6 +407,7 @@ export function createRuntimeSecretsActivator(params: { return activateRuntimeSecrets; } +/** Throw a formatted startup error when the loaded config snapshot is invalid. */ export function assertValidGatewayStartupConfigSnapshot( snapshot: ConfigFileSnapshot, options: { includeDoctorHint?: boolean } = {}, @@ -416,6 +428,7 @@ export function assertValidGatewayStartupConfigSnapshot( throw new Error(`Invalid config at ${snapshot.path}.\n${issues}${recoveryHint}`); } +/** Prepare the effective Gateway startup config after auth, overrides, and secrets activation. */ export async function prepareGatewayStartupConfig(params: { configSnapshot: ConfigFileSnapshot; authOverride?: GatewayAuthConfig; @@ -463,6 +476,8 @@ export async function prepareGatewayStartupConfig(params: { isDeepStrictEqual(pruneSkippedStartupSecretSurfaces(config), preflightPrepared.sourceConfig), ); const activateStartupSecrets = async (config: OpenClawConfig) => { + // Reuse the preflight snapshot only if generated startup auth did not + // change the secret-relevant source config. if (preflightPrepared && canReusePreflightPreparedSnapshot(config)) { return await params.activateRuntimeSecrets.activatePreparedSnapshot!(preflightPrepared, { reason: "startup", diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 14f03a56573..643afc07140 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -15,6 +15,7 @@ type GatewayMaintenanceParams = Parameters[0]; const loadRemoteSkillsRuntimeModule = async () => await import("../skills/runtime/remote.js"); +/** Measure an early-startup step when tracing is enabled, otherwise run it directly. */ async function measureStartup( startupTrace: GatewayStartupTrace | undefined, name: string, @@ -23,6 +24,7 @@ async function measureStartup( return startupTrace ? startupTrace.measure(name, run) : await run(); } +/** Start plugin discovery and return the Bonjour shutdown callback when discovery is active. */ export async function startGatewayPluginDiscovery(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; @@ -65,6 +67,7 @@ export async function startGatewayPluginDiscovery(params: { }); } +/** Start early Gateway side runtimes before the main server is fully ready. */ export async function startGatewayEarlyRuntime(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; @@ -119,6 +122,8 @@ export async function startGatewayEarlyRuntime(params: { ); setSkillsRemoteRegistry(params.nodeRegistry); void primeRemoteSkillsCache(); + // Task registry maintenance is authoritative in the Gateway process so + // restart-blocker counts reflect the same cron store as runtime execution. taskRegistryMaintenance.configureTaskRegistryMaintenance({ cronStorePath: resolveCronStorePath(params.cfgAtStart.cron?.store), runtimeAuthoritative: true, @@ -140,6 +145,8 @@ export async function startGatewayEarlyRuntime(params: { if (event.reason === "remote-node") { return; } + // Coalesce local skill changes before refreshing connected remote + // nodes so bulk plugin/skill updates do not stampede node refreshes. const existingTimer = params.getSkillsRefreshTimer(); if (existingTimer) { clearTimeout(existingTimer); @@ -153,6 +160,8 @@ export async function startGatewayEarlyRuntime(params: { }); const startMaintenance = async () => { + // Defer periodic maintenance until the caller has finished ready-state + // wiring, but keep the lazy import owned by this early-runtime bundle. if (params.minimalTestGateway) { return null; } diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index cb72849197b..46f3d1dd2bc 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -24,6 +24,7 @@ type StartupThinkLevel = | "adaptive" | "max"; +/** Emit startup summary lines after Gateway bind and plugin loading complete. */ export async function logGatewayStartup(params: { cfg: OpenClawConfig; bindHost: string; @@ -74,6 +75,7 @@ export async function logGatewayStartup(params: { } } +/** Normalize model thinking values that are useful in the compact startup log. */ function normalizeStartupThinkLevel(value: unknown): StartupThinkLevel | undefined { return value === "off" || value === "minimal" || @@ -87,6 +89,7 @@ function normalizeStartupThinkLevel(value: unknown): StartupThinkLevel | undefin : undefined; } +/** Resolve explicit thinking overrides from agent defaults and per-model config. */ function resolveExplicitStartupThinking(params: { cfg: OpenClawConfig; provider: string; @@ -104,6 +107,7 @@ function resolveExplicitStartupThinking(params: { ); } +/** True when a configured catalog entry disables reasoning for the startup model. */ function isConfiguredReasoningDisabled(params: { catalog: readonly ModelCatalogEntry[]; provider: string; @@ -115,6 +119,7 @@ function isConfiguredReasoningDisabled(params: { ); } +/** Format model thinking and fast-mode details for the Gateway startup banner. */ export function formatAgentModelStartupDetails(params: { cfg: OpenClawConfig; provider: string; @@ -158,6 +163,7 @@ export function formatAgentModelStartupDetails(params: { return `thinking=${thinking}, fast=${fast.enabled ? "on" : "off"}`; } +/** Format plugin count/list and optional startup duration for the ready log line. */ function formatReadyDetails( loadedPluginIds: readonly string[], startupDurationLabel: string | null, diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts index a5c9b355ad6..9b2c4fd5c4e 100644 --- a/src/gateway/server-startup-memory.ts +++ b/src/gateway/server-startup-memory.ts @@ -8,16 +8,19 @@ import { import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js"; import { normalizeAgentId } from "../routing/session-key.js"; +/** True when qmd memory config opts into startup boot sync work. */ function shouldRunQmdStartupBootSync(qmd: ResolvedQmdConfig): boolean { return qmd.update.onBoot && qmd.update.startup !== "off"; } +/** Check whether an agent overrides memory search instead of inheriting defaults. */ function hasExplicitAgentMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { return listAgentEntries(cfg).some( (entry) => normalizeAgentId(entry.id) === agentId && entry.memorySearch != null, ); } +/** Decide whether an agent's qmd memory manager should start during Gateway boot. */ function shouldEagerlyStartAgentMemory(params: { cfg: OpenClawConfig; agentId: string; @@ -35,6 +38,7 @@ function shouldEagerlyStartAgentMemory(params: { return hasExplicitAgentMemorySearchConfig(params.cfg, params.agentId); } +/** Start qmd memory boot sync for eligible agents without eagerly loading every agent. */ export async function startGatewayMemoryBackend(params: { cfg: OpenClawConfig; log: { info?: (msg: string) => void; warn: (msg: string) => void }; @@ -63,6 +67,8 @@ export async function startGatewayMemoryBackend(params: { agentCount: agentIds.length, }) ) { + // Multi-agent configs keep unconfigured non-default agents lazy so + // Gateway startup does not initialize every possible qmd store. deferredAgentIds.push(agentId); continue; } diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index de2d0793ccc..b240c3bae12 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -22,10 +22,13 @@ type GatewayStartupTrace = { detail: (name: string, metrics: ReadonlyArray) => void; }; +/** Returns the config snapshot used by channel/plugin startup maintenance. */ export function resolveGatewayStartupMaintenanceConfig(params: { cfgAtStart: OpenClawConfig; startupRuntimeConfig: OpenClawConfig; }): OpenClawConfig { + // Early config recovery may supply channel blocks after the start snapshot; startup + // maintenance needs those owner configs even when the original snapshot was sparse. return params.cfgAtStart.channels === undefined && params.startupRuntimeConfig.channels !== undefined ? { @@ -35,6 +38,7 @@ export function resolveGatewayStartupMaintenanceConfig(params: { : params.cfgAtStart; } +/** Builds plugin startup state and gateway method lists before the server binds. */ export async function prepareGatewayPluginBootstrap(params: { cfgAtStart: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -78,6 +82,8 @@ export async function prepareGatewayPluginBootstrap(params: { initSubagentRegistry(); + // Activation uses the pre-runtime source so auto-enable policy cannot be skewed by + // defaults injected while loading runtime config; runtime-only plugin config still merges in. const gatewayPluginConfig = params.minimalTestGateway ? params.cfgAtStart : mergeActivationSectionsIntoRuntimeConfig({ @@ -119,6 +125,8 @@ export async function prepareGatewayPluginBootstrap(params: { params.loadSetupRuntimePlugins === true && deferredConfiguredChannelPluginIds.length > 0; if (!params.minimalTestGateway && shouldLoadSetupRuntimePlugins) { + // Pre-bind bootstrap only loads deferred channel plugins that expose setup runtime hooks. + // Full plugin handlers are loaded later so startup does not register duplicate methods. ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = await loadGatewayStartupPluginRuntime( { cfg: gatewayPluginConfig, @@ -134,6 +142,8 @@ export async function prepareGatewayPluginBootstrap(params: { }, )); } else if (!params.minimalTestGateway && shouldLoadRuntimePlugins) { + // Normal bootstrap loads every startup plugin and records that runtime handlers are ready + // before the gateway exposes the method list. ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = await loadGatewayStartupPluginRuntime( { cfg: gatewayPluginConfig, @@ -149,6 +159,8 @@ export async function prepareGatewayPluginBootstrap(params: { }, )); } else { + // Minimal gateway tests reuse an already-active registry when present; production no-load + // paths install a fresh empty registry so stale plugin handlers cannot leak across starts. pluginRegistry = params.minimalTestGateway ? (getActivePluginRegistry() ?? emptyPluginRegistry) : emptyPluginRegistry; @@ -169,6 +181,7 @@ export async function prepareGatewayPluginBootstrap(params: { }; } +/** Loads startup plugin runtimes through the deferred bootstrap boundary. */ export async function loadGatewayStartupPluginRuntime(params: { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -183,6 +196,8 @@ export async function loadGatewayStartupPluginRuntime(params: { suppressPluginInfoLogs?: boolean; startupTrace?: GatewayStartupTrace; }) { + // Keep server-plugin-bootstrap behind one lazy boundary; startup config tests can exercise + // planning without importing plugin package runtimes. const { loadGatewayStartupPlugins } = await import("./server-plugin-bootstrap.js"); return loadGatewayStartupPlugins({ cfg: params.cfg, diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 4e2c581977c..95954df77ea 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -92,6 +92,7 @@ export type GatewayPostReadySidecarHandle = { stop: () => Awaitable; }; +/** Stop sidecars immediately when shutdown has already started before they are reported. */ export function stopPostReadySidecarsAfterCloseStarted(params: { postReadySidecars: readonly GatewayPostReadySidecarHandle[]; closeStarted: boolean; @@ -104,6 +105,7 @@ export function stopPostReadySidecarsAfterCloseStarted(params: { } } +/** Measure a post-attach startup step when tracing is active. */ async function measureStartup( startupTrace: GatewayStartupTrace | undefined, name: string, @@ -112,6 +114,7 @@ async function measureStartup( return startupTrace ? startupTrace.measure(name, run) : await run(); } +/** Measure provider-auth warming without letting event-loop stalls hide in wall time. */ async function measureProviderAuthWarm(run: () => Promise): Promise<{ elapsedMs: number; eventLoopMaxMs: number; @@ -261,6 +264,8 @@ function scheduleProviderAuthStatePrewarm(params: { } }; const scheduleAuthMapRewarm = (reason: string) => { + // Collapse repeated auth-profile failures into one rewarm turn while a + // previous rewarm is queued or running. if (isStopped()) { return; } @@ -404,6 +409,8 @@ function schedulePostReadySidecarTask(params: { handle.unref?.(); return { stop: async () => { + // Sidecars get both a synchronous stopped predicate and an AbortSignal so + // lazy imports and long-running watchers can cooperate with shutdown. stopped = true; abortController.abort(); clearImmediate(handle); @@ -722,6 +729,7 @@ function schedulePrimaryModelPrewarm( }); } +/** Start post-ready sidecars such as channels, hooks, plugin services, and cleanup tasks. */ export async function startGatewaySidecars(params: { cfg: OpenClawConfig; pluginRegistry: ReturnType; @@ -823,6 +831,8 @@ export async function startGatewaySidecars(params: { const shouldDispatchGatewayStartupInternalHook = internalHooksConfigured || (await hasGatewayStartupInternalHookListeners()); if (shouldDispatchGatewayStartupInternalHook) { + // Run startup hooks after sidecar startup has yielded once so gateway bind + // and channel startup are not delayed by hook handlers. setTimeout(() => { void loadInternalHooksModule().then(({ createInternalHookEvent, triggerInternalHook }) => { const hookEvent = createInternalHookEvent("gateway", "startup", "gateway:startup", { @@ -1075,6 +1085,8 @@ function createDeferredGatewayUpdateCheck(params: { return; } started = true; + // Update checks are intentionally post-attach so startup logging, sidecars, + // and Tailscale exposure are not serialized behind network I/O. setImmediate(() => { if (stopped) { return; @@ -1109,6 +1121,7 @@ function createDeferredGatewayUpdateCheck(params: { return { start, stop }; } +/** Start work that depends on the HTTP server being attached and visible. */ export async function startGatewayPostAttachRuntime( params: { minimalTestGateway: boolean; @@ -1264,6 +1277,8 @@ export async function startGatewayPostAttachRuntime( const waitForSidecarStartTurn = () => new Promise((resolve) => { if (params.deferSidecars === true) { + // Give startup logging and bind observers a deterministic head start + // when tests or callers request deferred sidecar startup. const timer = setTimeout(resolve, DEFERRED_SIDECAR_START_DELAY_MS); timer.unref?.(); return; diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index cd077923ed6..7b64adc57ad 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -18,6 +18,7 @@ import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js"; export { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js"; +/** Merge sparse runtime auth overrides into persisted Gateway auth config. */ export function mergeGatewayAuthConfig( base?: GatewayAuthConfig, override?: GatewayAuthConfig, @@ -47,6 +48,7 @@ export function mergeGatewayAuthConfig( return merged; } +/** Merge sparse runtime Tailscale overrides into persisted Gateway Tailscale config. */ export function mergeGatewayTailscaleConfig( base?: GatewayTailscaleConfig, override?: GatewayTailscaleConfig, @@ -85,6 +87,7 @@ function resolveGatewayAuthFromConfig(params: { }); } +/** Check every source that can satisfy token auth before startup generates one. */ function hasGatewayTokenCandidate(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -122,6 +125,7 @@ function hasGatewayPasswordOverrideCandidate(params: { ); } +/** Ensure startup has effective Gateway auth, generating only an ephemeral token if needed. */ export async function ensureGatewayStartupAuth(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -142,6 +146,8 @@ export async function ensureGatewayStartupAuth(params: { assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg); const env = params.env ?? process.env; const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode; + // Resolve only refs that can satisfy the effective mode; inactive refs stay + // as refs so startup does not require unrelated secret providers. const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([ resolveGatewayTokenSecretRefValue({ cfg: params.cfg, @@ -221,6 +227,7 @@ export async function ensureGatewayStartupAuth(params: { }; } +/** Prevent hook ingress and Gateway auth from sharing the same bearer token. */ export function assertHooksTokenSeparateFromGatewayAuth(params: { cfg: OpenClawConfig; auth: ResolvedGatewayAuth; diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 2d01be2183d..23c26dbd305 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -42,6 +42,7 @@ function readStore(filePath: string): DeviceAuthStore | null { } const cached = storeReadCache.get(filePath); if (cached !== undefined && storeCacheHit(cached, stat)) { + // Device auth is read during gateway reconnects; cache by file metadata to avoid rereads. return cached.store; } const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists( @@ -67,6 +68,7 @@ function writeStore(filePath: string, store: DeviceAuthStore): void { } } +/** Load a cached device-auth token from the configured OpenClaw state directory. */ export function loadDeviceAuthToken(params: { deviceId: string; role: string; @@ -80,6 +82,7 @@ export function loadDeviceAuthToken(params: { }); } +/** Persist or replace one device-auth role token in the private state directory. */ export function storeDeviceAuthToken(params: { deviceId: string; role: string; @@ -100,6 +103,7 @@ export function storeDeviceAuthToken(params: { }); } +/** Remove one role token for the current gateway device from the private state directory. */ export function clearDeviceAuthToken(params: { deviceId: string; role: string; diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 677e3336bae..05c6b5fb11b 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -18,8 +18,10 @@ import { resolvePairingPaths } from "./pairing-files.js"; import { createAsyncLock, pruneExpiredPending, tryReadJson, writeJson } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; +/** Bootstrap pairing tokens are short-lived bearer credentials for first device auth. */ export const DEVICE_BOOTSTRAP_TOKEN_TTL_MS = 10 * 60 * 1000; +/** Persisted bootstrap token state, including binding and role/scope redemption progress. */ export type DeviceBootstrapTokenRecord = { token: string; ts: number; @@ -108,6 +110,7 @@ function resolveIssuedBootstrapProfile(params: { }): DeviceBootstrapProfile { const input = resolveIssuedBootstrapProfileInput(params); if (input) { + // Issued tokens can request many roles/scopes, but bootstrap handoff persists only the allowlist. return normalizeDeviceBootstrapHandoffProfile(input); } return PAIRING_SETUP_BOOTSTRAP_PROFILE; @@ -185,6 +188,7 @@ function normalizeBootstrapPublicKey(publicKey: string): string { if (!trimmed) { return ""; } + // PEM/base64/base64url encodings for the same key must bind to one token identity. if (trimmed.includes("BEGIN") || /[+/=]/.test(trimmed)) { return normalizeDevicePublicKeyBase64Url(trimmed) ?? trimmed; } @@ -203,6 +207,7 @@ async function loadState(baseDir?: string): Promise { continue; } const record = entry as Partial; + // Older files may be keyed by a map id instead of the bearer token itself. const token = typeof record.token === "string" && record.token.trim().length > 0 ? record.token : tokenKey; const issuedAtMs = asDateTimestampMs(record.issuedAtMs) ?? 0; @@ -229,6 +234,7 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string): await writeJson(bootstrapPath, state); } +/** Issue a short-lived bootstrap token with a bounded role/scope handoff profile. */ export async function issueDeviceBootstrapToken( params: { baseDir?: string; @@ -263,6 +269,7 @@ export async function issueDeviceBootstrapToken( }); } +/** Remove every outstanding bootstrap token from the pairing state file. */ export async function clearDeviceBootstrapTokens( params: { baseDir?: string; @@ -276,6 +283,7 @@ export async function clearDeviceBootstrapTokens( }); } +/** Revoke one bootstrap token and return its record for best-effort restore flows. */ export async function revokeDeviceBootstrapToken(params: { token: string; baseDir?: string; @@ -299,6 +307,7 @@ export async function revokeDeviceBootstrapToken(params: { }); } +/** Revoke bootstrap tokens that are already bound to a specific device identity. */ export async function revokeDeviceBootstrapTokensForDevice(params: { deviceId: string; publicKey: string; @@ -329,6 +338,7 @@ export async function revokeDeviceBootstrapTokensForDevice(params: { }); } +/** Restore a previously revoked bootstrap token record after a downstream send failure. */ export async function restoreDeviceBootstrapToken(params: { record: DeviceBootstrapTokenRecord; baseDir?: string; @@ -340,6 +350,7 @@ export async function restoreDeviceBootstrapToken(params: { }); } +/** Read the issued profile for a valid token without binding or redeeming it. */ export async function getDeviceBootstrapTokenProfile(params: { token: string; baseDir?: string; @@ -357,6 +368,7 @@ export async function getDeviceBootstrapTokenProfile(params: { }); } +/** Record that one role/scope leg of a multi-role bootstrap handoff was redeemed. */ export async function redeemDeviceBootstrapTokenProfile(params: { token: string; role: string; @@ -378,6 +390,7 @@ export async function redeemDeviceBootstrapTokenProfile(params: { const [tokenKey, record] = found; const issuedProfile = resolvePersistedBootstrapProfile(record); const pendingProfile = resolvePersistedPendingProfile(record); + // Keep a pending profile until all requested roles/scopes from that handshake are redeemed. const redeemedProfile = normalizeDeviceBootstrapProfile({ roles: [...resolvePersistedRedeemedProfile(record).roles, params.role], scopes: [ @@ -415,6 +428,7 @@ export async function redeemDeviceBootstrapTokenProfile(params: { }); } +/** Verify a bootstrap token, bind it to the first device identity, and stage requested scopes. */ export async function verifyDeviceBootstrapToken(params: { token: string; deviceId: string; diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index b1dee31830e..37faa23c145 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { privateFileStoreSync } from "./private-file-store.js"; +/** Gateway/device Ed25519 identity used for APNs relay and gateway authentication. */ export type DeviceIdentity = { deviceId: string; publicKeyPem: string; @@ -51,6 +52,7 @@ function pemEncode(label: "PUBLIC KEY" | "PRIVATE KEY", der: Buffer): string { return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`; } +// Swift stores raw Ed25519 key bytes; Node crypto needs DER/PEM wrappers around them. function publicKeyPemFromRaw(publicKeyRaw: Buffer): string { return pemEncode("PUBLIC KEY", Buffer.concat([ED25519_SPKI_PREFIX, publicKeyRaw])); } @@ -181,6 +183,7 @@ function normalizeStoredIdentity(parsed: unknown): NormalizedStoredIdentity | nu if (!keyPairMatches(publicKeyPem, privateKeyPem)) { return { kind: "recognized-invalid" }; } + // Migrate the legacy Swift raw-key shape only after the key pair proves valid. const derivedId = fingerprintPublicKey(publicKeyPem); const validForReadOnly = derivedId === stored.deviceId; const migrated: StoredIdentity = { @@ -216,6 +219,7 @@ function identityFileExists(filePath: string): boolean { } } +/** Load a valid persisted identity, repair/migrate when safe, or create a new one. */ export function loadOrCreateDeviceIdentity( filePath: string = resolveDefaultIdentityPath(), ): DeviceIdentity { @@ -236,6 +240,7 @@ export function loadOrCreateDeviceIdentity( return normalized.identity; } if (normalized?.kind === "recognized-invalid") { + // Avoid overwriting recognizable but invalid identity files; callers can still use a fresh key. return generateIdentity(); } } catch { @@ -258,6 +263,7 @@ export function loadOrCreateDeviceIdentity( return identity; } +/** Load a valid persisted device identity without creating, repairing, or migrating files. */ export function loadDeviceIdentityIfPresent( filePath: string = resolveDefaultIdentityPath(), ): DeviceIdentity | null { @@ -275,12 +281,14 @@ export function loadDeviceIdentityIfPresent( } } +/** Sign a UTF-8 payload with a PEM Ed25519 private key and return base64url bytes. */ export function signDevicePayload(privateKeyPem: string, payload: string): string { const key = crypto.createPrivateKey(privateKeyPem); const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key); return base64UrlEncode(sig); } +/** Normalize PEM or raw base64/base64url public keys to canonical raw base64url bytes. */ export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | null { try { if (publicKey.includes("BEGIN")) { @@ -296,6 +304,7 @@ export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | n } } +/** Derive the stable device id from PEM or raw base64/base64url public key material. */ export function deriveDeviceIdFromPublicKey(publicKey: string): string | null { try { const raw = publicKey.includes("BEGIN") @@ -310,10 +319,12 @@ export function deriveDeviceIdFromPublicKey(publicKey: string): string | null { } } +/** Export a PEM Ed25519 public key as canonical raw base64url bytes. */ export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { return base64UrlEncode(derivePublicKeyRaw(publicKeyPem)); } +/** Verify a UTF-8 payload signature against PEM or raw base64/base64url public key material. */ export function verifyDeviceSignature( publicKey: string, payload: string, diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 6cb9535fa5a..266c9d6f024 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -23,6 +23,7 @@ import { } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; +/** Pending device pairing request awaiting owner approval. */ export type DevicePairingPendingRequest = { requestId: string; deviceId: string; @@ -41,6 +42,7 @@ export type DevicePairingPendingRequest = { ts: number; }; +/** Bearer token issued to one paired device role. */ export type DeviceAuthToken = { token: string; role: string; @@ -55,6 +57,7 @@ export type DeviceAuthToken = { lastUsedAtMs?: number; }; +/** Redacted token metadata safe for list/status responses. */ export type DeviceAuthTokenSummary = { role: string; scopes: string[]; @@ -64,22 +67,26 @@ export type DeviceAuthTokenSummary = { lastUsedAtMs?: number; }; +/** Deny reasons returned when rotating an existing paired-device token. */ export type RotateDeviceTokenDenyReason = | "unknown-device-or-role" | "missing-approved-scope-baseline" | "scope-outside-approved-baseline" | "caller-missing-scope"; +/** Token rotation result with the replacement token entry on success. */ export type RotateDeviceTokenResult = | { ok: true; entry: DeviceAuthToken } | { ok: false; reason: RotateDeviceTokenDenyReason; scope?: string }; export type RevokeDeviceTokenDenyReason = "unknown-device-or-role" | "caller-missing-scope"; +/** Token revocation result with the revoked entry on success. */ export type RevokeDeviceTokenResult = | { ok: true; entry: DeviceAuthToken } | { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string }; +/** Persisted approved device record, including durable approval and active role tokens. */ export type PairedDevice = { deviceId: string; publicKey: string; @@ -100,6 +107,7 @@ export type PairedDevice = { lastSeenReason?: string; }; +/** Metadata fields a device may refresh without changing approval or token state. */ export type PairedDeviceMetadataPatch = Pick< PairedDevice, | "displayName" @@ -111,16 +119,19 @@ export type PairedDeviceMetadataPatch = Pick< | "lastSeenReason" >; +/** Paired-device access metadata refreshed when an existing device reconnects. */ export type DevicePairingAccessMetadata = Pick< PairedDevice, "displayName" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason" >; +/** Combined pending/paired view returned by pairing list APIs. */ export type DevicePairingList = { pending: DevicePairingPendingRequest[]; paired: PairedDevice[]; }; +/** Authorization failure categories for owner approval and bootstrap approval flows. */ export type DevicePairingForbiddenReason = | "caller-scopes-required" | "caller-missing-scope" @@ -128,6 +139,7 @@ export type DevicePairingForbiddenReason = | "bootstrap-role-not-allowed" | "bootstrap-scope-not-allowed"; +/** Structured forbidden result with the missing/disallowed role or scope when known. */ export type DevicePairingForbiddenResult = { status: "forbidden"; reason: DevicePairingForbiddenReason; @@ -135,6 +147,7 @@ export type DevicePairingForbiddenResult = { role?: string; }; +/** Pairing approval outcome: approved, forbidden with reason, or request not found. */ export type ApproveDevicePairingResult = | { status: "approved"; requestId: string; device: PairedDevice } | DevicePairingForbiddenResult @@ -154,6 +167,7 @@ const BROWSER_DEVICE_CLIENT_MODE = "webchat"; const withLock = createAsyncLock(); +/** Format a device-pairing authorization failure for CLI/API callers. */ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbiddenResult): string { switch (result.reason) { case "caller-scopes-required": @@ -254,6 +268,7 @@ function listActiveTokenRoles( ); } +/** List the durable roles an owner approved for a paired device record. */ export function listApprovedPairedDeviceRoles( device: Pick, ): string[] { @@ -262,6 +277,7 @@ export function listApprovedPairedDeviceRoles( return mergeRoles(device.roles, device.role) ?? []; } +/** List active-token roles, bounded by the durable approved pairing roles. */ export function listEffectivePairedDeviceRoles( device: Pick, ): string[] { @@ -277,6 +293,7 @@ export function listEffectivePairedDeviceRoles( return []; } +/** Return whether a paired device currently has an active token for one role. */ export function hasEffectivePairedDeviceRole( device: Pick, role: string, @@ -575,6 +592,7 @@ export async function listDevicePairing(baseDir?: string): Promise, baseDir?: string, @@ -649,6 +669,7 @@ export async function requestDevicePairing( }); } +/** Approve a pending request with optional caller-scope checks for operator grants. */ export async function approveDevicePairing( requestId: string, baseDir?: string, @@ -760,6 +781,7 @@ export async function approveDevicePairing( }); } +/** Approve a pending request through a bounded bootstrap profile handoff. */ export async function approveBootstrapDevicePairing( requestId: string, bootstrapProfile: DeviceBootstrapProfile, @@ -858,6 +880,7 @@ export async function approveBootstrapDevicePairing( }); } +/** Reject a pending request and revoke matching bootstrap tokens for that device. */ export async function rejectDevicePairing( requestId: string, baseDir?: string, @@ -879,6 +902,7 @@ export async function rejectDevicePairing( }); } +/** Remove a paired device and any pending repair requests for the same device id. */ export async function removePairedDevice( deviceId: string, baseDir?: string, @@ -900,6 +924,7 @@ export async function removePairedDevice( }); } +/** Update non-auth metadata for a paired device presence/status refresh. */ export async function updatePairedDeviceMetadata( deviceId: string, patch: Partial, @@ -940,6 +965,7 @@ export async function updatePairedDeviceMetadata( }); } +/** Summarize token metadata without exposing bearer token strings. */ export function summarizeDeviceTokens( tokens: Record | undefined, ): DeviceAuthTokenSummary[] | undefined { @@ -959,6 +985,7 @@ export function summarizeDeviceTokens( return summaries.length > 0 ? summaries : undefined; } +/** Verify a device role token, scope it to the approval baseline, and mark last use. */ export async function verifyDeviceToken(params: { deviceId: string; token: string; @@ -1026,6 +1053,7 @@ export async function verifyDeviceToken(params: { }); } +/** Return a reusable token for a role or issue one within the approved scope baseline. */ export async function ensureDeviceToken(params: { deviceId: string; role: string; @@ -1115,6 +1143,7 @@ function resolveDeviceTokenUpdateContext(params: { return { device, role, tokens, existing }; } +/** Rotate a role token inside the device's approved scope baseline. */ export async function rotateDeviceToken(params: { deviceId: string; role: string; @@ -1176,6 +1205,7 @@ export async function rotateDeviceToken(params: { }); } +/** Revoke one active role token after optional caller-scope authorization. */ export async function revokeDeviceToken(params: { deviceId: string; role: string; @@ -1215,6 +1245,7 @@ export async function revokeDeviceToken(params: { }); } +/** Delete a paired device record without touching unrelated pending requests. */ export async function clearDevicePairing(deviceId: string, baseDir?: string): Promise { return await withLock(async () => { const state = await loadState(baseDir); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts index 9c72105433f..b0ad7ae777e 100644 --- a/src/infra/net/http-connect-tunnel.ts +++ b/src/infra/net/http-connect-tunnel.ts @@ -3,6 +3,7 @@ import * as tls from "node:tls"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import type { ManagedProxyTlsOptions } from "./proxy/proxy-tls.js"; +/** Parameters for opening an APNs HTTP/2 tunnel through an HTTP(S) forward proxy. */ export type HttpConnectTunnelParams = { proxyUrl: URL; proxyTls?: ManagedProxyTlsOptions; @@ -97,6 +98,8 @@ function readProxyConnectResponse( const responseHeader = nextBuffer.subarray(0, bodyOffset).toString("latin1"); const statusLine = responseHeader.split("\r\n", 1)[0] ?? ""; + // CONNECT can coalesce response headers and first tunneled bytes. Preserve + // those bytes so the target TLS handshake sees the stream from byte zero. const tunneledBytes = nextBuffer.length > bodyOffset ? nextBuffer.subarray(bodyOffset) : undefined; return { @@ -113,6 +116,7 @@ function isSuccessfulConnectStatusLine(statusLine: string): boolean { function connectToProxy(proxy: URL, proxyTls: ManagedProxyTlsOptions | undefined): ProxySocket { const proxyHost = resolveProxyHost(proxy); + // TLS SNI cannot be an IP literal; omit it for IP-addressed HTTPS proxies. const proxyServername = net.isIP(proxyHost) === 0 ? proxyHost : undefined; const connectOptions = { host: proxyHost, @@ -210,6 +214,8 @@ class HttpConnectTunnelAttempt { this.clearTimer(); this.cleanupProxyListeners(); this.cleanupTargetTlsListeners(); + // Failure may happen during either CONNECT or target TLS setup. Destroy both + // sockets so half-open proxy tunnels do not leak into the process. this.targetTlsSocket?.destroy(); this.proxySocket?.destroy(); this.reject(formatTunnelFailure(this.params.proxyUrl, err)); @@ -310,6 +316,7 @@ class HttpConnectTunnelAttempt { }; } +/** Opens a TLS-over-CONNECT tunnel and verifies the target negotiated HTTP/2. */ export async function openHttpConnectTunnel( params: HttpConnectTunnelParams, ): Promise { diff --git a/src/infra/net/node-proxy-agent.ts b/src/infra/net/node-proxy-agent.ts index 29c9f64d621..be43915038c 100644 --- a/src/infra/net/node-proxy-agent.ts +++ b/src/infra/net/node-proxy-agent.ts @@ -15,6 +15,7 @@ type ProxylineTlsOptions = ProxylineAgentOptions["proxyTls"]; const require = createRequire(import.meta.url); +/** Selects either ambient env proxy resolution or a caller-supplied fixed proxy URL. */ export type CreateNodeProxyAgentOptions = | { mode: "env"; @@ -57,6 +58,8 @@ function formatNoProxyTargetUrl(targetUrl: string | URL): string | undefined { return undefined; } const parsed = new URL(target.href); + // Bypass matching uses web request semantics. Map WebSocket schemes to the + // equivalent request schemes so default ports and host rules line up. if (parsed.protocol === "ws:") { parsed.protocol = "http:"; } else if (parsed.protocol === "wss:") { @@ -84,6 +87,8 @@ function proxyUrlWithDefaultScheme(proxyUrl: string, protocol: NodeProxyProtocol function fixedProxyEnv(proxyUrl: URL): ProxylineEnvSnapshot { const href = proxyUrl.href; + // Proxyline's ambient agent only reads env-shaped input. Pin both request + // scheme slots to the explicit URL and clear bypass rules for a fixed agent. return { HTTP_PROXY: href, HTTPS_PROXY: href, @@ -101,6 +106,7 @@ function loadCreateAmbientNodeProxyAgent(): ProxylineCreateAmbientNodeProxyAgent .createAmbientNodeProxyAgent; } +/** Resolves the env proxy URL that should be used for a specific Node target. */ export function resolveEnvNodeProxyUrlForTarget( targetUrl: string | URL, env: NodeJS.ProcessEnv = process.env, @@ -143,9 +149,11 @@ function createFixedNodeProxyAgent( return agent as HttpAgent; } +/** Creates a Node HTTP(S) agent for explicit proxy URLs; unsupported protocols throw. */ export function createNodeProxyAgent( options: Extract, ): HttpAgent; +/** Creates a Node HTTP(S) agent from env proxy settings, or undefined when bypassed. */ export function createNodeProxyAgent( options: Extract, ): HttpAgent | undefined; @@ -172,6 +180,7 @@ function createEnvNodeProxyAgentForTarget( }); } +/** Builds paired HTTP and HTTPS agents for libraries that require both slots. */ export function createFixedNodeProxyAgentPair(proxyUrl: string | URL): { httpAgent: HttpAgent; httpsAgent: HttpAgent; diff --git a/src/infra/net/proxy-env.ts b/src/infra/net/proxy-env.ts index bbdc4cd2732..20270452bdf 100644 --- a/src/infra/net/proxy-env.ts +++ b/src/infra/net/proxy-env.ts @@ -7,6 +7,7 @@ export const PROXY_ENV_KEYS = [ "all_proxy", ] as const; +/** Return whether any supported proxy environment variable is non-blank. */ export function hasProxyEnvConfigured(env: NodeJS.ProcessEnv = process.env): boolean { for (const key of PROXY_ENV_KEYS) { const value = env[key]; @@ -25,8 +26,11 @@ function normalizeProxyEnvValue(value: string | undefined): string | null | unde return trimmed.length > 0 ? trimmed : null; } +/** Explicit proxy option shape accepted by undici EnvHttpProxyAgent. */ export type EnvHttpProxyAgentProxyOptions = { + /** Proxy URL used for HTTP requests. */ httpProxy?: string; + /** Proxy URL used for HTTPS requests. */ httpsProxy?: string; }; @@ -52,6 +56,7 @@ export function resolveEnvHttpProxyUrl( return httpProxy ?? undefined; } +/** Return whether EnvHttpProxyAgent-style HTTP/S proxy resolution finds a proxy URL. */ export function hasEnvHttpProxyConfigured( protocol: "http" | "https" = "https", env: NodeJS.ProcessEnv = process.env, @@ -86,10 +91,12 @@ export function resolveEnvHttpProxyAgentOptions( return options.httpProxy || options.httpsProxy ? options : undefined; } +/** Return whether explicit EnvHttpProxyAgent options can be built from the environment. */ export function hasEnvHttpProxyAgentConfigured(env: NodeJS.ProcessEnv = process.env): boolean { return resolveEnvHttpProxyAgentOptions(env) !== undefined; } +/** Return whether a target URL should use configured HTTP/S env proxy variables. */ export function shouldUseEnvHttpProxyForUrl( targetUrl: string, env: NodeJS.ProcessEnv = process.env, diff --git a/src/infra/net/proxy-fetch.ts b/src/infra/net/proxy-fetch.ts index 09578df15e7..f8e57963a6b 100644 --- a/src/infra/net/proxy-fetch.ts +++ b/src/infra/net/proxy-fetch.ts @@ -7,6 +7,7 @@ import { } from "./proxy/managed-proxy-undici.js"; import { loadUndiciRuntimeDeps, type UndiciRuntimeDeps } from "./undici-runtime.js"; +/** Non-enumerable marker used to recover the explicit proxy URL from proxy fetch wrappers. */ export const PROXY_FETCH_PROXY_URL = Symbol.for("openclaw.proxyFetch.proxyUrl"); type ProxyFetchWithMetadata = typeof fetch & { [PROXY_FETCH_PROXY_URL]?: string; @@ -59,6 +60,8 @@ function normalizeInitForUndici( for (const [key, value] of body.entries()) { appendFormDataEntry(form, key, value); } + // Undici must generate the multipart boundary for its own FormData instance; + // forwarding caller-supplied multipart headers can send a stale boundary. const headers = new Headers(normalizedHeaders); headers.delete("content-length"); headers.delete("content-type"); @@ -98,6 +101,7 @@ export function makeProxyFetch(proxyUrl: string): typeof fetch { return proxyFetch; } +/** Return the explicit proxy URL attached by {@link makeProxyFetch}, if present. */ export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefined { const proxyUrl = (fetchImpl as ProxyFetchWithMetadata | undefined)?.[PROXY_FETCH_PROXY_URL]; if (typeof proxyUrl !== "string") { diff --git a/src/infra/net/proxy/active-proxy-state.ts b/src/infra/net/proxy/active-proxy-state.ts index a7c77aad37e..32c998e1561 100644 --- a/src/infra/net/proxy/active-proxy-state.ts +++ b/src/infra/net/proxy/active-proxy-state.ts @@ -3,8 +3,10 @@ import type { ManagedProxyTlsOptions } from "./proxy-tls.js"; export type ActiveManagedProxyUrl = Readonly; +/** Managed proxy loopback behavior shared by gateway and child-process fetch paths. */ export type ActiveManagedProxyLoopbackMode = NonNullable["loopbackMode"]>; +/** Ref-counted active proxy handle; callers must stop it when their proxy scope ends. */ export type ActiveManagedProxyRegistration = { proxyUrl: ActiveManagedProxyUrl; loopbackMode: ActiveManagedProxyLoopbackMode; @@ -12,6 +14,7 @@ export type ActiveManagedProxyRegistration = { stopped: boolean; }; +/** Registration metadata for managed proxy URLs and their TLS trust material. */ export type RegisterActiveManagedProxyOptions = { loopbackMode?: ActiveManagedProxyLoopbackMode; proxyTls?: ManagedProxyTlsOptions; @@ -35,12 +38,15 @@ function readInheritedActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopba if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") { return undefined; } + // Child processes inherit loopback policy through env even when they do not + // own the in-process proxy registration. return ( parseActiveManagedProxyLoopbackMode(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]) ?? "gateway-only" ); } +/** Registers the active managed proxy, sharing identical nested registrations. */ export function registerActiveManagedProxyUrl( proxyUrl: URL, options: ActiveManagedProxyLoopbackMode | RegisterActiveManagedProxyOptions = "gateway-only", @@ -68,6 +74,8 @@ export function registerActiveManagedProxyUrl( "stop the current proxy before changing proxy.tls.", ); } + // Identical registrations are nested scopes; keep proxy state alive until + // every owner stops its returned handle. activeProxyRegistrationCount += 1; return { proxyUrl: activeProxyUrl, @@ -91,6 +99,7 @@ function areProxyTlsOptionsEqual( return left?.ca === right?.ca; } +/** Stops one registration scope and clears active proxy state after the last owner. */ export function stopActiveManagedProxyRegistration( registration: ActiveManagedProxyRegistration, ): void { @@ -109,18 +118,22 @@ export function stopActiveManagedProxyRegistration( } } +/** Returns local loopback policy from in-process state or inherited proxy env. */ export function getActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopbackMode | undefined { return activeProxyLoopbackMode ?? readInheritedActiveManagedProxyLoopbackMode(); } +/** Returns the in-process managed proxy URL, if this process owns the proxy. */ export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined { return activeProxyUrl; } +/** Returns the active managed proxy TLS options used by undici/proxyline dispatchers. */ export function getActiveManagedProxyTlsOptions(): ManagedProxyTlsOptions | undefined { return activeProxyTlsOptions; } +/** Clears process-local proxy state for tests that share a worker process. */ export function resetActiveManagedProxyStateForTests(): void { activeProxyUrl = undefined; activeProxyLoopbackMode = undefined; diff --git a/src/infra/net/proxy/managed-proxy-undici.ts b/src/infra/net/proxy/managed-proxy-undici.ts index b8f04835aea..1fd8b29b17e 100644 --- a/src/infra/net/proxy/managed-proxy-undici.ts +++ b/src/infra/net/proxy/managed-proxy-undici.ts @@ -67,9 +67,12 @@ function resolveManagedProxyUrl(env: ManagedProxyTlsEnv = process.env): string | if (env["OPENCLAW_PROXY_ACTIVE"] !== "1") { return undefined; } + // Child processes inherit only env, so recover the managed proxy URL from + // HTTPS proxy settings when the active in-process registration is absent. return normalizeProxyUrl(resolveEnvHttpProxyUrl("https", env)); } +/** Resolves managed proxy TLS trust only when the target proxy is OpenClaw's active proxy. */ export function resolveActiveManagedProxyTlsOptions( params?: ResolveActiveManagedProxyTlsOptionsParams, ): ManagedProxyTlsOptions | undefined { @@ -92,14 +95,17 @@ export function resolveActiveManagedProxyTlsOptions( try { return loadManagedProxyTlsOptionsSync(proxyCaFile); } catch { + // Missing inherited CA files should not break non-managed or caller-owned proxies. return undefined; } } +/** Adds active managed proxy TLS options to env proxy agent options. */ export function addActiveManagedProxyTlsOptions( options: undefined, params?: AddActiveManagedProxyTlsOptionsParams, ): { proxyTls: ManagedProxyTlsOptions } | undefined; +/** Adds active managed proxy TLS options to explicit proxy agent options. */ export function addActiveManagedProxyTlsOptions( options: TOptions, params?: AddActiveManagedProxyTlsOptionsParams, @@ -130,6 +136,8 @@ export function addActiveManagedProxyTlsOptions( return options; } const existingProxyTls = readProxyTlsRecord(options); + // Caller-supplied proxyTls wins over managed defaults so explicit TLS policy + // is not overwritten while still inheriting missing managed CA fields. return { ...options, proxyTls: { @@ -139,6 +147,7 @@ export function addActiveManagedProxyTlsOptions( }; } +/** Resolves env proxy options with managed proxy TLS attached when applicable. */ export function resolveManagedEnvHttpProxyAgentOptions( env: NodeJS.ProcessEnv = process.env, ): ManagedEnvHttpProxyAgentOptions | undefined { diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index 4895a7e2f15..1e85070b6e8 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -1,12 +1,3 @@ -/** - * High-level lifecycle management for OpenClaw's operator-managed network - * proxy routing. - * - * OpenClaw does not spawn or configure the filtering proxy. When enabled, it - * routes process-wide HTTP clients through the configured forward proxy URL and - * restores the previous process state on shutdown. - */ - import { installGlobalProxy, type ProxylineHandle, @@ -31,6 +22,7 @@ import { resolveManagedProxyCaFileForUrl, } from "./proxy-tls.js"; +/** Process-wide managed proxy handle returned to CLI/gateway startup owners. */ export type ProxyHandle = { /** The operator-managed proxy URL injected into process.env. */ proxyUrl: string; @@ -57,6 +49,7 @@ const MANAGED_PROXY_UNDICI_OPTIONS = Object.freeze({ allowH2: false, }) satisfies ProxylineUndiciOptions; +/** Resets process-wide proxy lifecycle state between tests that share a worker. */ export function resetProxyLifecycleForTests(): void { baseProxyEnvSnapshot = null; proxylineHandle?.stop(); @@ -127,6 +120,8 @@ function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void { proxylineHandle = null; restoreProxyEnv(snapshot); forceResetGlobalDispatcher(); + // If this process itself is a child of an active managed proxy, restoring the + // local lifecycle should keep inherited proxy routing active. ensureInheritedManagedProxyRoutingActive(); } @@ -184,6 +179,7 @@ function redactProxyUrlForLog(value: string): string { } } +/** Reinstalls Proxyline routing in child processes that inherited active proxy env. */ export function ensureInheritedManagedProxyRoutingActive(): void { if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") { return; @@ -207,6 +203,7 @@ export function ensureInheritedManagedProxyRoutingActive(): void { forceResetGlobalDispatcher({ preserveProxylineManaged: true }); } +/** Starts process-wide managed proxy routing and returns the owner stop handle. */ export async function startProxy(config: ProxyConfig | undefined): Promise { if (config?.enabled !== true) { return null; @@ -218,6 +215,8 @@ export async function startProxy(config: ProxyConfig | undefined): Promise { if (!handle) { return; @@ -313,6 +313,7 @@ function getGatewayControlPlaneBypassAuthority(value: string): string | null { return url.port ? `${url.hostname}:${url.port}` : url.hostname; } +/** Registers a temporary direct route for trusted Gateway loopback control-plane URLs. */ export function registerManagedProxyGatewayLoopbackBypass(url: string): (() => void) | undefined { const authority = getGatewayControlPlaneBypassAuthority(url); if (!authority) { diff --git a/src/infra/net/proxy/proxy-tls.ts b/src/infra/net/proxy/proxy-tls.ts index 8af5fec1ef1..9ec4466a77d 100644 --- a/src/infra/net/proxy/proxy-tls.ts +++ b/src/infra/net/proxy/proxy-tls.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; +/** TLS trust material passed to proxy clients for OpenClaw-managed HTTPS proxies. */ export type ManagedProxyTlsOptions = Readonly<{ ca?: string; }>; @@ -26,6 +27,7 @@ function isHttpsProxyUrl(value: string | undefined): boolean { } } +/** Resolves the configured managed proxy CA file, with env/CLI override first. */ export function resolveManagedProxyCaFile(params: { config?: ProxyConfig; caFileOverride?: string; @@ -36,6 +38,7 @@ export function resolveManagedProxyCaFile(params: { ); } +/** Returns a CA file only for HTTPS proxy URLs; HTTP proxies do not need TLS trust. */ export function resolveManagedProxyCaFileForUrl(params: { proxyUrl: string | undefined; config?: ProxyConfig; @@ -50,6 +53,7 @@ export function resolveManagedProxyCaFileForUrl(params: { }); } +/** Loads managed proxy TLS options asynchronously for startup paths. */ export async function loadManagedProxyTlsOptions( caFile: string | undefined, ): Promise { @@ -65,6 +69,7 @@ export async function loadManagedProxyTlsOptions( } } +/** Loads managed proxy TLS options synchronously for inherited child-process routing. */ export function loadManagedProxyTlsOptionsSync( caFile: string | undefined, ): ManagedProxyTlsOptions | undefined { diff --git a/src/infra/net/proxy/proxy-validation.ts b/src/infra/net/proxy/proxy-validation.ts index 2aa4d44c061..949bba91e6e 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -17,8 +17,10 @@ const DEFAULT_PROXY_VALIDATION_TIMEOUT_MS = 5000; const DENIED_CANARY_HEADER = "x-openclaw-proxy-validation-canary"; const APNS_REACHABILITY_REASON = "InvalidProviderToken"; +/** Describes where the effective proxy validation URL came from. */ export type ProxyValidationConfigSource = "override" | "config" | "env" | "missing" | "disabled"; +/** Normalized proxy validation input plus actionable config errors. */ export type ProxyValidationResolvedConfig = { enabled: boolean; proxyUrl?: string; @@ -27,8 +29,10 @@ export type ProxyValidationResolvedConfig = { errors: string[]; }; +/** Validation probe categories reported to CLI output. */ export type ProxyValidationCheckKind = "allowed" | "denied" | "apns"; +/** Result for one proxy validation probe. */ export type ProxyValidationCheck = { kind: ProxyValidationCheckKind; url: string; @@ -37,12 +41,14 @@ export type ProxyValidationCheck = { error?: string; }; +/** Complete proxy validation result consumed by CLI formatting. */ export type ProxyValidationResult = { ok: boolean; config: ProxyValidationResolvedConfig; checks: ProxyValidationCheck[]; }; +/** Parameters for fetch-based proxy validation probes. */ export type ProxyValidationFetchCheckParams = { proxyUrl: string; proxyTls?: ManagedProxyTlsOptions; @@ -50,16 +56,19 @@ export type ProxyValidationFetchCheckParams = { timeoutMs: number; }; +/** Result from a fetch-based probe, including optional denied-canary evidence. */ export type ProxyValidationFetchCheckResult = { ok: boolean; status: number; deniedCanaryToken?: string; }; +/** Injectable fetch probe used by tests and the default runtime validator. */ export type ProxyValidationFetchCheck = ( params: ProxyValidationFetchCheckParams, ) => Promise; +/** Parameters for APNs reachability validation through the proxy tunnel. */ export type ProxyValidationApnsCheckParams = { proxyUrl: string; proxyTls?: ManagedProxyTlsOptions; @@ -75,10 +84,12 @@ export type ProxyValidationApnsCheckResult = { apnsReason?: string; }; +/** Injectable APNs probe used by tests and the default HTTP/2 validator. */ export type ProxyValidationApnsCheck = ( params: ProxyValidationApnsCheckParams, ) => Promise; +/** Inputs used to resolve proxy validation config before network probes run. */ export type ResolveProxyValidationConfigOptions = { config?: ProxyConfig; env?: NodeJS.ProcessEnv | Partial>; @@ -86,6 +97,7 @@ export type ResolveProxyValidationConfigOptions = { proxyCaFileOverride?: string; }; +/** Full proxy validation runner options, including probe overrides for tests. */ export type RunProxyValidationOptions = ResolveProxyValidationConfigOptions & { allowedUrls?: readonly string[]; deniedUrls?: readonly string[]; @@ -138,6 +150,7 @@ function validateResolvedProxy( return [...validateProxyUrl(value), ...validateProxyEnabled(source, enabled)]; } +/** Resolves validation config precedence: explicit override, config, then env. */ export function resolveProxyValidationConfig( options: ResolveProxyValidationConfigOptions, ): ProxyValidationResolvedConfig { @@ -270,6 +283,8 @@ function hasApnsReachabilityProof(result: ProxyValidationApnsCheckResult): boole if (result.apnsId) { return true; } + // APNs returns InvalidProviderToken for the intentionally invalid probe. That + // body proves the CONNECT tunnel reached Apple even without an apns-id header. return result.status === 403 && result.apnsReason === APNS_REACHABILITY_REASON; } @@ -314,6 +329,8 @@ function closeServer(server: Server): Promise { async function createLoopbackDeniedCanary(): Promise { const token = randomUUID(); + // The default denied probe targets loopback and expects the proxy to block it. + // If a proxy returns this token, it forwarded a destination it should deny. const server = createServer((_request, response) => { response.writeHead(204, { [DENIED_CANARY_HEADER]: token, @@ -521,6 +538,7 @@ async function runApnsReachabilityCheck(params: { } } +/** Runs allowed, denied, and optional APNs proxy validation probes. */ export async function runProxyValidation( options: RunProxyValidationOptions, ): Promise { diff --git a/src/infra/net/redirect-headers.ts b/src/infra/net/redirect-headers.ts index 3e865197883..cb018faeae6 100644 --- a/src/infra/net/redirect-headers.ts +++ b/src/infra/net/redirect-headers.ts @@ -17,6 +17,10 @@ const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([ "user-agent", ]); +/** + * Keeps only headers that are safe to replay after a redirect crosses origins. + * Authorization/cookie-like metadata must be dropped before the follow-up fetch. + */ export function retainSafeHeadersForCrossOriginRedirect( headers?: HeadersInit | Record, ): Record | undefined { @@ -26,6 +30,7 @@ export function retainSafeHeadersForCrossOriginRedirect( const incoming = new Headers(normalizeHeadersInitForFetch(headers)); const safeHeaders: Record = {}; for (const [key, value] of incoming.entries()) { + // Normalize lookup only; preserve the outgoing casing produced by Headers. if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(normalizeLowercaseStringOrEmpty(key))) { safeHeaders[key] = value; } diff --git a/src/infra/net/runtime-fetch.ts b/src/infra/net/runtime-fetch.ts index 13f88eae00c..f17a50a641f 100644 --- a/src/infra/net/runtime-fetch.ts +++ b/src/infra/net/runtime-fetch.ts @@ -30,6 +30,8 @@ function normalizeRuntimeFormData( return body; } + // Node's global FormData and undici's runtime FormData can be different + // constructors. Rebuild entries so runtime fetch can stream multipart bodies. const next = new RuntimeFormData(); for (const [key, value] of body.entries()) { const namedValue = value as FormDataEntryValueWithOptionalName; @@ -66,6 +68,8 @@ function normalizeRuntimeRequestInit( return initWithNormalizedHeaders; } + // The rebuilt FormData will choose its own boundary and length; stale caller + // values make undici send an invalid multipart request. const headers = new Headers(normalizedHeaders); headers.delete("content-length"); headers.delete("content-type"); @@ -76,6 +80,7 @@ function normalizeRuntimeRequestInit( }; } +/** Returns true for Vitest-style mocked fetch functions that should stay injectable. */ export function isMockedFetch(fetchImpl: FetchLike | undefined): boolean { if (typeof fetchImpl !== "function") { return false; @@ -83,6 +88,7 @@ export function isMockedFetch(fetchImpl: FetchLike | undefined): boolean { return typeof (fetchImpl as FetchLike & { mock?: unknown }).mock === "object"; } +/** Uses the undici runtime fetch so callers can pass dispatcher-aware options. */ export async function fetchWithRuntimeDispatcher( input: RequestInfo | URL, init?: DispatcherAwareRequestInit, @@ -98,6 +104,10 @@ export async function fetchWithRuntimeDispatcher( )) as Response; } +/** + * Uses test-injected global fetch when present, otherwise preserves dispatcher + * support by routing through the undici runtime fetch. + */ export async function fetchWithRuntimeDispatcherOrMockedGlobal( input: RequestInfo | URL, init?: DispatcherAwareRequestInit, diff --git a/src/infra/net/undici-family-policy.ts b/src/infra/net/undici-family-policy.ts index 07cfa4a4486..f413c333913 100644 --- a/src/infra/net/undici-family-policy.ts +++ b/src/infra/net/undici-family-policy.ts @@ -3,6 +3,7 @@ import { isWSL2Sync } from "../wsl.js"; const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; +/** Resolves the process default autoSelectFamily policy, with WSL2 forced to IPv4. */ export function resolveUndiciAutoSelectFamily(): boolean | undefined { if (typeof net.getDefaultAutoSelectFamily !== "function") { return undefined; @@ -20,6 +21,7 @@ export function resolveUndiciAutoSelectFamily(): boolean | undefined { } } +/** Converts an autoSelectFamily decision into the undici connect option shape. */ export function createUndiciAutoSelectFamilyConnectOptions( autoSelectFamily: boolean | undefined, ): { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } | undefined { @@ -32,12 +34,17 @@ export function createUndiciAutoSelectFamilyConnectOptions( }; } +/** Returns shared undici connect options for dispatchers that do not override them. */ export function resolveUndiciAutoSelectFamilyConnectOptions(): | { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } | undefined { return createUndiciAutoSelectFamilyConnectOptions(resolveUndiciAutoSelectFamily()); } +/** + * Temporarily applies an undici family decision around synchronous setup code. + * Restore is best-effort because older Node runtimes may not expose the setters. + */ export function withTemporaryUndiciAutoSelectFamily( autoSelectFamily: boolean | undefined, run: () => T, diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index 4b3a3053fab..eb6afb5bd2c 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -78,6 +78,8 @@ function createTimedProxylineManagedDispatcher( ): UndiciDispatcher { const existingState = timedProxylineManagedDispatchers.get(dispatcher); if (existingState) { + // Managed proxy dispatchers may be reconfigured in place; update the shared + // state so existing wrappers pick up timeout/family changes without nesting. existingState.autoSelectFamily = autoSelectFamily; existingState.timeoutMs = timeoutMs; return dispatcher; @@ -113,6 +115,8 @@ function createTimedProxylineManagedDispatcher( return value.bind(target); } if (UNDICI_DISPATCH_HELPER_METHODS.has(property)) { + // Undici helper methods expect the dispatcher proxy as `this` so they + // still route through our wrapped dispatch implementation. return (...args: unknown[]) => Reflect.apply(value, receiver, args); } return value; @@ -204,6 +208,7 @@ function resolveCurrentDispatcherInfo( }; } +/** Installs the env-proxy global dispatcher once proxy env is available. */ export function ensureGlobalUndiciEnvProxyDispatcher(): void { const shouldUseEnvProxy = hasEnvHttpProxyAgentConfigured(); if (!shouldUseEnvProxy) { @@ -279,6 +284,10 @@ function applyGlobalDispatcherStreamTimeouts(params: { } } +/** + * Records the stream timeout bridge and applies it only when the current global + * dispatcher already uses env or managed proxy routing. + */ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }): void { const timeoutMs = resolveStreamTimeoutMs(opts); if (timeoutMs === null) { @@ -306,6 +315,7 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }): }); } +/** Forces timeout/family policy onto the current supported global dispatcher. */ export function ensureGlobalUndiciDispatcherStreamTimeouts(opts?: { timeoutMs?: number }): void { const timeoutMs = resolveStreamTimeoutMs(opts); if (timeoutMs === null) { @@ -325,6 +335,7 @@ export function ensureGlobalUndiciDispatcherStreamTimeouts(opts?: { timeoutMs?: }); } +/** Clears module-level dispatcher bookkeeping between isolated tests. */ export function resetGlobalUndiciStreamTimeoutsForTests(): void { lastAppliedTimeoutKey = null; lastAppliedProxyBootstrapKey = null; diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index a47cfe95fd0..5fb4fa4164c 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -6,6 +6,7 @@ import { resolveUndiciAutoSelectFamilyConnectOptions } from "./undici-family-pol export const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; +/** Runtime-loaded undici constructors/functions used where static imports would affect globals. */ export type UndiciRuntimeDeps = { Agent: typeof import("undici").Agent; EnvHttpProxyAgent: typeof import("undici").EnvHttpProxyAgent; @@ -14,6 +15,7 @@ export type UndiciRuntimeDeps = { fetch: typeof import("undici").fetch; }; +/** Minimal undici surface needed by global-dispatcher installation code. */ export type UndiciGlobalDispatcherDeps = Pick & { getGlobalDispatcher: typeof import("undici").getGlobalDispatcher; setGlobalDispatcher: typeof import("undici").setGlobalDispatcher; @@ -106,6 +108,8 @@ function stripIpServernameFromConnect(connect: unknown): unknown { function createIpSafeProxyClientFactory(): UndiciProxyClientFactory { return (origin, options) => { const Pool = loadUndiciProxyPoolCtor(); + // HTTPS proxies addressed by IP can arrive with an IP servername. Strip it + // before TLS connect because OpenSSL rejects IP literals as SNI values. const clientOptions = isObjectRecord(options) ? { ...options, connect: stripIpServernameFromConnect(options.connect) } : options; @@ -120,12 +124,15 @@ function addIpSafeProxyClientFactory(options: TOptions) if ("clientFactory" in options) { return options; } + // Only install our factory when the caller did not provide one, otherwise + // custom proxy pools would lose their own connection policy. return { ...options, clientFactory: createIpSafeProxyClientFactory(), }; } +/** Loads undici lazily, allowing tests to inject constructors without global side effects. */ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { const override = (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY]; if (isUndiciRuntimeDeps(override)) { @@ -143,6 +150,7 @@ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { }; } +/** Loads only the undici global-dispatcher API used by startup proxy setup. */ export function loadUndiciGlobalDispatcherDeps(): UndiciGlobalDispatcherDeps { const override = (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY]; if (isUndiciGlobalDispatcherDeps(override)) { @@ -203,6 +211,7 @@ function withHttp1OnlyDispatcherOptions( return base; } +/** Creates a direct undici Agent with OpenClaw's HTTP/1-only dispatcher policy. */ export function createHttp1Agent( options?: UndiciAgentOptions, timeoutMs?: number, @@ -211,6 +220,10 @@ export function createHttp1Agent( return new Agent(withHttp1OnlyDispatcherOptions(options, timeoutMs)); } +/** + * Creates an EnvHttpProxyAgent with OpenClaw proxy TLS, IP-safe proxy pools, + * timeout propagation, and HTTP/1-only dispatch. + */ export function createHttp1EnvHttpProxyAgent( options?: UndiciEnvHttpProxyAgentOptions, timeoutMs?: number, @@ -228,6 +241,10 @@ export function createHttp1EnvHttpProxyAgent( ); } +/** + * Creates a fixed ProxyAgent with the same HTTP/1, managed TLS, timeout, and + * IP-safe proxy connection policy used by env proxy dispatchers. + */ export function createHttp1ProxyAgent( options: UndiciProxyAgentOptions, timeoutMs?: number, diff --git a/src/infra/node-pairing-authz.ts b/src/infra/node-pairing-authz.ts index 4d675949a9f..6e035db4573 100644 --- a/src/infra/node-pairing-authz.ts +++ b/src/infra/node-pairing-authz.ts @@ -1,11 +1,13 @@ import { NODE_SYSTEM_RUN_COMMANDS } from "./node-commands.js"; +/** Operator scopes required to approve a pending node pairing surface. */ export type NodeApprovalScope = "operator.pairing" | "operator.write" | "operator.admin"; const OPERATOR_PAIRING_SCOPE: NodeApprovalScope = "operator.pairing"; const OPERATOR_WRITE_SCOPE: NodeApprovalScope = "operator.write"; const OPERATOR_ADMIN_SCOPE: NodeApprovalScope = "operator.admin"; +/** Map declared node commands to the least operator scopes needed for approval. */ export function resolveNodePairApprovalScopes(commands: unknown): NodeApprovalScope[] { const normalized = Array.isArray(commands) ? commands.filter((command): command is string => typeof command === "string") diff --git a/src/infra/node-pairing-surface.ts b/src/infra/node-pairing-surface.ts index 3daa998b783..b6ef891fe32 100644 --- a/src/infra/node-pairing-surface.ts +++ b/src/infra/node-pairing-surface.ts @@ -1,9 +1,11 @@ import { normalizeArrayBackedTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +/** Normalize capability/command lists for node approval-surface comparison. */ export function normalizeNodeApprovalSurfaceList(value: readonly string[] | undefined): string[] { return normalizeArrayBackedTrimmedStringList(value) ?? []; } +/** Compare capability/command surfaces as normalized sets, ignoring order and duplicates. */ export function sameNodeApprovalSurfaceSet( left: readonly string[] | undefined, right: readonly string[] | undefined, @@ -21,6 +23,7 @@ export function sameNodeApprovalSurfaceSet( return true; } +/** Compare node permission maps deterministically so key order cannot trigger repairs. */ export function sameNodePermissionSurface( left: Record | undefined, right: Record | undefined, diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 02254e9c689..fb35cf0ffcf 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -34,18 +34,22 @@ type NodeDeclaredSurface = { type NodeApprovedSurface = NodeDeclaredSurface; +/** Node-declared pairing surface before approval. */ export type NodePairingRequestInput = NodeDeclaredSurface & { silent?: boolean; }; +/** Pending node pairing request awaiting operator approval. */ export type NodePairingPendingRequest = NodePairingRequestInput & { requestId: string; silent?: boolean; ts: number; }; +/** Pending request summary returned when a new approval surface supersedes older requests. */ export type NodePairingSupersededRequest = Pick; +/** Result for creating or refreshing a pending node pairing request. */ export type RequestNodePairingResult = { status: "pending"; request: NodePairingPendingRequest; @@ -57,6 +61,7 @@ type NodePairingPendingEntry = NodePairingPendingRequest & { requiredApproveScopes: NodeApprovalScope[]; }; +/** Approved node record with its pairing token and persisted capability surface. */ export type NodePairingPairedNode = NodeApprovedSurface & { token: string; bins?: string[]; @@ -141,6 +146,7 @@ function samePendingApprovalSurface( normalizeArrayBackedTrimmedStringList(incoming.commands) ?? existing.commands; const incomingPermissions = incoming.permissions ?? existing.permissions; return ( + // Metadata-only reconnects may refresh one pending request; approval-surface changes supersede. sameNodeApprovalSurfaceSet(existing.caps, incomingCaps) && sameNodeApprovalSurfaceSet(existing.commands, incomingCommands) && sameNodePermissionSurface(existing.permissions, incomingPermissions) @@ -232,6 +238,7 @@ export async function listNodePairing(baseDir?: string): Promise>, @@ -416,6 +429,7 @@ export async function updatePairedNodeMetadata( }); } +/** Rename a paired node display name while preserving token and approval metadata. */ export async function renamePairedNode( nodeId: string, displayName: string, diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index 8050a4334fe..84a4f450a13 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -3,6 +3,7 @@ import { resolveStateDir } from "../config/paths.js"; export { createAsyncLock, readJsonIfExists, tryReadJson, writeJson } from "./json-files.js"; +/** Resolve pending/paired JSON file locations for one pairing namespace. */ export function resolvePairingPaths(baseDir: string | undefined, subdir: string) { const root = baseDir ?? resolveStateDir(); const dir = path.join(root, subdir); @@ -13,6 +14,7 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string) }; } +/** Coerce persisted pairing maps, treating malformed arrays/scalars as empty state. */ export function coercePairingStateRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; @@ -20,6 +22,7 @@ export function coercePairingStateRecord(value: unknown): Record { return value as Record; } +/** Remove pending requests older than the caller's pairing TTL. */ export function pruneExpiredPending( pendingById: Record, nowMs: number, @@ -32,12 +35,14 @@ export function pruneExpiredPending( } } +/** Result shape for creating or refreshing a pending pairing request. */ export type PendingPairingRequestResult = { status: "pending"; request: TPending; created: boolean; }; +/** Refresh one compatible pending request or replace a superseded request set atomically. */ export async function reconcilePendingPairingRequests< TPending extends { requestId: string }, TIncoming, diff --git a/src/infra/pairing-pending.ts b/src/infra/pairing-pending.ts index a6b40b77338..fd6b70a15df 100644 --- a/src/infra/pairing-pending.ts +++ b/src/infra/pairing-pending.ts @@ -2,6 +2,7 @@ type PendingState = { pendingById: Record; }; +/** Reject one pending pairing request and return the caller-selected id field. */ export async function rejectPendingPairingRequest< TPending, TState extends PendingState, diff --git a/src/infra/pairing-token.ts b/src/infra/pairing-token.ts index 7fe0dc2e688..50cf4abd178 100644 --- a/src/infra/pairing-token.ts +++ b/src/infra/pairing-token.ts @@ -1,12 +1,15 @@ import { randomBytes } from "node:crypto"; import { safeEqualSecret } from "../security/secret-equal.js"; +/** Random byte length for base64url device/node/bootstrap bearer tokens. */ export const PAIRING_TOKEN_BYTES = 32; +/** Generate a URL-safe bearer token for pairing and bootstrap flows. */ export function generatePairingToken(): string { return randomBytes(PAIRING_TOKEN_BYTES).toString("base64url"); } +/** Verify nonblank pairing tokens with constant-time secret comparison. */ export function verifyPairingToken(provided: string, expected: string): boolean { if (provided.trim().length === 0 || expected.trim().length === 0) { return false; diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index e4de639706e..6b9197d9199 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -20,11 +20,13 @@ type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.ap export const APNS_HTTP2_CANCEL_CODE = http2.constants.NGHTTP2_CANCEL; const APNS_HTTP2_MIN_TIMEOUT_MS = 1000; +/** Parameters for opening an APNs HTTP/2 client session. */ export type ConnectApnsHttp2SessionParams = { authority: string; timeoutMs: number; }; +/** Parameters for validating APNs reachability through an explicit proxy. */ export type ProbeApnsHttp2ReachabilityViaProxyParams = { authority: string; proxyUrl: string; @@ -32,6 +34,7 @@ export type ProbeApnsHttp2ReachabilityViaProxyParams = { timeoutMs: number; }; +/** APNs probe response used to prove a proxy tunneled to Apple. */ export type ProbeApnsHttp2ReachabilityViaProxyResult = { status: number; body: string; @@ -60,6 +63,8 @@ function assertApnsAuthority(authority: string): ApnsAuthority { if (!APNS_AUTHORITIES.has(normalized)) { throw new Error(`Unsupported APNs authority: ${authority}`); } + // Return a normalized origin only. APNs paths are created by callers and + // should never be accepted from user/config authority input. return normalized as ApnsAuthority; } @@ -78,11 +83,14 @@ async function openProxiedApnsHttp2Session(params: { timeoutMs: params.timeoutMs, }); + // The CONNECT helper already completed the target TLS handshake; reuse that + // socket so the session cannot open a separate direct route. return http2.connect(params.authority, { createConnection: () => tlsSocket, }); } +/** Connects to APNs directly, or through the active managed proxy when present. */ export async function connectApnsHttp2Session( params: ConnectApnsHttp2SessionParams, ): Promise { @@ -105,6 +113,7 @@ function resolveApnsHttp2TimeoutMs(timeoutMs: number): number { return resolveTimerTimeoutMs(timeoutMs, APNS_HTTP2_MIN_TIMEOUT_MS, APNS_HTTP2_MIN_TIMEOUT_MS); } +/** Sends an intentionally invalid APNs push through a proxy to prove HTTP/2 reachability. */ export async function probeApnsHttp2ReachabilityViaProxy( params: ProbeApnsHttp2ReachabilityViaProxyParams, ): Promise { @@ -146,6 +155,8 @@ export async function probeApnsHttp2ReachabilityViaProxy( const request = session.request({ ":method": "POST", ":path": `/3/device/${"0".repeat(64)}`, + // APNs should reject this token with InvalidProviderToken. That failure + // is the success signal that the proxy actually tunneled to Apple. authorization: "bearer intentionally.invalid.openclaw.proxy.validation", "apns-topic": "ai.openclaw.ios", "apns-push-type": "alert", diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index f6458cc6e3f..67128b36c0a 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -15,6 +15,7 @@ import { normalizeHostname } from "./net/hostname.js"; type ApnsRelayPushType = "alert" | "background"; +/** Resolved APNs relay endpoint and client timeout for gateway-originated sends. */ export type ApnsRelayConfig = { baseUrl: string; timeoutMs: number; @@ -28,6 +29,7 @@ type ApnsRelayConfigResolutionOptions = { registrationRelayOrigin?: string; }; +/** Normalized relay response after the hosted relay has attempted an APNs send. */ export type ApnsRelayPushResponse = { ok: boolean; status: number; @@ -37,6 +39,7 @@ export type ApnsRelayPushResponse = { tokenSuffix?: string; }; +/** Test/integration seam for sending a signed APNs relay request. */ export type ApnsRelayRequestSender = (params: { relayConfig: ApnsRelayConfig; sendGrant: string; @@ -50,6 +53,7 @@ export type ApnsRelayRequestSender = (params: { payload: object; }) => Promise; +/** Hosted APNs relay origin used only when registrations prove they were minted there. */ export const DEFAULT_APNS_RELAY_BASE_URL = "https://ios-push-relay.openclaw.ai"; const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10_000; const GATEWAY_DEVICE_ID_HEADER = "x-openclaw-gateway-device-id"; @@ -96,6 +100,7 @@ function parseReason(value: unknown): string | undefined { return typeof value === "string" ? normalizeOptionalString(value) : undefined; } +/** Validate and canonicalize an APNs relay base URL for config and registration origins. */ export function normalizeApnsRelayBaseUrl( baseUrl: string, env: NodeJS.ProcessEnv = process.env, @@ -108,6 +113,7 @@ export function normalizeApnsRelayBaseUrl( if (!parsed.hostname) { throw new Error("host required"); } + // Plain HTTP is only for local relay development; production relay URLs must use TLS. if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) { throw new Error( "http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)", @@ -133,6 +139,7 @@ function buildRelayGatewaySignaturePayload(params: { signedAtMs: number; bodyJson: string; }): string { + // Domain-separate relay send signatures from other gateway/device signatures. return [ "openclaw-relay-send-v1", params.gatewayDeviceId.trim(), @@ -141,6 +148,7 @@ function buildRelayGatewaySignaturePayload(params: { ].join("\n"); } +/** Resolve the relay endpoint from env/config and require it to match relay-minted registrations. */ export function resolveApnsRelayConfigFromEnv( env: NodeJS.ProcessEnv = process.env, gatewayConfig?: GatewayConfig, @@ -230,6 +238,7 @@ async function sendApnsRelayRequest(params: { body: params.bodyJson, signal: AbortSignal.timeout(params.relayConfig.timeoutMs), }); + // Do not follow relay redirects; grants and signatures are scoped to the configured relay origin. if (response.status >= 300 && response.status < 400) { return { ok: false, @@ -264,6 +273,7 @@ async function sendApnsRelayRequest(params: { }; } +/** Sign and send an APNs relay push using the gateway device identity. */ export async function sendApnsRelayPush(params: { relayConfig: ApnsRelayConfig; sendGrant: string; diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 0fa4c091dd1..98565e16a3d 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -46,8 +46,10 @@ type RelayApnsRegistration = { tokenDebugSuffix?: string; }; +/** Stored APNs registration for either direct device tokens or official relay handles. */ export type ApnsRegistration = DirectApnsRegistration | RelayApnsRegistration; +/** Direct APNs provider authentication used to mint ES256 bearer tokens. */ export type ApnsAuthConfig = { teamId: string; keyId: string; @@ -56,6 +58,7 @@ export type ApnsAuthConfig = { type ApnsAuthConfigResolution = { ok: true; value: ApnsAuthConfig } | { ok: false; error: string }; +/** Normalized APNs push result returned to gateway push/nodes methods. */ export type ApnsPushResult = { ok: boolean; status: number; @@ -231,6 +234,8 @@ function getApnsBearerToken(auth: ApnsAuthConfig, nowMs: number = Date.now()): s return cachedJwt.token; } + // APNs provider tokens are valid for one hour. Cache for slightly less so + // bursty wake/approval pushes avoid repeated ECDSA signing. const iat = Math.floor(nowMs / 1000); const header = toBase64UrlJson({ alg: "ES256", kid: auth.keyId, typ: "JWT" }); const payload = toBase64UrlJson({ iss: auth.teamId, iat }); @@ -407,6 +412,7 @@ async function persistRegistrationsState( }); } +/** Normalizes the APNs environment string accepted by registration inputs. */ export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null { if (typeof value !== "string") { return null; @@ -418,6 +424,7 @@ export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null return null; } +/** Persists a validated direct or relay APNs registration for one node id. */ export async function registerApnsRegistration( params: RegisterApnsParams, ): Promise { @@ -493,6 +500,7 @@ export async function registerApnsRegistration( }); } +/** Backward-compatible helper for storing a direct APNs token registration. */ export async function registerApnsToken(params: { nodeId: string; token: string; @@ -506,6 +514,7 @@ export async function registerApnsToken(params: { })) as DirectApnsRegistration; } +/** Loads one normalized APNs registration by node id. */ export async function loadApnsRegistration( nodeId: string, baseDir?: string, @@ -518,6 +527,7 @@ export async function loadApnsRegistration( return state.registrationsByNodeId[normalizedNodeId] ?? null; } +/** Loads normalized APNs registrations for the requested node ids, preserving request order. */ export async function loadApnsRegistrations( nodeIds: readonly string[], baseDir?: string, @@ -537,6 +547,7 @@ export async function loadApnsRegistrations( return registrations; } +/** Removes a stored APNs registration by node id. */ export async function clearApnsRegistration(nodeId: string, baseDir?: string): Promise { const normalizedNodeId = normalizeNodeId(nodeId); if (!normalizedNodeId) { @@ -578,6 +589,7 @@ function isSameApnsRegistration(a: ApnsRegistration, b: ApnsRegistration): boole return false; } +/** Clears a registration only if storage still contains the caller's observed value. */ export async function clearApnsRegistrationIfCurrent(params: { nodeId: string; registration: ApnsRegistration; @@ -599,6 +611,7 @@ export async function clearApnsRegistrationIfCurrent(params: { }); } +/** Returns true for APNs responses that mean the direct device token is no longer usable. */ export function shouldInvalidateApnsRegistration(result: { status: number; reason?: string; @@ -609,6 +622,7 @@ export function shouldInvalidateApnsRegistration(result: { return result.status === 400 && result.reason?.trim() === "BadDeviceToken"; } +/** Decides whether a failed direct push should clear the persisted registration. */ export function shouldClearStoredApnsRegistration(params: { registration: ApnsRegistration; result: { status: number; reason?: string }; @@ -626,6 +640,7 @@ export function shouldClearStoredApnsRegistration(params: { return shouldInvalidateApnsRegistration(params.result); } +/** Resolves direct APNs provider auth from env, accepting inline or file-backed keys. */ export async function resolveApnsAuthConfigFromEnv( env: NodeJS.ProcessEnv = process.env, ): Promise { @@ -1064,6 +1079,7 @@ type RelayApnsExecApprovalResolvedParams = ApnsExecApprovalResolvedCommonParams requestSender?: never; }; +/** Sends a visible APNs alert via direct APNs token or relay registration. */ export async function sendApnsAlert( params: DirectApnsAlertParams | RelayApnsAlertParams, ): Promise { @@ -1097,6 +1113,7 @@ export async function sendApnsAlert( }); } +/** Sends a silent background wake via direct APNs token or relay registration. */ export async function sendApnsBackgroundWake( params: DirectApnsBackgroundWakeParams | RelayApnsBackgroundWakeParams, ): Promise { @@ -1129,6 +1146,7 @@ export async function sendApnsBackgroundWake( }); } +/** Sends an exec-approval alert notification via direct APNs or relay. */ export async function sendApnsExecApprovalAlert( params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams, ): Promise { @@ -1161,6 +1179,7 @@ export async function sendApnsExecApprovalAlert( }); } +/** Sends a silent wake telling the app an exec approval changed state. */ export async function sendApnsExecApprovalResolvedWake( params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams, ): Promise { diff --git a/src/security/secret-equal.ts b/src/security/secret-equal.ts index 69c60ff30a4..b197ffe08dd 100644 --- a/src/security/secret-equal.ts +++ b/src/security/secret-equal.ts @@ -9,6 +9,7 @@ function padSecretBytes(bytes: Buffer, length: number): Buffer { return padded; } +/** Compare two optional UTF-8 secrets without leaking length through timingSafeEqual errors. */ export function safeEqualSecret( provided: string | undefined | null, expected: string | undefined | null, diff --git a/src/shared/device-auth-store.ts b/src/shared/device-auth-store.ts index 948bb565231..28fcf2b2406 100644 --- a/src/shared/device-auth-store.ts +++ b/src/shared/device-auth-store.ts @@ -7,6 +7,7 @@ import { } from "./device-auth.js"; export type { DeviceAuthEntry, DeviceAuthStore } from "./device-auth.js"; +/** Storage seam used by shared device-auth helpers and filesystem-backed infra wrappers. */ export type DeviceAuthStoreAdapter = { readStore: () => DeviceAuthStore | null; writeStore: (store: DeviceAuthStore) => void; @@ -59,6 +60,7 @@ export function coerceDeviceAuthStore(value: unknown): DeviceAuthStore | null { }; } +/** Load one normalized role token, ignoring stores bound to a different gateway device id. */ export function loadDeviceAuthTokenFromStore(params: { adapter: DeviceAuthStoreAdapter; deviceId: string; @@ -72,6 +74,7 @@ export function loadDeviceAuthTokenFromStore(params: { return coerceDeviceAuthEntry(role, store.tokens[role]); } +/** Store one role token while preserving canonical tokens for the same gateway device id. */ export function storeDeviceAuthTokenInStore(params: { adapter: DeviceAuthStoreAdapter; deviceId: string; @@ -100,6 +103,7 @@ export function storeDeviceAuthTokenInStore(params: { return entry; } +/** Clear one normalized role token without rewriting missing or wrong-device stores. */ export function clearDeviceAuthTokenFromStore(params: { adapter: DeviceAuthStoreAdapter; deviceId: string; diff --git a/src/shared/device-auth.ts b/src/shared/device-auth.ts index 72ac4135858..4d2f92ed353 100644 --- a/src/shared/device-auth.ts +++ b/src/shared/device-auth.ts @@ -1,3 +1,4 @@ +/** Stored bearer token metadata for one authorized device role. */ export type DeviceAuthEntry = { token: string; role: string; @@ -5,16 +6,19 @@ export type DeviceAuthEntry = { updatedAtMs: number; }; +/** Versioned on-disk device-auth cache for a gateway device identity. */ export type DeviceAuthStore = { version: 1; deviceId: string; tokens: Record; }; +/** Normalize a device-auth role id without changing its case or namespace. */ export function normalizeDeviceAuthRole(role: string): string { return role.trim(); } +/** Normalize device-auth scopes, dedupe/sort them, and include implied operator scopes. */ export function normalizeDeviceAuthScopes(scopes: readonly unknown[] | undefined): string[] { if (!Array.isArray(scopes)) { return []; @@ -29,6 +33,7 @@ export function normalizeDeviceAuthScopes(scopes: readonly unknown[] | undefined out.add(trimmed); } } + // Operator scope implication keeps older approval checks working with broader grants. if (out.has("operator.admin")) { out.add("operator.read"); out.add("operator.write"); diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index cfecf1ddc2f..a0e2abdb920 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -1,15 +1,18 @@ import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js"; +/** Normalized roles/scopes carried by a bootstrap token during device handoff. */ export type DeviceBootstrapProfile = { roles: string[]; scopes: string[]; }; +/** Caller-provided bootstrap profile before role/scope normalization and bounding. */ export type DeviceBootstrapProfileInput = { roles?: readonly string[]; scopes?: readonly string[]; }; +/** Operator scopes allowed to cross the short-lived bootstrap handoff boundary. */ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ "operator.approvals", "operator.read", @@ -19,6 +22,7 @@ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES); +/** Default setup-code/QR bootstrap profile for native onboarding handoff. */ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { // QR/setup-code bootstrap must hand off both tokens for native onboarding: // iOS/Android suppress the operator loop while bootstrap auth is active and @@ -27,6 +31,7 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], }; +/** Return whether an input exactly matches the current setup-code bootstrap profile. */ export function isPairingSetupBootstrapProfile( input: DeviceBootstrapProfileInput | undefined, ): boolean { @@ -43,6 +48,7 @@ export function isPairingSetupBootstrapProfile( ); } +/** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */ export function resolveBootstrapProfileScopesForRole( role: string, scopes: readonly string[], @@ -55,6 +61,7 @@ export function resolveBootstrapProfileScopesForRole( return []; } +/** Resolve bounded bootstrap handoff scopes across a role set. */ export function resolveBootstrapProfileScopesForRoles( roles: readonly string[], scopes: readonly string[], @@ -64,6 +71,7 @@ export function resolveBootstrapProfileScopesForRoles( ); } +/** Normalize a requested bootstrap profile and strip scopes outside the handoff allowlist. */ export function normalizeDeviceBootstrapHandoffProfile( input: DeviceBootstrapProfileInput | undefined, ): DeviceBootstrapProfile { @@ -89,6 +97,7 @@ function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] return [...out].toSorted(); } +/** Normalize caller-provided bootstrap roles/scopes without applying handoff bounds. */ export function normalizeDeviceBootstrapProfile( input: DeviceBootstrapProfileInput | undefined, ): DeviceBootstrapProfile { diff --git a/src/talk/logging.ts b/src/talk/logging.ts index cc7b2bfca56..7561ec70a39 100644 --- a/src/talk/logging.ts +++ b/src/talk/logging.ts @@ -20,6 +20,7 @@ const OMITTED_TALK_LOG_EVENT_TYPES = new Set([ const TALK_LOGGER_BINDINGS = Object.freeze({ subsystem: "talk" }); +/** Converts high-level Talk events into compact structured log records, skipping noisy deltas. */ export function createTalkLogRecord(event: TalkEvent): TalkLogRecord | undefined { if (OMITTED_TALK_LOG_EVENT_TYPES.has(event.type)) { return undefined; @@ -57,6 +58,7 @@ export function createTalkLogRecord(event: TalkEvent): TalkLogRecord | undefined }; } +/** Emits Talk logs best-effort so logging failures never break realtime audio handling. */ export function recordTalkLogEvent(event: TalkEvent): void { const record = createTalkLogRecord(event); if (!record) { diff --git a/src/talk/session-log-runtime.ts b/src/talk/session-log-runtime.ts index b3ab9363eb3..9909573b1d8 100644 --- a/src/talk/session-log-runtime.ts +++ b/src/talk/session-log-runtime.ts @@ -1,12 +1,14 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { RealtimeVoiceBridgeEvent, RealtimeVoiceRole } from "./provider-types.js"; +/** Ring-buffer entry for transcript text used by Talk health and echo suppression. */ export type RealtimeVoiceTranscriptEntry = { at: string; role: RealtimeVoiceRole; text: string; }; +/** Compact health snapshot exposed to diagnostics without dumping full transcript history. */ export type RealtimeVoiceTranscriptHealth = { realtimeTranscriptLines: number; lastRealtimeTranscriptAt?: string; @@ -15,10 +17,12 @@ export type RealtimeVoiceTranscriptHealth = { recentRealtimeTranscript: RealtimeVoiceTranscriptEntry[]; }; +/** Bridge event plus capture time, kept separate from provider event payload shape. */ export type RealtimeVoiceBridgeEventLogEntry = RealtimeVoiceBridgeEvent & { at: string; }; +/** Compact health snapshot of recent realtime bridge events. */ export type RealtimeVoiceBridgeEventHealth = { lastRealtimeEventAt?: string; lastRealtimeEventType?: string; @@ -26,6 +30,7 @@ export type RealtimeVoiceBridgeEventHealth = { recentRealtimeEvents: RealtimeVoiceBridgeEventLogEntry[]; }; +/** Appends a transcript entry and trims old rows in-place to bound Talk diagnostics memory. */ export function recordRealtimeVoiceTranscript( transcript: RealtimeVoiceTranscriptEntry[], role: RealtimeVoiceRole, @@ -40,6 +45,7 @@ export function recordRealtimeVoiceTranscript( return entry; } +/** Summarizes transcript history for health endpoints and UI diagnostics. */ export function getRealtimeVoiceTranscriptHealth( transcript: RealtimeVoiceTranscriptEntry[], ): RealtimeVoiceTranscriptHealth { @@ -53,6 +59,7 @@ export function getRealtimeVoiceTranscriptHealth( }; } +/** Records low-volume bridge events while dropping raw audio chunks from diagnostics. */ export function recordRealtimeVoiceBridgeEvent( events: RealtimeVoiceBridgeEventLogEntry[], event: RealtimeVoiceBridgeEvent, @@ -67,6 +74,7 @@ export function recordRealtimeVoiceBridgeEvent( } } +/** Summarizes recent bridge events without exposing the full rolling event buffer. */ export function getRealtimeVoiceBridgeEventHealth( events: RealtimeVoiceBridgeEventLogEntry[], ): RealtimeVoiceBridgeEventHealth { @@ -102,6 +110,7 @@ function hasMeaningfulEchoOverlap(userTokens: string[], assistantTokens: string[ return overlap / uniqueUserTokens.length >= 0.58; } +/** Detects user transcript text that likely came from assistant speaker echo, not speech. */ export function isLikelyRealtimeVoiceAssistantEchoTranscript(params: { transcript: RealtimeVoiceTranscriptEntry[]; text: string; @@ -137,6 +146,7 @@ export function isLikelyRealtimeVoiceAssistantEchoTranscript(params: { ); } +/** Extends input suppression through the estimated playback tail for assistant audio. */ export function extendRealtimeVoiceOutputEchoSuppression(params: { audio: Buffer; bytesPerMs: number; diff --git a/src/utils.ts b/src/utils.ts index 6833c6a623f..58ad998156a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,14 +11,17 @@ import { isPlainObject } from "./infra/plain-object.js"; import { resolveTimerTimeoutMs } from "./shared/number-coercion.js"; export { escapeRegExp } from "./shared/regexp.js"; +/** Creates a directory tree if it does not already exist. */ export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); } +/** Clamps a number to an inclusive min/max range. */ export function clampNumber(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } +/** Floors a number before clamping it to an inclusive min/max range. */ export function clampInt(value: number, min: number, max: number): number { return clampNumber(Math.floor(value), min, max); } @@ -48,6 +51,7 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Normalizes phone-like input into the loose E.164 shape used by channel helpers. */ export function normalizeE164(number: string): string { const withoutPrefix = number.replace(/^[a-z][a-z0-9-]*:/i, "").trim(); const digits = withoutPrefix.replace(/[^\d+]/g, ""); @@ -57,6 +61,7 @@ export function normalizeE164(number: string): string { return `+${digits}`; } +/** Promise-based sleep that clamps timer inputs through the shared timeout resolver. */ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, resolveTimerTimeoutMs(ms, 0, 0))); } @@ -69,6 +74,7 @@ function isLowSurrogate(codeUnit: number): boolean { return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; } +/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */ export function sliceUtf16Safe(input: string, start: number, end?: number): string { const len = input.length; @@ -98,6 +104,7 @@ export function sliceUtf16Safe(input: string, start: number, end?: number): stri return input.slice(from, to); } +/** Truncates a UTF-16 string without cutting a surrogate pair in half. */ export function truncateUtf16Safe(input: string, maxLen: number): string { const limit = Math.max(0, Math.floor(maxLen)); if (input.length <= limit) { @@ -106,6 +113,7 @@ export function truncateUtf16Safe(input: string, maxLen: number): string { return sliceUtf16Safe(input, 0, limit); } +/** Resolves `~` and OpenClaw home-relative paths with injectable env/home sources. */ export function resolveUserPath( input: string, env: NodeJS.ProcessEnv = process.env, @@ -117,6 +125,7 @@ export function resolveUserPath( return resolveHomeRelativePath(input, { env, homedir }); } +/** Resolves the OpenClaw config directory from state/config env overrides or home. */ export function resolveConfigDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, @@ -141,6 +150,7 @@ export function resolveConfigDir( return newDir; } +/** Resolves the effective OpenClaw home directory, if one can be determined. */ export function resolveHomeDir(): string | undefined { return resolveEffectiveHomeDir(process.env, os.homedir); } @@ -157,6 +167,7 @@ function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefine return { home, prefix: "~" }; } +/** Replaces the leading home directory in a path with `~` or `$OPENCLAW_HOME`. */ export function shortenHomePath(input: string): string { if (!input) { return input; @@ -175,6 +186,7 @@ export function shortenHomePath(input: string): string { return input; } +/** Replaces all effective-home occurrences inside a diagnostic string. */ export function shortenHomeInString(input: string): string { if (!input) { return input; @@ -186,10 +198,12 @@ export function shortenHomeInString(input: string): string { return input.split(display.home).join(display.prefix); } +/** Shortens a path for display without changing non-home paths. */ export function displayPath(input: string): string { return shortenHomePath(input); } +/** Shortens home paths embedded in arbitrary display text. */ export function displayString(input: string): string { return shortenHomeInString(input); } diff --git a/src/utils/mask-api-key.ts b/src/utils/mask-api-key.ts index 4b0a1511d42..69ff0e201d4 100644 --- a/src/utils/mask-api-key.ts +++ b/src/utils/mask-api-key.ts @@ -1,3 +1,4 @@ +/** Masks credential-like values for diagnostics while preserving enough prefix/suffix to identify them. */ export const maskApiKey = (value: string): string => { const trimmed = value.trim(); if (!trimmed) { diff --git a/src/utils/run-with-concurrency.ts b/src/utils/run-with-concurrency.ts index 7f1c500c67f..d130a89fc61 100644 --- a/src/utils/run-with-concurrency.ts +++ b/src/utils/run-with-concurrency.ts @@ -1,5 +1,7 @@ +/** Controls whether the worker pool keeps scheduling after a task failure. */ export type ConcurrencyErrorMode = "continue" | "stop"; +/** Runs async tasks with bounded concurrency while preserving result indexes. */ export async function runTasksWithConcurrency(params: { tasks: Array<() => Promise>; limit: number; diff --git a/src/utils/timer-delay.ts b/src/utils/timer-delay.ts index 86091cca852..9b6146ed224 100644 --- a/src/utils/timer-delay.ts +++ b/src/utils/timer-delay.ts @@ -7,6 +7,7 @@ export { resolveSafeTimeoutDelayMs, } from "../../packages/gateway-client/src/timeouts.js"; +/** Wrapper around setTimeout that clamps unsafe or invalid delays before arming the timer. */ export function setSafeTimeout( callback: () => void, delayMs: number,