mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
Merge remote-tracking branch 'origin/main' into test/parallels-main-3224005-host
* origin/main: (72 commits) fix: macOS-only Parallels smoke harness regression (#75293) fix(ios): use configured speech language for system voice (#113372) refactor: centralize isolated agent test ownership (#115340) test(ui): stabilize browser route readiness (#115320) fix(cron): clear automation after failed scheduler shutdown (#115316) fix: keep isolated gateway workspaces and skills inside state dir (#114487) docs: document local TTS on macOS, Linux, and Windows (#110230) fix(tasks): clone only the requested task page (#114277) fix(agents): prevent Code Mode resume loss and unbounded preparation (#115314) fix(process): preserve the first process cancellation reason (#115317) fix(nostr): report unavailable ingress when queue open fails (#115313) fix: prevent Slack and Feishu ingress startup leaks (#115295) fix(slack): avoid unsafe emphasis boundaries (#111575) fix: prevent changed checks from expanding into full lint (#115322) fix(codex): stabilize prompts and expose real cache regressions (#115238) refactor(ui): derive page route specs from the route table (#115214) fix(compaction): plan cut points in trigger units (#111886) (#115309) fix: prevent cron and gateway stress-run timeouts (#115241) fix(ci): validate frozen Matrix QA targets (#115298) test(macos): block real process fallthrough (#113337) ... # Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -1405,7 +1405,10 @@ jobs:
|
||||
# Compatibility targets pin a frozen Control UI whose e2e expectations track
|
||||
# that release, not current main.
|
||||
if: needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
# Chromium, Vite, and the Vitest coordinator share this runner even though
|
||||
# files are serial. Four-vCPU runners let host-side CDP polling starve while
|
||||
# the browser kept making progress, producing rotating timeout failures.
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
# The suite runs one file at a time (fileParallelism: false) because each
|
||||
# file owns a Chromium context and a mocked Gateway; ~21 min measured, so
|
||||
# this cannot share checks-ui's 20-minute budget.
|
||||
@@ -1450,6 +1453,9 @@ jobs:
|
||||
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
||||
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
||||
|
||||
- name: Verify Control UI i18n source
|
||||
run: pnpm ui:i18n:verify
|
||||
|
||||
- name: Check Control UI locale parity
|
||||
# Source-only drift stays advisory because the post-merge bot owns
|
||||
# repair. Generated locale changes and full release CI remain strict.
|
||||
@@ -3168,7 +3174,7 @@ jobs:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_ios_build == 'true'
|
||||
runs-on: ${{ (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
||||
steps:
|
||||
@@ -3313,11 +3319,11 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Capture iOS release screenshots
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }}
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }}
|
||||
run: pnpm ios:screenshots
|
||||
|
||||
- name: Upload iOS release screenshot evidence
|
||||
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }}
|
||||
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}
|
||||
|
||||
@@ -404,16 +404,25 @@ jobs:
|
||||
mkdir -p "${output_dir}"
|
||||
|
||||
matrix_help="$(pnpm openclaw qa matrix --help 2>&1)"
|
||||
if ! grep -Fq -- "--shard <index/total>" <<<"${matrix_help}"; then
|
||||
echo "Selected target predates profile-free Matrix catalog sharding; update the target revision." >&2
|
||||
exit 1
|
||||
if grep -Fq -- "--shard <index/total>" <<<"${matrix_help}"; then
|
||||
matrix_selection=(--shard "${{ matrix.shard }}/5")
|
||||
else
|
||||
legacy_profiles=(transport media e2ee-smoke e2ee-deep e2ee-cli)
|
||||
shard_index="${{ matrix.shard }}"
|
||||
if (( shard_index < 1 || shard_index > ${#legacy_profiles[@]} )); then
|
||||
echo "Invalid Matrix shard index for legacy profile fallback: ${shard_index}" >&2
|
||||
exit 1
|
||||
fi
|
||||
legacy_profile="${legacy_profiles[shard_index - 1]}"
|
||||
echo "Selected target predates profile-free Matrix catalog sharding; using legacy profile '${legacy_profile}'."
|
||||
matrix_selection=(--profile "${legacy_profile}")
|
||||
fi
|
||||
|
||||
pnpm openclaw qa matrix \
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode mock-openai \
|
||||
--shard "${{ matrix.shard }}/5" \
|
||||
"${matrix_selection[@]}" \
|
||||
--fast
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
|
||||
+3
-2
@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- **Dev-channel updates:** finish package-to-git switches in a fresh CLI process even when source SHA and version metadata are unchanged, preventing stale hashed chunks from loading after the global package root changes.
|
||||
- **Parallels release smoke:** preserve Windows installer reboot results across Parallels, wait for WSL MSI/default-version readiness, force explicit test-owned gateway stops, and reset Linux package, config, and cache state before install lanes, preventing false prerequisite, safety-gate, and stale-config failures.
|
||||
- **OpenAI Realtime Talk auth:** remove the non-public Codex OAuth realtime fallback and require an OpenAI Platform API key for Talk, Voice Call, and Discord realtime voice, preventing OAuth-only gateways from advertising a browser session that the live service rejects. Fixes #115021.
|
||||
- **Codex native controls:** stop misclassifying valid thinking/fast runtime controls as provider overrides so Codex routes keep their native controls, while provider-native objects and invalid values stay fail-closed. Thanks @VACInc. (#107588)
|
||||
- **State snapshot verification:** run SQLite snapshot verification in a separate process so worker-thread file closes no longer drop the Gateway's POSIX WAL locks, eliminating spurious WAL misses and I/O errors. Thanks @VACInc. (#114016)
|
||||
- **Reply latency with model policies:** reuse one immutable plugin-metadata snapshot per model-selection run instead of repeating plugin discovery, cutting reply delay when a model policy is configured. Thanks @VACInc. (#114117)
|
||||
@@ -1175,7 +1176,7 @@ The [model catalog](https://docs.openclaw.ai/concepts/models) also reports avail
|
||||
- Cloudflare 403 challenges on OpenAI or Codex OAuth requests now produce gateway-block guidance instead of incorrectly telling users their authentication failed. [#94440](https://github.com/openclaw/openclaw/pull/94440) Related [#94432](https://github.com/openclaw/openclaw/issues/94432). Thanks @lzyyzznl, @pbm9z95m6z-hue.
|
||||
- OpenAI authentication errors now point ChatGPT/Codex OAuth users to a model compatible with their existing sign-in instead of recommending an outdated default. [#100579](https://github.com/openclaw/openclaw/pull/100579) Thanks @zhangguiping-xydt.
|
||||
- For Codex-backed OpenAI models, `/status` now identifies ChatGPT login authentication as `oauth (codex-cli)` instead of incorrectly labeling it as an environment API key. [#91240](https://github.com/openclaw/openclaw/pull/91240) Related [#91099](https://github.com/openclaw/openclaw/issues/91099). Thanks @849261680, @ukstem.
|
||||
- OpenAI Realtime voice in Talk, Voice Call, and Discord can now use an existing Codex/OpenAI OAuth login when no explicit API key is configured. [#100671](https://github.com/openclaw/openclaw/pull/100671) Thanks @steipete-oai.
|
||||
- OpenAI Realtime voice in Talk, Voice Call, and Discord was announced with a Codex/OpenAI OAuth fallback in [#100671](https://github.com/openclaw/openclaw/pull/100671). Correction: public Codex OAuth accounts do not have a supported realtime transport, so current builds require an OpenAI Platform API key. Thanks @steipete-oai.
|
||||
|
||||
##### Google and Gemini
|
||||
|
||||
@@ -6879,7 +6880,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
|
||||
- Agents/compaction: keep contributor diagnostics to a bounded top-three selection without sorting the full history. Thanks @shakkernerd.
|
||||
- Sessions/UI: avoid full-array sorting while selecting ACPX leases, Google Meet calendar events, and latest chat sessions. Thanks @shakkernerd.
|
||||
- Plugin SDK: mark direct `deliverOutboundPayloads` and legacy reply-dispatch bridges as deprecated compatibility substrate, enrich `sendDurableMessageBatch` with explicit durable send outcomes, migrate bundled send/turn paths off deprecated APIs, and enforce the split with `check:deprecated-api-usage`.
|
||||
- OpenAI/Talk: let browser realtime Talk, Gateway relay/Voice Call realtime bridges, and OpenAI realtime transcription use `openai-codex` OAuth when no direct API key is configured, make Google Meet `test_speech` honor `mode: "bidi"`, expose Control UI launch options for provider/model/voice/transport/VAD/reasoning, and update the default OpenAI realtime voice model to `gpt-realtime-2`. Thanks @Solvely-Colin.
|
||||
- OpenAI/Talk: add browser realtime Talk controls, Google Meet `test_speech` support for `mode: "bidi"`, and the `gpt-realtime-2` default. Correction: the announced `openai-codex` OAuth fallback does not have a supported public realtime transport; Talk, Gateway relay/Voice Call, and realtime transcription require OpenAI Platform credentials. Thanks @Solvely-Colin.
|
||||
- Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus.
|
||||
- Telegram/streaming: continue over-limit draft previews in a new message instead of stopping when rendered preview text crosses Telegram's message limit. (#74508) Thanks @anagnorisis2peripeteia.
|
||||
- Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip.
|
||||
|
||||
+312
-232
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
Uses the selected locale for Wear OS uppercase labels and compact token-count decimals.
|
||||
|
||||
Fixes secondary Gateway sessions disconnecting during temporary discovery gaps and preserves manual TLS when reopening saved Gateways and Control UI pages.
|
||||
|
||||
Prevents delayed Wear OS requests from a previous phone from blocking or corrupting the newly selected phone session.
|
||||
|
||||
@@ -23,6 +23,7 @@ data class GatewayRequestFrame(
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: JsonElement? = null,
|
||||
val traceparent: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -12,7 +12,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal data class TurnRecap(
|
||||
@@ -216,9 +219,10 @@ internal class TurnRecapResolver(
|
||||
@Composable
|
||||
internal fun ChatTurnRecapRow(recap: TurnRecap) {
|
||||
val duration = formatLocalizedChatDurationCompact(recap.runtimeMs.coerceAtLeast(1_000L))
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val tokens =
|
||||
recap.outputTokens?.let { count ->
|
||||
val format = turnRecapTokenFormat(count)
|
||||
val format = turnRecapTokenFormat(count, locale)
|
||||
if (format.singular) {
|
||||
nativeStringResource("1 token")
|
||||
} else {
|
||||
@@ -243,10 +247,24 @@ internal fun ChatTurnRecapRow(recap: TurnRecap) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun turnRecapTokenFormat(count: Long): TurnRecapTokenFormat = TurnRecapTokenFormat(singular = count == 1L, count = formatCompactTokenCount(count))
|
||||
internal fun turnRecapTokenFormat(
|
||||
count: Long,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): TurnRecapTokenFormat = TurnRecapTokenFormat(singular = count == 1L, count = formatCompactTokenCount(count, locale))
|
||||
|
||||
internal fun formatCompactTokenCount(count: Long): String {
|
||||
fun decimal(value: Double): String = String.format(Locale.US, "%.1f", value).removeSuffix(".0")
|
||||
internal fun formatCompactTokenCount(
|
||||
count: Long,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val decimalFormat =
|
||||
NumberFormat.getNumberInstance(locale).apply {
|
||||
isGroupingUsed = false
|
||||
minimumFractionDigits = 0
|
||||
maximumFractionDigits = 1
|
||||
roundingMode = RoundingMode.HALF_UP
|
||||
}
|
||||
|
||||
fun decimal(value: Double): String = decimalFormat.format(value)
|
||||
|
||||
fun millions(): String {
|
||||
val value = decimal(count / 1_000_000.0)
|
||||
@@ -257,7 +275,7 @@ internal fun formatCompactTokenCount(count: Long): String {
|
||||
count >= 1_000_000L -> millions()
|
||||
count >= 1_000L -> {
|
||||
val thousands = decimal(count / 1_000.0)
|
||||
if (thousands == "1000") millions() else nativeString("\${thousands}k", thousands)
|
||||
if (count >= 999_950L) millions() else nativeString("\${thousands}k", thousands)
|
||||
}
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
class ChatTurnRecapResolverTest {
|
||||
private val session = "agent:main:main"
|
||||
@@ -292,8 +293,10 @@ class ChatTurnRecapResolverTest {
|
||||
fun formatsZeroOneAndCompactTokenCounts() {
|
||||
assertEquals(TurnRecapTokenFormat(singular = false, count = "0"), turnRecapTokenFormat(0L))
|
||||
assertEquals(TurnRecapTokenFormat(singular = true, count = "1"), turnRecapTokenFormat(1L))
|
||||
assertEquals("1.2k", formatCompactTokenCount(1_234L))
|
||||
assertEquals("1M", formatCompactTokenCount(999_999L))
|
||||
assertEquals("1.2k", formatCompactTokenCount(1_234L, Locale.US))
|
||||
assertEquals("1.3k", formatCompactTokenCount(1_250L, Locale.US))
|
||||
assertEquals("1,2k", formatCompactTokenCount(1_234L, Locale.GERMANY))
|
||||
assertEquals("١M", formatCompactTokenCount(999_999L, Locale.forLanguageTag("ar")))
|
||||
}
|
||||
|
||||
private fun transcript(
|
||||
|
||||
@@ -49,7 +49,14 @@ class OpenClawTileService :
|
||||
protoLayoutResourceId = "openclaw_core_mascot",
|
||||
)
|
||||
},
|
||||
labelContent = { text(getString(R.string.talk).uppercase().layoutString) },
|
||||
labelContent = {
|
||||
text(
|
||||
wearUppercase(
|
||||
getString(R.string.talk),
|
||||
resources.configuration.locales[0],
|
||||
).layoutString,
|
||||
)
|
||||
},
|
||||
secondaryLabelContent = { text(getString(R.string.tile_phone_proxy).layoutString) },
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ai.openclaw.wear
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
internal fun localizedWearUppercase(value: String): String = wearUppercase(value, LocalConfiguration.current.locales[0])
|
||||
|
||||
internal fun wearUppercase(
|
||||
value: String,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String = value.uppercase(locale)
|
||||
@@ -1175,11 +1175,13 @@ private fun RealtimeTalkBubble(entry: WearRealtimeTalkEntry) {
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (isUser) {
|
||||
stringResource(R.string.you)
|
||||
} else {
|
||||
stringResource(R.string.agent)
|
||||
}.uppercase(),
|
||||
localizedWearUppercase(
|
||||
if (isUser) {
|
||||
stringResource(R.string.you)
|
||||
} else {
|
||||
stringResource(R.string.agent)
|
||||
},
|
||||
),
|
||||
color = if (isUser) foreground.copy(alpha = 0.72f) else colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1195,7 +1197,7 @@ private fun RealtimeTalkBubble(entry: WearRealtimeTalkEntry) {
|
||||
)
|
||||
if (entry.streaming) {
|
||||
Text(
|
||||
text = stringResource(R.string.live).uppercase(),
|
||||
text = localizedWearUppercase(stringResource(R.string.live)),
|
||||
color = colors.warning,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1385,7 +1387,7 @@ private fun OpenClawHeader(pageLabel: String) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
text = localizedWearUppercase(stringResource(R.string.app_name)),
|
||||
color = colors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1394,7 +1396,7 @@ private fun OpenClawHeader(pageLabel: String) {
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = pageLabel.uppercase(),
|
||||
text = localizedWearUppercase(pageLabel),
|
||||
color = colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -1489,7 +1491,7 @@ private fun ContextPickerRow(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = label.uppercase(),
|
||||
text = localizedWearUppercase(label),
|
||||
color = OpenClawWearTheme.colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1626,11 +1628,13 @@ private fun MessageBubble(message: WearChatMessage) {
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
when (message.chatRole) {
|
||||
WearChatRole.USER -> stringResource(R.string.you)
|
||||
WearChatRole.ASSISTANT -> stringResource(R.string.agent)
|
||||
WearChatRole.SYSTEM -> stringResource(R.string.system)
|
||||
}.uppercase(),
|
||||
localizedWearUppercase(
|
||||
when (message.chatRole) {
|
||||
WearChatRole.USER -> stringResource(R.string.you)
|
||||
WearChatRole.ASSISTANT -> stringResource(R.string.agent)
|
||||
WearChatRole.SYSTEM -> stringResource(R.string.system)
|
||||
},
|
||||
),
|
||||
color = if (isUser) foreground.copy(alpha = 0.72f) else colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1660,7 +1664,7 @@ private fun StreamingBubble(text: String) {
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_working).uppercase(),
|
||||
text = localizedWearUppercase(stringResource(R.string.agent_working)),
|
||||
color = colors.warning,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1702,7 +1706,7 @@ private fun ConnectionPanel(snapshot: WearConversationSnapshot) {
|
||||
)
|
||||
Spacer(modifier = Modifier.size(7.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.connection).uppercase(),
|
||||
text = localizedWearUppercase(stringResource(R.string.connection)),
|
||||
color = colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1733,7 +1737,7 @@ private fun ConnectionPanel(snapshot: WearConversationSnapshot) {
|
||||
private fun PhoneBoundaryPanel() {
|
||||
Panel {
|
||||
Text(
|
||||
text = stringResource(R.string.security_boundary).uppercase(),
|
||||
text = localizedWearUppercase(stringResource(R.string.security_boundary)),
|
||||
color = OpenClawWearTheme.colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1769,7 +1773,7 @@ private fun ThemeModeSelector(
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.appearance).uppercase(),
|
||||
text = localizedWearUppercase(stringResource(R.string.appearance)),
|
||||
color = colors.textMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package ai.openclaw.wear
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
class WearLocaleTextTest {
|
||||
@Test
|
||||
fun `uppercases labels with the active locale`() {
|
||||
assertEquals("İLETİŞİM", wearUppercase("iletişim", Locale.forLanguageTag("tr")))
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
var gateways: [DiscoveredGateway] = []
|
||||
var statusText: String = "Idle"
|
||||
var statusText: String = GatewayDiscoveryStatusText.idle
|
||||
private(set) var debugLog: [DebugLogEntry] = []
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
@@ -125,7 +125,7 @@ final class GatewayDiscoveryModel {
|
||||
self.gatewaysByDomain = [:]
|
||||
self.statesByDomain = [:]
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
self.statusText = GatewayDiscoveryStatusText.stopped
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
|
||||
@@ -71,6 +71,13 @@ private struct ChatCompletionResult {
|
||||
var assistantText: String?
|
||||
}
|
||||
|
||||
private struct TalkSpeechLanguageSelection: Equatable {
|
||||
/// Provider synthesis accepts a two-letter language, while the system
|
||||
/// voice accepts a BCP 47 locale. Keep both forms so fallback stays correct.
|
||||
let provider: String?
|
||||
let systemVoice: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TranscriptStreamingOwner {
|
||||
var task: Task<Void, Never>?
|
||||
@@ -324,7 +331,9 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechTasksByGeneration: [Int: Task<Void, Never>] = [:]
|
||||
private var incrementalSpeechActive = false
|
||||
private var incrementalSpeechUsed = false
|
||||
private var incrementalSpeechLanguage: String?
|
||||
private var incrementalSpeechLanguages = TalkSpeechLanguageSelection(
|
||||
provider: nil,
|
||||
systemVoice: nil)
|
||||
private var incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
private var incrementalSpeechContext: IncrementalSpeechContext?
|
||||
private var incrementalSpeechDirective: TalkDirective?
|
||||
@@ -2905,7 +2914,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
|
||||
let languages = self.resolvedSpeechLanguages(directiveLanguage: directive?.language)
|
||||
if self.runtimeRoute.usesGatewayTalkSpeak {
|
||||
do {
|
||||
try await self.playGatewayTalkSpeak(
|
||||
@@ -2922,7 +2931,7 @@ final class TalkModeManager: NSObject {
|
||||
self.logger.error("gateway TTS failed: \(errorMessage, privacy: .public); falling back to system voice")
|
||||
GatewayDiagnostics.log("talk tts: provider=system (gateway error) msg=\(error.localizedDescription)")
|
||||
do {
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
try await self.playSystemVoice(text: cleaned, language: languages.systemVoice)
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let status = String(
|
||||
@@ -2987,7 +2996,7 @@ final class TalkModeManager: NSObject {
|
||||
directive: directive,
|
||||
modelId: modelId,
|
||||
outputFormat: outputFormat,
|
||||
language: language)
|
||||
language: languages.provider)
|
||||
|
||||
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||
let rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
@@ -3006,7 +3015,7 @@ final class TalkModeManager: NSObject {
|
||||
directive: directive,
|
||||
modelId: modelId,
|
||||
outputFormat: mp3Format,
|
||||
language: language))
|
||||
language: languages.provider))
|
||||
}
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
@@ -3019,7 +3028,7 @@ final class TalkModeManager: NSObject {
|
||||
} else {
|
||||
self.logger.warning("tts unavailable; falling back to system voice (missing key or voiceId)")
|
||||
GatewayDiagnostics.log("talk tts: provider=system (missing key or voiceId)")
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
try await self.playSystemVoice(text: cleaned, language: languages.systemVoice)
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
@@ -3027,7 +3036,7 @@ final class TalkModeManager: NSObject {
|
||||
"tts failed: \(error.localizedDescription, privacy: .public); falling back to system voice")
|
||||
GatewayDiagnostics.log("talk tts: provider=system (error) msg=\(error.localizedDescription)")
|
||||
do {
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
try await self.playSystemVoice(text: cleaned, language: languages.systemVoice)
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let status = String(
|
||||
@@ -3134,6 +3143,21 @@ final class TalkModeManager: NSObject {
|
||||
try await TalkSystemSpeechSynthesizer.shared.speak(text: text, language: language)
|
||||
}
|
||||
|
||||
private func resolvedSpeechLanguages(
|
||||
directiveLanguage: String?,
|
||||
localSelection: String? = UserDefaults.standard.string(forKey: TalkSpeechLocale.storageKey),
|
||||
isSystemVoiceAvailable: (String) -> Bool = TalkSpeechLocale.isSystemVoiceAvailable)
|
||||
-> TalkSpeechLanguageSelection
|
||||
{
|
||||
TalkSpeechLanguageSelection(
|
||||
provider: ElevenLabsTTSClient.validatedLanguage(directiveLanguage),
|
||||
systemVoice: TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: directiveLanguage,
|
||||
localSelection: localSelection,
|
||||
gatewaySelection: self.gatewaySpeechLocaleID,
|
||||
isVoiceAvailable: isSystemVoiceAvailable))
|
||||
}
|
||||
|
||||
private func resolvedElevenLabsAPIKey() -> String? {
|
||||
let configuredKey = self.apiKey?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -3274,7 +3298,7 @@ final class TalkModeManager: NSObject {
|
||||
self.cancelIncrementalPrefetch()
|
||||
self.incrementalSpeechActive = true
|
||||
self.incrementalSpeechUsed = false
|
||||
self.incrementalSpeechLanguage = nil
|
||||
self.incrementalSpeechLanguages = self.resolvedSpeechLanguages(directiveLanguage: nil)
|
||||
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
self.incrementalSpeechContext = nil
|
||||
self.incrementalSpeechDirective = nil
|
||||
@@ -3504,7 +3528,7 @@ final class TalkModeManager: NSObject {
|
||||
let parsed = TalkDirectiveParser.parse(text)
|
||||
self.applyDirective(parsed.directive)
|
||||
if let lang = parsed.directive?.language {
|
||||
self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang)
|
||||
self.incrementalSpeechLanguages = self.resolvedSpeechLanguages(directiveLanguage: lang)
|
||||
}
|
||||
guard await self.updateIncrementalContextIfNeeded(speechGeneration: speechGeneration) else { return false }
|
||||
guard self.incrementalSpeechActive,
|
||||
@@ -3553,7 +3577,7 @@ final class TalkModeManager: NSObject {
|
||||
guard let text = OpenClawChatEventText.assistantText(from: chatEvent) else { continue }
|
||||
let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: false)
|
||||
if let lang = incrementalSpeechBuffer.directive?.language {
|
||||
self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang)
|
||||
self.incrementalSpeechLanguages = self.resolvedSpeechLanguages(directiveLanguage: lang)
|
||||
}
|
||||
guard await self.updateIncrementalContextIfNeeded(speechGeneration: speechGeneration) else { return }
|
||||
guard self.isCurrentTranscriptProcessing(generation),
|
||||
@@ -3569,13 +3593,13 @@ final class TalkModeManager: NSObject {
|
||||
guard self.isCurrentSpeechGeneration(speechGeneration) else { return false }
|
||||
let directive = self.incrementalSpeechBuffer.directive
|
||||
if let existing = incrementalSpeechContext, directive == incrementalSpeechDirective {
|
||||
if existing.language != self.incrementalSpeechLanguage {
|
||||
if existing.language != self.incrementalSpeechLanguages.provider {
|
||||
self.incrementalSpeechContext = IncrementalSpeechContext(
|
||||
apiKey: existing.apiKey,
|
||||
voiceId: existing.voiceId,
|
||||
modelId: existing.modelId,
|
||||
outputFormat: existing.outputFormat,
|
||||
language: self.incrementalSpeechLanguage,
|
||||
language: self.incrementalSpeechLanguages.provider,
|
||||
directive: existing.directive,
|
||||
canUseElevenLabs: existing.canUseElevenLabs)
|
||||
}
|
||||
@@ -3634,7 +3658,7 @@ final class TalkModeManager: NSObject {
|
||||
voiceId: voiceId,
|
||||
modelId: modelId,
|
||||
outputFormat: outputFormat,
|
||||
language: self.incrementalSpeechLanguage,
|
||||
language: self.incrementalSpeechLanguages.provider,
|
||||
directive: directive,
|
||||
canUseElevenLabs: canUseElevenLabs)
|
||||
}
|
||||
@@ -3723,7 +3747,7 @@ final class TalkModeManager: NSObject {
|
||||
guard let resolvedContext = incrementalSpeechContext else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
language: self.incrementalSpeechLanguages.systemVoice)
|
||||
return
|
||||
}
|
||||
context = resolvedContext
|
||||
@@ -3733,7 +3757,7 @@ final class TalkModeManager: NSObject {
|
||||
guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
language: self.incrementalSpeechLanguages.systemVoice)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4915,6 +4939,19 @@ extension TalkModeManager {
|
||||
await self.playAssistant(text: text)
|
||||
}
|
||||
|
||||
func _test_resolvedSpeechLanguages(
|
||||
directiveLanguage: String?,
|
||||
localSelection: String?,
|
||||
isSystemVoiceAvailable: (String) -> Bool = { _ in true })
|
||||
-> (provider: String?, systemVoice: String?)
|
||||
{
|
||||
let selection = self.resolvedSpeechLanguages(
|
||||
directiveLanguage: directiveLanguage,
|
||||
localSelection: localSelection,
|
||||
isSystemVoiceAvailable: isSystemVoiceAvailable)
|
||||
return (selection.provider, selection.systemVoice)
|
||||
}
|
||||
|
||||
func _test_stopSpeaking(storeInterruption: Bool = true) {
|
||||
self.stopSpeaking(storeInterruption: storeInterruption)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
@@ -45,6 +46,23 @@ enum TalkSpeechLocale {
|
||||
supportedLocaleIDs: supportedLocaleIDs)
|
||||
}
|
||||
|
||||
static func resolvedSynthesisLocaleID(
|
||||
directiveLanguage: String?,
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
isVoiceAvailable: (String) -> Bool = TalkSpeechLocale.isSystemVoiceAvailable) -> String?
|
||||
{
|
||||
// A missing higher-priority voice must not mask a later configured voice.
|
||||
// Return nil only after every candidate fails so synthesis uses the device default.
|
||||
[directiveLanguage, localSelection, gatewaySelection]
|
||||
.compactMap { TalkConfigParsing.normalizedExplicitSpeechLocaleID($0) }
|
||||
.first(where: isVoiceAvailable)
|
||||
}
|
||||
|
||||
static func isSystemVoiceAvailable(_ localeID: String) -> Bool {
|
||||
AVSpeechSynthesisVoice(language: localeID) != nil
|
||||
}
|
||||
|
||||
static func makeRecognizer(
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
|
||||
@@ -317,6 +317,36 @@ struct TalkGatewaySpeechClientTests {
|
||||
#expect(routing.route == .gatewayTalkSpeak)
|
||||
}
|
||||
|
||||
@Test func `system voice keeps BCP 47 locale separate from provider language`() {
|
||||
let manager = TalkModeManager(allowSimulatorCapture: true)
|
||||
manager._test_applyLoadedTalkConfig(
|
||||
Self.parseSpeechProvider("elevenlabs", speechLocale: "tr_TR"),
|
||||
providerSelection: .gatewayDefault)
|
||||
|
||||
let configured = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: nil,
|
||||
localSelection: TalkSpeechLocale.automaticID)
|
||||
let directive = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: "de_DE",
|
||||
localSelection: "fr-FR")
|
||||
let providerCompatible = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: "tr",
|
||||
localSelection: TalkSpeechLocale.automaticID)
|
||||
let unavailableDirective = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
isSystemVoiceAvailable: { $0 == "tr-TR" })
|
||||
|
||||
#expect(configured.provider == nil)
|
||||
#expect(configured.systemVoice == "tr-TR")
|
||||
#expect(directive.provider == nil)
|
||||
#expect(directive.systemVoice == "de-DE")
|
||||
#expect(providerCompatible.provider == "tr")
|
||||
#expect(providerCompatible.systemVoice == "tr")
|
||||
#expect(unavailableDirective.provider == nil)
|
||||
#expect(unavailableDirective.systemVoice == "tr-TR")
|
||||
}
|
||||
|
||||
@Test func `explicit realtime config keeps realtime relay`() {
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: [
|
||||
@@ -362,20 +392,23 @@ struct TalkGatewaySpeechClientTests {
|
||||
private static func parseSpeechProvider(
|
||||
_ provider: String,
|
||||
model: String? = "speech-model",
|
||||
interruptOnSpeech: Bool = false) -> TalkModeGatewayConfigState
|
||||
interruptOnSpeech: Bool = false,
|
||||
speechLocale: String? = nil) -> TalkModeGatewayConfigState
|
||||
{
|
||||
let providerConfig: [String: String] = model.map { ["model": $0] } ?? [:]
|
||||
var talkConfig: [String: Any] = [
|
||||
"provider": provider,
|
||||
"providers": [provider: providerConfig],
|
||||
"resolved": [
|
||||
"provider": provider,
|
||||
"config": providerConfig,
|
||||
],
|
||||
"interruptOnSpeech": interruptOnSpeech,
|
||||
]
|
||||
talkConfig["speechLocale"] = speechLocale
|
||||
return TalkModeGatewayConfigParser.parse(
|
||||
config: [
|
||||
"talk": [
|
||||
"provider": provider,
|
||||
"providers": [provider: providerConfig],
|
||||
"resolved": [
|
||||
"provider": provider,
|
||||
"config": providerConfig,
|
||||
],
|
||||
"interruptOnSpeech": interruptOnSpeech,
|
||||
],
|
||||
"talk": talkConfig,
|
||||
],
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
|
||||
@@ -38,4 +38,75 @@ import Testing
|
||||
#expect(deviceLocale == "fr-FR")
|
||||
#expect(english == "en-US")
|
||||
}
|
||||
|
||||
@Test func speechSynthesisPrefersDirectiveLocale() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: " tr_TR ",
|
||||
localSelection: "de-DE",
|
||||
gatewaySelection: "ru-RU",
|
||||
isVoiceAvailable: { _ in true })
|
||||
|
||||
#expect(locale == "tr-TR")
|
||||
}
|
||||
|
||||
@Test func speechSynthesisUsesLocalThenGatewayLocale() {
|
||||
let local = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: nil,
|
||||
localSelection: "de_DE",
|
||||
gatewaySelection: "ru-RU",
|
||||
isVoiceAvailable: { _ in true })
|
||||
let gateway = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: nil,
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
gatewaySelection: "ru_RU",
|
||||
isVoiceAvailable: { _ in true })
|
||||
|
||||
#expect(local == "de-DE")
|
||||
#expect(gateway == "ru-RU")
|
||||
}
|
||||
|
||||
@Test func automaticSpeechSynthesisUsesSystemDefaultWithoutGatewayLocale() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: nil,
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
gatewaySelection: nil,
|
||||
isVoiceAvailable: { _ in true })
|
||||
|
||||
#expect(locale == nil)
|
||||
}
|
||||
|
||||
@Test func unavailableDirectiveFallsThroughToAvailableLocalVoice() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: "fr-FR",
|
||||
gatewaySelection: "tr-TR",
|
||||
isVoiceAvailable: { $0 == "fr-FR" })
|
||||
|
||||
#expect(locale == "fr-FR")
|
||||
}
|
||||
|
||||
@Test func unavailableDirectiveAndLocalVoicesFallThroughToGatewayVoice() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: "yy-YY",
|
||||
gatewaySelection: "tr_TR",
|
||||
isVoiceAvailable: { $0 == "tr-TR" })
|
||||
|
||||
#expect(locale == "tr-TR")
|
||||
}
|
||||
|
||||
@Test func unavailableCandidatesUseSystemDefaultOnlyAfterEveryCandidateFails() {
|
||||
var checkedLocaleIDs: [String] = []
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: "fr-FR",
|
||||
gatewaySelection: "tr-TR",
|
||||
isVoiceAvailable: {
|
||||
checkedLocaleIDs.append($0)
|
||||
return false
|
||||
})
|
||||
|
||||
#expect(locale == nil)
|
||||
#expect(checkedLocaleIDs == ["zz-ZZ", "fr-FR", "tr-TR"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,8 +278,7 @@ final class AppState {
|
||||
/// via its own Mac app instead of a second, separately toggled bridge here.
|
||||
func applyPeekabooBridgeHostState() {
|
||||
self.ifNotPreview {
|
||||
let computerControlEnabled = UserDefaults.standard
|
||||
.object(forKey: computerControlEnabledKey) as? Bool ?? false
|
||||
let computerControlEnabled = isComputerControlEnabled()
|
||||
let shouldRun = self.peekabooBridgeEnabled && computerControlEnabled
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(shouldRun) }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
@@ -315,6 +316,101 @@ final class ComputerActionExecutionQueue {
|
||||
}
|
||||
}
|
||||
|
||||
struct ComputerControlPermissionSnapshot: Equatable, Sendable {
|
||||
enum Access: Equatable, Sendable {
|
||||
case granted
|
||||
case missing
|
||||
}
|
||||
|
||||
enum Bucket: Equatable, Sendable {
|
||||
case accessibility
|
||||
case postEvent
|
||||
case screenCapture
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .accessibility: "Accessibility"
|
||||
case .postEvent: "Event Posting"
|
||||
case .screenCapture: "Screen Recording"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Diagnostic: Equatable, Sendable {
|
||||
case granted
|
||||
case missing([Bucket])
|
||||
case accessibilityGrantMayBeStale
|
||||
|
||||
var statusText: String {
|
||||
switch self {
|
||||
case .granted: "Granted"
|
||||
case .missing: "Missing permission"
|
||||
case .accessibilityGrantMayBeStale: "Accessibility grant may be stale"
|
||||
}
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
switch self {
|
||||
case .granted:
|
||||
"Accessibility, Event Posting, and Screen Recording are granted."
|
||||
case let .missing(buckets):
|
||||
"Missing: \(buckets.map(\.displayName).joined(separator: ", ")). "
|
||||
+ "Grant access in System Settings → Privacy & Security, then reopen OpenClaw."
|
||||
case .accessibilityGrantMayBeStale:
|
||||
Self.staleAccessibilityRemediation
|
||||
}
|
||||
}
|
||||
|
||||
static let staleAccessibilityRemediation = """
|
||||
OpenClaw may already appear enabled under System Settings → Privacy & Security → Accessibility. \
|
||||
If so, the grant is pinned to an older build: select OpenClaw, remove it with −, then re-add \
|
||||
/Applications/OpenClaw.app.
|
||||
"""
|
||||
}
|
||||
|
||||
enum InputAccess: Equatable, Sendable {
|
||||
case granted
|
||||
case accessibilityMissing
|
||||
case accessibilityGrantMayBeStale
|
||||
case postEventMissing
|
||||
}
|
||||
|
||||
let accessibility: Access
|
||||
let postEvent: Access
|
||||
let screenCapture: Access
|
||||
|
||||
static func probe() -> Self {
|
||||
Self(
|
||||
accessibility: AXIsProcessTrusted() ? .granted : .missing,
|
||||
postEvent: CGPreflightPostEventAccess() ? .granted : .missing,
|
||||
screenCapture: CGPreflightScreenCaptureAccess() ? .granted : .missing)
|
||||
}
|
||||
|
||||
var diagnostic: Diagnostic {
|
||||
// Capture granted + AX denied is the observed stale cdhash signature after an app rebuild.
|
||||
if self.accessibility == .missing, self.screenCapture == .granted {
|
||||
return .accessibilityGrantMayBeStale
|
||||
}
|
||||
let missing = [
|
||||
(Bucket.accessibility, self.accessibility),
|
||||
(.postEvent, self.postEvent),
|
||||
(.screenCapture, self.screenCapture),
|
||||
].compactMap { bucket, access in
|
||||
access == .missing ? bucket : nil
|
||||
}
|
||||
return missing.isEmpty ? .granted : .missing(missing)
|
||||
}
|
||||
|
||||
var inputAccess: InputAccess {
|
||||
if self.accessibility == .missing {
|
||||
return self.screenCapture == .granted
|
||||
? .accessibilityGrantMayBeStale
|
||||
: .accessibilityMissing
|
||||
}
|
||||
return self.postEvent == .granted ? .granted : .postEventMissing
|
||||
}
|
||||
}
|
||||
|
||||
/// Fulfills `computer.act` on this Mac by driving the embedded Peekaboo
|
||||
/// automation engine in-process. Peekaboo covers single/right/double click,
|
||||
/// move, drag, scroll, and key/hold. A narrow CoreGraphics path handles
|
||||
@@ -339,6 +435,8 @@ final class ComputerActionService {
|
||||
|
||||
enum ComputerActionError: LocalizedError {
|
||||
case accessibilityNotTrusted
|
||||
case accessibilityGrantMayBeStale
|
||||
case postEventAccessDenied
|
||||
case noDisplays
|
||||
case invalidScreenIndex(Int)
|
||||
case missingDisplayFrameId
|
||||
@@ -359,6 +457,10 @@ final class ComputerActionService {
|
||||
switch self {
|
||||
case .accessibilityNotTrusted:
|
||||
"Accessibility permission is required for computer control"
|
||||
case .accessibilityGrantMayBeStale:
|
||||
ComputerControlPermissionSnapshot.Diagnostic.staleAccessibilityRemediation
|
||||
case .postEventAccessDenied:
|
||||
"Event Posting permission is required for computer control"
|
||||
case .noDisplays:
|
||||
"No displays available for computer control"
|
||||
case let .invalidScreenIndex(idx):
|
||||
@@ -394,7 +496,6 @@ final class ComputerActionService {
|
||||
}
|
||||
|
||||
private let automation: UIAutomationService
|
||||
private let permissions: PermissionsService
|
||||
private let mouseButtonEventPoster: MouseButtonEventPoster
|
||||
private let mouseEventFactory: MouseEventFactory
|
||||
private let mouseEventPoster: MouseEventPoster
|
||||
@@ -436,7 +537,6 @@ final class ComputerActionService {
|
||||
|
||||
init() {
|
||||
self.automation = UIAutomationService()
|
||||
self.permissions = PermissionsService()
|
||||
self.mouseButtonEventPoster = Self.postMouseButtonEvent
|
||||
self.mouseEventFactory = Self.makeMouseEvent
|
||||
self.mouseEventPoster = Self.postMouseEvent
|
||||
@@ -446,7 +546,6 @@ final class ComputerActionService {
|
||||
#if DEBUG
|
||||
init(mouseButtonEventPoster: @escaping MouseButtonEventPoster) {
|
||||
self.automation = UIAutomationService()
|
||||
self.permissions = PermissionsService()
|
||||
self.mouseButtonEventPoster = mouseButtonEventPoster
|
||||
self.mouseEventFactory = Self.makeMouseEvent
|
||||
self.mouseEventPoster = Self.postMouseEvent
|
||||
@@ -458,7 +557,6 @@ final class ComputerActionService {
|
||||
mouseEventPoster: @escaping MouseEventPoster)
|
||||
{
|
||||
self.automation = UIAutomationService()
|
||||
self.permissions = PermissionsService()
|
||||
self.mouseButtonEventPoster = Self.postMouseButtonEvent
|
||||
self.mouseEventFactory = mouseEventFactory
|
||||
self.mouseEventPoster = mouseEventPoster
|
||||
@@ -467,7 +565,6 @@ final class ComputerActionService {
|
||||
|
||||
init(textGraphemePoster: @escaping TextGraphemePoster) {
|
||||
self.automation = UIAutomationService()
|
||||
self.permissions = PermissionsService()
|
||||
self.mouseButtonEventPoster = Self.postMouseButtonEvent
|
||||
self.mouseEventFactory = Self.makeMouseEvent
|
||||
self.mouseEventPoster = Self.postMouseEvent
|
||||
@@ -495,9 +592,7 @@ final class ComputerActionService {
|
||||
lifecycleGeneration: UInt64) async throws -> OpenClawComputerActResult
|
||||
{
|
||||
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
|
||||
guard self.permissions.checkAccessibilityPermission() else {
|
||||
throw ComputerActionError.accessibilityNotTrusted
|
||||
}
|
||||
try Self.validateInputPermissions(ComputerControlPermissionSnapshot.probe())
|
||||
let display = try await resolveDisplay(params: params)
|
||||
try executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
|
||||
try await self.dispatch(
|
||||
@@ -509,6 +604,19 @@ final class ComputerActionService {
|
||||
return OpenClawComputerActResult(ok: true, cursorX: cursor.x, cursorY: cursor.y)
|
||||
}
|
||||
|
||||
static func validateInputPermissions(_ permissions: ComputerControlPermissionSnapshot) throws {
|
||||
switch permissions.inputAccess {
|
||||
case .granted:
|
||||
return
|
||||
case .accessibilityMissing:
|
||||
throw ComputerActionError.accessibilityNotTrusted
|
||||
case .accessibilityGrantMayBeStale:
|
||||
throw ComputerActionError.accessibilityGrantMayBeStale
|
||||
case .postEventMissing:
|
||||
throw ComputerActionError.postEventAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dispatch
|
||||
|
||||
private func dispatch(
|
||||
|
||||
@@ -40,6 +40,12 @@ let canvasEnabledKey = "openclaw.canvasEnabled"
|
||||
let quickChatEnabledKey = "openclaw.quickChatEnabled"
|
||||
let cameraEnabledKey = "openclaw.cameraEnabled"
|
||||
let computerControlEnabledKey = "openclaw.computerControlEnabled"
|
||||
|
||||
func isComputerControlEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
// object(forKey:) preserves an explicit false; bool(forKey:) would conflate it with an unset default.
|
||||
defaults.object(forKey: computerControlEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
let activeComputerPresenceEnabledKey = "openclaw.activeComputerPresenceEnabled"
|
||||
let locationModeKey = "openclaw.locationMode"
|
||||
let locationPreciseKey = "openclaw.locationPreciseEnabled"
|
||||
|
||||
@@ -267,6 +267,12 @@ extension GatewayLaunchAgentManager {
|
||||
payload: Data(payload.utf8),
|
||||
message: nil)
|
||||
}
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return CommandResult(
|
||||
success: false,
|
||||
payload: nil,
|
||||
message: "Gateway daemon commands require explicit interception during tests")
|
||||
}
|
||||
#endif
|
||||
let command = CommandResolver.openclawCommand(
|
||||
subcommand: "gateway",
|
||||
|
||||
@@ -16,7 +16,7 @@ struct GeneralSettings: View {
|
||||
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(computerControlEnabledKey) private var computerControlEnabled: Bool = false
|
||||
@AppStorage(computerControlEnabledKey) private var computerControlEnabled: Bool = true
|
||||
let page: Page
|
||||
let isActive: Bool
|
||||
private let healthStore = HealthStore.shared
|
||||
@@ -26,6 +26,7 @@ struct GeneralSettings: View {
|
||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
@State private var computerControlPermissions = ComputerControlPermissionSnapshot.probe()
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool {
|
||||
ProcessInfo.processInfo.isNixMode
|
||||
@@ -67,6 +68,12 @@ struct GeneralSettings: View {
|
||||
// Turning Computer Control on/off must start or stop the gated PeekabooBridge host.
|
||||
self.state.applyPeekabooBridgeHostState()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
self.refreshComputerControlPermissions()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openclawPermissionsChanged)) { _ in
|
||||
self.refreshComputerControlPermissions()
|
||||
}
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
@@ -128,11 +135,24 @@ struct GeneralSettings: View {
|
||||
SettingsCardToggleRow(
|
||||
title: "Allow Computer Control",
|
||||
subtitle: """
|
||||
Let an authorized agent move the pointer, click, and type on this Mac. \
|
||||
Also requires Accessibility, Screen Recording, and gateway command authorization. High risk.
|
||||
Starts enabled. After this Mac is paired and macOS access is granted, the paired Gateway can \
|
||||
move the pointer, click, and type without per-action confirmation. High risk.
|
||||
""",
|
||||
binding: self.$computerControlEnabled)
|
||||
|
||||
SettingsCardRow(
|
||||
title: "Computer Control access",
|
||||
subtitle: .verbatim(self.computerControlPermissions.diagnostic.detailText))
|
||||
{
|
||||
Label {
|
||||
Text(verbatim: self.computerControlPermissions.diagnostic.statusText)
|
||||
} icon: {
|
||||
Image(systemName: self.computerControlPermissionIcon)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(self.computerControlPermissionColor)
|
||||
}
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: """
|
||||
@@ -283,6 +303,7 @@ struct GeneralSettings: View {
|
||||
private func updateActiveWork(active: Bool) {
|
||||
guard !self.isPreview else { return }
|
||||
if active {
|
||||
self.refreshComputerControlPermissions()
|
||||
self.refreshGatewayStatus()
|
||||
if self.page == .connection {
|
||||
self.gatewayDiscovery.start()
|
||||
@@ -292,6 +313,26 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshComputerControlPermissions() {
|
||||
guard self.page == .general, self.isActive, !self.isPreview else { return }
|
||||
self.computerControlPermissions = .probe()
|
||||
}
|
||||
|
||||
private var computerControlPermissionIcon: String {
|
||||
switch self.computerControlPermissions.diagnostic {
|
||||
case .granted: "checkmark.circle.fill"
|
||||
case .missing: "exclamationmark.circle.fill"
|
||||
case .accessibilityGrantMayBeStale: "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var computerControlPermissionColor: Color {
|
||||
switch self.computerControlPermissions.diagnostic {
|
||||
case .granted: .green
|
||||
case .missing, .accessibilityGrantMayBeStale: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusPanel: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
|
||||
@@ -163,7 +163,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
self.refreshContinuation = refreshEvents.continuation
|
||||
self.lastObservedPaused = initialPaused ?? UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.lastObservedComputerControlEnabled = initialComputerControlEnabled ??
|
||||
(UserDefaults.standard.object(forKey: computerControlEnabledKey) as? Bool ?? false)
|
||||
isComputerControlEnabled()
|
||||
super.init()
|
||||
|
||||
guard observeNotifications else { return }
|
||||
@@ -269,8 +269,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
func refresh() {
|
||||
self.refresh(
|
||||
isPaused: UserDefaults.standard.bool(forKey: pauseDefaultsKey),
|
||||
computerControlEnabled: UserDefaults.standard.object(
|
||||
forKey: computerControlEnabledKey) as? Bool ?? false)
|
||||
computerControlEnabled: isComputerControlEnabled())
|
||||
}
|
||||
|
||||
func currentCanvasPluginSurfaceRoute() async -> GatewayCanvasHostRoute? {
|
||||
@@ -799,8 +798,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
claudeSessionCatalogEnabled: Bool) -> [String]
|
||||
{
|
||||
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
let computerControlEnabled =
|
||||
UserDefaults.standard.object(forKey: computerControlEnabledKey) as? Bool ?? false
|
||||
let computerControlEnabled = isComputerControlEnabled()
|
||||
return Self.resolvedCaps(
|
||||
browserControlEnabled: browserControlEnabled,
|
||||
cameraEnabled: cameraEnabled,
|
||||
|
||||
@@ -554,6 +554,18 @@ extension MacNodeRuntime {
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "ACCESSIBILITY_REQUIRED: grant Accessibility permission to OpenClaw")
|
||||
case .accessibilityGrantMayBeStale:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "ACCESSIBILITY_REQUIRED: "
|
||||
+ ComputerControlPermissionSnapshot.Diagnostic.staleAccessibilityRemediation)
|
||||
case .postEventAccessDenied:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "POST_EVENT_REQUIRED: macOS denied Event Posting access; re-grant OpenClaw "
|
||||
+ "under System Settings → Privacy & Security → Accessibility")
|
||||
case .noDisplays, .invalidScreenIndex, .missingDisplayFrameId, .displayFrameChanged,
|
||||
.missingCoordinate, .coordinateOutOfBounds, .invalidReferenceWidth, .missingKeys,
|
||||
.emptyText, .invalidScroll, .invalidModifier, .buttonAlreadyHeld, .buttonNotHeld:
|
||||
@@ -939,7 +951,7 @@ extension MacNodeRuntime {
|
||||
}
|
||||
|
||||
nonisolated static func computerControlEnabledDefault() -> Bool {
|
||||
UserDefaults.standard.object(forKey: computerControlEnabledKey) as? Bool ?? false
|
||||
isComputerControlEnabled()
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> OpenClawLocationMode {
|
||||
|
||||
@@ -184,11 +184,14 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
private var remoteChoiceSubtitle: String {
|
||||
let count = gatewayDiscovery.gateways.count
|
||||
Self.remoteChoiceSubtitle(discoveredGatewayCount: gatewayDiscovery.gateways.count)
|
||||
}
|
||||
|
||||
static func remoteChoiceSubtitle(discoveredGatewayCount count: Int) -> String {
|
||||
if count > 0 {
|
||||
return count == 1
|
||||
? "1 gateway found on your network — click to choose it."
|
||||
: "\(count) gateways found on your network — click to choose one."
|
||||
? String(localized: "1 gateway found on your network — click to choose it.")
|
||||
: String(localized: "\(count) gateways found on your network — click to choose one.")
|
||||
}
|
||||
return "For advanced setups — use a gateway that runs elsewhere."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
extension ProcessInfo {
|
||||
/// SwiftPM loads test bundles into these helpers, so bundle inspection alone
|
||||
/// cannot identify every test process. Keep current and legacy runner names.
|
||||
private static let swiftPMTestHelperNames: Set<String> = [
|
||||
"swiftpm-testing-helper",
|
||||
"swiftpm-xctest-helper",
|
||||
]
|
||||
|
||||
var isPreview: Bool {
|
||||
guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false }
|
||||
return String(cString: raw) == "1"
|
||||
@@ -34,15 +41,29 @@ extension ProcessInfo {
|
||||
isAppBundle: isAppBundle)
|
||||
}
|
||||
|
||||
var isRunningTests: Bool {
|
||||
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
|
||||
// guaranteed to be the `.xctest` bundle, so check all loaded bundles.
|
||||
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
||||
if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
|
||||
static func resolveIsRunningTests(
|
||||
environment: [String: String],
|
||||
processName: String,
|
||||
arguments: [String],
|
||||
bundleURLs: [URL]) -> Bool
|
||||
{
|
||||
if bundleURLs.contains(where: { $0.pathExtension == "xctest" }) { return true }
|
||||
if self.swiftPMTestHelperNames.contains(processName) { return true }
|
||||
if let executable = arguments.first.map({ URL(fileURLWithPath: $0).lastPathComponent }),
|
||||
self.swiftPMTestHelperNames.contains(executable)
|
||||
{
|
||||
return true
|
||||
}
|
||||
return environment["XCTestConfigurationFilePath"] != nil
|
||||
|| environment["XCTestBundlePath"] != nil
|
||||
|| environment["XCTestSessionIdentifier"] != nil
|
||||
}
|
||||
|
||||
// Backwards-compatible fallbacks for runners that still set XCTest env vars.
|
||||
return self.environment["XCTestConfigurationFilePath"] != nil
|
||||
|| self.environment["XCTestBundlePath"] != nil
|
||||
|| self.environment["XCTestSessionIdentifier"] != nil
|
||||
var isRunningTests: Bool {
|
||||
Self.resolveIsRunningTests(
|
||||
environment: self.environment,
|
||||
processName: self.processName,
|
||||
arguments: self.arguments,
|
||||
bundleURLs: Bundle.allBundles.map(\.bundleURL) + [Bundle.main.bundleURL])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
public var gateways: [DiscoveredGateway] = []
|
||||
public var statusText: String = "Idle"
|
||||
public var statusText: String = GatewayDiscoveryStatusText.idle
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||
@@ -170,7 +170,7 @@ public final class GatewayDiscoveryModel {
|
||||
self.tailscaleServeFallbackTask = nil
|
||||
self.tailscaleServeFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
self.statusText = GatewayDiscoveryStatusText.stopped
|
||||
}
|
||||
|
||||
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
|
||||
|
||||
@@ -127,6 +127,60 @@ struct ComputerActionServiceTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `capture grant distinguishes a stale accessibility grant`() {
|
||||
let permissions = ComputerControlPermissionSnapshot(
|
||||
accessibility: .missing,
|
||||
postEvent: .granted,
|
||||
screenCapture: .granted)
|
||||
|
||||
#expect(permissions.diagnostic == .accessibilityGrantMayBeStale)
|
||||
#expect(permissions.diagnostic.detailText == """
|
||||
OpenClaw may already appear enabled under System Settings → Privacy & Security → Accessibility. \
|
||||
If so, the grant is pinned to an older build: select OpenClaw, remove it with −, then re-add \
|
||||
/Applications/OpenClaw.app.
|
||||
""")
|
||||
#expect(permissions.inputAccess == .accessibilityGrantMayBeStale)
|
||||
let error = self.validationError {
|
||||
try ComputerActionService.validateInputPermissions(permissions)
|
||||
}
|
||||
if case .some(.accessibilityGrantMayBeStale) = error {} else {
|
||||
Issue.record("expected stale Accessibility error, got \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `missing accessibility and capture is a plain missing permission`() {
|
||||
let permissions = ComputerControlPermissionSnapshot(
|
||||
accessibility: .missing,
|
||||
postEvent: .granted,
|
||||
screenCapture: .missing)
|
||||
|
||||
#expect(permissions.diagnostic == .missing([.accessibility, .screenCapture]))
|
||||
#expect(permissions.diagnostic.detailText == """
|
||||
Missing: Accessibility, Screen Recording. \
|
||||
Grant access in System Settings → Privacy & Security, then reopen OpenClaw.
|
||||
""")
|
||||
#expect(permissions.inputAccess == .accessibilityMissing)
|
||||
let error = self.validationError {
|
||||
try ComputerActionService.validateInputPermissions(permissions)
|
||||
}
|
||||
if case .some(.accessibilityNotTrusted) = error {} else {
|
||||
Issue.record("expected missing Accessibility error, got \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `post event denial remains distinct from accessibility denial`() {
|
||||
let permissions = ComputerControlPermissionSnapshot(
|
||||
accessibility: .granted,
|
||||
postEvent: .missing,
|
||||
screenCapture: .granted)
|
||||
|
||||
#expect(permissions.diagnostic == .missing([.postEvent]))
|
||||
#expect(permissions.inputAccess == .postEventMissing)
|
||||
#expect(throws: ComputerActionService.ComputerActionError.self) {
|
||||
try ComputerActionService.validateInputPermissions(permissions)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `coordinate input requires the current display frame identity`() throws {
|
||||
let currentFrameId = "display-frame:v1:current"
|
||||
let missing = OpenClawComputerActParams(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ComputerControlSettingsTests {
|
||||
@Test func `computer control defaults on while preserving explicit choices`() throws {
|
||||
let suiteName = "ComputerControlSettingsTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
#expect(isComputerControlEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(false, forKey: computerControlEnabledKey)
|
||||
#expect(!isComputerControlEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(true, forKey: computerControlEnabledKey)
|
||||
#expect(isComputerControlEnabled(defaults: defaults))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayLaunchAgentManagerTests {
|
||||
@Test func `reads Gateway service ownership command directly from launchd`() throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
@@ -75,6 +76,22 @@ struct GatewayLaunchAgentManagerTests {
|
||||
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `unintercepted daemon commands fail closed during tests`() async {
|
||||
let marker = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openclaw-no-disable-marker-\(UUID().uuidString)")
|
||||
defer {
|
||||
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil)
|
||||
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false)
|
||||
}
|
||||
|
||||
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(marker)
|
||||
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false)
|
||||
|
||||
let error = await GatewayLaunchAgentManager.kickstart()
|
||||
|
||||
#expect(error == "Gateway daemon commands require explicit interception during tests")
|
||||
}
|
||||
|
||||
@Test func `launch agent plist snapshot parses args and env`() throws {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
|
||||
|
||||
@@ -337,55 +337,14 @@ struct LowCoverageHelperTests {
|
||||
#expect(siblingPlan.reap.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `port guardian classifies a real orphaned tunnel process for reaping`() async throws {
|
||||
// Real ssh that hangs safely: ProxyCommand replaces the TCP transport, so no
|
||||
// network traffic happens and the -L port is never bound (forwards only bind
|
||||
// after auth). Spawned through sh so the parent exits and ssh reparents to
|
||||
// launchd — the exact orphan shape the reaper must detect.
|
||||
let port = 45871
|
||||
// Detach the child's stdio: the pipe must reach EOF when sh exits, not when ssh dies.
|
||||
let script = "/usr/bin/ssh -o BatchMode=yes -o ProxyCommand='sleep 60' " +
|
||||
"-N -L \(port):127.0.0.1:\(port) orphan-reap-test-host >/dev/null 2>&1 & echo $!"
|
||||
let spawn = Process()
|
||||
spawn.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
spawn.arguments = ["-c", script]
|
||||
let out = Pipe()
|
||||
spawn.standardOutput = out
|
||||
try spawn.run()
|
||||
spawn.waitUntilExit()
|
||||
let pidText = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let pid = try #require(Int32(pidText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
||||
defer { kill(pid, SIGKILL) }
|
||||
@Test func `port guardian reads current process metadata without spawning children`() throws {
|
||||
let info = try #require(PortGuardian._testTunnelProcessInfo(pid: getpid()))
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
// Reparenting to launchd is immediate once sh exits, but give ps/sysctl a beat.
|
||||
var info: PortGuardian.TunnelProcessInfo?
|
||||
for _ in 0..<40 {
|
||||
info = PortGuardian._testTunnelProcessInfo(pid: pid)
|
||||
if info?.parentPid == 1, info?.fullCommand?.isEmpty == false { break }
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
let orphan = try #require(info)
|
||||
#expect(orphan.parentPid == 1)
|
||||
// Kernel start time must be sane so the pid-reuse gate can rely on it.
|
||||
#expect(abs(orphan.startedAt - Date().timeIntervalSince1970) < 60)
|
||||
let recordedAt = Date().timeIntervalSince1970
|
||||
let record = PortGuardian.Record(
|
||||
port: port, pid: pid, command: "/usr/bin/ssh", mode: "remote", timestamp: recordedAt)
|
||||
#expect(PortGuardian.classifyTunnelRecord(record, process: orphan) == .reap)
|
||||
|
||||
// Same process under a different recorded port must never be reap-eligible.
|
||||
let mismatched = PortGuardian.Record(
|
||||
port: port + 1, pid: pid, command: "/usr/bin/ssh", mode: "remote", timestamp: recordedAt)
|
||||
#expect(PortGuardian.classifyTunnelRecord(mismatched, process: orphan) == .drop)
|
||||
|
||||
// A record predating this process (reused pid) must drop, not reap.
|
||||
let predates = PortGuardian.Record(
|
||||
port: port,
|
||||
pid: pid,
|
||||
command: "/usr/bin/ssh",
|
||||
mode: "remote",
|
||||
timestamp: orphan.startedAt - 3600)
|
||||
#expect(PortGuardian.classifyTunnelRecord(predates, process: orphan) == .drop)
|
||||
#expect(info.parentPid > 0)
|
||||
#expect(info.startedAt > now - ProcessInfo.processInfo.systemUptime - 1)
|
||||
#expect(info.startedAt <= now + 1)
|
||||
#expect(info.fullCommand?.isEmpty == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canvas scheme handler resolves files and errors`() throws {
|
||||
|
||||
@@ -25,6 +25,41 @@ struct NixModeStableSuiteTests {
|
||||
#expect(resolved)
|
||||
}
|
||||
|
||||
@Test func `detects SwiftPM and XCTest runners`() {
|
||||
#expect(ProcessInfo.resolveIsRunningTests(
|
||||
environment: [:],
|
||||
processName: "swiftpm-testing-helper",
|
||||
arguments: [],
|
||||
bundleURLs: []))
|
||||
#expect(ProcessInfo.resolveIsRunningTests(
|
||||
environment: [:],
|
||||
processName: "swiftpm-xctest-helper",
|
||||
arguments: [],
|
||||
bundleURLs: []))
|
||||
for helper in ["swiftpm-testing-helper", "swiftpm-xctest-helper"] {
|
||||
#expect(ProcessInfo.resolveIsRunningTests(
|
||||
environment: [:],
|
||||
processName: "OpenClawTests",
|
||||
arguments: ["/Library/Developer/Toolchains/usr/libexec/swift/pm/\(helper)"],
|
||||
bundleURLs: []))
|
||||
}
|
||||
#expect(ProcessInfo.resolveIsRunningTests(
|
||||
environment: ["XCTestSessionIdentifier": "session"],
|
||||
processName: "OpenClawTests",
|
||||
arguments: [],
|
||||
bundleURLs: []))
|
||||
#expect(ProcessInfo.resolveIsRunningTests(
|
||||
environment: [:],
|
||||
processName: "OpenClawTests",
|
||||
arguments: [],
|
||||
bundleURLs: [URL(fileURLWithPath: "/tmp/OpenClawTests.xctest")]))
|
||||
#expect(!ProcessInfo.resolveIsRunningTests(
|
||||
environment: [:],
|
||||
processName: "OpenClaw",
|
||||
arguments: [],
|
||||
bundleURLs: []))
|
||||
}
|
||||
|
||||
@Test func `ignores stable suite outside app bundles`() throws {
|
||||
let suite = try #require(UserDefaults(suiteName: launchdLabel))
|
||||
let key = "openclaw.nixMode"
|
||||
|
||||
@@ -32,6 +32,15 @@ private func makeOnboardingResumeDefaults() throws -> (UserDefaults, String) {
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingViewSmokeTests {
|
||||
@Test func `discovered gateway summary uses localized runtime strings`() {
|
||||
#expect(
|
||||
OnboardingView.remoteChoiceSubtitle(discoveredGatewayCount: 1) ==
|
||||
"1 gateway found on your network — click to choose it.")
|
||||
#expect(
|
||||
OnboardingView.remoteChoiceSubtitle(discoveredGatewayCount: 2) ==
|
||||
"2 gateways found on your network — click to choose one.")
|
||||
}
|
||||
|
||||
@Test func `onboarding view builds body`() {
|
||||
let state = AppState(preview: true)
|
||||
let view = OnboardingView(
|
||||
|
||||
@@ -29,7 +29,12 @@ enum ExecApprovalsLegacyMigrationGate {
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Legacy exec approvals exist at \(sourceURL.path). " +
|
||||
"Run `openclaw doctor --fix` before using exec approvals.",
|
||||
// Doctor repairs whichever state directory its own environment resolves to,
|
||||
// and an app store never shares the CLI's default root, so always name the
|
||||
// directory; a bare `doctor --fix` would repair a different one. Prose,
|
||||
// not a `VAR=value cmd` one-liner, so the path needs no shell quoting.
|
||||
"Run `openclaw doctor --fix` with OPENCLAW_STATE_DIR set to " +
|
||||
"\(stateDirectoryURL.path) before using exec approvals.",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@ import Foundation
|
||||
import Network
|
||||
|
||||
public enum GatewayDiscoveryStatusText {
|
||||
public static var idle: String {
|
||||
String(localized: "Idle")
|
||||
}
|
||||
|
||||
public static var stopped: String {
|
||||
String(localized: "Stopped")
|
||||
}
|
||||
|
||||
public static func make(states: [NWBrowser.State], hasBrowsers: Bool) -> String {
|
||||
if states.isEmpty {
|
||||
return hasBrowsers ? "Setup" : "Idle"
|
||||
return hasBrowsers ? String(localized: "Setup") : self.idle
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
@@ -12,7 +20,7 @@ public enum GatewayDiscoveryStatusText {
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
return "Failed: \(err)"
|
||||
return "\(String(localized: "Failed")): \(err)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +29,7 @@ public enum GatewayDiscoveryStatusText {
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
return "Waiting: \(err)"
|
||||
return "\(String(localized: "Waiting")): \(err)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +40,7 @@ public enum GatewayDiscoveryStatusText {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
return "Searching…"
|
||||
return String(localized: "Searching…")
|
||||
}
|
||||
|
||||
if states.contains(where: {
|
||||
@@ -42,9 +50,9 @@ public enum GatewayDiscoveryStatusText {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
return "Setup"
|
||||
return String(localized: "Setup")
|
||||
}
|
||||
|
||||
return "Searching…"
|
||||
return String(localized: "Searching…")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,17 +1179,20 @@ public struct RequestFrame: Codable, Sendable {
|
||||
public let id: String
|
||||
public let method: String
|
||||
public let params: AnyCodable?
|
||||
public let traceparent: String?
|
||||
|
||||
public init(
|
||||
type: String,
|
||||
id: String,
|
||||
method: String,
|
||||
params: AnyCodable? = nil)
|
||||
params: AnyCodable? = nil,
|
||||
traceparent: String? = nil)
|
||||
{
|
||||
self.type = type
|
||||
self.id = id
|
||||
self.method = method
|
||||
self.params = params
|
||||
self.traceparent = traceparent
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1197,6 +1200,7 @@ public struct RequestFrame: Codable, Sendable {
|
||||
case id
|
||||
case method
|
||||
case params
|
||||
case traceparent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,12 @@ struct ExecApprovalsSQLiteStoreTests {
|
||||
_ = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL)
|
||||
Issue.record("Expected pending legacy approvals to refuse SQLite access")
|
||||
} catch {
|
||||
#expect(error.localizedDescription.contains("Run `openclaw doctor --fix`"))
|
||||
// The blocked state directory must be named; a bare command repairs the
|
||||
// default root, and an app store never shares the CLI's default root.
|
||||
#expect(
|
||||
error.localizedDescription.contains(
|
||||
"Run `openclaw doctor --fix` with OPENCLAW_STATE_DIR set to "
|
||||
+ stateDirectoryURL.path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Network
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
struct GatewayDiscoveryStatusTextTests {
|
||||
@Test func `discovery states return localized presentation copy`() {
|
||||
#expect(GatewayDiscoveryStatusText.make(states: [], hasBrowsers: false) == "Idle")
|
||||
#expect(GatewayDiscoveryStatusText.make(states: [], hasBrowsers: true) == "Setup")
|
||||
#expect(GatewayDiscoveryStatusText.make(states: [.ready], hasBrowsers: true) == "Searching…")
|
||||
#expect(GatewayDiscoveryStatusText.stopped == "Stopped")
|
||||
}
|
||||
|
||||
@Test func `discovery failures preserve network error detail`() {
|
||||
let failedError = NWError.posix(.ETIMEDOUT)
|
||||
let waitingError = NWError.posix(.ENETDOWN)
|
||||
let failed = GatewayDiscoveryStatusText.make(
|
||||
states: [.failed(failedError)],
|
||||
hasBrowsers: true)
|
||||
let waiting = GatewayDiscoveryStatusText.make(
|
||||
states: [.waiting(waitingError)],
|
||||
hasBrowsers: true)
|
||||
|
||||
#expect(failed.hasPrefix("Failed: "))
|
||||
#expect(failed.hasSuffix(String(describing: failedError)))
|
||||
#expect(waiting.hasPrefix("Waiting: "))
|
||||
#expect(waiting.hasSuffix(String(describing: waitingError)))
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ a18c29e77a6797a9712e52f9469062438f32c33209c54be23e5bbc50efa2fc7d module/channel
|
||||
4a7ada095f0f483525dcbd848fbccab26473749eba87e6a6c6e5074fd04ee1d1 module/channel-ingress-runtime
|
||||
c97dd36cdf8f83c2893c33e9430a93cd131a03d855725783ca5b545de0cf84f8 module/channel-lifecycle
|
||||
159d034b431d113f3a6dc41ec0bcadba2d6664051f158330b0e3dd3da8b5d42f module/channel-logging
|
||||
b8a583252a6188a5c6e15b093e08407c691e8714ec51d4adadfac49c1e76a55d module/channel-message
|
||||
476f0ec01b26c318c5c1be743a64ce4fb2510dcc5ed8085705f941f777ee2024 module/channel-outbound
|
||||
15648ed4ab4143f565301ba7a4073b45dce47c2be7bc14a542f5d858c50340d7 module/channel-message
|
||||
2bf2547e3bd5e5971890de6e5d3b107a9b5a44cf56103b554cdc49d4daec616c module/channel-outbound
|
||||
930beff13ed42a138f65164013c82f4f4c96422292c5fc634a5edee1dce71367 module/channel-pairing
|
||||
ee4292b069d4d48cce4fc2dc26df5b5c87eb1fa4769f1f6be9a10c3e1221e1a9 module/channel-plugin-common
|
||||
94ef57c8f6087fcaa56e59e493c391a04377ed03f23c681edd8d8f6e2d64e0da module/channel-policy
|
||||
|
||||
@@ -40,8 +40,8 @@ Generated locale trees and live translation memory now live in the publish repo:
|
||||
## Files in this folder
|
||||
|
||||
- `glossary.<lang>.json` — preferred term mappings used as prompt guidance.
|
||||
- `zh-Hans-navigation.json` — curated zh-Hans Mintlify locale navigation reinserted into the publish repo during sync.
|
||||
- `ar-navigation.json`, `de-navigation.json`, `es-navigation.json`, `fr-navigation.json`, `id-navigation.json`, `it-navigation.json`, `ja-navigation.json`, `ko-navigation.json`, `pl-navigation.json`, `pt-BR-navigation.json`, and `tr-navigation.json` — starter locale metadata kept alongside the source repo, but the publish sync now clones the full English nav tree for clone-en locales so translated pages are visible in Mintlify without hand-maintaining per-locale nav JSON.
|
||||
- `zh-Hans-navigation.json` — curated zh-Hans tab and group labels overlaid onto the current English navigation tree during publish sync.
|
||||
- `ar-navigation.json`, `de-navigation.json`, `es-navigation.json`, `fr-navigation.json`, `id-navigation.json`, `it-navigation.json`, `ja-navigation.json`, `ko-navigation.json`, `pl-navigation.json`, `pt-BR-navigation.json`, and `tr-navigation.json` — starter locale labels kept alongside the source repo. Publish sync clones the full English navigation tree, prefixes locale routes, and overlays translated labels by matching shared page anchors.
|
||||
- `<lang>.tm.jsonl` — translation memory keyed by workflow + model + text hash.
|
||||
|
||||
In this repo, generated locale TM files such as `docs/.i18n/zh-CN.tm.jsonl`, `docs/.i18n/zh-TW.tm.jsonl`, `docs/.i18n/ja-JP.tm.jsonl`, `docs/.i18n/es.tm.jsonl`, `docs/.i18n/pt-BR.tm.jsonl`, `docs/.i18n/ko.tm.jsonl`, `docs/.i18n/de.tm.jsonl`, `docs/.i18n/fr.tm.jsonl`, `docs/.i18n/ar.tm.jsonl`, `docs/.i18n/it.tm.jsonl`, `docs/.i18n/vi.tm.jsonl`, `docs/.i18n/nl.tm.jsonl`, `docs/.i18n/fa.tm.jsonl`, `docs/.i18n/tr.tm.jsonl`, `docs/.i18n/uk.tm.jsonl`, `docs/.i18n/id.tm.jsonl`, `docs/.i18n/pl.tm.jsonl`, and `docs/.i18n/th.tm.jsonl` are intentionally no longer committed.
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ for commands and recovery.
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ubuntu-24.04` | `security-fast`, manual CI dispatch and non-canonical repository fallbacks, the QA Smoke aggregate, CodeQL security and quality scans, workflow-sanity, labeler, auto-response, the standalone Docs workflow, and the whole Install Smoke workflow |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `preflight`, `pnpm-store-warmup`, `native-i18n`, `checks-fast-core` except QA Smoke CI, plugin/channel contract shards, most bundled/lower-weight Linux Node shards, `check-*` lanes except `check-lint`, selected `check-additional-*` shards, `check-docs`, and `skills-python` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, boundary/extension-heavy `check-additional-*` shards, and `android` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, the serial Chromium/Vite `checks-ui-e2e` lane, boundary/extension-heavy `check-additional-*` shards, and `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | Automatic QA Smoke CI shards, `build-artifacts` in CI and Testbox, and `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved) |
|
||||
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
|
||||
@@ -23,6 +23,7 @@ When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate in
|
||||
- Default: `~/.openclaw/workspace`
|
||||
- If `OPENCLAW_PROFILE` is set and not `"default"`, the default becomes `~/.openclaw/workspace-<profile>`.
|
||||
- `OPENCLAW_WORKSPACE_DIR` overrides both of the above when set.
|
||||
- `openclaw onboard --non-interactive` uses `<state-dir>/workspace` when `OPENCLAW_STATE_DIR` is non-default, including for the initial `main` agent entry.
|
||||
- Non-default agents (`agents.entries.*`) without an explicit workspace resolve to `<state-dir>/workspace-<agentId>`, not the shared default workspace.
|
||||
|
||||
Override in `~/.openclaw/openclaw.json`:
|
||||
|
||||
@@ -3648,6 +3648,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Quick checks
|
||||
- H2: Deep diagnostics
|
||||
- H2: Health monitor config
|
||||
- H2: Inbound ingress health
|
||||
- H2: Uptime monitoring
|
||||
- H3: Monitoring service setup examples
|
||||
- H2: When something fails
|
||||
@@ -3850,6 +3851,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Signals exported
|
||||
- H2: Configuration reference
|
||||
- H3: Environment variables
|
||||
- H2: Continue an upstream WebSocket trace
|
||||
- H2: Privacy and content capture
|
||||
- H2: Sampling and flushing
|
||||
- H3: Model-call observation units
|
||||
@@ -5053,6 +5055,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: The computer.act node command
|
||||
- H2: Authorization
|
||||
- H2: Safety
|
||||
- H2: macOS permission troubleshooting
|
||||
- H2: Relationship to other desktop-control paths
|
||||
|
||||
## nodes/images.md
|
||||
@@ -5525,6 +5528,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- Headings:
|
||||
- H2: Requirements for stable permissions
|
||||
- H2: Accessibility grants for Node and CLI runtimes
|
||||
- H2: Separate Computer Control grants
|
||||
- H2: Recovery checklist when prompts disappear
|
||||
- H2: Files and folders permissions (Desktop/Documents/Downloads)
|
||||
- H2: Related
|
||||
@@ -10706,6 +10710,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Quick start
|
||||
- H2: Supported providers
|
||||
- H2: Configuration
|
||||
- H3: Local Speech Swift and speech-core
|
||||
- H3: Per-agent voice overrides
|
||||
- H2: Personas
|
||||
- H3: Minimal persona
|
||||
|
||||
@@ -1492,7 +1492,7 @@ Defaults for Talk mode (macOS/iOS/Android and the browser Control UI).
|
||||
- macOS MLX playback runs through the bundled `openclaw-mlx-tts` helper when present, or an executable on `PATH`; `OPENCLAW_MLX_TTS_BIN` overrides the helper path for development.
|
||||
- `consultThinkingLevel` controls the thinking level for the full OpenClaw agent run behind Control UI Talk realtime `openclaw_agent_consult` calls. Leave unset to preserve normal session/model behavior.
|
||||
- `consultFastMode` sets a one-shot fast-mode override for Control UI Talk realtime consults without changing the session's normal fast-mode setting.
|
||||
- `speechLocale` sets the BCP 47 locale id used by Android, iOS, and macOS Talk speech recognition. Android also uses its language component to guide realtime input transcription. Leave unset to use the device default.
|
||||
- `speechLocale` sets the BCP 47 locale id used by Android, iOS, and macOS Talk speech recognition and by the iOS system-voice fallback. Android also uses its language component to guide realtime input transcription. Leave unset to use the device default.
|
||||
- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`).
|
||||
- `realtime.instructions` appends provider-facing system instructions to OpenClaw's built-in realtime prompt, so voice style can be configured without losing default `openclaw_agent_consult` guidance.
|
||||
- `realtime.vadThreshold` sets the provider voice-activity threshold from `0` (most sensitive) to `1` (least sensitive). Unset keeps the provider default.
|
||||
|
||||
@@ -45,7 +45,7 @@ See [Configuration - agents](/gateway/config-agents) for:
|
||||
- `talk.*` (Talk mode)
|
||||
- `talk.consultThinkingLevel`: thinking level override for the full OpenClaw agent run behind Control UI Talk realtime consults
|
||||
- `talk.consultFastMode`: one-shot fast-mode override for Control UI Talk realtime consults
|
||||
- `talk.speechLocale`: optional BCP 47 locale id for Talk speech recognition on Android, iOS, and macOS
|
||||
- `talk.speechLocale`: optional BCP 47 locale id for Talk speech recognition on Android, iOS, and macOS, and for iOS system-voice fallback
|
||||
- `talk.silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700 ms on macOS and Android, 900 ms on iOS`)
|
||||
- `talk.realtime.consultRouting`: Gateway relay fallback for finalized realtime Talk transcripts that skip `openclaw_agent_consult`
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ health commands above for live connectivity checks.
|
||||
- These per-channel overrides apply to the built-in channels that expose them today: Discord, Google Chat, iMessage, IRC, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
|
||||
- A crashing channel is recovered by its own auto-restart backoff first (`auto-restart attempt N/10` in the logs). The health monitor stays out of the way until that ladder ends with `giving up after 10 restart attempts`, then takes over as the last restart owner.
|
||||
|
||||
## Inbound ingress health
|
||||
|
||||
Channel connectivity and inbound admission are separate failure domains. A channel can hold a healthy transport connection — sending replies normally — while its durable ingress queue is unavailable, so not a single inbound message is admitted.
|
||||
|
||||
- When a channel cannot open its durable ingress queue, its start fails and the gateway records the account as unable to receive. `openclaw channels status` reports `Channel cannot admit inbound events; its durable ingress queue is unavailable. Outbound may still work.`
|
||||
- Such an account is **unhealthy** regardless of transport state, and readiness reports it as failing. Previously it reported `health: healthy` and the health monitor never touched it.
|
||||
- Recovery stays automatic. The ingress verdict describes the account's last start attempt and is cleared by the next one, so the ordinary restart path is also how a transient queue-open failure recovers. Those restarts log as `health-monitor: restarting (reason: ingress-unavailable)` instead of the generic `stuck`.
|
||||
- If the restarts keep repeating, the cause is not transient. Check the logged ingress failure: a plugin denied the `openChannelIngressQueue` capability, for example, needs operator action rather than another restart.
|
||||
- Channels that never report ingress state are unaffected: absence means "no signal", never "broken". There is no traffic-staleness heuristic, so a genuinely quiet channel is never marked unhealthy for having received nothing.
|
||||
|
||||
## Uptime monitoring
|
||||
|
||||
External uptime monitoring services should use the dedicated `/health` endpoint, not `/v1/chat/completions`.
|
||||
|
||||
@@ -114,6 +114,39 @@ stdout, or `both` for both.
|
||||
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Set to `gen_ai_latest_experimental` to emit the latest GenAI inference span shape: `{gen_ai.operation.name} {gen_ai.request.model}` span names, `CLIENT` span kind, and `gen_ai.provider.name` instead of the legacy `gen_ai.system`. GenAI metrics always use bounded, low-cardinality attributes regardless. |
|
||||
| `OPENCLAW_OTEL_PRELOADED` | Set to `1` when another preload or host process already registered the global OpenTelemetry SDK. The plugin then skips its own NodeSDK lifecycle but still wires diagnostic listeners and honors `traces`/`metrics`/`logs`. |
|
||||
|
||||
## Continue an upstream WebSocket trace
|
||||
|
||||
An authenticated Gateway WebSocket client can attach a W3C `traceparent` to
|
||||
each request frame:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "eval-item-42",
|
||||
"method": "agent",
|
||||
"params": {},
|
||||
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
||||
}
|
||||
```
|
||||
|
||||
The Gateway creates a child request context that preserves the upstream trace
|
||||
ID and sampling flags. Agent, harness, model-call, and provider spans created
|
||||
inside the request remain on that trace. This allows a local experiment runner
|
||||
to create one Langfuse/OpenTelemetry trace per dataset item and correlate the
|
||||
corresponding OpenClaw execution.
|
||||
|
||||
Trace context is request-scoped, not connection-scoped. On a long-lived
|
||||
WebSocket, generate or inject the appropriate `traceparent` independently for
|
||||
every RPC. Concurrent requests remain isolated even when their work
|
||||
interleaves.
|
||||
|
||||
The field is accepted only after the existing Gateway authentication handshake
|
||||
and does not affect authentication or method authorization. A `traceparent` on
|
||||
the initial `connect` frame is ignored. Missing or syntactically malformed
|
||||
values within the 128-character field limit silently fall back to a fresh
|
||||
request trace; longer values make the request frame invalid. `tracestate` and
|
||||
`baggage` are not accepted by the Gateway WebSocket protocol.
|
||||
|
||||
## Privacy and content capture
|
||||
|
||||
Raw model/tool content is **not** exported by default. Spans carry bounded
|
||||
|
||||
@@ -45,10 +45,19 @@ that supervise the Gateway as a child process, see
|
||||
|
||||
Frame shapes:
|
||||
|
||||
- Request: `{type:"req", id, method, params}`
|
||||
- Request: `{type:"req", id, method, params, traceparent?}`
|
||||
- Response: `{type:"res", id, ok, payload|error}`
|
||||
- Event: `{type:"event", event, payload, seq?, stateVersion?}`
|
||||
|
||||
After authentication, a client may include a W3C `traceparent` string on each
|
||||
request frame. The Gateway continues a valid value as a child trace context for
|
||||
that request. Missing or syntactically malformed values within the
|
||||
128-character field limit keep the default fresh request trace and do not fail
|
||||
the RPC; longer values make the request frame invalid. The initial `connect`
|
||||
request never establishes trace context for later frames. Use a separate
|
||||
`traceparent` for each logical request on a long-lived connection; do not treat
|
||||
the WebSocket itself as one trace.
|
||||
|
||||
Response errors use `{ code, message, details?, retryable?, retryAfterMs? }`.
|
||||
Clients should branch on `code` and `details.code`; `message` remains human-readable
|
||||
and can change except where a compatibility note says otherwise. Method-level
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
|
||||
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
|
||||
- **Open source**: MIT licensed, community-driven
|
||||
|
||||
**What do you need?** Node 24.15+ (recommended), Node 22 LTS (`22.22.3+`) for compatibility, or Node 25.9+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
**What do you need?** Node 26 (recommended), or another supported release: Node 22.22.3+, Node 24.15+, or Node 25.9+. You also need an API key from your chosen provider and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ DigitalOcean is a straightforward paid VPS path. For cheaper or free options:
|
||||
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# Install Node.js 24
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
# Install Node.js 26
|
||||
curl -fsSL https://deb.nodesource.com/setup_26.x | bash -
|
||||
apt install -y nodejs
|
||||
|
||||
# Install OpenClaw
|
||||
|
||||
@@ -72,9 +72,9 @@ Recommended for most interactive installs on macOS/Linux/WSL.
|
||||
<Step title="Detect OS">
|
||||
Supports macOS and Linux (including WSL).
|
||||
</Step>
|
||||
<Step title="Ensure Node.js 24 by default">
|
||||
<Step title="Ensure Node.js 26 by default">
|
||||
Checks Node version and installs Node 26 if needed (Homebrew `node` on macOS, NodeSource setup scripts on Linux apt/dnf/yum). On macOS, Homebrew is installed only when the installer needs it for Node or Git. Node 22.22.3+, Node 24.15+, and Node 25.9+ are supported; Node 23 is unsupported.
|
||||
On Alpine/musl Linux, the installer uses apk packages instead of NodeSource and verifies the actual linked SQLite version. Current stable Alpine package streams can provide a new-enough Node with vulnerable system SQLite; when that happens, use an official `node:24-alpine` container or a glibc-based host instead.
|
||||
On Alpine/musl Linux, the installer uses apk packages instead of NodeSource and verifies the actual linked SQLite version. Current stable Alpine package streams can provide a new-enough Node with vulnerable system SQLite; when that happens, use an official `node:26-alpine` container or a glibc-based host instead.
|
||||
</Step>
|
||||
<Step title="Ensure Git">
|
||||
Installs Git if missing using the detected package manager, including Homebrew on macOS and apk on Alpine.
|
||||
@@ -299,7 +299,7 @@ by default, plus git-checkout installs under the same prefix flow.
|
||||
<Step title="Ensure PowerShell + Windows environment">
|
||||
Requires PowerShell 5+.
|
||||
</Step>
|
||||
<Step title="Ensure Node.js 24 by default">
|
||||
<Step title="Ensure a supported Node.js runtime">
|
||||
If missing, attempts install via winget, then Chocolatey, then Scoop. If no package manager is available, the script downloads the official Node.js 26 Windows zip into `%LOCALAPPDATA%\OpenClaw\deps\portable-node` and adds it to the current process and user PATH. Node 22.22.3+, Node 24.15+, and Node 25.9+ are supported; Node 23 is unsupported.
|
||||
</Step>
|
||||
<Step title="Install OpenClaw">
|
||||
|
||||
@@ -66,9 +66,9 @@ Run a persistent, always-on OpenClaw Gateway on a Raspberry Pi. Since the Pi is
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Install Node.js 24">
|
||||
<Step title="Install Node.js 26">
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
curl -fsSL https://deb.nodesource.com/setup_26.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
node --version
|
||||
```
|
||||
|
||||
@@ -14,8 +14,8 @@ The agent emits one uniform command, `computer.act`; it cannot tell how a node f
|
||||
## Requirements
|
||||
|
||||
- A paired, connected node advertising both `computer.act` and `screen.snapshot`, with `screen.snapshot` returning `displayFrameId`.
|
||||
- **macOS fulfiller:** app setting **Allow Computer Control** enabled (default: off).
|
||||
- **macOS fulfiller:** **Accessibility** permission granted to OpenClaw (for pointer/keyboard injection) and **Screen Recording** permission (for `screen.snapshot`).
|
||||
- **macOS fulfiller:** app setting **Allow Computer Control** enabled. It defaults on; an explicit off choice stays off.
|
||||
- **macOS fulfiller:** **Accessibility** and Event Posting access granted to OpenClaw (for pointer/keyboard injection), plus **Screen Recording** permission (for `screen.snapshot`).
|
||||
- **Windows/Linux fulfiller:** bundled `cua-computer` plugin enabled and a compatible `cua-driver` 0.10.x executable installed.
|
||||
- The pairing update that includes `computer.act` approved on the gateway.
|
||||
- A vision-capable agent model.
|
||||
@@ -80,7 +80,7 @@ Reads reuse `screen.snapshot`; there is no second capture path. See [Camera and
|
||||
|
||||
## Authorization
|
||||
|
||||
1. Enable the platform fulfiller: on macOS, enable **Settings → Allow Computer Control**, then grant **Accessibility** and **Screen Recording** under **Settings → Permissions**; on Windows/Linux, follow the experimental `cua-computer` setup above.
|
||||
1. Enable the platform fulfiller: on macOS, **Settings → Allow Computer Control** starts enabled, then grant **Accessibility** and **Screen Recording** under **Settings → Permissions**; on Windows/Linux, follow the experimental `cua-computer` setup above.
|
||||
2. Approve the pairing update on the gateway (a new command forces re-pairing).
|
||||
3. Expose the tool to the vision-capable agent. For the default `coding` profile:
|
||||
|
||||
@@ -96,6 +96,8 @@ Reads reuse `screen.snapshot`; there is no second capture path. See [Camera and
|
||||
|
||||
Once the node-local control is enabled and the pairing update is approved, `computer.act` is durably available while the node continues to advertise it. There is no lease, expiry, or arm/disarm command. Disabling Computer Control locally removes the advertised command and the node rechecks the toggle at invocation time.
|
||||
|
||||
On macOS, default-on means a paired gateway can drive pointer and keyboard input as soon as the required macOS grants exist. There is no per-action confirmation. Turn off **Allow Computer Control** before pairing, or at any later time, to stop advertising and accepting `computer.act`.
|
||||
|
||||
`gateway.nodes.commands.deny` remains an explicit global revocation and always wins. `computer.act` does not need a `gateway.nodes.commands.allow` entry. An authenticated operator with `operator.write` can invoke an enabled, paired command through `node.invoke`; there is no per-action admin check.
|
||||
|
||||
## Safety
|
||||
@@ -105,6 +107,12 @@ Once the node-local control is enabled and the pairing update is approved, `comp
|
||||
- Screenshots are model-only and never auto-sent to chat (issue [#44759](https://github.com/openclaw/openclaw/issues/44759)).
|
||||
- Treat screen content as untrusted; it can carry prompt injection.
|
||||
|
||||
## macOS permission troubleshooting
|
||||
|
||||
The Computer Control status in **Settings → General → Capabilities** checks Accessibility, Event Posting, and Screen Recording separately. Screen capture can work while input remains denied because macOS stores those grants in separate TCC buckets.
|
||||
|
||||
If the status says **Accessibility grant may be stale**, OpenClaw may already appear enabled under **System Settings → Privacy & Security → Accessibility** even though macOS rejects it. This happens when the Accessibility entry is pinned to an older app build. Select OpenClaw in that list, remove it with **−**, then re-add `/Applications/OpenClaw.app`. Quit and reopen OpenClaw after changing the grant because macOS can cache Accessibility trust for the lifetime of the process.
|
||||
|
||||
## Relationship to other desktop-control paths
|
||||
|
||||
This is the agent-driven path. See [Peekaboo bridge](/platforms/mac/peekaboo) for how it relates to the PeekabooBridge host, Codex Computer Use, and the direct `cua-driver` MCP.
|
||||
|
||||
+6
-16
@@ -97,27 +97,17 @@ Supported keys: `voice` / `voice_id` / `voiceId`, `model` / `model_id` / `modelI
|
||||
|
||||
OpenAI browser and iOS WebRTC Talk use Platform credentials in this order:
|
||||
the configured realtime API key, an `openai` API-key profile, then
|
||||
`OPENAI_API_KEY`. When none is configured and the bundled Codex runtime is
|
||||
active, Talk falls back to its logged-in ChatGPT/Codex subscription
|
||||
automatically. OpenAI OAuth/Codex agent sessions activate that runtime without
|
||||
an additional Talk auth setting. This experimental fallback supports client-owned WebRTC only;
|
||||
Gateway relay and backend voice bridges still require OpenAI Platform
|
||||
credentials.
|
||||
|
||||
OpenClaw does not read or copy the Codex OAuth token. The Codex app-server owns
|
||||
the subscription-authenticated realtime connection and starts an ephemeral,
|
||||
read-only thread seeded with bounded context from the active agent session.
|
||||
Codex owns the realtime model, base prompt, and agent handoff on this route;
|
||||
`talk.realtime.model`, direct provider tools, and Video Talk apply only to the
|
||||
Platform WebRTC route. Custom Talk instructions and bounded profile context are
|
||||
added as developer context without replacing Codex's native delegation prompt.
|
||||
`OPENAI_API_KEY`. ChatGPT/Codex OAuth authenticates the subscription Codex
|
||||
backend, not the public OpenAI Realtime API, and does not configure Talk,
|
||||
Voice Call, or Discord realtime voice. Configure a Platform API key even when
|
||||
agent turns use Codex OAuth.
|
||||
|
||||
| Key | Default | Notes |
|
||||
| ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `agentId` | configured default agent | Owns Talk sessions created without an explicit agent-scoped session key. |
|
||||
| `provider` | - | Active Talk TTS provider. Use `elevenlabs`, `mlx`, or `system` for macOS-local playback paths. |
|
||||
| `providers.<id>.voiceId` | - | ElevenLabs falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID`, or the first available voice with an API key. |
|
||||
| `speechLocale` | device default | BCP 47 locale for Android, iOS, and macOS native speech recognition. Apple Speech may use network services; Android also forwards the language component to realtime input transcription. |
|
||||
| `speechLocale` | device default | BCP 47 locale for Android, iOS, and macOS native speech recognition, plus the iOS system-voice fallback. Apple Speech may use network services; Android also forwards the language component to realtime input transcription. |
|
||||
| `providers.elevenlabs.modelId` | `eleven_v3` | |
|
||||
| `providers.mlx.modelId` | `mlx-community/Soprano-80M-bf16` | |
|
||||
| `providers.elevenlabs.apiKey` | - | Falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available). |
|
||||
@@ -132,7 +122,7 @@ added as developer context without replacing Codex's native delegation prompt.
|
||||
| `realtime.transport` | - | `webrtc`: client-owned OpenAI WebRTC on iOS and in the browser. `provider-websocket`: browser-owned, stays on Gateway relay on iOS. `gateway-relay`: keeps provider audio on the Gateway; Android uses realtime only with this transport. |
|
||||
| `realtime.brain` | - | `agent-consult` routes realtime tool calls through Gateway policy; `direct-tools` is legacy direct-tool compatibility; `none` is for transcription/external orchestration. |
|
||||
| `realtime.consultRouting` | - | `provider-direct` preserves the provider's direct reply when it skips `openclaw_agent_consult`; `force-agent-consult` routes finalized user transcripts through OpenClaw instead. |
|
||||
| `realtime.instructions` | - | Appends provider-facing system instructions to OpenClaw's built-in realtime prompt. On the Codex OAuth fallback, the text is developer context and Codex's native delegation prompt stays authoritative. |
|
||||
| `realtime.instructions` | - | Appends provider-facing system instructions to OpenClaw's built-in realtime prompt. |
|
||||
|
||||
`talk.catalog` exposes canonical provider ids and registry aliases, each provider's valid modes/transports/brain strategies/realtime audio formats/capability flags, and the runtime-selected readiness result. First-party Talk clients should read that catalog instead of maintaining provider aliases locally; treat an older Gateway that omits group readiness as unverified rather than definitively unconfigured. Streaming transcription providers are discovered through `talk.catalog.transcription`; the current Gateway relay uses the Voice Call streaming provider config until a dedicated Talk transcription config surface ships.
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ Linux v1 uses one Canvas window. HTTP and HTTPS pages are renderable, but A2UI a
|
||||
|
||||
The CLI remains the simplest option for a headless server, a VPS, or a remote Gateway:
|
||||
|
||||
1. Install Node 24.15+ (recommended), Node 22.22.3+ (LTS), or Node 25.9+.
|
||||
1. Install Node 26 (recommended), or another supported release: Node 22.22.3+, Node 24.15+, or Node 25.9+.
|
||||
2. `npm i -g openclaw@latest`
|
||||
3. `openclaw onboard --install-daemon`
|
||||
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
|
||||
|
||||
@@ -26,8 +26,8 @@ OpenClaw package.
|
||||
|
||||
## Manual recovery
|
||||
|
||||
Node 24.15+ is recommended for a manual install; Node 22.22.3+ also works. Install
|
||||
`openclaw` globally:
|
||||
For a manual install, use Node 26 (recommended) or another supported release:
|
||||
Node 22.22.3+, Node 24.15+, or Node 25.9+. Install `openclaw` globally:
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@<version>
|
||||
|
||||
@@ -12,7 +12,7 @@ macOS permission grants are fragile. TCC associates a permission grant with the
|
||||
|
||||
## Requirements for stable permissions
|
||||
|
||||
- Same path: run the app from a fixed location (for OpenClaw, `dist/OpenClaw.app`).
|
||||
- Same path: run a release app from `/Applications/OpenClaw.app`; keep development builds at one fixed path such as `dist/OpenClaw.app`.
|
||||
- Same bundle identifier: OpenClaw's bundle ID is `ai.openclaw.mac`; changing it creates a new permission identity.
|
||||
- Signed app: unsigned or ad-hoc signed builds do not persist permissions.
|
||||
- Consistent signature: use a real Apple Development or Developer ID certificate so the signature stays stable across rebuilds.
|
||||
@@ -31,6 +31,12 @@ Accessibility approval does not enable activity sharing. **Settings -> Permissio
|
||||
|
||||
If you accidentally granted Accessibility to `node`, remove that entry from System Settings -> Privacy & Security -> Accessibility. Then grant the signed app or helper that should own UI automation.
|
||||
|
||||
## Separate Computer Control grants
|
||||
|
||||
macOS keeps Accessibility, Event Posting, input listening, and Screen Recording in separate TCC buckets. One successful grant does not prove the others are usable. OpenClaw's Computer Control status checks Accessibility, Event Posting, and Screen Recording separately; this is why screenshots can succeed while clicks and typing fail.
|
||||
|
||||
An Accessibility row can also remain visibly enabled while its code requirement is pinned to an older build. When OpenClaw reports **Accessibility grant may be stale**, select OpenClaw under **System Settings -> Privacy & Security -> Accessibility**, remove it with **-**, then re-add `/Applications/OpenClaw.app`. Quit and reopen OpenClaw afterward because Accessibility trust can remain cached in the running process.
|
||||
|
||||
## Recovery checklist when prompts disappear
|
||||
|
||||
1. Quit the app.
|
||||
|
||||
@@ -58,10 +58,12 @@ returns.
|
||||
Optional settings include custom append delays, a `drain` option block for
|
||||
advanced drain ordering/concurrency/retry policy, an external `abortSignal`, a
|
||||
clock, pump error reporting, a stopped-error factory, and admission policy.
|
||||
The returned monitor exposes `admit`, `start`, `pause`, `stop`, `waitForIdle`,
|
||||
`isRunning`, and `isStopped`. `stop` first settles accepted admissions, then
|
||||
aborts and disposes the drain, waits for the pump and active deliveries, and
|
||||
disposes again to close the lazy-creation race.
|
||||
The returned monitor exposes `admit`, `ensureQueueAvailable`, `start`, `pause`,
|
||||
`stop`, `waitForIdle`, `isRunning`, and `isStopped`. Use the idempotent
|
||||
`ensureQueueAvailable()` check when plugin-owned migration or preparation must
|
||||
run after the queue opens but before the drain starts. `stop` first settles
|
||||
accepted admissions, then aborts and disposes the drain, waits for the pump and
|
||||
active deliveries, and disposes again to close the lazy-creation race.
|
||||
|
||||
Keep transport-specific redaction, raw-envelope validation, non-retryable
|
||||
classification, and persisted payload shape in the plugin. Webhook transports
|
||||
|
||||
+21
-33
@@ -142,20 +142,20 @@ explicit runtime config.
|
||||
|
||||
## OpenClaw feature coverage
|
||||
|
||||
| OpenAI capability | OpenClaw surface | Status |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Chat / Responses | `openai/<model>` model provider | Yes |
|
||||
| Codex subscription models | `openai/<model>` with OpenAI OAuth | Yes |
|
||||
| Legacy Codex model refs | old Codex model refs, `codex-cli/<model>` | Repaired by doctor to `openai/<model>` |
|
||||
| Codex app-server harness | Codex-compatible HTTPS route with runtime unset/`auto`, or explicit `agentRuntime.id: codex` | Yes |
|
||||
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no other provider is pinned |
|
||||
| Images | `image_generate` | Yes |
|
||||
| Videos | `video_generate` | Yes |
|
||||
| Text-to-speech | `tts.provider: "openai"` / `tts` | Yes |
|
||||
| Batch speech-to-text | `tools.media.audio` / media understanding | Yes |
|
||||
| Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes |
|
||||
| Realtime voice | Voice Call `realtime.provider: "openai"` / Control UI Talk `talk.realtime.provider: "openai"` | Yes (Platform API key; experimental Codex OAuth for browser WebRTC Talk) |
|
||||
| Embeddings | memory embedding provider | Yes |
|
||||
| OpenAI capability | OpenClaw surface | Status |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| Chat / Responses | `openai/<model>` model provider | Yes |
|
||||
| Codex subscription models | `openai/<model>` with OpenAI OAuth | Yes |
|
||||
| Legacy Codex model refs | old Codex model refs, `codex-cli/<model>` | Repaired by doctor to `openai/<model>` |
|
||||
| Codex app-server harness | Codex-compatible HTTPS route with runtime unset/`auto`, or explicit `agentRuntime.id: codex` | Yes |
|
||||
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no other provider is pinned |
|
||||
| Images | `image_generate` | Yes |
|
||||
| Videos | `video_generate` | Yes |
|
||||
| Text-to-speech | `tts.provider: "openai"` / `tts` | Yes |
|
||||
| Batch speech-to-text | `tools.media.audio` / media understanding | Yes |
|
||||
| Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes |
|
||||
| Realtime voice | Voice Call `realtime.provider: "openai"` / Control UI Talk `talk.realtime.provider: "openai"` | Yes (Platform API key) |
|
||||
| Embeddings | memory embedding provider | Yes |
|
||||
|
||||
<Note>
|
||||
OpenAI Realtime voice normally goes through the public **OpenAI Platform
|
||||
@@ -163,19 +163,11 @@ Realtime API** and requires a Platform API key. Codex OAuth tokens authenticate
|
||||
the ChatGPT Codex backend instead; they are not interchangeable with Platform
|
||||
API keys for the public Realtime endpoints.
|
||||
|
||||
Control UI and iOS WebRTC Talk can instead use the experimental Codex
|
||||
app-server route automatically when no Platform credential is configured and
|
||||
the bundled Codex runtime is active. OpenAI OAuth/Codex agent sessions activate
|
||||
that runtime without an additional Talk auth setting. Platform auth wins in this order:
|
||||
configured realtime API key, `openai` API-key profile, then `OPENAI_API_KEY`.
|
||||
Only when all three are absent does browser WebRTC use the Codex plugin's
|
||||
logged-in subscription. The OAuth token is never exposed to OpenClaw or the
|
||||
browser. This fallback is limited to client-owned WebRTC; Voice Call and
|
||||
Gateway-relay realtime still require Platform credentials. Codex owns the
|
||||
realtime model, base prompt, and native agent delegation on this route.
|
||||
OpenClaw adds configured Talk instructions and bounded profile context as
|
||||
developer context without replacing that prompt. Direct Realtime function
|
||||
tools, VAD/reasoning tuning, and Video Talk remain Platform-only.
|
||||
Platform auth is resolved in this order: configured realtime API key, `openai`
|
||||
API-key profile, then `OPENAI_API_KEY`. ChatGPT/Codex OAuth can still
|
||||
authenticate agent models and other explicitly supported subscription
|
||||
surfaces, but it does not configure Talk, Voice Call, Discord realtime voice,
|
||||
or realtime transcription.
|
||||
|
||||
If API-key auth reports missing billing, top up Platform credits at
|
||||
[platform.openai.com/account/billing](https://platform.openai.com/account/billing)
|
||||
@@ -952,12 +944,8 @@ compatibility fallback when the shared
|
||||
against the OpenAI Realtime API when using Platform credentials. The
|
||||
Gateway mints that client secret with the selected `openai` credential.
|
||||
Configured realtime keys, API-key profiles, and `OPENAI_API_KEY` use that
|
||||
path in that order. When none exists and the bundled Codex runtime is
|
||||
active, browser WebRTC falls back to the logged-in Codex app-server
|
||||
automatically. Gateway relay and Voice Call backend realtime WebSocket
|
||||
bridges continue to use Platform credentials. The Codex route keeps
|
||||
Codex's native realtime prompt, model selection, and agent handoff; it does
|
||||
not accept the direct Platform model/tool/camera controls.
|
||||
path in that order. ChatGPT/Codex OAuth is not a Platform Realtime
|
||||
credential and is not used as a fallback.
|
||||
Maintainer live verification is available with
|
||||
`OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts`;
|
||||
the OpenAI legs verify both the backend WebSocket bridge and the browser
|
||||
|
||||
@@ -82,7 +82,13 @@ Since 2026.7.2, `openclaw update` refuses to install a release that cannot open
|
||||
|
||||
### The Gateway refuses to start with a newer schema version error
|
||||
|
||||
A newer OpenClaw build wrote your databases, and the running build is older. The error and the Gateway startup log name the build that owns the database (`app_version`). Install that version or newer, or use one of the options above. Do not edit the database to silence the error.
|
||||
A newer OpenClaw build wrote your databases, and the running build is older. The error names the refusing install — release version, commit, and install root — plus the schema it supports and the schema it found.
|
||||
|
||||
Act on the install root, not the version. One release version string spans many `main` commits and several schema levels, so two installs can both call themselves `2026.7.2` and support different schemas. A prerelease version may not exist on the `latest` npm tag at all: check `npm view openclaw dist-tags` before reinstalling, because the tag carrying the schema you need may be `beta`, and reinstalling from `latest` can move you further away.
|
||||
|
||||
A linked source checkout is the case where the commit misleads: `openclaw --version` reports the checkout's git HEAD, but the code actually executing is whatever `dist/` was last built. If the install root is a checkout, rebuild it (`pnpm build`) before concluding the version is wrong.
|
||||
|
||||
Open the database with a build that supports its schema, or point the older build at a separate `OPENCLAW_STATE_DIR`. Do not edit the database to silence the error.
|
||||
|
||||
### A database is quarantined after integrity verification failed
|
||||
|
||||
|
||||
@@ -815,7 +815,7 @@ The [model catalog](/concepts/models) also reports availability and capability m
|
||||
- ChatGPT OAuth sign-in and refresh now reject unexpectedly large token responses cleanly instead of risking Gateway memory exhaustion. [#99479](https://github.com/openclaw/openclaw/pull/99479) Thanks @pandah97.
|
||||
- OpenAI-compatible providers routed through the GitHub Copilot BYOK harness now retain bearer authentication, reducing false 401 errors for valid credentials. [#99955](https://github.com/openclaw/openclaw/pull/99955) Thanks @hxy91819.
|
||||
- A detail-less failure from an OpenAI-compatible provider no longer puts a valid API-key profile into cooldown or forces avoidable fallback traffic. [#100617](https://github.com/openclaw/openclaw/pull/100617) Thanks @fengjikui.
|
||||
- OpenAI Realtime voice in Talk, Voice Call, and Discord can now use an existing Codex/OpenAI OAuth login when no explicit API key is configured. [#100671](https://github.com/openclaw/openclaw/pull/100671) Thanks @steipete-oai.
|
||||
- OpenAI Realtime voice in Talk, Voice Call, and Discord was announced with a Codex/OpenAI OAuth fallback in [#100671](https://github.com/openclaw/openclaw/pull/100671). Correction: public Codex OAuth accounts do not have a supported realtime transport, so current builds require an OpenAI Platform API key. Thanks @steipete-oai.
|
||||
- OpenAI image generation can now be enabled from `models.providers.openai` with a usable API key and custom base URL, without also requiring `OPENAI_API_KEY` or an auth profile. [#100745](https://github.com/openclaw/openclaw/pull/100745) Thanks @amknight.
|
||||
- Codex users can route both `codex/*` and `openai/*` models through the bundled runtime, and older conversations resume without unnecessary context reprojection. [#105034](https://github.com/openclaw/openclaw/pull/105034)
|
||||
- The bundled Codex plugin can complete backend requests again after updating its managed app-server runtime, with no model-picker or configuration migration required. [#106098](https://github.com/openclaw/openclaw/pull/106098)
|
||||
|
||||
@@ -12,7 +12,7 @@ working chat session.
|
||||
|
||||
## What you need
|
||||
|
||||
- **Node.js 22.22.3+, 24.15+, or 25.9+** (24 is the recommended default)
|
||||
- **Node.js 22.22.3+, 24.15+, or 25.9+** (Node 26 is the recommended runtime)
|
||||
- **An API key** from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you
|
||||
|
||||
<Tip>
|
||||
|
||||
@@ -16,13 +16,13 @@ detailed setup.
|
||||
|
||||
## Which path should I use?
|
||||
|
||||
| | CLI onboarding | macOS app onboarding |
|
||||
| -------------- | -------------------------------------- | ------------------------------ |
|
||||
| **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only |
|
||||
| **Interface** | Inference setup, then OpenClaw | Inference setup, then OpenClaw |
|
||||
| **Best for** | Servers, headless, full control | Desktop Mac, visual setup |
|
||||
| **Automation** | `--non-interactive` for scripts | Manual only |
|
||||
| **Command** | `openclaw onboard` | Launch the app |
|
||||
| | CLI onboarding | macOS app onboarding |
|
||||
| -------------- | -------------------------------------- | ------------------------------------------------------------- |
|
||||
| **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only |
|
||||
| **Interface** | Inference setup, then OpenClaw | Inference setup, then OpenClaw |
|
||||
| **Best for** | Servers, headless, full control | Desktop Mac, visual setup |
|
||||
| **Automation** | `--non-interactive` for scripts | Manual only |
|
||||
| **Command** | `openclaw onboard` | [Download the app](/platforms/macos#download), then launch it |
|
||||
|
||||
Most users should start with **CLI onboarding** — it works everywhere and gives
|
||||
you the most control.
|
||||
@@ -81,7 +81,8 @@ CLI command docs: [`openclaw onboard`](/cli/onboard)
|
||||
|
||||
## macOS app onboarding
|
||||
|
||||
Open the OpenClaw app. If its configured local or remote Gateway is reachable
|
||||
[Download the macOS app](/platforms/macos#download), then open it. If its
|
||||
configured local or remote Gateway is reachable
|
||||
and the default agent already has a configured model, the app skips onboarding
|
||||
and OpenClaw and opens the normal agent UI immediately.
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ verified AI backend, grant permissions, and hand off to the agent's own
|
||||
bootstrap ritual.
|
||||
For CLI onboarding and a comparison of both paths, see [Onboarding Overview](/start/onboarding-overview).
|
||||
|
||||
<Tip>
|
||||
Need the app first? [Download OpenClaw for macOS](/platforms/macos#download),
|
||||
then return here for first-run setup.
|
||||
</Tip>
|
||||
|
||||
<Steps>
|
||||
<Step title="Approve macOS warning">
|
||||
<Frame>
|
||||
|
||||
@@ -100,6 +100,12 @@ Self-learning has two conservative paths:
|
||||
a stable procedure that would remove at least two future model or tool round
|
||||
trips.
|
||||
|
||||
Generated proposals follow shared authoring standards: class-level names,
|
||||
one-sentence descriptions that lead with the task or trigger, and compact
|
||||
evidence-backed imperative steps. They retain supported pitfalls and
|
||||
verification checks, capture working fixes, and do not invent commands, paths,
|
||||
flags, or APIs.
|
||||
|
||||
Good candidates include:
|
||||
|
||||
- a reliable recovery after repeated tool or model failures;
|
||||
|
||||
+14
-9
@@ -38,8 +38,8 @@ skill name appears in multiple places, the highest source wins.
|
||||
| ----------- | ---------------------- | --------------------------------------- |
|
||||
| 1 — highest | Workspace skills | `<workspace>/skills` |
|
||||
| 2 | Project agent skills | `<workspace>/.agents/skills` |
|
||||
| 3 | Personal agent skills | `~/.agents/skills` |
|
||||
| 4 | Managed / local skills | `~/.openclaw/skills` |
|
||||
| 3 | Personal agent skills | `~/.agents/skills` (default state only) |
|
||||
| 4 | Managed / local skills | `<state-dir>/skills` |
|
||||
| 5 | Bundled skills | shipped with the install |
|
||||
| 6 — lowest | Extra directories | `skills.load.extraDirs` + plugin skills |
|
||||
|
||||
@@ -81,13 +81,18 @@ files. See [Nodes](/nodes#node-hosted-skills) for pairing and off-switches.
|
||||
In multi-agent setups, each agent has its own workspace. Use the path that
|
||||
matches your desired visibility:
|
||||
|
||||
| Scope | Path | Visible to |
|
||||
| -------------- | ---------------------------- | --------------------------- |
|
||||
| Per-agent | `<workspace>/skills` | Only that agent |
|
||||
| Project-agent | `<workspace>/.agents/skills` | Only that workspace's agent |
|
||||
| Personal-agent | `~/.agents/skills` | All agents on this machine |
|
||||
| Shared managed | `~/.openclaw/skills` | All agents on this machine |
|
||||
| Extra dirs | `skills.load.extraDirs` | All agents on this machine |
|
||||
| Scope | Path | Visible to |
|
||||
| -------------- | ---------------------------- | ------------------------------ |
|
||||
| Per-agent | `<workspace>/skills` | Only that agent |
|
||||
| Project-agent | `<workspace>/.agents/skills` | Only that workspace's agent |
|
||||
| Personal-agent | `~/.agents/skills` | Agents using the default state |
|
||||
| Shared managed | `<state-dir>/skills` | All agents using that state |
|
||||
| Extra dirs | `skills.load.extraDirs` | All agents using that config |
|
||||
|
||||
When `OPENCLAW_STATE_DIR` points somewhere other than the default
|
||||
`~/.openclaw`, session skill indexes exclude home-scoped personal or
|
||||
compatibility skill roots such as `~/.agents/skills`. Workspace, project,
|
||||
bundled, extra, and state-owned managed skills continue to load normally.
|
||||
|
||||
## Agent allowlists
|
||||
|
||||
|
||||
+158
-9
@@ -350,6 +350,149 @@ For Xiaomi `mimo-v2.5-tts-voicedesign`, omit `speakerVoice` and set `style` to
|
||||
the voice-design prompt. OpenClaw sends that prompt as the TTS `user` message
|
||||
and does not send `audio.voice` for the voicedesign model.
|
||||
|
||||
### Local Speech Swift and speech-core
|
||||
|
||||
[Speech Swift](https://github.com/soniqo/speech-swift) and
|
||||
[speech-core](https://github.com/soniqo/speech-core) provide local speech
|
||||
inference across macOS, Linux, and Windows. Use the OpenAI-compatible HTTP
|
||||
provider when Speech Swift and OpenClaw run on the same Mac. Use Local CLI for
|
||||
direct executable integration on any supported host.
|
||||
|
||||
Install `ffmpeg` when a channel needs OpenClaw to convert WAV output to Opus or
|
||||
raw PCM.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="macOS HTTP">
|
||||
<Warning>
|
||||
This HTTP setup requires Speech Swift v0.0.23 or later. If Homebrew already
|
||||
installed an older version, run `brew update && brew upgrade speech` first.
|
||||
</Warning>
|
||||
|
||||
Start Speech Swift's local server:
|
||||
|
||||
```bash
|
||||
brew install speech
|
||||
speech-server --port 8080
|
||||
```
|
||||
|
||||
Point the OpenAI speech provider at its loopback endpoint. `responseFormat`
|
||||
must be `wav` because the local endpoint does not emit compressed audio:
|
||||
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "local",
|
||||
baseUrl: "http://127.0.0.1:8080/v1",
|
||||
model: "tts-1",
|
||||
speakerVoice: "alloy",
|
||||
responseFormat: "wav",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`tts-1` selects Kokoro. Speech Swift registry aliases such as `qwen3-tts`,
|
||||
`cosyvoice`, and `voxcpm2` select other local engines. The placeholder API key
|
||||
is required by OpenClaw's provider configuration but is not validated by the
|
||||
loopback server.
|
||||
</Tab>
|
||||
<Tab title="macOS CLI">
|
||||
The Homebrew `speech` executable can write directly to OpenClaw's temporary
|
||||
output path:
|
||||
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "tts-local-cli",
|
||||
providers: {
|
||||
"tts-local-cli": {
|
||||
command: "speech",
|
||||
args: ["speak", "{{Text}}", "--output", "{{OutputPath}}"],
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Linux CLI">
|
||||
Install a speech-core Linux release package, download the ONNX model set once,
|
||||
and verify synthesis before starting OpenClaw:
|
||||
|
||||
```bash
|
||||
speech download-models
|
||||
speech speak "Hello from OpenClaw" hello.wav
|
||||
```
|
||||
|
||||
Then configure the packaged Kokoro command:
|
||||
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "tts-local-cli",
|
||||
providers: {
|
||||
"tts-local-cli": {
|
||||
command: "speech",
|
||||
args: ["speak", "{{Text}}", "{{OutputPath}}"],
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See the [speech-core Linux CLI reference](https://github.com/soniqo/speech-core/blob/main/docs/cli.md)
|
||||
for release packages and model-directory settings.
|
||||
</Tab>
|
||||
<Tab title="Windows CLI">
|
||||
Download the speech-core Windows release, extract it, and install the ONNX
|
||||
models once:
|
||||
|
||||
```powershell
|
||||
$Version = "0.0.11"
|
||||
$Url = "https://github.com/soniqo/speech-core/releases/download/v$Version/speech-$Version-windows-x64.zip"
|
||||
Invoke-WebRequest $Url -OutFile speech.zip
|
||||
Expand-Archive speech.zip
|
||||
Set-Location "speech\speech-$Version-windows-x64\bin"
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
.\speech_download_models.ps1
|
||||
```
|
||||
|
||||
Then point Local CLI at the packaged Kokoro executable:
|
||||
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "tts-local-cli",
|
||||
providers: {
|
||||
"tts-local-cli": {
|
||||
command: "C:\\path\\to\\speech-0.0.11-windows-x64\\bin\\speech_synthesize.exe",
|
||||
args: ["{{OutputPath}}", "{{Text}}", "en"],
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See the [speech-core Windows CLI reference](https://github.com/soniqo/speech-core/blob/main/docs/cli.md)
|
||||
for the packaged server, model cache, and standalone command syntax.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Per-agent voice overrides
|
||||
|
||||
Use `agents.entries.*.tts` when one agent should speak with a different provider,
|
||||
@@ -673,7 +816,10 @@ Per-provider notes:
|
||||
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
|
||||
- When no explicit voice override is set and the default English voice is used, OpenClaw auto-switches to a Chinese neural voice (`zh-CN-XiaoxiaoNeural`, `zh-CN` locale) if the reply text is CJK-dominant.
|
||||
|
||||
OpenAI and ElevenLabs output formats are fixed per channel as listed above.
|
||||
OpenAI and ElevenLabs choose output formats per channel as listed above. An
|
||||
explicit OpenAI `responseFormat` overrides that selection; a format that is not
|
||||
voice-note compatible may be delivered as an audio file or transcoded by a
|
||||
channel that supports conversion.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -849,6 +995,7 @@ and resolved values still fail startup or reject the update.
|
||||
<ParamField path="model" type="string">OpenAI TTS model id. Default `gpt-4o-mini-tts`.</ParamField>
|
||||
<ParamField path="speakerVoice" type="string">Voice name (e.g. `alloy`, `cedar`). Default `coral`. Legacy alias: `voice`.</ParamField>
|
||||
<ParamField path="instructions" type="string">Explicit OpenAI `instructions` field. When set, persona prompt fields are **not** auto-mapped.</ParamField>
|
||||
<ParamField path="responseFormat" type='"mp3" | "opus" | "wav"'>Explicit response format. When omitted, OpenClaw selects Opus for voice-note targets and MP3 otherwise. Use `wav` for compatible local endpoints that do not encode compressed audio.</ParamField>
|
||||
<ParamField path="extraBody / extra_body" type="Record<string, unknown>">Extra JSON fields merged into `/audio/speech` request bodies after generated OpenAI TTS fields. Use this for OpenAI-compatible endpoints such as Kokoro that require provider-specific keys like `lang`; unsafe prototype keys are ignored.</ParamField>
|
||||
<ParamField path="baseUrl" type="string">
|
||||
Override the OpenAI TTS endpoint. Resolution order: config → `OPENAI_TTS_BASE_URL` → `https://api.openai.com/v1`. Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted, and `speed` loses its `0.25..4.0` range check.
|
||||
@@ -926,20 +1073,22 @@ provider default.
|
||||
|
||||
## Service links
|
||||
|
||||
- [OpenAI text-to-speech guide](https://platform.openai.com/docs/guides/text-to-speech)
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [Azure Speech REST text-to-speech](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech)
|
||||
- [Azure Speech provider](/providers/azure-speech)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [Azure Speech REST text-to-speech](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech)
|
||||
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [Gradium](/providers/gradium)
|
||||
- [Inworld TTS API](https://docs.inworld.ai/tts/tts)
|
||||
- [MiniMax T2A v2 API](https://platform.minimaxi.com/document/T2A%20V2)
|
||||
- [Volcengine TTS HTTP API](/providers/volcengine#text-to-speech)
|
||||
- [Xiaomi MiMo speech synthesis](/providers/xiaomi#text-to-speech)
|
||||
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
|
||||
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
|
||||
- [MiniMax T2A v2 API](https://platform.minimaxi.com/document/T2A%20V2)
|
||||
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [OpenAI text-to-speech guide](https://platform.openai.com/docs/guides/text-to-speech)
|
||||
- [speech-core](https://github.com/soniqo/speech-core)
|
||||
- [Speech Swift](https://github.com/soniqo/speech-swift)
|
||||
- [Volcengine TTS HTTP API](/providers/volcengine#text-to-speech)
|
||||
- [xAI text to speech](https://docs.x.ai/developers/rest-api-reference/inference/voice#text-to-speech-rest)
|
||||
- [Xiaomi MiMo speech synthesis](/providers/xiaomi#text-to-speech)
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ const CLIENT_ID = GATEWAY_CLIENT_IDS.BROWSER_COPILOT;
|
||||
const CLIENT_MODE = GATEWAY_CLIENT_MODES.UI;
|
||||
const ROLE = "operator";
|
||||
const SCOPES = ["operator.read", "operator.write"];
|
||||
// Keep browser opening bounded by the Gateway's default preauth deadline.
|
||||
const COPILOT_GATEWAY_OPENING_TIMEOUT_MS = 15_000;
|
||||
export function isDefinitiveGatewayRejection(error) {
|
||||
return error instanceof GatewayProtocolRequestError;
|
||||
}
|
||||
@@ -57,14 +59,56 @@ export async function waitForCopilotGatewayReady(client, gatewayScope) {
|
||||
|
||||
function createBrowserSocket(url, handlers, WebSocketImpl) {
|
||||
const socket = new WebSocketImpl(url);
|
||||
socket.addEventListener("open", handlers.open);
|
||||
let opening = true;
|
||||
let openingTimedOut = false;
|
||||
let openingTimer;
|
||||
const finishOpening = () => {
|
||||
opening = false;
|
||||
if (openingTimer !== undefined) {
|
||||
clearTimeout(openingTimer);
|
||||
openingTimer = undefined;
|
||||
}
|
||||
};
|
||||
socket.addEventListener("open", () => {
|
||||
finishOpening();
|
||||
handlers.open();
|
||||
});
|
||||
socket.addEventListener("message", (event) => handlers.message(String(event.data)));
|
||||
socket.addEventListener("close", (event) => handlers.close(event.code, event.reason));
|
||||
socket.addEventListener("error", () => handlers.error(new Error("Gateway WebSocket error")));
|
||||
socket.addEventListener("close", (event) => {
|
||||
finishOpening();
|
||||
handlers.close(event.code, event.reason ?? "");
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
finishOpening();
|
||||
if (!openingTimedOut) {
|
||||
handlers.error(new Error("Gateway WebSocket error"));
|
||||
}
|
||||
});
|
||||
openingTimer = setTimeout(() => {
|
||||
openingTimer = undefined;
|
||||
if (!opening) {
|
||||
return;
|
||||
}
|
||||
opening = false;
|
||||
openingTimedOut = true;
|
||||
try {
|
||||
handlers.error(
|
||||
new Error(
|
||||
`Gateway WebSocket opening timed out after ${COPILOT_GATEWAY_OPENING_TIMEOUT_MS}ms`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}, COPILOT_GATEWAY_OPENING_TIMEOUT_MS);
|
||||
return {
|
||||
isOpen: () => socket.readyState === WebSocketImpl.OPEN,
|
||||
send: (data) => socket.send(data),
|
||||
close: (code, reason) => socket.close(code, reason),
|
||||
close: (code, reason) => {
|
||||
finishOpening();
|
||||
// Browsers reject client-initiated policy close 1008; 4008 is wire-safe.
|
||||
socket.close(code === 1008 ? 4008 : code, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,14 +54,19 @@ function controllableStorageArea() {
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
static autoOpen = true;
|
||||
|
||||
readyState = 0;
|
||||
sent: Array<Record<string, unknown>> = [];
|
||||
closeCalls: Array<{ code: number; reason: string }> = [];
|
||||
private listeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
|
||||
|
||||
constructor() {
|
||||
FakeWebSocket.instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (!FakeWebSocket.autoOpen || this.readyState === 3) {
|
||||
return;
|
||||
}
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.emit("open", {});
|
||||
});
|
||||
@@ -78,9 +83,13 @@ class FakeWebSocket {
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "") {
|
||||
if (code !== 1000 && (code < 3000 || code > 4999)) {
|
||||
throw new DOMException("Invalid WebSocket close code", "InvalidAccessError");
|
||||
}
|
||||
if (this.readyState === 3) {
|
||||
return;
|
||||
}
|
||||
this.closeCalls.push({ code, reason });
|
||||
this.readyState = 3;
|
||||
queueMicrotask(() => this.emit("close", { code, reason }));
|
||||
}
|
||||
@@ -307,6 +316,78 @@ describe("browser copilot Gateway custody", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reconnects after a malformed challenge with a browser-valid policy close", async () => {
|
||||
vi.useFakeTimers();
|
||||
FakeWebSocket.instances = [];
|
||||
FakeWebSocket.autoOpen = true;
|
||||
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
|
||||
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
|
||||
const client = new CopilotGatewayClient({
|
||||
storage: storageArea(),
|
||||
WebSocketImpl: FakeWebSocket as never,
|
||||
});
|
||||
|
||||
try {
|
||||
client.start("ws://127.0.0.1:28789/");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const first = FakeWebSocket.instances[0];
|
||||
expect(first).toBeDefined();
|
||||
|
||||
first?.message({ type: "event", event: "connect.challenge", payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(first?.closeCalls).toContainEqual({
|
||||
code: 4008,
|
||||
reason: "connect challenge missing nonce",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
} finally {
|
||||
client.stop();
|
||||
FakeWebSocket.autoOpen = true;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes and reconnects when the browser socket never opens", async () => {
|
||||
vi.useFakeTimers();
|
||||
FakeWebSocket.instances = [];
|
||||
FakeWebSocket.autoOpen = false;
|
||||
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
|
||||
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
|
||||
const client = new CopilotGatewayClient({
|
||||
storage: storageArea(),
|
||||
WebSocketImpl: FakeWebSocket as never,
|
||||
});
|
||||
const statuses: Array<Record<string, unknown>> = [];
|
||||
client.onStatus((status) => {
|
||||
statuses.push(status);
|
||||
});
|
||||
|
||||
try {
|
||||
client.start("ws://127.0.0.1:28789/");
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
expect(statuses).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: "error",
|
||||
label: "Gateway WebSocket opening timed out after 15000ms",
|
||||
}),
|
||||
);
|
||||
expect(FakeWebSocket.instances[0]?.closeCalls).toEqual([{ code: 1000, reason: "" }]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
} finally {
|
||||
client.stop();
|
||||
FakeWebSocket.autoOpen = true;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("distinguishes server rejection from ambiguous transport failure", () => {
|
||||
expect(
|
||||
isDefinitiveGatewayRejection(
|
||||
|
||||
@@ -14,10 +14,6 @@ import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state
|
||||
import { registerCodexCliMetadata } from "./cli-metadata.js";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import {
|
||||
CODEX_REALTIME_OFFER_PATH,
|
||||
configureCodexRealtimeBrowserSession,
|
||||
} from "./realtime-voice-api.js";
|
||||
import { readCodexPluginConfig } from "./src/app-server/config.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
@@ -94,42 +90,6 @@ export default definePluginEntry({
|
||||
return livePluginConfig;
|
||||
};
|
||||
const resolveCurrentPluginConfig = () => resolvePluginConfig(resolveCurrentConfig);
|
||||
if (api.registrationMode === "full") {
|
||||
const realtimeBrowserSession = configureCodexRealtimeBrowserSession({
|
||||
getConfig: resolveCurrentConfig,
|
||||
getPluginConfig: resolveCurrentPluginConfig,
|
||||
});
|
||||
api.registerHttpRoute({
|
||||
path: CODEX_REALTIME_OFFER_PATH,
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
handler: realtimeBrowserSession.handler,
|
||||
});
|
||||
api.registerService({
|
||||
id: "codex-oauth-realtime-browser-session-warmup",
|
||||
start: () => {
|
||||
void realtimeBrowserSession.warmup().catch((error: unknown) => {
|
||||
api.logger.debug?.(
|
||||
`Codex OAuth realtime warmup unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
api.lifecycle.registerRuntimeLifecycle({
|
||||
id: "codex-oauth-realtime-browser-session",
|
||||
description: "Release Codex OAuth realtime browser sessions when the plugin stops",
|
||||
cleanup: async ({ reason }) => {
|
||||
// Session cleanup must not release the process runtime. Registry
|
||||
// restart and plugin disable release this registration's lease.
|
||||
if (reason === "reset" || reason === "delete") {
|
||||
return;
|
||||
}
|
||||
await realtimeBrowserSession.cleanup();
|
||||
},
|
||||
});
|
||||
}
|
||||
let bindingStateStore: PluginStateSyncKeyedStore<StoredCodexAppServerBinding> | undefined;
|
||||
const openBindingStateStore = () =>
|
||||
(bindingStateStore ??= api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { configureCodexRealtimeBrowserSession } from "./realtime-voice-api.js";
|
||||
|
||||
const leases: Array<ReturnType<typeof configureCodexRealtimeBrowserSession>> = [];
|
||||
|
||||
const createRuntime = () => {
|
||||
const runtime = configureCodexRealtimeBrowserSession({
|
||||
getConfig: () => undefined,
|
||||
getPluginConfig: () => undefined,
|
||||
});
|
||||
leases.push(runtime);
|
||||
return runtime;
|
||||
};
|
||||
|
||||
describe("Codex realtime voice runtime artifact", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(leases.splice(0).map((runtime) => runtime.cleanup()));
|
||||
});
|
||||
|
||||
it("shares one owner runtime across registration leases", async () => {
|
||||
const first = createRuntime();
|
||||
const second = createRuntime();
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.broker).toBe(first.broker);
|
||||
|
||||
await first.cleanup();
|
||||
await second.cleanup();
|
||||
});
|
||||
|
||||
it("reads config from the newest active registration", () => {
|
||||
let firstConfigReads = 0;
|
||||
let secondConfigReads = 0;
|
||||
const first = configureCodexRealtimeBrowserSession({
|
||||
getConfig: () => {
|
||||
firstConfigReads += 1;
|
||||
return undefined;
|
||||
},
|
||||
getPluginConfig: () => undefined,
|
||||
});
|
||||
const second = configureCodexRealtimeBrowserSession({
|
||||
getConfig: () => {
|
||||
secondConfigReads += 1;
|
||||
return undefined;
|
||||
},
|
||||
getPluginConfig: () => undefined,
|
||||
});
|
||||
leases.push(first, second);
|
||||
|
||||
expect(second.broker.isConfigured()).toBe(false);
|
||||
expect(firstConfigReads).toBe(0);
|
||||
expect(secondConfigReads).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Bundled Codex realtime integration shared with the bundled OpenAI provider.
|
||||
*
|
||||
* This artifact keeps the HTTP offer route and the provider fallback on one
|
||||
* process-owned runtime without adding a general Plugin SDK registration API.
|
||||
*/
|
||||
import { createCodexRealtimeBrowserSessionBroker } from "./src/realtime-browser-session.js";
|
||||
|
||||
type CodexRealtimeBrowserSessionRuntime = ReturnType<
|
||||
typeof createCodexRealtimeBrowserSessionBroker
|
||||
>;
|
||||
type CodexRealtimeBrowserSessionParams = Parameters<
|
||||
typeof createCodexRealtimeBrowserSessionBroker
|
||||
>[0];
|
||||
|
||||
type CodexRealtimeGlobalState = {
|
||||
version: 1;
|
||||
runtime?: CodexRealtimeBrowserSessionRuntime;
|
||||
sources: Map<symbol, CodexRealtimeBrowserSessionParams>;
|
||||
};
|
||||
|
||||
const CODEX_REALTIME_GLOBAL_STATE = Symbol.for("openclaw.codex.realtime-voice.v1");
|
||||
|
||||
function getGlobalState(): CodexRealtimeGlobalState {
|
||||
const root = globalThis as typeof globalThis & {
|
||||
[CODEX_REALTIME_GLOBAL_STATE]?: CodexRealtimeGlobalState;
|
||||
};
|
||||
const state = (root[CODEX_REALTIME_GLOBAL_STATE] ??= {
|
||||
version: 1,
|
||||
sources: new Map(),
|
||||
});
|
||||
state.sources ??= new Map();
|
||||
return state;
|
||||
}
|
||||
|
||||
export function configureCodexRealtimeBrowserSession(
|
||||
params: CodexRealtimeBrowserSessionParams,
|
||||
): CodexRealtimeBrowserSessionRuntime {
|
||||
const state = getGlobalState();
|
||||
const leaseId = Symbol("codex-realtime-registration");
|
||||
state.sources.set(leaseId, params);
|
||||
if (state.runtime) {
|
||||
return createRuntimeLease(state, state.runtime, leaseId);
|
||||
}
|
||||
const resolveCurrentSource = (): CodexRealtimeBrowserSessionParams | undefined =>
|
||||
Array.from(state.sources.values()).at(-1);
|
||||
const created = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => resolveCurrentSource()?.getConfig(),
|
||||
getPluginConfig: () => resolveCurrentSource()?.getPluginConfig(),
|
||||
});
|
||||
state.runtime = created;
|
||||
return createRuntimeLease(state, created, leaseId);
|
||||
}
|
||||
|
||||
function createRuntimeLease(
|
||||
state: CodexRealtimeGlobalState,
|
||||
runtime: CodexRealtimeBrowserSessionRuntime,
|
||||
leaseId: symbol,
|
||||
): CodexRealtimeBrowserSessionRuntime {
|
||||
let released = false;
|
||||
return {
|
||||
...runtime,
|
||||
cleanup: async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
state.sources.delete(leaseId);
|
||||
if (state.runtime !== runtime || state.sources.size > 0) {
|
||||
return;
|
||||
}
|
||||
state.runtime = undefined;
|
||||
await runtime.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { CODEX_REALTIME_OFFER_PATH } from "./src/realtime-browser-session.js";
|
||||
@@ -5,9 +5,8 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
|
||||
import {
|
||||
DELIVERY_NO_REPLY_RUNTIME_CONTRACT,
|
||||
installSessionManagerFileCompat,
|
||||
openFileBackedSessionManagerForTest,
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CodexAppServerEventProjector } from "./event-projector.js";
|
||||
@@ -17,15 +16,13 @@ const THREAD_ID = "thread-delivery-contract";
|
||||
const TURN_ID = "turn-delivery-contract";
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
|
||||
|
||||
async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.openFile(sessionFile);
|
||||
openFileBackedSessionManagerForTest(sessionFile);
|
||||
return {
|
||||
prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt,
|
||||
sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId,
|
||||
|
||||
@@ -282,6 +282,55 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
expectNoNamespace(specs.find((tool) => tool.name === "message"));
|
||||
});
|
||||
|
||||
it("keeps model-visible tools stable when plugin discovery order changes", () => {
|
||||
const tools = [
|
||||
createTool({ name: "web_search" }),
|
||||
createTool({ name: "sessions_yield" }),
|
||||
createTool({ name: "message" }),
|
||||
createTool({ name: "computer", catalogMode: "direct-only" }),
|
||||
createTool({ name: "agents_list" }),
|
||||
createTool({ name: "browser", catalogMode: "direct-only" }),
|
||||
createTool({ name: "openclaw" }),
|
||||
];
|
||||
const createBridge = (orderedTools: AnyAgentTool[]) =>
|
||||
createCodexDynamicToolBridge({
|
||||
tools: orderedTools,
|
||||
registeredTools: orderedTools,
|
||||
signal: new AbortController().signal,
|
||||
directToolNames: ["openclaw"],
|
||||
});
|
||||
const forward = createBridge(tools);
|
||||
const reversed = createBridge(tools.toReversed());
|
||||
|
||||
expect(forward.availableSpecs).toEqual(reversed.availableSpecs);
|
||||
expect(forward.specs).toEqual(reversed.specs);
|
||||
expect(specNames(forward.specs)).toEqual([
|
||||
"agents_list",
|
||||
"openclaw",
|
||||
"sessions_yield",
|
||||
"message",
|
||||
"web_search",
|
||||
"browser",
|
||||
"computer",
|
||||
]);
|
||||
expect(forward.specs.filter((spec) => spec.type === "namespace")).toEqual([
|
||||
expect.objectContaining({
|
||||
name: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
tools: [
|
||||
expect.objectContaining({ name: "message", deferLoading: true }),
|
||||
expect.objectContaining({ name: "web_search", deferLoading: true }),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: CODEX_OPENCLAW_DIRECT_DYNAMIC_TOOL_NAMESPACE,
|
||||
tools: [
|
||||
expect.objectContaining({ name: "browser" }),
|
||||
expect.objectContaining({ name: "computer" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("can register a durable tool schema while denying execution for the current turn", async () => {
|
||||
const heartbeatExecute = vi.fn(async () => textToolResult("heartbeat recorded"));
|
||||
const onAgentToolResult = vi.fn();
|
||||
@@ -297,7 +346,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
});
|
||||
|
||||
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
|
||||
expect(specNames(bridge.specs)).toEqual(["message", HEARTBEAT_RESPONSE_TOOL_NAME]);
|
||||
expect(specNames(bridge.specs)).toEqual([HEARTBEAT_RESPONSE_TOOL_NAME, "message"]);
|
||||
|
||||
const result = await bridge.handleToolCall(
|
||||
{
|
||||
|
||||
@@ -963,7 +963,13 @@ function createCodexDynamicToolSpecs(params: {
|
||||
const specs: CodexDynamicToolSpec[] = [];
|
||||
const namespaceTools: CodexDynamicToolFunctionSpec[] = [];
|
||||
const directOnlyNamespaceTools: CodexDynamicToolFunctionSpec[] = [];
|
||||
for (const entry of params.entries) {
|
||||
// Codex reuses its incremental websocket request only when the complete
|
||||
// searchable surface is unchanged. Direct mode retains its compatibility order.
|
||||
const entries =
|
||||
params.loading === "direct"
|
||||
? params.entries
|
||||
: params.entries.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const functionSpec = createCodexDynamicToolFunctionSpec({ entry });
|
||||
if (entry.name === "openclaw" && params.directToolNames.has(entry.name)) {
|
||||
// OpenClaw is ring-zero and its whole turn surface. Keep its canonical
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
inferToolMetaFromArgs,
|
||||
resetAgentEventsForTest,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { openFileBackedSessionManagerForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
@@ -25,8 +25,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerEventProjector } from "./event-projector.js";
|
||||
import { createCodexTestModel, createCodexTestToolTerminalObserver } from "./test-support.js";
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
export { readAttemptTerminal } from "./attempt-terminal.test-helper.js";
|
||||
|
||||
export {
|
||||
@@ -93,7 +91,9 @@ export async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-projector-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.openFile(sessionFile).appendMessage(assistantMessage("history", Date.now()));
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("history", Date.now()),
|
||||
);
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createTempHomeEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
applyCodexNativeSkillIsolation,
|
||||
resolveCodexNativeSkillIsolation,
|
||||
} from "./native-skill-isolation.js";
|
||||
|
||||
it("disables native user-scope skills only for non-default state directories", async () => {
|
||||
const tempHome = await createTempHomeEnv("openclaw-codex-native-skills-");
|
||||
try {
|
||||
// macOS exposes os.tmpdir() through /var while real paths use /private/var.
|
||||
const home = await fs.realpath(tempHome.home);
|
||||
const workspace = path.join(home, "workspace");
|
||||
const personalSkill = path.join(home, ".claude", "skills", "personal", "SKILL.md");
|
||||
const projectSkill = path.join(workspace, ".agents", "skills", "project", "SKILL.md");
|
||||
const pluginSkill = path.join(home, "plugin-cache", "skills", "plugin", "SKILL.md");
|
||||
const hiddenSkill = path.join(home, ".claude", "skills", ".git", "hidden", "SKILL.md");
|
||||
const customCodexHomeTarget = path.join(home, ".codex-work");
|
||||
const customCodexHome = path.join(home, "scratch-state", "codex-home");
|
||||
const customCodexSkill = path.join(customCodexHomeTarget, "skills", "custom", "SKILL.md");
|
||||
const stateOwnedCodexSkill = path.join(customCodexHome, "skills", "state-owned", "SKILL.md");
|
||||
const nestedSymlinkTarget = path.join(home, "nested-skill-tree");
|
||||
const nestedSymlinkSkill = path.join(nestedSymlinkTarget, "nested", "SKILL.md");
|
||||
const skillNamedDirectoryLink = path.join(home, ".agents", "skills", "linked", "SKILL.md");
|
||||
await fs.mkdir(path.dirname(personalSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(projectSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(pluginSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(hiddenSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(customCodexSkill), { recursive: true });
|
||||
await fs.mkdir(path.join(customCodexHome, "skills"), { recursive: true });
|
||||
await fs.mkdir(path.dirname(stateOwnedCodexSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(nestedSymlinkSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(skillNamedDirectoryLink), { recursive: true });
|
||||
await fs.writeFile(personalSkill, "personal");
|
||||
await fs.writeFile(projectSkill, "project");
|
||||
await fs.writeFile(pluginSkill, "plugin");
|
||||
await fs.writeFile(hiddenSkill, "hidden");
|
||||
await fs.writeFile(customCodexSkill, "custom");
|
||||
await fs.writeFile(stateOwnedCodexSkill, "state-owned");
|
||||
await fs.writeFile(nestedSymlinkSkill, "nested");
|
||||
await fs.symlink(
|
||||
path.join(customCodexHomeTarget, "skills", "custom"),
|
||||
path.join(customCodexHome, "skills", "custom"),
|
||||
"dir",
|
||||
);
|
||||
await fs.symlink(nestedSymlinkTarget, skillNamedDirectoryLink, "dir");
|
||||
const personalSkillRealPath = await fs.realpath(personalSkill);
|
||||
const projectSkillRealPath = await fs.realpath(projectSkill);
|
||||
const pluginSkillRealPath = await fs.realpath(pluginSkill);
|
||||
const hiddenSkillRealPath = await fs.realpath(hiddenSkill);
|
||||
const customCodexSkillRealPath = await fs.realpath(customCodexSkill);
|
||||
const stateOwnedCodexSkillRealPath = await fs.realpath(stateOwnedCodexSkill);
|
||||
const nestedSymlinkSkillRealPath = await fs.realpath(nestedSymlinkSkill);
|
||||
const request = vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
cwd: workspace,
|
||||
errors: [],
|
||||
skills: [
|
||||
{
|
||||
name: "personal",
|
||||
description: "Personal",
|
||||
path: personalSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "project",
|
||||
description: "Project",
|
||||
path: projectSkillRealPath,
|
||||
scope: "repo" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "plugin",
|
||||
description: "Plugin",
|
||||
path: pluginSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
path: hiddenSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "custom",
|
||||
description: "Custom Codex home",
|
||||
path: customCodexSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "state-owned",
|
||||
description: "State-owned Codex home",
|
||||
path: stateOwnedCodexSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "nested",
|
||||
description: "Nested through a SKILL.md directory symlink",
|
||||
path: nestedSymlinkSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
|
||||
await withEnvAsync(
|
||||
{ HOME: home, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") },
|
||||
async () => {
|
||||
await expect(
|
||||
resolveCodexNativeSkillIsolation({ client, codexHome: customCodexHome, cwd: workspace }),
|
||||
).resolves.toBe(undefined);
|
||||
},
|
||||
);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
|
||||
const isolation = await withEnvAsync(
|
||||
{
|
||||
HOME: path.join(home, "gateway-home"),
|
||||
OPENCLAW_STATE_DIR: path.join(home, "scratch-state"),
|
||||
},
|
||||
async () =>
|
||||
await resolveCodexNativeSkillIsolation({
|
||||
client,
|
||||
codexHome: customCodexHome,
|
||||
cwd: workspace,
|
||||
home,
|
||||
}),
|
||||
);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"skills/list",
|
||||
{ cwds: [workspace], forceReload: true },
|
||||
{ signal: undefined },
|
||||
);
|
||||
expect(
|
||||
applyCodexNativeSkillIsolation(
|
||||
{ "skills.config": [{ path: projectSkillRealPath, enabled: true }] },
|
||||
isolation,
|
||||
),
|
||||
).toMatchObject({
|
||||
"skills.include_instructions": false,
|
||||
"skills.config": [
|
||||
{ path: projectSkillRealPath, enabled: true },
|
||||
{ path: personalSkillRealPath, enabled: false },
|
||||
{ path: customCodexSkillRealPath, enabled: false },
|
||||
{ path: nestedSymlinkSkillRealPath, enabled: false },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await tempHome.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"fails closed to all native user skills when a personal root is unreadable",
|
||||
async () => {
|
||||
const tempHome = await createTempHomeEnv("openclaw-codex-native-skills-fail-closed-");
|
||||
try {
|
||||
const home = await fs.realpath(tempHome.home);
|
||||
const skillsDir = path.join(home, ".claude", "skills");
|
||||
const personalSkill = path.join(skillsDir, "personal", "SKILL.md");
|
||||
const outsideSkill = path.join(home, "plugin-cache", "outside", "SKILL.md");
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(personalSkill), { recursive: true });
|
||||
await fs.mkdir(path.dirname(outsideSkill), { recursive: true });
|
||||
await fs.writeFile(personalSkill, "personal");
|
||||
await fs.writeFile(outsideSkill, "outside");
|
||||
await fs.symlink(path.join(skillsDir, "loop"), path.join(skillsDir, "loop"));
|
||||
const personalSkillRealPath = await fs.realpath(personalSkill);
|
||||
const outsideSkillRealPath = await fs.realpath(outsideSkill);
|
||||
const client = {
|
||||
request: vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
cwd: home,
|
||||
errors: [],
|
||||
skills: [
|
||||
{
|
||||
name: "outside",
|
||||
description: "Outside",
|
||||
path: outsideSkillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
} as unknown as CodexAppServerClient;
|
||||
|
||||
const isolation = await withEnvAsync(
|
||||
{ HOME: home, OPENCLAW_STATE_DIR: path.join(home, "scratch-state") },
|
||||
async () => await resolveCodexNativeSkillIsolation({ client, cwd: home }),
|
||||
);
|
||||
expect(isolation?.disabledUserSkillPaths).toEqual([
|
||||
personalSkillRealPath,
|
||||
outsideSkillRealPath,
|
||||
]);
|
||||
} finally {
|
||||
await tempHome.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("captures a personal skill created during the authoritative Codex reload", async () => {
|
||||
const tempHome = await createTempHomeEnv("openclaw-codex-native-skills-reload-race-");
|
||||
try {
|
||||
const home = await fs.realpath(tempHome.home);
|
||||
const skillPath = path.join(home, ".claude", "skills", "late", "SKILL.md");
|
||||
const client = {
|
||||
request: vi.fn(async () => {
|
||||
await fs.mkdir(path.dirname(skillPath), { recursive: true });
|
||||
await fs.writeFile(skillPath, "late");
|
||||
return { data: [{ cwd: home, errors: [], skills: [] }] };
|
||||
}),
|
||||
} as unknown as CodexAppServerClient;
|
||||
|
||||
const isolation = await withEnvAsync(
|
||||
{ HOME: home, OPENCLAW_STATE_DIR: path.join(home, "scratch-state") },
|
||||
async () => await resolveCodexNativeSkillIsolation({ client, cwd: home }),
|
||||
);
|
||||
expect(isolation?.disabledUserSkillPaths).toEqual([await fs.realpath(skillPath)]);
|
||||
} finally {
|
||||
await tempHome.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves direct skills under a state-owned default Codex home", async () => {
|
||||
const tempHome = await createTempHomeEnv("openclaw-codex-native-state-home-");
|
||||
try {
|
||||
const stateHome = await fs.realpath(tempHome.home);
|
||||
const skillPath = path.join(stateHome, ".codex", "skills", "state-owned", "SKILL.md");
|
||||
await fs.mkdir(path.dirname(skillPath), { recursive: true });
|
||||
await fs.writeFile(skillPath, "state-owned");
|
||||
const skillRealPath = await fs.realpath(skillPath);
|
||||
const client = {
|
||||
request: vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
cwd: stateHome,
|
||||
errors: [],
|
||||
skills: [
|
||||
{
|
||||
name: "state-owned",
|
||||
description: "State owned",
|
||||
path: skillRealPath,
|
||||
scope: "user" as const,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
} as unknown as CodexAppServerClient;
|
||||
|
||||
const isolation = await withEnvAsync(
|
||||
{ HOME: stateHome, OPENCLAW_STATE_DIR: stateHome },
|
||||
async () => await resolveCodexNativeSkillIsolation({ client, cwd: stateHome }),
|
||||
);
|
||||
expect(isolation?.disabledUserSkillPaths).toEqual([]);
|
||||
} finally {
|
||||
await tempHome.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveRequiredHomeDir, resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { JsonObject, JsonValue } from "./protocol.js";
|
||||
|
||||
export type CodexNativeSkillIsolation = {
|
||||
disabledUserSkillPaths: string[];
|
||||
};
|
||||
|
||||
const MAX_PERSONAL_SKILL_DIRECTORIES = 2_000;
|
||||
const MAX_PERSONAL_SKILL_DEPTH = 6;
|
||||
const MAX_PERSONAL_SKILL_ENTRIES = 10_000;
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
}
|
||||
|
||||
function isPathWithin(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
||||
return (
|
||||
relative === "" ||
|
||||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
|
||||
);
|
||||
}
|
||||
|
||||
async function canonicalizeExistingPath(candidate: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(candidate);
|
||||
} catch {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
async function usesDefaultStateDir(): Promise<boolean> {
|
||||
if (!process.env.OPENCLAW_STATE_DIR?.trim()) {
|
||||
return true;
|
||||
}
|
||||
const home = resolveRequiredHomeDir();
|
||||
const [stateDir, defaultStateDir] = await Promise.all([
|
||||
canonicalizeExistingPath(resolveStateDir()),
|
||||
canonicalizeExistingPath(path.join(home, ".openclaw")),
|
||||
]);
|
||||
return stateDir === defaultStateDir;
|
||||
}
|
||||
|
||||
async function collectPersonalSkillRealPaths(
|
||||
homes: string[],
|
||||
codexHome: string | undefined,
|
||||
): Promise<{ complete: boolean; skillPaths: Set<string> }> {
|
||||
const realStateDir = await canonicalizeExistingPath(resolveStateDir());
|
||||
const roots: Array<{ dir: string; onlyEscapedStateTargets: boolean }> = [];
|
||||
for (const home of homes) {
|
||||
for (const dir of [".agents", ".claude"]) {
|
||||
roots.push({
|
||||
dir: path.join(home, dir, "skills"),
|
||||
onlyEscapedStateTargets: false,
|
||||
});
|
||||
}
|
||||
const defaultCodexHome = path.join(home, ".codex");
|
||||
const realDefaultCodexHome = await canonicalizeExistingPath(defaultCodexHome);
|
||||
roots.push({
|
||||
dir: path.join(defaultCodexHome, "skills"),
|
||||
onlyEscapedStateTargets: isPathWithin(realStateDir, realDefaultCodexHome),
|
||||
});
|
||||
}
|
||||
const configuredCodexHome = codexHome?.trim() || process.env.CODEX_HOME?.trim();
|
||||
if (configuredCodexHome) {
|
||||
const realCodexHome = await canonicalizeExistingPath(configuredCodexHome);
|
||||
const stateOwned = isPathWithin(realStateDir, realCodexHome);
|
||||
roots.push({
|
||||
dir: path.join(configuredCodexHome, "skills"),
|
||||
// Direct descendants of a state-owned Codex home belong to this isolated instance.
|
||||
// Only realpath escapes cross back into operator-home state and must be disabled.
|
||||
onlyEscapedStateTargets: stateOwned,
|
||||
});
|
||||
}
|
||||
const skillPaths = new Set<string>();
|
||||
let complete = true;
|
||||
const seenDirectories = new Set<string>();
|
||||
const queue = roots.map((root) => ({
|
||||
dir: root.dir,
|
||||
onlyEscapedStateTargets: root.onlyEscapedStateTargets,
|
||||
depth: 0,
|
||||
}));
|
||||
let entryCount = 0;
|
||||
const recordSkillFile = async (filePath: string, onlyEscapedStateTargets: boolean) => {
|
||||
try {
|
||||
const skillRealPath = await fs.realpath(filePath);
|
||||
if (!onlyEscapedStateTargets || !isPathWithin(realStateDir, skillRealPath)) {
|
||||
skillPaths.add(skillRealPath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
let realDir: string;
|
||||
try {
|
||||
realDir = await fs.realpath(current.dir);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
continue;
|
||||
}
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
if (seenDirectories.has(realDir)) {
|
||||
continue;
|
||||
}
|
||||
seenDirectories.add(realDir);
|
||||
if (seenDirectories.size > MAX_PERSONAL_SKILL_DIRECTORIES) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let directory: Awaited<ReturnType<typeof fs.opendir>>;
|
||||
try {
|
||||
directory = await fs.opendir(current.dir);
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
complete = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for await (const entry of directory) {
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_PERSONAL_SKILL_ENTRIES) {
|
||||
complete = false;
|
||||
queue.length = 0;
|
||||
break;
|
||||
}
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const entryPath = path.join(current.dir, entry.name);
|
||||
if (entry.name === "SKILL.md" && entry.isFile()) {
|
||||
await recordSkillFile(entryPath, current.onlyEscapedStateTargets);
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const stat = await fs.stat(entryPath);
|
||||
if (entry.name === "SKILL.md" && stat.isFile()) {
|
||||
await recordSkillFile(entryPath, current.onlyEscapedStateTargets);
|
||||
} else if (stat.isDirectory()) {
|
||||
if (current.depth < MAX_PERSONAL_SKILL_DEPTH) {
|
||||
queue.push({
|
||||
dir: entryPath,
|
||||
depth: current.depth + 1,
|
||||
onlyEscapedStateTargets: current.onlyEscapedStateTargets,
|
||||
});
|
||||
} else {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.depth >= MAX_PERSONAL_SKILL_DEPTH) {
|
||||
if (entry.isDirectory()) {
|
||||
complete = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
queue.push({
|
||||
dir: entryPath,
|
||||
depth: current.depth + 1,
|
||||
onlyEscapedStateTargets: current.onlyEscapedStateTargets,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { complete, skillPaths };
|
||||
}
|
||||
|
||||
/** Resolves the native user-scope skills that an isolated OpenClaw thread must disable. */
|
||||
export async function resolveCodexNativeSkillIsolation(params: {
|
||||
client: CodexAppServerClient;
|
||||
codexHome?: string;
|
||||
cwd: string;
|
||||
home?: string;
|
||||
userProfile?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CodexNativeSkillIsolation | undefined> {
|
||||
if (await usesDefaultStateDir()) {
|
||||
return undefined;
|
||||
}
|
||||
const response = await params.client.request(
|
||||
"skills/list",
|
||||
{ cwds: [params.cwd], forceReload: true },
|
||||
{ signal: params.signal },
|
||||
);
|
||||
const effectiveHome =
|
||||
params.home?.trim() ||
|
||||
process.env.HOME?.trim() ||
|
||||
process.env.USERPROFILE?.trim() ||
|
||||
os.homedir();
|
||||
const homes = [effectiveHome];
|
||||
if (process.platform === "win32") {
|
||||
homes.push(params.userProfile?.trim() || os.homedir());
|
||||
}
|
||||
const personalSkills = await collectPersonalSkillRealPaths(
|
||||
[...new Set(homes.map((home) => path.resolve(home)))],
|
||||
params.codexHome,
|
||||
);
|
||||
const disabledUserSkillPaths = [
|
||||
// Codex also labels explicit plugin and extra roots as user scope. Preserve those on a
|
||||
// complete provenance scan; fall back to all user paths only when personal-root proof failed.
|
||||
...(personalSkills.complete
|
||||
? personalSkills.skillPaths
|
||||
: new Set([
|
||||
...personalSkills.skillPaths,
|
||||
...response.data.flatMap((entry) =>
|
||||
entry.skills.filter((skill) => skill.scope === "user").map((skill) => skill.path),
|
||||
),
|
||||
])),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
return { disabledUserSkillPaths };
|
||||
}
|
||||
|
||||
/** Applies path-exact session rules after caller config so isolated user skills stay disabled. */
|
||||
export function applyCodexNativeSkillIsolation(
|
||||
config: JsonObject | undefined,
|
||||
isolation: CodexNativeSkillIsolation | undefined,
|
||||
): JsonObject | undefined {
|
||||
if (!isolation) {
|
||||
return config;
|
||||
}
|
||||
const existingRules = config?.["skills.config"];
|
||||
if (existingRules !== undefined && !Array.isArray(existingRules)) {
|
||||
throw new Error("Codex thread skills.config must be an array");
|
||||
}
|
||||
const disabledRules: JsonValue[] = isolation.disabledUserSkillPaths.map((skillPath) => ({
|
||||
path: skillPath,
|
||||
enabled: false,
|
||||
}));
|
||||
return {
|
||||
...config,
|
||||
"skills.include_instructions": false,
|
||||
"skills.config": [...(existingRules ?? []), ...disabledRules],
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,9 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness
|
||||
import { classifyEmbeddedAgentRunResultForModelFallback } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
createContractRunResult,
|
||||
installSessionManagerFileCompat,
|
||||
openFileBackedSessionManagerForTest,
|
||||
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readAttemptTerminal } from "./attempt-terminal.test-helper.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
@@ -21,8 +20,6 @@ const THREAD_ID = "thread-outcome-contract";
|
||||
const TURN_ID = "turn-outcome-contract";
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
|
||||
type ProjectedAttemptResult = ReturnType<CodexAppServerEventProjector["buildResult"]>;
|
||||
type CodexAppServerToolTelemetry = Parameters<CodexAppServerEventProjector["buildResult"]>[0];
|
||||
@@ -32,7 +29,7 @@ async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.openFile(sessionFile);
|
||||
openFileBackedSessionManagerForTest(sessionFile);
|
||||
return {
|
||||
prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt,
|
||||
sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
supportsModelTools,
|
||||
type HarnessContextEngine as ContextEngine,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { openFileBackedSessionManagerForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
@@ -40,8 +40,6 @@ import {
|
||||
|
||||
const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexTestAppServerClientFactory | undefined;
|
||||
|
||||
@@ -470,7 +468,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("bootstraps and assembles non-legacy context before the Codex turn starts", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()) as never,
|
||||
);
|
||||
const openSpy = vi.spyOn(SessionManager, "open");
|
||||
@@ -548,7 +546,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("keeps context-engine history bound to the run session when sandbox key differs", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("canonical main context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine();
|
||||
@@ -739,7 +737,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("bootstrap-only context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
@@ -991,7 +989,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("previous stale-bootstrap request", Date.now()) as never,
|
||||
);
|
||||
@@ -1080,7 +1078,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("keeps mirrored history when an inactive per-turn context-engine binding starts fresh", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("previous per-turn request", 10) as never);
|
||||
sessionManager.appendMessage(assistantMessage("previous per-turn answer", 11) as never);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1458,7 +1456,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("retries a resumed context-engine thread on a fresh Codex thread without plugin compaction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1560,7 +1558,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("returns a replay-safe recovery result when the executable owner changes during overflow retry", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1627,7 +1625,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1699,7 +1697,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("clears a resumed context-engine binding when a turn terminally overflows", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1777,7 +1775,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not pre-compact over-budget rendered context-engine prompts before Codex turn/start", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
const hugePayload = {
|
||||
@@ -1859,7 +1857,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not call hung owning context-engine compaction during Codex overflow recovery", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1940,7 +1938,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("keeps current inbound context at the front of the Codex context-engine prompt", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("older context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine();
|
||||
@@ -2039,7 +2037,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("reloads mirrored history after bootstrap mutates the session transcript", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()) as never,
|
||||
);
|
||||
const afterTurn = vi.fn(
|
||||
@@ -2047,7 +2045,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
);
|
||||
const bootstrap = vi.fn(
|
||||
async ({ sessionFile: file }: Parameters<NonNullable<ContextEngine["bootstrap"]>>[0]) => {
|
||||
SessionManager.openFile(file).appendMessage(
|
||||
openFileBackedSessionManagerForTest(file).appendMessage(
|
||||
assistantMessage("bootstrap context", Date.now() + 1) as never,
|
||||
);
|
||||
return { bootstrapped: true };
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
resolveActiveEmbeddedRunSessionId,
|
||||
type AgentEventPayload,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { openFileBackedSessionManagerForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
waitForDiagnosticEventsDrained,
|
||||
@@ -41,8 +40,6 @@ import {
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
type ReplyBackend = Parameters<
|
||||
NonNullable<ReturnType<typeof createParams>["replyOperation"]>["attachBackend"]
|
||||
>[0];
|
||||
@@ -118,7 +115,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(assistantMessage("existing context", Date.now()));
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
@@ -314,7 +311,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
return {};
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(assistantMessage("existing context", Date.now()));
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
@@ -946,7 +943,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
openFileBackedSessionManagerForTest(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()),
|
||||
);
|
||||
createStartedThreadHarness(async (method) => {
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { replaceRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { openFileBackedSessionManagerForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
waitForDiagnosticEventsDrained,
|
||||
@@ -107,8 +106,6 @@ import {
|
||||
startOrResumeThread as startOrResumeThreadImpl,
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
const testing = {
|
||||
buildDeveloperInstructions,
|
||||
buildDynamicTools,
|
||||
@@ -2386,7 +2383,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
]),
|
||||
);
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(assistantMessage("previous turn", Date.now()));
|
||||
const harness = createStartedThreadHarness();
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
@@ -2439,7 +2436,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
it("projects bounded continuity when starting Codex without a native thread binding", async () => {
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
|
||||
@@ -2479,7 +2476,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
it("keeps large fresh-thread continuity under the Codex turn/start input limit", async () => {
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
|
||||
@@ -2537,7 +2534,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("prior visible context", Date.now()));
|
||||
sessionManager.appendMessage(assistantMessage("prior assistant context", Date.now() + 1));
|
||||
const harness = createStartedThreadHarness();
|
||||
@@ -2579,7 +2576,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt - 2_000),
|
||||
);
|
||||
@@ -2712,7 +2709,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("old native-owned context", bindingUpdatedAt - 2_000));
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000),
|
||||
@@ -2756,7 +2753,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
const codexMirrorUserMessage = {
|
||||
...userMessage("codex mirrored user echo", bindingUpdatedAt + 1_000),
|
||||
idempotencyKey: "client-run:user",
|
||||
@@ -2803,7 +2800,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
...originalBinding,
|
||||
historyCoveredThrough: new Date(originalBindingUpdatedAt).toISOString(),
|
||||
});
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
const firstHarness = createResumeHarness();
|
||||
const firstRun = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await firstHarness.waitForMethod("turn/start");
|
||||
@@ -2841,7 +2838,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
...oldBinding,
|
||||
historyCoveredThrough: new Date(oldBindingUpdatedAt).toISOString(),
|
||||
});
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000),
|
||||
);
|
||||
@@ -4684,7 +4681,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const sessionManager = openFileBackedSessionManagerForTest(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"pre-binding native-owned context: keep the original plan",
|
||||
|
||||
@@ -389,16 +389,19 @@ describe("Codex app-server binding store", () => {
|
||||
const rawUserMcpServersFingerprint = JSON.stringify({
|
||||
mcp_servers: { legacy: { command: "node" } },
|
||||
});
|
||||
const nativeSkillIsolationFingerprint = `sha256:${"b".repeat(64)}`;
|
||||
const imported = createStoredCodexAppServerBinding({
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-legacy-fingerprints",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dynamicToolsFingerprint: rawDynamicToolsFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint: rawUserMcpServersFingerprint,
|
||||
});
|
||||
expect(imported?.binding).toMatchObject({
|
||||
dynamicToolsFingerprint: hashCodexAppServerBindingFingerprint(rawDynamicToolsFingerprint),
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint: hashCodexAppServerBindingFingerprint(rawUserMcpServersFingerprint),
|
||||
});
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ const threadBindingSchema = z
|
||||
dynamicToolsFingerprint: optionalStringSchema,
|
||||
dynamicToolsContainDeferred: optionalBooleanSchema,
|
||||
webSearchThreadConfigFingerprint: optionalStringSchema,
|
||||
nativeSkillIsolationFingerprint: optionalStringSchema,
|
||||
userMcpServersFingerprint: optionalStringSchema,
|
||||
mcpServersFingerprint: optionalStringSchema,
|
||||
ringZeroConfigFingerprint: optionalStringSchema,
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
isCodexAppServerConnectionClosedError,
|
||||
resolveCodexAppServerClientInstanceId,
|
||||
} from "./client.js";
|
||||
import {
|
||||
applyCodexNativeSkillIsolation,
|
||||
type CodexNativeSkillIsolation,
|
||||
} from "./native-skill-isolation.js";
|
||||
import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js";
|
||||
import {
|
||||
buildCodexPluginAppsConfigPatchFromPolicyContext,
|
||||
@@ -60,6 +64,7 @@ type ThreadRequestContext = {
|
||||
dynamicToolsFingerprint: string;
|
||||
dynamicToolsContainDeferred: boolean;
|
||||
webSearchThreadConfigFingerprint?: string;
|
||||
nativeSkillIsolationFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
ringZeroConfigFingerprint?: string;
|
||||
ringZeroClientInstanceId?: string;
|
||||
@@ -69,6 +74,7 @@ type ThreadRequestContext = {
|
||||
hostSystemAgentActive: boolean;
|
||||
ringZeroActive: boolean;
|
||||
ringZeroInheritedMcpServerNames: string[];
|
||||
nativeSkillIsolation?: CodexNativeSkillIsolation;
|
||||
lifecycleTiming: CodexThreadLifecycleTimingTracker;
|
||||
normalizeBindingModelProvider: (
|
||||
authProfileId: string | undefined,
|
||||
@@ -118,6 +124,7 @@ export async function resumeExistingCodexThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
ringZeroClientInstanceId,
|
||||
@@ -127,6 +134,7 @@ export async function resumeExistingCodexThread(
|
||||
hostSystemAgentActive,
|
||||
ringZeroActive,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
nativeSkillIsolation,
|
||||
lifecycleTiming,
|
||||
normalizeBindingModelProvider,
|
||||
throwIfAborted,
|
||||
@@ -152,11 +160,14 @@ export async function resumeExistingCodexThread(
|
||||
params.pluginThreadConfig?.enabled && resumeBinding.pluginAppPolicyContext
|
||||
? buildCodexPluginAppsConfigPatchFromPolicyContext(resumeBinding.pluginAppPolicyContext)
|
||||
: undefined;
|
||||
const resumeConfig = mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginAppsConfigPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
const resumeConfig = applyCodexNativeSkillIsolation(
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginAppsConfigPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
),
|
||||
nativeSkillIsolation,
|
||||
);
|
||||
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
|
||||
buildThreadResumeParams(params.params, {
|
||||
@@ -242,6 +253,7 @@ export async function resumeExistingCodexThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
@@ -384,6 +396,7 @@ export async function startFreshCodexThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
ringZeroClientInstanceId,
|
||||
@@ -393,6 +406,7 @@ export async function startFreshCodexThread(
|
||||
hostSystemAgentActive,
|
||||
ringZeroActive,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
nativeSkillIsolation,
|
||||
lifecycleTiming,
|
||||
normalizeBindingModelProvider,
|
||||
throwIfAborted,
|
||||
@@ -411,11 +425,14 @@ export async function startFreshCodexThread(
|
||||
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
|
||||
};
|
||||
const config = lifecycleTiming.measureSync("merge-thread-config", () =>
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
applyCodexNativeSkillIsolation(
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
),
|
||||
nativeSkillIsolation,
|
||||
),
|
||||
);
|
||||
const startParams = lifecycleTiming.measureSync("thread-start-params", () =>
|
||||
@@ -492,6 +509,7 @@ export async function startFreshCodexThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
@@ -542,6 +560,7 @@ export async function startFreshCodexThread(
|
||||
response.modelProvider ?? requestModelProvider ?? startModelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
isHostScopedAgentToolActive,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { buildCodexUserMcpServersThreadConfigPatchForRuntime } from "openclaw/plugin-sdk/codex-mcp-projection";
|
||||
import { getCodexAppServerClientInstanceId } from "./client.js";
|
||||
import { isSystemAgentOnlyCodexDynamicToolAllowlist } from "./dynamic-tool-profile.js";
|
||||
import { resolveCodexNativeSkillIsolation } from "./native-skill-isolation.js";
|
||||
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
|
||||
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
|
||||
import { hashCodexAppServerBindingFingerprint } from "./session-binding.js";
|
||||
import { buildContextEngineBinding } from "./thread-context-engine.js";
|
||||
import {
|
||||
codexLegacyDynamicToolsFingerprint as legacyFingerprintDynamicTools,
|
||||
fingerprintEnvironmentSelection,
|
||||
fingerprintJsonObject,
|
||||
fingerprintUserMcpServersConfigPatch,
|
||||
legacyFingerprintUserMcpServersConfigPatch,
|
||||
} from "./thread-fingerprints.js";
|
||||
import { createCodexThreadLifecycleTimingTracker } from "./thread-lifecycle-timing.js";
|
||||
import type { CodexStartOrResumeThreadParams } from "./thread-lifecycle-types.js";
|
||||
import {
|
||||
assertCodexRingZeroHasNoManagedHooks,
|
||||
buildCodexRingZeroThreadConfigPatch,
|
||||
CODEX_RING_ZERO_BASE_INSTRUCTIONS,
|
||||
readCodexInheritedMcpServerNames,
|
||||
} from "./thread-requests.js";
|
||||
import { resolveCodexWebSearchPlan } from "./web-search.js";
|
||||
|
||||
export async function prepareCodexThreadLifecyclePreflight(params: CodexStartOrResumeThreadParams) {
|
||||
// Thread lifecycle spans are useful when profiling startup churn, but normal
|
||||
// turns should not pay Date.now/span-array overhead while resuming threads.
|
||||
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
|
||||
...params.timing,
|
||||
enabled: params.timing?.enabled ?? isCodexAppServerProfilerEnabled(params.params.config),
|
||||
});
|
||||
const legacyDynamicToolsFingerprint = lifecycleTiming.measureSync(
|
||||
"legacy-dynamic-tools-fingerprint",
|
||||
() => legacyFingerprintDynamicTools(params.dynamicTools),
|
||||
);
|
||||
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
|
||||
hashCodexAppServerBindingFingerprint(legacyDynamicToolsFingerprint),
|
||||
);
|
||||
const dynamicToolsContainDeferred = flattenCodexDynamicToolFunctions(params.dynamicTools).some(
|
||||
(tool) => tool.deferLoading === true,
|
||||
);
|
||||
const webSearchPlan = lifecycleTiming.measureSync("web-search-plan", () =>
|
||||
resolveCodexWebSearchPlan({
|
||||
config: params.params.config,
|
||||
disableTools: params.params.disableTools,
|
||||
nativeToolSurfaceEnabled: params.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
|
||||
webSearchAllowed: params.webSearchAllowed,
|
||||
}),
|
||||
);
|
||||
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
|
||||
const networkProxyConfigFingerprint = params.appServer.networkProxy?.configFingerprint;
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
const userMcpServersConfigPatch =
|
||||
params.userMcpServersEnabled === false
|
||||
? undefined
|
||||
: await buildCodexUserMcpServersThreadConfigPatchForRuntime(params.params.config, {
|
||||
agentId: params.agentId ?? params.params.agentId,
|
||||
agentDir: params.params.agentDir,
|
||||
allowLiteralOAuthProjection: params.appServer.connectionClass !== "remote",
|
||||
onServerUnavailable: (serverName, error) =>
|
||||
embeddedAgentLog.warn("skipping unavailable MCP OAuth server", {
|
||||
serverName,
|
||||
error: formatErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
const nativeSkillIsolation = await lifecycleTiming.measure("native-skill-isolation", () =>
|
||||
resolveCodexNativeSkillIsolation({
|
||||
client: params.client,
|
||||
codexHome: params.appServer.start.env?.CODEX_HOME,
|
||||
cwd: params.cwd,
|
||||
home: params.appServer.start.env?.HOME,
|
||||
signal: params.signal,
|
||||
userProfile: params.appServer.start.env?.USERPROFILE,
|
||||
}),
|
||||
);
|
||||
const nativeSkillIsolationFingerprint = nativeSkillIsolation
|
||||
? fingerprintJsonObject({
|
||||
version: 1,
|
||||
disabledUserSkillPaths: nativeSkillIsolation.disabledUserSkillPaths,
|
||||
})
|
||||
: undefined;
|
||||
const legacyUserMcpServersFingerprint =
|
||||
legacyFingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
|
||||
const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
|
||||
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
|
||||
params.environmentSelection,
|
||||
);
|
||||
const hostSystemAgentActive =
|
||||
params.hostSystemAgentActive ?? isHostScopedAgentToolActive("openclaw");
|
||||
const ringZeroActive =
|
||||
hostSystemAgentActive && isSystemAgentOnlyCodexDynamicToolAllowlist(params.params.toolsAllow);
|
||||
if (ringZeroActive && params.nativeCodeModeEnabled !== false) {
|
||||
throw new Error("Codex ring-zero requires native code mode to be disabled");
|
||||
}
|
||||
const ringZeroInheritedMcpServerNames = ringZeroActive
|
||||
? await lifecycleTiming.measure("ring-zero-mcp-config-read", () =>
|
||||
readCodexInheritedMcpServerNames(params.client, params.cwd, params.signal),
|
||||
)
|
||||
: [];
|
||||
if (ringZeroActive) {
|
||||
await lifecycleTiming.measure("ring-zero-config-requirements-read", () =>
|
||||
assertCodexRingZeroHasNoManagedHooks(params.client, params.signal),
|
||||
);
|
||||
}
|
||||
const ringZeroConfigFingerprint = ringZeroActive
|
||||
? fingerprintJsonObject({
|
||||
version: 1,
|
||||
baseInstructions: CODEX_RING_ZERO_BASE_INSTRUCTIONS,
|
||||
config: buildCodexRingZeroThreadConfigPatch(
|
||||
params.params,
|
||||
true,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
)!,
|
||||
})
|
||||
: undefined;
|
||||
const ringZeroClientInstanceId = ringZeroActive
|
||||
? getCodexAppServerClientInstanceId(params.client)
|
||||
: undefined;
|
||||
return {
|
||||
contextEngineBinding,
|
||||
dynamicToolsContainDeferred,
|
||||
dynamicToolsFingerprint,
|
||||
environmentSelectionFingerprint,
|
||||
hostSystemAgentActive,
|
||||
legacyDynamicToolsFingerprint,
|
||||
legacyUserMcpServersFingerprint,
|
||||
lifecycleTiming,
|
||||
nativeSkillIsolation,
|
||||
nativeSkillIsolationFingerprint,
|
||||
networkProxyConfigFingerprint,
|
||||
ringZeroActive,
|
||||
ringZeroClientInstanceId,
|
||||
ringZeroConfigFingerprint,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
userMcpServersConfigPatch,
|
||||
userMcpServersFingerprint,
|
||||
webSearchThreadConfigFingerprint,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,17 @@
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
isHostScopedAgentToolActive,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { buildCodexUserMcpServersThreadConfigPatchForRuntime } from "openclaw/plugin-sdk/codex-mcp-projection";
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { isIncognitoSessionKey } from "../incognito-session.js";
|
||||
import { closeCodexStartupClientBestEffort } from "./attempt-client-cleanup.js";
|
||||
import {
|
||||
getCodexAppServerClientInstanceId,
|
||||
resolveCodexAppServerClientInstanceId,
|
||||
} from "./client.js";
|
||||
import { isSystemAgentOnlyCodexDynamicToolAllowlist } from "./dynamic-tool-profile.js";
|
||||
import { resolveCodexAppServerClientInstanceId } from "./client.js";
|
||||
import { applyCodexNativeSkillIsolation } from "./native-skill-isolation.js";
|
||||
import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js";
|
||||
import {
|
||||
isCodexPluginThreadBindingStale,
|
||||
mergeCodexThreadConfigs,
|
||||
type CodexPluginThreadConfig,
|
||||
} from "./plugin-thread-config.js";
|
||||
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
|
||||
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
|
||||
import {
|
||||
assertCodexBindingMayBeReplaced,
|
||||
createCodexSessionGenerationSupersededError,
|
||||
hashCodexAppServerBindingFingerprint,
|
||||
normalizeCodexAppServerBindingModelProvider,
|
||||
reclaimCurrentCodexSessionGeneration,
|
||||
sessionBindingIdentity,
|
||||
@@ -36,23 +25,15 @@ import {
|
||||
shouldRotateCodexAppServerBindingForRuntime,
|
||||
shouldRotateCodexGpt56MultiAgentBinding,
|
||||
} from "./thread-binding-policy.js";
|
||||
import {
|
||||
buildContextEngineBinding,
|
||||
isContextEngineBindingCompatible,
|
||||
} from "./thread-context-engine.js";
|
||||
import { isContextEngineBindingCompatible } from "./thread-context-engine.js";
|
||||
import {
|
||||
areDynamicToolFingerprintsCompatible,
|
||||
areUserMcpServersFingerprintsCompatible,
|
||||
codexLegacyDynamicToolsFingerprint as legacyFingerprintDynamicTools,
|
||||
fingerprintEnvironmentSelection,
|
||||
fingerprintJsonObject,
|
||||
fingerprintUserMcpServersConfigPatch,
|
||||
legacyFingerprintUserMcpServersConfigPatch,
|
||||
shouldStartTransientNoToolThread,
|
||||
} from "./thread-fingerprints.js";
|
||||
import { CodexThreadBindingConflictError } from "./thread-lifecycle-errors.js";
|
||||
import { resumeExistingCodexThread, startFreshCodexThread } from "./thread-lifecycle-io.js";
|
||||
import { createCodexThreadLifecycleTimingTracker } from "./thread-lifecycle-timing.js";
|
||||
import { prepareCodexThreadLifecyclePreflight } from "./thread-lifecycle-preflight.js";
|
||||
import type {
|
||||
CodexAppServerThreadLifecycleBinding,
|
||||
CodexStartOrResumeThreadParams,
|
||||
@@ -64,14 +45,7 @@ import {
|
||||
tryReuseCodexLiveThread,
|
||||
} from "./thread-lifecycle-warm.js";
|
||||
import { resolveCodexAppServerThreadModelSelection } from "./thread-model-selection.js";
|
||||
import {
|
||||
assertCodexRingZeroHasNoManagedHooks,
|
||||
buildCodexRingZeroThreadConfigPatch,
|
||||
CODEX_RING_ZERO_BASE_INSTRUCTIONS,
|
||||
readCodexInheritedMcpServerNames,
|
||||
} from "./thread-requests.js";
|
||||
import { materializePendingSupervisionBranch } from "./thread-supervision.js";
|
||||
import { resolveCodexWebSearchPlan } from "./web-search.js";
|
||||
|
||||
export async function startOrResumeThread(
|
||||
params: CodexStartOrResumeThreadParams,
|
||||
@@ -85,87 +59,26 @@ export async function startOrResumeThread(
|
||||
config: params.params.config,
|
||||
});
|
||||
return await params.bindingStore.withLease(bindingIdentity, async () => {
|
||||
// Thread lifecycle spans are useful when profiling startup churn, but normal
|
||||
// turns should not pay Date.now/span-array overhead while resuming threads.
|
||||
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
|
||||
...params.timing,
|
||||
enabled: params.timing?.enabled ?? isCodexAppServerProfilerEnabled(params.params.config),
|
||||
});
|
||||
const legacyDynamicToolsFingerprint = lifecycleTiming.measureSync(
|
||||
"legacy-dynamic-tools-fingerprint",
|
||||
() => legacyFingerprintDynamicTools(params.dynamicTools),
|
||||
);
|
||||
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
|
||||
hashCodexAppServerBindingFingerprint(legacyDynamicToolsFingerprint),
|
||||
);
|
||||
const dynamicToolsContainDeferred = flattenCodexDynamicToolFunctions(params.dynamicTools).some(
|
||||
(tool) => tool.deferLoading === true,
|
||||
);
|
||||
const webSearchPlan = lifecycleTiming.measureSync("web-search-plan", () =>
|
||||
resolveCodexWebSearchPlan({
|
||||
config: params.params.config,
|
||||
disableTools: params.params.disableTools,
|
||||
nativeToolSurfaceEnabled: params.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
|
||||
webSearchAllowed: params.webSearchAllowed,
|
||||
}),
|
||||
);
|
||||
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
|
||||
const networkProxyConfigFingerprint = params.appServer.networkProxy?.configFingerprint;
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
const userMcpServersConfigPatch =
|
||||
params.userMcpServersEnabled === false
|
||||
? undefined
|
||||
: await buildCodexUserMcpServersThreadConfigPatchForRuntime(params.params.config, {
|
||||
agentId: params.agentId ?? params.params.agentId,
|
||||
agentDir: params.params.agentDir,
|
||||
allowLiteralOAuthProjection: params.appServer.connectionClass !== "remote",
|
||||
onServerUnavailable: (serverName, error) =>
|
||||
embeddedAgentLog.warn("skipping unavailable MCP OAuth server", {
|
||||
serverName,
|
||||
error: formatErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
const legacyUserMcpServersFingerprint =
|
||||
legacyFingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
|
||||
const userMcpServersFingerprint =
|
||||
fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
|
||||
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
|
||||
params.environmentSelection,
|
||||
);
|
||||
const hostSystemAgentActive =
|
||||
params.hostSystemAgentActive ?? isHostScopedAgentToolActive("openclaw");
|
||||
const ringZeroActive =
|
||||
hostSystemAgentActive && isSystemAgentOnlyCodexDynamicToolAllowlist(params.params.toolsAllow);
|
||||
if (ringZeroActive && params.nativeCodeModeEnabled !== false) {
|
||||
throw new Error("Codex ring-zero requires native code mode to be disabled");
|
||||
}
|
||||
const ringZeroInheritedMcpServerNames = ringZeroActive
|
||||
? await lifecycleTiming.measure("ring-zero-mcp-config-read", () =>
|
||||
readCodexInheritedMcpServerNames(params.client, params.cwd, params.signal),
|
||||
)
|
||||
: [];
|
||||
if (ringZeroActive) {
|
||||
await lifecycleTiming.measure("ring-zero-config-requirements-read", () =>
|
||||
assertCodexRingZeroHasNoManagedHooks(params.client, params.signal),
|
||||
);
|
||||
}
|
||||
const ringZeroConfigFingerprint = ringZeroActive
|
||||
? fingerprintJsonObject({
|
||||
version: 1,
|
||||
baseInstructions: CODEX_RING_ZERO_BASE_INSTRUCTIONS,
|
||||
config: buildCodexRingZeroThreadConfigPatch(
|
||||
params.params,
|
||||
true,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
)!,
|
||||
})
|
||||
: undefined;
|
||||
const ringZeroClientInstanceId = ringZeroActive
|
||||
? getCodexAppServerClientInstanceId(params.client)
|
||||
: undefined;
|
||||
const {
|
||||
contextEngineBinding,
|
||||
dynamicToolsContainDeferred,
|
||||
dynamicToolsFingerprint,
|
||||
environmentSelectionFingerprint,
|
||||
hostSystemAgentActive,
|
||||
legacyDynamicToolsFingerprint,
|
||||
legacyUserMcpServersFingerprint,
|
||||
lifecycleTiming,
|
||||
nativeSkillIsolation,
|
||||
nativeSkillIsolationFingerprint,
|
||||
networkProxyConfigFingerprint,
|
||||
ringZeroActive,
|
||||
ringZeroClientInstanceId,
|
||||
ringZeroConfigFingerprint,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
userMcpServersConfigPatch,
|
||||
userMcpServersFingerprint,
|
||||
webSearchThreadConfigFingerprint,
|
||||
} = await prepareCodexThreadLifecyclePreflight(params);
|
||||
let binding = await lifecycleTiming.measure("read-binding", () =>
|
||||
params.bindingStore.read(bindingIdentity),
|
||||
);
|
||||
@@ -218,11 +131,14 @@ export async function startOrResumeThread(
|
||||
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
|
||||
};
|
||||
const config = lifecycleTiming.measureSync("merge-thread-config", () =>
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
applyCodexNativeSkillIsolation(
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
),
|
||||
nativeSkillIsolation,
|
||||
),
|
||||
);
|
||||
return await materializePendingSupervisionBranch({
|
||||
@@ -257,6 +173,7 @@ export async function startOrResumeThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint:
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
@@ -293,6 +210,16 @@ export async function startOrResumeThread(
|
||||
}
|
||||
binding = undefined;
|
||||
};
|
||||
if (
|
||||
binding?.threadId &&
|
||||
binding.nativeSkillIsolationFingerprint !== nativeSkillIsolationFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server native skill isolation changed; starting a new thread",
|
||||
{ threadId: binding.threadId },
|
||||
);
|
||||
await clearCurrentBinding("rotating stale native skill isolation");
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
(binding.ringZeroConfigFingerprint !== ringZeroConfigFingerprint ||
|
||||
@@ -669,6 +596,7 @@ export async function startOrResumeThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
ringZeroClientInstanceId,
|
||||
@@ -678,6 +606,7 @@ export async function startOrResumeThread(
|
||||
hostSystemAgentActive,
|
||||
ringZeroActive,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
nativeSkillIsolation,
|
||||
lifecycleTiming,
|
||||
normalizeBindingModelProvider,
|
||||
throwIfAborted,
|
||||
@@ -701,6 +630,7 @@ export async function startOrResumeThread(
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
nativeSkillIsolationFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
ringZeroConfigFingerprint,
|
||||
ringZeroClientInstanceId,
|
||||
@@ -710,6 +640,7 @@ export async function startOrResumeThread(
|
||||
hostSystemAgentActive,
|
||||
ringZeroActive,
|
||||
ringZeroInheritedMcpServerNames,
|
||||
nativeSkillIsolation,
|
||||
lifecycleTiming,
|
||||
normalizeBindingModelProvider,
|
||||
throwIfAborted,
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import type { CodexServerNotification } from "./app-server/protocol.js";
|
||||
import {
|
||||
CODEX_REALTIME_OFFER_PATH,
|
||||
createCodexRealtimeBrowserSessionBroker,
|
||||
} from "./realtime-browser-session.js";
|
||||
|
||||
const sharedClientMocks = vi.hoisted(() => ({
|
||||
getClient: vi.fn(),
|
||||
getSharedClient: vi.fn(),
|
||||
releaseClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./app-server/shared-client.js", () => ({
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getClient,
|
||||
getSharedCodexAppServerClient: sharedClientMocks.getSharedClient,
|
||||
releaseLeasedSharedCodexAppServerClient: sharedClientMocks.releaseClient,
|
||||
}));
|
||||
|
||||
function createSdpRequest(token: string, origin?: string): IncomingMessage {
|
||||
return Object.assign(Readable.from(["v=offer\r\n"]), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/sdp",
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
}) as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function createPreflightRequest(origin: string): IncomingMessage {
|
||||
return Object.assign(Readable.from([]), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin,
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "authorization,content-type",
|
||||
"access-control-request-private-network": "true",
|
||||
},
|
||||
}) as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function createResponseHarness(options: { autoFinish?: boolean } = {}): {
|
||||
res: ServerResponse;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
readBody: () => string;
|
||||
close: () => void;
|
||||
} {
|
||||
let body = "";
|
||||
const end = vi.fn((value?: string) => {
|
||||
body = value ?? "";
|
||||
if (options.autoFinish !== false) {
|
||||
queueMicrotask(() => res.emit("finish"));
|
||||
}
|
||||
});
|
||||
const setHeader = vi.fn();
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
statusCode: 200,
|
||||
setHeader,
|
||||
end,
|
||||
}) as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
end,
|
||||
setHeader,
|
||||
readBody: () => body,
|
||||
close: () => {
|
||||
res.emit("close");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeClient(
|
||||
options: {
|
||||
stallRealtimeStart?: boolean;
|
||||
genericRealtimeStartAbortError?: boolean;
|
||||
realtimeStartNotifications?: CodexServerNotification[];
|
||||
} = {},
|
||||
): {
|
||||
client: CodexAppServerClient;
|
||||
methods: string[];
|
||||
emitClose: () => void;
|
||||
emitNotification: (notification: CodexServerNotification) => void;
|
||||
readRealtimeStartSignal: () => AbortSignal | undefined;
|
||||
} {
|
||||
let closeHandler: ((client: CodexAppServerClient) => void) | undefined;
|
||||
let notificationHandler: ((notification: CodexServerNotification) => void) | undefined;
|
||||
let realtimeStartSignal: AbortSignal | undefined;
|
||||
const methods: string[] = [];
|
||||
const client = {
|
||||
request: vi.fn(
|
||||
async (method: string, _params?: unknown, requestOptions?: { signal?: AbortSignal }) => {
|
||||
methods.push(method);
|
||||
if (method === "thread/start") {
|
||||
return {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
cwd: "/tmp/workspace",
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
sandbox: { type: "readOnly" },
|
||||
thread: {
|
||||
id: "thread-1",
|
||||
sessionId: "session-1",
|
||||
cliVersion: "0.145.0",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
cwd: "/tmp/workspace",
|
||||
ephemeral: true,
|
||||
modelProvider: "openai",
|
||||
preview: "",
|
||||
source: "appServer",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "thread/realtime/start") {
|
||||
realtimeStartSignal = requestOptions?.signal;
|
||||
const notifications = options.realtimeStartNotifications ?? [
|
||||
{
|
||||
method: "thread/realtime/sdp",
|
||||
params: { threadId: "thread-1", sdp: "v=answer\r\n" },
|
||||
},
|
||||
];
|
||||
queueMicrotask(() => {
|
||||
for (const notification of notifications) {
|
||||
notificationHandler?.(notification);
|
||||
}
|
||||
});
|
||||
if (options.stallRealtimeStart) {
|
||||
return await new Promise((_, reject) => {
|
||||
const signal = requestOptions?.signal;
|
||||
const rejectAbort = () =>
|
||||
reject(
|
||||
options.genericRealtimeStartAbortError
|
||||
? new Error("request cancelled")
|
||||
: signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("realtime start aborted"),
|
||||
);
|
||||
signal?.addEventListener("abort", rejectAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
rejectAbort();
|
||||
}
|
||||
});
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/realtime/stop" || method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected Codex request: ${method}`);
|
||||
},
|
||||
),
|
||||
addNotificationHandler: vi.fn((handler: (notification: CodexServerNotification) => void) => {
|
||||
notificationHandler = handler;
|
||||
return () => {
|
||||
notificationHandler = undefined;
|
||||
};
|
||||
}),
|
||||
addCloseHandler: vi.fn((handler: (client: CodexAppServerClient) => void) => {
|
||||
closeHandler = handler;
|
||||
}),
|
||||
} as unknown as CodexAppServerClient;
|
||||
return {
|
||||
client,
|
||||
methods,
|
||||
emitClose: () => closeHandler?.(client),
|
||||
emitNotification: (notification) => notificationHandler?.(notification),
|
||||
readRealtimeStartSignal: () => realtimeStartSignal,
|
||||
};
|
||||
}
|
||||
|
||||
function useFakeClient(fake: ReturnType<typeof createFakeClient>): void {
|
||||
sharedClientMocks.getClient.mockResolvedValue(fake.client);
|
||||
sharedClientMocks.getSharedClient.mockResolvedValue(fake.client);
|
||||
}
|
||||
|
||||
describe("Codex OAuth realtime browser session", () => {
|
||||
beforeEach(() => {
|
||||
sharedClientMocks.getClient.mockReset();
|
||||
sharedClientMocks.getSharedClient.mockReset();
|
||||
sharedClientMocks.releaseClient.mockReset();
|
||||
});
|
||||
|
||||
it("advertises the broker only after subscription warmup succeeds", async () => {
|
||||
const fake = createFakeClient();
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
|
||||
expect(realtime.broker.isConfigured()).toBe(false);
|
||||
await realtime.warmup();
|
||||
expect(realtime.broker.isConfigured()).toBe(true);
|
||||
expect(sharedClientMocks.getSharedClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authRequirement: "subscription" }),
|
||||
);
|
||||
|
||||
await realtime.cleanup();
|
||||
});
|
||||
|
||||
it("handles offer preflights only for configured Control UI origins", async () => {
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({
|
||||
gateway: {
|
||||
controlUi: {
|
||||
allowedOrigins: ["https://Control.Example"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
|
||||
try {
|
||||
const accepted = createResponseHarness();
|
||||
await expect(
|
||||
realtime.handler(createPreflightRequest("https://control.example"), accepted.res),
|
||||
).resolves.toBe(true);
|
||||
expect(accepted.res.statusCode).toBe(204);
|
||||
expect(accepted.setHeader).toHaveBeenCalledWith(
|
||||
"Access-Control-Allow-Origin",
|
||||
"https://control.example",
|
||||
);
|
||||
expect(accepted.setHeader).toHaveBeenCalledWith(
|
||||
"Access-Control-Allow-Methods",
|
||||
"POST, OPTIONS",
|
||||
);
|
||||
expect(accepted.setHeader).toHaveBeenCalledWith(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Authorization, Content-Type",
|
||||
);
|
||||
expect(accepted.setHeader).toHaveBeenCalledWith(
|
||||
"Access-Control-Allow-Private-Network",
|
||||
"true",
|
||||
);
|
||||
|
||||
const rejected = createResponseHarness();
|
||||
await expect(
|
||||
realtime.handler(createPreflightRequest("https://untrusted.example"), rejected.res),
|
||||
).resolves.toBe(true);
|
||||
expect(rejected.res.statusCode).toBe(403);
|
||||
expect(rejected.setHeader).not.toHaveBeenCalledWith(
|
||||
"Access-Control-Allow-Origin",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(sharedClientMocks.getClient).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("probes again after failed warmup without advertising or reserving the failure", async () => {
|
||||
const fake = createFakeClient();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
||||
sharedClientMocks.getSharedClient.mockRejectedValueOnce(new Error("ChatGPT login required"));
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
|
||||
await expect(realtime.warmup()).rejects.toThrow("ChatGPT login required");
|
||||
expect(realtime.broker.isConfigured()).toBe(false);
|
||||
sharedClientMocks.getSharedClient.mockRejectedValueOnce(new Error("ChatGPT login required"));
|
||||
await expect(realtime.broker.createBrowserSession({ providerConfig: {} })).rejects.toThrow(
|
||||
"ChatGPT login required",
|
||||
);
|
||||
|
||||
useFakeClient(fake);
|
||||
now.mockReturnValue(11_000);
|
||||
expect(realtime.broker.isConfigured()).toBe(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(realtime.broker.isConfigured()).toBe(true);
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, () => realtime.broker.createBrowserSession({ providerConfig: {} })),
|
||||
);
|
||||
|
||||
await realtime.cleanup();
|
||||
now.mockRestore();
|
||||
});
|
||||
|
||||
it("revalidates after the warmed Codex client closes", async () => {
|
||||
const first = createFakeClient();
|
||||
const replacement = createFakeClient();
|
||||
useFakeClient(first);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
|
||||
try {
|
||||
await realtime.warmup();
|
||||
const session = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
if (session.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser sessions to use WebRTC");
|
||||
}
|
||||
await realtime.handler(createSdpRequest(session.clientSecret), createResponseHarness().res);
|
||||
|
||||
sharedClientMocks.getSharedClient.mockResolvedValue(replacement.client);
|
||||
first.emitClose();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(realtime.broker.isConfigured()).toBe(true);
|
||||
expect(sharedClientMocks.releaseClient).toHaveBeenCalledWith(first.client);
|
||||
});
|
||||
await expect(
|
||||
realtime.broker.createBrowserSession({ providerConfig: {} }),
|
||||
).resolves.toMatchObject({
|
||||
provider: "openai",
|
||||
transport: "webrtc",
|
||||
});
|
||||
expect(sharedClientMocks.getSharedClient).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ authRequirement: "subscription" }),
|
||||
);
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("redeems browser reservations once and invalidates pending ones on cleanup", async () => {
|
||||
const fake = createFakeClient();
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({
|
||||
gateway: {
|
||||
controlUi: {
|
||||
allowedOrigins: ["https://Control.Example"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
expect(realtime.broker.capabilities).toEqual({
|
||||
transports: ["webrtc"],
|
||||
handlesAgentConsult: true,
|
||||
supportsToolCalls: false,
|
||||
supportsVideoFrames: false,
|
||||
});
|
||||
const first = await realtime.broker.createBrowserSession({
|
||||
providerConfig: {},
|
||||
instructions: " Keep the same Talk persona. ",
|
||||
model: " gpt-realtime-2 ",
|
||||
voice: " Marin ",
|
||||
initialItems: [
|
||||
{ role: "user", text: "Earlier question" },
|
||||
{ role: "assistant", text: "Earlier answer" },
|
||||
],
|
||||
});
|
||||
const second = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
const cancelled = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
|
||||
expect(first).toMatchObject({
|
||||
provider: "openai",
|
||||
transport: "webrtc",
|
||||
offerUrl: CODEX_REALTIME_OFFER_PATH,
|
||||
voice: "Marin",
|
||||
clientSecret: expect.stringMatching(/^[A-Za-z0-9_-]{40,}$/),
|
||||
expiresAt: expect.any(Number),
|
||||
});
|
||||
expect(first).not.toHaveProperty("model");
|
||||
if (first.transport !== "webrtc" || second.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser sessions to use WebRTC");
|
||||
}
|
||||
expect(second.clientSecret).not.toBe(first.clientSecret);
|
||||
await realtime.broker.cancelBrowserSession(cancelled);
|
||||
|
||||
try {
|
||||
const accepted = createResponseHarness();
|
||||
await expect(
|
||||
realtime.handler(
|
||||
createSdpRequest(first.clientSecret, "https://control.example"),
|
||||
accepted.res,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(accepted.res.statusCode).toBe(200);
|
||||
expect(accepted.readBody()).toBe("v=answer\r\n");
|
||||
expect(accepted.setHeader).toHaveBeenCalledWith(
|
||||
"Access-Control-Allow-Origin",
|
||||
"https://control.example",
|
||||
);
|
||||
const threadStartParams = (fake.client.request as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
([method]) => method === "thread/start",
|
||||
)?.[1];
|
||||
expect(threadStartParams).toEqual({
|
||||
cwd: process.cwd(),
|
||||
ephemeral: true,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
config: { "features.realtime_conversation": true },
|
||||
});
|
||||
const realtimeStartParams = (fake.client.request as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
([method]) => method === "thread/realtime/start",
|
||||
)?.[1];
|
||||
expect(realtimeStartParams).toEqual({
|
||||
threadId: "thread-1",
|
||||
outputModality: "audio",
|
||||
transport: { type: "webrtc", sdp: "v=offer\r\n" },
|
||||
version: "v3",
|
||||
includeStartupContext: true,
|
||||
voice: "Marin",
|
||||
initialItems: [
|
||||
{ role: "developer", text: "Keep the same Talk persona." },
|
||||
{ role: "user", text: "Earlier question" },
|
||||
{ role: "assistant", text: "Earlier answer" },
|
||||
],
|
||||
});
|
||||
expect(realtimeStartParams).not.toHaveProperty("prompt");
|
||||
expect(realtimeStartParams).not.toHaveProperty("model");
|
||||
|
||||
const replayed = createResponseHarness();
|
||||
await expect(
|
||||
realtime.handler(createSdpRequest(first.clientSecret), replayed.res),
|
||||
).resolves.toBe(true);
|
||||
expect(replayed.res.statusCode).toBe(401);
|
||||
expect(sharedClientMocks.getClient).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (cancelled.transport !== "webrtc") {
|
||||
throw new Error("Expected cancelled Codex browser session to use WebRTC");
|
||||
}
|
||||
const cancelledResponse = createResponseHarness();
|
||||
await expect(
|
||||
realtime.handler(createSdpRequest(cancelled.clientSecret), cancelledResponse.res),
|
||||
).resolves.toBe(true);
|
||||
expect(cancelledResponse.res.statusCode).toBe(401);
|
||||
|
||||
await realtime.cleanup();
|
||||
expect(fake.methods).toContain("thread/realtime/stop");
|
||||
expect(fake.methods).toContain("thread/unsubscribe");
|
||||
expect(sharedClientMocks.releaseClient).toHaveBeenCalledWith(fake.client);
|
||||
|
||||
const invalidated = createResponseHarness();
|
||||
await expect(
|
||||
realtime.handler(createSdpRequest(second.clientSecret), invalidated.res),
|
||||
).resolves.toBe(true);
|
||||
expect(invalidated.res.statusCode).toBe(401);
|
||||
await expect(realtime.broker.createBrowserSession({ providerConfig: {} })).rejects.toThrow(
|
||||
"Codex OAuth realtime is stopping",
|
||||
);
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts and closes backend startup when the browser offer disconnects", async () => {
|
||||
const fake = createFakeClient({ stallRealtimeStart: true });
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
const reservation = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
if (reservation.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser session to use WebRTC");
|
||||
}
|
||||
const response = createResponseHarness();
|
||||
|
||||
try {
|
||||
const handling = realtime.handler(createSdpRequest(reservation.clientSecret), response.res);
|
||||
await vi.waitFor(() => {
|
||||
expect(fake.readRealtimeStartSignal()).toBeDefined();
|
||||
});
|
||||
|
||||
response.close();
|
||||
|
||||
await expect(handling).resolves.toBe(true);
|
||||
expect(fake.readRealtimeStartSignal()?.aborted).toBe(true);
|
||||
expect(fake.methods).toContain("thread/realtime/stop");
|
||||
expect(fake.methods).toContain("thread/unsubscribe");
|
||||
expect(sharedClientMocks.releaseClient).toHaveBeenCalledWith(fake.client);
|
||||
expect(response.end).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the Codex startup error when it arrives before the start response", async () => {
|
||||
const fake = createFakeClient({
|
||||
stallRealtimeStart: true,
|
||||
genericRealtimeStartAbortError: true,
|
||||
realtimeStartNotifications: [
|
||||
{
|
||||
method: "thread/realtime/error",
|
||||
params: { threadId: "thread-1", message: "subscription unavailable" },
|
||||
},
|
||||
],
|
||||
});
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
const reservation = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
if (reservation.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser session to use WebRTC");
|
||||
}
|
||||
const response = createResponseHarness();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
realtime.handler(createSdpRequest(reservation.clientSecret), response.res),
|
||||
).resolves.toBe(true);
|
||||
expect(response.res.statusCode).toBe(502);
|
||||
expect(response.readBody()).toBe("subscription unavailable");
|
||||
expect(fake.methods).toContain("thread/realtime/stop");
|
||||
expect(fake.methods).toContain("thread/unsubscribe");
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not return an SDP answer after Codex closes during startup", async () => {
|
||||
const fake = createFakeClient({
|
||||
stallRealtimeStart: true,
|
||||
genericRealtimeStartAbortError: true,
|
||||
realtimeStartNotifications: [
|
||||
{
|
||||
method: "thread/realtime/sdp",
|
||||
params: { threadId: "thread-1", sdp: "v=stale-answer\r\n" },
|
||||
},
|
||||
{
|
||||
method: "thread/realtime/closed",
|
||||
params: { threadId: "thread-1", reason: "backend closed" },
|
||||
},
|
||||
],
|
||||
});
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
const reservation = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
if (reservation.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser session to use WebRTC");
|
||||
}
|
||||
const response = createResponseHarness();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
realtime.handler(createSdpRequest(reservation.clientSecret), response.res),
|
||||
).resolves.toBe(true);
|
||||
expect(response.res.statusCode).toBe(502);
|
||||
expect(response.readBody()).toBe(
|
||||
"Codex realtime session closed before returning an SDP answer",
|
||||
);
|
||||
expect(response.readBody()).not.toContain("stale-answer");
|
||||
expect(fake.methods).toContain("thread/realtime/stop");
|
||||
expect(fake.methods).toContain("thread/unsubscribe");
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases the backend when Codex reports an error after startup", async () => {
|
||||
const fake = createFakeClient();
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
const reservation = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
if (reservation.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser session to use WebRTC");
|
||||
}
|
||||
const response = createResponseHarness();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
realtime.handler(createSdpRequest(reservation.clientSecret), response.res),
|
||||
).resolves.toBe(true);
|
||||
|
||||
fake.emitNotification({
|
||||
method: "thread/realtime/error",
|
||||
params: { threadId: "thread-1", message: "backend failed" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sharedClientMocks.releaseClient).toHaveBeenCalledWith(fake.client);
|
||||
});
|
||||
expect(fake.methods).toContain("thread/realtime/stop");
|
||||
expect(fake.methods).toContain("thread/unsubscribe");
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the backend when the browser disconnects while the SDP answer is flushing", async () => {
|
||||
const fake = createFakeClient();
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
const reservation = await realtime.broker.createBrowserSession({ providerConfig: {} });
|
||||
if (reservation.transport !== "webrtc") {
|
||||
throw new Error("Expected Codex browser session to use WebRTC");
|
||||
}
|
||||
const response = createResponseHarness({ autoFinish: false });
|
||||
|
||||
try {
|
||||
const handling = realtime.handler(createSdpRequest(reservation.clientSecret), response.res);
|
||||
await vi.waitFor(() => {
|
||||
expect(response.end).toHaveBeenCalledWith("v=answer\r\n");
|
||||
});
|
||||
|
||||
response.close();
|
||||
|
||||
await expect(handling).resolves.toBe(true);
|
||||
expect(fake.methods).toContain("thread/realtime/stop");
|
||||
expect(fake.methods).toContain("thread/unsubscribe");
|
||||
expect(sharedClientMocks.releaseClient).toHaveBeenCalledWith(fake.client);
|
||||
} finally {
|
||||
await realtime.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("caps concurrent pending and active browser sessions", async () => {
|
||||
const fake = createFakeClient();
|
||||
useFakeClient(fake);
|
||||
const realtime = createCodexRealtimeBrowserSessionBroker({
|
||||
getConfig: () => ({}),
|
||||
getPluginConfig: () => ({}),
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, () => realtime.broker.createBrowserSession({ providerConfig: {} })),
|
||||
);
|
||||
|
||||
await expect(realtime.broker.createBrowserSession({ providerConfig: {} })).rejects.toThrow(
|
||||
"Too many concurrent Codex OAuth realtime sessions",
|
||||
);
|
||||
|
||||
await realtime.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,694 +0,0 @@
|
||||
// Experimental ChatGPT OAuth browser session broker for Control UI realtime Talk.
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type {
|
||||
RealtimeVoiceBrowserSession,
|
||||
RealtimeVoiceBrowserSessionCreateRequest,
|
||||
RealtimeVoiceProviderCapabilities,
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./app-server/attempt-client-cleanup.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
|
||||
import { assertCodexThreadStartResponse } from "./app-server/protocol-validators.js";
|
||||
import type { CodexThreadStartParams } from "./app-server/protocol.js";
|
||||
import {
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./app-server/shared-client.js";
|
||||
|
||||
const CODEX_REALTIME_OFFER_PATH = "/plugins/codex/realtime/calls";
|
||||
const CODEX_REALTIME_PENDING_TTL_MS = 60_000;
|
||||
const CODEX_REALTIME_SESSION_TTL_MS = 30 * 60_000;
|
||||
const CODEX_REALTIME_MAX_SESSIONS = 8;
|
||||
const CODEX_REALTIME_MAX_SDP_BYTES = 256 * 1024;
|
||||
const CODEX_REALTIME_START_TIMEOUT_MS = 60_000;
|
||||
const CODEX_REALTIME_PROBE_COOLDOWN_MS = 1_000;
|
||||
|
||||
type CodexRealtimeBrowserSessionCreateRequest = RealtimeVoiceBrowserSessionCreateRequest & {
|
||||
agentId?: string;
|
||||
workspaceDir?: string;
|
||||
initialItems?: Array<{
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CodexRealtimeProviderCapabilities = Partial<RealtimeVoiceProviderCapabilities> & {
|
||||
handlesAgentConsult?: boolean;
|
||||
};
|
||||
|
||||
type PendingOffer = {
|
||||
expiresAt: number;
|
||||
request: CodexRealtimeBrowserSessionCreateRequest;
|
||||
};
|
||||
|
||||
type CodexRealtimeBrowserSessionFallback = {
|
||||
capabilities: CodexRealtimeProviderCapabilities;
|
||||
isConfigured: () => boolean;
|
||||
createBrowserSession: (
|
||||
request: CodexRealtimeBrowserSessionCreateRequest,
|
||||
) => Promise<RealtimeVoiceBrowserSession>;
|
||||
cancelBrowserSession: (session: RealtimeVoiceBrowserSession) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type ActiveSession = {
|
||||
client: CodexAppServerClient;
|
||||
reservationToken: string;
|
||||
threadId: string;
|
||||
timer: NodeJS.Timeout;
|
||||
disposeNotificationHandler: () => void;
|
||||
};
|
||||
|
||||
type RealtimeNotificationParams = {
|
||||
threadId?: unknown;
|
||||
sdp?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
type ResponseDeliveryWaiter = {
|
||||
result: Promise<boolean>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
function createResponseDeliveryWaiter(
|
||||
res: ServerResponse,
|
||||
onDelivered: () => void,
|
||||
): ResponseDeliveryWaiter {
|
||||
let settle!: (delivered: boolean) => void;
|
||||
const result = new Promise<boolean>((resolve) => {
|
||||
settle = (delivered) => {
|
||||
res.removeListener("finish", onFinish);
|
||||
res.removeListener("close", onClose);
|
||||
resolve(delivered);
|
||||
};
|
||||
});
|
||||
const onFinish = () => {
|
||||
// ServerResponse may emit close immediately after finish. Remove the
|
||||
// disconnect abort synchronously so normal completion keeps WebRTC alive.
|
||||
onDelivered();
|
||||
settle(true);
|
||||
};
|
||||
const onClose = () => settle(false);
|
||||
res.once("finish", onFinish);
|
||||
res.once("close", onClose);
|
||||
return { result, cancel: () => settle(false) };
|
||||
}
|
||||
|
||||
function respondText(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.setHeader("content-type", "text/plain; charset=utf-8");
|
||||
res.setHeader("x-content-type-options", "nosniff");
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function resolveConfiguredControlUiOrigin(
|
||||
req: IncomingMessage,
|
||||
cfg: OpenClawConfig | undefined,
|
||||
): string | undefined {
|
||||
const rawOrigin = typeof req.headers.origin === "string" ? req.headers.origin.trim() : "";
|
||||
if (!rawOrigin) {
|
||||
return undefined;
|
||||
}
|
||||
let origin: string;
|
||||
try {
|
||||
const parsed = new URL(rawOrigin);
|
||||
if (parsed.origin !== rawOrigin || parsed.username || parsed.password) {
|
||||
return undefined;
|
||||
}
|
||||
origin = parsed.origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const allowed = cfg?.gateway?.controlUi?.allowedOrigins ?? [];
|
||||
return allowed.some((candidate) => {
|
||||
const normalized = candidate.trim().toLowerCase();
|
||||
return normalized === "*" || normalized === origin;
|
||||
})
|
||||
? origin
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function applyRealtimeOfferCorsHeaders(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
cfg: OpenClawConfig | undefined,
|
||||
): boolean {
|
||||
if (!req.headers.origin) {
|
||||
return true;
|
||||
}
|
||||
const origin = resolveConfiguredControlUiOrigin(req, cfg);
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
return true;
|
||||
}
|
||||
|
||||
function readBearerToken(req: IncomingMessage): string | undefined {
|
||||
const authorization = req.headers.authorization?.trim();
|
||||
const match = authorization?.match(/^Bearer\s+([^\s]+)$/i);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function readNotificationParams(value: unknown): RealtimeNotificationParams {
|
||||
return value && typeof value === "object" ? (value as RealtimeNotificationParams) : {};
|
||||
}
|
||||
|
||||
function buildCodexRealtimeThreadStartParams(params: { cwd: string }): CodexThreadStartParams {
|
||||
return {
|
||||
cwd: params.cwd,
|
||||
ephemeral: true,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
config: { "features.realtime_conversation": true },
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexRealtimeStartParams(params: {
|
||||
threadId: string;
|
||||
sdp: string;
|
||||
developerInstructions?: string;
|
||||
voice?: string;
|
||||
initialItems?: CodexRealtimeBrowserSessionCreateRequest["initialItems"];
|
||||
}): Record<string, unknown> {
|
||||
const initialItems = [
|
||||
...(params.developerInstructions
|
||||
? [{ role: "developer" as const, text: params.developerInstructions }]
|
||||
: []),
|
||||
...(params.initialItems ?? []),
|
||||
];
|
||||
return {
|
||||
threadId: params.threadId,
|
||||
outputModality: "audio",
|
||||
transport: { type: "webrtc", sdp: params.sdp },
|
||||
version: "v3",
|
||||
includeStartupContext: true,
|
||||
...(params.voice ? { voice: params.voice } : {}),
|
||||
...(initialItems.length > 0 ? { initialItems } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function waitForRealtimeSdpAnswer(
|
||||
answerPromise: Promise<string>,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (result: { answer: string } | { error: Error }) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
if ("answer" in result) {
|
||||
resolve(result.answer);
|
||||
} else {
|
||||
reject(result.error);
|
||||
}
|
||||
};
|
||||
const onAbort = () =>
|
||||
finish({
|
||||
error:
|
||||
signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("Codex realtime session stopped during startup"),
|
||||
});
|
||||
const timeout = setTimeout(
|
||||
() => finish({ error: new Error("Codex realtime SDP answer timed out") }),
|
||||
CODEX_REALTIME_START_TIMEOUT_MS,
|
||||
);
|
||||
timeout.unref?.();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
void answerPromise.then(
|
||||
(answer) => finish({ answer }),
|
||||
(error: unknown) =>
|
||||
finish({ error: error instanceof Error ? error : new Error("Codex realtime failed") }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCodexRealtimeBrowserSessionBroker(params: {
|
||||
getConfig: () => OpenClawConfig | undefined;
|
||||
getPluginConfig: () => unknown;
|
||||
}): {
|
||||
broker: CodexRealtimeBrowserSessionFallback;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
warmup: () => Promise<void>;
|
||||
cleanup: () => Promise<void>;
|
||||
} {
|
||||
const pendingOffers = new Map<string, PendingOffer>();
|
||||
const reservations = new Set<string>();
|
||||
const activeSessions = new Set<ActiveSession>();
|
||||
const inFlightHandlers = new Set<Promise<boolean>>();
|
||||
const readyClients = new Set<CodexAppServerClient>();
|
||||
const shutdownController = new AbortController();
|
||||
let cleanedUp = false;
|
||||
let probePromise: Promise<void> | undefined;
|
||||
let nextProbeAt = 0;
|
||||
|
||||
const closeSession = async (session: ActiveSession) => {
|
||||
if (!activeSessions.delete(session)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(session.timer);
|
||||
reservations.delete(session.reservationToken);
|
||||
session.disposeNotificationHandler();
|
||||
try {
|
||||
await session.client.request(
|
||||
"thread/realtime/stop",
|
||||
{ threadId: session.threadId },
|
||||
{ timeoutMs: 2_000 },
|
||||
);
|
||||
} catch {
|
||||
// The peer or app-server may already have closed the realtime transport.
|
||||
}
|
||||
await unsubscribeCodexThreadBestEffort(session.client, {
|
||||
threadId: session.threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
releaseLeasedSharedCodexAppServerClient(session.client);
|
||||
};
|
||||
|
||||
const resolveSubscriptionClientOptions = (request: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}) => {
|
||||
const pluginConfig = readCodexPluginConfig(params.getPluginConfig());
|
||||
const agentDir =
|
||||
request.agentId && request.cfg ? resolveAgentDir(request.cfg, request.agentId) : undefined;
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig,
|
||||
config: request.cfg,
|
||||
agentDir,
|
||||
});
|
||||
return {
|
||||
startOptions: runtime.start,
|
||||
pluginConfig,
|
||||
config: request.cfg,
|
||||
agentDir,
|
||||
authRequirement: "subscription" as const,
|
||||
timeoutMs: CODEX_REALTIME_START_TIMEOUT_MS,
|
||||
};
|
||||
};
|
||||
|
||||
const ensureSubscriptionRuntime = async (request: { cfg?: OpenClawConfig; agentId?: string }) => {
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
...resolveSubscriptionClientOptions(request),
|
||||
abandonSignal: shutdownController.signal,
|
||||
});
|
||||
if (!readyClients.has(client)) {
|
||||
readyClients.add(client);
|
||||
client.addCloseHandler((closedClient) => {
|
||||
readyClients.delete(closedClient);
|
||||
void Promise.allSettled(
|
||||
[...activeSessions]
|
||||
.filter((session) => session.client === closedClient)
|
||||
.map((session) => closeSession(session)),
|
||||
);
|
||||
nextProbeAt = 0;
|
||||
requestProbe();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runProbe = () => {
|
||||
if (probePromise) {
|
||||
return probePromise;
|
||||
}
|
||||
const cfg = params.getConfig();
|
||||
nextProbeAt = Date.now() + CODEX_REALTIME_PROBE_COOLDOWN_MS;
|
||||
const running = ensureSubscriptionRuntime({
|
||||
cfg,
|
||||
...(cfg ? { agentId: resolveDefaultAgentId(cfg) } : {}),
|
||||
}).finally(() => {
|
||||
if (probePromise === running) {
|
||||
probePromise = undefined;
|
||||
}
|
||||
});
|
||||
probePromise = running;
|
||||
return running;
|
||||
};
|
||||
|
||||
function requestProbe(): void {
|
||||
if (
|
||||
cleanedUp ||
|
||||
shutdownController.signal.aborted ||
|
||||
probePromise ||
|
||||
Date.now() < nextProbeAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Readiness stays false while the async Codex probe runs. This lets auth or
|
||||
// process recovery become visible without shadowing another healthy provider.
|
||||
void runProbe().catch(() => undefined);
|
||||
}
|
||||
|
||||
const warmup = async () => {
|
||||
await runProbe();
|
||||
};
|
||||
|
||||
const prunePendingOffers = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, offer] of pendingOffers) {
|
||||
if (offer.expiresAt <= now) {
|
||||
pendingOffers.delete(token);
|
||||
reservations.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const broker: CodexRealtimeBrowserSessionFallback = {
|
||||
capabilities: {
|
||||
transports: ["webrtc"],
|
||||
handlesAgentConsult: true,
|
||||
supportsToolCalls: false,
|
||||
supportsVideoFrames: false,
|
||||
},
|
||||
isConfigured: () => {
|
||||
if (cleanedUp || shutdownController.signal.aborted) {
|
||||
return false;
|
||||
}
|
||||
if (readyClients.size > 0) {
|
||||
return true;
|
||||
}
|
||||
requestProbe();
|
||||
return false;
|
||||
},
|
||||
createBrowserSession: async (request: CodexRealtimeBrowserSessionCreateRequest) => {
|
||||
if (cleanedUp || shutdownController.signal.aborted) {
|
||||
throw new Error("Codex OAuth realtime is stopping; restart Gateway and try again");
|
||||
}
|
||||
// Revalidate the request's exact agent/runtime before the Gateway persists a
|
||||
// client voice session. The warmed shared process makes this path cheap.
|
||||
await ensureSubscriptionRuntime(request);
|
||||
if (cleanedUp || shutdownController.signal.aborted) {
|
||||
throw new Error("Codex OAuth realtime is stopping; restart Gateway and try again");
|
||||
}
|
||||
prunePendingOffers();
|
||||
if (reservations.size >= CODEX_REALTIME_MAX_SESSIONS) {
|
||||
throw new Error("Too many concurrent Codex OAuth realtime sessions; try again in a minute");
|
||||
}
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = Date.now() + CODEX_REALTIME_PENDING_TTL_MS;
|
||||
reservations.add(token);
|
||||
pendingOffers.set(token, { expiresAt, request });
|
||||
const voice = request.voice?.trim() || undefined;
|
||||
return {
|
||||
provider: "openai",
|
||||
transport: "webrtc",
|
||||
clientSecret: token,
|
||||
offerUrl: CODEX_REALTIME_OFFER_PATH,
|
||||
...(voice ? { voice } : {}),
|
||||
expiresAt,
|
||||
};
|
||||
},
|
||||
cancelBrowserSession: (session) => {
|
||||
if (session.transport !== "webrtc") {
|
||||
return;
|
||||
}
|
||||
pendingOffers.delete(session.clientSecret);
|
||||
reservations.delete(session.clientSecret);
|
||||
},
|
||||
};
|
||||
|
||||
const handleOffer = async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const corsAllowed = applyRealtimeOfferCorsHeaders(req, res, params.getConfig());
|
||||
if (req.method === "OPTIONS") {
|
||||
if (!corsAllowed) {
|
||||
respondText(res, 403, "Origin not allowed");
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 204;
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
||||
res.setHeader(
|
||||
"Vary",
|
||||
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
||||
);
|
||||
if (req.headers["access-control-request-private-network"] === "true") {
|
||||
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
||||
}
|
||||
res.setHeader("Access-Control-Max-Age", "600");
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
respondText(res, 405, "Method not allowed");
|
||||
return true;
|
||||
}
|
||||
if (!req.headers["content-type"]?.toLowerCase().startsWith("application/sdp")) {
|
||||
respondText(res, 415, "Expected application/sdp");
|
||||
return true;
|
||||
}
|
||||
prunePendingOffers();
|
||||
const token = readBearerToken(req);
|
||||
const offer = token ? pendingOffers.get(token) : undefined;
|
||||
if (!token || !offer || offer.expiresAt <= Date.now()) {
|
||||
respondText(res, 401, "Invalid or expired realtime session token");
|
||||
return true;
|
||||
}
|
||||
// A browser session token is single-use so captured requests cannot be replayed.
|
||||
pendingOffers.delete(token);
|
||||
const requestController = new AbortController();
|
||||
const abortFromBrowser = () => {
|
||||
requestController.abort(new Error("Browser realtime offer request closed"));
|
||||
};
|
||||
req.once("aborted", abortFromBrowser);
|
||||
res.once("close", abortFromBrowser);
|
||||
const detachBrowserAbort = () => {
|
||||
req.removeListener("aborted", abortFromBrowser);
|
||||
res.removeListener("close", abortFromBrowser);
|
||||
};
|
||||
const lifecycleSignal = AbortSignal.any([shutdownController.signal, requestController.signal]);
|
||||
|
||||
let client: CodexAppServerClient | undefined;
|
||||
let session: ActiveSession | undefined;
|
||||
let threadId: string | undefined;
|
||||
let reservationTransferred = false;
|
||||
let responseDeliveryWaiter: ResponseDeliveryWaiter | undefined;
|
||||
try {
|
||||
const sdp = await readRequestBodyWithLimit(req, {
|
||||
maxBytes: CODEX_REALTIME_MAX_SDP_BYTES,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!sdp.trim()) {
|
||||
respondText(res, 400, "SDP offer is required");
|
||||
return true;
|
||||
}
|
||||
if (lifecycleSignal.aborted) {
|
||||
throw new Error("Codex realtime session stopped during startup");
|
||||
}
|
||||
|
||||
// Share the agent's normal Codex app-server process. A fresh ephemeral thread
|
||||
// keeps realtime from replacing a live normal turn on the bound Codex thread.
|
||||
client = await getLeasedSharedCodexAppServerClient({
|
||||
...resolveSubscriptionClientOptions(offer.request),
|
||||
abandonSignal: lifecycleSignal,
|
||||
});
|
||||
|
||||
const started = assertCodexThreadStartResponse(
|
||||
await client.request(
|
||||
"thread/start",
|
||||
buildCodexRealtimeThreadStartParams({
|
||||
cwd: offer.request.workspaceDir ?? process.cwd(),
|
||||
}),
|
||||
{ timeoutMs: CODEX_REALTIME_START_TIMEOUT_MS, signal: lifecycleSignal },
|
||||
),
|
||||
);
|
||||
threadId = started.thread.id;
|
||||
if (lifecycleSignal.aborted) {
|
||||
throw new Error("Codex realtime session stopped during startup");
|
||||
}
|
||||
|
||||
let resolveSdp!: (answer: string) => void;
|
||||
let rejectSdp!: (error: Error) => void;
|
||||
const answerPromise = new Promise<string>((resolve, reject) => {
|
||||
resolveSdp = resolve;
|
||||
rejectSdp = reject;
|
||||
});
|
||||
const startupController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([lifecycleSignal, startupController.signal]);
|
||||
let startupComplete = false;
|
||||
const stopStartup = (error: Error) => {
|
||||
if (!startupController.signal.aborted) {
|
||||
startupController.abort(error);
|
||||
}
|
||||
};
|
||||
const closeAfterStartup = () => {
|
||||
// During startup the request handler owns ordered cleanup. Once the SDP
|
||||
// response is ready, terminal notifications must release the live session.
|
||||
if (startupComplete && session) {
|
||||
void closeSession(session);
|
||||
}
|
||||
};
|
||||
const disposeNotificationHandler = client.addNotificationHandler((notification) => {
|
||||
const notificationParams = readNotificationParams(notification.params);
|
||||
if (notificationParams.threadId !== threadId) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "thread/realtime/sdp") {
|
||||
if (typeof notificationParams.sdp === "string" && notificationParams.sdp.trim()) {
|
||||
resolveSdp(notificationParams.sdp);
|
||||
} else {
|
||||
rejectSdp(new Error("Codex returned an empty realtime SDP answer"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (notification.method === "thread/realtime/error") {
|
||||
const error = new Error(
|
||||
typeof notificationParams.message === "string"
|
||||
? notificationParams.message
|
||||
: "Codex realtime session failed",
|
||||
);
|
||||
rejectSdp(error);
|
||||
stopStartup(error);
|
||||
closeAfterStartup();
|
||||
return;
|
||||
}
|
||||
if (notification.method === "thread/realtime/closed") {
|
||||
const error = new Error("Codex realtime session closed before returning an SDP answer");
|
||||
rejectSdp(error);
|
||||
stopStartup(error);
|
||||
closeAfterStartup();
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (session) {
|
||||
void closeSession(session);
|
||||
}
|
||||
}, CODEX_REALTIME_SESSION_TTL_MS);
|
||||
timer.unref?.();
|
||||
session = {
|
||||
client,
|
||||
reservationToken: token,
|
||||
threadId,
|
||||
timer,
|
||||
disposeNotificationHandler,
|
||||
};
|
||||
reservationTransferred = true;
|
||||
activeSessions.add(session);
|
||||
|
||||
// Observe the answer before starting the request: Codex may emit a terminal
|
||||
// notification before the matching JSON-RPC response reaches this process.
|
||||
const answerResultPromise = waitForRealtimeSdpAnswer(answerPromise, startupSignal).then(
|
||||
(answer) => ({ ok: true as const, answer }),
|
||||
(error: unknown) => ({
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error : new Error("Codex realtime session failed"),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await client.request(
|
||||
"thread/realtime/start",
|
||||
buildCodexRealtimeStartParams({
|
||||
threadId,
|
||||
sdp,
|
||||
developerInstructions: offer.request.instructions?.trim() || undefined,
|
||||
voice: offer.request.voice?.trim() || undefined,
|
||||
initialItems: offer.request.initialItems,
|
||||
}),
|
||||
{ timeoutMs: CODEX_REALTIME_START_TIMEOUT_MS, signal: startupSignal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (startupController.signal.aborted) {
|
||||
const answerResult = await answerResultPromise;
|
||||
if (!answerResult.ok) {
|
||||
throw answerResult.error;
|
||||
}
|
||||
throw startupController.signal.reason instanceof Error
|
||||
? startupController.signal.reason
|
||||
: new Error("Codex realtime session stopped during startup");
|
||||
}
|
||||
stopStartup(
|
||||
error instanceof Error ? error : new Error("Codex realtime session failed to start"),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const answerResult = await answerResultPromise;
|
||||
if (!answerResult.ok) {
|
||||
throw answerResult.error;
|
||||
}
|
||||
if (startupSignal.aborted) {
|
||||
throw startupSignal.reason instanceof Error
|
||||
? startupSignal.reason
|
||||
: new Error("Codex realtime session stopped during startup");
|
||||
}
|
||||
startupComplete = true;
|
||||
responseDeliveryWaiter = createResponseDeliveryWaiter(res, detachBrowserAbort);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.setHeader("content-type", "application/sdp");
|
||||
res.setHeader("x-content-type-options", "nosniff");
|
||||
res.end(answerResult.answer);
|
||||
const delivered = await responseDeliveryWaiter.result;
|
||||
responseDeliveryWaiter = undefined;
|
||||
if (!delivered || lifecycleSignal.aborted) {
|
||||
await closeSession(session);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (session) {
|
||||
await closeSession(session);
|
||||
} else if (client) {
|
||||
if (threadId) {
|
||||
await unsubscribeCodexThreadBestEffort(client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
if (requestController.signal.aborted) {
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Codex realtime session failed";
|
||||
respondText(res, 502, message);
|
||||
return true;
|
||||
} finally {
|
||||
responseDeliveryWaiter?.cancel();
|
||||
detachBrowserAbort();
|
||||
if (!reservationTransferred) {
|
||||
reservations.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
const trackedHandleOffer = (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const handling = handleOffer(req, res);
|
||||
inFlightHandlers.add(handling);
|
||||
return handling.finally(() => {
|
||||
inFlightHandlers.delete(handling);
|
||||
});
|
||||
};
|
||||
const handler = trackedHandleOffer;
|
||||
|
||||
const cleanup = async () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
cleanedUp = true;
|
||||
shutdownController.abort();
|
||||
pendingOffers.clear();
|
||||
await Promise.all([...activeSessions].map((session) => closeSession(session)));
|
||||
await Promise.allSettled(inFlightHandlers);
|
||||
reservations.clear();
|
||||
};
|
||||
|
||||
return { broker, handler, warmup, cleanup };
|
||||
}
|
||||
|
||||
export { CODEX_REALTIME_OFFER_PATH };
|
||||
@@ -139,11 +139,67 @@ describe("Discord Activity interaction", () => {
|
||||
expect(writeOrder).toBeLessThan(launchOrder);
|
||||
});
|
||||
|
||||
it("clears the exact watchdog handle when the pending launch write wins", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const nativeSetTimeout = globalThis.setTimeout;
|
||||
let watchdogHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
let watchdogFired = false;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(((
|
||||
callback: (...args: unknown[]) => void,
|
||||
delay?: number,
|
||||
...args: unknown[]
|
||||
): ReturnType<typeof setTimeout> => {
|
||||
const handle = nativeSetTimeout(() => {
|
||||
if (delay === 250) {
|
||||
watchdogFired = true;
|
||||
}
|
||||
callback(...args);
|
||||
}, delay);
|
||||
if (delay === 250) {
|
||||
watchdogHandle = handle;
|
||||
}
|
||||
return handle;
|
||||
}) as typeof setTimeout);
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
reply: vi.fn(async () => undefined) as never,
|
||||
});
|
||||
if (!button) {
|
||||
throw new Error("expected activity button");
|
||||
}
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = {
|
||||
launchActivity,
|
||||
rawData: { channel_id: "777" },
|
||||
userId: "42",
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
try {
|
||||
await button.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
|
||||
expect(watchdogHandle).toBeDefined();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(watchdogHandle);
|
||||
await new Promise<void>((resolve) => {
|
||||
nativeSetTimeout(resolve, 275);
|
||||
});
|
||||
expect(watchdogFired).toBe(false);
|
||||
expect(launchActivity).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("launches after the write budget when the store stalls and logs once", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const pendingWrite = createDeferred<void>();
|
||||
vi.spyOn(runtime.store, "recordPendingLaunch").mockReturnValue(pendingWrite.promise);
|
||||
let backgroundSettled = false;
|
||||
const stalledWrite = pendingWrite.promise.then(() => {
|
||||
backgroundSettled = true;
|
||||
});
|
||||
vi.spyOn(runtime.store, "recordPendingLaunch").mockReturnValue(stalledWrite);
|
||||
const logError = vi.fn();
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
reply: vi.fn(async () => undefined) as never,
|
||||
@@ -158,14 +214,21 @@ describe("Discord Activity interaction", () => {
|
||||
rawData: { channel_id: "777" },
|
||||
userId: "42",
|
||||
} as unknown as ButtonInteraction;
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
await button.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
expect(launchActivity).toHaveBeenCalledOnce();
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(String(logError.mock.calls[0]?.[0])).toContain("exceeded");
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(240);
|
||||
expect(backgroundSettled).toBe(false);
|
||||
} finally {
|
||||
pendingWrite.resolve();
|
||||
await stalledWrite;
|
||||
}
|
||||
expect(backgroundSettled).toBe(true);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still launches when recording the pending launch fails and logs once", async () => {
|
||||
|
||||
@@ -69,14 +69,21 @@ class DiscordActivityButton extends Button {
|
||||
this.logPendingLaunchFailure(error);
|
||||
return "failed" as const;
|
||||
});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
const timer = setTimeout(() => resolve("timeout"), PENDING_LAUNCH_WRITE_BUDGET_MS);
|
||||
timer = setTimeout(() => resolve("timeout"), PENDING_LAUNCH_WRITE_BUDGET_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
if ((await Promise.race([write, timeout])) === "timeout") {
|
||||
this.logPendingLaunchFailure(
|
||||
new Error(`pending launch write exceeded ${PENDING_LAUNCH_WRITE_BUDGET_MS}ms`),
|
||||
);
|
||||
try {
|
||||
if ((await Promise.race([write, timeout])) === "timeout") {
|
||||
this.logPendingLaunchFailure(
|
||||
new Error(`pending launch write exceeded ${PENDING_LAUNCH_WRITE_BUDGET_MS}ms`),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
await interaction.launchActivity();
|
||||
|
||||
@@ -561,8 +561,8 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
||||
...(params.statusSink ? { statusSink: params.statusSink } : {}),
|
||||
});
|
||||
|
||||
durableIngress?.start();
|
||||
try {
|
||||
durableIngress?.start();
|
||||
if (connectionMode === "webhook") {
|
||||
return await monitorWebhook({
|
||||
account,
|
||||
|
||||
@@ -11,6 +11,8 @@ const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuAiAgentMock = vi.hoisted(() => vi.fn());
|
||||
const readCachedFeishuBotIdentityMock = vi.hoisted(() => vi.fn());
|
||||
const writeCachedFeishuBotIdentityMock = vi.hoisted(() => vi.fn());
|
||||
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuDurableIngressMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
@@ -24,7 +26,18 @@ vi.mock("./bot-identity-cache.js", () => ({
|
||||
|
||||
vi.mock("./client.js", async () => {
|
||||
const { createFeishuClientMockModule } = await import("./monitor.test-mocks.js");
|
||||
return createFeishuClientMockModule();
|
||||
return {
|
||||
...createFeishuClientMockModule(),
|
||||
createEventDispatcher: createEventDispatcherMock,
|
||||
};
|
||||
});
|
||||
vi.mock("./feishu-ingress.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./feishu-ingress.js")>();
|
||||
return {
|
||||
...actual,
|
||||
createFeishuDurableIngress: (...args: Parameters<typeof actual.createFeishuDurableIngress>) =>
|
||||
createFeishuDurableIngressMock(...args) ?? actual.createFeishuDurableIngress(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("./runtime.js", async () => {
|
||||
const { createFeishuRuntimeMockModule } = await import("./monitor.test-mocks.js");
|
||||
@@ -39,6 +52,8 @@ beforeEach(() => {
|
||||
registerFeishuAiAgentMock.mockReset().mockResolvedValue({ ok: true });
|
||||
readCachedFeishuBotIdentityMock.mockReset().mockResolvedValue(null);
|
||||
writeCachedFeishuBotIdentityMock.mockReset().mockResolvedValue(undefined);
|
||||
createEventDispatcherMock.mockReset().mockReturnValue({ register: vi.fn() });
|
||||
createFeishuDurableIngressMock.mockReset().mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
||||
@@ -78,11 +93,42 @@ afterEach(() => {
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./probe.js");
|
||||
vi.doUnmock("./client.js");
|
||||
vi.doUnmock("./feishu-ingress.js");
|
||||
vi.doUnmock("./runtime.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Feishu monitor startup preflight", () => {
|
||||
it("stops durable ingress when ingress start throws", async () => {
|
||||
const startError = new Error("durable ingress unavailable");
|
||||
const ingressStart = vi.fn(() => {
|
||||
throw startError;
|
||||
});
|
||||
const ingressStop = vi.fn().mockResolvedValue(undefined);
|
||||
createEventDispatcherMock.mockReturnValue({ register: vi.fn(), invoke: vi.fn() });
|
||||
createFeishuDurableIngressMock.mockReturnValue({
|
||||
invoke: vi.fn(),
|
||||
resolveLifecycle: vi.fn(),
|
||||
setSocketTerminator: vi.fn(),
|
||||
start: ingressStart,
|
||||
stop: ingressStop,
|
||||
waitForIdle: vi.fn(),
|
||||
});
|
||||
probeFeishuMock.mockResolvedValue({
|
||||
ok: true,
|
||||
appId: "cli_alpha",
|
||||
botOpenId: "bot_alpha",
|
||||
botName: "Alpha",
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorFeishuProvider({ config: buildMultiAccountWebsocketConfig(["alpha"]) }),
|
||||
).rejects.toBe(startError);
|
||||
|
||||
expect(ingressStart).toHaveBeenCalledTimes(1);
|
||||
expect(ingressStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("parses startup probe timeout env strictly", () => {
|
||||
expect(resolveStartupProbeTimeoutMs({})).toBe(30_000);
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Feishu plugin module implements monitor mocks behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createChannelIngressQueueForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createFeishuClientMockModule(): {
|
||||
@@ -12,36 +16,39 @@ export function createFeishuClientMockModule(): {
|
||||
}
|
||||
|
||||
export function createFeishuRuntimeMockModule(): {
|
||||
getFeishuRuntime: () => {
|
||||
getFeishuRuntime: () => PluginRuntime;
|
||||
} {
|
||||
const stateDir = path.join(
|
||||
process.env.HOME ?? process.cwd(),
|
||||
`.openclaw-feishu-monitor-${randomUUID()}`,
|
||||
);
|
||||
const runtime = {
|
||||
state: {
|
||||
resolveStateDir: () => stateDir,
|
||||
openChannelIngressQueue: (
|
||||
options?: Omit<Parameters<typeof createChannelIngressQueueForTests>[0], "channelId">,
|
||||
) =>
|
||||
createChannelIngressQueueForTests({
|
||||
...options,
|
||||
channelId: "feishu",
|
||||
stateDir: options?.stateDir ?? stateDir,
|
||||
}),
|
||||
},
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => number;
|
||||
createInboundDebouncer: () => {
|
||||
enqueue: () => Promise<void>;
|
||||
flushKey: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
text: {
|
||||
hasControlCommand: () => boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
} {
|
||||
return {
|
||||
getFeishuRuntime: () => ({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: async () => {},
|
||||
flushKey: async () => {},
|
||||
cancelKey: () => false,
|
||||
}),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: async () => {},
|
||||
flushKey: async () => {},
|
||||
cancelKey: () => false,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
return {
|
||||
getFeishuRuntime: () => runtime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ const resolveMatrixMentionsForBodyMock = vi.hoisted(() =>
|
||||
};
|
||||
}),
|
||||
);
|
||||
const getGlobalHookRunnerMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
getGlobalHookRunner: getGlobalHookRunnerMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
editMessageMatrix: editMessageMatrixMock,
|
||||
@@ -116,6 +125,7 @@ async function writeMatrixSessionMeta(
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
installMatrixMonitorTestRuntime();
|
||||
getGlobalHookRunnerMock.mockReset().mockReturnValue(null);
|
||||
prepareMatrixSingleTextMock.mockReset().mockImplementation((text: string) => {
|
||||
const trimmedText = text.trim();
|
||||
return {
|
||||
@@ -2988,6 +2998,53 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("preserves provider previews for observer-only hooks", async () => {
|
||||
getGlobalHookRunnerMock.mockReturnValue({
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "message_sent"),
|
||||
});
|
||||
const { dispatch } = createStreamingHarness({ streaming: "partial" });
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Visible preview" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await deliver({ text: "Visible preview" }, { kind: "final" });
|
||||
|
||||
expectEditLiveFlag("$draft1", "Visible preview", false);
|
||||
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
|
||||
await finish();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "reply_payload_sending", hooks: ["reply_payload_sending"] },
|
||||
{ label: "message_sending", hooks: ["message_sending"] },
|
||||
{
|
||||
label: "both modifying hooks",
|
||||
hooks: ["reply_payload_sending", "message_sending"],
|
||||
},
|
||||
])("suppresses provider previews when $label is registered", async ({ hooks }) => {
|
||||
const registered = new Set(hooks);
|
||||
getGlobalHookRunnerMock.mockReturnValue({
|
||||
hasHooks: vi.fn((hookName: string) => registered.has(hookName)),
|
||||
});
|
||||
const { dispatch } = createStreamingHarness({
|
||||
previewToolProgressEnabled: true,
|
||||
streaming: "progress",
|
||||
});
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
expect(opts.onPartialReply).toBeUndefined();
|
||||
expect(opts.onToolStart).toBeUndefined();
|
||||
expect(opts.suppressDefaultToolProgressMessages).toBeUndefined();
|
||||
await deliver({ text: "Durable final" }, { kind: "final" });
|
||||
|
||||
expect(sendSingleTextMessageMatrixMock).not.toHaveBeenCalled();
|
||||
expect(editMessageMatrixMock).not.toHaveBeenCalled();
|
||||
expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("streams tool progress into the Matrix draft preview when enabled", async () => {
|
||||
const { dispatch, redactEventMock } = createStreamingHarness({
|
||||
previewToolProgressEnabled: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
@@ -388,9 +389,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
},
|
||||
});
|
||||
// Matrix drafts are provider-visible before outbound modifiers run. Keep them off when a
|
||||
// hook can rewrite or cancel so the original payload cannot escape the delivery gate.
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const allowProviderPreview = !(
|
||||
(hookRunner?.hasHooks("reply_payload_sending") ?? false) ||
|
||||
(hookRunner?.hasHooks("message_sending") ?? false)
|
||||
);
|
||||
const draftController = await createMatrixDraftController({
|
||||
streaming,
|
||||
previewToolProgressEnabled,
|
||||
streaming: allowProviderPreview ? streaming : "off",
|
||||
previewToolProgressEnabled: allowProviderPreview && previewToolProgressEnabled,
|
||||
replyToMode,
|
||||
messageId,
|
||||
threadTarget,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { buildMemoryFlushPlan } from "./src/flush-plan.js";
|
||||
import type { MemoryCoreAcquireLocalService } from "./src/memory/embedding-local-service.js";
|
||||
import type { MemoryCoreRuntimeHost } from "./src/memory/runtime-host.js";
|
||||
import { buildPromptSection } from "./src/prompt-section.js";
|
||||
import { registerSessionBackfillGatewayMethods } from "./src/session-backfill-gateway.js";
|
||||
|
||||
type MemoryToolsModule = typeof import("./src/tools.js");
|
||||
type StandingIntentToolModule = typeof import("./src/standing-intents-tool.js");
|
||||
@@ -320,6 +321,7 @@ export default definePluginEntry({
|
||||
);
|
||||
const memoryRuntime = createLazyMemoryRuntime(host);
|
||||
registerShortTermPromotionDreaming(api);
|
||||
registerSessionBackfillGatewayMethods(api);
|
||||
registerMemoryManagerWarmup(api, memoryRuntime);
|
||||
api.registerMemoryCapability({
|
||||
promptBuilder: buildPromptSection,
|
||||
|
||||
@@ -692,6 +692,131 @@ describe("gateway startup reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers on the runtime interval after startup cron reconciliation fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness([], { listThrowsForFirstCalls: 1 });
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.listCalls).toBe(1);
|
||||
expect(harness.addCalls).toHaveLength(0);
|
||||
expectLogContains(logger.error, "dreaming startup reconciliation failed");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(constants.RUNTIME_CRON_RECONCILE_INTERVAL_MS);
|
||||
|
||||
expect(harness.listCalls).toBe(2);
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
expectCronSchedule(requireAddCall(harness, 0).schedule, "15 4 * * *", "UTC");
|
||||
} finally {
|
||||
await triggerGatewayStop(onMock).catch(() => undefined);
|
||||
vi.useRealTimers();
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not arm runtime recovery after gateway_stop wins the startup race", async () => {
|
||||
vi.useFakeTimers();
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
let rejectStartupList: (reason?: unknown) => void = () => undefined;
|
||||
const startupListPromise = new Promise<CronJobLike[]>((_resolve, reject) => {
|
||||
rejectStartupList = reject;
|
||||
});
|
||||
let listCalls = 0;
|
||||
const addCalls: CronAddInput[] = [];
|
||||
const cron: CronParam = {
|
||||
async list() {
|
||||
listCalls += 1;
|
||||
if (listCalls === 1) {
|
||||
return startupListPromise;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async add(input) {
|
||||
addCalls.push(input);
|
||||
return {};
|
||||
},
|
||||
async update() {
|
||||
return {};
|
||||
},
|
||||
async remove() {
|
||||
return { removed: false };
|
||||
},
|
||||
};
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
const startup = triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => cron,
|
||||
});
|
||||
|
||||
await triggerGatewayStop(onMock);
|
||||
rejectStartupList(new Error("startup list failed"));
|
||||
await startup;
|
||||
await vi.advanceTimersByTimeAsync(constants.RUNTIME_CRON_RECONCILE_INTERVAL_MS);
|
||||
|
||||
expect(listCalls).toBe(1);
|
||||
expect(addCalls).toHaveLength(0);
|
||||
expectLogContains(logger.error, "dreaming startup reconciliation failed");
|
||||
} finally {
|
||||
await triggerGatewayStop(onMock).catch(() => undefined);
|
||||
vi.useRealTimers();
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles disabled->enabled config changes during runtime", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
|
||||
@@ -935,7 +935,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
};
|
||||
|
||||
const startRuntimeCronReconcileTimer = (): void => {
|
||||
if (runtimeCronReconcileTimer) {
|
||||
if (disposed || runtimeCronReconcileTimer) {
|
||||
return;
|
||||
}
|
||||
runtimeCronReconcileTimer = setInterval(() => {
|
||||
@@ -956,12 +956,13 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
startupConfig: ctx.config,
|
||||
startupCron: () => resolveCronServiceFromGatewayContext(ctx),
|
||||
});
|
||||
startRuntimeCronReconcileTimer();
|
||||
scheduleStartupCronRetry();
|
||||
} catch (err) {
|
||||
api.logger.error(
|
||||
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
} finally {
|
||||
startRuntimeCronReconcileTimer();
|
||||
scheduleStartupCronRetry();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerSessionBackfillGatewayMethods } from "./session-backfill-gateway.js";
|
||||
import { executeSessionBackfill, executeSessionBackfillBatch } from "./session-backfill.js";
|
||||
|
||||
vi.mock("./session-backfill.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./session-backfill.js")>()),
|
||||
executeSessionBackfill: vi.fn(),
|
||||
executeSessionBackfillBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
type RegisteredMethod = {
|
||||
handler: (options: GatewayRequestHandlerOptions) => Promise<void>;
|
||||
scope: string | undefined;
|
||||
};
|
||||
|
||||
const executeMock = vi.mocked(executeSessionBackfill);
|
||||
const executeBatchMock = vi.mocked(executeSessionBackfillBatch);
|
||||
const SESSION_BACKFILL_GATEWAY_METHODS = {
|
||||
preview: "memory.sessionBackfill.preview",
|
||||
apply: "memory.sessionBackfill.apply",
|
||||
rollback: "memory.sessionBackfill.rollback",
|
||||
} as const;
|
||||
|
||||
function createHarness(config?: Record<string, unknown>) {
|
||||
const methods = new Map<string, RegisteredMethod>();
|
||||
const runtimeConfig =
|
||||
config !== undefined
|
||||
? config
|
||||
: {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace: "/tmp/main-workspace" }],
|
||||
},
|
||||
};
|
||||
const api = {
|
||||
runtime: {
|
||||
config: { current: () => runtimeConfig },
|
||||
agent: {
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/main-workspace"),
|
||||
},
|
||||
},
|
||||
registerGatewayMethod(
|
||||
method: string,
|
||||
handler: RegisteredMethod["handler"],
|
||||
options?: { scope?: string },
|
||||
) {
|
||||
methods.set(method, { handler, scope: options?.scope });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
registerSessionBackfillGatewayMethods(api);
|
||||
return { api, methods };
|
||||
}
|
||||
|
||||
async function invoke(method: RegisteredMethod, params: unknown) {
|
||||
const respond = vi.fn();
|
||||
await method.handler({ params, respond } as unknown as GatewayRequestHandlerOptions);
|
||||
return respond;
|
||||
}
|
||||
|
||||
describe("session backfill gateway methods", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers read preview and admin mutation methods", () => {
|
||||
const { methods } = createHarness();
|
||||
expect([...methods.entries()].map(([name, value]) => [name, value.scope])).toEqual([
|
||||
[SESSION_BACKFILL_GATEWAY_METHODS.preview, "operator.read"],
|
||||
[SESSION_BACKFILL_GATEWAY_METHODS.apply, "operator.admin"],
|
||||
[SESSION_BACKFILL_GATEWAY_METHODS.rollback, "operator.admin"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates preview params and returns at most three samples per day", async () => {
|
||||
const { methods } = createHarness();
|
||||
executeBatchMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/main-workspace",
|
||||
applied: false,
|
||||
rem: false,
|
||||
days: [
|
||||
{
|
||||
day: "2026-07-01",
|
||||
candidateCount: 4,
|
||||
topCandidates: ["one", "two", "three", "four"],
|
||||
},
|
||||
],
|
||||
candidateCount: 4,
|
||||
stagedEntries: 0,
|
||||
writtenDiaryEntries: 0,
|
||||
replacedDiaryEntries: 0,
|
||||
},
|
||||
continuation: { advanced: false, hasMore: true },
|
||||
});
|
||||
const preview = methods.get(SESSION_BACKFILL_GATEWAY_METHODS.preview)!;
|
||||
const respond = await invoke(preview, {
|
||||
agentId: "main",
|
||||
from: "2026-07-01",
|
||||
to: "2026-07-31",
|
||||
limitDays: 14,
|
||||
});
|
||||
|
||||
expect(executeBatchMock).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
from: "2026-07-01",
|
||||
to: "2026-07-31",
|
||||
limitDays: 14,
|
||||
workspaceDir: "/tmp/main-workspace",
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
days: 1,
|
||||
candidates: 4,
|
||||
perDay: [{ day: "2026-07-01", candidateCount: 4, sample: ["one", "two", "three"] }],
|
||||
staged: 0,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid ranges and unknown params before executing", async () => {
|
||||
const { methods } = createHarness();
|
||||
const preview = methods.get(SESSION_BACKFILL_GATEWAY_METHODS.preview)!;
|
||||
const invalidRange = await invoke(preview, {
|
||||
agentId: "main",
|
||||
from: "2026-08-01",
|
||||
to: "2026-07-01",
|
||||
});
|
||||
const unexpected = await invoke(preview, { agentId: "main", archiveFiles: [] });
|
||||
|
||||
expect(executeMock).not.toHaveBeenCalled();
|
||||
expect(invalidRange.mock.calls[0]?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "from must not be after to.",
|
||||
});
|
||||
expect(unexpected.mock.calls[0]?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unexpected parameter: archiveFiles",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown agents as invalid requests", async () => {
|
||||
const { methods } = createHarness();
|
||||
const respond = await invoke(methods.get(SESSION_BACKFILL_GATEWAY_METHODS.preview)!, {
|
||||
agentId: "missing",
|
||||
});
|
||||
|
||||
expect(executeMock).not.toHaveBeenCalled();
|
||||
expect(respond.mock.calls[0]?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: 'Unknown agent id "missing".',
|
||||
});
|
||||
});
|
||||
|
||||
it("allows only the implicit default agent when no roster is configured", async () => {
|
||||
const { methods } = createHarness({});
|
||||
const preview = methods.get(SESSION_BACKFILL_GATEWAY_METHODS.preview)!;
|
||||
const missing = await invoke(preview, { agentId: "missing" });
|
||||
|
||||
expect(missing.mock.calls[0]?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: 'Unknown agent id "missing".',
|
||||
});
|
||||
});
|
||||
|
||||
it("applies a chunk with cursor progress and rolls back by agent", async () => {
|
||||
const { methods } = createHarness();
|
||||
executeBatchMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/main-workspace",
|
||||
applied: true,
|
||||
rem: false,
|
||||
days: [{ day: "2026-07-01", candidateCount: 2, topCandidates: ["one", "two"] }],
|
||||
candidateCount: 2,
|
||||
stagedEntries: 1,
|
||||
writtenDiaryEntries: 1,
|
||||
replacedDiaryEntries: 0,
|
||||
},
|
||||
continuation: { advanced: true, hasMore: false },
|
||||
});
|
||||
executeMock.mockResolvedValueOnce({
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/main-workspace",
|
||||
applied: false,
|
||||
rem: false,
|
||||
days: [],
|
||||
candidateCount: 0,
|
||||
stagedEntries: 0,
|
||||
writtenDiaryEntries: 0,
|
||||
replacedDiaryEntries: 0,
|
||||
rollback: { removedDiaryEntries: 3, removedStagedEntries: 2 },
|
||||
});
|
||||
|
||||
const applyRespond = await invoke(methods.get(SESSION_BACKFILL_GATEWAY_METHODS.apply)!, {
|
||||
agentId: "main",
|
||||
limitDays: 14,
|
||||
});
|
||||
expect(applyRespond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
days: 1,
|
||||
candidates: 2,
|
||||
staged: 1,
|
||||
cursor: { advanced: true, exhausted: false, hasMore: false },
|
||||
}),
|
||||
);
|
||||
const rollbackRespond = await invoke(methods.get(SESSION_BACKFILL_GATEWAY_METHODS.rollback)!, {
|
||||
agentId: "main",
|
||||
});
|
||||
expect(rollbackRespond).toHaveBeenCalledWith(true, {
|
||||
removedDiaryEntries: 3,
|
||||
removedStagedEntries: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user