diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index f6a466b8cde3..d82aea973b8c 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -5587,7 +5587,7 @@ }, { "kind": "conditional-branch", - "line": 340, + "line": 346, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Listening", "surface": "android", @@ -5595,7 +5595,7 @@ }, { "kind": "conditional-branch", - "line": 340, + "line": 346, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Ready", "surface": "android", @@ -5603,7 +5603,7 @@ }, { "kind": "conditional-branch", - "line": 700, + "line": 706, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Off", "surface": "android", @@ -5611,7 +5611,7 @@ }, { "kind": "conditional-branch", - "line": 701, + "line": 707, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: Realtime provider closed unexpectedly.", "surface": "android", @@ -5619,7 +5619,7 @@ }, { "kind": "conditional-branch", - "line": 702, + "line": 708, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: Realtime provider closed: $reason", "surface": "android", @@ -5627,7 +5627,7 @@ }, { "kind": "conditional-branch", - "line": 1611, + "line": 1617, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Aborted", "surface": "android", @@ -5635,7 +5635,7 @@ }, { "kind": "conditional-branch", - "line": 1611, + "line": 1617, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Chat error", "surface": "android", @@ -11051,7 +11051,7 @@ }, { "kind": "conditional-branch", - "line": 2545, + "line": 2603, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -11059,7 +11059,7 @@ }, { "kind": "conditional-branch", - "line": 2545, + "line": 2603, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -11067,7 +11067,7 @@ }, { "kind": "conditional-branch", - "line": 3100, + "line": 3158, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -11075,7 +11075,7 @@ }, { "kind": "conditional-branch", - "line": 3100, + "line": 3158, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -11083,7 +11083,7 @@ }, { "kind": "conditional-branch", - "line": 3104, + "line": 3162, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -11091,7 +11091,7 @@ }, { "kind": "conditional-branch", - "line": 3104, + "line": 3162, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -11099,7 +11099,7 @@ }, { "kind": "conditional-branch", - "line": 3392, + "line": 3450, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -11107,7 +11107,7 @@ }, { "kind": "conditional-branch", - "line": 3392, + "line": 3450, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", 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 cca8a9232c3d..319c2a85b7b2 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 @@ -1378,7 +1378,7 @@ class NodeRuntime( refreshExecApprovalsFromGateway() } } else { - stopManualVoiceSession() + stopActiveVoiceSession() publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true) } } @@ -1877,7 +1877,7 @@ class NodeRuntime( private fun stopActiveVoiceSession() { talkMode.ttsOnAllResponses = false - talkMode.setEnabled(false) + talkMode.stopAllCapture() stopVoicePlayback() micCapture.setMicEnabled(false) prefs.setVoiceMicEnabled(false) 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 9e6daae6d50c..2cd87ac980fe 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 @@ -37,12 +37,18 @@ internal data class CalendarAddRequest( val startMs: Long, val endMs: Long, val isAllDay: Boolean, + val timeZoneId: String, val location: String?, val notes: String?, val calendarId: Long?, val calendarTitle: String?, ) +private data class CalendarAddRange( + val start: Instant, + val end: Instant, +) + /** * Normalized calendar event returned through gateway calendar commands. */ @@ -148,7 +154,7 @@ private object SystemCalendarDataSource : CalendarDataSource { put(CalendarContract.Events.DTSTART, request.startMs) put(CalendarContract.Events.DTEND, request.endMs) put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0) - put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + put(CalendarContract.Events.EVENT_TIMEZONE, request.timeZoneId) request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) } } @@ -393,11 +399,14 @@ class CalendarHandler private constructor( val end = parseISO((params["endISO"] as? JsonPrimitive)?.content) ?: return null + val isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false + val addRange = normalizeAddRange(start, end, isAllDay) return CalendarAddRequest( title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(), - startMs = start.toEpochMilli(), - endMs = end.toEpochMilli(), - isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false, + startMs = addRange.start.toEpochMilli(), + endMs = addRange.end.toEpochMilli(), + isAllDay = isAllDay, + timeZoneId = if (isAllDay) "UTC" else TimeZone.getDefault().id, location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }, notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }, calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(), @@ -405,6 +414,20 @@ class CalendarHandler private constructor( ) } + private fun normalizeAddRange( + start: Instant, + end: Instant, + isAllDay: Boolean, + ): CalendarAddRange { + if (!isAllDay || end <= start) return CalendarAddRange(start = start, end = end) + val dayStart = start.truncatedTo(ChronoUnit.DAYS) + val dayEnd = end.truncatedTo(ChronoUnit.DAYS) + return CalendarAddRange( + start = dayStart, + end = if (dayEnd > dayStart) dayEnd else dayStart.plus(1, ChronoUnit.DAYS), + ) + } + private fun parseISO(raw: String?): Instant? { val value = raw?.trim().orEmpty() if (value.isEmpty()) return null 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 90bbbac82ac0..8d099e40bf90 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 @@ -240,6 +240,12 @@ class TalkModeManager internal constructor( } } + /** Stops continuous, one-shot, or push-to-talk capture regardless of the enabled flag. */ + fun stopAllCapture() { + _isEnabled.value = false + stop() + } + /** Starts a push-to-talk capture session for gateway node.invoke callers. */ suspend fun beginPushToTalk(allowNewCapture: Boolean): TalkPttStartPayload { if (!allowNewCapture) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 98f5685ebe08..2b43881e84a7 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -666,6 +666,46 @@ class GatewayBootstrapAuthTest { assertFalse(readField>(runtime, "externalAudioCaptureActive").value) } + @Test + fun backgroundingStopsTalkModeCapture() { + val app = RuntimeEnvironment.getApplication() + val runtime = NodeRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + readField>(runtime, "_voiceCaptureMode").value = VoiceCaptureMode.TalkMode + readField>(talkMode, "_isEnabled").value = true + readField>(runtime, "externalAudioCaptureActive").value = true + talkMode.ttsOnAllResponses = true + + assertEquals(VoiceCaptureMode.TalkMode, runtime.voiceCaptureMode.value) + assertTrue(talkMode.isEnabled.value) + assertTrue(readField>(runtime, "externalAudioCaptureActive").value) + + runtime.setForeground(false) + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(talkMode.isEnabled.value) + assertFalse(talkMode.ttsOnAllResponses) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } + + @Test + fun backgroundingStopsGatewayPttWhenVoiceModeIsOff() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = NodeRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + writeField(talkMode, "activePttCaptureId", "capture-1") + readField>(runtime, "externalAudioCaptureActive").value = true + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + + runtime.setForeground(false) + + assertNull(readField(talkMode, "activePttCaptureId")) + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } + private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt { repeat(50) { runtime.pendingGatewayTrust.value?.let { return it } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt index 3e0aa6532070..edfadc4b4b24 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt @@ -9,6 +9,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.time.Instant +import java.util.TimeZone class CalendarHandlerTest : NodeHandlerRobolectricTest() { @Test @@ -86,6 +88,58 @@ class CalendarHandlerTest : NodeHandlerRobolectricTest() { assertFalse(result.ok) assertEquals("CALENDAR_NOT_FOUND", result.error?.code) } + + @Test + fun handleCalendarAdd_normalizesAllDayEventForAndroidProvider() { + val source = FakeCalendarDataSource(canRead = true, canWrite = true) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Holiday","startISO":"2026-07-05T09:00:00Z","endISO":"2026-07-06T09:00:00Z","isAllDay":true}""", + ) + + assertTrue(result.ok) + val request = source.addedRequest ?: error("missing add request") + assertTrue(request.isAllDay) + assertEquals("UTC", request.timeZoneId) + assertEquals(Instant.parse("2026-07-05T00:00:00Z").toEpochMilli(), request.startMs) + assertEquals(Instant.parse("2026-07-06T00:00:00Z").toEpochMilli(), request.endMs) + } + + @Test + fun handleCalendarAdd_expandsSameDayAllDayRangeToOneDay() { + val source = FakeCalendarDataSource(canRead = true, canWrite = true) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Holiday","startISO":"2026-07-05T09:00:00Z","endISO":"2026-07-05T17:00:00Z","isAllDay":true}""", + ) + + assertTrue(result.ok) + val request = source.addedRequest ?: error("missing add request") + assertEquals(Instant.parse("2026-07-05T00:00:00Z").toEpochMilli(), request.startMs) + assertEquals(Instant.parse("2026-07-06T00:00:00Z").toEpochMilli(), request.endMs) + } + + @Test + fun handleCalendarAdd_preservesTimedInstantsAndDeviceTimezone() { + val source = FakeCalendarDataSource(canRead = true, canWrite = true) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Call","startISO":"2026-07-05T09:15:00Z","endISO":"2026-07-05T10:45:00Z"}""", + ) + + assertTrue(result.ok) + val request = source.addedRequest ?: error("missing add request") + assertFalse(request.isAllDay) + assertEquals(TimeZone.getDefault().id, request.timeZoneId) + assertEquals(Instant.parse("2026-07-05T09:15:00Z").toEpochMilli(), request.startMs) + assertEquals(Instant.parse("2026-07-05T10:45:00Z").toEpochMilli(), request.endMs) + } } private class FakeCalendarDataSource( @@ -104,6 +158,9 @@ private class FakeCalendarDataSource( ), private val addError: Throwable? = null, ) : CalendarDataSource { + var addedRequest: CalendarAddRequest? = null + private set + override fun hasReadPermission(context: Context): Boolean = canRead override fun hasWritePermission(context: Context): Boolean = canWrite @@ -118,6 +175,7 @@ private class FakeCalendarDataSource( request: CalendarAddRequest, ): CalendarEventRecord { addError?.let { throw it } + addedRequest = request return addResult } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt index 05f13e7cd46b..0e287e9c3214 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -82,6 +83,20 @@ class TalkModeManagerTest { assertEquals("capture-1", payload.captureId) } + @Test + fun stopAllCaptureClearsPttWhenContinuousModeIsDisabled() { + val manager = createManager() + setPrivateField(manager, "activePttCaptureId", "capture-1") + setMutableStateFlow(manager, "_isListening", true) + + manager.stopAllCapture() + + assertNull(readPrivateField(manager, "activePttCaptureId")) + assertFalse(manager.isEnabled.value) + assertFalse(manager.isListening.value) + assertEquals("Off", manager.statusText.value) + } + @Test fun duplicateFinalForPendingTalkRunDoesNotStartAllResponseTts() { val manager = createManager() diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ff190f679e92..84002836a66c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -261,7 +261,11 @@ final class NodeAppModel { #if DEBUG @ObservationIgnored private var testAgentRequestHandler: ((AgentDeepLink) async throws -> Void)? #endif - private var pttVoiceWakeSuspended = false + private var pttVoiceWakeLeaseCount = 0 + private var pttVoiceWakeWasSuspended = false + private var pttSessionOwnsVoiceWakeLease = false + private var talkInvokeInFlight = false + private var talkInvokeWaiters: [CheckedContinuation] = [] private var talkVoiceWakeSuspended = false private var backgroundVoiceWakeSuspended = false private var backgroundTalkSuspended = false @@ -1803,31 +1807,50 @@ final class NodeAppModel { } private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + if req.command == OpenClawTalkCommand.pttOnce.rawValue { + self.acquirePttVoiceWakeLease() + defer { self.releasePttVoiceWakeLease() } + let payload = try await talkMode.runPushToTalkOnce() + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } + + await self.acquireTalkInvoke() + defer { self.releaseTalkInvoke() } + switch req.command { case OpenClawTalkCommand.pttStart.rawValue: - self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - let payload = try await talkMode.beginPushToTalk() + let acquiredLease = !self.pttSessionOwnsVoiceWakeLease + if acquiredLease { + self.acquirePttVoiceWakeLease() + self.pttSessionOwnsVoiceWakeLease = true + } + let payload: OpenClawTalkPTTStartPayload + do { + payload = try await self.talkMode.beginPushToTalk() + } catch { + if acquiredLease { + self.pttSessionOwnsVoiceWakeLease = false + self.releasePttVoiceWakeLease() + } + throw error + } let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawTalkCommand.pttStop.rawValue: let payload = await talkMode.endPushToTalk() - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false + if self.pttSessionOwnsVoiceWakeLease { + self.pttSessionOwnsVoiceWakeLease = false + self.releasePttVoiceWakeLease() + } let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawTalkCommand.pttCancel.rawValue: let payload = await talkMode.cancelPushToTalk() - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttOnce.rawValue: - self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - defer { - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false + if self.pttSessionOwnsVoiceWakeLease { + self.pttSessionOwnsVoiceWakeLease = false + self.releasePttVoiceWakeLease() } - let payload = try await talkMode.runPushToTalkOnce() let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) default: @@ -1837,6 +1860,41 @@ final class NodeAppModel { error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) } } + + private func acquirePttVoiceWakeLease() { + if self.pttVoiceWakeLeaseCount == 0 { + self.pttVoiceWakeWasSuspended = self.voiceWake.suspendForExternalAudioCapture() + } + self.pttVoiceWakeLeaseCount += 1 + } + + private func releasePttVoiceWakeLease() { + guard self.pttVoiceWakeLeaseCount > 0 else { return } + self.pttVoiceWakeLeaseCount -= 1 + guard self.pttVoiceWakeLeaseCount == 0 else { return } + // Overlapping one-shot and session PTT captures share one Voice Wake suspension. + // Resume only after the final owner releases it or microphone capture can overlap. + self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeWasSuspended) + self.pttVoiceWakeWasSuspended = false + } + + private func acquireTalkInvoke() async { + if !self.talkInvokeInFlight { + self.talkInvokeInFlight = true + return + } + await withCheckedContinuation { continuation in + self.talkInvokeWaiters.append(continuation) + } + } + + private func releaseTalkInvoke() { + guard !self.talkInvokeWaiters.isEmpty else { + self.talkInvokeInFlight = false + return + } + self.talkInvokeWaiters.removeFirst().resume() + } } extension NodeAppModel { @@ -6675,6 +6733,14 @@ extension NodeAppModel { await self.handleInvoke(req, gatewayStableID: gatewayStableID) } + func _test_acquirePttVoiceWakeLease() { + self.acquirePttVoiceWakeLease() + } + + func _test_releasePttVoiceWakeLease() { + self.releasePttVoiceWakeLease() + } + static func _test_decodeParams(_ type: T.Type, from json: String?) throws -> T { try self.decodeParams(type, from: json) } diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index c4aa1d9c2c95..c2adb9fe859e 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -550,6 +550,10 @@ extension VoiceWakeManager { self.isStarting = isStarting } + func _test_isSuspendedForExternalAudio() -> Bool { + self.isSuspendedForExternalAudio + } + func _test_waitForScheduledStart() async { let task = self.scheduledStartTask await task?.value diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 12ad985f50bf..8d666e429aee 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -688,6 +688,42 @@ private actor WatchSnapshotSendGate { #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) } + @Test @MainActor func `failed PTT start restores voice wake suspension`() async { + let talkMode = TalkModeManager(allowSimulatorCapture: true) + let appModel = NodeAppModel(talkMode: talkMode) + appModel.voiceWake.isEnabled = true + appModel.voiceWake.isListening = true + appModel.voiceWake.statusText = "Listening" + + let request = BridgeInvokeRequest( + id: "ptt-start", + command: OpenClawTalkCommand.pttStart.rawValue) + let response = await appModel._test_handleInvoke(request) + + #expect(response.ok == false) + #expect(response.error?.message.contains("Gateway not connected") == true) + #expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == false) + appModel.voiceWake.stop() + } + + @Test @MainActor func `overlapping PTT owners keep voice wake suspended until final release`() { + let appModel = NodeAppModel(talkMode: TalkModeManager(allowSimulatorCapture: true)) + appModel.voiceWake.isEnabled = true + appModel.voiceWake.isListening = true + appModel.voiceWake.statusText = "Listening" + + appModel._test_acquirePttVoiceWakeLease() + appModel._test_acquirePttVoiceWakeLease() + #expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == true) + + appModel._test_releasePttVoiceWakeLease() + #expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == true) + + appModel._test_releasePttVoiceWakeLease() + #expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == false) + appModel.voiceWake.stop() + } + @Test @MainActor func `late watch snapshot is repaired after gateway switch`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 08c4bc9da82b..e84567bbb290 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -47,6 +47,8 @@ For `ssh` and OpenShell `remote`, recreate matters more than with Docker: the re Inspect the effective sandbox mode/scope/workspace access, sandbox tool policy, and elevated-tool gates (with fix-it config key paths). +The report keeps `workspaceRoot` as the configured sandbox root and separately shows the effective host workspace, backend runtime workdir, and Docker mount table. For `workspaceAccess: "rw"`, the effective host workspace is the agent workspace rather than a directory below `workspaceRoot`. + ```bash openclaw sandbox explain openclaw sandbox explain --session agent:main:main diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index d9e8934585b0..f4dc9e0c1f73 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -360,7 +360,7 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied g Debugging: - `openclaw sandbox list` shows sandbox containers, status, image match, age, idle time, and associated session/agent. -- `openclaw sandbox explain [--session ] [--agent ]` inspects effective sandbox mode, tool policy, and fix-it config keys. +- `openclaw sandbox explain [--session ] [--agent ]` inspects effective sandbox mode, host workspace, runtime workdir, Docker mounts, tool policy, and fix-it config keys. Its `workspaceRoot` field remains the configured sandbox root; `effectiveHostWorkspaceRoot` shows where the active workspace actually lives. - `openclaw sandbox recreate [--all | --session | --agent ] [--browser] [--force]` removes containers/environments so they get recreated with current config on next use. - See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for the "why is this blocked?" mental model. diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index c8a2101418aa..5488dbc76950 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -1371,6 +1371,55 @@ describe("google transport stream", () => { }); }); + it("rejects oversized authorized_user ADC token responses", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-large-")); + const credentialsPath = path.join(tempDir, "application_default_credentials.json"); + await writeFile( + credentialsPath, + JSON.stringify({ + type: "authorized_user", + client_id: "client-id", + client_secret: "client-secret", + refresh_token: "large-refresh-token", + }), + "utf8", + ); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); + const tokenFetchMock = vi + .fn() + .mockResolvedValue(new Response("x".repeat(1024 * 1024 + 1), { status: 200 })); + + await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).rejects.toThrow( + "Google OAuth token response exceeds 1048576 bytes", + ); + }); + + it("rejects authorized_user ADC gzip responses that expand past the limit", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-bomb-")); + const credentialsPath = path.join(tempDir, "application_default_credentials.json"); + await writeFile( + credentialsPath, + JSON.stringify({ + type: "authorized_user", + client_id: "client-id", + client_secret: "client-secret", + refresh_token: "bomb-refresh-token", + }), + "utf8", + ); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); + const tokenFetchMock = vi.fn().mockResolvedValue( + new Response(gzipSync("x".repeat(1024 * 1024 + 1)), { + status: 200, + headers: { "content-encoding": "gzip" }, + }), + ); + + await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).rejects.toThrow( + "Google OAuth token response exceeds 1048576 decompressed bytes", + ); + }); + it("does not reuse authorized_user ADC tokens with unsafe expiry lifetimes", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-unsafe-adc-")); const credentialsPath = path.join(tempDir, "application_default_credentials.json"); diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts index 9bf2e45c7998..2300fa5de53c 100644 --- a/extensions/google/vertex-adc.ts +++ b/extensions/google/vertex-adc.ts @@ -9,6 +9,7 @@ import { resolveExpiresAtMsFromDurationMs, resolveExpiresAtMsFromDurationSeconds, } from "openclaw/plugin-sdk/number-runtime"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type GoogleAuthorizedUserCredentials = { @@ -46,6 +47,7 @@ const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platfor const GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS = 60_000; const GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600; const GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS = 5 * 60_000; +const GOOGLE_OAUTH_TOKEN_RESPONSE_MAX_BYTES = 1024 * 1024; let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined; let cachedGoogleAuthClient: @@ -277,7 +279,10 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: { async function readGoogleOauthTokenResponsePayload( response: Response, ): Promise { - const bytes = Buffer.from(await response.arrayBuffer()); + const bytes = await readResponseWithLimit(response, GOOGLE_OAUTH_TOKEN_RESPONSE_MAX_BYTES, { + onOverflow: ({ maxBytes }) => + new Error(`Google OAuth token response exceeds ${maxBytes} bytes`), + }); const text = decodeGoogleOauthTokenResponseBody(bytes, response.headers.get("content-encoding")); if (!text.trim()) { return undefined; @@ -292,8 +297,21 @@ async function readGoogleOauthTokenResponsePayload( function decodeGoogleOauthTokenResponseBody(bytes: Buffer, contentEncoding: string | null): string { if (shouldGunzipGoogleOauthTokenResponse(bytes, contentEncoding)) { try { - return gunzipSync(bytes).toString("utf8"); - } catch { + return gunzipSync(bytes, { maxOutputLength: GOOGLE_OAUTH_TOKEN_RESPONSE_MAX_BYTES }).toString( + "utf8", + ); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ERR_BUFFER_TOO_LARGE" + ) { + throw new Error( + `Google OAuth token response exceeds ${GOOGLE_OAUTH_TOKEN_RESPONSE_MAX_BYTES} decompressed bytes`, + { cause: error }, + ); + } return bytes.toString("utf8"); } } diff --git a/src/agents/agent-bundle-lsp-runtime.test.ts b/src/agents/agent-bundle-lsp-runtime.test.ts index 209b70f77be6..bc52c4f4cc22 100644 --- a/src/agents/agent-bundle-lsp-runtime.test.ts +++ b/src/agents/agent-bundle-lsp-runtime.test.ts @@ -47,7 +47,10 @@ class MockChildProcess extends EventEmitter { readonly stderr = new PassThrough(); readonly stdin: Writable; - constructor(private readonly initializeResponsePrefix = "") { + constructor( + private readonly initializeResponsePrefix = "", + private readonly respondMethods?: ReadonlySet, + ) { super(); this.stdin = new Writable({ write: (chunk, _encoding, callback) => { @@ -70,6 +73,9 @@ class MockChildProcess extends EventEmitter { if (!body || typeof body.id !== "number" || typeof body.method !== "string") { return; } + if (this.respondMethods && !this.respondMethods.has(body.method)) { + return; + } const result = body.method === "initialize" ? { capabilities: { hoverProvider: true } } : null; queueMicrotask(() => { this.stdout.write( @@ -138,6 +144,102 @@ describe("bundle LSP runtime", () => { expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 }); }); + it.each([ + { + name: "stdout fails", + fail: (child: MockChildProcess) => child.stdout.emit("error", new Error("stdout failed")), + message: "stdout failed", + }, + { + name: "stdin fails", + fail: (child: MockChildProcess) => child.stdin.emit("error", new Error("stdin failed")), + message: "stdin failed", + }, + ])("rejects pending and future LSP requests when $name", async ({ fail, message }) => { + configureSingleLspServer(); + const child = new MockChildProcess("", new Set(["initialize"])); + spawnMock.mockReturnValue(child); + const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js"); + + const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); + const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); + if (!hoverTool) { + throw new Error("expected hover tool"); + } + + const hoverParams = { + uri: "file:///tmp/workspace/index.ts", + line: 0, + character: 0, + }; + const request = hoverTool.execute("call-1", hoverParams); + fail(child); + + await expect(request).rejects.toThrow(message); + await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow(message); + + await runtime.dispose(); + }); + + it("blocks new LSP requests on exit while allowing a final stdout response to drain", async () => { + configureSingleLspServer(); + const child = new MockChildProcess("", new Set(["initialize"])); + spawnMock.mockReturnValue(child); + const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js"); + + const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); + const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); + if (!hoverTool) { + throw new Error("expected hover tool"); + } + const hoverParams = { + uri: "file:///tmp/workspace/index.ts", + line: 0, + character: 0, + }; + const pendingRequest = hoverTool.execute("call-1", hoverParams); + + child.exitCode = 1; + child.emit("exit", 1, null); + await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow( + 'LSP server "typescript" exited (1)', + ); + child.stdout.write( + encodeLspMessage({ jsonrpc: "2.0", id: 2, result: { contents: "final hover" } }), + ); + + await expect(pendingRequest).resolves.toMatchObject({ + details: { lspServer: "typescript", lspMethod: "hover" }, + }); + child.emit("close", 1, null); + await runtime.dispose(); + }); + + it("rejects undrained LSP requests when the exited process closes", async () => { + configureSingleLspServer(); + const child = new MockChildProcess("", new Set(["initialize"])); + spawnMock.mockReturnValue(child); + const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js"); + + const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); + const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); + if (!hoverTool) { + throw new Error("expected hover tool"); + } + const request = hoverTool.execute("call-1", { + uri: "file:///tmp/workspace/index.ts", + line: 0, + character: 0, + }); + + child.exitCode = 1; + child.emit("exit", 1, null); + child.emit("close", 1, null); + + await expect(request).rejects.toThrow('LSP server "typescript" exited (1)'); + await runtime.dispose(); + }); + it("keeps LSP framing aligned after multibyte messages in the same chunk", async () => { configureSingleLspServer(); const prefix = encodeLspMessage({ diff --git a/src/agents/agent-bundle-lsp-runtime.ts b/src/agents/agent-bundle-lsp-runtime.ts index 9f3090bde112..574666c51d7c 100644 --- a/src/agents/agent-bundle-lsp-runtime.ts +++ b/src/agents/agent-bundle-lsp-runtime.ts @@ -30,7 +30,7 @@ type LspSession = { initialized: boolean; capabilities: LspServerCapabilities; disposed: boolean; - // Preserve a spawn failure so requests created after the event reject immediately + // Preserve a terminal process/transport failure so later requests reject immediately // instead of waiting for the per-request timeout. failure?: Error; }; @@ -110,24 +110,56 @@ function registerActiveLspSession(session: LspSession): void { activeBundleLspSessions.add(session); } +function rememberLspFailure(session: LspSession, error: Error): void { + session.failure ??= error; +} + +function failLspSession(session: LspSession, error: Error): void { + rememberLspFailure(session, error); + for (const pending of session.pendingRequests.values()) { + clearTimeout(pending.timeout); + pending.reject(session.failure ?? error); + } + session.pendingRequests.clear(); +} + +function lspProcessExitError( + session: LspSession, + code: number | null, + signal: NodeJS.Signals | null, +) { + return new Error(`LSP server "${session.serverName}" exited (${signal ?? code ?? "unknown"})`); +} + function attachLspProcessHandlers(session: LspSession): void { session.process.on("error", (error) => { - session.failure = error; - for (const pending of session.pendingRequests.values()) { - clearTimeout(pending.timeout); - pending.reject(error); - } - session.pendingRequests.clear(); + failLspSession(session, error); + }); + session.process.on("exit", (code, signal) => { + // Block new requests immediately, but let stdout drain any final response before close. + rememberLspFailure(session, lspProcessExitError(session, code, signal)); + }); + session.process.on("close", (code, signal) => { + failLspSession(session, lspProcessExitError(session, code, signal)); }); session.process.stdout?.on("data", (chunk: Buffer | string) => handleIncomingData(session, chunk), ); + session.process.stdout?.on("error", (error) => { + failLspSession(session, error); + }); + session.process.stdin?.on("error", (error) => { + failLspSession(session, error); + }); session.process.stderr?.setEncoding("utf-8"); session.process.stderr?.on("data", (chunk: string) => { for (const line of chunk.split(/\r?\n/).filter(Boolean)) { logDebug(`bundle-lsp:${session.serverName}: ${line.trim()}`); } }); + session.process.stderr?.on("error", (error) => { + logWarn(`bundle-lsp:${session.serverName}: stderr failed: ${String(error)}`); + }); } function encodeLspMessage(body: unknown): string { diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 22f1e7640d5b..427d3f41a9e4 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -4,7 +4,6 @@ * Prepares workspace layout, backend handle, filesystem bridge, browser bridge, and registry state for one run. */ import fs from "node:fs/promises"; -import path from "node:path"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { ensureBrowserControlAuth, @@ -16,18 +15,14 @@ import { } from "../../plugin-sdk/browser-profiles.js"; import { defaultRuntime } from "../../runtime.js"; import type { SkillEligibilityContext } from "../../skills/types.js"; -import { resolveUserPath } from "../../utils.js"; -import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from "./backend.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; -import { SANDBOX_STATE_DIR } from "./constants.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { updateRegistry } from "./registry.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; -import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; +import { resolveSandboxWorkspaceLayoutPaths } from "./shared.js"; import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js"; -import { resolveMaterializedSandboxSkillsWorkspaceDir } from "./workspace-mounts.js"; import { ensureSandboxWorkspace } from "./workspace.js"; async function syncSandboxSkillsToWorkspace(params: { @@ -83,23 +78,12 @@ async function ensureSandboxWorkspaceLayout(params: { workspaceDir: string; }> { const { cfg, rawSessionKey } = params; - - const agentWorkspaceDir = resolveUserPath( - params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR, - ); - const workspaceRoot = resolveUserPath(cfg.workspaceRoot); - const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); - const sandboxWorkspaceDir = - cfg.scope === "shared" ? workspaceRoot : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); - const workspaceDir = cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir; - const materializedSkillsRoot = resolveSandboxWorkspaceDir( - path.join(SANDBOX_STATE_DIR, "skills-workspaces"), - scopeKey, - ); - const skillsWorkspaceDir = - cfg.workspaceAccess === "rw" - ? resolveMaterializedSandboxSkillsWorkspaceDir(materializedSkillsRoot) - : sandboxWorkspaceDir; + const { agentWorkspaceDir, sandboxWorkspaceDir, scopeKey, skillsWorkspaceDir, workspaceDir } = + resolveSandboxWorkspaceLayoutPaths({ + cfg, + rawSessionKey, + workspaceDir: params.workspaceDir, + }); let skillsEligibility: SkillEligibilityContext | undefined; if (cfg.workspaceAccess !== "rw") { diff --git a/src/agents/sandbox/shared.ts b/src/agents/sandbox/shared.ts index 3d25ad275f34..14f5f1562478 100644 --- a/src/agents/sandbox/shared.ts +++ b/src/agents/sandbox/shared.ts @@ -8,7 +8,11 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { resolveAgentIdFromSessionKey } from "../agent-scope.js"; +import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; +import { SANDBOX_STATE_DIR } from "./constants.js"; import { hashTextSha256 } from "./hash.js"; +import type { SandboxConfig } from "./types.js"; +import { resolveMaterializedSandboxSkillsWorkspaceDir } from "./workspace-mounts.js"; /** Converts an arbitrary session key into a bounded filesystem/container-safe slug. */ export function slugifySessionKey(value: string) { @@ -53,3 +57,39 @@ export function resolveSandboxAgentId(scopeKey: string): string | undefined { } return resolveAgentIdFromSessionKey(trimmed); } + +/** Resolves the host-side workspace paths shared by diagnostics and runtime setup. */ +export function resolveSandboxWorkspaceLayoutPaths(params: { + cfg: Pick; + rawSessionKey: string; + workspaceDir?: string; +}) { + const agentWorkspaceDir = resolveUserPath( + params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR, + ); + const workspaceRoot = resolveUserPath(params.cfg.workspaceRoot); + const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.rawSessionKey); + const sandboxWorkspaceDir = + params.cfg.scope === "shared" + ? workspaceRoot + : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); + const workspaceDir = + params.cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir; + const materializedSkillsRoot = resolveSandboxWorkspaceDir( + path.join(SANDBOX_STATE_DIR, "skills-workspaces"), + scopeKey, + ); + const skillsWorkspaceDir = + params.cfg.workspaceAccess === "rw" + ? resolveMaterializedSandboxSkillsWorkspaceDir(materializedSkillsRoot) + : sandboxWorkspaceDir; + + return { + agentWorkspaceDir, + scopeKey, + sandboxWorkspaceDir, + skillsWorkspaceDir, + workspaceDir, + workspaceSource: params.cfg.workspaceAccess === "rw" ? "agent" : "sandbox", + } as const; +} diff --git a/src/agents/tools/common.params.test.ts b/src/agents/tools/common.params.test.ts index a9e1c00217fd..8da1795808b7 100644 --- a/src/agents/tools/common.params.test.ts +++ b/src/agents/tools/common.params.test.ts @@ -166,6 +166,8 @@ describe("readNumberParam", () => { it("throws for invalid present bounded finite number params", () => { expect(readFiniteNumberParam({ quality: "0.75" }, "quality")).toBe(0.75); expect(readFiniteNumberParam({ quality: null }, "quality")).toBeUndefined(); + expect(readFiniteNumberParam({ quality: "" }, "quality")).toBeUndefined(); + expect(readFiniteNumberParam({ quality: " \t\n" }, "quality")).toBeUndefined(); expect(() => readFiniteNumberParam({ quality: "0.8jpg" }, "quality")).toThrow( "quality must be a finite number", ); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 35b3092bc50b..a754c62f0f33 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -301,7 +301,8 @@ export function readFiniteNumberParam( strict: true, }); if (value === undefined) { - if (readParamRaw(params, key) != null) { + const raw = readParamRaw(params, key); + if (raw != null && !isBlankParamValue(raw)) { throw new ToolInputError(options.message ?? `${key} must be a finite number`); } return undefined; diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts index 6e2c64363879..d108f434cba0 100644 --- a/src/commands/sandbox-explain.test.ts +++ b/src/commands/sandbox-explain.test.ts @@ -1,4 +1,7 @@ // Sandbox explain tests cover command output for sandbox browser and container diagnostics. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { sandboxExplainCommand } from "./sandbox-explain.js"; @@ -99,4 +102,319 @@ describe("sandbox explain command", () => { key: "agents.list[].tools.sandbox.tools.alsoAllow", }); }); + + it("reports the effective rw workspace and Docker mount without changing workspaceRoot", async () => { + mockCfg = { + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, + }, + list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], + }, + session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" }, + }; + + const logs: string[] = []; + await sandboxExplainCommand({ json: true, agent: "builder" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + const agentWorkspace = path.resolve("/tmp/openclaw-agent-workspace"); + expect(parsed.sandbox.workspaceRoot).toBe("/tmp/openclaw-sandboxes"); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe(agentWorkspace); + expect(parsed.sandbox.runtimeWorkdir).toBe("/workspace"); + expect(parsed.sandbox.workspaceSource).toBe("agent"); + expect(parsed.sandbox.workspaceMounts).toEqual([ + { + hostRoot: agentWorkspace, + containerRoot: "/workspace", + writable: true, + source: "workspace", + }, + ]); + }); + + it("uses the canonical derived workspace for non-default agents", async () => { + mockCfg = { + agents: { + defaults: { + workspace: "/tmp/openclaw-agent-workspaces", + sandbox: { + mode: "all", + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, + }, + list: [{ id: "main", default: true }, { id: "builder" }], + }, + session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" }, + }; + + const logs: string[] = []; + await sandboxExplainCommand({ json: true, agent: "builder" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe( + path.resolve("/tmp/openclaw-agent-workspaces/builder"), + ); + expect(parsed.sandbox.workspaceMounts[0]).toMatchObject({ + hostRoot: path.resolve("/tmp/openclaw-agent-workspaces/builder"), + source: "workspace", + writable: true, + }); + }); + + it("reports the generated sandbox workspace for non-rw sessions", async () => { + mockCfg = { + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", + workspaceAccess: "none", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, + }, + list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], + }, + session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" }, + }; + + const logs: string[] = []; + await sandboxExplainCommand({ json: true, agent: "builder" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toMatch( + /^\/tmp\/openclaw-sandboxes\/agent-builder-/, + ); + expect(parsed.sandbox.workspaceSource).toBe("sandbox"); + expect(parsed.sandbox.workspaceMounts).toEqual([ + expect.objectContaining({ source: "workspace", writable: false }), + ]); + }); + + it("reports the agent workspace for direct sessions", async () => { + mockCfg = { + agents: { + defaults: { + sandbox: { + mode: "off", + scope: "agent", + workspaceAccess: "none", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, + }, + list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], + }, + session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" }, + }; + + const logs: string[] = []; + await sandboxExplainCommand({ json: true, agent: "builder" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe( + path.resolve("/tmp/openclaw-agent-workspace"), + ); + expect(parsed.sandbox.runtimeWorkdir).toBe(path.resolve("/tmp/openclaw-agent-workspace")); + expect(parsed.sandbox.workspaceSource).toBe("direct"); + expect(parsed.sandbox.workspaceMounts).toEqual([]); + }); + + it("uses persisted spawned-session workspace and cwd overrides", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-explain-")); + const storePath = path.join(tempDir, "sessions.json"); + const sessionKey = "agent:builder:subagent:child"; + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "child-session", + updatedAt: Date.now(), + spawnedBy: "agent:builder:main", + spawnedWorkspaceDir: "/tmp/openclaw-child-workspace", + spawnedCwd: "/tmp/openclaw-child-workspace/task", + }, + }), + ); + mockCfg = { + agents: { + defaults: { sandbox: { mode: "off" } }, + list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], + }, + session: { store: storePath }, + }; + + try { + const logs: string[] = []; + await sandboxExplainCommand({ json: true, session: sessionKey }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe( + path.resolve("/tmp/openclaw-child-workspace"), + ); + expect(parsed.sandbox.runtimeWorkdir).toBe("/tmp/openclaw-child-workspace/task"); + expect(parsed.sandbox.workspaceSource).toBe("direct"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("mounts a persisted spawned workspace for sandboxed sessions", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-explain-")); + const storePath = path.join(tempDir, "sessions.json"); + const sessionKey = "agent:builder:subagent:child"; + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "child-session", + updatedAt: Date.now(), + spawnedBy: "agent:builder:main", + spawnedWorkspaceDir: "/tmp/openclaw-child-workspace", + }, + }), + ); + mockCfg = { + agents: { + defaults: { + sandbox: { mode: "all", scope: "agent", workspaceAccess: "rw" }, + }, + list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], + }, + session: { store: storePath }, + }; + + try { + const logs: string[] = []; + await sandboxExplainCommand({ json: true, session: sessionKey }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe( + path.resolve("/tmp/openclaw-child-workspace"), + ); + expect(parsed.sandbox.runtimeWorkdir).toBe("/workspace"); + expect(parsed.sandbox.workspaceMounts[0]).toMatchObject({ + hostRoot: path.resolve("/tmp/openclaw-child-workspace"), + containerRoot: "/workspace", + writable: true, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("reports a global main session as direct in non-main mode", async () => { + mockCfg = { + agents: { + defaults: { + sandbox: { + mode: "non-main", + scope: "agent", + workspaceAccess: "none", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, + }, + list: [{ id: "main", workspace: "/tmp/openclaw-main-workspace" }], + }, + session: { + scope: "global", + store: "/tmp/openclaw-test-sessions-{agentId}.json", + }, + }; + + const logs: string[] = []; + await sandboxExplainCommand({ json: true, session: "global" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.sandbox.sessionIsSandboxed).toBe(false); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe( + path.resolve("/tmp/openclaw-main-workspace"), + ); + expect(parsed.sandbox.workspaceSource).toBe("direct"); + expect(parsed.sandbox.workspaceMounts).toEqual([]); + }); + + it("uses the configured default agent for global sessions", async () => { + mockCfg = { + agents: { + defaults: { + sandbox: { mode: "non-main" }, + }, + list: [ + { + id: "ops", + default: true, + workspace: "/tmp/openclaw-ops-workspace", + }, + ], + }, + session: { scope: "global" }, + }; + + const logs: string[] = []; + await sandboxExplainCommand({ json: true, agent: "ops", session: "global" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); + + const parsed = JSON.parse(logs.join("")); + expect(parsed.agentId).toBe("ops"); + expect(parsed.sandbox.sessionIsSandboxed).toBe(false); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe( + path.resolve("/tmp/openclaw-ops-workspace"), + ); + }); + + it("rejects a fully qualified session from a different agent", async () => { + mockCfg = { + agents: { + defaults: { + sandbox: { mode: "non-main" }, + }, + }, + }; + + await expect( + sandboxExplainCommand({ json: true, agent: "builder", session: "agent:main:main" }, { + log: () => {}, + error: () => {}, + exit: (_code: number) => {}, + } as unknown as Parameters[1]), + ).rejects.toThrow('agent "builder" does not match session agent "main"'); + }); }); diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index 8d9239f86a6e..f583de1ea387 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -5,20 +5,30 @@ * and prints either JSON or a human-readable fix-it report. */ import { + normalizeOptionalString, normalizeOptionalLowercaseString, normalizeStringifiedEntries, } from "@openclaw/normalization-core/string-coerce"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { colorize, isRich, theme } from "../../packages/terminal-core/src/theme.js"; -import { resolveAgentConfig } from "../agents/agent-scope.js"; +import { + resolveAgentConfig, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; +import { getSandboxBackendWorkdirResolver } from "../agents/sandbox/backend.js"; +import { buildSandboxFsMounts } from "../agents/sandbox/fs-paths.js"; +import { resolveSandboxRuntimeStatus } from "../agents/sandbox/runtime-status.js"; +import { resolveSandboxWorkspaceLayoutPaths } from "../agents/sandbox/shared.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; +import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../agents/spawned-context.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveAgentMainSessionKey, - resolveMainSessionKey, resolveStorePath, + type SessionEntry, } from "../config/sessions.js"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -100,30 +110,18 @@ function inferProviderFromSessionKey(params: { function resolveActiveChannel(params: { cfg: OpenClawConfig; - agentId: string; + entry?: SessionEntry; sessionKey: string; }): string | undefined { - const storePath = resolveStorePath(params.cfg.session?.store, { - agentId: params.agentId, - }); - const entry = loadSessionEntry({ - agentId: params.agentId, - sessionKey: params.sessionKey, - storePath, - }) as - | { - lastChannel?: string; - channel?: string; - // Legacy keys (pre-rename). - lastProvider?: string; - provider?: string; - } + const legacyEntry = params.entry as + | (SessionEntry & { lastProvider?: string; provider?: string }) | undefined; const candidate = ( - entry?.lastChannel ?? - entry?.channel ?? - entry?.lastProvider ?? - entry?.provider ?? + params.entry?.lastChannel ?? + params.entry?.channel ?? + // Legacy keys (pre-rename). + legacyEntry?.lastProvider ?? + legacyEntry?.provider ?? "" ).trim(); const normalizedCandidate = normalizeOptionalLowercaseString(candidate); @@ -155,14 +153,22 @@ export async function sandboxExplainCommand( ): Promise { const cfg = getRuntimeConfig(); - const defaultAgentId = resolveAgentIdFromSessionKey(resolveMainSessionKey(cfg)); - const resolvedAgentId = normalizeAgentId( - opts.agent?.trim() - ? opts.agent - : opts.session?.trim() - ? resolveAgentIdFromSessionKey(opts.session) - : defaultAgentId, - ); + const defaultAgentId = resolveDefaultAgentId(cfg); + const requestedSession = opts.session?.trim(); + const requestedAgentId = opts.agent?.trim() ? normalizeAgentId(opts.agent) : undefined; + const sessionAgentId = requestedSession + ? requestedSession === "global" + ? defaultAgentId + : requestedSession.includes(":") + ? normalizeAgentId(resolveAgentIdFromSessionKey(requestedSession)) + : undefined + : undefined; + if (requestedAgentId && sessionAgentId && requestedAgentId !== sessionAgentId) { + throw new Error( + `Sandbox explain agent "${requestedAgentId}" does not match session agent "${sessionAgentId}".`, + ); + } + const resolvedAgentId = sessionAgentId ?? requestedAgentId ?? defaultAgentId; const sessionKey = normalizeExplainSessionKey({ cfg, @@ -172,24 +178,70 @@ export async function sandboxExplainCommand( const sandboxCfg = resolveSandboxConfigForAgent(cfg, resolvedAgentId); const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, resolvedAgentId); - const mainSessionKey = resolveAgentMainSessionKey({ + const sandboxRuntime = resolveSandboxRuntimeStatus({ cfg, + sessionKey, + }); + const mainSessionKey = sandboxRuntime.mainSessionKey; + const sessionIsSandboxed = sandboxRuntime.sandboxed; + const storePath = resolveStorePath(cfg.session?.store, { agentId: resolvedAgentId, }); - const sessionIsSandboxed = - sandboxCfg.mode === "all" - ? true - : sandboxCfg.mode === "off" - ? false - : sessionKey.trim() !== mainSessionKey.trim(); - - const channel = resolveActiveChannel({ - cfg, + const sessionEntry = loadSessionEntry({ agentId: resolvedAgentId, sessionKey, + storePath, }); const agentConfig = resolveAgentConfig(cfg, resolvedAgentId); + // Spawned sessions persist their inherited workspace and direct-mode cwd so + // later turns keep running in the same location. Explain must mirror those + // overrides or its effective paths point at a different runtime. + const configuredWorkspaceDir = resolveAgentWorkspaceDir(cfg, resolvedAgentId); + const sessionWorkspaceDir = resolveIngressWorkspaceOverrideForSpawnedRun({ + spawnedBy: sessionEntry?.spawnedBy, + workspaceDir: sessionEntry?.spawnedWorkspaceDir, + }); + const effectiveAgentWorkspaceDir = sessionWorkspaceDir ?? configuredWorkspaceDir; + const directRuntimeCwd = + normalizeOptionalString(sessionEntry?.spawnedCwd) ?? effectiveAgentWorkspaceDir; + const workspaceLayout = resolveSandboxWorkspaceLayoutPaths({ + cfg: sandboxCfg, + rawSessionKey: sessionKey, + workspaceDir: effectiveAgentWorkspaceDir, + }); + const sandboxWorkdir = getSandboxBackendWorkdirResolver(sandboxCfg.backend)?.({ + sessionKey, + scopeKey: workspaceLayout.scopeKey, + workspaceDir: workspaceLayout.workspaceDir, + agentWorkspaceDir: workspaceLayout.agentWorkspaceDir, + skillsWorkspaceDir: workspaceLayout.skillsWorkspaceDir, + cfg: sandboxCfg, + }); + const effectiveHostWorkspaceRoot = sessionIsSandboxed + ? workspaceLayout.workspaceDir + : workspaceLayout.agentWorkspaceDir; + const runtimeWorkdir = sessionIsSandboxed ? sandboxWorkdir : directRuntimeCwd; + const workspaceSource = sessionIsSandboxed ? workspaceLayout.workspaceSource : "direct"; + const workspaceMounts = + sessionIsSandboxed && sandboxCfg.backend === "docker" && sandboxWorkdir + ? buildSandboxFsMounts({ + workspaceDir: workspaceLayout.workspaceDir, + agentWorkspaceDir: workspaceLayout.agentWorkspaceDir, + skillsWorkspaceDir: workspaceLayout.skillsWorkspaceDir, + workspaceAccess: sandboxCfg.workspaceAccess, + containerName: "", + containerWorkdir: sandboxWorkdir, + docker: sandboxCfg.docker, + }) + : []; + + const channel = resolveActiveChannel({ + cfg, + entry: sessionEntry, + sessionKey, + }); + const elevatedGlobal = cfg.tools?.elevated; const elevatedAgent = agentConfig?.tools?.elevated; const elevatedGlobalEnabled = elevatedGlobal?.enabled !== false; @@ -263,8 +315,13 @@ export async function sandboxExplainCommand( sandbox: { mode: sandboxCfg.mode, scope: sandboxCfg.scope, + backend: sandboxCfg.backend, workspaceAccess: sandboxCfg.workspaceAccess, workspaceRoot: sandboxCfg.workspaceRoot, + effectiveHostWorkspaceRoot, + runtimeWorkdir, + workspaceMounts, + workspaceSource, sessionIsSandboxed, tools: { allow: toolPolicy.allow, @@ -318,6 +375,24 @@ export async function sandboxExplainCommand( payload.sandbox.workspaceAccess, )} ${key("workspaceRoot:")} ${value(payload.sandbox.workspaceRoot)}`, ); + lines.push( + ` ${key("effectiveHostWorkspaceRoot:")} ${value(payload.sandbox.effectiveHostWorkspaceRoot)}`, + ); + lines.push( + ` ${key("backend:")} ${value(payload.sandbox.backend)} ${key("runtimeWorkdir:")} ${value( + payload.sandbox.runtimeWorkdir ?? "(direct host)", + )} ${key("workspaceSource:")} ${value(payload.sandbox.workspaceSource)}`, + ); + if (payload.sandbox.workspaceMounts.length > 0) { + lines.push(` ${key("workspaceMounts:")}`); + for (const mount of payload.sandbox.workspaceMounts) { + lines.push( + ` - ${value(mount.hostRoot)} -> ${value(mount.containerRoot)} ${key( + mount.writable ? "rw" : "ro", + )} ${key(`(${mount.source})`)}`, + ); + } + } lines.push(""); lines.push(heading("Sandbox tool policy:")); lines.push( diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index 473d77539af2..ad13f14b817e 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -229,6 +229,21 @@ describe("talk normalization", () => { }); }); + it.each(["constructor", "__proto__"])( + "does not resolve inherited Object.prototype provider key %s", + (provider) => { + const payload = buildTalkConfigResponse({ + provider, + providers: { + elevenlabs: { voiceId: "voice-123" }, + }, + }); + + expect(payload?.resolved).toBeUndefined(); + expect(payload?.provider).toBeUndefined(); + }, + ); + it("preserves SecretRef apiKey values during normalization", () => { const normalized = normalizeTalkSection({ provider: TALK_TEST_PROVIDER_ID, diff --git a/src/config/talk.ts b/src/config/talk.ts index e61b9d5e079a..be47dd58fda6 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -194,7 +194,7 @@ function activeProviderFromTalk(talk: TalkConfig): string | undefined { const provider = normalizeOptionalString(talk.provider); const providers = talk.providers; if (provider) { - if (providers && !(provider in providers)) { + if (providers && !Object.hasOwn(providers, provider)) { return undefined; } return provider; diff --git a/src/config/zod-schema.talk.test.ts b/src/config/zod-schema.talk.test.ts index e7c81b060a9a..7dc7ad3256c4 100644 --- a/src/config/zod-schema.talk.test.ts +++ b/src/config/zod-schema.talk.test.ts @@ -114,6 +114,18 @@ describe("OpenClawSchema talk validation", () => { ).toThrow(/talk\.provider|talk\.providers|missing "acme"/i); }); + it.each(["constructor", "__proto__"])( + "rejects inherited Object.prototype key %s as a Talk provider", + (provider) => { + const providers = { elevenlabs: { voiceId: "voice-123" } }; + + expect(OpenClawSchema.safeParse({ talk: { provider, providers } }).success).toBe(false); + expect( + OpenClawSchema.safeParse({ talk: { realtime: { provider, providers } } }).success, + ).toBe(false); + }, + ); + it("rejects multi-provider talk config without talk.provider", () => { expect(() => OpenClawSchema.parse({ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d820dbdac0f6..3a213cad369f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -330,7 +330,7 @@ const TalkRealtimeSchema = z const provider = normalizeLowercaseStringOrEmpty(realtime.provider ?? ""); const providers = realtime.providers ? Object.keys(realtime.providers) : []; - if (provider && providers.length > 0 && !(provider in realtime.providers!)) { + if (provider && providers.length > 0 && !Object.hasOwn(realtime.providers!, provider)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provider"], @@ -366,7 +366,7 @@ const TalkSchema = z const provider = normalizeLowercaseStringOrEmpty(talk.provider ?? ""); const providers = talk.providers ? Object.keys(talk.providers) : []; - if (provider && providers.length > 0 && !(provider in talk.providers!)) { + if (provider && providers.length > 0 && !Object.hasOwn(talk.providers!, provider)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provider"], diff --git a/src/cron/isolated-agent/run-execution.runtime.ts b/src/cron/isolated-agent/run-execution.runtime.ts index f0a1268ddf24..4cc4c296add0 100644 --- a/src/cron/isolated-agent/run-execution.runtime.ts +++ b/src/cron/isolated-agent/run-execution.runtime.ts @@ -8,6 +8,10 @@ export { resolveCronAgentLane } from "../../agents/lanes.js"; export { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; export { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; export { runWithModelFallback } from "../../agents/model-fallback.js"; +export { + classifyEmbeddedAgentRunResultForModelFallback, + mergeEmbeddedAgentRunResultForModelFallbackExhaustion, +} from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; export { isCliProvider } from "../../agents/model-selection-cli.js"; export { normalizeVerboseLevel } from "../../auto-reply/thinking.shared.js"; export { resolveSessionTranscriptPath } from "../../config/sessions/paths.js"; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 00d865c5f343..47e6778d695d 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -25,11 +25,13 @@ import { } from "./channel-output-policy.js"; import { resolveCronPayloadOutcome } from "./helpers.js"; import { + classifyEmbeddedAgentRunResultForModelFallback, ensureSelectedAgentHarnessPlugin, getCliSessionBinding, isCliProvider, LiveSessionModelSwitchError, logWarn, + mergeEmbeddedAgentRunResultForModelFallbackExhaustion, normalizeVerboseLevel, registerAgentRunContext, resolveBootstrapWarningSignaturesSeen, @@ -339,6 +341,9 @@ export function createCronPromptExecutor(params: { }); }, fallbacksOverride: cronFallbacksOverride, + classifyResult: ({ provider, model, result }) => + classifyEmbeddedAgentRunResultForModelFallback({ provider, model, result }), + mergeExhaustedResult: mergeEmbeddedAgentRunResultForModelFallbackExhaustion, run: async (providerOverride, modelOverride, runOptions) => { if (params.abortSignal?.aborted) { throw new Error(params.abortReason()); diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts index b6778a1895e7..465c3cffb0b9 100644 --- a/src/cron/isolated-agent/run.payload-fallbacks.test.ts +++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js"; import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js"; import { + classifyEmbeddedAgentRunResultForModelFallbackMock, isCliProviderMock, loadRunCronIsolatedAgentTurn, + mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock, mockRunCronFallbackPassthrough, resolveConfiguredModelRefMock, resolveCliRuntimeExecutionProviderMock, @@ -17,13 +19,20 @@ import { const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); function requireModelFallbackRequest(): { + classifyResult?: (params: { provider: string; model: string; result: unknown }) => unknown; fallbacksOverride?: string[]; + mergeExhaustedResult?: (params: { latestResult: unknown; preferredResult: unknown }) => unknown; provider?: string; model?: string; } { const request = runWithModelFallbackMock.mock.calls[0]?.[0] as | { + classifyResult?: (params: { provider: string; model: string; result: unknown }) => unknown; fallbacksOverride?: string[]; + mergeExhaustedResult?: (params: { + latestResult: unknown; + preferredResult: unknown; + }) => unknown; provider?: string; model?: string; } @@ -99,6 +108,38 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => { expect(requireModelFallbackRequest().fallbacksOverride).toEqual(expectedFallbacks); }); + it("classifies isolated cron results for model fallback", async () => { + const classification = { reason: "format", code: "empty_result" }; + classifyEmbeddedAgentRunResultForModelFallbackMock.mockReturnValue(classification); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentParamsFixture({ + job: makeIsolatedAgentJobFixture({ + payload: { kind: "agentTurn", message: "test" }, + }), + }), + ); + + expect(result.status).toBe("ok"); + const fallbackRequest = requireModelFallbackRequest(); + const embeddedResult = { payloads: [], meta: { agentMeta: {} } }; + expect( + fallbackRequest.classifyResult?.({ + provider: "anthropic", + model: "claude-sonnet-4-6", + result: embeddedResult, + }), + ).toBe(classification); + expect(classifyEmbeddedAgentRunResultForModelFallbackMock).toHaveBeenCalledWith({ + provider: "anthropic", + model: "claude-sonnet-4-6", + result: embeddedResult, + }); + expect(fallbackRequest.mergeExhaustedResult).toBe( + mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock, + ); + }); + it("plans Anthropic fallbacks canonically while executing compatible attempts through Claude CLI", async () => { isCliProviderMock.mockImplementation((provider: string) => provider === "claude-cli"); resolveCliRuntimeExecutionProviderMock.mockImplementation( diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 0e030eb87b80..6a49cadf7800 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -86,6 +86,8 @@ export const callGatewayMock = createMock(); export const ensureRuntimePluginsLoadedMock = createMock(); export const listWebSearchProvidersMock = createMock(); export const resolveWebSearchProviderIdMock = createMock(); +export const classifyEmbeddedAgentRunResultForModelFallbackMock = createMock(); +export const mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock = createMock(); const resolveBootstrapWarningSignaturesSeenMock = createMock(); const resolveCronStyleNowMock = createMock(); @@ -258,6 +260,10 @@ vi.mock("./run-execution.runtime.js", () => ({ resolveSessionTranscriptPath: resolveSessionTranscriptPathMock, registerAgentRunContext: registerAgentRunContextMock, logWarn: (...args: unknown[]) => logWarnMock(...args), + classifyEmbeddedAgentRunResultForModelFallback: + classifyEmbeddedAgentRunResultForModelFallbackMock, + mergeEmbeddedAgentRunResultForModelFallbackExhaustion: + mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock, })); vi.mock("../../agents/model-runtime-aliases.js", () => ({ @@ -522,6 +528,12 @@ function resetRunExecutionMocks(): void { registerAgentRunContextMock.mockReturnValue(undefined); runWithModelFallbackMock.mockReset(); runWithModelFallbackMock.mockResolvedValue(makeDefaultModelFallbackResult()); + classifyEmbeddedAgentRunResultForModelFallbackMock.mockReset(); + classifyEmbeddedAgentRunResultForModelFallbackMock.mockReturnValue(null); + mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock.mockReset(); + mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock.mockImplementation( + (params: { latestResult: unknown }) => params.latestResult, + ); runEmbeddedAgentMock.mockReset(); runEmbeddedAgentMock.mockResolvedValue(makeDefaultEmbeddedResult()); runCliAgentMock.mockReset(); diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index e755848757ed..206106e8449e 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -6,9 +6,10 @@ import { } from "./provider-self-hosted-setup.js"; import type { ProviderAuthMethodNonInteractiveContext } from "./types.js"; -const { fetchWithSsrFGuardMock, upsertAuthProfileWithLock } = vi.hoisted(() => ({ +const { fetchWithSsrFGuardMock, upsertAuthProfileWithLock, loggerWarnMock } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: vi.fn(), upsertAuthProfileWithLock: vi.fn(async () => null), + loggerWarnMock: vi.fn(), })); vi.mock("../infra/net/fetch-guard.js", () => ({ @@ -19,6 +20,10 @@ vi.mock("../agents/auth-profiles/upsert-with-lock.js", () => ({ upsertAuthProfileWithLock, })); +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn: loggerWarnMock }), +})); + beforeEach(() => { vi.clearAllMocks(); }); @@ -142,6 +147,27 @@ function cancelTrackedResponse(init?: ResponseInit): { } describe("discoverOpenAICompatibleLocalModels", () => { + it("labels malformed discovery JSON in the warning", async () => { + const release = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response("not valid json {", { status: 200 }), + finalUrl: "http://127.0.0.1:8080/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "local llama.cpp", + env: {}, + }); + + expect(models).toEqual([]); + expect(loggerWarnMock).toHaveBeenCalledWith( + expect.stringContaining("local llama.cpp discovery response is not valid JSON"), + ); + expect(release).toHaveBeenCalledOnce(); + }); + it("uses guarded fetch pinned to the configured self-hosted provider", async () => { const release = vi.fn(async () => undefined); const propsRelease = vi.fn(async () => undefined); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 33066851932a..399ac462c5be 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -105,7 +105,11 @@ async function readSelfHostedDiscoveryJson(response: Response, label: string): P `${label} discovery response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, ), }); - return JSON.parse(new TextDecoder().decode(bytes)); + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch (cause) { + throw new Error(`${label} discovery response is not valid JSON`, { cause }); + } } async function cancelUnreadResponseBody(response: Response): Promise { diff --git a/src/security/install-policy.test.ts b/src/security/install-policy.test.ts index 3f29040a62ef..fb0536a02b04 100644 --- a/src/security/install-policy.test.ts +++ b/src/security/install-policy.test.ts @@ -1,8 +1,20 @@ // Covers install-policy checks for packages and plugin installs. +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: Parameters) => + spawnMock(...args) ?? actual.spawn(...args), + }; +}); import type { OpenClawConfig } from "../config/types.openclaw.js"; import { killPidIfAlive, @@ -674,3 +686,59 @@ describe("runInstallPolicy", () => { }, ); }); + +describe("runPolicyCommand stream errors", () => { + function createFakeChild(): { + child: ChildProcess; + kill: ReturnType; + } { + const child = new EventEmitter() as EventEmitter & ChildProcess; + const kill = vi.fn(() => true); + child.stdout = new EventEmitter() as EventEmitter & NonNullable; + child.stderr = new EventEmitter() as EventEmitter & NonNullable; + child.stdin = new EventEmitter() as EventEmitter & NonNullable; + child.stdin.write = vi.fn(() => true) as NonNullable["write"]; + child.stdin.end = vi.fn() as NonNullable["end"]; + child.kill = kill as ChildProcess["kill"]; + return { child, kill }; + } + + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it.each(["stdout", "stderr", "stdin"] as const)( + "fails closed and kills the policy process after a %s stream error", + async (streamName) => { + const dir = await makeTempDir(); + const policyScriptPath = await writePolicyScript(dir); + const { child, kill } = createFakeChild(); + spawnMock.mockImplementation(() => { + queueMicrotask(() => { + child.stdout?.emit( + "data", + Buffer.from(JSON.stringify({ protocolVersion: 1, decision: "allow" })), + ); + child[streamName]?.emit("error", new Error(`${streamName} read failed`)); + child.emit("close", 0, null); + }); + return child; + }); + + const result = await runInstallPolicy({ + config: configWithPolicy(policyScriptPath, {}), + request: baseRequest(dir), + }); + + expect(result?.blocked?.code).toBe("security_scan_failed"); + expect(result?.blocked?.reason).toContain(`policy ${streamName} stream failed`); + expect(kill).toHaveBeenCalledWith("SIGKILL"); + }, + ); +}); diff --git a/src/security/install-policy.ts b/src/security/install-policy.ts index 6519f9d7e012..d76fd9a5cd9b 100644 --- a/src/security/install-policy.ts +++ b/src/security/install-policy.ts @@ -561,6 +561,18 @@ async function runPolicyCommand(params: { } }; + const failCommand = (error: unknown, kill: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimers(); + if (kill) { + forceKillChildProcessTree(child); + } + reject(error instanceof Error ? error : new Error(String(error))); + }; + const armNoOutputTimer = () => { if (noOutputTimer) { clearTimeout(noOutputTimer); @@ -575,12 +587,7 @@ async function runPolicyCommand(params: { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); outputBytes += Buffer.byteLength(text, "utf8"); if (outputBytes > params.maxOutputBytes) { - forceKillChildProcessTree(child); - if (!settled) { - settled = true; - clearTimers(); - reject(new Error(`output exceeded maxOutputBytes (${params.maxOutputBytes})`)); - } + failCommand(new Error(`output exceeded maxOutputBytes (${params.maxOutputBytes})`), true); return; } if (target === "stdout") { @@ -593,14 +600,15 @@ async function runPolicyCommand(params: { armNoOutputTimer(); child.on("error", (error) => { - if (settled) { - return; - } - settled = true; - clearTimers(); - reject(error); + failCommand(error, false); + }); + child.stdout?.on("error", (error) => { + failCommand(new Error(`policy stdout stream failed: ${formatErrorMessage(error)}`), true); }); child.stdout?.on("data", (chunk) => append(chunk, "stdout")); + child.stderr?.on("error", (error) => { + failCommand(new Error(`policy stderr stream failed: ${formatErrorMessage(error)}`), true); + }); child.stderr?.on("data", (chunk) => append(chunk, "stderr")); child.on("close", (code, signal) => { if (settled) { @@ -621,9 +629,7 @@ async function runPolicyCommand(params: { if (isIgnorableStdinWriteError(error) || settled) { return; } - settled = true; - clearTimers(); - reject(error instanceof Error ? error : new Error(String(error))); + failCommand(new Error(`policy stdin stream failed: ${formatErrorMessage(error)}`), true); }; child.stdin?.on("error", handleStdinError); try {