Skip to content

Commit b039925

Browse files
authored
chore: defer release note to maintainer batch
1 parent eda3eb7 commit b039925

5 files changed

Lines changed: 205 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ Docs: https://docs.openclaw.ai
2525
- **Managed browser cookie persistence:** initialize new isolated macOS headless profiles with a non-interactive encryption key while preserving existing profile keys, and close Chromium through CDP before bounded signal fallback so persistent logins survive graceful browser and Gateway restarts. (#96704, #98284) Thanks @TurboTheTurtle.
2626
- **MCP OAuth response bounds:** reject body-less foreign error bodies without calling their inherently unbounded `text()` fallback, while preserving HTTP status and headers for safe SDK diagnostics. (#98143) Thanks @Pick-cat.
2727
- **Control UI approval prompts:** keep stale resolve failures and busy-state cleanup from leaking across newer approvals or Gateway reconnects. (#98394) Thanks @haruaiclone-droid.
28-
- **Control UI completed-run state:** keep the composer on Send when stale session updates repeat a completed run, while preserving newer active runs across refresh and reconnect. (#91680) Thanks @tiffanychum.
2928
- **Agent empty replies:** surface a visible failure when a completed interactive turn has no deliverable reply, including queued follow-ups, while preserving explicit silence, pending continuations, and committed side effects, honoring queued send policies, and treating compaction notices as progress. (#100456) Thanks @mushuiyu886.
3029
- **Child process output safety:** prevent stdout/stderr pipe failures from crashing agent exec sessions, local TUI shell commands, and bounded process execution. (#100407, #100406, #100410) Thanks @cxbAsDev.
3130
- **Background refresh isolation:** keep remote skill-bin refreshes running when one node fails, and contain periodic subagent-sweeper failures without hiding errors from direct callers. (#100393, #100390) Thanks @cxbAsDev.
@@ -72,6 +71,7 @@ Docs: https://docs.openclaw.ai
7271
- **TUI new-session hooks:** create `/new` sessions through the shared Gateway lifecycle so command and session hooks receive the completed parent transcript in both Gateway and embedded modes, while preventing rollover during an active turn. (#100241, #49918) Thanks @BingqingLyu.
7372
- **TUI abort diagnostics:** show sanitized tool argument-validation summaries for aborted runs in both Gateway and local TUI modes without exposing raw model arguments. (#91002) Thanks @wsyjh8.
7473
- **iOS Watch replies:** persist queued quick replies in the gateway-scoped chat outbox and submit them through idempotent chat delivery, preventing losses, duplicates, and cross-gateway sends after reconnects. (#100031) Thanks @NianJiuZst.
74+
- **iOS Gateway auth retry:** restrict stored device-token retry to parsed loopback hosts and reject wildcard bind addresses, preventing remote lookalike hostnames from receiving trusted retry credentials. (#99859) Thanks @ly85206559.
7575

7676
## 2026.7.1
7777

apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1200,7 +1200,7 @@ public actor GatewayChannelActor {
12001200
else {
12011201
return false
12021202
}
1203-
if host == "localhost" || host == "::1" || host == "127.0.0.1" || host.hasPrefix("127.") {
1203+
if Self.isTrustedDeviceRetryLoopbackHost(host) {
12041204
return true
12051205
}
12061206
if self.url.scheme?.lowercased() == "wss",
@@ -1211,6 +1211,14 @@ public actor GatewayChannelActor {
12111211
return false
12121212
}
12131213

1214+
private static func isTrustedDeviceRetryLoopbackHost(_ host: String) -> Bool {
1215+
let normalized = LoopbackHost.normalizedHost(host)
1216+
if normalized == "0.0.0.0" || normalized == "::" {
1217+
return false
1218+
}
1219+
return LoopbackHost.isLoopbackHost(normalized)
1220+
}
1221+
12141222
private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool {
12151223
do {
12161224
try await Task.sleep(nanoseconds: nanoseconds)

apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,98 @@ struct GatewayNodeSessionTests {
13571357
await gateway.disconnect()
13581358
}
13591359

1360+
@Test(.stateDirectoryIsolated)
1361+
func `token mismatch retries stored device token only for trusted loopback hosts`() async throws {
1362+
let tempDir = FileManager.default.temporaryDirectory
1363+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
1364+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
1365+
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
1366+
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
1367+
defer {
1368+
if let previousStateDir {
1369+
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
1370+
} else {
1371+
unsetenv("OPENCLAW_STATE_DIR")
1372+
}
1373+
try? FileManager.default.removeItem(at: tempDir)
1374+
}
1375+
1376+
let identity = DeviceIdentityStore.loadOrCreate()
1377+
_ = DeviceAuthStore.storeToken(
1378+
deviceId: identity.deviceId,
1379+
role: "operator",
1380+
token: "stored-device-token")
1381+
1382+
let options = GatewayConnectOptions(
1383+
role: "operator",
1384+
scopes: ["operator.read"],
1385+
caps: [],
1386+
commands: [],
1387+
permissions: [:],
1388+
clientId: "openclaw-ios-test",
1389+
clientMode: "ui",
1390+
clientDisplayName: "iOS Test",
1391+
includeDeviceIdentity: true)
1392+
1393+
let connectError: [String: Any] = [
1394+
"code": GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
1395+
"message": "token mismatch",
1396+
"details": [
1397+
"canRetryWithDeviceToken": true,
1398+
],
1399+
]
1400+
1401+
func retryAuth(for rawURL: String) async throws -> [String: Any] {
1402+
let session = FakeGatewayWebSocketSession(connectError: connectError)
1403+
let gateway = GatewayNodeSession()
1404+
let url = try #require(URL(string: rawURL))
1405+
1406+
for _ in 0..<2 {
1407+
do {
1408+
try await gateway.connect(
1409+
url: url,
1410+
token: "shared-gateway-token",
1411+
bootstrapToken: nil,
1412+
password: nil,
1413+
connectOptions: options,
1414+
sessionBox: WebSocketSessionBox(session: session),
1415+
onConnected: {},
1416+
onDisconnected: { _ in },
1417+
onInvoke: { req in
1418+
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
1419+
})
1420+
Issue.record("connect unexpectedly succeeded")
1421+
} catch let error as GatewayConnectAuthError {
1422+
#expect(error.detail == .authTokenMismatch)
1423+
}
1424+
}
1425+
1426+
let retryAuth = try #require(session.latestTask()?.latestConnectAuth())
1427+
await gateway.disconnect()
1428+
return retryAuth
1429+
}
1430+
1431+
for rawURL in [
1432+
"ws://127.attacker.example:18789",
1433+
"ws://0.0.0.0:18789",
1434+
"ws://[::]:18789",
1435+
] {
1436+
let retryAuth = try await retryAuth(for: rawURL)
1437+
#expect(retryAuth["token"] as? String == "shared-gateway-token")
1438+
#expect(retryAuth["deviceToken"] == nil)
1439+
}
1440+
1441+
for rawURL in [
1442+
"ws://localhost:18789",
1443+
"ws://127.0.0.2:18789",
1444+
"ws://[::1]:18789",
1445+
] {
1446+
let retryAuth = try await retryAuth(for: rawURL)
1447+
#expect(retryAuth["token"] as? String == "shared-gateway-token")
1448+
#expect(retryAuth["deviceToken"] as? String == "stored-device-token")
1449+
}
1450+
}
1451+
13601452
@Test
13611453
func `normalize canvas host url preserves explicit secure canvas port`() throws {
13621454
let normalized = try canonicalizeCanvasHostUrl(

src/infra/outbound/message-action-runner.plugin-dispatch.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ function createGatewayActionPlugin(params: {
187187
gatewayActions?: ChannelMessageActionName[];
188188
capabilities?: ChannelPlugin["capabilities"];
189189
messaging?: ChannelPlugin["messaging"];
190+
threading?: ChannelPlugin["threading"];
190191
handleAction: ChannelActionHandler;
191192
}): ChannelPlugin {
192193
const actions = new Set(params.actions);
@@ -203,6 +204,7 @@ function createGatewayActionPlugin(params: {
203204
capabilities: params.capabilities ?? { chatTypes: ["direct"] },
204205
config: createAlwaysConfiguredPluginConfig(),
205206
messaging: params.messaging,
207+
threading: params.threading,
206208
actions: {
207209
describeMessageTool: () => ({ actions: params.actions }),
208210
supportsAction: ({ action }) => actions.has(action),
@@ -1570,6 +1572,90 @@ describe("runMessageAction plugin dispatch", () => {
15701572
});
15711573
});
15721574

1575+
describe("threaded plugin actions", () => {
1576+
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
1577+
jsonResult({ ok: true, params }),
1578+
);
1579+
const cfg = { channels: { forumchat: { enabled: true } } } as OpenClawConfig;
1580+
const threading: ChannelPlugin["threading"] = {
1581+
resolveAutoThreadId: ({ toolContext, to }) =>
1582+
toolContext?.currentChannelId === to ? toolContext.currentThreadTs : undefined,
1583+
};
1584+
const createThreadedPlugin = (executionMode: "local" | "gateway") =>
1585+
createGatewayActionPlugin({
1586+
pluginId: "forumchat",
1587+
label: "Forum Chat",
1588+
blurb: "Forum chat threaded action dispatch test plugin.",
1589+
actions: ["sticker"],
1590+
gatewayActions: executionMode === "gateway" ? ["sticker"] : [],
1591+
capabilities: { chatTypes: ["channel"] },
1592+
threading,
1593+
handleAction,
1594+
messaging: {
1595+
targetResolver: {
1596+
looksLikeId: () => true,
1597+
},
1598+
},
1599+
});
1600+
1601+
afterEach(() => {
1602+
setActivePluginRegistry(createTestRegistry([]));
1603+
vi.clearAllMocks();
1604+
});
1605+
1606+
it.each(["local", "gateway"] as const)(
1607+
"applies auto threadId before %s plugin dispatch",
1608+
async (executionMode) => {
1609+
setActivePluginRegistry(
1610+
createTestRegistry([
1611+
{
1612+
pluginId: "forumchat",
1613+
source: "test",
1614+
plugin: createThreadedPlugin(executionMode),
1615+
},
1616+
]),
1617+
);
1618+
mocks.callGatewayLeastPrivilege.mockResolvedValue({ ok: true });
1619+
1620+
await runMessageAction({
1621+
cfg,
1622+
action: "sticker",
1623+
params: {
1624+
channel: "forumchat",
1625+
target: "forum:123",
1626+
stickerName: "wave",
1627+
},
1628+
toolContext: {
1629+
currentChannelProvider: "forumchat",
1630+
currentChannelId: "forum:123",
1631+
currentThreadTs: "42",
1632+
},
1633+
gateway: executionMode === "gateway" ? { clientName: "cli", mode: "cli" } : undefined,
1634+
dryRun: false,
1635+
});
1636+
1637+
const dispatchedParams =
1638+
executionMode === "gateway"
1639+
? readRecordField(
1640+
readRecordField(
1641+
readMockCallArg(mocks.callGatewayLeastPrivilege, "gateway call"),
1642+
"params",
1643+
"gateway call params",
1644+
),
1645+
"params",
1646+
"gateway action params",
1647+
)
1648+
: readRecordField(readFirstPluginCall(handleAction), "params", "plugin params");
1649+
expectRecordFields(
1650+
dispatchedParams,
1651+
{ to: "forum:123", threadId: "42" },
1652+
`${executionMode} action params`,
1653+
);
1654+
expect(handleAction).toHaveBeenCalledTimes(executionMode === "local" ? 1 : 0);
1655+
},
1656+
);
1657+
});
1658+
15731659
describe("presentation-only send behavior", () => {
15741660
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
15751661
jsonResult({

src/infra/outbound/message-action-runner.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,23 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
13791379
if (!plugin?.actions?.handleAction) {
13801380
throw new Error(`Channel ${channel} is unavailable for message actions (plugin not loaded).`);
13811381
}
1382+
1383+
// Plugin actions bypass send/poll, so inherit thread metadata before either
1384+
// gateway or local dispatch to keep both execution modes on the same topic.
1385+
const targetForThreading =
1386+
normalizeOptionalString(params.to) ?? normalizeOptionalString(params.channelId) ?? "";
1387+
if (targetForThreading) {
1388+
resolveAndApplyOutboundThreadId(params, {
1389+
cfg,
1390+
to: targetForThreading,
1391+
accountId,
1392+
toolContext: input.toolContext,
1393+
resolveAutoThreadId: plugin.threading?.resolveAutoThreadId,
1394+
resolveReplyTransport: plugin.threading?.resolveReplyTransport,
1395+
replyToIsExplicit: Boolean(readStringParam(params, "replyTo")),
1396+
});
1397+
}
1398+
13821399
const gatewayPluginAction = await runGatewayPluginMessageActionOrNull({
13831400
cfg,
13841401
params,

0 commit comments

Comments
 (0)