mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 12:52:06 +08:00
Compare commits
6 Commits
main
...
qa-fold-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
369f2885d2 | ||
|
|
ab8db1c6b6 | ||
|
|
0ccc78b68d | ||
|
|
68da7fea24 | ||
|
|
33bf13a6e4 | ||
|
|
ae73322774 |
@@ -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
|
||||
|
||||
4
.github/workflows/openclaw-performance.yml
vendored
4
.github/workflows/openclaw-performance.yml
vendored
@@ -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
|
||||
@@ -98,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 }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
d991fae868626a78a65ea66389f3d62a48479fe6af3bb2db71a22802aa41ceea plugin-sdk-api-baseline.json
|
||||
4aa4f623cfc221bca16c00995cd278b15d1706aac074d8016d14e97e1d9423f7 plugin-sdk-api-baseline.jsonl
|
||||
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
|
||||
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -152,10 +152,10 @@
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" },
|
||||
"authToken": { "type": ["string", "object"] },
|
||||
"authToken": { "type": "string" },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": ["string", "object"] }
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"clearEnv": {
|
||||
"type": "array",
|
||||
@@ -254,14 +254,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"secretInputs": {
|
||||
"paths": [
|
||||
{ "path": "appServer.authToken", "expected": "string" },
|
||||
{ "path": "appServer.headers.*", "expected": "string" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"codexDynamicToolsLoading": {
|
||||
"label": "Dynamic Tools Loading",
|
||||
@@ -390,7 +382,6 @@
|
||||
"appServer.headers": {
|
||||
"label": "Headers",
|
||||
"help": "Additional headers sent to the WebSocket app-server.",
|
||||
"sensitive": true,
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.clearEnv": {
|
||||
|
||||
@@ -28,10 +28,6 @@ function resolveRuntimeForTest(params: RuntimeOptionsParams = {}) {
|
||||
return resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null, ...params });
|
||||
}
|
||||
|
||||
function envRef(id: string) {
|
||||
return { source: "env" as const, provider: "default", id };
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
@@ -417,65 +413,6 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved app-server SecretInput strings through to auth token and headers", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: " resolved-capability-token ",
|
||||
headers: {
|
||||
" x-codex-client-session-token ": " resolved-session-token ",
|
||||
Authorization: " Bearer explicit-token ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectFields(runtime.start, "runtime start", {
|
||||
authToken: "resolved-capability-token",
|
||||
headers: {
|
||||
"x-codex-client-session-token": "resolved-session-token",
|
||||
Authorization: "Bearer explicit-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unresolved app-server auth token SecretRefs at runtime option resolution", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: envRef("CODEX_APP_SERVER_TOKEN"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.authToken: unresolved SecretRef "env:default:CODEX_APP_SERVER_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unresolved app-server header SecretRefs at runtime option resolution", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: "capability-token",
|
||||
headers: {
|
||||
"x-codex-client-session-token": envRef("CODEX_CLIENT_SESSION_TOKEN"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.headers.x-codex-client-session-token: unresolved SecretRef "env:default:CODEX_CLIENT_SESSION_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats IPv6 loopback websocket app-servers as local loopback", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
@@ -2377,47 +2314,6 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(second).not.toContain("sk-second");
|
||||
});
|
||||
|
||||
it("derives distinct shared-client keys for distinct headers without exposing them", () => {
|
||||
const first = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer first",
|
||||
"x-codex-client-session-token": "session-first",
|
||||
},
|
||||
});
|
||||
const second = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer second",
|
||||
"x-codex-client-session-token": "session-second",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
expect(
|
||||
codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer first",
|
||||
"x-codex-client-session-token": "session-first",
|
||||
},
|
||||
}),
|
||||
).toEqual(first);
|
||||
expect(first).not.toContain("Bearer first");
|
||||
expect(first).not.toContain("session-first");
|
||||
expect(second).not.toContain("Bearer second");
|
||||
expect(second).not.toContain("session-second");
|
||||
});
|
||||
|
||||
it("keeps secret-derived shared-client keys stable across module reloads", async () => {
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
|
||||
@@ -13,11 +13,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
normalizeResolvedSecretInputString,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
@@ -216,8 +211,8 @@ export type CodexPluginConfig = {
|
||||
command?: string;
|
||||
args?: string[] | string;
|
||||
url?: string;
|
||||
authToken?: SecretInput;
|
||||
headers?: Record<string, SecretInput>;
|
||||
authToken?: string;
|
||||
headers?: Record<string, string>;
|
||||
clearEnv?: string[];
|
||||
remoteWorkspaceRoot?: string;
|
||||
codeModeOnly?: boolean;
|
||||
@@ -299,7 +294,6 @@ const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network";
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
const SecretInputSchema = buildSecretInputSchema();
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
const codexAppServerApprovalPolicySchema = z.enum([
|
||||
"never",
|
||||
@@ -393,8 +387,8 @@ const codexPluginConfigSchema = z
|
||||
command: z.string().optional(),
|
||||
args: z.union([z.array(z.string()), z.string()]).optional(),
|
||||
url: z.string().optional(),
|
||||
authToken: SecretInputSchema.optional(),
|
||||
headers: z.record(z.string(), SecretInputSchema).optional(),
|
||||
authToken: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
clearEnv: z.array(z.string()).optional(),
|
||||
remoteWorkspaceRoot: codexAppServerRemoteWorkspaceRootSchema.optional(),
|
||||
codeModeOnly: z.boolean().optional(),
|
||||
@@ -537,10 +531,7 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
|
||||
const headers = normalizeHeaders(config.headers);
|
||||
const clearEnv = normalizeStringList(config.clearEnv);
|
||||
const authToken = normalizeCodexAppServerSecretInput({
|
||||
value: config.authToken,
|
||||
path: "plugins.entries.codex.config.appServer.authToken",
|
||||
});
|
||||
const authToken = readNonEmptyString(config.authToken);
|
||||
const url = readNonEmptyString(config.url);
|
||||
const connectionClass = inferCodexAppServerConnectionClass({ transport, url });
|
||||
const remoteAppsSubstrate: CodexAppServerRemoteAppsSubstrate = "preconfigured";
|
||||
@@ -877,9 +868,9 @@ export function codexAppServerStartOptionsKey(
|
||||
args: options.args,
|
||||
url: options.url ?? null,
|
||||
authToken: hashSecretForKey(options.authToken, "authToken"),
|
||||
headers: Object.entries(options.headers)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `header:${key}`)]),
|
||||
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
env: Object.entries(options.env ?? {})
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
|
||||
@@ -2046,27 +2037,11 @@ function normalizeHeaders(value: unknown): Record<string, string> {
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(
|
||||
([key, child]) =>
|
||||
[
|
||||
key.trim(),
|
||||
normalizeCodexAppServerSecretInput({
|
||||
value: child,
|
||||
path: `plugins.entries.codex.config.appServer.headers.${key}`,
|
||||
}),
|
||||
] as const,
|
||||
)
|
||||
.map(([key, child]) => [key.trim(), readNonEmptyString(child)] as const)
|
||||
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCodexAppServerSecretInput(params: {
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
return normalizeResolvedSecretInputString(params);
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
return normalizeTrimmedStringList(value);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CODEX_TURN_START_TEXT_INPUT_MAX_CHARS,
|
||||
fitCodexProjectedContextForTurnStart,
|
||||
projectContextEngineAssemblyForCodex,
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
@@ -227,92 +226,6 @@ describe("projectContextEngineAssemblyForCodex", () => {
|
||||
expect(fitted).not.toContain("old context");
|
||||
});
|
||||
|
||||
it("bounds output when the non-context text alone exceeds the turn limit", () => {
|
||||
// A large older-context header prefix pushes before + after over maxChars
|
||||
// while the trailing user request stays small enough to keep its label.
|
||||
const before = `OpenClaw assembled context for this turn:\n${"prefix ".repeat(120)}`;
|
||||
const context = "older context ".repeat(40);
|
||||
const prompt = `urgent request ${"q".repeat(120)}`;
|
||||
const after = `\n</conversation_context>\n\nCurrent user request:\n${prompt}`;
|
||||
const promptText = `${before}${context}${after}`;
|
||||
const maxChars = 420;
|
||||
// before + after already exceed maxChars, so the context budget is non-positive.
|
||||
expect(before.length + after.length).toBeGreaterThan(maxChars);
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText,
|
||||
contextRange: { start: before.length, end: before.length + context.length },
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
// The user's actual request is the priority tail and must survive truncation.
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted.endsWith("q".repeat(40))).toBe(true);
|
||||
// The dropped older context is reported, not silently lost.
|
||||
expect(fitted).toContain("[truncated ");
|
||||
});
|
||||
|
||||
it("bounds output for a large request under the default Codex turn limit", () => {
|
||||
const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
|
||||
// A large assembled header prefix already over the cap forces the
|
||||
// non-positive context budget on the real default limit (1 << 20).
|
||||
const before = `header\n${"older history ".repeat(90_000)}`;
|
||||
const context = "x".repeat(2_000);
|
||||
const prompt = `urgent request ${"u".repeat(2_000)}`;
|
||||
const after = `\n</conversation_context>\n\nCurrent user request:\n${prompt}`;
|
||||
const promptText = `${before}${context}${after}`;
|
||||
expect(before.length + after.length).toBeGreaterThan(maxChars);
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText,
|
||||
contextRange: { start: before.length, end: before.length + context.length },
|
||||
// maxChars omitted -> defaults to CODEX_TURN_START_TEXT_INPUT_MAX_CHARS.
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
// The user request is the priority tail and survives even though the older
|
||||
// header text is truncated to satisfy the limit.
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted.endsWith("u".repeat(1_000))).toBe(true);
|
||||
});
|
||||
|
||||
it("never splits a UTF-16 surrogate pair at the truncation boundary", () => {
|
||||
// Drive the non-positive-budget path with an emoji (surrogate pair) sitting
|
||||
// across the kept-tail cut. A naive code-unit slice would orphan the low
|
||||
// surrogate into U+FFFD; the boundary must stay on a whole code point.
|
||||
const before = `OpenClaw assembled context for this turn:\n${"H".repeat(300)}`;
|
||||
const context = "older context ".repeat(20);
|
||||
// Emoji immediately before the user text so the cut can fall mid-pair.
|
||||
const prompt = `\u{1F600}${"U".repeat(60)}`;
|
||||
const after = `\n</conversation_context>\n\nCurrent user request:\n${prompt}`;
|
||||
const promptText = `${before}${context}${after}`;
|
||||
const contextRange = { start: before.length, end: before.length + context.length };
|
||||
|
||||
// Sweep cap sizes around the cut so the test is not brittle to marker length;
|
||||
// at least one value lands the boundary inside the surrogate pair.
|
||||
for (let maxChars = 90; maxChars <= 140; maxChars += 1) {
|
||||
const fitted = fitCodexProjectedContextForTurnStart({ promptText, contextRange, maxChars });
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
// U+FFFD only appears when a lone surrogate is rendered, i.e. a split pair.
|
||||
expect(fitted).not.toContain("<22>");
|
||||
// Any surviving emoji must be the complete pair, not a lone low surrogate.
|
||||
for (let i = 0; i < fitted.length; i += 1) {
|
||||
const code = fitted.charCodeAt(i);
|
||||
const isLowSurrogate = code >= 0xdc00 && code <= 0xdfff;
|
||||
const isHighSurrogate = code >= 0xd800 && code <= 0xdbff;
|
||||
if (isLowSurrogate) {
|
||||
const prev = fitted.charCodeAt(i - 1);
|
||||
expect(prev >= 0xd800 && prev <= 0xdbff).toBe(true);
|
||||
}
|
||||
if (isHighSurrogate) {
|
||||
const next = fitted.charCodeAt(i + 1);
|
||||
expect(next >= 0xdc00 && next <= 0xdfff).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the old conservative cap when no runtime budget is available", () => {
|
||||
expect(resolveCodexContextEngineProjectionMaxChars({})).toBe(24_000);
|
||||
expect(resolveCodexContextEngineProjectionMaxChars({ contextTokenBudget: 0 })).toBe(24_000);
|
||||
|
||||
@@ -139,16 +139,8 @@ export function fitCodexProjectedContextForTurnStart(params: {
|
||||
const context = params.promptText.slice(range.start, range.end);
|
||||
const afterContext = params.promptText.slice(range.end);
|
||||
const contextBudget = maxChars - beforeContext.length - afterContext.length;
|
||||
if (contextBudget > 0) {
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
}
|
||||
// The header plus the trailing user request already fill the limit, so the
|
||||
// older context drops entirely and the remaining text must still be bounded;
|
||||
// otherwise Codex app-server rejects the turn for exceeding
|
||||
// MAX_USER_INPUT_TEXT_CHARS. truncateOlderContext keeps the tail, preserving
|
||||
// the user's actual request over the older header text.
|
||||
return truncateOlderContext(`${beforeContext}${afterContext}`, maxChars);
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
}
|
||||
|
||||
function normalizeProjectedContextRange(
|
||||
@@ -465,20 +457,5 @@ function truncateOlderContext(text: string, maxChars: number): string {
|
||||
return marker.slice(0, maxChars);
|
||||
}
|
||||
tailChars = maxChars - marker.length;
|
||||
return `${marker}${sliceTailFromCodePointBoundary(text, tailChars).trimStart()}`;
|
||||
}
|
||||
|
||||
// Keep the kept tail at a code-point boundary so a UTF-16 surrogate pair is
|
||||
// never split at the cut: a tail start that lands on a low surrogate would
|
||||
// orphan it into U+FFFD, corrupting the first character. Dropping that unit
|
||||
// stays within maxChars (it only removes a char), so the bound still holds.
|
||||
function sliceTailFromCodePointBoundary(text: string, tailChars: number): string {
|
||||
let start = text.length - tailChars;
|
||||
if (start > 0 && start < text.length) {
|
||||
const code = text.charCodeAt(start);
|
||||
if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
return text.slice(start);
|
||||
return `${marker}${text.slice(text.length - tailChars).trimStart()}`;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterSupportedLanguageHints,
|
||||
normalizeDiffViewerPayloadLanguages,
|
||||
normalizeSupportedLanguageHint,
|
||||
} from "./language-hints.js";
|
||||
|
||||
async function normalizeHints(values: readonly string[], options = {}) {
|
||||
return await Promise.all(values.map((value) => normalizeSupportedLanguageHint(value, options)));
|
||||
}
|
||||
|
||||
describe("normalizeSupportedLanguageHint", () => {
|
||||
describe("filterSupportedLanguageHints", () => {
|
||||
it("keeps supported languages", async () => {
|
||||
await expect(normalizeHints(["typescript", "cpp", "text"])).resolves.toEqual([
|
||||
await expect(filterSupportedLanguageHints(["typescript", "cpp", "text"])).resolves.toEqual([
|
||||
"typescript",
|
||||
"cpp",
|
||||
"text",
|
||||
@@ -21,7 +17,7 @@ describe("normalizeSupportedLanguageHint", () => {
|
||||
|
||||
it("normalizes common aliases to base viewer languages", async () => {
|
||||
await expect(
|
||||
normalizeHints(["ts", "c++", "c#", "bash", "dockerfile", "rb", "kt", "ps1"]),
|
||||
filterSupportedLanguageHints(["ts", "c++", "c#", "bash", "dockerfile", "rb", "kt", "ps1"]),
|
||||
).resolves.toEqual([
|
||||
"typescript",
|
||||
"cpp",
|
||||
@@ -36,7 +32,7 @@ describe("normalizeSupportedLanguageHint", () => {
|
||||
|
||||
it("keeps mainstream languages in the base viewer without the language pack", async () => {
|
||||
await expect(
|
||||
normalizeHints([
|
||||
filterSupportedLanguageHints([
|
||||
"ruby",
|
||||
"swift",
|
||||
"kotlin",
|
||||
@@ -61,24 +57,23 @@ describe("normalizeSupportedLanguageHint", () => {
|
||||
});
|
||||
|
||||
it("drops uncommon languages without the language pack", async () => {
|
||||
await expect(normalizeSupportedLanguageHint("abap")).resolves.toBeUndefined();
|
||||
await expect(filterSupportedLanguageHints(["abap"])).resolves.toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("keeps uncommon languages when the language pack is available", async () => {
|
||||
await expect(
|
||||
normalizeSupportedLanguageHint("abap", { languagePackAvailable: true }),
|
||||
).resolves.toBe("abap");
|
||||
filterSupportedLanguageHints(["abap"], { languagePackAvailable: true }),
|
||||
).resolves.toEqual(["abap"]);
|
||||
});
|
||||
|
||||
it("drops invalid languages", async () => {
|
||||
await expect(normalizeSupportedLanguageHint("not-a-real-language")).resolves.toBeUndefined();
|
||||
it("drops invalid languages and falls back to text", async () => {
|
||||
await expect(filterSupportedLanguageHints(["not-a-real-language"])).resolves.toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("keeps valid languages when invalid hints are mixed in", async () => {
|
||||
await expect(normalizeHints(["typescript", "not-a-real-language"])).resolves.toEqual([
|
||||
"typescript",
|
||||
undefined,
|
||||
]);
|
||||
await expect(
|
||||
filterSupportedLanguageHints(["typescript", "not-a-real-language"]),
|
||||
).resolves.toEqual(["typescript"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ export async function normalizeSupportedLanguageHint(
|
||||
}
|
||||
}
|
||||
|
||||
export async function filterSupportedLanguageHints(
|
||||
values: Iterable<string>,
|
||||
options: { languagePackAvailable?: boolean } = {},
|
||||
): Promise<SupportedLanguages[]> {
|
||||
return normalizeSupportedLanguageHints(values, { fallbackToText: true, ...options });
|
||||
}
|
||||
|
||||
async function normalizeSupportedLanguageHints(
|
||||
values: Iterable<string>,
|
||||
options: { fallbackToText: boolean; languagePackAvailable?: boolean },
|
||||
|
||||
@@ -55,17 +55,16 @@ const viewerPayload = JSON.stringify({
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["text"],
|
||||
oldFile: { name: "a.ts", lang: "text", contents: "old" },
|
||||
newFile: { name: "a.ts", lang: "text", contents: "new" },
|
||||
oldFile: { fileName: "a.ts", lang: "text", content: "old" },
|
||||
newFile: { fileName: "a.ts", lang: "text", content: "new" },
|
||||
});
|
||||
|
||||
function renderCard(payloadOverride?: string): void {
|
||||
const payload = payloadOverride ?? viewerPayload;
|
||||
function renderCard(): void {
|
||||
document.body.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<section class="oc-diff-card">
|
||||
<div data-openclaw-diff-host></div>
|
||||
<script type="application/json" data-openclaw-diff-payload>${payload}</script>
|
||||
<script type="application/json" data-openclaw-diff-payload>${viewerPayload}</script>
|
||||
</section>`,
|
||||
);
|
||||
}
|
||||
@@ -173,300 +172,3 @@ describe("hydrateViewer", () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewerState initialization", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
delete document.documentElement.dataset.openclawDiffsError;
|
||||
delete document.documentElement.dataset.openclawDiffsReady;
|
||||
delete document.body.dataset.theme;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("seeds viewerState from firstPayload options and syncs document theme", async () => {
|
||||
const customPayload = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "split",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "light",
|
||||
backgroundEnabled: false,
|
||||
overflow: "scroll",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["text"],
|
||||
oldFile: { name: "a.ts", lang: "text", contents: "old" },
|
||||
newFile: { name: "a.ts", lang: "text", contents: "new" },
|
||||
});
|
||||
renderCard(customPayload);
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
expect(document.body.dataset.theme).toBe("light");
|
||||
|
||||
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts.diffStyle).toBe("split");
|
||||
expect(opts.themeType).toBe("light");
|
||||
expect(opts.overflow).toBe("scroll");
|
||||
expect(opts.disableBackground).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults viewerState to dark/unified/wrap/background when firstPayload uses defaults", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
expect(document.body.dataset.theme).toBe("dark");
|
||||
|
||||
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts.diffStyle).toBe("unified");
|
||||
expect(opts.themeType).toBe("dark");
|
||||
expect(opts.overflow).toBe("wrap");
|
||||
expect(opts.disableBackground).toBe(false);
|
||||
});
|
||||
|
||||
it("preloadHighlighter receives merged language set from all cards", async () => {
|
||||
const payload1 = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff1</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["typescript"],
|
||||
oldFile: { name: "a.ts", lang: "typescript", contents: "old" },
|
||||
newFile: { name: "a.ts", lang: "typescript", contents: "new" },
|
||||
});
|
||||
const payload2 = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff2</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["python"],
|
||||
oldFile: { name: "b.py", lang: "python", contents: "old" },
|
||||
newFile: { name: "b.py", lang: "python", contents: "new" },
|
||||
});
|
||||
renderCard(payload1);
|
||||
renderCard(payload2);
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
const preloadArg = (preloadHighlighterMock.mock.calls as unknown[][])[0]?.[0] as
|
||||
| { langs: string[]; themes: string[] }
|
||||
| undefined;
|
||||
expect(preloadArg).toBeDefined();
|
||||
expect(preloadArg!.langs).toContain("typescript");
|
||||
expect(preloadArg!.langs).toContain("python");
|
||||
expect(preloadArg!.themes).toEqual(["pierre-light", "pierre-dark"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolbar button toggles", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
delete document.body.dataset.theme;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("layout toggle switches between unified and split", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.diffStyle).toBe("unified");
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[0].click();
|
||||
|
||||
expect(fileDiffRerenderMock).toHaveBeenCalled();
|
||||
|
||||
const opts2 = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(opts2.diffStyle).toBe("split");
|
||||
});
|
||||
|
||||
it("theme toggle switches between dark and light", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.themeType).toBe("dark");
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[3].click();
|
||||
|
||||
const lastOpts = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(lastOpts.themeType).toBe("light");
|
||||
expect(document.body.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("wrap toggle switches between wrap and scroll", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.overflow).toBe("wrap");
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[1].click();
|
||||
|
||||
const lastOpts = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(lastOpts.overflow).toBe("scroll");
|
||||
});
|
||||
|
||||
it("background toggle inverts disableBackground", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.disableBackground).toBe(false);
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[2].click();
|
||||
|
||||
const lastOpts = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(lastOpts.disableBackground).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureShadowRoot", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("attaches shadow root from template and removes template element", async () => {
|
||||
renderCard();
|
||||
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
|
||||
const template = document.createElement("template");
|
||||
template.setAttribute("shadowrootmode", "open");
|
||||
template.innerHTML = "<div>shadow content</div>";
|
||||
host.append(template);
|
||||
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
expect(host.shadowRoot).toBeDefined();
|
||||
expect(host.shadowRoot!.querySelector("div")?.textContent).toBe("shadow content");
|
||||
expect(host.querySelector("template")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips shadow root attachment when no template is present", async () => {
|
||||
renderCard();
|
||||
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
|
||||
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
expect(host.shadowRoot).toBeNull();
|
||||
expect(fileDiffHydrateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips shadow root when already attached", async () => {
|
||||
renderCard();
|
||||
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
|
||||
host.attachShadow({ mode: "open" });
|
||||
host.shadowRoot!.innerHTML = "<span>existing</span>";
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.setAttribute("shadowrootmode", "open");
|
||||
template.innerHTML = "<div>new content</div>";
|
||||
host.append(template);
|
||||
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
expect(host.shadowRoot!.querySelector("span")?.textContent).toBe("existing");
|
||||
expect(host.querySelector("template")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHydrateProps branching", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes fileDiff directly when payload has fileDiff", async () => {
|
||||
const fileDiffPayload = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["text"],
|
||||
fileDiff: { name: "patch.diff", lang: "text", hunks: [] },
|
||||
});
|
||||
renderCard(fileDiffPayload);
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(hydrateArg.fileDiff).toEqual({ name: "patch.diff", lang: "text", hunks: [] });
|
||||
expect(hydrateArg.oldFile).toBeUndefined();
|
||||
expect(hydrateArg.newFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes oldFile and newFile when payload has them without fileDiff", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(hydrateArg.fileDiff).toBeUndefined();
|
||||
expect(hydrateArg.oldFile).toEqual({ name: "a.ts", lang: "text", contents: "old" });
|
||||
expect(hydrateArg.newFile).toEqual({ name: "a.ts", lang: "text", contents: "new" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,6 +379,11 @@ export async function hasAllGuildPermissionsDiscord(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Prefer hasAnyGuildPermissionDiscord or hasAllGuildPermissionsDiscord for clarity.
|
||||
*/
|
||||
export const hasGuildPermissionDiscord = hasAnyGuildPermissionDiscord;
|
||||
|
||||
export async function fetchChannelPermissionsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Elevenlabs tests cover speech provider plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isValidElevenLabsVoiceId } from "./shared.js";
|
||||
import { buildElevenLabsSpeechProvider } from "./speech-provider.js";
|
||||
import { buildElevenLabsSpeechProvider, isValidVoiceId } from "./speech-provider.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: async ({
|
||||
@@ -120,7 +119,7 @@ describe("elevenlabs speech provider", () => {
|
||||
{ value: "voice?param=value", expected: false },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(isValidElevenLabsVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
|
||||
expect(isValidVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ function parseNumberValue(value: string): number | undefined {
|
||||
return parseStrictFiniteNumber(value);
|
||||
}
|
||||
|
||||
export const isValidVoiceId = isValidElevenLabsVoiceId;
|
||||
|
||||
function normalizeVoiceSetting(value: unknown, min: number, max: number): number | undefined {
|
||||
const number = asFiniteNumber(value);
|
||||
return number !== undefined && number >= min && number <= max ? number : undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// File Transfer tests cover errors plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { err, throwFromNodePayload } from "./errors.js";
|
||||
import { classifyFsError, err, throwFromNodePayload } from "./errors.js";
|
||||
|
||||
describe("err", () => {
|
||||
it("returns an error envelope without canonicalPath when omitted", () => {
|
||||
@@ -18,6 +18,28 @@ describe("err", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyFsError", () => {
|
||||
it("maps ENOENT to NOT_FOUND", () => {
|
||||
expect(classifyFsError({ code: "ENOENT" })).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("maps EACCES and EPERM to PERMISSION_DENIED", () => {
|
||||
expect(classifyFsError({ code: "EACCES" })).toBe("PERMISSION_DENIED");
|
||||
expect(classifyFsError({ code: "EPERM" })).toBe("PERMISSION_DENIED");
|
||||
});
|
||||
|
||||
it("maps EISDIR to IS_DIRECTORY", () => {
|
||||
expect(classifyFsError({ code: "EISDIR" })).toBe("IS_DIRECTORY");
|
||||
});
|
||||
|
||||
it("falls back to READ_ERROR for unknown / null / non-object input", () => {
|
||||
expect(classifyFsError({ code: "EUNKNOWN" })).toBe("READ_ERROR");
|
||||
expect(classifyFsError(null)).toBe("READ_ERROR");
|
||||
expect(classifyFsError(undefined)).toBe("READ_ERROR");
|
||||
expect(classifyFsError("nope")).toBe("READ_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("throwFromNodePayload", () => {
|
||||
it("preserves code and message in the thrown Error", () => {
|
||||
expect(() =>
|
||||
|
||||
@@ -42,6 +42,21 @@ export function err(
|
||||
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
|
||||
}
|
||||
|
||||
// Translate a node-side fs error to a public error code.
|
||||
export function classifyFsError(e: unknown): FileTransferErrCode {
|
||||
const code = (e as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
return "PERMISSION_DENIED";
|
||||
}
|
||||
if (code === "EISDIR") {
|
||||
return "IS_DIRECTORY";
|
||||
}
|
||||
return "READ_ERROR";
|
||||
}
|
||||
|
||||
// Convert a node-host error payload to a thrown Error for agent-tool consumption.
|
||||
// The agent-tool surfaces these as failed tool results uniformly.
|
||||
export function throwFromNodePayload(operation: string, payload: Record<string, unknown>): never {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createApproverRestrictedNativeApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime";
|
||||
import {
|
||||
createApproverRestrictedNativeApprovalCapability,
|
||||
splitChannelApprovalCapability,
|
||||
} from "openclaw/plugin-sdk/approval-delivery-runtime";
|
||||
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import {
|
||||
@@ -237,3 +240,7 @@ export const googleChatApprovalCapability: ChannelApprovalCapability =
|
||||
.googleChatApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
|
||||
}),
|
||||
});
|
||||
|
||||
export const googleChatNativeApprovalAdapter = splitChannelApprovalCapability(
|
||||
googleChatApprovalCapability,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseIrcPrefix,
|
||||
sanitizeIrcOutboundText,
|
||||
sanitizeIrcTarget,
|
||||
splitIrcText,
|
||||
} from "./protocol.js";
|
||||
|
||||
describe("irc protocol", () => {
|
||||
@@ -36,4 +37,13 @@ describe("irc protocol", () => {
|
||||
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
|
||||
});
|
||||
|
||||
it("splits long text on boundaries", () => {
|
||||
const chunks = splitIrcText("a ".repeat(300), 120);
|
||||
expect(chunks.length).toBeGreaterThan(2);
|
||||
expect(
|
||||
chunks
|
||||
.map((chunk, index) => ({ index, length: chunk.length }))
|
||||
.filter((chunk) => chunk.length > 120),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,6 +141,30 @@ export function sanitizeIrcTarget(raw: string): string {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function splitIrcText(text: string, maxChars = 350): string[] {
|
||||
const cleaned = sanitizeIrcOutboundText(text);
|
||||
if (!cleaned) {
|
||||
return [];
|
||||
}
|
||||
if (cleaned.length <= maxChars) {
|
||||
return [cleaned];
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let remaining = cleaned;
|
||||
while (remaining.length > maxChars) {
|
||||
let splitAt = remaining.lastIndexOf(" ", maxChars);
|
||||
if (splitAt < Math.floor(maxChars * 0.5)) {
|
||||
splitAt = maxChars;
|
||||
}
|
||||
chunks.push(remaining.slice(0, splitAt).trim());
|
||||
remaining = remaining.slice(splitAt).trimStart();
|
||||
}
|
||||
if (remaining) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
return chunks.filter(Boolean);
|
||||
}
|
||||
|
||||
export function makeIrcMessageId() {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
import {
|
||||
detectLineMediaKind,
|
||||
resolveLineOutboundMedia,
|
||||
validateLineMediaUrl,
|
||||
} from "./outbound-media.js";
|
||||
@@ -70,6 +71,28 @@ describe("validateLineMediaUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectLineMediaKind", () => {
|
||||
it("maps image MIME to image", () => {
|
||||
expect(detectLineMediaKind("image/jpeg")).toBe("image");
|
||||
});
|
||||
|
||||
it("maps uppercase image MIME to image", () => {
|
||||
expect(detectLineMediaKind("IMAGE/JPEG")).toBe("image");
|
||||
});
|
||||
|
||||
it("maps video MIME to video", () => {
|
||||
expect(detectLineMediaKind("video/mp4")).toBe("video");
|
||||
});
|
||||
|
||||
it("maps audio MIME to audio", () => {
|
||||
expect(detectLineMediaKind("audio/mpeg")).toBe("audio");
|
||||
});
|
||||
|
||||
it("falls back unknown MIME to image", () => {
|
||||
expect(detectLineMediaKind("application/octet-stream")).toBe("image");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLineOutboundMedia", () => {
|
||||
beforeEach(() => {
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset();
|
||||
|
||||
@@ -41,6 +41,20 @@ export async function validateLineMediaUrl(url: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export function detectLineMediaKind(mimeType: string): LineOutboundMediaKind {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(mimeType);
|
||||
if (normalized.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (normalized.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (normalized.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
return "image";
|
||||
}
|
||||
|
||||
function isHttpsUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { getSessionBindingService, testing } from "openclaw/plugin-sdk/session-binding-runtime";
|
||||
@@ -198,8 +197,6 @@ describe("matrix thread bindings", () => {
|
||||
state: {
|
||||
openKeyedStore: (options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests("matrix", options),
|
||||
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateSyncKeyedStoreForTests("matrix", options),
|
||||
resolveStateDir: () => stateDir,
|
||||
},
|
||||
} as PluginRuntime);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Mattermost tests cover draft stream plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import { createMattermostDraftStream } from "./draft-stream.js";
|
||||
import { buildMattermostToolStatusText, createMattermostDraftStream } from "./draft-stream.js";
|
||||
|
||||
type RequestRecord = {
|
||||
path: string;
|
||||
@@ -253,3 +253,30 @@ describe("createMattermostDraftStream", () => {
|
||||
expect(calls[1]?.path).toBe("/posts/post-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMattermostToolStatusText", () => {
|
||||
it("renders a status with the shared tool label", () => {
|
||||
expect(buildMattermostToolStatusText({ name: "read" })).toBe("📖 Read");
|
||||
});
|
||||
|
||||
it("honors raw exec detail mode", () => {
|
||||
expect(
|
||||
buildMattermostToolStatusText({
|
||||
name: "exec",
|
||||
args: { command: "pnpm test -- --watch=false" },
|
||||
detailMode: "raw",
|
||||
}),
|
||||
).toBe("🛠️ run tests, `pnpm test -- --watch=false`");
|
||||
});
|
||||
|
||||
it("can hide raw exec detail from status text", () => {
|
||||
expect(
|
||||
buildMattermostToolStatusText({
|
||||
name: "exec",
|
||||
args: { command: "pnpm test -- --watch=false" },
|
||||
detailMode: "raw",
|
||||
config: { streaming: { preview: { commandText: "status" } } },
|
||||
}),
|
||||
).toBe("🛠️ Exec");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Mattermost plugin module implements draft stream behavior.
|
||||
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { formatChannelProgressDraftLineForEntry } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createMattermostPost,
|
||||
deleteMattermostPost,
|
||||
@@ -32,6 +33,27 @@ function normalizeMattermostDraftText(text: string, maxChars: number): string {
|
||||
return `${trimmed.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function buildMattermostToolStatusText(params: {
|
||||
name?: string;
|
||||
phase?: string;
|
||||
args?: Record<string, unknown>;
|
||||
detailMode?: "explain" | "raw";
|
||||
config?: Parameters<typeof formatChannelProgressDraftLineForEntry>[0];
|
||||
}): string {
|
||||
return (
|
||||
formatChannelProgressDraftLineForEntry(
|
||||
params.config,
|
||||
{
|
||||
event: "tool",
|
||||
name: params.name,
|
||||
phase: params.phase,
|
||||
args: params.args,
|
||||
},
|
||||
params.detailMode ? { detailMode: params.detailMode } : undefined,
|
||||
) ?? "Running tool..."
|
||||
);
|
||||
}
|
||||
|
||||
export function createMattermostDraftStream(params: {
|
||||
client: MattermostClient;
|
||||
channelId: string;
|
||||
|
||||
@@ -104,6 +104,7 @@ vi.mock("./client.js", async () => {
|
||||
});
|
||||
|
||||
vi.mock("./draft-stream.js", () => ({
|
||||
buildMattermostToolStatusText: () => "Working",
|
||||
createMattermostDraftStream: mockState.createMattermostDraftStream,
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
resolveMattermostEffectiveReplyToId,
|
||||
resolveMattermostReplyRootId,
|
||||
resolveMattermostThreadSessionContext,
|
||||
shouldFinalizeMattermostPreviewAfterDispatch,
|
||||
shouldClearMattermostDraftPreview,
|
||||
shouldSuppressMattermostDefaultToolProgressMessages,
|
||||
shouldUpdateMattermostDraftToolProgress,
|
||||
type MattermostMentionGateInput,
|
||||
@@ -367,6 +369,35 @@ describe("shouldSuppressMattermostDefaultToolProgressMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearMattermostDraftPreview", () => {
|
||||
it("deletes the preview after successful normal final delivery", () => {
|
||||
expect(
|
||||
shouldClearMattermostDraftPreview({
|
||||
finalizedViaPreviewPost: false,
|
||||
finalReplyDelivered: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the preview when final delivery failed", () => {
|
||||
expect(
|
||||
shouldClearMattermostDraftPreview({
|
||||
finalizedViaPreviewPost: false,
|
||||
finalReplyDelivered: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the preview when it already became the final reply", () => {
|
||||
expect(
|
||||
shouldClearMattermostDraftPreview({
|
||||
finalizedViaPreviewPost: true,
|
||||
finalReplyDelivered: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deliverMattermostReplyWithDraftPreview", () => {
|
||||
it("suppresses reasoning-prefixed finals before preview finalization", async () => {
|
||||
const draftStream = createDraftStreamMock();
|
||||
@@ -701,6 +732,35 @@ describe("formatMattermostFinalDeliveryOutcomeLog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldFinalizeMattermostPreviewAfterDispatch", () => {
|
||||
it("reuses the preview only for a single eligible final payload", () => {
|
||||
expect(
|
||||
shouldFinalizeMattermostPreviewAfterDispatch({
|
||||
finalCount: 1,
|
||||
canFinalizeInPlace: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to normal sends for multi-payload finals", () => {
|
||||
expect(
|
||||
shouldFinalizeMattermostPreviewAfterDispatch({
|
||||
finalCount: 2,
|
||||
canFinalizeInPlace: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to normal sends when the final cannot be edited into the preview", () => {
|
||||
expect(
|
||||
shouldFinalizeMattermostPreviewAfterDispatch({
|
||||
finalCount: 1,
|
||||
canFinalizeInPlace: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostEffectiveReplyToId", () => {
|
||||
it("keeps an existing thread root", () => {
|
||||
expect(
|
||||
|
||||
@@ -295,6 +295,20 @@ export function canFinalizeMattermostPreviewInPlace(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldClearMattermostDraftPreview(params: {
|
||||
finalizedViaPreviewPost: boolean;
|
||||
finalReplyDelivered: boolean;
|
||||
}): boolean {
|
||||
return params.finalReplyDelivered && !params.finalizedViaPreviewPost;
|
||||
}
|
||||
|
||||
export function shouldFinalizeMattermostPreviewAfterDispatch(params: {
|
||||
finalCount: number;
|
||||
canFinalizeInPlace: boolean;
|
||||
}): boolean {
|
||||
return params.finalCount === 1 && params.canFinalizeInPlace;
|
||||
}
|
||||
|
||||
type MattermostDraftPreviewState = {
|
||||
finalizedViaPreviewPost: boolean;
|
||||
};
|
||||
|
||||
@@ -50,22 +50,19 @@ describe("qa scenario catalog", () => {
|
||||
expect(
|
||||
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
|
||||
).toEqual(requiredScenarioIds);
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution?.kind !== "flow")
|
||||
.map((scenario) => scenario.id)
|
||||
.toSorted(),
|
||||
).toStrictEqual(
|
||||
[
|
||||
"channel-message-flows",
|
||||
"control-ui-chat-flow-playwright",
|
||||
"gateway-smoke",
|
||||
"package-openclaw-for-docker",
|
||||
"plugin-lifecycle-probe",
|
||||
"qa-otel-smoke",
|
||||
"ux-matrix-evidence-dashboard",
|
||||
].toSorted(),
|
||||
const nativeExecutionScenarios = pack.scenarios.filter(
|
||||
(scenario) => scenario.execution.kind !== "flow",
|
||||
);
|
||||
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
|
||||
for (const scenario of nativeExecutionScenarios) {
|
||||
const execution = scenario.execution;
|
||||
if (execution.kind === "flow") {
|
||||
throw new Error(`expected native execution scenario: ${scenario.id}`);
|
||||
}
|
||||
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
|
||||
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
|
||||
expect(execution.flow).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution.kind === "flow")
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
callPluginToolsMcp,
|
||||
findSkill,
|
||||
handleQaAction,
|
||||
resolveWorkspaceSkillPath,
|
||||
writeWorkspaceSkill,
|
||||
} from "./suite-runtime-agent-tools.js";
|
||||
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
||||
@@ -92,25 +91,6 @@ describe("qa suite runtime agent tools helpers", () => {
|
||||
expect(skillPath).toBe(path.join(workspaceDir, "skills", "my-skill", "SKILL.md"));
|
||||
});
|
||||
|
||||
it("rejects workspace skill names that escape the skills directory", async () => {
|
||||
const workspaceDir = await makeTempDir("qa-workspace-");
|
||||
|
||||
for (const name of ["", " spaced", "spaced ", ".", "..", "../escape", "..\\escape", "a/b"]) {
|
||||
expect(() => resolveWorkspaceSkillPath(workspaceDir, name), name).toThrow(
|
||||
`invalid QA workspace skill name: ${JSON.stringify(name)}`,
|
||||
);
|
||||
await expect(
|
||||
writeWorkspaceSkill({
|
||||
env: { gateway: { workspaceDir } } as never,
|
||||
name,
|
||||
body: "escape",
|
||||
}),
|
||||
).rejects.toThrow(`invalid QA workspace skill name: ${JSON.stringify(name)}`);
|
||||
}
|
||||
|
||||
await expect(fs.readdir(path.join(workspaceDir, "skills"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("routes generic transport actions through the payload extractor", async () => {
|
||||
const handleAction = vi.fn(async () => ({
|
||||
content: [{ type: "text", text: "done" }],
|
||||
|
||||
@@ -26,36 +26,14 @@ function findSkill(skills: QaSkillStatusEntry[], name: string) {
|
||||
return skills.find((skill) => skill.name === name);
|
||||
}
|
||||
|
||||
function resolveWorkspaceSkillPath(workspaceDir: string, name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed !== name ||
|
||||
trimmed === "." ||
|
||||
trimmed === ".." ||
|
||||
trimmed.includes("\0") ||
|
||||
/[\\/]/u.test(trimmed)
|
||||
) {
|
||||
throw new Error(`invalid QA workspace skill name: ${JSON.stringify(name)}`);
|
||||
}
|
||||
|
||||
const skillsDir = path.resolve(workspaceDir, "skills");
|
||||
const skillDir = path.resolve(skillsDir, trimmed);
|
||||
const relative = path.relative(skillsDir, skillDir);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`invalid QA workspace skill name: ${JSON.stringify(name)}`);
|
||||
}
|
||||
return path.join(skillDir, "SKILL.md");
|
||||
}
|
||||
|
||||
async function writeWorkspaceSkill(params: {
|
||||
env: Pick<QaSuiteRuntimeEnv, "gateway">;
|
||||
name: string;
|
||||
body: string;
|
||||
}) {
|
||||
const skillPath = resolveWorkspaceSkillPath(params.env.gateway.workspaceDir, params.name);
|
||||
const skillDir = path.dirname(skillPath);
|
||||
const skillDir = path.join(params.env.gateway.workspaceDir, "skills", params.name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
const skillPath = path.join(skillDir, "SKILL.md");
|
||||
await fs.writeFile(skillPath, `${params.body.trim()}\n`, "utf8");
|
||||
return skillPath;
|
||||
}
|
||||
@@ -135,10 +113,4 @@ async function handleQaAction(params: {
|
||||
return extractQaToolPayload(result as Parameters<typeof extractQaToolPayload>[0]);
|
||||
}
|
||||
|
||||
export {
|
||||
callPluginToolsMcp,
|
||||
findSkill,
|
||||
handleQaAction,
|
||||
resolveWorkspaceSkillPath,
|
||||
writeWorkspaceSkill,
|
||||
};
|
||||
export { callPluginToolsMcp, findSkill, handleQaAction, writeWorkspaceSkill };
|
||||
|
||||
@@ -183,19 +183,6 @@ describe("containerCheck", () => {
|
||||
error: "Signal container receive endpoint did not upgrade to WebSocket (HTTP 200)",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects container receive endpoints that close before opening", async () => {
|
||||
wsMockState.behavior = "close";
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await containerCheck("http://localhost:8080", 1000, "+14259798283");
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: "Signal container receive WebSocket closed before open (1000: done)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("containerRestRequest", () => {
|
||||
|
||||
@@ -193,14 +193,6 @@ function containerReceiveCheck(
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
ws.once("close", (code, reason) => {
|
||||
const reasonText = reason.length > 0 ? `: ${reason.toString("utf8")}` : "";
|
||||
settle({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: `Signal container receive WebSocket closed before open (${code}${reasonText})`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("./webhook/tailscale.js", () => ({
|
||||
getTailscaleDnsName: mocks.getTailscaleDnsName,
|
||||
}));
|
||||
|
||||
import { startNgrokTunnel, startTailscaleTunnel, startTunnel } from "./tunnel.js";
|
||||
import { isNgrokAvailable, startNgrokTunnel, startTailscaleTunnel, startTunnel } from "./tunnel.js";
|
||||
|
||||
function nextProcess(): FakeChildProcess {
|
||||
const proc = new FakeChildProcess();
|
||||
@@ -53,6 +53,25 @@ describe("voice-call tunnels", () => {
|
||||
mocks.getTailscaleDnsName.mockReset();
|
||||
});
|
||||
|
||||
it("checks ngrok availability from the version command exit code", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = isNgrokAvailable();
|
||||
proc.close(0);
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.spawn).toHaveBeenCalledWith("ngrok", ["version"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats ngrok spawn failures as unavailable", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = isNgrokAvailable();
|
||||
proc.fail(new Error("spawn ngrok ENOENT"));
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("starts ngrok and appends the webhook path to the public URL", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = startNgrokTunnel({ port: 3334, path: "/voice/webhook" });
|
||||
|
||||
@@ -201,6 +201,25 @@ async function runNgrokCommand(args: string[]): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ngrok is installed and available.
|
||||
*/
|
||||
export async function isNgrokAvailable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("ngrok", ["version"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
proc.on("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Tailscale serve/funnel tunnel.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_POLLY_VOICE,
|
||||
escapeXml,
|
||||
getOpenAiVoiceNames,
|
||||
isOpenAiVoice,
|
||||
mapVoiceToPolly,
|
||||
} from "./voice-mapping.js";
|
||||
|
||||
@@ -21,4 +23,11 @@ describe("voice mapping", () => {
|
||||
expect(mapVoiceToPolly("unknown")).toBe(DEFAULT_POLLY_VOICE);
|
||||
expect(mapVoiceToPolly(undefined)).toBe(DEFAULT_POLLY_VOICE);
|
||||
});
|
||||
|
||||
it("detects known openai voices and lists them", () => {
|
||||
expect(isOpenAiVoice("nova")).toBe(true);
|
||||
expect(isOpenAiVoice("NOVA")).toBe(true);
|
||||
expect(isOpenAiVoice("Polly.Joanna")).toBe(false);
|
||||
expect(getOpenAiVoiceNames()).toEqual(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,3 +50,17 @@ export function mapVoiceToPolly(voice: string | undefined): string {
|
||||
// Map OpenAI voices to Polly equivalents
|
||||
return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || DEFAULT_POLLY_VOICE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a voice name is a known OpenAI voice.
|
||||
*/
|
||||
export function isOpenAiVoice(voice: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(voice) in OPENAI_TO_POLLY_MAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported OpenAI voice names.
|
||||
*/
|
||||
export function getOpenAiVoiceNames(): string[] {
|
||||
return Object.keys(OPENAI_TO_POLLY_MAP);
|
||||
}
|
||||
|
||||
@@ -624,32 +624,6 @@ describe("deliverWebReply", () => {
|
||||
expect(warnContext.mediaUrl).toBe("http://example.com/img.jpg");
|
||||
});
|
||||
|
||||
it("delivers the opening text chunk when the first media fails on a multi-chunk reply", async () => {
|
||||
const msg = makeMsg();
|
||||
mockLoadedImageMedia();
|
||||
mockFirstSendMediaFailure(msg, "boom");
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: { text: "ALPHALINEBRAVOLINE", mediaUrl: "http://example.com/img.jpg" },
|
||||
msg,
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
textLimit: 9,
|
||||
replyLogger,
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(replyText(msg, 0)).toContain("ALPHALINE");
|
||||
expect(replyText(msg, 0)).toContain("⚠️ Media failed");
|
||||
const allReplies = (
|
||||
msg.platform.reply as unknown as { mock: { calls: unknown[][] } }
|
||||
).mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.join("\n");
|
||||
expect(allReplies).toContain("ALPHALINE");
|
||||
expect(allReplies).toContain("BRAVOLINE");
|
||||
expect(allReplies).not.toContain("boom");
|
||||
});
|
||||
|
||||
it("still attempts later media after the first media fails", async () => {
|
||||
vi.clearAllMocks();
|
||||
const msg = makeMsg();
|
||||
|
||||
@@ -340,7 +340,7 @@ export async function deliverWebReply(params: {
|
||||
return;
|
||||
}
|
||||
const warning = "⚠️ Media failed.";
|
||||
const fallbackTextParts = [caption ?? "", warning].filter(Boolean);
|
||||
const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean);
|
||||
const fallbackText = fallbackTextParts.join("\n");
|
||||
if (!fallbackText) {
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isXaiToolEnabled,
|
||||
resolveFallbackXaiAuth,
|
||||
resolveXaiToolApiKey,
|
||||
resolveXaiToolApiKeyWithAuth,
|
||||
} from "./tool-auth-shared.js";
|
||||
|
||||
@@ -80,11 +81,11 @@ describe("xai tool auth helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime, then source config, then env for tool auth", async () => {
|
||||
it("falls back to runtime, then source config, then env for tool auth", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "env-key");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
runtimeConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -112,10 +113,10 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("runtime-key");
|
||||
).toBe("runtime-key");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -130,9 +131,9 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("source-key");
|
||||
).toBe("source-key");
|
||||
|
||||
await expect(resolveXaiToolApiKeyWithAuth({})).resolves.toBe("env-key");
|
||||
expect(resolveXaiToolApiKey({})).toBe("env-key");
|
||||
});
|
||||
|
||||
it("honors explicit disabled flags before auth fallback", () => {
|
||||
@@ -152,11 +153,11 @@ describe("xai tool auth helpers", () => {
|
||||
await expect(resolveXaiToolApiKeyWithAuth({ auth })).resolves.toBe("profile-key");
|
||||
});
|
||||
|
||||
it("does not use env fallback when a non-env SecretRef is configured but unavailable", async () => {
|
||||
it("does not use env fallback when a non-env SecretRef is configured but unavailable", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "env-key");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -175,7 +176,7 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not bypass blocked explicit tool config with auth profiles", async () => {
|
||||
@@ -206,11 +207,11 @@ describe("xai tool auth helpers", () => {
|
||||
await expect(resolveXaiToolApiKeyWithAuth({ sourceConfig, auth })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves env SecretRefs from source config when runtime snapshot is unavailable", async () => {
|
||||
it("resolves env SecretRefs from source config when runtime snapshot is unavailable", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -229,14 +230,14 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("xai-secretref-key");
|
||||
).toBe("xai-secretref-key");
|
||||
});
|
||||
|
||||
it("does not read arbitrary env SecretRef ids for xAI tool auth", async () => {
|
||||
it("does not read arbitrary env SecretRef ids for xAI tool auth", () => {
|
||||
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -255,14 +256,14 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider allowlist excludes XAI_API_KEY", async () => {
|
||||
it("does not resolve env SecretRefs when provider allowlist excludes XAI_API_KEY", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
secrets: {
|
||||
providers: {
|
||||
@@ -289,14 +290,14 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider source is not env", async () => {
|
||||
it("does not resolve env SecretRefs when provider source is not env", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||
|
||||
await expect(
|
||||
resolveXaiToolApiKeyWithAuth({
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
secrets: {
|
||||
providers: {
|
||||
@@ -323,6 +324,6 @@ describe("xai tool auth helpers", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,6 +157,20 @@ export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth |
|
||||
return readLegacyGrokFallbackAuth(cfg);
|
||||
}
|
||||
|
||||
export function resolveXaiToolApiKey(params: {
|
||||
runtimeConfig?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
}): string | undefined {
|
||||
const configured = resolveConfiguredXaiToolApiKeyResult(params);
|
||||
if (configured.status === "available") {
|
||||
return configured.value;
|
||||
}
|
||||
if (configured.status === "blocked") {
|
||||
return undefined;
|
||||
}
|
||||
return readProviderEnvValue([XAI_API_KEY_ENV_VAR]);
|
||||
}
|
||||
|
||||
export async function resolveXaiToolApiKeyWithAuth(params: {
|
||||
runtimeConfig?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
|
||||
@@ -714,7 +714,7 @@ export class GatewayClient {
|
||||
this.suppressedTransientPreHelloCleanCloses += 1;
|
||||
this.flushPendingErrors(new GatewayClientTransientPreHelloCloseError());
|
||||
this.scheduleReconnect();
|
||||
this.notifyClose(code, reasonText, closeInfo);
|
||||
this.opts.onClose?.(code, reasonText, closeInfo);
|
||||
return;
|
||||
}
|
||||
// Clear persisted device auth state only when device-token auth was active.
|
||||
@@ -745,16 +745,16 @@ export class GatewayClient {
|
||||
details: connectErrorDetails,
|
||||
})
|
||||
) {
|
||||
this.notifyReconnectPaused({
|
||||
this.opts.onReconnectPaused?.({
|
||||
code,
|
||||
reason: reasonText,
|
||||
detailCode: connectErrorDetailCode,
|
||||
});
|
||||
this.notifyClose(code, reasonText);
|
||||
this.opts.onClose?.(code, reasonText);
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
this.notifyClose(code, reasonText);
|
||||
this.opts.onClose?.(code, reasonText);
|
||||
});
|
||||
ws.on("error", (err) => {
|
||||
this.logDebug(`gateway client error: ${formatGatewayClientErrorForLog(err)}`);
|
||||
@@ -916,7 +916,7 @@ export class GatewayClient {
|
||||
: 30_000;
|
||||
this.lastTick = Date.now();
|
||||
this.startTickWatch();
|
||||
this.notifyHelloOk(helloOk);
|
||||
this.opts.onHelloOk?.(helloOk);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof GatewayClientTransientPreHelloCloseError) {
|
||||
@@ -1127,38 +1127,6 @@ export class GatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyHelloOk(helloOk: HelloOk): void {
|
||||
try {
|
||||
this.opts.onHelloOk?.(helloOk);
|
||||
} catch (err) {
|
||||
this.logDebug(
|
||||
`gateway client hello-ok handler error: ${formatGatewayClientErrorForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyReconnectPaused(info: GatewayReconnectPausedInfo): void {
|
||||
try {
|
||||
this.opts.onReconnectPaused?.(info);
|
||||
} catch (err) {
|
||||
this.logDebug(
|
||||
`gateway client reconnect paused handler error: ${formatGatewayClientErrorForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyClose(code: number, reason: string, info?: GatewayClientCloseInfo): void {
|
||||
try {
|
||||
if (info === undefined) {
|
||||
this.opts.onClose?.(code, reason);
|
||||
return;
|
||||
}
|
||||
this.opts.onClose?.(code, reason, info);
|
||||
} catch (err) {
|
||||
this.logDebug(`gateway client close handler error: ${formatGatewayClientErrorForLog(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveConnectScopes(params: {
|
||||
usingStoredDeviceToken?: boolean;
|
||||
storedScopes?: string[];
|
||||
|
||||
@@ -327,10 +327,8 @@ export class OpenClaw {
|
||||
});
|
||||
private readonly replayByRunId = new Map<string, OpenClawEvent[]>();
|
||||
private connected = false;
|
||||
private closed = false;
|
||||
private eventPumpPromise: Promise<void> | null = null;
|
||||
private eventPumpReady: Promise<void> | null = null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(options: OpenClawOptions = {}) {
|
||||
this.transport =
|
||||
@@ -353,45 +351,24 @@ export class OpenClaw {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.assertOpen();
|
||||
if (this.connected) {
|
||||
await this.startEventPump();
|
||||
this.assertOpen();
|
||||
return;
|
||||
}
|
||||
if (isConnectableTransport(this.transport)) {
|
||||
await this.transport.connect();
|
||||
}
|
||||
this.assertOpen();
|
||||
this.connected = true;
|
||||
await this.startEventPump();
|
||||
this.assertOpen();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closePromise) {
|
||||
return await this.closePromise;
|
||||
}
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.closePromise = (async () => {
|
||||
try {
|
||||
await this.transport.close?.();
|
||||
await this.eventPumpPromise?.catch(() => {});
|
||||
} finally {
|
||||
this.normalizedEvents.close();
|
||||
this.eventPumpPromise = null;
|
||||
this.eventPumpReady = null;
|
||||
this.connected = false;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await this.closePromise;
|
||||
} finally {
|
||||
this.closePromise = null;
|
||||
}
|
||||
await this.transport.close?.();
|
||||
await this.eventPumpPromise?.catch(() => {});
|
||||
this.normalizedEvents.close();
|
||||
this.eventPumpPromise = null;
|
||||
this.eventPumpReady = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
@@ -400,7 +377,6 @@ export class OpenClaw {
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
await this.connect();
|
||||
this.assertOpen();
|
||||
return await this.transport.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
@@ -416,21 +392,13 @@ export class OpenClaw {
|
||||
}
|
||||
|
||||
rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
|
||||
this.assertOpen();
|
||||
return this.transport.events(filter);
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new Error("OpenClaw SDK client is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private async *iterateEvents(
|
||||
filter?: (event: OpenClawEvent) => boolean,
|
||||
): AsyncIterable<OpenClawEvent> {
|
||||
await this.connect();
|
||||
this.assertOpen();
|
||||
for await (const event of this.normalizedEvents.stream(filter)) {
|
||||
yield event;
|
||||
}
|
||||
@@ -441,7 +409,6 @@ export class OpenClaw {
|
||||
filter?: (event: OpenClawEvent) => boolean,
|
||||
): AsyncIterable<OpenClawEvent> {
|
||||
await this.connect();
|
||||
this.assertOpen();
|
||||
const replayEvents = this.replaySnapshot(runId);
|
||||
let hasCanonicalAssistantRunEvent = replayEvents.some(isAssistantRunEvent);
|
||||
let hasTerminalRunEvent = replayEvents.some(isTerminalRunEvent);
|
||||
|
||||
@@ -54,51 +54,6 @@ class FakeTransport implements OpenClawTransport {
|
||||
}
|
||||
}
|
||||
|
||||
class DelayedConnectTransport extends FakeTransport {
|
||||
connectCalls = 0;
|
||||
private finishConnectCurrent: (() => void) | null = null;
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connectCalls += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
this.finishConnectCurrent = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
finishConnect(): void {
|
||||
const finish = this.finishConnectCurrent;
|
||||
if (!finish) {
|
||||
throw new Error("expected pending connect");
|
||||
}
|
||||
this.finishConnectCurrent = null;
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
class ClosingEventPumpTransport extends FakeTransport {
|
||||
onFirstEventPoll?: () => void;
|
||||
|
||||
override events(): AsyncIterable<GatewayEvent> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: (): AsyncIterator<GatewayEvent> => {
|
||||
let firstPoll = true;
|
||||
return {
|
||||
next: async (): Promise<IteratorResult<GatewayEvent>> => {
|
||||
if (firstPoll) {
|
||||
firstPoll = false;
|
||||
this.onFirstEventPoll?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
return { done: true, value: undefined as never };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class EventsOnlyTransport implements OpenClawTransport {
|
||||
constructor(private readonly eventSource: AsyncIterable<GatewayEvent>) {}
|
||||
|
||||
@@ -676,42 +631,6 @@ describe("OpenClaw SDK", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps close terminal when it races a pending connect", async () => {
|
||||
const transport = new DelayedConnectTransport({
|
||||
"agents.list": { agents: [] },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
const connect = oc.connect();
|
||||
const close = oc.close();
|
||||
transport.finishConnect();
|
||||
|
||||
await expect(connect).rejects.toThrow("OpenClaw SDK client is closed");
|
||||
await close;
|
||||
await expect(oc.agents.list()).rejects.toThrow("OpenClaw SDK client is closed");
|
||||
await expect(oc.events()[Symbol.asyncIterator]().next()).rejects.toThrow(
|
||||
"OpenClaw SDK client is closed",
|
||||
);
|
||||
expect(() => oc.rawEvents()).toThrow("OpenClaw SDK client is closed");
|
||||
expect(transport.connectCalls).toBe(1);
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not request after close races event pump startup", async () => {
|
||||
const transport = new ClosingEventPumpTransport({
|
||||
"agents.list": { agents: [] },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
let closePromise: Promise<void> | undefined;
|
||||
transport.onFirstEventPoll = () => {
|
||||
closePromise = oc.close();
|
||||
};
|
||||
|
||||
await expect(oc.agents.list()).rejects.toThrow("OpenClaw SDK client is closed");
|
||||
await closePromise;
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels runs and checks model auth status through current Gateway methods", async () => {
|
||||
const transport = new FakeTransport({
|
||||
agent: { status: "accepted", runId: "run_without_session" },
|
||||
|
||||
@@ -50,49 +50,4 @@ describe("GatewayClientTransport", () => {
|
||||
await connectExpectation;
|
||||
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects reconnect attempts after close", async () => {
|
||||
const transport = new GatewayClientTransport();
|
||||
|
||||
await transport.close();
|
||||
|
||||
await expect(transport.connect()).rejects.toThrow("gateway transport is closed");
|
||||
expect(gatewayClientMocks.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves connect when a hello observer throws", async () => {
|
||||
const onHelloOk = vi.fn(() => {
|
||||
throw new Error("hello observer failed");
|
||||
});
|
||||
const transport = new GatewayClientTransport({ onHelloOk });
|
||||
|
||||
const connect = transport.connect();
|
||||
const client = gatewayClientMocks.instances[0];
|
||||
|
||||
expect(() => client?.opts.onHelloOk?.({ sessionId: "session-1" })).toThrow(
|
||||
"hello observer failed",
|
||||
);
|
||||
|
||||
await expect(connect).resolves.toBeUndefined();
|
||||
expect(onHelloOk).toHaveBeenCalledWith({ sessionId: "session-1" });
|
||||
});
|
||||
|
||||
it("rejects connect when a connect-error observer throws", async () => {
|
||||
const onConnectError = vi.fn(() => {
|
||||
throw new Error("connect observer failed");
|
||||
});
|
||||
const transport = new GatewayClientTransport({ onConnectError });
|
||||
|
||||
const connect = transport.connect();
|
||||
const connectExpectation = expect(connect).rejects.toThrow("gateway rejected");
|
||||
const client = gatewayClientMocks.instances[0];
|
||||
|
||||
expect(() => client?.opts.onConnectError?.(new Error("gateway rejected"))).toThrow(
|
||||
"connect observer failed",
|
||||
);
|
||||
|
||||
await connectExpectation;
|
||||
expect(onConnectError).toHaveBeenCalledOnce();
|
||||
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,16 +80,12 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private rejectPendingConnect: ((error: Error) => void) | null = null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: GatewayClientTransportOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("gateway transport is closed"));
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
@@ -103,27 +99,21 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
this.options.onEvent?.(normalized);
|
||||
},
|
||||
onHelloOk: (_hello: unknown) => {
|
||||
try {
|
||||
this.options.onHelloOk?.(_hello);
|
||||
} finally {
|
||||
this.rejectPendingConnect = null;
|
||||
resolve();
|
||||
}
|
||||
this.options.onHelloOk?.(_hello);
|
||||
this.rejectPendingConnect = null;
|
||||
resolve();
|
||||
},
|
||||
onConnectError: (error: Error) => {
|
||||
try {
|
||||
this.options.onConnectError?.(error);
|
||||
} finally {
|
||||
if (this.client === client) {
|
||||
this.client = null;
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
void client.stopAndWait().catch(() => {});
|
||||
this.rejectPendingConnect = null;
|
||||
reject(error);
|
||||
this.options.onConnectError?.(error);
|
||||
if (this.client === client) {
|
||||
this.client = null;
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
void client.stopAndWait().catch(() => {});
|
||||
this.rejectPendingConnect = null;
|
||||
reject(error);
|
||||
},
|
||||
onReconnectPaused: this.options.onReconnectPaused,
|
||||
onClose: this.options.onClose,
|
||||
@@ -156,10 +146,6 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
if (this.closePromise) {
|
||||
return await this.closePromise;
|
||||
}
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.eventsHub.close();
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
|
||||
@@ -186,13 +186,6 @@ export function resolveNoteColumns(columns: number | undefined): number {
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function resolveNoteOutputColumns(message: string, columns: number): number {
|
||||
const widestLine = message
|
||||
.split("\n")
|
||||
.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
||||
return Math.max(columns, widestLine + 6);
|
||||
}
|
||||
|
||||
function createNoteOutput(columns: number): NodeJS.WriteStream {
|
||||
if (process.stdout.columns === columns) {
|
||||
return process.stdout;
|
||||
@@ -214,9 +207,8 @@ export function note(message: unknown, title?: string) {
|
||||
return;
|
||||
}
|
||||
const columns = resolveNoteColumns(process.stdout.columns);
|
||||
const wrappedMessage = wrapNoteMessage(message, { columns });
|
||||
clackNote(wrappedMessage, stylePromptTitle(title), {
|
||||
output: createNoteOutput(resolveNoteOutputColumns(wrappedMessage, columns)),
|
||||
clackNote(wrapNoteMessage(message, { columns }), stylePromptTitle(title), {
|
||||
output: createNoteOutput(columns),
|
||||
format: (line) => line,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { note as clackNote } from "@clack/prompts";
|
||||
// Terminal Core tests cover table behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { visibleWidth } from "./ansi.js";
|
||||
import { resolveNoteColumns, resolveNoteOutputColumns, wrapNoteMessage } from "./note.js";
|
||||
import { resolveNoteColumns, wrapNoteMessage } from "./note.js";
|
||||
import { renderTable } from "./table.js";
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
@@ -349,33 +348,6 @@ describe("wrapNoteMessage", () => {
|
||||
expect(resolveNoteColumns(120)).toBe(120);
|
||||
});
|
||||
|
||||
it("widens note output columns so clack does not re-wrap copy-sensitive lines", () => {
|
||||
const wrapped = wrapNoteMessage(
|
||||
[
|
||||
"- Found 1 session lock file.",
|
||||
"- ~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock pid=86519 (alive) age=2m47s stale=no",
|
||||
].join("\n"),
|
||||
{ columns: 80 },
|
||||
);
|
||||
const writes: string[] = [];
|
||||
const output = {
|
||||
columns: resolveNoteOutputColumns(wrapped, 80),
|
||||
write(chunk: string) {
|
||||
writes.push(chunk);
|
||||
return true;
|
||||
},
|
||||
} as unknown as NodeJS.WriteStream;
|
||||
|
||||
clackNote(wrapped, "Session locks", { output, format: (line) => line });
|
||||
|
||||
const rendered = writes.join("");
|
||||
expect(rendered).toContain(".jsonl.lock");
|
||||
expect(rendered).not.toContain(".js\n");
|
||||
expect(rendered).toContain(
|
||||
"- ~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock",
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces nullish and non-string note messages before wrapping", () => {
|
||||
expect(wrapNoteMessage(undefined, { maxWidth: 20, columns: 80 })).toBe("");
|
||||
expect(wrapNoteMessage(null, { maxWidth: 20, columns: 80 })).toBe("");
|
||||
|
||||
30
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
30
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
title: OpenAI-compatible chat tools HTTP API
|
||||
|
||||
scenario:
|
||||
id: openai-compatible-chat-tools
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- gateway.tool-invocation-api
|
||||
secondary:
|
||||
- gateway.openai-compatible-apis
|
||||
- runtime.hosted-tool-use
|
||||
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
|
||||
successCriteria:
|
||||
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
|
||||
- The generated config preserves strict positive gateway port and timeout values.
|
||||
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
|
||||
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
|
||||
docsRefs:
|
||||
- docs/gateway/protocol.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-chat-tools/client.mjs
|
||||
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
|
||||
- scripts/e2e/openai-chat-tools-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
|
||||
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.
|
||||
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
title: OpenAI web_search minimal reasoning gate
|
||||
|
||||
scenario:
|
||||
id: openai-web-search-minimal
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.reasoning-and-cache-controls
|
||||
secondary:
|
||||
- web-search.openai-native-web-search
|
||||
- tools.web-search
|
||||
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
|
||||
successCriteria:
|
||||
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
|
||||
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
|
||||
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
|
||||
- Gateway ports are parsed strictly before connecting.
|
||||
docsRefs:
|
||||
- docs/tools/web.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
|
||||
- scripts/e2e/openai-web-search-minimal-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
|
||||
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.
|
||||
@@ -0,0 +1,30 @@
|
||||
title: OpenAI native web_search request assertions
|
||||
|
||||
scenario:
|
||||
id: openai-web-search-native-assertions
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- web-search.openai-native-web-search
|
||||
- plugins.web-search-and-fetch
|
||||
secondary:
|
||||
- web-search.model-and-filter-routing
|
||||
- tools.web-search
|
||||
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
|
||||
successCriteria:
|
||||
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
|
||||
- Large request logs are scanned without missing later success requests.
|
||||
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
|
||||
- Function-shaped `web_search` is rejected as native Responses proof.
|
||||
docsRefs:
|
||||
- docs/tools/web.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
|
||||
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
|
||||
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
|
||||
summary: Vitest coverage for native OpenAI web_search request-log assertions.
|
||||
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: OpenWebUI OpenAI-compatible API probe
|
||||
|
||||
scenario:
|
||||
id: openwebui-openai-compatible
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- gateway.openai-compatible-apis
|
||||
secondary:
|
||||
- runtime.hosted-provider-turns
|
||||
- runtime.provider-specific-model-options
|
||||
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
|
||||
successCriteria:
|
||||
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
|
||||
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
|
||||
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
|
||||
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/openwebui-probe.mjs
|
||||
- scripts/e2e/openwebui-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
|
||||
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
@@ -33,6 +32,7 @@ const legacyWriterNames = new Set([
|
||||
const legacyTranscriptWriterNames = new Set([
|
||||
"appendSessionTranscriptMessage",
|
||||
"emitSessionTranscriptUpdate",
|
||||
"rewriteTranscriptEntriesInSessionFile",
|
||||
]);
|
||||
const sessionCreateLifecycleWriterNames = new Set([
|
||||
"applySessionStoreEntryPatch",
|
||||
@@ -49,28 +49,6 @@ const legacyLifecycleCleanupNames = new Set([
|
||||
"archiveRemovedSessionTranscripts",
|
||||
"cleanupArchivedSessionTranscripts",
|
||||
]);
|
||||
const sessionStoreRuntimeFileBackedCompatNames = new Set([
|
||||
"loadSessionStore",
|
||||
"readSessionEntries",
|
||||
"readSessionEntry",
|
||||
"readLatestAssistantTextFromSessionTranscript",
|
||||
"readSessionStoreReadOnly",
|
||||
"resolveAndPersistSessionFile",
|
||||
"resolveSessionFilePath",
|
||||
"resolveSessionStoreEntry",
|
||||
"saveSessionStore",
|
||||
"updateSessionStore",
|
||||
]);
|
||||
|
||||
export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([
|
||||
"loadSessionStore",
|
||||
"readLatestAssistantTextFromSessionTranscript",
|
||||
"resolveAndPersistSessionFile",
|
||||
"resolveSessionFilePath",
|
||||
"resolveSessionStoreEntry",
|
||||
"saveSessionStore",
|
||||
"updateSessionStore",
|
||||
]);
|
||||
|
||||
export const migratedSessionAccessorFiles = new Set([
|
||||
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
|
||||
@@ -275,74 +253,6 @@ function findNamedSessionStoreViolations(content, fileName, legacyNames, legacyK
|
||||
);
|
||||
}
|
||||
|
||||
export function collectSessionStoreRuntimeFileBackedCompatExports(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const exports = new Map();
|
||||
|
||||
const rememberExport = (node, exportedName, sourceName = exportedName) => {
|
||||
if (!sessionStoreRuntimeFileBackedCompatNames.has(sourceName)) {
|
||||
return;
|
||||
}
|
||||
exports.set(exportedName, {
|
||||
line: toLine(sourceFile, node),
|
||||
sourceName,
|
||||
});
|
||||
};
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
const isExported = statement.modifiers?.some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
||||
);
|
||||
if (isExported && ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
rememberExport(declaration.name, declaration.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isExported && ts.isFunctionDeclaration(statement) && statement.name) {
|
||||
rememberExport(statement.name, statement.name.text);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause)
|
||||
) {
|
||||
for (const specifier of statement.exportClause.elements) {
|
||||
rememberExport(
|
||||
specifier,
|
||||
specifier.name.text,
|
||||
specifier.propertyName?.text ?? specifier.name.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exports;
|
||||
}
|
||||
|
||||
export function findSessionStoreRuntimeFileBackedCompatExportViolations(
|
||||
content,
|
||||
fileName = "source.ts",
|
||||
) {
|
||||
const exports = collectSessionStoreRuntimeFileBackedCompatExports(content, fileName);
|
||||
const violations = [];
|
||||
for (const [exportedName, exported] of exports) {
|
||||
if (
|
||||
exportedName !== exported.sourceName ||
|
||||
!allowedSessionStoreRuntimeFileBackedCompatExports.has(exportedName)
|
||||
) {
|
||||
violations.push({
|
||||
line: exported.line,
|
||||
reason: `exports unratcheted file-backed SDK session helper "${exported.sourceName}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function findSessionAccessorBoundaryViolations(content, fileName = "source.ts") {
|
||||
const legacyNames = legacyNamesForFile(fileName);
|
||||
const legacyKind = legacyNames === legacyWholeStoreAccessNames ? "access" : "reader";
|
||||
@@ -496,14 +406,6 @@ export async function main() {
|
||||
),
|
||||
findViolations: findSessionLifecycleCleanupBoundaryViolations,
|
||||
});
|
||||
const sessionStoreRuntimePath = path.join(repoRoot, "src/plugin-sdk/session-store-runtime.ts");
|
||||
const sessionStoreRuntimeCompatViolations =
|
||||
findSessionStoreRuntimeFileBackedCompatExportViolations(
|
||||
await fs.readFile(sessionStoreRuntimePath, "utf8"),
|
||||
sessionStoreRuntimePath,
|
||||
).map((violation) =>
|
||||
Object.assign({ path: "src/plugin-sdk/session-store-runtime.ts" }, violation),
|
||||
);
|
||||
const violations = [
|
||||
...readViolations,
|
||||
...writeViolations,
|
||||
@@ -511,7 +413,6 @@ export async function main() {
|
||||
...sessionCreateLifecycleViolations,
|
||||
...manualCompactTrimViolations,
|
||||
...lifecycleCleanupViolations,
|
||||
...sessionStoreRuntimeCompatViolations,
|
||||
];
|
||||
|
||||
if (violations.length === 0) {
|
||||
@@ -524,7 +425,7 @@ export async function main() {
|
||||
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
|
||||
}
|
||||
console.error(
|
||||
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand file-backed SDK compatibility only as an explicit pre-SQLite migration decision.",
|
||||
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand this ratchet only after a slice migrates more files.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
|
||||
"extensions/memory-core/src/memory-tool-manager-mock.ts",
|
||||
"ui/src/ui/browser-redact.ts",
|
||||
"src/agents/subagent-registry.runtime.ts",
|
||||
"src/auto-reply/inbound.group-require-mention-test-plugins.ts",
|
||||
"src/auto-reply/reply/get-reply.test-loader.ts",
|
||||
"src/cli/daemon-cli-compat.ts",
|
||||
"src/commands/doctor/shared/deprecation-compat.ts",
|
||||
|
||||
@@ -22,7 +22,6 @@ FIXTURE_PORT="18080"
|
||||
TOKEN="browser-cdp-e2e-token"
|
||||
CONTAINER_NAME="openclaw-browser-cdp-e2e-$$"
|
||||
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_BROWSER_CDP_SNAPSHOT_DOCKER_COMMAND_TIMEOUT:-900s}"
|
||||
SNAPSHOT_MAX_BYTES="$(docker_e2e_read_positive_int_env OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES 524288)"
|
||||
|
||||
cleanup() {
|
||||
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
@@ -64,7 +63,6 @@ docker_e2e_docker_cmd run -d \
|
||||
-e OPENCLAW_SKIP_GMAIL_WATCHER=1 \
|
||||
-e OPENCLAW_SKIP_CRON=1 \
|
||||
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
|
||||
-e "OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES=$SNAPSHOT_MAX_BYTES" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
|
||||
@@ -4,62 +4,12 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstall-e2e" OPENCLAW_BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_IMAGE)"
|
||||
LIST_TIMEOUT_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS 30000
|
||||
)"
|
||||
LIST_MAX_BUFFER_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES 4194304
|
||||
)"
|
||||
RUNTIME_PORT_BASE="$(docker_e2e_read_tcp_port_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE 19000)"
|
||||
RUNTIME_OUTPUT_CHARS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS 1048576
|
||||
)"
|
||||
RUNTIME_LOG_SCAN_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES 262144
|
||||
)"
|
||||
RUNTIME_GATEWAY_LOG_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES 16777216
|
||||
)"
|
||||
RUNTIME_READY_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS 900000
|
||||
)"
|
||||
RUNTIME_RPC_MS="$(docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS 60000)"
|
||||
RUNTIME_RPC_READY_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_READY_MS 210000
|
||||
)"
|
||||
RUNTIME_WATCHDOG_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS 1000
|
||||
)"
|
||||
RUNTIME_COMMAND_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS 120000
|
||||
)"
|
||||
RUNTIME_HTTP_MS="$(docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS 5000)"
|
||||
RUNTIME_TEARDOWN_GRACE_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS 10000
|
||||
)"
|
||||
RUNTIME_TEARDOWN_KILL_GRACE_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS 1000
|
||||
)"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 bundled-plugin-install-uninstall empty)"
|
||||
|
||||
DOCKER_ENV_ARGS=(
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS=$LIST_TIMEOUT_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES=$LIST_MAX_BUFFER_BYTES"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE=$RUNTIME_PORT_BASE"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS=$RUNTIME_OUTPUT_CHARS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES=$RUNTIME_LOG_SCAN_BYTES"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES=$RUNTIME_GATEWAY_LOG_BYTES"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS=$RUNTIME_READY_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS=$RUNTIME_RPC_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_READY_MS=$RUNTIME_RPC_READY_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS=$RUNTIME_WATCHDOG_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS=$RUNTIME_COMMAND_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS=$RUNTIME_HTTP_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS=$RUNTIME_TEARDOWN_GRACE_MS"
|
||||
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS=$RUNTIME_TEARDOWN_KILL_GRACE_MS"
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
|
||||
)
|
||||
for env_name in \
|
||||
@@ -68,6 +18,11 @@ for env_name in \
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS \
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT \
|
||||
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE \
|
||||
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE \
|
||||
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS \
|
||||
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS \
|
||||
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS \
|
||||
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS \
|
||||
OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER \
|
||||
OPENCLAW_PLUGIN_LIFECYCLE_TRACE \
|
||||
OPENAI_API_KEY; do
|
||||
|
||||
@@ -7,7 +7,6 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-codex-media-path-e2e" OPENCLAW_CODEX_MEDIA_PATH_E2E_IMAGE)"
|
||||
PORT="$(docker_e2e_read_tcp_port_env OPENCLAW_CODEX_MEDIA_PATH_PORT 18790)"
|
||||
TIMEOUT_SECONDS="$(docker_e2e_read_positive_int_env OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS 180)"
|
||||
LOG_TAIL_MAX_BYTES="$(docker_e2e_read_positive_int_env OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES 2097152)"
|
||||
TOKEN="codex-media-path-e2e-$$"
|
||||
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC:-npm:@openclaw/codex}"
|
||||
|
||||
@@ -18,7 +17,6 @@ echo "Running Codex media-path Docker E2E..."
|
||||
docker_e2e_run_logged_with_harness codex-media-path \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e "OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC=$CODEX_PLUGIN_SPEC" \
|
||||
-e "OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES=$LOG_TAIL_MAX_BYTES" \
|
||||
-e "OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS=$TIMEOUT_SECONDS" \
|
||||
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
|
||||
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
||||
|
||||
@@ -20,24 +20,6 @@ PROFILE_FILE="${OPENCLAW_CODEX_NPM_PLUGIN_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFI
|
||||
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:-}"
|
||||
CODEX_PLUGIN_MOUNT=()
|
||||
CODEX_PLUGIN_PACK_DIR=""
|
||||
ASSERT_MAX_TEXT_FILE_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES 1048576
|
||||
)"
|
||||
ASSERT_MAX_ERROR_TAIL_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES 65536
|
||||
)"
|
||||
ASSERT_MAX_TRANSCRIPT_FILES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES 64
|
||||
)"
|
||||
ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES 4096
|
||||
)"
|
||||
ASSERT_MAX_TRANSCRIPT_SCAN_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES 2097152
|
||||
)"
|
||||
AGENT_TURN_TIMEOUT_SECONDS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS 420
|
||||
)"
|
||||
run_log=""
|
||||
|
||||
cleanup() {
|
||||
@@ -128,10 +110,6 @@ if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then
|
||||
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro)
|
||||
PROFILE_STATUS="$PROFILE_FILE"
|
||||
fi
|
||||
AGENT_TURN_TIMEOUT_SECONDS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS"
|
||||
)"
|
||||
COMMAND_TIMEOUT="${OPENCLAW_E2E_COMMAND_TIMEOUT:-$((10#$AGENT_TURN_TIMEOUT_SECONDS + 60))s}"
|
||||
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
run_log="$(docker_e2e_run_log codex-npm-plugin-live)"
|
||||
@@ -146,13 +124,6 @@ if ! docker_e2e_run_with_harness \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL="${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-1}" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-codex/gpt-5.4}" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="$CODEX_PLUGIN_SPEC" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES=$ASSERT_MAX_TEXT_FILE_BYTES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES=$ASSERT_MAX_ERROR_TAIL_BYTES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES=$ASSERT_MAX_TRANSCRIPT_FILES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES=$ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES=$ASSERT_MAX_TRANSCRIPT_SCAN_BYTES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS=$AGENT_TURN_TIMEOUT_SECONDS" \
|
||||
-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT" \
|
||||
-e OPENAI_API_KEY \
|
||||
-e OPENAI_BASE_URL \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
@@ -194,7 +165,6 @@ MODEL_REF="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:?missing OPENCLAW_CODEX_NPM_PLUGIN_
|
||||
POST_UNINSTALL_MODEL_REF="codex/${MODEL_REF#*/}"
|
||||
SESSION_ID="codex-npm-plugin-live"
|
||||
SUCCESS_MARKER="OPENCLAW-CODEX-NPM-PLUGIN-LIVE-OK"
|
||||
AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS:-420}"
|
||||
PLUGIN_INSTALL_FLAGS=(--force)
|
||||
if [ "${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-0}" = "1" ]; then
|
||||
PLUGIN_INSTALL_FLAGS+=(--dangerously-force-unsafe-install)
|
||||
@@ -281,7 +251,7 @@ run_agent_turn() {
|
||||
--model "$MODEL_REF" \
|
||||
--message "$message" \
|
||||
--thinking low \
|
||||
--timeout "$AGENT_TURN_TIMEOUT_SECONDS" \
|
||||
--timeout 420 \
|
||||
--json >"$out" 2>"$err" </dev/null; then
|
||||
status=0
|
||||
else
|
||||
|
||||
@@ -7,8 +7,6 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-doctor-install-switch-e2e" OPENCLAW_DOCTOR_INSTALL_SWITCH_E2E_IMAGE)"
|
||||
NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}"
|
||||
COMMAND_TIMEOUT="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
|
||||
cleanup() {
|
||||
docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"
|
||||
}
|
||||
@@ -24,8 +22,6 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" doctor-switch "$ROOT_DIR/scripts/e2e/Doc
|
||||
echo "Running doctor install switch E2E..."
|
||||
docker_e2e_run_with_harness \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e "OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT=$COMMAND_TIMEOUT" \
|
||||
-e "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=$NPM_INSTALL_TIMEOUT" \
|
||||
-e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
|
||||
@@ -12,22 +12,6 @@ NET_NAME="openclaw-net-e2e-$$"
|
||||
GW_NAME="openclaw-gateway-e2e-$$"
|
||||
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_GATEWAY_NETWORK_DOCKER_COMMAND_TIMEOUT:-600s}"
|
||||
CLIENT_TIMEOUT="${OPENCLAW_GATEWAY_NETWORK_CLIENT_TIMEOUT:-90s}"
|
||||
CLIENT_LIMIT_ENV_ARGS=()
|
||||
if [[ -n "${OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS+x}" ]]; then
|
||||
CLIENT_CONNECT_TIMEOUT_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS 80000
|
||||
)"
|
||||
CLIENT_LIMIT_ENV_ARGS+=(
|
||||
-e "OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS=$CLIENT_CONNECT_TIMEOUT_MS"
|
||||
)
|
||||
elif [[ -n "${OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS+x}" ]]; then
|
||||
CONNECT_READY_TIMEOUT_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS 80000
|
||||
)"
|
||||
CLIENT_LIMIT_ENV_ARGS+=(
|
||||
-e "OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS=$CONNECT_READY_TIMEOUT_MS"
|
||||
)
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
docker_e2e_docker_cmd rm -f "$GW_NAME" >/dev/null 2>&1 || true
|
||||
@@ -65,7 +49,6 @@ echo "Running client container (connect + health)..."
|
||||
DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docker_e2e_docker_run_cmd run --rm \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
--network "$NET_NAME" \
|
||||
"${CLIENT_LIMIT_ENV_ARGS[@]}" \
|
||||
-e "GW_URL=ws://$GW_NAME:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
"$IMAGE_NAME" \
|
||||
|
||||
@@ -7,9 +7,6 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-plugin-e2e" OPENCL
|
||||
OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536
|
||||
)"
|
||||
CLAW_HUB_FIXTURE_WAIT_ATTEMPTS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS 600
|
||||
)"
|
||||
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 kitchen-sink-plugin empty)"
|
||||
KITCHEN_SINK_NPM_SPEC="${OPENCLAW_KITCHEN_SINK_NPM_SPEC:-npm:@openclaw/kitchen-sink@latest}"
|
||||
@@ -51,7 +48,6 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-plugin
|
||||
|
||||
DOCKER_ENV_ARGS=(
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
-e "OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS=$CLAW_HUB_FIXTURE_WAIT_ATTEMPTS"
|
||||
-e "OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES=$OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES"
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
|
||||
-e "KITCHEN_SINK_SCENARIOS=$KITCHEN_SINK_SCENARIOS"
|
||||
|
||||
@@ -7,9 +7,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-rpc-e2e" OPENCLAW_KITCHEN_SINK_RPC_E2E_IMAGE)"
|
||||
MAX_MEMORY_MIB="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_MAX_MEMORY_MIB 2048)"
|
||||
MAX_CPU_PERCENT="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT 1200)"
|
||||
# Keep the outer Docker watchdog above the walker's install, enable, inspect,
|
||||
# readiness, and first-RPC retry budgets so inner failures stay diagnostic.
|
||||
DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_RPC_DOCKER_RUN_TIMEOUT:-1500s}"
|
||||
DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_RPC_DOCKER_RUN_TIMEOUT:-900s}"
|
||||
CONTAINER_NAME="openclaw-kitchen-sink-rpc-e2e-$$"
|
||||
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-rpc.XXXXXX")"
|
||||
STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-rpc-stats.XXXXXX")"
|
||||
|
||||
@@ -892,10 +892,14 @@ export async function fetchJson(url, options = {}) {
|
||||
let removeExternalAbort = () => {};
|
||||
const abortPromise = externalSignal
|
||||
? new Promise((_, reject) => {
|
||||
const abortError = () =>
|
||||
externalSignal.reason instanceof Error
|
||||
? externalSignal.reason
|
||||
: new Error("fetch aborted");
|
||||
const onAbort = () => {
|
||||
const error = getExternalAbortReason(externalSignal);
|
||||
const error = abortError();
|
||||
controller.abort(error);
|
||||
reject(createExternalAbortError(externalSignal));
|
||||
reject(new Error(error.message, { cause: error }));
|
||||
};
|
||||
if (externalSignal.aborted) {
|
||||
onAbort();
|
||||
@@ -935,7 +939,7 @@ export async function fetchJson(url, options = {}) {
|
||||
if (attempt >= attempts || !isRetryableTransientNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await delayWithAbort(options.retryDelayMs ?? 250, externalSignal);
|
||||
await delay(options.retryDelayMs ?? 250);
|
||||
} finally {
|
||||
removeExternalAbort();
|
||||
if (timeout) {
|
||||
@@ -946,33 +950,6 @@ export async function fetchJson(url, options = {}) {
|
||||
throw toLintErrorObject(lastError ?? new Error(`fetch ${url} failed`), "Non-Error thrown");
|
||||
}
|
||||
|
||||
function getExternalAbortReason(signal) {
|
||||
return signal?.reason instanceof Error ? signal.reason : new Error("fetch aborted");
|
||||
}
|
||||
|
||||
function createExternalAbortError(signal) {
|
||||
const reason = getExternalAbortReason(signal);
|
||||
return new Error(reason.message, { cause: reason });
|
||||
}
|
||||
|
||||
async function delayWithAbort(delayMs, signal) {
|
||||
if (!signal) {
|
||||
await delay(delayMs);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw createExternalAbortError(signal);
|
||||
}
|
||||
try {
|
||||
await delay(delayMs, undefined, { signal });
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw createExternalAbortError(signal);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBoundedResponseText(response, byteLimit, timeoutPromise) {
|
||||
const resolvedByteLimit = byteLimit ?? resolveKitchenSinkRpcConfig().fetchBodyMaxBytes;
|
||||
const contentLength = response.headers?.get?.("content-length");
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readBoundedResponseText } from "../bounded-response-text.mjs";
|
||||
|
||||
const TOKEN = "bundled-plugin-runtime-smoke-token";
|
||||
const RUNTIME_PORT_BASE_ENV = "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE";
|
||||
@@ -32,7 +31,6 @@ const RPC_READY_TIMEOUT_MS = readPositiveIntEnv(
|
||||
);
|
||||
const COMMAND_TIMEOUT_MS = readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS", 120000);
|
||||
const HTTP_PROBE_TIMEOUT_MS = readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS", 5000);
|
||||
const HTTP_PROBE_BODY_MAX_BYTES = 1024 * 1024;
|
||||
const GATEWAY_TEARDOWN_GRACE_MS = readPositiveIntEnv(
|
||||
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS",
|
||||
10000,
|
||||
@@ -695,37 +693,18 @@ export async function waitForReady(params) {
|
||||
async function fetchHttpProbeStatus(port, pathName, options = {}) {
|
||||
const { parseJson = false, timeoutMs = HTTP_PROBE_TIMEOUT_MS } = options;
|
||||
const controller = new AbortController();
|
||||
const timeoutError = Object.assign(
|
||||
new Error(`${pathName} probe timed out after ${timeoutMs}ms`),
|
||||
{
|
||||
code: "ETIMEDOUT",
|
||||
},
|
||||
);
|
||||
let clearProbeTimer;
|
||||
const timeoutPromise = timeoutMs
|
||||
? new Promise((_, reject) => {
|
||||
clearProbeTimer = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
clearProbeTimer.unref?.();
|
||||
})
|
||||
const clearProbeTimer = timeoutMs
|
||||
? setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
fetch(`http://127.0.0.1:${port}${pathName}`, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
...(timeoutPromise ? [timeoutPromise] : []),
|
||||
]);
|
||||
const res = await fetch(`http://127.0.0.1:${port}${pathName}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const status = { ok: res.ok, status: res.status, body: undefined, bodyText: undefined };
|
||||
if (parseJson) {
|
||||
const text = await readBoundedResponseText(
|
||||
res,
|
||||
`${pathName} probe`,
|
||||
HTTP_PROBE_BODY_MAX_BYTES,
|
||||
timeoutPromise,
|
||||
);
|
||||
const text = await res.text();
|
||||
status.bodyText = text;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
|
||||
@@ -37,10 +37,6 @@ const MAX_TRANSCRIPT_SCAN_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES",
|
||||
2 * 1024 * 1024,
|
||||
);
|
||||
const AGENT_TURN_TIMEOUT_SECONDS = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS",
|
||||
420,
|
||||
);
|
||||
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const text = String(process.env[name] ?? fallback).trim();
|
||||
@@ -104,7 +100,7 @@ function configure() {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
requestTimeoutMs: AGENT_TURN_TIMEOUT_SECONDS * 1000,
|
||||
requestTimeoutMs: 420_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -121,7 +117,7 @@ function configure() {
|
||||
},
|
||||
workspace: path.join(state, "workspace"),
|
||||
skipBootstrap: true,
|
||||
timeoutSeconds: AGENT_TURN_TIMEOUT_SECONDS,
|
||||
timeoutSeconds: 420,
|
||||
},
|
||||
};
|
||||
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
||||
|
||||
@@ -53,39 +53,26 @@ function readPositiveInt(raw, fallback, label) {
|
||||
async function withClickClackFixtureResponse(url, init, consume, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? clickClackHttpTimeoutMs();
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new Error(`${url} timed out after ${timeoutMs}ms`);
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return await consume(response, { timeoutPromise });
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await consume(response);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponseText(
|
||||
response,
|
||||
label,
|
||||
byteLimit = clickClackHttpBodyMaxBytes(),
|
||||
options = {},
|
||||
) {
|
||||
return await readBoundedResponseTextWithLimit(response, label, byteLimit, options.timeoutPromise);
|
||||
async function readBoundedResponseText(response, label, byteLimit = clickClackHttpBodyMaxBytes()) {
|
||||
return await readBoundedResponseTextWithLimit(response, label, byteLimit);
|
||||
}
|
||||
|
||||
async function readBoundedResponseJson(response, label, options = {}) {
|
||||
return JSON.parse(await readBoundedResponseText(response, label, undefined, options));
|
||||
async function readBoundedResponseJson(response, label) {
|
||||
return JSON.parse(await readBoundedResponseText(response, label));
|
||||
}
|
||||
|
||||
function resolveHomePath(value) {
|
||||
@@ -291,10 +278,8 @@ async function postClickClackInbound() {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
},
|
||||
async (response, options) => {
|
||||
const text = response.ok
|
||||
? ""
|
||||
: await readBoundedResponseText(response, "ClickClack inbound", undefined, options);
|
||||
async (response) => {
|
||||
const text = response.ok ? "" : await readBoundedResponseText(response, "ClickClack inbound");
|
||||
assert(response.ok, `fixture inbound failed: ${response.status} ${text}`);
|
||||
},
|
||||
);
|
||||
@@ -313,9 +298,9 @@ async function waitClickClackSocket() {
|
||||
const state = await withClickClackFixtureResponse(
|
||||
`${baseUrl}/fixture/state`,
|
||||
{},
|
||||
async (response, options) =>
|
||||
async (response) =>
|
||||
response.ok
|
||||
? await readBoundedResponseJson(response, "ClickClack fixture state", options)
|
||||
? await readBoundedResponseJson(response, "ClickClack fixture state")
|
||||
: undefined,
|
||||
{
|
||||
timeoutMs: Math.min(clickClackHttpTimeoutMs(), remainingMs),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// Probes gateway state for upgrade-survivor E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readBoundedResponseText } from "../../../lib/bounded-response.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -105,29 +104,47 @@ function matchesDegradedReadyExpectation(body) {
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundedResponseText(response, byteLimit) {
|
||||
const contentLength = response.headers?.get?.("content-length");
|
||||
if (contentLength && /^\d+$/u.test(contentLength)) {
|
||||
const parsedContentLength = Number(contentLength);
|
||||
if (Number.isSafeInteger(parsedContentLength) && parsedContentLength > byteLimit) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(`${url} probe body exceeded ${byteLimit} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return "";
|
||||
}
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > byteLimit) {
|
||||
await reader.cancel();
|
||||
throw new Error(`${url} probe body exceeded ${byteLimit} bytes`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
||||
}
|
||||
|
||||
async function fetchProbeText() {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const remainingMs = Math.max(1, timeoutMs - elapsedMs);
|
||||
const controller = new AbortController();
|
||||
const attemptDeadlineMs = Math.min(attemptTimeoutMs, remainingMs);
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${url} probe attempt timed out after ${attemptDeadlineMs}ms`));
|
||||
controller.abort();
|
||||
}, attemptDeadlineMs);
|
||||
});
|
||||
const timer = setTimeout(() => controller.abort(), Math.min(attemptTimeoutMs, remainingMs));
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetch(url, { method: "GET", signal: controller.signal }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
const response = await fetch(url, { method: "GET", signal: controller.signal });
|
||||
return {
|
||||
response,
|
||||
text: await readBoundedResponseText(response, `${url} probe`, maxBodyBytes, {
|
||||
formatTooLargeMessage: (_label, bytes) => `${url} probe body exceeded ${bytes} bytes`,
|
||||
timeoutPromise,
|
||||
}),
|
||||
text: await readBoundedResponseText(response, maxBodyBytes),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -12,11 +12,9 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-live-plugin-tool-e2e" OPENCLAW_
|
||||
DOCKER_TARGET="${OPENCLAW_LIVE_PLUGIN_TOOL_DOCKER_TARGET:-bare}"
|
||||
HOST_BUILD="${OPENCLAW_LIVE_PLUGIN_TOOL_HOST_BUILD:-1}"
|
||||
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
|
||||
PROFILE_FILE="${OPENCLAW_LIVE_PLUGIN_TOOL_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}"
|
||||
AGENT_TURN_TIMEOUT_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS 300)"
|
||||
AGENT_OUTPUT_MAX_BYTES="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES 1048576)"
|
||||
AGENT_OUTPUT_DUMP_BYTES="$(openclaw_e2e_read_nonnegative_int_env OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_DUMP_BYTES 16384)"
|
||||
SESSION_SCAN_MAX_ENTRIES="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES 50000)"
|
||||
PROFILE_FILE="${OPENCLAW_LIVE_PLUGIN_TOOL_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}"
|
||||
run_log=""
|
||||
|
||||
cleanup() {
|
||||
@@ -59,8 +57,6 @@ if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then
|
||||
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro)
|
||||
PROFILE_STATUS="$PROFILE_FILE"
|
||||
fi
|
||||
AGENT_TURN_TIMEOUT_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS")"
|
||||
COMMAND_TIMEOUT="${OPENCLAW_E2E_COMMAND_TIMEOUT:-$((10#$AGENT_TURN_TIMEOUT_SECONDS + 60))s}"
|
||||
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
run_log="$(docker_e2e_run_log live-plugin-tool)"
|
||||
@@ -73,11 +69,8 @@ if ! docker_e2e_run_with_harness \
|
||||
-e OPENAI_API_KEY \
|
||||
-e OPENAI_BASE_URL \
|
||||
-e OPENCLAW_LIVE_PLUGIN_TOOL_MODEL="${OPENCLAW_LIVE_PLUGIN_TOOL_MODEL:-openai/gpt-5.5}" \
|
||||
-e "OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_DUMP_BYTES=$AGENT_OUTPUT_DUMP_BYTES" \
|
||||
-e "OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES=$AGENT_OUTPUT_MAX_BYTES" \
|
||||
-e "OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES=$SESSION_SCAN_MAX_ENTRIES" \
|
||||
-e "OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS=$AGENT_TURN_TIMEOUT_SECONDS" \
|
||||
-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"${PROFILE_MOUNT[@]}" \
|
||||
|
||||
@@ -12,12 +12,6 @@ DOCKER_TARGET="${OPENCLAW_NPM_ONBOARD_DOCKER_TARGET:-bare}"
|
||||
HOST_BUILD="${OPENCLAW_NPM_ONBOARD_HOST_BUILD:-1}"
|
||||
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
|
||||
CHANNEL="${OPENCLAW_NPM_ONBOARD_CHANNEL:-telegram}"
|
||||
JSON_ARTIFACT_MAX_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES 1048576
|
||||
)"
|
||||
STATUS_TEXT_MAX_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES 1048576
|
||||
)"
|
||||
run_log=""
|
||||
|
||||
cleanup() {
|
||||
@@ -62,8 +56,6 @@ echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..."
|
||||
if ! docker_e2e_run_with_harness \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \
|
||||
-e "OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES=$JSON_ARTIFACT_MAX_BYTES" \
|
||||
-e "OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES=$STATUS_TEXT_MAX_BYTES" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'; then
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-onboard-e2e" OPENCLAW_ONBOARD_E2E_IMAGE)"
|
||||
OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"
|
||||
@@ -10,8 +9,6 @@ MAX_MEMORY_MIB="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_ONBOARD_MAX_M
|
||||
MAX_CPU_PERCENT="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_ONBOARD_MAX_CPU_PERCENT 1200)"
|
||||
DOCKER_RUN_TIMEOUT="${OPENCLAW_ONBOARD_DOCKER_RUN_TIMEOUT:-1200s}"
|
||||
COMMAND_TIMEOUT="${OPENCLAW_ONBOARD_COMMAND_TIMEOUT:-${OPENCLAW_E2E_COMMAND_TIMEOUT:-300s}}"
|
||||
GATEWAY_WAIT_ATTEMPTS="$(openclaw_e2e_read_positive_int_env OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS 20)"
|
||||
GATEWAY_WAIT_INTERVAL_S="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S 1)"
|
||||
CONTAINER_NAME="openclaw-onboard-e2e-$$"
|
||||
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-onboard.XXXXXX")"
|
||||
STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-onboard-stats.XXXXXX")"
|
||||
@@ -30,8 +27,6 @@ docker_e2e_harness_mount_args
|
||||
DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" -t \
|
||||
-e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \
|
||||
-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT" \
|
||||
-e "OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS=$GATEWAY_WAIT_ATTEMPTS" \
|
||||
-e "OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S=$GATEWAY_WAIT_INTERVAL_S" \
|
||||
"$IMAGE_NAME" bash scripts/e2e/lib/onboard/scenario.sh >"$RUN_LOG" 2>&1 &
|
||||
docker_pid="$!"
|
||||
|
||||
|
||||
@@ -58,28 +58,6 @@ function throwIfFailed(label: string, result: CommandResult, check: boolean | un
|
||||
throw new Error(`${label} failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
const POSIX_GUEST_SCRIPT_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function appendCommandResult(phases: PhaseRunner, result: CommandResult): void {
|
||||
phases.append(result.stdout);
|
||||
phases.append(result.stderr);
|
||||
}
|
||||
|
||||
function cleanupPosixGuestScript(phases: PhaseRunner, transportArgs: string[]): void {
|
||||
try {
|
||||
appendCommandResult(
|
||||
phases,
|
||||
run("prlctl", transportArgs, {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: POSIX_GUEST_SCRIPT_CLEANUP_TIMEOUT_MS,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Cleanup must not hide the command failure that made the phase useful.
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWindowsBackgroundPowerShell(
|
||||
options: WindowsBackgroundPowerShellOptions,
|
||||
): Promise<void> {
|
||||
@@ -376,36 +354,48 @@ export class LinuxGuest {
|
||||
) {}
|
||||
|
||||
exec(args: string[], options: GuestExecOptions = {}): string {
|
||||
const result = run("prlctl", this.transportArgs(args), {
|
||||
check: false,
|
||||
input: options.input,
|
||||
quiet: true,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
|
||||
});
|
||||
const result = run(
|
||||
"prlctl",
|
||||
["exec", this.vmName, "/usr/bin/env", "HOME=/root", "OPENCLAW_ALLOW_ROOT=1", ...args],
|
||||
{
|
||||
check: false,
|
||||
input: options.input,
|
||||
quiet: true,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
|
||||
},
|
||||
);
|
||||
this.phases.append(result.stdout);
|
||||
this.phases.append(result.stderr);
|
||||
throwIfFailed("Linux guest command", result, options.check);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
private transportArgs(args: string[]): string[] {
|
||||
return ["exec", this.vmName, "/usr/bin/env", "HOME=/root", "OPENCLAW_ALLOW_ROOT=1", ...args];
|
||||
}
|
||||
|
||||
bash(script: string): string {
|
||||
const scriptPath = `/tmp/${guestScriptName("sh")}`;
|
||||
try {
|
||||
const write = run("prlctl", this.transportArgs(["dd", `of=${scriptPath}`, "bs=1048576"]), {
|
||||
check: false,
|
||||
const write = run(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
this.vmName,
|
||||
"/usr/bin/env",
|
||||
"HOME=/root",
|
||||
"OPENCLAW_ALLOW_ROOT=1",
|
||||
"dd",
|
||||
`of=${scriptPath}`,
|
||||
"bs=1048576",
|
||||
],
|
||||
{
|
||||
input: `umask 022\n${script}`,
|
||||
quiet: true,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
});
|
||||
appendCommandResult(this.phases, write);
|
||||
throwIfFailed("Linux guest script write", write, undefined);
|
||||
},
|
||||
);
|
||||
this.phases.append(write.stdout);
|
||||
this.phases.append(write.stderr);
|
||||
try {
|
||||
return this.exec(["bash", scriptPath]);
|
||||
} finally {
|
||||
cleanupPosixGuestScript(this.phases, this.transportArgs(["/bin/rm", "-f", scriptPath]));
|
||||
this.exec(["rm", "-f", scriptPath], { check: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,31 +420,29 @@ export class MacosGuest {
|
||||
return this.run(args, options).stdout.trim();
|
||||
}
|
||||
|
||||
private transportArgs(args: string[], env: Record<string, string> = {}): string[] {
|
||||
const envArgs = Object.entries({ PATH: this.input.path, ...env }).map(
|
||||
run(args: string[], options: MacosGuestOptions = {}): CommandResult {
|
||||
const envArgs = Object.entries({ PATH: this.input.path, ...options.env }).map(
|
||||
([key, value]) => `${key}=${value}`,
|
||||
);
|
||||
const user = this.input.getUser();
|
||||
return this.input.getTransport() === "sudo"
|
||||
? [
|
||||
"exec",
|
||||
this.input.vmName,
|
||||
"/usr/bin/sudo",
|
||||
"-H",
|
||||
"-u",
|
||||
user,
|
||||
"/usr/bin/env",
|
||||
`HOME=${this.input.resolveDesktopHome(user)}`,
|
||||
`USER=${user}`,
|
||||
`LOGNAME=${user}`,
|
||||
...envArgs,
|
||||
...args,
|
||||
]
|
||||
: ["exec", this.input.vmName, "--current-user", "/usr/bin/env", ...envArgs, ...args];
|
||||
}
|
||||
|
||||
run(args: string[], options: MacosGuestOptions = {}): CommandResult {
|
||||
const result = run("prlctl", this.transportArgs(args, options.env), {
|
||||
const transportArgs =
|
||||
this.input.getTransport() === "sudo"
|
||||
? [
|
||||
"exec",
|
||||
this.input.vmName,
|
||||
"/usr/bin/sudo",
|
||||
"-H",
|
||||
"-u",
|
||||
user,
|
||||
"/usr/bin/env",
|
||||
`HOME=${this.input.resolveDesktopHome(user)}`,
|
||||
`USER=${user}`,
|
||||
`LOGNAME=${user}`,
|
||||
...envArgs,
|
||||
...args,
|
||||
]
|
||||
: ["exec", this.input.vmName, "--current-user", "/usr/bin/env", ...envArgs, ...args];
|
||||
const result = run("prlctl", transportArgs, {
|
||||
check: false,
|
||||
input: options.input,
|
||||
quiet: true,
|
||||
@@ -468,13 +456,13 @@ export class MacosGuest {
|
||||
|
||||
sh(script: string, env: Record<string, string> = {}): string {
|
||||
const scriptPath = `/tmp/${guestScriptName("sh")}`;
|
||||
this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], {
|
||||
input: `umask 022\n${script}`,
|
||||
});
|
||||
try {
|
||||
this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], {
|
||||
input: `umask 022\n${script}`,
|
||||
});
|
||||
return this.exec(["/bin/bash", scriptPath], { env });
|
||||
} finally {
|
||||
cleanupPosixGuestScript(this.phases, this.transportArgs(["/bin/rm", "-f", scriptPath]));
|
||||
this.exec(["/bin/rm", "-f", scriptPath], { check: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,9 +531,6 @@ export async function runStreaming(
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.on("error", (error) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
|
||||
@@ -71,12 +71,7 @@ function posixPrintLogTailFunction(): string {
|
||||
}`;
|
||||
}
|
||||
|
||||
function posixAssertAgentOkScript(
|
||||
command: string,
|
||||
input: NpmUpdateScriptInput,
|
||||
platform: Extract<Platform, "linux" | "macos">,
|
||||
sessionId: string,
|
||||
) {
|
||||
function posixAssertAgentOkScript(command: string, input: NpmUpdateScriptInput, sessionId: string) {
|
||||
return `${posixProviderOnlyPluginIsolationScript({
|
||||
fallbackPluginId: input.auth.modelId.split("/", 1)[0] || "openai",
|
||||
modelId: input.auth.modelId,
|
||||
@@ -89,7 +84,7 @@ for attempt in 1 2; do
|
||||
rm -f "$HOME/.openclaw/agents/main/sessions/$session_id.jsonl"
|
||||
output_file="$(mktemp)"
|
||||
set +e
|
||||
OPENCLAW_ALLOW_ROOT="\${OPENCLAW_ALLOW_ROOT:-}" ${input.auth.apiKeyEnv}=${shellQuote(input.auth.apiKeyValue)} ${command} agent --local --agent main --session-id "$session_id" --message 'Reply with exact ASCII text OK only.' --thinking off --timeout ${resolveParallelsModelTimeoutSeconds(platform)} --json >"$output_file" 2>&1
|
||||
OPENCLAW_ALLOW_ROOT="\${OPENCLAW_ALLOW_ROOT:-}" ${input.auth.apiKeyEnv}=${shellQuote(input.auth.apiKeyValue)} ${command} agent --local --agent main --session-id "$session_id" --message 'Reply with exact ASCII text OK only.' --thinking off --json >"$output_file" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
print_log_tail "$output_file"
|
||||
@@ -264,7 +259,7 @@ ${posixModelProviderConfigCommands(macosOpenClawCommand, input.auth.modelId, "ma
|
||||
"$OPENCLAW_BIN" config set agents.defaults.skipBootstrap true --strict-json
|
||||
"$OPENCLAW_BIN" config set tools.profile minimal
|
||||
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
|
||||
${posixAssertAgentOkScript(macosOpenClawCommand, input, "macos", "parallels-npm-update-macos")}`;
|
||||
${posixAssertAgentOkScript(macosOpenClawCommand, input, "parallels-npm-update-macos")}`;
|
||||
}
|
||||
|
||||
export function windowsUpdateScript(input: NpmUpdateScriptInput): string {
|
||||
@@ -408,7 +403,7 @@ ${posixModelProviderConfigCommands("openclaw", input.auth.modelId, "linux")}
|
||||
openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
openclaw config set tools.profile minimal
|
||||
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
|
||||
${posixAssertAgentOkScript("openclaw", input, "linux", "parallels-npm-update-linux")}`;
|
||||
${posixAssertAgentOkScript("openclaw", input, "parallels-npm-update-linux")}`;
|
||||
}
|
||||
|
||||
function posixVersionCheck(command: string, expectedNeedle: string): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
import { spawn } from "node:child_process";
|
||||
// Npm Update Smoke script supports OpenClaw repository automation.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { copyFile, readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -1109,32 +1109,23 @@ export class NpmUpdateSmoke {
|
||||
if (write.status !== 0) {
|
||||
throw new Error(`failed to write guest script ${scriptPath}: ${write.stderr.trim()}`);
|
||||
}
|
||||
try {
|
||||
const chmod = run("prlctl", ["exec", vm, "/bin/chmod", "755", scriptPath], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (chmod.status !== 0) {
|
||||
throw new Error(`failed to chmod guest script ${scriptPath}: ${chmod.stderr.trim()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.removeGuestScript(vm, scriptPath);
|
||||
throw error;
|
||||
const chmod = run("prlctl", ["exec", vm, "/bin/chmod", "755", scriptPath], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (chmod.status !== 0) {
|
||||
throw new Error(`failed to chmod guest script ${scriptPath}: ${chmod.stderr.trim()}`);
|
||||
}
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
private removeGuestScript(vm: string, scriptPath: string): void {
|
||||
try {
|
||||
run("prlctl", ["exec", vm, "/bin/rm", "-f", scriptPath], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
} catch {
|
||||
// Cleanup must not hide the update failure that made the log useful.
|
||||
}
|
||||
run("prlctl", ["exec", vm, "/bin/rm", "-f", scriptPath], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
private async runStreamingToJobLog(
|
||||
@@ -1190,7 +1181,6 @@ export class NpmUpdateSmoke {
|
||||
|
||||
child.on("error", (error) => {
|
||||
ctx.signal.removeEventListener("abort", abort);
|
||||
clearTimeout(timer);
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ append_positive_number_env() {
|
||||
|
||||
append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS
|
||||
append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS
|
||||
append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS
|
||||
append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_MAX_RSS_KB
|
||||
append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_MAX_WALL_MS
|
||||
append_positive_number_env OPENCLAW_PLUGIN_LIFECYCLE_MAX_CPU_CORE_RATIO
|
||||
|
||||
@@ -7,13 +7,6 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-plugins-e2e" OPENCLAW_PLUGINS_E
|
||||
OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536
|
||||
)"
|
||||
CLAW_HUB_PREFLIGHT_BODY_MAX_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_BODY_MAX_BYTES 1048576
|
||||
)"
|
||||
CLAW_HUB_PREFLIGHT_TIMEOUT_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS 30000
|
||||
)"
|
||||
PLUGINS_CLI_TIMEOUT="${OPENCLAW_PLUGINS_CLI_TIMEOUT:-180s}"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" plugins
|
||||
|
||||
@@ -21,9 +14,6 @@ OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugins empty)
|
||||
DOCKER_ENV_ARGS=(
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
-e "OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES=$OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES"
|
||||
-e "OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_BODY_MAX_BYTES=$CLAW_HUB_PREFLIGHT_BODY_MAX_BYTES"
|
||||
-e "OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS=$CLAW_HUB_PREFLIGHT_TIMEOUT_MS"
|
||||
-e "OPENCLAW_PLUGINS_CLI_TIMEOUT=$PLUGINS_CLI_TIMEOUT"
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
|
||||
)
|
||||
for env_name in \
|
||||
@@ -42,8 +32,7 @@ if [[ "${OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB:-0}" = "1" ]]; then
|
||||
CLAWHUB_URL \
|
||||
OPENCLAW_CLAWHUB_TOKEN \
|
||||
CLAWHUB_TOKEN \
|
||||
CLAWHUB_AUTH_TOKEN \
|
||||
OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY; do
|
||||
CLAWHUB_AUTH_TOKEN; do
|
||||
env_value="${!env_name:-}"
|
||||
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
|
||||
DOCKER_ENV_ARGS+=(-e "$env_name")
|
||||
|
||||
@@ -9,12 +9,6 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
||||
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-release-user-journey-e2e" OPENCLAW_RELEASE_USER_JOURNEY_E2E_IMAGE)"
|
||||
SKIP_BUILD="${OPENCLAW_RELEASE_USER_JOURNEY_E2E_SKIP_BUILD:-0}"
|
||||
HTTP_TIMEOUT_MS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS 5000
|
||||
)"
|
||||
HTTP_BODY_MAX_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES 1048576
|
||||
)"
|
||||
run_log=""
|
||||
cleanup() {
|
||||
docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"
|
||||
@@ -34,8 +28,6 @@ run_log="$(docker_e2e_run_log release-user-journey)"
|
||||
echo "Running release user journey Docker E2E..."
|
||||
if ! docker_e2e_run_with_harness \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e "OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS=$HTTP_TIMEOUT_MS" \
|
||||
-e "OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES=$HTTP_BODY_MAX_BYTES" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-user-journey/scenario.sh >"$run_log" 2>&1; then
|
||||
|
||||
@@ -18,33 +18,11 @@ UPDATE_RESTART_MODE="${OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE:-manual}"
|
||||
COMMAND_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"
|
||||
START_BUDGET_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS 90)"
|
||||
STATUS_BUDGET_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS 30)"
|
||||
PROBE_TIMEOUT_MS="$(openclaw_e2e_read_nonnegative_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS 60000)"
|
||||
PROBE_ATTEMPT_TIMEOUT_MS="$(
|
||||
openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS 5000
|
||||
)"
|
||||
PROBE_MAX_BODY_BYTES="$(
|
||||
openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES 1048576
|
||||
)"
|
||||
LANE_ARTIFACT_SUFFIX="${OPENCLAW_DOCKER_ALL_LANE_NAME:-default}"
|
||||
LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}"
|
||||
ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}"
|
||||
ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}"
|
||||
DOCKER_RUN_USER_ARGS=()
|
||||
PROBE_ENV_ARGS=(
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS"
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS="$PROBE_ATTEMPT_TIMEOUT_MS"
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES="$PROBE_MAX_BODY_BYTES"
|
||||
)
|
||||
if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING:-}" ]; then
|
||||
PROBE_ENV_ARGS+=(
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING="$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING"
|
||||
)
|
||||
fi
|
||||
if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED:-}" ]; then
|
||||
PROBE_ENV_ARGS+=(
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED="$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED"
|
||||
)
|
||||
fi
|
||||
cleanup_outer() {
|
||||
docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"
|
||||
}
|
||||
@@ -134,7 +112,6 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON=/tmp/openclaw-upgrade-survivor-artifacts/summary.json \
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \
|
||||
"${PROBE_ENV_ARGS[@]}" \
|
||||
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"${DOCKER_RUN_USER_ARGS[@]}" \
|
||||
@@ -162,7 +139,6 @@ docker_e2e_run_with_harness \
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT="$COMMAND_TIMEOUT" \
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \
|
||||
"${PROBE_ENV_ARGS[@]}" \
|
||||
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"${DOCKER_RUN_USER_ARGS[@]}" \
|
||||
@@ -395,20 +371,11 @@ node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \
|
||||
--path /healthz \
|
||||
--expect live \
|
||||
--out /tmp/openclaw-upgrade-survivor-healthz.json
|
||||
|
||||
readyz_probe_args=(
|
||||
--base-url "http://127.0.0.1:$PORT"
|
||||
--path /readyz
|
||||
--expect ready
|
||||
)
|
||||
if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING:-}" ]; then
|
||||
readyz_probe_args+=(--allow-failing "$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING")
|
||||
fi
|
||||
if [ "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED:-}" = "1" ]; then
|
||||
readyz_probe_args+=(--allow-degraded-ready)
|
||||
fi
|
||||
readyz_probe_args+=(--out /tmp/openclaw-upgrade-survivor-readyz.json)
|
||||
node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs "${readyz_probe_args[@]}"
|
||||
node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \
|
||||
--base-url "http://127.0.0.1:$PORT" \
|
||||
--path /readyz \
|
||||
--expect ready \
|
||||
--out /tmp/openclaw-upgrade-survivor-readyz.json
|
||||
|
||||
echo "Checking gateway RPC status..."
|
||||
status_start="$(node -e "process.stdout.write(String(Date.now()))")"
|
||||
|
||||
@@ -11,7 +11,6 @@ export const dependencyGraphGuardMarker = "<!-- openclaw:dependency-graph-guard
|
||||
export const dependencyChangedLabel = "dependencies-changed";
|
||||
export const allowDependenciesCommand = "/allow-dependencies-change";
|
||||
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
|
||||
export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024;
|
||||
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const maxListedFiles = 25;
|
||||
@@ -490,33 +489,12 @@ function githubErrorBodyTooLarge(maxBytes) {
|
||||
return new Error(`GitHub error response body exceeded ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
function githubResponseBodyTooLarge(maxBytes) {
|
||||
return new Error(`GitHub response body exceeded ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
export async function readBoundedGitHubErrorText(
|
||||
response,
|
||||
maxBytes = GITHUB_ERROR_BODY_MAX_BYTES,
|
||||
options = {},
|
||||
) {
|
||||
export async function readBoundedGitHubErrorText(response, maxBytes = GITHUB_ERROR_BODY_MAX_BYTES) {
|
||||
return await readBoundedResponseText(response, "GitHub error", maxBytes, {
|
||||
createTooLargeError: () => githubErrorBodyTooLarge(maxBytes),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readBoundedGitHubJson(
|
||||
response,
|
||||
maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES,
|
||||
options = {},
|
||||
) {
|
||||
const text = await readBoundedResponseText(response, "GitHub", maxBytes, {
|
||||
createTooLargeError: () => githubResponseBodyTooLarge(maxBytes),
|
||||
...options,
|
||||
});
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function timeoutError(path, method, timeoutMs) {
|
||||
return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`);
|
||||
}
|
||||
@@ -535,7 +513,6 @@ function combineAbortSignals(signals) {
|
||||
export function githubApi(token, options = {}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
|
||||
const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES;
|
||||
const baseHeaders = {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
@@ -565,10 +542,7 @@ export function githubApi(token, options = {}) {
|
||||
if (!response.ok) {
|
||||
let errorText;
|
||||
try {
|
||||
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
|
||||
signal: timeoutController.signal,
|
||||
timeoutPromise,
|
||||
});
|
||||
errorText = await readBoundedGitHubErrorText(response);
|
||||
} catch (bodyError) {
|
||||
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
|
||||
}
|
||||
@@ -576,10 +550,7 @@ export function githubApi(token, options = {}) {
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
|
||||
signal: timeoutController.signal,
|
||||
timeoutPromise,
|
||||
});
|
||||
return response.json();
|
||||
})();
|
||||
operationPromise.catch(() => {});
|
||||
try {
|
||||
|
||||
@@ -10,7 +10,6 @@ export const securitySensitiveGuardMarker = "<!-- openclaw:security-sensitive-gu
|
||||
export const securitySensitiveChangedLabel = "security-sensitive-changed";
|
||||
export const allowSecuritySensitiveCommand = "/allow-security-sensitive-change";
|
||||
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
|
||||
export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024;
|
||||
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const securityTeamSlug = process.env.OPENCLAW_SECURITY_TEAM_SLUG ?? "openclaw-secops";
|
||||
@@ -350,33 +349,12 @@ function githubErrorBodyTooLarge(maxBytes) {
|
||||
return new Error(`GitHub error response body exceeded ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
function githubResponseBodyTooLarge(maxBytes) {
|
||||
return new Error(`GitHub response body exceeded ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
export async function readBoundedGitHubErrorText(
|
||||
response,
|
||||
maxBytes = GITHUB_ERROR_BODY_MAX_BYTES,
|
||||
options = {},
|
||||
) {
|
||||
export async function readBoundedGitHubErrorText(response, maxBytes = GITHUB_ERROR_BODY_MAX_BYTES) {
|
||||
return await readBoundedResponseText(response, "GitHub error", maxBytes, {
|
||||
createTooLargeError: () => githubErrorBodyTooLarge(maxBytes),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readBoundedGitHubJson(
|
||||
response,
|
||||
maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES,
|
||||
options = {},
|
||||
) {
|
||||
const text = await readBoundedResponseText(response, "GitHub", maxBytes, {
|
||||
createTooLargeError: () => githubResponseBodyTooLarge(maxBytes),
|
||||
...options,
|
||||
});
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function timeoutError(path, method, timeoutMs) {
|
||||
return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`);
|
||||
}
|
||||
@@ -395,7 +373,6 @@ function combineAbortSignals(signals) {
|
||||
export function githubApi(token, options = {}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
|
||||
const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES;
|
||||
const baseHeaders = {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
@@ -425,10 +402,7 @@ export function githubApi(token, options = {}) {
|
||||
if (!response.ok) {
|
||||
let errorText;
|
||||
try {
|
||||
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
|
||||
signal: timeoutController.signal,
|
||||
timeoutPromise,
|
||||
});
|
||||
errorText = await readBoundedGitHubErrorText(response);
|
||||
} catch (bodyError) {
|
||||
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
|
||||
}
|
||||
@@ -436,10 +410,7 @@ export function githubApi(token, options = {}) {
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
|
||||
signal: timeoutController.signal,
|
||||
timeoutPromise,
|
||||
});
|
||||
return response.json();
|
||||
})();
|
||||
operationPromise.catch(() => {});
|
||||
try {
|
||||
|
||||
@@ -255,7 +255,7 @@ function kitchenSinkRpcLane() {
|
||||
{
|
||||
resources: ["npm"],
|
||||
stateScenario: "empty",
|
||||
timeoutMs: 25 * 60 * 1000,
|
||||
timeoutMs: 15 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
);
|
||||
@@ -521,13 +521,17 @@ export const mainLanes = [
|
||||
lane("commitments-safety", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:commitments-safety", {
|
||||
stateScenario: "empty",
|
||||
}),
|
||||
liveLane("npm-telegram-live", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-telegram-live", {
|
||||
e2eImageKind: "bare",
|
||||
provider: "openai",
|
||||
resources: ["live:telegram", "npm", "service"],
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
}),
|
||||
liveLane(
|
||||
"npm-telegram-live",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-telegram-live",
|
||||
{
|
||||
e2eImageKind: "bare",
|
||||
provider: "openai",
|
||||
resources: ["live:telegram", "npm", "service"],
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
lane("qr", "pnpm test:docker:qr"),
|
||||
];
|
||||
|
||||
|
||||
@@ -36,24 +36,17 @@ export function evaluateToleratedPartialKovaReport(report) {
|
||||
return { ok: false, reason: `blocking count was ${JSON.stringify(gate.blockingCount)}` };
|
||||
}
|
||||
|
||||
const reportBaseline = report?.baseline;
|
||||
const gateBaseline = report?.gate?.baseline;
|
||||
if (
|
||||
(reportBaseline !== null && reportBaseline !== undefined) ||
|
||||
(gateBaseline !== null && gateBaseline !== undefined)
|
||||
) {
|
||||
const baselineRegressionCount = numericCount(
|
||||
reportBaseline?.comparison?.regressionCount ?? gateBaseline?.regressionCount,
|
||||
);
|
||||
if (baselineRegressionCount === undefined) {
|
||||
return { ok: false, reason: "missing baseline regression count" };
|
||||
}
|
||||
if (baselineRegressionCount !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `baseline regression count was ${JSON.stringify(baselineRegressionCount)}`,
|
||||
};
|
||||
}
|
||||
const baselineRegressionCount = numericCount(
|
||||
report?.baseline?.comparison?.regressionCount ?? report?.gate?.baseline?.regressionCount,
|
||||
);
|
||||
if (baselineRegressionCount === undefined) {
|
||||
return { ok: false, reason: "missing baseline regression count" };
|
||||
}
|
||||
if (baselineRegressionCount !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `baseline regression count was ${JSON.stringify(baselineRegressionCount)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const statuses = report?.summary?.statuses;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts";
|
||||
import { readBoundedResponseText } from "./bounded-response.ts";
|
||||
import {
|
||||
collectExtensionPackageJsonCandidates,
|
||||
collectChangedPathsFromGitRange,
|
||||
@@ -87,8 +86,6 @@ type ClawHubPublishablePluginPackageFilters = {
|
||||
};
|
||||
|
||||
const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
const CLAWHUB_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 64 * 1024;
|
||||
const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw";
|
||||
const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml";
|
||||
const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
@@ -117,58 +114,6 @@ function getRegistryBaseUrl(explicit?: string) {
|
||||
);
|
||||
}
|
||||
|
||||
type ClawHubRequestOptions = {
|
||||
fetchImpl?: typeof fetch;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
async function fetchClawHubRequest(
|
||||
url: URL,
|
||||
options: ClawHubRequestOptions = {},
|
||||
): Promise<{
|
||||
clearTimeout: () => void;
|
||||
response: Response;
|
||||
signal: AbortSignal;
|
||||
timeoutPromise: Promise<never>;
|
||||
}> {
|
||||
const timeoutMs = options.requestTimeoutMs ?? CLAWHUB_REQUEST_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
const timeoutError = Object.assign(
|
||||
new Error(`ClawHub request timed out after ${timeoutMs}ms: ${url.href}`),
|
||||
{ code: "ETIMEDOUT" },
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
(options.fetchImpl ?? fetch)(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return {
|
||||
clearTimeout: () => clearTimeout(timeout),
|
||||
response,
|
||||
signal: controller.signal,
|
||||
timeoutPromise,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function formatClawHubPackageArtifactName(
|
||||
plugin: Pick<PublishablePluginPackage, "packageName" | "version">,
|
||||
) {
|
||||
@@ -401,33 +346,30 @@ async function isPluginVersionPublishedOnClawHub(
|
||||
options: {
|
||||
fetchImpl?: typeof fetch;
|
||||
registryBaseUrl?: string;
|
||||
requestTimeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const url = new URL(
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(version)}`,
|
||||
getRegistryBaseUrl(options.registryBaseUrl),
|
||||
);
|
||||
const request = await fetchClawHubRequest(url, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const { response } = request;
|
||||
|
||||
try {
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to query ClawHub for ${packageName}@${version}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
} finally {
|
||||
request.clearTimeout();
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to query ClawHub for ${packageName}@${version}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function doesClawHubPackageExist(
|
||||
@@ -435,33 +377,30 @@ async function doesClawHubPackageExist(
|
||||
options: {
|
||||
fetchImpl?: typeof fetch;
|
||||
registryBaseUrl?: string;
|
||||
requestTimeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const url = new URL(
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}`,
|
||||
getRegistryBaseUrl(options.registryBaseUrl),
|
||||
);
|
||||
const request = await fetchClawHubRequest(url, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const { response } = request;
|
||||
|
||||
try {
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to query ClawHub package ${packageName}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
request.clearTimeout();
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to query ClawHub package ${packageName}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hasClawHubTrustedPublisher(
|
||||
@@ -469,48 +408,36 @@ async function hasClawHubTrustedPublisher(
|
||||
options: {
|
||||
fetchImpl?: typeof fetch;
|
||||
registryBaseUrl?: string;
|
||||
requestTimeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const url = new URL(
|
||||
`/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`,
|
||||
getRegistryBaseUrl(options.registryBaseUrl),
|
||||
);
|
||||
const request = await fetchClawHubRequest(url, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const { response } = request;
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
let trustedPublisherDetail: ClawHubTrustedPublisherDetail;
|
||||
const text = await readBoundedResponseText(
|
||||
response,
|
||||
`ClawHub trusted publisher ${packageName}`,
|
||||
CLAWHUB_RESPONSE_BODY_MAX_BYTES,
|
||||
{
|
||||
signal: request.signal,
|
||||
timeoutPromise: request.timeoutPromise,
|
||||
},
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
try {
|
||||
trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher);
|
||||
} finally {
|
||||
request.clearTimeout();
|
||||
}
|
||||
|
||||
let trustedPublisherDetail: ClawHubTrustedPublisherDetail;
|
||||
try {
|
||||
trustedPublisherDetail = (await response.json()) as ClawHubTrustedPublisherDetail;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher);
|
||||
}
|
||||
|
||||
function isOpenClawPluginTrustedPublisher(value: unknown): boolean {
|
||||
@@ -543,7 +470,6 @@ export async function collectPluginClawHubReleasePlan(params?: {
|
||||
gitRange?: GitRangeSelection;
|
||||
registryBaseUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
requestTimeoutMs?: number;
|
||||
}): Promise<PluginReleasePlan> {
|
||||
const rootDir = params?.rootDir;
|
||||
const selection = params?.selection ?? [];
|
||||
@@ -580,20 +506,17 @@ export async function collectPluginClawHubReleasePlan(params?: {
|
||||
const packageExists = await doesClawHubPackageExist(plugin.packageName, {
|
||||
registryBaseUrl: params?.registryBaseUrl,
|
||||
fetchImpl: params?.fetchImpl,
|
||||
requestTimeoutMs: params?.requestTimeoutMs,
|
||||
});
|
||||
const hasTrustedPublisher = packageExists
|
||||
? await hasClawHubTrustedPublisher(plugin.packageName, {
|
||||
registryBaseUrl: params?.registryBaseUrl,
|
||||
fetchImpl: params?.fetchImpl,
|
||||
requestTimeoutMs: params?.requestTimeoutMs,
|
||||
})
|
||||
: false;
|
||||
const alreadyPublished = packageExists
|
||||
? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, {
|
||||
registryBaseUrl: params?.registryBaseUrl,
|
||||
fetchImpl: params?.fetchImpl,
|
||||
requestTimeoutMs: params?.requestTimeoutMs,
|
||||
})
|
||||
: false;
|
||||
|
||||
|
||||
@@ -42,11 +42,6 @@ export type NpmViewFields = {
|
||||
tarball?: string;
|
||||
};
|
||||
|
||||
type FetchWithRetryResult = {
|
||||
response: Response;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
type WorkflowRunSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -267,17 +262,16 @@ async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
attempts: number,
|
||||
): Promise<FetchWithRetryResult> {
|
||||
): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(CLAWHUB_REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal,
|
||||
signal: AbortSignal.timeout(CLAWHUB_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return { response, signal };
|
||||
return response;
|
||||
}
|
||||
lastError = new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
@@ -294,28 +288,23 @@ async function fetchWithRetry(
|
||||
}
|
||||
|
||||
async function fetchJsonWithRetry(url: string): Promise<unknown> {
|
||||
const { response, signal } = await fetchWithRetry(
|
||||
url,
|
||||
{ headers: { accept: "application/json" } },
|
||||
5,
|
||||
);
|
||||
const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, 5);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned HTTP ${response.status}.`);
|
||||
}
|
||||
return await readBoundedJsonResponse(response, url, undefined, { signal });
|
||||
return await readBoundedJsonResponse(response, url);
|
||||
}
|
||||
|
||||
export async function readBoundedJsonResponse(
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes = CLAWHUB_RESPONSE_BODY_MAX_BYTES,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<unknown> {
|
||||
return parseJson(await readBoundedResponseText(response, label, maxBytes, options), label);
|
||||
return parseJson(await readBoundedResponseText(response, label, maxBytes), label);
|
||||
}
|
||||
|
||||
async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
|
||||
const { response } = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
|
||||
const response = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
|
||||
return response.status;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user