Skip to content

Commit 3de9b42

Browse files
Merge branch 'main' into fix/usage-bar-in-operator-prototype-98466
2 parents bc978ef + f3ac874 commit 3de9b42

54 files changed

Lines changed: 1882 additions & 179 deletions

Some content is hidden

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

apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt

Lines changed: 62 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,36 @@ fun OnboardingFlow(
182182

183183
val permissionState = rememberPermissionState(context = context, viewModel = viewModel)
184184

185+
fun connectToGatewayConfig(
186+
config: GatewayConnectConfig,
187+
resetSetupAuth: Boolean,
188+
) {
189+
setupError = null
190+
attemptedGatewayName = null
191+
attemptedConnect = true
192+
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
193+
viewModel.saveGatewayConfigAndConnect(
194+
host = config.host,
195+
port = config.port,
196+
tls = config.tls,
197+
token = config.token,
198+
bootstrapToken = config.bootstrapToken,
199+
password = config.password,
200+
resetSetupAuth = resetSetupAuth,
201+
)
202+
step = OnboardingStep.Recovery
203+
}
204+
205+
fun resolveCurrentGatewayConfig(setupCodeValue: String = setupCode): GatewayConnectConfig? =
206+
resolveOnboardingGatewayConnectConfig(
207+
setupCode = setupCodeValue,
208+
manualHost = manualHost,
209+
manualPort = manualPort,
210+
manualTls = manualTls,
211+
token = token,
212+
password = password,
213+
)
214+
185215
LaunchedEffect(startAtGatewaySetup) {
186216
if (startAtGatewaySetup) {
187217
step = OnboardingStep.Gateway
@@ -261,15 +291,26 @@ fun OnboardingFlow(
261291
.startScan()
262292
.addOnSuccessListener { barcode ->
263293
val scanned = resolveScannedSetupCodeResult(barcode.rawValue.orEmpty())
264-
if (scanned.setupCode == null) {
294+
val scannedSetupCode = scanned.setupCode
295+
if (scannedSetupCode == null) {
265296
setupError =
266297
gatewayEndpointValidationMessage(
267298
scanned.error ?: GatewayEndpointValidationError.INVALID_URL,
268299
GatewayEndpointInputSource.QR_SCAN,
300+
)
301+
return@addOnSuccessListener
302+
}
303+
val config = resolveCurrentGatewayConfig(setupCodeValue = scannedSetupCode)
304+
if (config == null) {
305+
setupError =
306+
gatewayEndpointValidationMessage(
307+
GatewayEndpointValidationError.INVALID_URL,
308+
GatewayEndpointInputSource.QR_SCAN,
269309
)
270310
return@addOnSuccessListener
271311
}
272-
setupCode = scanned.setupCode
312+
setupCode = scannedSetupCode
313+
connectToGatewayConfig(config, resetSetupAuth = true)
273314
}.addOnFailureListener { setupError = "Could not open the scanner." }
274315
},
275316
onSetupCodeChange = {
@@ -296,34 +337,12 @@ fun OnboardingFlow(
296337
step = OnboardingStep.Recovery
297338
},
298339
onPair = {
299-
val config =
300-
resolveGatewayConfig(
301-
setupCode = setupCode,
302-
manualHost = manualHost,
303-
manualPort = manualPort,
304-
manualTls = manualTls,
305-
token = token,
306-
password = password,
307-
)
340+
val config = resolveCurrentGatewayConfig()
308341
if (config == null) {
309342
setupError = "Enter a setup code or a valid gateway URL."
310343
return@GatewaySetupScreen
311344
}
312-
313-
setupError = null
314-
attemptedGatewayName = null
315-
attemptedConnect = true
316-
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
317-
viewModel.saveGatewayConfigAndConnect(
318-
host = config.host,
319-
port = config.port,
320-
tls = config.tls,
321-
token = config.token,
322-
bootstrapToken = config.bootstrapToken,
323-
password = config.password,
324-
resetSetupAuth = true,
325-
)
326-
step = OnboardingStep.Recovery
345+
connectToGatewayConfig(config, resetSetupAuth = true)
327346
},
328347
)
329348
OnboardingStep.Recovery ->
@@ -339,26 +358,8 @@ fun OnboardingFlow(
339358
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
340359
onBack = { step = OnboardingStep.Gateway },
341360
onRetry = {
342-
attemptedConnect = true
343-
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
344-
val config =
345-
resolveGatewayConfig(
346-
setupCode = setupCode,
347-
manualHost = manualHost,
348-
manualPort = manualPort,
349-
manualTls = manualTls,
350-
token = token,
351-
password = password,
352-
) ?: return@GatewayRecoveryScreen
353-
viewModel.saveGatewayConfigAndConnect(
354-
host = config.host,
355-
port = config.port,
356-
tls = config.tls,
357-
token = config.token,
358-
bootstrapToken = config.bootstrapToken,
359-
password = config.password,
360-
resetSetupAuth = false,
361-
)
361+
val config = resolveCurrentGatewayConfig() ?: return@GatewayRecoveryScreen
362+
connectToGatewayConfig(config, resetSetupAuth = false)
362363
},
363364
onEdit = { step = OnboardingStep.Gateway },
364365
onContinue = { step = OnboardingStep.Permissions },
@@ -1151,59 +1152,28 @@ internal fun recoveryGatewayName(
11511152
?.takeIf { it.isNotEmpty() }
11521153
?: "Home Gateway"
11531154

1154-
private data class GatewayConfig(
1155-
val host: String,
1156-
val port: Int,
1157-
val tls: Boolean,
1158-
val bootstrapToken: String,
1159-
val token: String,
1160-
val password: String,
1161-
)
1162-
1163-
/** Resolves setup-code or manual fields into the gateway config used for first connect. */
1164-
private fun resolveGatewayConfig(
1155+
/** Resolves onboarding setup-code or manual fields into the gateway config used for connect. */
1156+
internal fun resolveOnboardingGatewayConnectConfig(
11651157
setupCode: String,
11661158
manualHost: String,
11671159
manualPort: String,
11681160
manualTls: Boolean,
11691161
token: String,
11701162
password: String,
1171-
): GatewayConfig? {
1172-
val setup = setupCode.takeIf { it.isNotBlank() }?.let(::decodeGatewaySetupCode)
1173-
if (setup != null) {
1174-
val endpoint = parseGatewayEndpointResult(setup.url).config ?: return null
1175-
val bootstrapToken = setup.bootstrapToken?.trim().orEmpty()
1176-
// Bootstrap setup codes own first-pairing auth; fall back to typed token or
1177-
// password only for non-bootstrap setup payloads.
1178-
return GatewayConfig(
1179-
host = endpoint.host,
1180-
port = endpoint.port,
1181-
tls = endpoint.tls,
1182-
bootstrapToken = bootstrapToken,
1183-
token =
1184-
setup.token
1185-
?.trim()
1186-
.orEmpty()
1187-
.ifEmpty { if (bootstrapToken.isEmpty()) token.trim() else "" },
1188-
password =
1189-
setup.password
1190-
?.trim()
1191-
.orEmpty()
1192-
.ifEmpty { if (bootstrapToken.isEmpty()) password.trim() else "" },
1193-
)
1194-
}
1195-
1196-
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null
1197-
val endpoint = parseGatewayEndpointResult(manualUrl).config ?: return null
1198-
return GatewayConfig(
1199-
host = endpoint.host,
1200-
port = endpoint.port,
1201-
tls = endpoint.tls,
1202-
bootstrapToken = "",
1203-
token = token.trim(),
1204-
password = password.trim(),
1163+
): GatewayConnectConfig? =
1164+
resolveGatewayConnectConfig(
1165+
useSetupCode = setupCode.isNotBlank(),
1166+
setupCode = setupCode,
1167+
savedManualHost = manualHost,
1168+
savedManualPort = manualPort,
1169+
savedManualTls = manualTls,
1170+
manualHostInput = manualHost,
1171+
manualPortInput = manualPort,
1172+
manualTlsInput = manualTls,
1173+
fallbackBootstrapToken = "",
1174+
fallbackToken = token,
1175+
fallbackPassword = password,
12051176
)
1206-
}
12071177

12081178
/** Selects the recovery detail line from endpoint metadata and transient gateway status. */
12091179
internal fun recoveryGatewayDetail(

apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import kotlinx.coroutines.launch
77
import kotlinx.coroutines.test.runTest
88
import org.junit.Assert.assertEquals
99
import org.junit.Assert.assertFalse
10+
import org.junit.Assert.assertNull
1011
import org.junit.Assert.assertTrue
1112
import org.junit.Test
13+
import java.util.Base64
1214

1315
class OnboardingFlowLogicTest {
1416
@Test
@@ -432,4 +434,51 @@ class OnboardingFlowLogicTest {
432434
),
433435
)
434436
}
437+
438+
@Test
439+
fun resolvesOnboardingSetupCodeConnectConfigForScannedQr() {
440+
val setupCode =
441+
encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""")
442+
val scanned = resolveScannedSetupCodeResult(setupCode)
443+
444+
val resolved =
445+
resolveOnboardingGatewayConnectConfig(
446+
setupCode = requireNotNull(scanned.setupCode),
447+
manualHost = "127.0.0.1",
448+
manualPort = "18789",
449+
manualTls = false,
450+
token = "stale-shared-token",
451+
password = "stale-shared-password",
452+
)
453+
454+
assertEquals("10.0.2.2", resolved?.host)
455+
assertEquals(18789, resolved?.port)
456+
assertEquals(false, resolved?.tls)
457+
assertEquals("bootstrap-1", resolved?.bootstrapToken)
458+
assertEquals("", resolved?.token)
459+
assertEquals("", resolved?.password)
460+
assertNull(scanned.error)
461+
}
462+
463+
@Test
464+
fun resolvesOnboardingManualConnectConfigWhenSetupCodeIsBlank() {
465+
val resolved =
466+
resolveOnboardingGatewayConnectConfig(
467+
setupCode = "",
468+
manualHost = "127.0.0.1",
469+
manualPort = "18789",
470+
manualTls = false,
471+
token = "shared-token",
472+
password = "shared-password",
473+
)
474+
475+
assertEquals("127.0.0.1", resolved?.host)
476+
assertEquals(18789, resolved?.port)
477+
assertEquals(false, resolved?.tls)
478+
assertEquals("", resolved?.bootstrapToken)
479+
assertEquals("shared-token", resolved?.token)
480+
assertEquals("shared-password", resolved?.password)
481+
}
482+
483+
private fun encodeSetupCode(payloadJson: String): String = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
435484
}

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ releases.
547547
| `plugin-sdk/lazy-runtime` | Lazy runtime helpers | `createLazyRuntimeModule`, `createLazyRuntimeMethod`, `createLazyRuntimeMethodBinder`, `createLazyRuntimeNamedExport`, `createLazyRuntimeSurface` |
548548
| `plugin-sdk/process-runtime` | Process helpers | Shared exec helpers |
549549
| `plugin-sdk/cli-runtime` | CLI runtime helpers | Command formatting, waits, version helpers |
550-
| `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client, event-loop-ready start helper, and channel-status patch helpers |
550+
| `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client, event-loop-ready start helper, advertised LAN host resolution, and channel-status patch helpers |
551551
| `plugin-sdk/config-runtime` | Deprecated config compatibility shim | Prefer `config-contracts`, `plugin-config-runtime`, `runtime-config-snapshot`, and `config-mutation` |
552552
| `plugin-sdk/telegram-command-config` | Telegram command helpers | Fallback-stable Telegram command validation helpers when the bundled Telegram contract surface is unavailable |
553553
| `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers, and structured approval display path formatting |

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ usage endpoint failed or returned no usable usage data.
232232
| `plugin-sdk/cli-runtime` | CLI formatting, wait, version, argument-invocation, and lazy command-group helpers |
233233
| `plugin-sdk/qa-live-transport-scenarios` | Shared live transport QA scenario ids, baseline coverage helpers, and scenario-selection helper |
234234
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
235-
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, and channel-status patch helpers |
235+
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers |
236236
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
237237
| `plugin-sdk/plugin-config-runtime` | Runtime plugin-config lookup helpers such as `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` |
238238
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |

extensions/copilot/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export default definePluginEntry({
2929
name: "GitHub Copilot agent runtime",
3030
description: "Registers the GitHub Copilot agent runtime.",
3131
register(api) {
32+
// Copilot is a full-runtime plugin (registers an agent harness).
33+
// Metadata-only registration paths (discovery, cli-metadata, setup-only)
34+
// cannot supply a durable session store — skip registration here and let
35+
// the full gateway activation path pick it up later.
36+
if (api.registrationMode !== "full") {
37+
return;
38+
}
3239
const poolOptions = readPoolOptions(api.pluginConfig);
3340
const sessionStore = api.runtime.state.openSyncKeyedStore<CopilotSessionBinding>({
3441
namespace: "sdk-sessions",

extensions/device-pair/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
resolveGatewayPort,
1515
resolveTailnetHostWithRunner,
1616
} from "openclaw/plugin-sdk/core";
17+
export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime";
1718
export {
1819
resolvePreferredOpenClawTmpDir,
1920
runPluginCommandWithTimeout,

extensions/device-pair/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ vi.mock("./api.js", () => {
4242
renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl,
4343
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
4444
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
45+
resolveAdvertisedLanHost: vi.fn(async () => null),
4546
resolveGatewayBindUrl: vi.fn(),
4647
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
4748
resolveTailnetHostWithRunner: vi.fn(),
@@ -59,6 +60,7 @@ vi.mock("./notify.js", () => ({
5960
import {
6061
approveDevicePairing,
6162
listDevicePairing,
63+
resolveAdvertisedLanHost,
6264
resolveGatewayBindUrl,
6365
resolveTailnetHostWithRunner,
6466
} from "./api.js";
@@ -860,6 +862,40 @@ describe("device-pair /pair default setup code", () => {
860862
expect(requireText(result)).toContain("Gateway: ws://openclaw.local:18789");
861863
});
862864

865+
it("uses the advertised LAN helper for bind-derived setup urls", async () => {
866+
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("10.211.55.3");
867+
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
868+
url: `ws://${params.pickLanHost()}:18789`,
869+
source: "gateway.bind=lan",
870+
}));
871+
const command = registerPairCommand({
872+
config: {
873+
gateway: {
874+
bind: "lan",
875+
auth: {
876+
mode: "token",
877+
token: "gateway-token",
878+
},
879+
},
880+
},
881+
pluginConfig: {
882+
publicUrl: undefined,
883+
},
884+
});
885+
const result = await command.handler(
886+
createCommandContext({
887+
channel: "webchat",
888+
args: "",
889+
commandBody: "/pair",
890+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
891+
}),
892+
);
893+
894+
expect(resolveAdvertisedLanHost).toHaveBeenCalledTimes(1);
895+
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
896+
expect(requireText(result)).toContain("Gateway: ws://10.211.55.3:18789");
897+
});
898+
863899
it("rejects public cleartext setup urls before issuing setup codes", async () => {
864900
const command = registerPairCommand({
865901
pluginConfig: {

0 commit comments

Comments
 (0)