mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
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:
committed by
GitHub
parent
366ed56a95
commit
aaf5ab910c
@@ -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()
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user