fix: land ten small reliability fixes (#100483)

* fix(agents): harden LSP process failures

Source: #100450

Co-authored-by: morluto <williamlin1327@gmail.com>

* fix(sandbox): report effective workspace layout

Sources: #100435, #100439

Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>

* fix(security): fail install checks on stream errors

Source: #100413

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(android): normalize all-day calendar events

Source: #100032

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): serialize push-to-talk lifecycle

Source: #99942

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(talk): reject inherited provider names

Source: #99849

Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com>

* fix(android): stop voice capture in background

Source: #99840

Co-authored-by: xialonglee <li.xialong@xydigit.com>

* fix(cron): preserve fallback result classification

Source: #99913

Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com>

* fix(google): bound Vertex response decompression

Source: #99812

Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com>

* fix(plugins): report malformed discovery JSON

Source: #99892

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* test(sandbox): configure non-default workspace fixture

* test: fix small-fix batch validation

---------

Co-authored-by: morluto <williamlin1327@gmail.com>
Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>
Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com>
Co-authored-by: xialonglee <li.xialong@xydigit.com>
Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com>
Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-06 00:08:51 +01:00
committed by GitHub
parent 366ed56a95
commit aaf5ab910c
34 changed files with 1196 additions and 132 deletions
+15 -15
View File
@@ -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",
@@ -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)
@@ -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
@@ -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) {
@@ -666,6 +666,46 @@ class GatewayBootstrapAuthTest {
assertFalse(readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value)
}
@Test
fun backgroundingStopsTalkModeCapture() {
val app = RuntimeEnvironment.getApplication()
val runtime = NodeRuntime(app)
val talkMode = readField<Lazy<TalkModeManager>>(runtime, "talkMode\$delegate").value
readField<MutableStateFlow<VoiceCaptureMode>>(runtime, "_voiceCaptureMode").value = VoiceCaptureMode.TalkMode
readField<MutableStateFlow<Boolean>>(talkMode, "_isEnabled").value = true
readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value = true
talkMode.ttsOnAllResponses = true
assertEquals(VoiceCaptureMode.TalkMode, runtime.voiceCaptureMode.value)
assertTrue(talkMode.isEnabled.value)
assertTrue(readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value)
runtime.setForeground(false)
assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value)
assertFalse(talkMode.isEnabled.value)
assertFalse(talkMode.ttsOnAllResponses)
assertFalse(readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value)
}
@Test
fun backgroundingStopsGatewayPttWhenVoiceModeIsOff() {
val app = RuntimeEnvironment.getApplication()
shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO)
val runtime = NodeRuntime(app)
val talkMode = readField<Lazy<TalkModeManager>>(runtime, "talkMode\$delegate").value
writeField(talkMode, "activePttCaptureId", "capture-1")
readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value = true
assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value)
runtime.setForeground(false)
assertNull(readField<String?>(talkMode, "activePttCaptureId"))
assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value)
assertFalse(readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value)
}
private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt {
repeat(50) {
runtime.pendingGatewayTrust.value?.let { return it }
@@ -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
}
}
@@ -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()
+81 -15
View File
@@ -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<Void, Never>] = []
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<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
try self.decodeParams(type, from: json)
}
@@ -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
@@ -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() }
+2
View File
@@ -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
+1 -1
View File
@@ -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 <key>] [--agent <id>]` inspects effective sandbox mode, tool policy, and fix-it config keys.
- `openclaw sandbox explain [--session <key>] [--agent <id>]` 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 <key> | --agent <id>] [--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.
@@ -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");
+21 -3
View File
@@ -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<GoogleOauthTokenResponsePayload | undefined> {
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");
}
}
+103 -1
View File
@@ -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<string>,
) {
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({
+39 -7
View File
@@ -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 {
+7 -23
View File
@@ -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") {
+40
View File
@@ -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<SandboxConfig, "scope" | "workspaceAccess" | "workspaceRoot">;
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;
}
+2
View File
@@ -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",
);
+2 -1
View File
@@ -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;
+318
View File
@@ -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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[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<typeof sandboxExplainCommand>[1]),
).rejects.toThrow('agent "builder" does not match session agent "main"');
});
});
+115 -40
View File
@@ -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<void> {
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(
+15
View File
@@ -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,
+1 -1
View File
@@ -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;
+12
View File
@@ -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({
+2 -2
View File
@@ -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"],
@@ -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";
+5
View File
@@ -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());
@@ -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(
@@ -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();
+27 -1
View File
@@ -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);
+5 -1
View File
@@ -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<void> {
+68
View File
@@ -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<typeof import("node:child_process")>();
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) =>
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<typeof vi.fn>;
} {
const child = new EventEmitter() as EventEmitter & ChildProcess;
const kill = vi.fn(() => true);
child.stdout = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdout"]>;
child.stderr = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stderr"]>;
child.stdin = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdin"]>;
child.stdin.write = vi.fn(() => true) as NonNullable<ChildProcess["stdin"]>["write"];
child.stdin.end = vi.fn() as NonNullable<ChildProcess["stdin"]>["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");
},
);
});
+21 -15
View File
@@ -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 {