Compare commits

..

2 Commits

Author SHA1 Message Date
Dallin Romney
7a2cccbe8b docs: tighten changed lint guidance 2026-06-18 17:41:58 -07:00
Dallin Romney
0c03ce94e8 chore: target changed lint checks 2026-06-18 16:53:39 -07:00
701 changed files with 7482 additions and 19859 deletions

View File

@@ -30,9 +30,6 @@ out of this repo. If a score needs private evidence, use the redacted
completeness-instruction paths.
- Feature `coverageIds` are ANDed proof targets, not aliases. A feature may
list multiple IDs when each ID proves part of one capability.
- Coverage IDs use dotted `namespace.behavior` form, with lowercase
alphanumeric/dash segments. Profile, surface, and category IDs may remain
dashed or dotted.
- Keep categories and feature names unique, product-shaped, and broader than raw
coverage IDs. Do not promote generic IDs into standalone feature names.
- Avoid duplicate coverage-ID bundles under different feature names in one

4
.github/labeler.yml vendored
View File

@@ -171,10 +171,6 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1686,8 +1686,7 @@ jobs:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_PROVIDERS: ${{ matrix.providers }}
OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }}
OPENCLAW_LIVE_MODELS: ${{ matrix.models || 'modern' }}
OPENCLAW_LIVE_MAX_MODELS: ${{ matrix.max_models || '6' }}
OPENCLAW_LIVE_MAX_MODELS: "6"
OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000"
OPENCLAW_SKIP_DOCKER_BUILD: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -2001,7 +2000,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: stable full
@@ -2304,7 +2303,7 @@ jobs:
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

@@ -45,7 +45,7 @@ on:
kova_ref:
description: openclaw/Kova Git ref to install
required: false
default: 4f146016583018bad9e24f8e64a6af5f963bb7ee
default: b63b6f9e20efb23641df00487e982230d81a90ac
type: string
dispatch_id:
description: Optional parent workflow dispatch identifier
@@ -66,7 +66,6 @@ env:
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
KOVA_SCENARIO_TIMEOUT_MS: "300000"
jobs:
kova:
@@ -99,7 +98,7 @@ jobs:
live: "true"
include_filters: "scenario:agent-cold-warm-message"
env:
KOVA_REF: ${{ inputs.kova_ref || '4f146016583018bad9e24f8e64a6af5f963bb7ee' }}
KOVA_REF: ${{ inputs.kova_ref || 'b63b6f9e20efb23641df00487e982230d81a90ac' }}
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
@@ -292,7 +291,6 @@ jobs:
--auth "$AUTH_MODE"
--parallel 1
--repeat "$repeat"
--timeout-ms "$KOVA_SCENARIO_TIMEOUT_MS"
--report-dir "$REPORT_DIR"
--execute
--json
@@ -363,7 +361,6 @@ jobs:
- Kova repository: ${KOVA_REPOSITORY}
- Kova ref: ${KOVA_REF}
- Kova profile: ${PROFILE}
- Kova scenario timeout: ${KOVA_SCENARIO_TIMEOUT_MS}ms
- Lane auth: ${AUTH_MODE}
- Lane model: ${PERFORMANCE_MODEL_ID}
- Lane repeat: ${repeat}

View File

@@ -532,7 +532,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail
@@ -625,7 +624,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
@@ -723,7 +721,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
@@ -818,7 +815,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"

View File

@@ -117,11 +117,11 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
## Validation

View File

@@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
This audited record covers the complete v2026.6.8..HEAD history: 373 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
@@ -410,8 +410,6 @@ This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PR
- **PR #94118** [codex] Fix Telegram rich local Markdown link hrefs. Related #94117. Thanks @dankarization and @obviyus.
- **PR #94646** refactor(sqlite): land database-first memory and proxy alignment. Thanks @vincentkoc.
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
- **PR #92135** fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.
- **PR #93737** refactor: add session maintenance transaction seam. Thanks @jalehman.
## 2026.6.8

View File

@@ -898,38 +898,32 @@ private fun SettingsShellScreen(
ProfilePanel(displayName = displayName.ifBlank { "OpenClaw" }, onClick = { onRouteChange(SettingsRoute.Profile) })
}
val settingsRows =
listOf(
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
)
settingsSections(settingsRows).forEach { section ->
item {
SettingsSectionTitle(section.title)
}
item {
SettingsGroup(rows = section.rows, onOpen = onRouteChange)
}
}
item {
SettingsSectionTitle("Account")
SettingsGroup(
rows =
listOf(
SettingsRow("Profile", displayName.ifBlank { "Local device" }, Icons.Default.Person, route = SettingsRoute.Profile),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
),
onOpen = onRouteChange,
)
}
item {
SettingsGroup(
rows = listOf(SettingsRow("Sign Out", "Disconnect", Icons.AutoMirrored.Filled.ExitToApp)),
@@ -1063,7 +1057,7 @@ private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? =
else -> null
}
internal data class SettingsRow(
private data class SettingsRow(
val title: String,
val value: String,
val icon: ImageVector,
@@ -1071,65 +1065,6 @@ internal data class SettingsRow(
val route: SettingsRoute? = null,
)
internal data class SettingsSection(
val title: String,
val rows: List<SettingsRow>,
)
internal fun settingsSections(rows: List<SettingsRow>): List<SettingsSection> =
settingsSectionOrder.mapNotNull { title ->
val sectionRows = rows.filter { row -> row.route?.let(::settingsSectionTitleForRoute) == title }
if (sectionRows.isEmpty()) null else SettingsSection(title = title, rows = sectionRows)
}
private val settingsSectionOrder =
listOf(
"Connection",
"Agents & automation",
"Phone context & privacy",
"Profile & device",
"Diagnostics",
)
internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
when (route) {
SettingsRoute.Gateway,
SettingsRoute.NodesDevices,
SettingsRoute.Channels,
-> "Connection"
SettingsRoute.Agents,
SettingsRoute.Approvals,
SettingsRoute.CronJobs,
SettingsRoute.Usage,
SettingsRoute.Skills,
SettingsRoute.Dreaming,
-> "Agents & automation"
SettingsRoute.Voice,
SettingsRoute.Canvas,
SettingsRoute.Notifications,
SettingsRoute.PhoneCapabilities,
-> "Phone context & privacy"
SettingsRoute.Profile,
SettingsRoute.Appearance,
SettingsRoute.About,
-> "Profile & device"
SettingsRoute.Health -> "Diagnostics"
SettingsRoute.Home -> "Diagnostics"
}
@Composable
private fun SettingsSectionTitle(title: String) {
Text(
text = title.uppercase(),
style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
)
}
@Composable
private fun ProfilePanel(
displayName: String,

View File

@@ -7,8 +7,6 @@ import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -157,46 +155,7 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.Notifications))
assertEquals("Profile & device", settingsSectionTitleForRoute(SettingsRoute.Appearance))
assertEquals("Diagnostics", settingsSectionTitleForRoute(SettingsRoute.Health))
}
@Test
fun settingsSectionsPreserveMeaningfulOrder() {
val sections =
settingsSections(
listOf(
settingsRow(SettingsRoute.Voice),
settingsRow(SettingsRoute.Agents),
settingsRow(SettingsRoute.Gateway),
settingsRow(SettingsRoute.Appearance),
settingsRow(SettingsRoute.Health),
),
)
assertEquals(
listOf(
"Connection",
"Agents & automation",
"Phone context & privacy",
"Profile & device",
"Diagnostics",
),
sections.map { it.title },
)
}
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(route.name, "Value", Icons.Default.Settings, route = route)
}

View File

@@ -12,7 +12,7 @@ report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchApp/Sources/**
- WatchExtension/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -3,7 +3,6 @@
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"appGroupId": "group.ai.openclawfoundation.app.shared",
"targets": [
{
"target": "OpenClaw",
@@ -12,8 +11,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
"capabilities": ["PUSH_NOTIFICATIONS"]
},
{
"target": "OpenClawShareExtension",
@@ -22,8 +20,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": ["APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
"capabilities": []
},
{
"target": "OpenClawActivityWidget",
@@ -42,6 +39,15 @@
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -7,11 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -7,12 +7,13 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -101,7 +101,6 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -156,8 +155,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
- `ai.openclawfoundation.app.watchkitapp.extension`
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.

View File

@@ -41,7 +41,5 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</dict>
</plist>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -184,8 +184,7 @@ final class ShareViewController: UIViewController {
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
includeDeviceIdentity: false)
}
do {

View File

@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
@@ -19,6 +19,7 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -53,7 +53,8 @@ struct SettingsProTab: View {
@State var suppressCredentialPersist = false
@State var locationStatusText: String?
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State var notificationStatus: SettingsNotificationStatus = .checking
@State var notificationStatusText = "Checking"
@State var notificationActionText = "Request Access"
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false

View File

@@ -65,7 +65,7 @@ extension SettingsProTab {
title: "Notifications",
detail: "Approval and event alert channel",
value: self.notificationStatusText,
color: self.notificationStatus.color)
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "rectangle.on.rectangle",
@@ -157,7 +157,7 @@ extension SettingsProTab {
gatewayConnected: self.gatewayDiagnosticConnected,
discoveredGatewayCount: self.gatewayController.gateways.count,
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
notificationsAllowed: self.notificationStatus == .allowed)
notificationStatusText: self.notificationStatusText)
self.diagnosticsIssueCount = issueCount
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
}
@@ -422,8 +422,8 @@ extension SettingsProTab {
}
func handleNotificationAction() {
if self.notificationStatus.shouldOpenNotificationSettings {
self.openNotificationSettings()
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
self.openSystemSettings()
return
}
@@ -434,14 +434,28 @@ extension SettingsProTab {
.sound,
])) ?? false
await MainActor.run {
self.notificationStatus = granted ? .allowed : .notAllowed
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
}
}
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
self.notificationStatus = SettingsNotificationStatus(status)
switch status {
case .authorized, .provisional, .ephemeral:
self.notificationStatusText = "Allowed"
self.notificationActionText = "Open System Settings"
case .denied:
self.notificationStatusText = "Not Allowed"
self.notificationActionText = "Open System Settings"
case .notDetermined:
self.notificationStatusText = "Not Set"
self.notificationActionText = "Request Access"
@unknown default:
self.notificationStatusText = "Unknown"
self.notificationActionText = "Open System Settings"
}
}
func persistGatewayToken(_ value: String) {
@@ -462,8 +476,8 @@ extension SettingsProTab {
instanceId: instanceId)
}
func openNotificationSettings() {
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
@@ -763,12 +777,4 @@ extension SettingsProTab {
case .always: "Always"
}
}
var notificationStatusText: String {
self.notificationStatus.text
}
var notificationActionText: String {
self.notificationStatus.actionTitle
}
}

View File

@@ -492,7 +492,7 @@ extension SettingsProTab {
title: "Notifications",
detail: "Approvals and event alerts from OpenClaw.",
value: self.notificationStatusText,
color: self.notificationStatus.color)
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
@@ -501,7 +501,7 @@ extension SettingsProTab {
} label: {
Label(
self.notificationActionText,
systemImage: self.notificationStatus.actionIcon)
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)

View File

@@ -1,7 +1,6 @@
import Darwin
import OpenClawKit
import SwiftUI
import UserNotifications
enum SettingsRoute: Hashable {
case gateway
@@ -66,63 +65,6 @@ struct SettingsApprovalRow: View {
}
}
enum SettingsNotificationStatus: Equatable {
case checking
case allowed
case notAllowed
case notSet
case unknown
init(_ status: UNAuthorizationStatus) {
switch status {
case .authorized, .provisional, .ephemeral:
self = .allowed
case .denied:
self = .notAllowed
case .notDetermined:
self = .notSet
@unknown default:
self = .unknown
}
}
var text: String {
switch self {
case .checking: "Checking"
case .allowed: "Allowed"
case .notAllowed: "Not Allowed"
case .notSet: "Not Set"
case .unknown: "Unknown"
}
}
var actionTitle: String {
switch self {
case .notSet, .checking:
"Request Access"
case .allowed, .notAllowed, .unknown:
"Open System Settings"
}
}
var actionIcon: String {
self == .allowed ? "gear" : "bell.badge"
}
var color: Color {
self == .allowed ? OpenClawBrand.ok : .secondary
}
var shouldOpenNotificationSettings: Bool {
switch self {
case .allowed, .notAllowed, .unknown:
true
case .checking, .notSet:
false
}
}
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
case gatewayOffline
case discoveryUnavailable
@@ -135,13 +77,13 @@ enum SettingsDiagnostics {
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationsAllowed: Bool) -> [SettingsDiagnosticIssue]
notificationStatusText: String) -> [SettingsDiagnosticIssue]
{
var issues: [SettingsDiagnosticIssue] = []
if !gatewayConnected { issues.append(.gatewayOffline) }
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
if !notificationsAllowed { issues.append(.notificationsUnavailable) }
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
return issues
}
@@ -149,13 +91,13 @@ enum SettingsDiagnostics {
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationsAllowed: Bool) -> Int
notificationStatusText: String) -> Int
{
self.issues(
gatewayConnected: gatewayConnected,
discoveredGatewayCount: discoveredGatewayCount,
talkConfigLoaded: talkConfigLoaded,
notificationsAllowed: notificationsAllowed).count
notificationStatusText: notificationStatusText).count
}
static func timestamp(_ date: Date) -> String {

View File

@@ -62,7 +62,6 @@ struct GatewayConnectConfig {
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&

View File

@@ -78,8 +78,6 @@
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>

View File

@@ -18,7 +18,6 @@ enum GatewayOnboardingReset {
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)

View File

@@ -4,9 +4,5 @@
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -109,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
ShareExtension/ShareViewController.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift
WatchApp/Sources/OpenClawWatchApp.swift
WatchApp/Sources/WatchConnectivityReceiver.swift
WatchApp/Sources/WatchInboxStore.swift
WatchApp/Sources/WatchInboxView.swift
WatchExtension/Sources/OpenClawWatchApp.swift
WatchExtension/Sources/WatchConnectivityReceiver.swift
WatchExtension/Sources/WatchInboxStore.swift
WatchExtension/Sources/WatchInboxView.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

View File

@@ -8,7 +8,7 @@ import Testing
gatewayConnected: false,
discoveredGatewayCount: 0,
talkConfigLoaded: false,
notificationsAllowed: false) == [
notificationStatusText: "Not Set") == [
.gatewayOffline,
.discoveryUnavailable,
.notificationsUnavailable,
@@ -21,12 +21,12 @@ import Testing
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: false,
notificationsAllowed: true) == [.talkConfigMissing])
notificationStatusText: "Allowed") == [.talkConfigMissing])
#expect(
SettingsDiagnostics.issueCount(
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: true,
notificationsAllowed: true) == 0)
notificationStatusText: "Allowed") == 0)
}
}

View File

@@ -3,10 +3,6 @@ import OpenClawKit
import Testing
@Suite struct ShareToAgentDeepLinkTests {
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
}
@Test func buildMessageIncludesSharedFields() {
let payload = SharedContentPayload(
title: "Article",

View File

@@ -20,9 +20,9 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClaw</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>WKAppBundleIdentifier</key>
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.watchkit</string>
</dict>
</dict>
</plist>

View File

@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
suggestions: [String],
onSubmit: @escaping (String) -> Void)
{
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
withSuggestions: suggestions,
allowedInputMode: .allowEmoji)
{ results in

View File

@@ -293,8 +293,6 @@ def capture_watch_screenshot
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
# Single-target watch apps only expose generic simulator build destinations in Xcode.
# Keep the selected UDID for install/launch/screenshot below.
sh(
xcodebuild_shell_join([
"xcodebuild",
@@ -305,7 +303,7 @@ def capture_watch_screenshot
"-configuration",
"Debug",
"-destination",
"generic/platform=watchOS Simulator",
"platform=watchOS Simulator,id=#{udid}",
"-derivedDataPath",
derived_data_path,
"build",
@@ -313,8 +311,10 @@ def capture_watch_screenshot
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
watch_app_identifier = bundle_identifier_for_product(app_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier]
watch_extension_identifier = bundle_identifier_for_product(extension_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
@@ -492,9 +492,6 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
services
end
@@ -570,15 +567,6 @@ def profile_plist_value(profile_path, key_path)
end
end
def profile_plist_array_values(profile_path, key_path)
raw = profile_plist_value(profile_path, key_path)
return [] unless raw
raw.lines.map(&:strip).reject do |line|
line.empty? || line == "Array {" || line == "}"
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
@@ -594,17 +582,6 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_GROUPS")
expected_app_groups = target.fetch("appGroups")
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
missing = expected_app_groups - actual_app_groups
unless missing.empty?
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -65,8 +65,6 @@ targets:
embed: true
- target: OpenClawActivityWidget
embed: true
# A companion watch application belongs in the standard Watch bundle location.
# PlugIns is for extension products and breaks paired watch installation.
- target: OpenClawWatchApp
- package: OpenClawKit
- package: OpenClawKit
@@ -90,7 +88,7 @@ targets:
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint
basedOnDependencyAnalysis: false
@@ -142,7 +140,6 @@ targets:
- openclaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -195,7 +192,6 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -210,7 +206,6 @@ targets:
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -256,17 +251,13 @@ targets:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
OpenClawWatchApp:
type: application
type: application.watchapp2
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchApp
excludes:
- Info.plist
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
- target: OpenClawWatchExtension
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
@@ -283,8 +274,6 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
info:
path: WatchApp/Info.plist
properties:
@@ -292,7 +281,42 @@ targets:
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKApplication: true
WKWatchKitApp: true
OpenClawWatchExtension:
type: watchkit2-extension
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchExtension/Sources
- path: WatchExtension/Assets.xcassets
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
NSExtensionPointIdentifier: com.apple.watchkit
OpenClawTests:
type: bundle.unit-test

View File

@@ -1,32 +0,0 @@
import AppKit
import WebKit
extension CanvasWindowController {
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in canvas HTML to a native `NSOpenPanel`.
/// Without a `WKUIDelegate`, WebKit silently drops the request and file-picker
/// buttons in canvas pages do nothing.
@MainActor
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
}

View File

@@ -5,7 +5,7 @@ import OpenClawKit
import WebKit
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
let sessionKey: String
private let root: URL
private let sessionDir: URL
@@ -159,7 +159,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WK
}
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()

View File

@@ -19,7 +19,7 @@ private final class DashboardWindowDragRegionView: NSView {
}
@MainActor
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
private let webView: WKWebView
private var currentURL: URL
private var auth: DashboardWindowAuth
@@ -44,37 +44,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
super.init(window: window)
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
}
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in the embedded Control UI to a native
/// `NSOpenPanel`; without a `WKUIDelegate`, WebKit silently drops the request
/// and "Choose image" / file-picker buttons do nothing.
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")

View File

@@ -21,12 +21,10 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
public static func loadToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -35,11 +33,10 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = [],
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
scopes: [String] = []) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore(profile: profile)
var next = self.readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -53,25 +50,17 @@ public enum DeviceAuthStore {
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store, profile: profile)
self.writeStore(store)
}
return entry
}
public static func clearToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store, profile: profile)
}
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
self.writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
@@ -85,14 +74,14 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.authFileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
let url = self.fileURL(profile: profile)
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -101,8 +90,8 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
let url = self.fileURL(profile: profile)
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -1,29 +1,6 @@
import CryptoKit
import Foundation
public enum GatewayDeviceIdentityProfile: String, Sendable {
case primary
case shareExtension
var identityFileName: String {
switch self {
case .primary:
"device.json"
case .shareExtension:
"share-device.json"
}
}
var authFileName: String {
switch self {
case .primary:
"device-auth.json"
case .shareExtension:
"share-device-auth.json"
}
}
}
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
@@ -42,32 +19,6 @@ enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
static func stateDirURL() -> URL {
self.stateDirURL(
overrideURL: self.stateDirOverrideURL(),
legacyStateDirURL: self.legacyStateDirURL(),
appGroupStateDirURL: self.appGroupStateDirURL(),
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
appGroupStateDirURL: URL?,
temporaryDirectory: URL) -> URL
{
if let overrideURL {
return overrideURL
}
if let appGroupStateDirURL {
return appGroupStateDirURL
}
if let legacyStateDirURL {
return legacyStateDirURL
}
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
private static func stateDirOverrideURL() -> URL? {
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -76,49 +27,34 @@ enum DeviceIdentityPaths {
}
}
}
return nil
}
private static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
return nil
}
private static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
else {
return nil
}
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(profile: .primary)
}
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
self.loadOrCreate(fileURL: self.fileURL())
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case let .identity(decoded):
case .identity(let decoded):
return decoded
case .recognizedInvalid:
return self.generate()
@@ -207,7 +143,7 @@ public enum DeviceIdentityStore {
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32, privateKeyData.count == 32,
guard publicKeyData.count == 32 && privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
@@ -275,11 +211,11 @@ public enum DeviceIdentityStore {
}
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
private static func fileURL() -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
}

View File

@@ -107,7 +107,6 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
@@ -123,7 +122,6 @@ public struct GatewayConnectOptions: Sendable {
clientId: String,
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
{
self.role = role
@@ -135,7 +133,6 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -439,15 +436,13 @@ public actor GatewayChannelActor {
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let deviceIdentityProfile = options.deviceIdentityProfile
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
let scopes = self.resolveConnectScopes(
@@ -537,11 +532,7 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceIdentityProfile: deviceIdentityProfile)
try await self.handleConnectResponse(response, identity: identity, role: role)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -559,10 +550,7 @@ public actor GatewayChannelActor {
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
profile: deviceIdentityProfile)
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
@@ -571,7 +559,6 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
{
@@ -581,7 +568,7 @@ public actor GatewayChannelActor {
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -769,8 +756,7 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
scopes: [String])
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
@@ -779,8 +765,7 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes,
profile: deviceIdentityProfile)
scopes: filteredScopes)
}
private func persistIssuedDeviceToken(
@@ -788,8 +773,7 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
scopes: [String])
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
@@ -799,23 +783,20 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
profile: deviceIdentityProfile)
scopes: scopes)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
role: String) async throws
{
if res.ok == false {
let error = res.error
@@ -874,8 +855,7 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -893,8 +873,7 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
}
}
}

View File

@@ -162,7 +162,6 @@ public actor GatewayNodeSession {
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
@@ -180,7 +179,6 @@ public actor GatewayNodeSession {
clientId,
clientMode,
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
permissions,
].joined(separator: "|")

View File

@@ -1,11 +0,0 @@
import Foundation
public enum OpenClawAppGroup {
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
public static var identifier: String {
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
}
}

View File

@@ -26,7 +26,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
}
public enum ShareGatewayRelaySettings {
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let suiteName = "group.ai.openclaw.shared"
private static let relayConfigKey = "share.gatewayRelay.config.v1"
private static let lastEventKey = "share.gatewayRelay.event.v1"

View File

@@ -1,7 +1,7 @@
import Foundation
public enum ShareToAgentSettings {
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let suiteName = "group.ai.openclaw.shared"
private static let defaultInstructionKey = "share.defaultInstruction"
private static var defaults: UserDefaults {

View File

@@ -548,7 +548,6 @@ public struct MessageActionParams: Codable, Sendable {
public let action: String
public let params: [String: AnyCodable]
public let accountid: String?
public let requesteraccountid: String?
public let requestersenderid: String?
public let senderisowner: Bool?
public let sessionkey: String?
@@ -563,7 +562,6 @@ public struct MessageActionParams: Codable, Sendable {
action: String,
params: [String: AnyCodable],
accountid: String?,
requesteraccountid: String? = nil,
requestersenderid: String?,
senderisowner: Bool?,
sessionkey: String?,
@@ -577,7 +575,6 @@ public struct MessageActionParams: Codable, Sendable {
self.action = action
self.params = params
self.accountid = accountid
self.requesteraccountid = requesteraccountid
self.requestersenderid = requestersenderid
self.senderisowner = senderisowner
self.sessionkey = sessionkey
@@ -593,7 +590,6 @@ public struct MessageActionParams: Codable, Sendable {
case action
case params
case accountid = "accountId"
case requesteraccountid = "requesterAccountId"
case requestersenderid = "requesterSenderId"
case senderisowner = "senderIsOwner"
case sessionkey = "sessionKey"

View File

@@ -5,99 +5,8 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test
func `state directory override wins over shared app group storage`() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true)
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: overrideURL,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == overrideURL)
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
}
@Test
func `shared app group storage wins over legacy app support storage`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == sharedURL)
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
func `share extension profile uses separate identity and auth files`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-token",
profile: .shareExtension)
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
#expect(FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(
DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
"share-token")
DeviceAuthStore.clearAll(profile: .shareExtension)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
}
@Test
func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws {
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
func loadsTypeScriptPEMIdentitySchema() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -131,8 +40,8 @@ struct DeviceIdentityStoreTests {
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
}
@Test
func `does not overwrite a recognized invalid TypeScript identity schema`() throws {
@Test("does not overwrite a recognized invalid TypeScript identity schema")
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -143,14 +52,14 @@ struct DeviceIdentityStoreTests {
at: identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = """
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)

View File

@@ -1,22 +0,0 @@
import OpenClawProtocol
import Testing
struct GatewayModelsCompatibilityTests {
@Test
func messageActionParamsKeepsRequesterAccountAdditive() {
let params = MessageActionParams(
channel: "slack",
action: "member-info",
params: [:],
accountid: "default",
requestersenderid: "U123",
senderisowner: true,
sessionkey: nil,
sessionid: nil,
toolcontext: nil,
idempotencykey: "test"
)
#expect(params.requesteraccountid == nil)
}
}

View File

@@ -1,10 +1,10 @@
import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawKit
import OpenClawProtocol
extension NSLock {
fileprivate func withLock<T>(_ body: () -> T) -> T {
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
@@ -18,9 +18,7 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
self.callbacks = callbacks
}
var state: URLSessionTask.State {
.running
}
var state: URLSessionTask.State { .running }
func resume() {}
@@ -55,7 +53,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var connectDevice: [String: Any]?
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@@ -76,10 +73,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
@@ -98,13 +92,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
let device = params?["device"] as? [String: Any]
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
self.lock.withLock {
self.connectRequestId = id
self.connectAuth = auth
self.connectDevice = device
}
}
}
@@ -113,10 +104,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.lock.withLock { self.connectAuth }
}
func latestConnectDevice() -> [String: Any]? {
self.lock.withLock { self.connectDevice }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@@ -147,10 +134,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
func emitReceiveFailure() {
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
self._state = .canceling
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
@@ -191,7 +175,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"policy": [
"maxPayload": 1,
"maxBufferedBytes": 1,
"tickIntervalMs": 30000,
"tickIntervalMs": 30_000,
],
"auth": [:],
]
@@ -239,25 +223,20 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
private actor SeqGapProbe {
private var saw = false
func mark() {
self.saw = true
}
func value() -> Bool {
self.saw
}
func mark() { self.saw = true }
func value() -> Bool { self.saw }
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func `websocket ping ignores duplicate success callbacks`() async throws {
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
try await WebSocketTaskBox(task: task).sendPing()
}
@Test
func `websocket ping ignores duplicate callbacks after first error`() async throws {
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
let firstError = URLError(.networkConnectionLost)
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
@@ -270,7 +249,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -305,7 +284,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -326,74 +305,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `share extension identity profile uses separate node identity and token store`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-node-token")
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "share-node-token",
"role": "node",
"scopes": [],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: "shared-password",
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let shareDevice = try #require(session.latestTask()?.latestConnectDevice())
let shareDeviceId = try #require(shareDevice["id"] as? String)
#expect(shareDeviceId != primaryIdentity.deviceId)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
.token == "primary-node-token")
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
#expect(
DeviceAuthStore
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
"share-node-token")
await gateway.disconnect()
}
@Test
func `password takes precedence over bootstrap token`() async throws {
func passwordTakesPrecedenceOverBootstrapToken() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -408,7 +320,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "stale-bootstrap-token",
password: "shared-password",
@@ -429,7 +341,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `changed session box rebuilds existing gateway channel`() async throws {
func changedSessionBoxRebuildsExistingGatewayChannel() async throws {
let firstSession = FakeGatewayWebSocketSession()
let secondSession = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
@@ -445,7 +357,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -458,7 +370,7 @@ struct GatewayNodeSessionTests {
})
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -477,7 +389,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `bootstrap hello stores additional device tokens`() async throws {
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -528,7 +440,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -556,7 +468,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -597,7 +509,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -618,7 +530,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -662,7 +574,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -681,25 +593,25 @@ struct GatewayNodeSessionTests {
}
@Test
func `normalize canvas host url preserves explicit secure canvas port`() throws {
let normalized = try canonicalizeCanvasHostUrl(
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: #require(URL(string: "wss://gateway.example.com")))
activeURL: URL(string: "wss://gateway.example.com")!)
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func `normalize canvas host url backfills gateway host for loopback canvas`() throws {
let normalized = try canonicalizeCanvasHostUrl(
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: #require(URL(string: "wss://gateway.example.com:7443")))
activeURL: URL(string: "wss://gateway.example.com:7443")!)
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func `invoke with timeout returns underlying response before timeout`() async {
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -707,7 +619,8 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
})
}
)
#expect(response.ok == true)
#expect(response.error == nil)
@@ -715,7 +628,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `invoke with timeout returns timeout error`() async {
func invokeWithTimeoutReturnsTimeoutError() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -723,7 +636,8 @@ struct GatewayNodeSessionTests {
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
})
}
)
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
@@ -731,7 +645,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `invoke with timeout zero disables timeout`() async {
func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -739,14 +653,15 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
}
)
#expect(response.ok == true)
#expect(response.error == nil)
}
@Test
func `emits synthetic seq gap after reconnect snapshot`() async throws {
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -772,7 +687,7 @@ struct GatewayNodeSessionTests {
}
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: nil,
password: nil,

View File

@@ -1,4 +1,4 @@
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
e78623d6eace69e46950cd5d9a5cf14aa910dac1ecdf9d054a0bd9999e936061 config-baseline.json
5ecafa3c9a59fc0675f964f6e3238b2f20625376ebad1835278c5dd7323770d3 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json
7c2c51b795d32e4c4c325080d59fec8fd11317c41db7db642f70e436779738bc config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
c0ead0a6a428d4517c7ee5f09aa0151ba18f7051bc5c9806562dec544dfad20b plugin-sdk-api-baseline.json
d4a0b6915c2ec8c68371b18b7a0999e48678ee243e7e9d41932d4d96390540cf plugin-sdk-api-baseline.jsonl
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl

View File

@@ -1194,9 +1194,5 @@
{
"source": "cohere",
"target": "cohere"
},
{
"source": "Zalo ClawBot",
"target": "Zalo ClawBot"
}
]

View File

@@ -52,7 +52,6 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing.
- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin).
- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin).
- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin).
- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin).
## Notes

View File

@@ -1409,14 +1409,10 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
- Block actions, shortcuts, and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- block actions: selected values, labels, picker values, and `workflow_*` metadata
- global shortcuts: callback and actor metadata, routed to the actor's direct session
- message shortcuts: callback, actor, channel, thread, and selected-message context
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
Define global or message shortcuts in your Slack app configuration and use any non-empty callback ID. OpenClaw acknowledges matching shortcut payloads, applies the same DM/channel sender policy as other Slack interactions, and queues the sanitized event for the routed agent session. Trigger IDs and response URLs are redacted from agent context.
## Configuration reference
Primary reference: [Configuration reference - Slack](/gateway/config-channels#slack).

View File

@@ -1,95 +0,0 @@
---
summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin"
read_when:
- You want a personal Zalo assistant bot with QR-code login
- You are installing or troubleshooting the openclaw-zaloclawbot channel plugin
title: "Zalo ClawBot"
---
OpenClaw connects to Zalo ClawBot through the catalog-listed external
`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR
code.
## Compatibility
| Plugin Version | OpenClaw Version | npm dist-tag | Status |
| -------------- | ---------------- | ------------ | ------------- |
| 0.1.x | >=2026.4.10 | `latest` | Active / Beta |
## Prerequisites
- Node.js **>= 22**
- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available).
- A Zalo account on a mobile device to scan the login QR code.
## Install with onboard (recommended)
Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu:
```bash
openclaw onboard
```
The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed.
## Manual Installation
To add the channel to an already-onboarded gateway, follow these steps:
### 1. Install the plugin
```bash
openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4"
```
Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install.
### 2. Enable the plugin in config
```bash
openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true
```
### 3. Generate QR code and log in
```bash
openclaw channels login --channel openclaw-zaloclawbot
```
Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session.
### 4. Restart the gateway
```bash
openclaw gateway restart
```
---
## How It Works
Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure:
1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID.
2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure.
3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of
browser or web-session automation.
## Under the Hood
The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime:
- Long-poll connections utilize the `getUpdates` endpoint.
- Webhooks are disabled by default for local desktop/terminal gateway runs.
- Messages are processed client-side and mapped directly to your local agent runtime.
The external plugin manages bot credentials under the OpenClaw state directory.
Treat that directory as sensitive and include it in the same access-control and
backup policy as the rest of your OpenClaw state.
---
## Troubleshooting
- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one.
- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger.

View File

@@ -315,7 +315,7 @@ Current existing-session limits:
- `hover`, `scrollintoview`, `drag`, `select`, `fill`, and `evaluate` reject
per-call timeout overrides
- `select` supports one value only
- `wait --load networkidle` is not supported on existing-session profiles (works on managed and raw/remote CDP)
- `wait --load networkidle` is not supported
- file uploads require `--ref` / `--input-ref`, do not support CSS
`--element`, and currently support one file at a time
- dialog hooks do not support `--timeout`

View File

@@ -59,9 +59,6 @@ selected-category counts and missing coverage IDs; the individual evidence
entries remain the source of truth for the tests, coverage roles, and results.
Taxonomy feature coverage IDs are exact proof targets, not aliases. Primary
scenario coverage fulfills matching IDs; secondary coverage stays advisory.
Coverage IDs use dotted `namespace.behavior` form with lowercase
alphanumeric/dash segments; profile, surface, and category IDs may still use
the existing dashed or dotted taxonomy IDs.
Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
`smoke-ci` defaults to slim, and `--evidence-mode full` restores full entries:

View File

@@ -315,15 +315,9 @@ The same section also includes the OpenClaw source location. Git checkouts expos
source root so the agent can inspect code directly. Package installs include the GitHub
source URL and tell the agent to review source there whenever the docs are incomplete or
stale. The prompt also notes the public docs mirror, community Discord, and ClawHub
([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It frames docs as the
authority for OpenClaw self-knowledge before the model understands how OpenClaw works,
including memory/daily notes, sessions, tools, Gateway, config, commands, or project
context. The prompt tells the model to use local docs (or the docs mirror when local docs
are unavailable) first, and to treat AGENTS.md, project context, workspace/profile/memory
notes, and `memory_search` as instruction context or user memory rather than OpenClaw
design or implementation knowledge. If docs are silent or stale, the model should say so
and inspect source. The prompt also tells the model to run `openclaw status` itself when
possible, asking the user only when it lacks access.
([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It tells the model to
consult docs first for OpenClaw behavior, commands, configuration, or architecture, and to
run `openclaw status` itself when possible (asking the user only when it lacks access).
For configuration specifically, it points agents to the `gateway` tool action
`config.schema.lookup` for exact field-level docs and constraints, then to
`docs/gateway/configuration.md` and `docs/gateway/configuration-reference.md`

View File

@@ -316,10 +316,6 @@
"source": "/providers/zalo",
"destination": "/channels/zalo"
},
{
"source": "/channels/openclaw-zaloclawbot",
"destination": "/channels/zaloclawbot"
},
{
"source": "/providers/whatsapp",
"destination": "/channels/whatsapp"
@@ -1136,7 +1132,6 @@
"channels/feishu",
"channels/yuanbao",
"channels/zalo",
"channels/zaloclawbot",
"channels/zalouser"
]
},

View File

@@ -1096,7 +1096,6 @@ Notes:
traces: true,
metrics: true,
logs: false,
logsExporter: "otlp",
sampleRate: 1.0,
flushIntervalMs: 5000,
captureContent: {
@@ -1133,7 +1132,6 @@ Notes:
- `otel.headers`: extra HTTP/gRPC metadata headers sent with OTel export requests.
- `otel.serviceName`: service name for resource attributes.
- `otel.traces` / `otel.metrics` / `otel.logs`: enable trace, metrics, or log export.
- `otel.logsExporter`: log export sink: `"otlp"` (default), `"stdout"` for one JSON object per stdout line, or `"both"`.
- `otel.sampleRate`: trace sampling rate `0`-`1`.
- `otel.flushIntervalMs`: periodic telemetry flush interval in ms.
- `otel.captureContent`: opt-in raw content capture for OTEL span attributes. Defaults to off. Boolean `true` captures non-system message/tool content; the object form lets you enable `inputMessages`, `outputMessages`, `toolInputs`, `toolOutputs`, `systemPrompt`, and `toolDefinitions` explicitly.

View File

@@ -397,7 +397,6 @@ That stages grounded durable candidates into the short-term dreaming store while
- **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected).
- **macOS cloud-synced state dir**: warns when state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...` because sync-backed paths can cause slower I/O and lock/sync races.
- **Linux SD or eMMC state dir**: warns when state resolves to an `mmcblk*` mount source, because SD or eMMC-backed random I/O can be slower and wear faster under session and credential writes.
- **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state with its WAL/journal sidecars will disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains.
- **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes.
- **Transcript mismatch**: warns when recent session entries have missing transcript files.
- **Main session "1-line JSONL"**: flags when the main transcript has only one line (history is not accumulating).

View File

@@ -1,5 +1,5 @@
---
summary: "Export OpenClaw diagnostics to OpenTelemetry collectors or stdout JSONL via the diagnostics-otel plugin"
summary: "Export OpenClaw diagnostics to any OpenTelemetry collector via the diagnostics-otel plugin (OTLP/HTTP)"
title: "OpenTelemetry export"
read_when:
- You want to send OpenClaw model usage, message flow, or session metrics to an OpenTelemetry collector
@@ -8,10 +8,9 @@ read_when:
---
OpenClaw exports diagnostics through the official `diagnostics-otel` plugin
using **OTLP/HTTP (protobuf)**. Logs can also be written as stdout JSONL for
container and sandbox log pipelines. Any collector or backend that accepts
OTLP/HTTP works without code changes. For local file logs and how to read them,
see [Logging](/logging).
using **OTLP/HTTP (protobuf)**. Any collector or backend that accepts OTLP/HTTP
works without code changes. For local file logs and how to read them, see
[Logging](/logging).
## How it fits together
@@ -19,8 +18,7 @@ see [Logging](/logging).
Gateway and bundled plugins for model runs, message flow, sessions, queues,
and exec.
- **`diagnostics-otel` plugin** subscribes to those events and exports them as
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP. It can
also mirror diagnostic log records to stdout JSONL.
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP.
- **Provider calls** receive a W3C `traceparent` header from OpenClaw's
trusted model-call span context when the provider transport accepts custom
headers. Plugin-emitted trace context is not propagated.
@@ -76,13 +74,11 @@ openclaw plugins enable diagnostics-otel
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Metrics** | Counters and histograms for token usage, cost, run duration, failover, skill usage, message flow, Talk events, queue lanes, session state/recovery, tool execution, oversized payloads, exec, and memory pressure. |
| **Traces** | Spans for model usage, model calls, harness lifecycle, skill usage, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
| **Logs** | Structured `logging.file` records exported over OTLP or stdout JSONL when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
Toggle `traces`, `metrics`, and `logs` independently. Traces and metrics
default to on when `diagnostics.otel.enabled` is true. Logs default to off and
are exported only when `diagnostics.otel.logs` is explicitly `true`. Log export
defaults to OTLP; set `diagnostics.otel.logsExporter` to `stdout` for JSONL on
stdout, or `both` to send each diagnostic log record to OTLP and stdout.
are exported only when `diagnostics.otel.logs` is explicitly `true`.
## Configuration reference
@@ -102,7 +98,6 @@ stdout, or `both` to send each diagnostic log record to OTLP and stdout.
traces: true,
metrics: true,
logs: true,
logsExporter: "otlp", // otlp | stdout | both
sampleRate: 0.2, // root-span sampler, 0.0..1.0
flushIntervalMs: 60000, // metric export interval (min 1000ms)
captureContent: {
@@ -181,11 +176,6 @@ on the public diagnostic event bus.
- **Logs:** OTLP logs respect `logging.level` (file log level). They use the
diagnostic log-record redaction path, not console formatting. High-volume
installs should prefer OTLP collector sampling/filtering over local sampling.
Set `diagnostics.otel.logsExporter: "stdout"` when your platform already
ships stdout/stderr to a log processor and you do not have an OTLP logs
collector. Stdout records are one JSON object per line with `ts`, `signal`,
`service.name`, severity, body, redacted attributes, and trusted trace fields
when available.
- **File-log correlation:** JSONL file logs include top-level `traceId`,
`spanId`, `parentSpanId`, and `traceFlags` when the log call carries a valid
diagnostic trace context, which lets log processors join local log lines with

View File

@@ -224,10 +224,8 @@ model-call traces become children of the active request trace, so local logs,
diagnostic snapshots, OTEL spans, and trusted provider `traceparent` headers can
be joined by `traceId` without logging raw request or model content.
Talk lifecycle log records also flow to diagnostics-otel log export when
OpenTelemetry log export is enabled, using the same bounded attributes as file
logs. Configure `diagnostics.otel.logsExporter` to choose OTLP, stdout JSONL, or
both sinks.
Talk lifecycle log records also flow to OTLP logs when OpenTelemetry log export
is enabled, using the same bounded attributes as file logs.
### Model call size and timing

View File

@@ -91,8 +91,8 @@ Supported `appServer` fields:
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
@@ -149,15 +149,11 @@ must report stable version `0.125.0` or newer.
OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires
identity-bearing WebSocket auth through `appServer.authToken` or an
`Authorization` header. `appServer.authToken` and each `appServer.headers.*`
value can be a SecretInput; the secrets runtime resolves SecretRefs and env
shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native Codex
plugins are configured, OpenClaw uses the connected app-server's plugin control
plane to install or refresh those plugins and then refreshes app inventory so
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
`Authorization` header. When native Codex plugins are configured, OpenClaw uses
the connected app-server's plugin control plane to install or refresh those
plugins and then refreshes app inventory so plugin-owned apps are visible to the
Codex thread. Only connect OpenClaw to remote app-servers that are trusted to
accept OpenClaw-managed plugin installs and app inventory refreshes.
## Approval and sandbox modes

View File

@@ -552,8 +552,8 @@ Supported `appServer` fields:
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |

View File

@@ -227,7 +227,7 @@ Each entry lists the package, distribution route, and description.
- **[deepseek](/plugins/reference/deepseek)** (`@openclaw/deepseek-provider`) - npm; ClawHub: `clawhub:@openclaw/deepseek-provider`. Adds DeepSeek model provider support to OpenClaw.
- **[diagnostics-otel](/plugins/reference/diagnostics-otel)** (`@openclaw/diagnostics-otel`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-otel`. OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs.
- **[diagnostics-otel](/plugins/reference/diagnostics-otel)** (`@openclaw/diagnostics-otel`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-otel`. OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.
- **[diagnostics-prometheus](/plugins/reference/diagnostics-prometheus)** (`@openclaw/diagnostics-prometheus`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus`. OpenClaw diagnostics Prometheus exporter for runtime metrics.

View File

@@ -1,5 +1,5 @@
---
summary: "OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs."
summary: "OpenClaw diagnostics OpenTelemetry exporter for metrics and traces."
read_when:
- You are installing, configuring, or auditing the diagnostics-otel plugin
title: "Diagnostics OpenTelemetry plugin"
@@ -7,7 +7,7 @@ title: "Diagnostics OpenTelemetry plugin"
# Diagnostics OpenTelemetry plugin
OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs.
OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.
## Distribution

View File

@@ -164,9 +164,7 @@ two-party event loops that do not go through the shared inbound reply runner.
});
```
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone.
</Accordion>
<Accordion title="api.runtime.agent.defaults">

View File

@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |

View File

@@ -504,10 +504,9 @@ Legacy aliases still normalize to the canonical bundled ids:
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
the consent page may show Grok Build even though OpenClaw does not require
the Grok Build app.
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
serves these models through the Responses API, but they do not accept the
client-side or custom tools used by OpenClaw's shared agent loop. See the
[xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations).
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.

View File

@@ -43,8 +43,6 @@ Scope intent:
- `tools.web.fetch.firecrawl.apiKey`
- `plugins.entries.acpx.config.mcpServers.*.env.*`
- `plugins.entries.brave.config.webSearch.apiKey`
- `plugins.entries.codex.config.appServer.authToken`
- `plugins.entries.codex.config.appServer.headers.*`
- `plugins.entries.exa.config.webSearch.apiKey`
- `plugins.entries.google-meet.config.realtime.providers.*.apiKey`
- `plugins.entries.google.config.webSearch.apiKey`

View File

@@ -554,20 +554,6 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.codex.config.appServer.authToken",
"configFile": "openclaw.json",
"path": "plugins.entries.codex.config.appServer.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.codex.config.appServer.headers.*",
"configFile": "openclaw.json",
"path": "plugins.entries.codex.config.appServer.headers.*",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.exa.config.webSearch.apiKey",
"configFile": "openclaw.json",

View File

@@ -322,7 +322,6 @@ You can wait on more than just time/text:
- `openclaw browser wait --url "**/dash"`
- Wait for load state:
- `openclaw browser wait --load networkidle`
- Supported on managed `openclaw` and raw/remote CDP profiles. The `user` and `existing-session` profiles reject `networkidle`; use `--url`, `--text`, a selector, or `--fn` waits there.
- Wait for a JS predicate:
- `openclaw browser wait --fn "window.ready===true"`
- Wait for a selector to become visible:

View File

@@ -743,7 +743,7 @@ Compared to the managed `openclaw` profile, existing-session drivers are more co
- **Screenshots** - page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
- **Actions** - `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported on existing-session profiles (it works on managed and raw/remote CDP profiles). Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Dialog visibility** - Managed browser action responses include `blockedByDialog` and `browserState.dialogs.pending` when an action opens a modal dialog; snapshots also include pending dialog state. Respond with `browser dialog --accept/--dismiss --dialog-id <id>` while a dialog is pending. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
- **Managed-only features** - batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.

View File

@@ -173,7 +173,6 @@ plugins.
| --- | --- |
| `/new [model]` | Archive the current session and start a fresh one |
| `/reset [soft [message]]` | Reset the current session in place. `soft` keeps the transcript, drops reused CLI backend session ids, and reruns startup |
| `/name <title>` | Name or rename the current session. Omit the title to see the current name and a suggestion |
| `/compact [instructions]` | Compact the session context. See [Compaction](/concepts/compaction) |
| `/stop` | Abort the current run |
| `/session idle <duration\|off>` | Manage thread-binding idle expiry |

View File

@@ -297,3 +297,8 @@ export function renderIsolatedCodexConfig(params: {
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
.join("\n");
}
/** Render only the project trust section for a session-local Codex config. */
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
return renderIsolatedCodexConfig({ projectPaths });
}

View File

@@ -1,11 +1,11 @@
// Bonjour tests cover ciao plugin behavior.
import { describe, expect, it } from "vitest";
const { classifyCiaoProcessError } = await import("./ciao.js");
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = await import("./ciao.js");
describe("bonjour-ciao", () => {
it("classifies ciao cancellation rejections separately from side effects", () => {
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).toEqual({
expect(classifyCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toEqual({
kind: "cancellation",
formatted: "CIAO PROBING CANCELLED",
});
@@ -13,7 +13,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao interface assertions separately from side effects", () => {
expect(
classifyCiaoProcessError(
classifyCiaoUnhandledRejection(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
),
).toEqual({
@@ -24,7 +24,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao interface assertions using changed wording", () => {
expect(
classifyCiaoProcessError(
classifyCiaoUnhandledRejection(
new Error("Reached illegal state! IPv4 address changed from undefined to defined!"),
),
).toEqual({
@@ -35,7 +35,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao netmask assertions separately from side effects", () => {
expect(
classifyCiaoProcessError(
classifyCiaoUnhandledRejection(
Object.assign(
new Error(
"IP address version must match. Netmask cannot have a version different from the address!",
@@ -52,7 +52,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao self-probe races separately from side effects", () => {
expect(
classifyCiaoProcessError(
classifyCiaoUnhandledRejection(
new Error(
"Can't probe for a service which is announced already. Received announcing for service OpenClaw Gateway._openclaw._tcp.local.",
),
@@ -65,18 +65,18 @@ describe("bonjour-ciao", () => {
});
it("suppresses ciao announcement cancellation rejections", () => {
expect(classifyCiaoProcessError(new Error("Ciao announcement cancelled by shutdown"))).not.toBe(
null,
expect(ignoreCiaoUnhandledRejection(new Error("Ciao announcement cancelled by shutdown"))).toBe(
true,
);
});
it("suppresses ciao probing cancellation rejections", () => {
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).not.toBe(null);
expect(ignoreCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toBe(true);
});
it("suppresses wrapped ciao cancellation rejections", () => {
expect(
classifyCiaoProcessError({
classifyCiaoUnhandledRejection({
reason: new Error("CIAO ANNOUNCEMENT CANCELLED"),
}),
).toEqual({
@@ -87,7 +87,7 @@ describe("bonjour-ciao", () => {
it("suppresses aggregate ciao assertion rejections", () => {
expect(
classifyCiaoProcessError(
classifyCiaoUnhandledRejection(
new AggregateError([
Object.assign(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
@@ -103,7 +103,7 @@ describe("bonjour-ciao", () => {
});
it("suppresses lower-case string cancellation reasons too", () => {
expect(classifyCiaoProcessError("ciao announcement cancelled during cleanup")).not.toBe(null);
expect(ignoreCiaoUnhandledRejection("ciao announcement cancelled during cleanup")).toBe(true);
});
it("suppresses ciao interface assertion rejections as non-fatal", () => {
@@ -112,7 +112,7 @@ describe("bonjour-ciao", () => {
{ name: "AssertionError" },
);
expect(classifyCiaoProcessError(error)).not.toBe(null);
expect(ignoreCiaoUnhandledRejection(error)).toBe(true);
});
it("suppresses ciao netmask assertion errors as non-fatal", () => {
@@ -123,7 +123,7 @@ describe("bonjour-ciao", () => {
{ name: "AssertionError" },
);
expect(classifyCiaoProcessError(error)).not.toBe(null);
expect(ignoreCiaoUnhandledRejection(error)).toBe(true);
});
it("classifies networkInterfaces SystemError failures (restricted sandboxes)", () => {
@@ -131,7 +131,7 @@ describe("bonjour-ciao", () => {
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
{ name: "SystemError" },
);
expect(classifyCiaoProcessError(err)).toEqual({
expect(classifyCiaoUnhandledRejection(err)).toEqual({
kind: "interface-enumeration-failure",
formatted:
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
@@ -144,10 +144,10 @@ describe("bonjour-ciao", () => {
{ name: "SystemError" },
);
const wrapper = new Error("ciao NetworkManager init failed", { cause: inner });
expect(classifyCiaoProcessError(wrapper)).not.toBe(null);
expect(ignoreCiaoUnhandledRejection(wrapper)).toBe(true);
});
it("keeps unrelated rejections visible", () => {
expect(classifyCiaoProcessError(new Error("boom"))).toBe(null);
expect(ignoreCiaoUnhandledRejection(new Error("boom"))).toBe(false);
});
});

View File

@@ -55,3 +55,15 @@ export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClass
}
return null;
}
/**
* Backward-compatible alias for unhandled-rejection classification.
*
* @deprecated Use classifyCiaoProcessError.
*/
export const classifyCiaoUnhandledRejection = classifyCiaoProcessError;
/** Return whether a ciao unhandled rejection is known and ignorable. */
export function ignoreCiaoUnhandledRejection(reason: unknown): boolean {
return classifyCiaoProcessError(reason) !== null;
}

View File

@@ -7,6 +7,7 @@ import {
type AriaSnapshotNode,
captureScreenshot,
createTargetViaCdp,
evaluateJavaScript,
formatAriaSnapshot,
normalizeCdpWsUrl,
type RawAXNode,
@@ -328,6 +329,47 @@ describe("cdp internal", () => {
});
});
describe("evaluateJavaScript", () => {
it("throws when Runtime.evaluate returns no result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
/Runtime\.evaluate returned no result/,
);
});
it("surfaces CDP exceptionDetails alongside result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: { type: "undefined" },
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
},
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
expect(res.exceptionDetails?.text).toBe("ReferenceError");
});
});
describe("formatAriaSnapshot", () => {
it("returns an empty array when the AX tree is empty", () => {
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
@@ -897,6 +939,27 @@ describe("cdp internal", () => {
expect(snap.nodes).toStrictEqual([]);
});
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 1 } },
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
expect(res.result.value).toBe(1);
});
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
// the fullPage finally block.
@@ -945,4 +1008,5 @@ describe("cdp internal", () => {
expect(buf.toString("utf8")).toBe("S");
});
});
});

View File

@@ -12,7 +12,7 @@ import {
isWebSocketUrl,
parseBrowserHttpUrl as parseHttpUrl,
} from "./cdp.helpers.js";
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
@@ -412,6 +412,32 @@ describe("cdp", () => {
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
expect(msg.params?.expression).toBe("1+1");
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 2 } },
}),
);
}
});
const res = await evaluateJavaScript({
wsUrl: `ws://127.0.0.1:${wsPort}`,
expression: "1+1",
});
expect(res.result.type).toBe("number");
expect(res.result.value).toBe(2);
});
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(

View File

@@ -318,6 +318,37 @@ export type CdpExceptionDetails = {
stackTrace?: unknown;
};
/** Evaluate JavaScript in a CDP target and return by value when possible. */
export async function evaluateJavaScript(opts: {
wsUrl: string;
expression: string;
awaitPromise?: boolean;
returnByValue?: boolean;
}): Promise<{
result: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
}> {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Runtime.enable").catch(() => {});
const evaluated = (await send("Runtime.evaluate", {
expression: opts.expression,
awaitPromise: Boolean(opts.awaitPromise),
returnByValue: opts.returnByValue ?? true,
userGesture: true,
includeCommandLineAPI: true,
})) as {
result?: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
};
const result = evaluated?.result;
if (!result) {
throw new Error("CDP Runtime.evaluate returned no result");
}
return { result, exceptionDetails: evaluated.exceptionDetails };
});
}
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

View File

@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clickChromeMcpCoords,
clickChromeMcpElement,
buildChromeMcpArgs,
decodeChromeMcpStderrTail,
ensureChromeMcpAvailable,
evaluateChromeMcpScript,
@@ -211,6 +212,114 @@ describe("chrome MCP page parsing", () => {
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
});
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--userDataDir",
"/tmp/brave-profile",
]);
});
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "http://127.0.0.1:9222",
userDataDir: "/tmp/brave-profile",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--wsEndpoint",
"ws://127.0.0.1:9222/devtools/browser/abc",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
expect(
buildChromeMcpArgs({
userDataDir: "/tmp/brave-profile",
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
]);
});
it("lets explicit Chrome MCP usage-statistics args override the default opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--usage-statistics",
]);
});
it("does not duplicate an explicit Chrome MCP usage-statistics opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--no-usage-statistics",
]);
});
it("omits the npx package prefix for a custom Chrome MCP command", () => {
expect(
buildChromeMcpArgs({
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
cdpUrl: "http://127.0.0.1:9222",
}),
).toEqual([
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
const session = createFakeSession();
Object.assign(session, { ownsProcessTree: true });

View File

@@ -462,6 +462,11 @@ function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOption
];
}
/** Build command-line args for launching chrome-devtools-mcp. */
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
}
function drainStderr(transport: StdioClientTransport): () => string {
const stream = transport.stderr;
if (!stream) {

View File

@@ -36,133 +36,12 @@ function createElementProgram(): Command {
return program;
}
function getLastActionBody(): Record<string, unknown> | undefined {
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
?.body;
}
describe("browser element commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it.each([
{
name: "click",
argv: [
"browser",
"click",
" ref-1 ",
"--target-id",
"tab-1",
"--double",
"--button",
"right",
"--modifiers",
"Shift, Alt",
],
expectedBody: {
kind: "click",
ref: "ref-1",
targetId: "tab-1",
doubleClick: true,
button: "right",
modifiers: ["Shift", "Alt"],
},
},
{
name: "click-coords",
argv: [
"browser",
"click-coords",
"12.5",
"42",
"--target-id",
"tab-2",
"--double",
"--button",
"middle",
"--delay-ms",
"25",
],
expectedBody: {
kind: "clickCoords",
x: 12.5,
y: 42,
targetId: "tab-2",
doubleClick: true,
button: "middle",
delayMs: 25,
},
},
{
name: "type",
argv: ["browser", "type", "input-1", "hello", "--submit", "--slowly", "--target-id", "tab-2"],
expectedBody: {
kind: "type",
ref: "input-1",
text: "hello",
submit: true,
slowly: true,
targetId: "tab-2",
},
},
{
name: "press",
argv: ["browser", "press", "Enter", "--target-id", "tab-3"],
expectedBody: { kind: "press", key: "Enter", targetId: "tab-3" },
},
{
name: "hover",
argv: ["browser", "hover", "node-1", "--target-id", "tab-4"],
expectedBody: { kind: "hover", ref: "node-1", targetId: "tab-4" },
},
{
name: "scrollintoview",
argv: ["browser", "scrollintoview", "node-2", "--target-id", "tab-5"],
expectedBody: { kind: "scrollIntoView", ref: "node-2", targetId: "tab-5" },
},
{
name: "drag",
argv: ["browser", "drag", "start-1", "end-1", "--target-id", "tab-6"],
expectedBody: {
kind: "drag",
startRef: "start-1",
endRef: "end-1",
targetId: "tab-6",
},
},
{
name: "select",
argv: ["browser", "select", "select-1", "alpha", "beta", "--target-id", "tab-7"],
expectedBody: {
kind: "select",
ref: "select-1",
values: ["alpha", "beta"],
targetId: "tab-7",
},
},
])("sends the expected $name action body", async ({ argv, expectedBody }) => {
const program = createElementProgram();
await program.parseAsync(argv, { from: "user" });
expect(getLastActionBody()).toMatchObject(expectedBody);
});
it("rejects a blank required ref before dispatch", async () => {
const program = createElementProgram();
await expect(program.parseAsync(["browser", "click", " "], { from: "user" })).rejects.toThrow(
"__exit__:1",
);
const capture = getBrowserCliRuntimeCapture();
expect(capture.runtimeErrors.join("\n")).toContain("ref is required");
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
});
it("rejects non-decimal coordinate values before dispatch", async () => {
const program = createElementProgram();

View File

@@ -31,10 +31,6 @@ vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
);
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error);
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit);
vi.spyOn(cliCoreApiModule, "resolveExistingUploadPaths").mockResolvedValue({
ok: true,
paths: ["/tmp/openclaw/uploads/a.pdf", "/tmp/openclaw/uploads/b.pdf"],
});
const { registerBrowserActionInputCommands } = await import("./register.js");
@@ -51,51 +47,10 @@ function getLastRequestOptions(): { timeoutMs?: number } | undefined {
describe("browser action input file/download commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
vi.mocked(cliCoreApiModule.resolveExistingUploadPaths).mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
getBrowserCliRuntime().exit.mockImplementation(() => {});
});
it("arms uploads with normalized paths and element targeting options", async () => {
const program = createActionInputProgram();
await program.parseAsync(
[
"browser",
"upload",
"/tmp/openclaw/uploads/a.pdf",
"media://inbound/b",
"--input-ref",
"file-input",
"--element",
"input[type=file]",
"--target-id",
"tab-1",
"--timeout-ms",
"45000",
],
{ from: "user" },
);
expect(cliCoreApiModule.resolveExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: ["/tmp/openclaw/uploads/a.pdf", "media://inbound/b"],
});
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
| { path?: string; body?: Record<string, unknown> }
| undefined;
expect(request).toMatchObject({
path: "/hooks/file-chooser",
body: {
paths: ["/tmp/openclaw/uploads/a.pdf", "/tmp/openclaw/uploads/b.pdf"],
inputRef: "file-input",
element: "input[type=file]",
targetId: "tab-1",
timeoutMs: 45000,
},
});
expect(getLastRequestOptions()?.timeoutMs).toBeGreaterThan(45000);
});
it("keeps the outer waitfordownload request open for the advertised default wait", async () => {
const program = createActionInputProgram();

View File

@@ -36,43 +36,6 @@ function createActionInputProgram(): Command {
return program;
}
function getLastActionBody(): Record<string, unknown> | undefined {
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
?.body;
}
describe("browser action input fill command", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("sends normalized fill fields and target id to the act route", async () => {
const program = createActionInputProgram();
await program.parseAsync(
[
"browser",
"fill",
"--fields",
'[{"ref":"name","value":"Ada"},{"ref":"enabled","value":true}]',
"--target-id",
"tab-1",
],
{ from: "user" },
);
expect(getLastActionBody()).toMatchObject({
kind: "fill",
fields: [
{ ref: "name", type: "text", value: "Ada" },
{ ref: "enabled", type: "text", value: true },
],
targetId: "tab-1",
});
});
});
describe("browser action input wait command", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
@@ -136,31 +99,6 @@ describe("browser action input evaluate command", () => {
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("sends evaluate function, ref, and target id to the act route", async () => {
const program = createActionInputProgram();
await program.parseAsync(
[
"browser",
"evaluate",
"--fn",
"el => el.textContent",
"--ref",
"button-1",
"--target-id",
"tab-2",
],
{ from: "user" },
);
expect(getLastActionBody()).toMatchObject({
kind: "evaluate",
fn: "el => el.textContent",
ref: "button-1",
targetId: "tab-2",
});
});
it("passes timeout-ms through to the evaluate action and outer request", async () => {
const program = createActionInputProgram();

View File

@@ -2,7 +2,6 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as browserCliResizeModule from "../browser-cli-resize.js";
import * as browserCliSharedModule from "../browser-cli-shared.js";
import {
createBrowserProgram,
getBrowserCliRuntime,
@@ -11,17 +10,9 @@ import {
import * as cliCoreApiModule from "../core-api.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn<
(
opts?: unknown,
req?: unknown,
extra?: { timeoutMs?: number },
) => Promise<Record<string, unknown>>
>(async () => ({ url: "https://example.test/landing" })),
runBrowserResizeWithOutput: vi.fn(async () => {}),
}));
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
vi.spyOn(browserCliResizeModule, "runBrowserResizeWithOutput").mockImplementation(
mocks.runBrowserResizeWithOutput,
);
@@ -43,51 +34,10 @@ function createNavigationProgram(): Command {
describe("browser navigation commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
mocks.runBrowserResizeWithOutput.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("sends navigate requests with the URL and target id", async () => {
const program = createNavigationProgram();
await program.parseAsync(
["browser", "navigate", "https://example.test/page", "--target-id", "tab-1"],
{ from: "user" },
);
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
| { method?: string; path?: string; body?: Record<string, unknown> }
| undefined;
const options = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as
| { timeoutMs?: number }
| undefined;
expect(request).toMatchObject({
method: "POST",
path: "/navigate",
body: { url: "https://example.test/page", targetId: "tab-1" },
});
expect(options?.timeoutMs).toBe(20000);
});
it("passes normalized resize dimensions and target id to the resize helper", async () => {
const program = createNavigationProgram();
await program.parseAsync(["browser", "resize", "1024", "768", "--target-id", "tab-2"], {
from: "user",
});
expect(mocks.runBrowserResizeWithOutput).toHaveBeenCalledWith(
expect.objectContaining({
width: 1024,
height: 768,
targetId: "tab-2",
timeoutMs: 20000,
successMessage: "resized to 1024x768",
}),
);
});
it("rejects non-decimal resize dimensions before dispatch", async () => {
const program = createNavigationProgram();

View File

@@ -6,7 +6,7 @@ import {
isLiveTestEnabled,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { BYTEPLUS_CODING_BASE_URL } from "./models.js";
import { BYTEPLUS_CODING_BASE_URL, BYTEPLUS_DEFAULT_COST } from "./models.js";
const BYTEPLUS_KEY = process.env.BYTEPLUS_API_KEY ?? "";
const BYTEPLUS_CODING_MODEL = process.env.BYTEPLUS_CODING_MODEL?.trim() || "ark-code-latest";
@@ -33,7 +33,7 @@ describeLive("byteplus coding plan live", () => {
baseUrl: BYTEPLUS_CODING_BASE_URL,
reasoning: false,
input: ["text"],
cost: { input: 0.0001, output: 0.0002, cacheRead: 0, cacheWrite: 0 },
cost: BYTEPLUS_DEFAULT_COST,
contextWindow: 256000,
maxTokens: 4096,
};

View File

@@ -20,6 +20,14 @@ export const BYTEPLUS_BASE_URL = BYTEPLUS_MANIFEST_PROVIDER.baseUrl;
/** Base URL for BytePlus Plan coding APIs from the manifest catalog. */
export const BYTEPLUS_CODING_BASE_URL = BYTEPLUS_CODING_MANIFEST_PROVIDER.baseUrl;
/** Fallback cost shape retained for callers that need BytePlus defaults. */
export const BYTEPLUS_DEFAULT_COST = {
input: 0.0001,
output: 0.0002,
cacheRead: 0,
cacheWrite: 0,
};
/** BytePlus general model catalog entries. */
export const BYTEPLUS_MODEL_CATALOG: ModelDefinitionConfig[] = BYTEPLUS_MANIFEST_PROVIDER.models;
/** BytePlus coding/planning model catalog entries. */

View File

@@ -1,4 +1,5 @@
export declare function isBundleHashInputPath(filePath: string, repoRoot?: string): boolean;
export declare function getLocalRolldownCliCandidates(repoRoot?: string): string[];
export declare function getBundleHashRepoInputPaths(repoRoot?: string): string[];
export declare function getBundleHashInputPaths(repoRoot?: string): string[];
export declare function compareNormalizedPaths(left: string, right: string): number;

View File

@@ -18,7 +18,9 @@ const require = createRequire(import.meta.url);
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const rootPackageFile = path.join(rootDir, "package.json");
const lockFile = path.join(rootDir, "pnpm-lock.yaml");
const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir];
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
normalizePath(path.relative(rootDir, inputPath)),
);
@@ -75,6 +77,11 @@ export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
];
}
/** Returns A2UI bundle hash input paths. */
export function getBundleHashInputPaths(repoRoot = rootDir) {
return getBundleHashRepoInputPaths(repoRoot);
}
/** Compares paths after normalizing separators to POSIX slashes. */
export function compareNormalizedPaths(left, right) {
const normalizedLeft = normalizePath(left);

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
compareNormalizedPaths,
getBundleHashInputPaths,
getBundleHashRepoInputPaths,
getLocalRolldownCliCandidates,
isBundleHashInputPath,
@@ -51,7 +52,7 @@ describe("scripts/bundle-a2ui.mjs", () => {
it("keeps local node_modules state out of bundle hash inputs", () => {
const repoRoot = process.cwd();
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
const inputPaths = getBundleHashInputPaths(repoRoot);
expect(inputPaths).not.toContain(path.join(repoRoot, "node_modules", "lit", "package.json"));
expect(inputPaths).not.toContain(

View File

@@ -5,12 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedClickClackAccount } from "./types.js";
class FakeSocket extends EventEmitter {
emitErrorOnClose = false;
close = vi.fn(() => {
if (this.emitErrorOnClose) {
this.emit("error", new Error("socket closed while connecting"));
}
this.emit("close");
});
}
@@ -196,46 +191,6 @@ describe("ClickClack gateway", () => {
await run;
});
it("reconnects after ClickClack websocket errors", async () => {
const firstSocket = new FakeSocket();
firstSocket.emitErrorOnClose = true;
const secondSocket = new FakeSocket();
mocks.client.websocket.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
firstSocket.emit("error", new Error("gateway dropped"));
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(2));
expect(ctx.log?.warn).toHaveBeenCalledWith(
"[default] ClickClack websocket error; reconnecting: gateway dropped",
);
abort.abort();
await run;
});
it("does not log reconnect warnings when abort closes a connecting websocket", async () => {
const socket = new FakeSocket();
socket.emitErrorOnClose = true;
mocks.client.websocket.mockReturnValue(socket);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
abort.abort();
await run;
expect(ctx.log?.warn).not.toHaveBeenCalledWith(
"[default] ClickClack websocket error; reconnecting: socket closed while connecting",
);
expect(mocks.client.websocket).toHaveBeenCalledTimes(1);
});
it("clears running status when backlog polling fails", async () => {
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
const abort = new AbortController();

View File

@@ -170,23 +170,11 @@ export async function startClickClackGatewayAccount(
}
const socket = client.websocket(workspaceId, afterCursor);
await new Promise<void>((resolve, reject) => {
let settled = false;
let removeAbortListener: (() => void) | undefined;
const finishSocketCycle = () => {
if (settled) {
return;
}
settled = true;
removeAbortListener?.();
removeAbortListener = undefined;
resolve();
};
const abort = () => {
socket.close();
finishSocketCycle();
resolve();
};
ctx.abortSignal.addEventListener("abort", abort, { once: true });
removeAbortListener = () => ctx.abortSignal.removeEventListener("abort", abort);
socket.on("message", (data) => {
void (async () => {
const event = parseSocketEvent(data);
@@ -206,20 +194,11 @@ export async function startClickClackGatewayAccount(
});
})().catch(reject);
});
socket.on("close", finishSocketCycle);
socket.on("error", (error) => {
if (settled || ctx.abortSignal.aborted) {
finishSocketCycle();
return;
}
ctx.log?.warn?.(
`[${account.accountId}] ClickClack websocket error; reconnecting: ${
error instanceof Error ? error.message : String(error)
}`,
);
finishSocketCycle();
socket.close();
socket.on("close", () => {
ctx.abortSignal.removeEventListener("abort", abort);
resolve();
});
socket.on("error", reject);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => {

Some files were not shown because too many files have changed in this diff Show More