Skip to content

Commit 4acdeee

Browse files
Auto-merge upstream openclaw/openclaw
2 parents cb20743 + 3e5c571 commit 4acdeee

644 files changed

Lines changed: 26694 additions & 4314 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/security-triage/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Check in this order:
5555
- Was it fixed before release?
5656
3. Exploit path
5757
- Does the report show a real boundary bypass, not just prompt injection, local same-user control, or helper-level semantics?
58+
- If data only moves between trusted workspace-memory files called out in `SECURITY.md`, do not treat "injection markers" alone as a security bug.
59+
- In that case, frame sanitization as optional hardening only if it preserves expected memory workflows.
5860
4. Functional tradeoff
5961
- If a hardening change would reduce intended user functionality, call that out before proposing it.
6062
- Prefer fixes that preserve user workflows over deny-by-default regressions unless the boundary demands it.
@@ -104,5 +106,6 @@ gh search prs --repo openclaw/openclaw --match title,body,comments -- "<terms>"
104106
- “fixed on main, unreleased” is usually not a close.
105107
- “needs attacker-controlled trusted local state first” is usually out of scope.
106108
- “same-host same-user process can already read/write local state” is usually out of scope.
109+
- “trusted workspace memory promotes/reindexes trusted workspace memory” is usually out of scope unless it crosses a documented boundary.
107110
- “helper function behaves differently than documented config semantics” is usually invalid.
108111
- If only the severity is wrong but the bug is real, keep it open and narrow the impact in the reply.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ node_modules
44
docker-compose.override.yml
55
docker-compose.extra.yml
66
dist
7-
dist-runtime
7+
dist-runtime/
88
pnpm-lock.yaml
99
bun.lock
1010
bun.lockb

CHANGELOG.md

Lines changed: 37 additions & 117 deletions
Large diffs are not rendered by default.

apps/android/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ android {
6565
applicationId = "ai.openclaw.app"
6666
minSdk = 31
6767
targetSdk = 36
68-
versionCode = 2026040301
69-
versionName = "2026.4.3"
68+
versionCode = 2026040401
69+
versionName = "2026.4.4"
7070
ndk {
7171
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
7272
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,11 +418,63 @@ class GatewaySession(
418418
}
419419
throw GatewayConnectFailure(error)
420420
}
421-
handleConnectSuccess(res, identity.deviceId)
421+
handleConnectSuccess(res, identity.deviceId, selectedAuth.authSource)
422422
connectDeferred.complete(Unit)
423423
}
424424

425-
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
425+
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
426+
if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false
427+
if (isLoopbackGatewayHost(endpoint.host)) return true
428+
return tls != null
429+
}
430+
431+
private fun filteredBootstrapHandoffScopes(role: String, scopes: List<String>): List<String>? {
432+
return when (role.trim()) {
433+
"node" -> emptyList()
434+
"operator" -> {
435+
val allowedOperatorScopes =
436+
setOf(
437+
"operator.approvals",
438+
"operator.read",
439+
"operator.talk.secrets",
440+
"operator.write",
441+
)
442+
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
443+
}
444+
else -> null
445+
}
446+
}
447+
448+
private fun persistBootstrapHandoffToken(
449+
deviceId: String,
450+
role: String,
451+
token: String,
452+
scopes: List<String>,
453+
) {
454+
if (filteredBootstrapHandoffScopes(role, scopes) == null) return
455+
deviceAuthStore.saveToken(deviceId, role, token)
456+
}
457+
458+
private fun persistIssuedDeviceToken(
459+
authSource: GatewayConnectAuthSource,
460+
deviceId: String,
461+
role: String,
462+
token: String,
463+
scopes: List<String>,
464+
) {
465+
if (authSource == GatewayConnectAuthSource.BOOTSTRAP_TOKEN) {
466+
if (!shouldPersistBootstrapHandoffTokens(authSource)) return
467+
persistBootstrapHandoffToken(deviceId, role, token, scopes)
468+
return
469+
}
470+
deviceAuthStore.saveToken(deviceId, role, token)
471+
}
472+
473+
private fun handleConnectSuccess(
474+
res: RpcResponse,
475+
deviceId: String,
476+
authSource: GatewayConnectAuthSource,
477+
) {
426478
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
427479
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
428480
pendingDeviceTokenRetry = false
@@ -432,8 +484,27 @@ class GatewaySession(
432484
val authObj = obj["auth"].asObjectOrNull()
433485
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
434486
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
487+
val authScopes =
488+
authObj?.get("scopes").asArrayOrNull()
489+
?.mapNotNull { it.asStringOrNull() }
490+
?: emptyList()
435491
if (!deviceToken.isNullOrBlank()) {
436-
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
492+
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
493+
}
494+
if (shouldPersistBootstrapHandoffTokens(authSource)) {
495+
authObj?.get("deviceTokens").asArrayOrNull()
496+
?.mapNotNull { it.asObjectOrNull() }
497+
?.forEach { tokenEntry ->
498+
val handoffToken = tokenEntry["deviceToken"].asStringOrNull()
499+
val handoffRole = tokenEntry["role"].asStringOrNull()
500+
val handoffScopes =
501+
tokenEntry["scopes"].asArrayOrNull()
502+
?.mapNotNull { it.asStringOrNull() }
503+
?: emptyList()
504+
if (!handoffToken.isNullOrBlank() && !handoffRole.isNullOrBlank()) {
505+
persistBootstrapHandoffToken(deviceId, handoffRole, handoffToken, handoffScopes)
506+
}
507+
}
437508
}
438509
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
439510
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
@@ -899,6 +970,8 @@ private fun formatGatewayAuthorityHost(host: String): String {
899970

900971
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
901972

973+
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
974+
902975
private fun JsonElement?.asStringOrNull(): String? =
903976
when (this) {
904977
is JsonNull -> null

apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,137 @@ class GatewaySessionInvokeTest {
213213
}
214214
}
215215

216+
@Test
217+
fun connect_storesPrimaryDeviceTokenFromSuccessfulSharedTokenConnect() = runBlocking {
218+
val json = testJson()
219+
val connected = CompletableDeferred<Unit>()
220+
val lastDisconnect = AtomicReference("")
221+
val server =
222+
startGatewayServer(json) { webSocket, id, method, _ ->
223+
when (method) {
224+
"connect" -> {
225+
webSocket.send(
226+
connectResponseFrame(
227+
id,
228+
authJson = """{"deviceToken":"shared-node-token","role":"node","scopes":[]}""",
229+
),
230+
)
231+
webSocket.close(1000, "done")
232+
}
233+
}
234+
}
235+
236+
val harness =
237+
createNodeHarness(
238+
connected = connected,
239+
lastDisconnect = lastDisconnect,
240+
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
241+
242+
try {
243+
connectNodeSession(
244+
session = harness.session,
245+
port = server.port,
246+
token = "shared-auth-token",
247+
bootstrapToken = null,
248+
)
249+
awaitConnectedOrThrow(connected, lastDisconnect, server)
250+
251+
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
252+
assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(deviceId, "node"))
253+
assertNull(harness.deviceAuthStore.loadToken(deviceId, "operator"))
254+
} finally {
255+
shutdownHarness(harness, server)
256+
}
257+
}
258+
259+
@Test
260+
fun bootstrapConnect_storesAdditionalBoundedDeviceTokensOnTrustedTransport() = runBlocking {
261+
val json = testJson()
262+
val connected = CompletableDeferred<Unit>()
263+
val lastDisconnect = AtomicReference("")
264+
val server =
265+
startGatewayServer(json) { webSocket, id, method, _ ->
266+
when (method) {
267+
"connect" -> {
268+
webSocket.send(
269+
connectResponseFrame(
270+
id,
271+
authJson =
272+
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""",
273+
),
274+
)
275+
webSocket.close(1000, "done")
276+
}
277+
}
278+
}
279+
280+
val harness =
281+
createNodeHarness(
282+
connected = connected,
283+
lastDisconnect = lastDisconnect,
284+
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
285+
286+
try {
287+
connectNodeSession(
288+
session = harness.session,
289+
port = server.port,
290+
token = null,
291+
bootstrapToken = "bootstrap-token",
292+
)
293+
awaitConnectedOrThrow(connected, lastDisconnect, server)
294+
295+
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
296+
assertEquals("bootstrap-node-token", harness.deviceAuthStore.loadToken(deviceId, "node"))
297+
assertEquals("bootstrap-operator-token", harness.deviceAuthStore.loadToken(deviceId, "operator"))
298+
} finally {
299+
shutdownHarness(harness, server)
300+
}
301+
}
302+
303+
@Test
304+
fun nonBootstrapConnect_ignoresAdditionalBootstrapDeviceTokens() = runBlocking {
305+
val json = testJson()
306+
val connected = CompletableDeferred<Unit>()
307+
val lastDisconnect = AtomicReference("")
308+
val server =
309+
startGatewayServer(json) { webSocket, id, method, _ ->
310+
when (method) {
311+
"connect" -> {
312+
webSocket.send(
313+
connectResponseFrame(
314+
id,
315+
authJson =
316+
"""{"deviceToken":"shared-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"shared-operator-token","role":"operator","scopes":["operator.approvals","operator.read"]}]}""",
317+
),
318+
)
319+
webSocket.close(1000, "done")
320+
}
321+
}
322+
}
323+
324+
val harness =
325+
createNodeHarness(
326+
connected = connected,
327+
lastDisconnect = lastDisconnect,
328+
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
329+
330+
try {
331+
connectNodeSession(
332+
session = harness.session,
333+
port = server.port,
334+
token = "shared-auth-token",
335+
bootstrapToken = null,
336+
)
337+
awaitConnectedOrThrow(connected, lastDisconnect, server)
338+
339+
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
340+
assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(deviceId, "node"))
341+
assertNull(harness.deviceAuthStore.loadToken(deviceId, "operator"))
342+
} finally {
343+
shutdownHarness(harness, server)
344+
}
345+
}
346+
216347
@Test
217348
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
218349
val handshakeOrigin = AtomicReference<String?>(null)
@@ -470,9 +601,14 @@ class GatewaySessionInvokeTest {
470601
}
471602
}
472603

473-
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
604+
private fun connectResponseFrame(
605+
id: String,
606+
canvasHostUrl: String? = null,
607+
authJson: String? = null,
608+
): String {
474609
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
475-
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
610+
val auth = authJson?.let { "\"auth\":$it," } ?: ""
611+
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas$auth"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
476612
}
477613

478614
private fun startGatewayServer(

apps/ios/Config/Version.xcconfig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Shared iOS version defaults.
22
// Generated overrides live in build/Version.xcconfig (git-ignored).
33

4-
OPENCLAW_GATEWAY_VERSION = 2026.4.3
5-
OPENCLAW_MARKETING_VERSION = 2026.4.3
6-
OPENCLAW_BUILD_VERSION = 2026040301
4+
OPENCLAW_GATEWAY_VERSION = 2026.4.4
5+
OPENCLAW_MARKETING_VERSION = 2026.4.4
6+
OPENCLAW_BUILD_VERSION = 2026040401
77

88
#include? "../build/Version.xcconfig"

0 commit comments

Comments
 (0)