mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 10:21:45 +08:00
Compare commits
66 Commits
codex/i18n
...
codex/mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432b941ca6 | ||
|
|
e2bea545e0 | ||
|
|
6533bb0216 | ||
|
|
956f35a8c9 | ||
|
|
74bd78b464 | ||
|
|
1ba45b3d94 | ||
|
|
84c531709f | ||
|
|
dc575d148a | ||
|
|
12685ee6b7 | ||
|
|
36722014ef | ||
|
|
1b8b8500ce | ||
|
|
c29e1fe764 | ||
|
|
c52adf7505 | ||
|
|
199700de26 | ||
|
|
b14a95b3fd | ||
|
|
ebf1ba70d5 | ||
|
|
78d70230b6 | ||
|
|
98ed83f848 | ||
|
|
1bdde66950 | ||
|
|
2720ac06b7 | ||
|
|
ce15f348bb | ||
|
|
e5c3c59c67 | ||
|
|
2e881ab1c6 | ||
|
|
90c20d15c2 | ||
|
|
cb8bc71ff8 | ||
|
|
b5c662f4f5 | ||
|
|
d693ed4af3 | ||
|
|
6c5a9fde9f | ||
|
|
b8e3de1160 | ||
|
|
b9c64142e2 | ||
|
|
84bcd500c9 | ||
|
|
f857e8d66e | ||
|
|
a048aeae16 | ||
|
|
4b9e01813e | ||
|
|
7830faa5fe | ||
|
|
ddedf13190 | ||
|
|
cb4244fe15 | ||
|
|
361869e434 | ||
|
|
4010b81a77 | ||
|
|
8fa24325b5 | ||
|
|
f4fa10c2c5 | ||
|
|
2100ee7cc8 | ||
|
|
6e8f30c0e2 | ||
|
|
9d800b71c0 | ||
|
|
5ccfc97b31 | ||
|
|
a7bfc06f45 | ||
|
|
c5d34c8376 | ||
|
|
fbfadbd806 | ||
|
|
6f1076351c | ||
|
|
898ca9741c | ||
|
|
67118d5ab9 | ||
|
|
bf2a8ecfdb | ||
|
|
cee2aca409 | ||
|
|
56259606d1 | ||
|
|
552ec2b49d | ||
|
|
4d0f19a968 | ||
|
|
072d3ed7b5 | ||
|
|
1bccd29304 | ||
|
|
498567190d | ||
|
|
5880e0afc4 | ||
|
|
65fec9d787 | ||
|
|
4d9cd7d227 | ||
|
|
12ea61a08d | ||
|
|
4932366b92 | ||
|
|
4f3d81b918 | ||
|
|
e09b9dfc1b |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -848,32 +848,6 @@ jobs:
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
native-i18n:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check native app i18n inventory
|
||||
run: pnpm native:i18n:check
|
||||
|
||||
- name: Check Android app i18n resources
|
||||
if: needs.preflight.outputs.run_android == 'true'
|
||||
run: pnpm android:i18n:check
|
||||
|
||||
checks-fast-core:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -81,7 +81,7 @@ Automatic fast mode starts short conversations quickly, then returns longer or f
|
||||
|
||||
- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695).
|
||||
|
||||
### Plugins, Packaging, and QA
|
||||
### Plugins and Packaging
|
||||
|
||||
#### Codex service-tier clearing
|
||||
|
||||
@@ -96,7 +96,6 @@ Automatic fast mode starts short conversations quickly, then returns longer or f
|
||||
#### Doctor check ordering
|
||||
|
||||
- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo.
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -52,7 +51,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -102,7 +100,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text(stringResource(R.string.trust_this_gateway), style = mobileHeadline, color = mobileText) },
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
text = {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
@@ -121,7 +119,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
Text(stringResource(R.string.trust_and_continue))
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@@ -129,7 +127,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -160,10 +158,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.gateway_connection), style = mobileTitle1, color = mobileText)
|
||||
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
|
||||
Text(
|
||||
if (isConnected) stringResource(R.string.connected_gateway_ready)
|
||||
else stringResource(R.string.connect_gateway_get_started),
|
||||
if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
@@ -194,7 +191,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.endpoint), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
}
|
||||
}
|
||||
@@ -216,7 +213,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.status), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText)
|
||||
}
|
||||
}
|
||||
@@ -241,7 +238,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.disconnect), style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
@@ -310,7 +307,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.connect_gateway), style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +354,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.copy_report_for_claw), style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,7 +373,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.advanced_controls), style = mobileHeadline, color = mobileText)
|
||||
Text("Advanced controls", style = mobileHeadline, color = mobileText)
|
||||
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
|
||||
}
|
||||
Icon(
|
||||
@@ -398,15 +395,15 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.connection_method), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MethodChip(
|
||||
label = stringResource(R.string.setup_code),
|
||||
label = "Setup Code",
|
||||
active = inputMode == ConnectInputMode.SetupCode,
|
||||
onClick = { inputMode = ConnectInputMode.SetupCode },
|
||||
)
|
||||
MethodChip(
|
||||
label = stringResource(R.string.manual),
|
||||
label = "Manual",
|
||||
active = inputMode == ConnectInputMode.Manual,
|
||||
onClick = { inputMode = ConnectInputMode.Manual },
|
||||
)
|
||||
@@ -422,14 +419,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
Text(stringResource(R.string.setup_code), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = setupCode,
|
||||
onValueChange = {
|
||||
setupCode = it
|
||||
validationText = null
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.paste_setup_code), style = mobileBody, color = mobileTextTertiary) },
|
||||
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
@@ -463,7 +460,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.host), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualHostInput,
|
||||
onValueChange = {
|
||||
@@ -505,7 +502,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.use_tls), style = mobileHeadline, color = mobileText)
|
||||
Text("Use TLS", style = mobileHeadline, color = mobileText)
|
||||
Text(
|
||||
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
|
||||
style = mobileCallout,
|
||||
@@ -528,7 +525,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayToken,
|
||||
onValueChange = { viewModel.setGatewayToken(it) },
|
||||
@@ -549,7 +546,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
OutlinedTextField(
|
||||
value = passwordInput,
|
||||
onValueChange = { passwordInput = it },
|
||||
placeholder = { Text(stringResource(R.string.password), style = mobileBody, color = mobileTextTertiary) },
|
||||
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
@@ -566,7 +563,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
|
||||
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
|
||||
Text(stringResource(R.string.run_onboarding_again), style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -213,13 +212,7 @@ fun OnboardingFlow(
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::declineGatewayTrustPrompt,
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.trust_this_gateway),
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
},
|
||||
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
text = {
|
||||
Text(
|
||||
"Verify the certificate fingerprint before continuing.\n\n${prompt.fingerprintSha256}",
|
||||
@@ -229,12 +222,12 @@ fun OnboardingFlow(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::acceptGatewayTrustPrompt) {
|
||||
Text(stringResource(R.string.trust_and_continue))
|
||||
Text("Trust")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::declineGatewayTrustPrompt) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -541,24 +534,20 @@ private fun GatewaySetupScreen(
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
item {
|
||||
OnboardingHeader(
|
||||
title = stringResource(R.string.gateway_setup),
|
||||
subtitle = stringResource(R.string.connect_to_gateway),
|
||||
onBack = onBack,
|
||||
)
|
||||
OnboardingHeader(title = "Gateway Setup", subtitle = "Connect to your Gateway", onBack = onBack)
|
||||
}
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.QrCode2,
|
||||
title = stringResource(R.string.scan_setup_code),
|
||||
subtitle = stringResource(R.string.use_gateway_qr),
|
||||
title = "Scan setup code",
|
||||
subtitle = "Use your Gateway QR or setup code",
|
||||
onClick = onScan,
|
||||
)
|
||||
}
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.WifiTethering,
|
||||
title = stringResource(R.string.nearby_gateway),
|
||||
title = "Nearby gateway",
|
||||
subtitle = nearbyGateway.subtitle,
|
||||
status = nearbyGateway.status,
|
||||
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
|
||||
@@ -567,8 +556,8 @@ private fun GatewaySetupScreen(
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.Link,
|
||||
title = stringResource(R.string.enter_gateway_url),
|
||||
subtitle = stringResource(R.string.connect_manual_url),
|
||||
title = "Enter gateway URL",
|
||||
subtitle = "Connect using a manual URL",
|
||||
onClick = { advancedOpen = true },
|
||||
)
|
||||
}
|
||||
@@ -649,7 +638,7 @@ private fun GatewayRecoveryScreen(
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
OnboardingHeader(title = stringResource(R.string.gateway_setup), onBack = onBack)
|
||||
OnboardingHeader(title = "Gateway Recovery", onBack = onBack)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(
|
||||
@@ -934,9 +923,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHelp = false },
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = {
|
||||
Text(stringResource(R.string.permissions), style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
},
|
||||
title = { Text("Permissions", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
text = {
|
||||
Text(
|
||||
"Choose what this phone can share with OpenClaw. You can change these later in Settings.",
|
||||
@@ -946,7 +933,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showHelp = false }) {
|
||||
Text(stringResource(R.string.done))
|
||||
Text("Done")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">اتصال البوابة</string>
|
||||
<string name="connect_gateway">توصيل البوابة</string>
|
||||
<string name="disconnect">قطع الاتصال</string>
|
||||
<string name="trust_this_gateway">هل تثق بهذه البوابة؟</string>
|
||||
<string name="trust_and_continue">الثقة والمتابعة</string>
|
||||
<string name="cancel">إلغاء</string>
|
||||
<string name="endpoint">نقطة النهاية</string>
|
||||
<string name="status">الحالة</string>
|
||||
<string name="connected_gateway_ready">بوابتك نشطة وجاهزة.</string>
|
||||
<string name="connect_gateway_get_started">اتصل ببوابتك للبدء.</string>
|
||||
<string name="copy_report_for_claw">نسخ التقرير لـ Claw</string>
|
||||
<string name="advanced_controls">عناصر التحكم المتقدمة</string>
|
||||
<string name="connection_method">طريقة الاتصال</string>
|
||||
<string name="setup_code">رمز الإعداد</string>
|
||||
<string name="manual">يدوي</string>
|
||||
<string name="paste_setup_code">الصق رمز الإعداد</string>
|
||||
<string name="host">المضيف</string>
|
||||
<string name="use_tls">استخدام TLS</string>
|
||||
<string name="token_optional">الرمز المميز (اختياري)</string>
|
||||
<string name="password">كلمة المرور</string>
|
||||
<string name="run_onboarding_again">تشغيل الإعداد الأولي مرة أخرى</string>
|
||||
<string name="resolved_endpoint">نقطة النهاية التي تم حلها</string>
|
||||
<string name="gateway_setup">إعداد البوابة</string>
|
||||
<string name="connect_to_gateway">الاتصال ببوابتك</string>
|
||||
<string name="scan_setup_code">مسح رمز الإعداد</string>
|
||||
<string name="use_gateway_qr">استخدم رمز QR الخاص ببوابتك أو رمز الإعداد</string>
|
||||
<string name="nearby_gateway">بوابة قريبة</string>
|
||||
<string name="enter_gateway_url">أدخل عنوان URL للبوابة</string>
|
||||
<string name="connect_manual_url">الاتصال باستخدام عنوان URL يدوي</string>
|
||||
<string name="permissions">الأذونات</string>
|
||||
<string name="done">تم</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Gateway-Verbindung</string>
|
||||
<string name="connect_gateway">Gateway verbinden</string>
|
||||
<string name="disconnect">Trennen</string>
|
||||
<string name="trust_this_gateway">Diesem Gateway vertrauen?</string>
|
||||
<string name="trust_and_continue">Vertrauen und fortfahren</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="endpoint">Endpunkt</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Ihr Gateway ist aktiv und bereit.</string>
|
||||
<string name="connect_gateway_get_started">Verbinden Sie sich mit Ihrem Gateway, um loszulegen.</string>
|
||||
<string name="copy_report_for_claw">Bericht für Claw kopieren</string>
|
||||
<string name="advanced_controls">Erweiterte Steuerungen</string>
|
||||
<string name="connection_method">Verbindungsmethode</string>
|
||||
<string name="setup_code">Einrichtungscode</string>
|
||||
<string name="manual">Manuell</string>
|
||||
<string name="paste_setup_code">Einrichtungscode einfügen</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">TLS verwenden</string>
|
||||
<string name="token_optional">Token (optional)</string>
|
||||
<string name="password">Passwort</string>
|
||||
<string name="run_onboarding_again">Onboarding erneut ausführen</string>
|
||||
<string name="resolved_endpoint">Aufgelöster Endpunkt</string>
|
||||
<string name="gateway_setup">Gateway-Einrichtung</string>
|
||||
<string name="connect_to_gateway">Mit Ihrem Gateway verbinden</string>
|
||||
<string name="scan_setup_code">Einrichtungscode scannen</string>
|
||||
<string name="use_gateway_qr">Verwenden Sie Ihren Gateway-QR- oder Einrichtungscode</string>
|
||||
<string name="nearby_gateway">Gateway in der Nähe</string>
|
||||
<string name="enter_gateway_url">Gateway-URL eingeben</string>
|
||||
<string name="connect_manual_url">Über eine manuelle URL verbinden</string>
|
||||
<string name="permissions">Berechtigungen</string>
|
||||
<string name="done">Fertig</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Conexión de Gateway</string>
|
||||
<string name="connect_gateway">Conectar Gateway</string>
|
||||
<string name="disconnect">Desconectar</string>
|
||||
<string name="trust_this_gateway">¿Confiar en este gateway?</string>
|
||||
<string name="trust_and_continue">Confiar y continuar</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Estado</string>
|
||||
<string name="connected_gateway_ready">Tu gateway está activo y listo.</string>
|
||||
<string name="connect_gateway_get_started">Conéctate a tu gateway para empezar.</string>
|
||||
<string name="copy_report_for_claw">Copiar informe para Claw</string>
|
||||
<string name="advanced_controls">Controles avanzados</string>
|
||||
<string name="connection_method">Método de conexión</string>
|
||||
<string name="setup_code">Código de configuración</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Pegar código de configuración</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Usar TLS</string>
|
||||
<string name="token_optional">Token (opcional)</string>
|
||||
<string name="password">Contraseña</string>
|
||||
<string name="run_onboarding_again">Ejecutar la incorporación de nuevo</string>
|
||||
<string name="resolved_endpoint">Endpoint resuelto</string>
|
||||
<string name="gateway_setup">Configuración de Gateway</string>
|
||||
<string name="connect_to_gateway">Conéctate a tu Gateway</string>
|
||||
<string name="scan_setup_code">Escanear código de configuración</string>
|
||||
<string name="use_gateway_qr">Usa el QR o código de configuración de tu Gateway</string>
|
||||
<string name="nearby_gateway">Gateway cercano</string>
|
||||
<string name="enter_gateway_url">Introduce la URL del gateway</string>
|
||||
<string name="connect_manual_url">Conectar usando una URL manual</string>
|
||||
<string name="permissions">Permisos</string>
|
||||
<string name="done">Listo</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">اتصال دروازه</string>
|
||||
<string name="connect_gateway">اتصال به دروازه</string>
|
||||
<string name="disconnect">قطع اتصال</string>
|
||||
<string name="trust_this_gateway">به این دروازه اعتماد دارید؟</string>
|
||||
<string name="trust_and_continue">اعتماد و ادامه</string>
|
||||
<string name="cancel">لغو</string>
|
||||
<string name="endpoint">نقطه پایانی</string>
|
||||
<string name="status">وضعیت</string>
|
||||
<string name="connected_gateway_ready">دروازه شما فعال و آماده است.</string>
|
||||
<string name="connect_gateway_get_started">برای شروع، به دروازه خود متصل شوید.</string>
|
||||
<string name="copy_report_for_claw">کپی گزارش برای Claw</string>
|
||||
<string name="advanced_controls">کنترلهای پیشرفته</string>
|
||||
<string name="connection_method">روش اتصال</string>
|
||||
<string name="setup_code">کد راهاندازی</string>
|
||||
<string name="manual">دستی</string>
|
||||
<string name="paste_setup_code">کد راهاندازی را جایگذاری کنید</string>
|
||||
<string name="host">میزبان</string>
|
||||
<string name="use_tls">استفاده از TLS</string>
|
||||
<string name="token_optional">توکن (اختیاری)</string>
|
||||
<string name="password">رمز عبور</string>
|
||||
<string name="run_onboarding_again">اجرای دوباره فرایند شروع به کار</string>
|
||||
<string name="resolved_endpoint">نقطه پایانی حلشده</string>
|
||||
<string name="gateway_setup">راهاندازی دروازه</string>
|
||||
<string name="connect_to_gateway">به دروازه خود متصل شوید</string>
|
||||
<string name="scan_setup_code">اسکن کد راهاندازی</string>
|
||||
<string name="use_gateway_qr">از QR دروازه یا کد راهاندازی خود استفاده کنید</string>
|
||||
<string name="nearby_gateway">دروازه نزدیک</string>
|
||||
<string name="enter_gateway_url">URL دروازه را وارد کنید</string>
|
||||
<string name="connect_manual_url">اتصال با استفاده از URL دستی</string>
|
||||
<string name="permissions">مجوزها</string>
|
||||
<string name="done">انجام شد</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Connexion à la passerelle</string>
|
||||
<string name="connect_gateway">Connecter la passerelle</string>
|
||||
<string name="disconnect">Déconnecter</string>
|
||||
<string name="trust_this_gateway">Faire confiance à cette passerelle ?</string>
|
||||
<string name="trust_and_continue">Faire confiance et continuer</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="endpoint">Point de terminaison</string>
|
||||
<string name="status">État</string>
|
||||
<string name="connected_gateway_ready">Votre passerelle est active et prête.</string>
|
||||
<string name="connect_gateway_get_started">Connectez-vous à votre passerelle pour commencer.</string>
|
||||
<string name="copy_report_for_claw">Copier le rapport pour Claw</string>
|
||||
<string name="advanced_controls">Contrôles avancés</string>
|
||||
<string name="connection_method">Méthode de connexion</string>
|
||||
<string name="setup_code">Code de configuration</string>
|
||||
<string name="manual">Manuel</string>
|
||||
<string name="paste_setup_code">Coller le code de configuration</string>
|
||||
<string name="host">Hôte</string>
|
||||
<string name="use_tls">Utiliser TLS</string>
|
||||
<string name="token_optional">Jeton (facultatif)</string>
|
||||
<string name="password">Mot de passe</string>
|
||||
<string name="run_onboarding_again">Relancer l’intégration</string>
|
||||
<string name="resolved_endpoint">Point de terminaison résolu</string>
|
||||
<string name="gateway_setup">Configuration de la passerelle</string>
|
||||
<string name="connect_to_gateway">Connectez-vous à votre Gateway</string>
|
||||
<string name="scan_setup_code">Scanner le code de configuration</string>
|
||||
<string name="use_gateway_qr">Utilisez le QR de votre Gateway ou le code de configuration</string>
|
||||
<string name="nearby_gateway">Passerelle à proximité</string>
|
||||
<string name="enter_gateway_url">Saisir l’URL de la passerelle</string>
|
||||
<string name="connect_manual_url">Se connecter avec une URL manuelle</string>
|
||||
<string name="permissions">Autorisations</string>
|
||||
<string name="done">Terminé</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">गेटवे कनेक्शन</string>
|
||||
<string name="connect_gateway">गेटवे कनेक्ट करें</string>
|
||||
<string name="disconnect">डिस्कनेक्ट करें</string>
|
||||
<string name="trust_this_gateway">इस गेटवे पर भरोसा करें?</string>
|
||||
<string name="trust_and_continue">भरोसा करें और जारी रखें</string>
|
||||
<string name="cancel">रद्द करें</string>
|
||||
<string name="endpoint">एंडपॉइंट</string>
|
||||
<string name="status">स्थिति</string>
|
||||
<string name="connected_gateway_ready">आपका गेटवे सक्रिय और तैयार है।</string>
|
||||
<string name="connect_gateway_get_started">शुरू करने के लिए अपने गेटवे से कनेक्ट करें।</string>
|
||||
<string name="copy_report_for_claw">Claw के लिए रिपोर्ट कॉपी करें</string>
|
||||
<string name="advanced_controls">उन्नत नियंत्रण</string>
|
||||
<string name="connection_method">कनेक्शन विधि</string>
|
||||
<string name="setup_code">सेटअप कोड</string>
|
||||
<string name="manual">मैन्युअल</string>
|
||||
<string name="paste_setup_code">सेटअप कोड पेस्ट करें</string>
|
||||
<string name="host">होस्ट</string>
|
||||
<string name="use_tls">TLS का उपयोग करें</string>
|
||||
<string name="token_optional">टोकन (वैकल्पिक)</string>
|
||||
<string name="password">पासवर्ड</string>
|
||||
<string name="run_onboarding_again">ऑनबोर्डिंग फिर से चलाएँ</string>
|
||||
<string name="resolved_endpoint">रिज़ॉल्व किया गया एंडपॉइंट</string>
|
||||
<string name="gateway_setup">गेटवे सेटअप</string>
|
||||
<string name="connect_to_gateway">अपने गेटवे से कनेक्ट करें</string>
|
||||
<string name="scan_setup_code">सेटअप कोड स्कैन करें</string>
|
||||
<string name="use_gateway_qr">अपने गेटवे QR या सेटअप कोड का उपयोग करें</string>
|
||||
<string name="nearby_gateway">नज़दीकी गेटवे</string>
|
||||
<string name="enter_gateway_url">गेटवे URL दर्ज करें</string>
|
||||
<string name="connect_manual_url">मैन्युअल URL का उपयोग करके कनेक्ट करें</string>
|
||||
<string name="permissions">अनुमतियाँ</string>
|
||||
<string name="done">हो गया</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Koneksi Gateway</string>
|
||||
<string name="connect_gateway">Hubungkan Gateway</string>
|
||||
<string name="disconnect">Putuskan koneksi</string>
|
||||
<string name="trust_this_gateway">Percayai gateway ini?</string>
|
||||
<string name="trust_and_continue">Percayai dan lanjutkan</string>
|
||||
<string name="cancel">Batal</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Gateway Anda aktif dan siap.</string>
|
||||
<string name="connect_gateway_get_started">Hubungkan ke gateway Anda untuk memulai.</string>
|
||||
<string name="copy_report_for_claw">Salin Laporan untuk Claw</string>
|
||||
<string name="advanced_controls">Kontrol lanjutan</string>
|
||||
<string name="connection_method">Metode koneksi</string>
|
||||
<string name="setup_code">Kode Penyiapan</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Tempel kode penyiapan</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Gunakan TLS</string>
|
||||
<string name="token_optional">Token (opsional)</string>
|
||||
<string name="password">Kata sandi</string>
|
||||
<string name="run_onboarding_again">Jalankan onboarding lagi</string>
|
||||
<string name="resolved_endpoint">Endpoint yang diselesaikan</string>
|
||||
<string name="gateway_setup">Penyiapan Gateway</string>
|
||||
<string name="connect_to_gateway">Hubungkan ke Gateway Anda</string>
|
||||
<string name="scan_setup_code">Pindai kode penyiapan</string>
|
||||
<string name="use_gateway_qr">Gunakan QR Gateway atau kode penyiapan Anda</string>
|
||||
<string name="nearby_gateway">Gateway terdekat</string>
|
||||
<string name="enter_gateway_url">Masukkan URL gateway</string>
|
||||
<string name="connect_manual_url">Hubungkan menggunakan URL manual</string>
|
||||
<string name="permissions">Izin</string>
|
||||
<string name="done">Selesai</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Connessione al gateway</string>
|
||||
<string name="connect_gateway">Connetti gateway</string>
|
||||
<string name="disconnect">Disconnetti</string>
|
||||
<string name="trust_this_gateway">Considerare attendibile questo gateway?</string>
|
||||
<string name="trust_and_continue">Considera attendibile e continua</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Stato</string>
|
||||
<string name="connected_gateway_ready">Il tuo gateway è attivo e pronto.</string>
|
||||
<string name="connect_gateway_get_started">Connettiti al tuo gateway per iniziare.</string>
|
||||
<string name="copy_report_for_claw">Copia report per Claw</string>
|
||||
<string name="advanced_controls">Controlli avanzati</string>
|
||||
<string name="connection_method">Metodo di connessione</string>
|
||||
<string name="setup_code">Codice di configurazione</string>
|
||||
<string name="manual">Manuale</string>
|
||||
<string name="paste_setup_code">Incolla codice di configurazione</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Usa TLS</string>
|
||||
<string name="token_optional">Token (opzionale)</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="run_onboarding_again">Esegui di nuovo l'onboarding</string>
|
||||
<string name="resolved_endpoint">Endpoint risolto</string>
|
||||
<string name="gateway_setup">Configurazione gateway</string>
|
||||
<string name="connect_to_gateway">Connettiti al tuo Gateway</string>
|
||||
<string name="scan_setup_code">Scansiona codice di configurazione</string>
|
||||
<string name="use_gateway_qr">Usa il QR del tuo Gateway o il codice di configurazione</string>
|
||||
<string name="nearby_gateway">Gateway nelle vicinanze</string>
|
||||
<string name="enter_gateway_url">Inserisci URL del gateway</string>
|
||||
<string name="connect_manual_url">Connetti usando un URL manuale</string>
|
||||
<string name="permissions">Autorizzazioni</string>
|
||||
<string name="done">Fine</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">ゲートウェイ接続</string>
|
||||
<string name="connect_gateway">ゲートウェイに接続</string>
|
||||
<string name="disconnect">切断</string>
|
||||
<string name="trust_this_gateway">このゲートウェイを信頼しますか?</string>
|
||||
<string name="trust_and_continue">信頼して続行</string>
|
||||
<string name="cancel">キャンセル</string>
|
||||
<string name="endpoint">エンドポイント</string>
|
||||
<string name="status">ステータス</string>
|
||||
<string name="connected_gateway_ready">ゲートウェイはアクティブで準備完了です。</string>
|
||||
<string name="connect_gateway_get_started">開始するにはゲートウェイに接続してください。</string>
|
||||
<string name="copy_report_for_claw">Claw 用レポートをコピー</string>
|
||||
<string name="advanced_controls">詳細コントロール</string>
|
||||
<string name="connection_method">接続方法</string>
|
||||
<string name="setup_code">セットアップコード</string>
|
||||
<string name="manual">手動</string>
|
||||
<string name="paste_setup_code">セットアップコードを貼り付け</string>
|
||||
<string name="host">ホスト</string>
|
||||
<string name="use_tls">TLS を使用</string>
|
||||
<string name="token_optional">トークン(任意)</string>
|
||||
<string name="password">パスワード</string>
|
||||
<string name="run_onboarding_again">オンボーディングを再実行</string>
|
||||
<string name="resolved_endpoint">解決済みエンドポイント</string>
|
||||
<string name="gateway_setup">ゲートウェイ設定</string>
|
||||
<string name="connect_to_gateway">ゲートウェイに接続</string>
|
||||
<string name="scan_setup_code">セットアップコードをスキャン</string>
|
||||
<string name="use_gateway_qr">ゲートウェイの QR またはセットアップコードを使用</string>
|
||||
<string name="nearby_gateway">近くのゲートウェイ</string>
|
||||
<string name="enter_gateway_url">ゲートウェイ URL を入力</string>
|
||||
<string name="connect_manual_url">手動 URL で接続</string>
|
||||
<string name="permissions">権限</string>
|
||||
<string name="done">完了</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">게이트웨이 연결</string>
|
||||
<string name="connect_gateway">게이트웨이 연결</string>
|
||||
<string name="disconnect">연결 해제</string>
|
||||
<string name="trust_this_gateway">이 게이트웨이를 신뢰하시겠습니까?</string>
|
||||
<string name="trust_and_continue">신뢰하고 계속</string>
|
||||
<string name="cancel">취소</string>
|
||||
<string name="endpoint">엔드포인트</string>
|
||||
<string name="status">상태</string>
|
||||
<string name="connected_gateway_ready">게이트웨이가 활성화되어 준비되었습니다.</string>
|
||||
<string name="connect_gateway_get_started">시작하려면 게이트웨이에 연결하세요.</string>
|
||||
<string name="copy_report_for_claw">Claw용 보고서 복사</string>
|
||||
<string name="advanced_controls">고급 제어</string>
|
||||
<string name="connection_method">연결 방법</string>
|
||||
<string name="setup_code">설정 코드</string>
|
||||
<string name="manual">수동</string>
|
||||
<string name="paste_setup_code">설정 코드 붙여넣기</string>
|
||||
<string name="host">호스트</string>
|
||||
<string name="use_tls">TLS 사용</string>
|
||||
<string name="token_optional">토큰(선택 사항)</string>
|
||||
<string name="password">비밀번호</string>
|
||||
<string name="run_onboarding_again">온보딩 다시 실행</string>
|
||||
<string name="resolved_endpoint">확인된 엔드포인트</string>
|
||||
<string name="gateway_setup">게이트웨이 설정</string>
|
||||
<string name="connect_to_gateway">게이트웨이에 연결</string>
|
||||
<string name="scan_setup_code">설정 코드 스캔</string>
|
||||
<string name="use_gateway_qr">게이트웨이 QR 또는 설정 코드 사용</string>
|
||||
<string name="nearby_gateway">주변 게이트웨이</string>
|
||||
<string name="enter_gateway_url">게이트웨이 URL 입력</string>
|
||||
<string name="connect_manual_url">수동 URL을 사용하여 연결</string>
|
||||
<string name="permissions">권한</string>
|
||||
<string name="done">완료</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Gatewayverbinding</string>
|
||||
<string name="connect_gateway">Gateway verbinden</string>
|
||||
<string name="disconnect">Verbinding verbreken</string>
|
||||
<string name="trust_this_gateway">Deze gateway vertrouwen?</string>
|
||||
<string name="trust_and_continue">Vertrouwen en doorgaan</string>
|
||||
<string name="cancel">Annuleren</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Je gateway is actief en klaar voor gebruik.</string>
|
||||
<string name="connect_gateway_get_started">Verbind met je gateway om te beginnen.</string>
|
||||
<string name="copy_report_for_claw">Rapport voor Claw kopiëren</string>
|
||||
<string name="advanced_controls">Geavanceerde bediening</string>
|
||||
<string name="connection_method">Verbindingsmethode</string>
|
||||
<string name="setup_code">Setupcode</string>
|
||||
<string name="manual">Handmatig</string>
|
||||
<string name="paste_setup_code">Setupcode plakken</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">TLS gebruiken</string>
|
||||
<string name="token_optional">Token (optioneel)</string>
|
||||
<string name="password">Wachtwoord</string>
|
||||
<string name="run_onboarding_again">Onboarding opnieuw uitvoeren</string>
|
||||
<string name="resolved_endpoint">Opgelost endpoint</string>
|
||||
<string name="gateway_setup">Gateway instellen</string>
|
||||
<string name="connect_to_gateway">Verbinden met je Gateway</string>
|
||||
<string name="scan_setup_code">Setupcode scannen</string>
|
||||
<string name="use_gateway_qr">Gebruik je Gateway-QR-code of setupcode</string>
|
||||
<string name="nearby_gateway">Gateway in de buurt</string>
|
||||
<string name="enter_gateway_url">Gateway-URL invoeren</string>
|
||||
<string name="connect_manual_url">Verbinden met een handmatige URL</string>
|
||||
<string name="permissions">Machtigingen</string>
|
||||
<string name="done">Gereed</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Połączenie z bramą</string>
|
||||
<string name="connect_gateway">Połącz z bramą</string>
|
||||
<string name="disconnect">Rozłącz</string>
|
||||
<string name="trust_this_gateway">Ufać tej bramie?</string>
|
||||
<string name="trust_and_continue">Zaufaj i kontynuuj</string>
|
||||
<string name="cancel">Anuluj</string>
|
||||
<string name="endpoint">Punkt końcowy</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Twoja brama jest aktywna i gotowa.</string>
|
||||
<string name="connect_gateway_get_started">Połącz się ze swoją bramą, aby rozpocząć.</string>
|
||||
<string name="copy_report_for_claw">Kopiuj raport dla Claw</string>
|
||||
<string name="advanced_controls">Zaawansowane ustawienia</string>
|
||||
<string name="connection_method">Metoda połączenia</string>
|
||||
<string name="setup_code">Kod konfiguracji</string>
|
||||
<string name="manual">Ręcznie</string>
|
||||
<string name="paste_setup_code">Wklej kod konfiguracji</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Użyj TLS</string>
|
||||
<string name="token_optional">Token (opcjonalnie)</string>
|
||||
<string name="password">Hasło</string>
|
||||
<string name="run_onboarding_again">Uruchom ponownie wdrażanie</string>
|
||||
<string name="resolved_endpoint">Rozpoznany punkt końcowy</string>
|
||||
<string name="gateway_setup">Konfiguracja bramy</string>
|
||||
<string name="connect_to_gateway">Połącz ze swoją bramą</string>
|
||||
<string name="scan_setup_code">Zeskanuj kod konfiguracji</string>
|
||||
<string name="use_gateway_qr">Użyj kodu QR bramy lub kodu konfiguracji</string>
|
||||
<string name="nearby_gateway">Pobliska brama</string>
|
||||
<string name="enter_gateway_url">Wprowadź URL bramy</string>
|
||||
<string name="connect_manual_url">Połącz, używając ręcznego URL</string>
|
||||
<string name="permissions">Uprawnienia</string>
|
||||
<string name="done">Gotowe</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Conexão do Gateway</string>
|
||||
<string name="connect_gateway">Conectar Gateway</string>
|
||||
<string name="disconnect">Desconectar</string>
|
||||
<string name="trust_this_gateway">Confiar neste gateway?</string>
|
||||
<string name="trust_and_continue">Confiar e continuar</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Seu gateway está ativo e pronto.</string>
|
||||
<string name="connect_gateway_get_started">Conecte-se ao seu gateway para começar.</string>
|
||||
<string name="copy_report_for_claw">Copiar relatório para o Claw</string>
|
||||
<string name="advanced_controls">Controles avançados</string>
|
||||
<string name="connection_method">Método de conexão</string>
|
||||
<string name="setup_code">Código de configuração</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Colar código de configuração</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Usar TLS</string>
|
||||
<string name="token_optional">Token (opcional)</string>
|
||||
<string name="password">Senha</string>
|
||||
<string name="run_onboarding_again">Executar integração novamente</string>
|
||||
<string name="resolved_endpoint">Endpoint resolvido</string>
|
||||
<string name="gateway_setup">Configuração do Gateway</string>
|
||||
<string name="connect_to_gateway">Conecte-se ao seu Gateway</string>
|
||||
<string name="scan_setup_code">Escanear código de configuração</string>
|
||||
<string name="use_gateway_qr">Use o QR do seu Gateway ou o código de configuração</string>
|
||||
<string name="nearby_gateway">Gateway próximo</string>
|
||||
<string name="enter_gateway_url">Inserir URL do gateway</string>
|
||||
<string name="connect_manual_url">Conectar usando uma URL manual</string>
|
||||
<string name="permissions">Permissões</string>
|
||||
<string name="done">Concluído</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Подключение к шлюзу</string>
|
||||
<string name="connect_gateway">Подключить шлюз</string>
|
||||
<string name="disconnect">Отключить</string>
|
||||
<string name="trust_this_gateway">Доверять этому шлюзу?</string>
|
||||
<string name="trust_and_continue">Доверять и продолжить</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="endpoint">Конечная точка</string>
|
||||
<string name="status">Статус</string>
|
||||
<string name="connected_gateway_ready">Ваш шлюз активен и готов.</string>
|
||||
<string name="connect_gateway_get_started">Подключитесь к своему шлюзу, чтобы начать.</string>
|
||||
<string name="copy_report_for_claw">Скопировать отчет для Claw</string>
|
||||
<string name="advanced_controls">Расширенные настройки</string>
|
||||
<string name="connection_method">Способ подключения</string>
|
||||
<string name="setup_code">Код настройки</string>
|
||||
<string name="manual">Вручную</string>
|
||||
<string name="paste_setup_code">Вставьте код настройки</string>
|
||||
<string name="host">Хост</string>
|
||||
<string name="use_tls">Использовать TLS</string>
|
||||
<string name="token_optional">Токен (необязательно)</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="run_onboarding_again">Запустить настройку заново</string>
|
||||
<string name="resolved_endpoint">Разрешенная конечная точка</string>
|
||||
<string name="gateway_setup">Настройка шлюза</string>
|
||||
<string name="connect_to_gateway">Подключитесь к своему шлюзу</string>
|
||||
<string name="scan_setup_code">Сканировать код настройки</string>
|
||||
<string name="use_gateway_qr">Используйте QR-код или код настройки вашего шлюза</string>
|
||||
<string name="nearby_gateway">Шлюз поблизости</string>
|
||||
<string name="enter_gateway_url">Введите URL шлюза</string>
|
||||
<string name="connect_manual_url">Подключиться с помощью URL вручную</string>
|
||||
<string name="permissions">Разрешения</string>
|
||||
<string name="done">Готово</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">การเชื่อมต่อเกตเวย์</string>
|
||||
<string name="connect_gateway">เชื่อมต่อเกตเวย์</string>
|
||||
<string name="disconnect">ตัดการเชื่อมต่อ</string>
|
||||
<string name="trust_this_gateway">เชื่อถือเกตเวย์นี้หรือไม่?</string>
|
||||
<string name="trust_and_continue">เชื่อถือและดำเนินการต่อ</string>
|
||||
<string name="cancel">ยกเลิก</string>
|
||||
<string name="endpoint">เอนด์พอยต์</string>
|
||||
<string name="status">สถานะ</string>
|
||||
<string name="connected_gateway_ready">เกตเวย์ของคุณเปิดใช้งานและพร้อมใช้งานแล้ว</string>
|
||||
<string name="connect_gateway_get_started">เชื่อมต่อกับเกตเวย์ของคุณเพื่อเริ่มต้นใช้งาน</string>
|
||||
<string name="copy_report_for_claw">คัดลอกรายงานสำหรับ Claw</string>
|
||||
<string name="advanced_controls">การควบคุมขั้นสูง</string>
|
||||
<string name="connection_method">วิธีการเชื่อมต่อ</string>
|
||||
<string name="setup_code">รหัสตั้งค่า</string>
|
||||
<string name="manual">ด้วยตนเอง</string>
|
||||
<string name="paste_setup_code">วางรหัสตั้งค่า</string>
|
||||
<string name="host">โฮสต์</string>
|
||||
<string name="use_tls">ใช้ TLS</string>
|
||||
<string name="token_optional">โทเค็น (ไม่บังคับ)</string>
|
||||
<string name="password">รหัสผ่าน</string>
|
||||
<string name="run_onboarding_again">เรียกใช้การเริ่มต้นใช้งานอีกครั้ง</string>
|
||||
<string name="resolved_endpoint">เอนด์พอยต์ที่แก้ไขแล้ว</string>
|
||||
<string name="gateway_setup">การตั้งค่าเกตเวย์</string>
|
||||
<string name="connect_to_gateway">เชื่อมต่อกับเกตเวย์ของคุณ</string>
|
||||
<string name="scan_setup_code">สแกนรหัสตั้งค่า</string>
|
||||
<string name="use_gateway_qr">ใช้ QR ของเกตเวย์หรือรหัสตั้งค่าของคุณ</string>
|
||||
<string name="nearby_gateway">เกตเวย์ใกล้เคียง</string>
|
||||
<string name="enter_gateway_url">ป้อน URL เกตเวย์</string>
|
||||
<string name="connect_manual_url">เชื่อมต่อโดยใช้ URL ด้วยตนเอง</string>
|
||||
<string name="permissions">สิทธิ์</string>
|
||||
<string name="done">เสร็จสิ้น</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Ağ Geçidi Bağlantısı</string>
|
||||
<string name="connect_gateway">Ağ Geçidine Bağlan</string>
|
||||
<string name="disconnect">Bağlantıyı Kes</string>
|
||||
<string name="trust_this_gateway">Bu ağ geçidine güvenilsin mi?</string>
|
||||
<string name="trust_and_continue">Güven ve devam et</string>
|
||||
<string name="cancel">İptal</string>
|
||||
<string name="endpoint">Uç nokta</string>
|
||||
<string name="status">Durum</string>
|
||||
<string name="connected_gateway_ready">Ağ geçidiniz etkin ve hazır.</string>
|
||||
<string name="connect_gateway_get_started">Başlamak için ağ geçidinize bağlanın.</string>
|
||||
<string name="copy_report_for_claw">Claw için Raporu Kopyala</string>
|
||||
<string name="advanced_controls">Gelişmiş kontroller</string>
|
||||
<string name="connection_method">Bağlantı yöntemi</string>
|
||||
<string name="setup_code">Kurulum Kodu</string>
|
||||
<string name="manual">Manuel</string>
|
||||
<string name="paste_setup_code">Kurulum kodunu yapıştır</string>
|
||||
<string name="host">Ana makine</string>
|
||||
<string name="use_tls">TLS kullan</string>
|
||||
<string name="token_optional">Token (isteğe bağlı)</string>
|
||||
<string name="password">Parola</string>
|
||||
<string name="run_onboarding_again">Başlangıç sürecini tekrar çalıştır</string>
|
||||
<string name="resolved_endpoint">Çözümlenen uç nokta</string>
|
||||
<string name="gateway_setup">Ağ Geçidi Kurulumu</string>
|
||||
<string name="connect_to_gateway">Ağ Geçidinize Bağlanın</string>
|
||||
<string name="scan_setup_code">Kurulum kodunu tara</string>
|
||||
<string name="use_gateway_qr">Gateway QR kodunuzu veya kurulum kodunuzu kullanın</string>
|
||||
<string name="nearby_gateway">Yakındaki ağ geçidi</string>
|
||||
<string name="enter_gateway_url">Ağ geçidi URL'sini girin</string>
|
||||
<string name="connect_manual_url">Manuel URL kullanarak bağlan</string>
|
||||
<string name="permissions">İzinler</string>
|
||||
<string name="done">Bitti</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Підключення до шлюзу</string>
|
||||
<string name="connect_gateway">Підключити шлюз</string>
|
||||
<string name="disconnect">Відключити</string>
|
||||
<string name="trust_this_gateway">Довіряти цьому шлюзу?</string>
|
||||
<string name="trust_and_continue">Довіряти й продовжити</string>
|
||||
<string name="cancel">Скасувати</string>
|
||||
<string name="endpoint">Кінцева точка</string>
|
||||
<string name="status">Стан</string>
|
||||
<string name="connected_gateway_ready">Ваш шлюз активний і готовий.</string>
|
||||
<string name="connect_gateway_get_started">Підключіться до свого шлюзу, щоб почати.</string>
|
||||
<string name="copy_report_for_claw">Скопіювати звіт для Claw</string>
|
||||
<string name="advanced_controls">Розширені елементи керування</string>
|
||||
<string name="connection_method">Спосіб підключення</string>
|
||||
<string name="setup_code">Код налаштування</string>
|
||||
<string name="manual">Вручну</string>
|
||||
<string name="paste_setup_code">Вставте код налаштування</string>
|
||||
<string name="host">Хост</string>
|
||||
<string name="use_tls">Використовувати TLS</string>
|
||||
<string name="token_optional">Токен (необов’язково)</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="run_onboarding_again">Запустити адаптацію знову</string>
|
||||
<string name="resolved_endpoint">Визначена кінцева точка</string>
|
||||
<string name="gateway_setup">Налаштування шлюзу</string>
|
||||
<string name="connect_to_gateway">Підключіться до свого шлюзу</string>
|
||||
<string name="scan_setup_code">Сканувати код налаштування</string>
|
||||
<string name="use_gateway_qr">Використайте QR-код свого шлюзу або код налаштування</string>
|
||||
<string name="nearby_gateway">Шлюз поблизу</string>
|
||||
<string name="enter_gateway_url">Введіть URL-адресу шлюзу</string>
|
||||
<string name="connect_manual_url">Підключитися за допомогою URL-адреси вручну</string>
|
||||
<string name="permissions">Дозволи</string>
|
||||
<string name="done">Готово</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Kết nối cổng</string>
|
||||
<string name="connect_gateway">Kết nối cổng</string>
|
||||
<string name="disconnect">Ngắt kết nối</string>
|
||||
<string name="trust_this_gateway">Tin cậy cổng này?</string>
|
||||
<string name="trust_and_continue">Tin cậy và tiếp tục</string>
|
||||
<string name="cancel">Hủy</string>
|
||||
<string name="endpoint">Điểm cuối</string>
|
||||
<string name="status">Trạng thái</string>
|
||||
<string name="connected_gateway_ready">Cổng của bạn đang hoạt động và sẵn sàng.</string>
|
||||
<string name="connect_gateway_get_started">Kết nối với cổng của bạn để bắt đầu.</string>
|
||||
<string name="copy_report_for_claw">Sao chép báo cáo cho Claw</string>
|
||||
<string name="advanced_controls">Điều khiển nâng cao</string>
|
||||
<string name="connection_method">Phương thức kết nối</string>
|
||||
<string name="setup_code">Mã thiết lập</string>
|
||||
<string name="manual">Thủ công</string>
|
||||
<string name="paste_setup_code">Dán mã thiết lập</string>
|
||||
<string name="host">Máy chủ</string>
|
||||
<string name="use_tls">Sử dụng TLS</string>
|
||||
<string name="token_optional">Token (tùy chọn)</string>
|
||||
<string name="password">Mật khẩu</string>
|
||||
<string name="run_onboarding_again">Chạy hướng dẫn thiết lập lại</string>
|
||||
<string name="resolved_endpoint">Điểm cuối đã phân giải</string>
|
||||
<string name="gateway_setup">Thiết lập cổng</string>
|
||||
<string name="connect_to_gateway">Kết nối với Gateway của bạn</string>
|
||||
<string name="scan_setup_code">Quét mã thiết lập</string>
|
||||
<string name="use_gateway_qr">Sử dụng mã QR Gateway hoặc mã thiết lập của bạn</string>
|
||||
<string name="nearby_gateway">Cổng gần đây</string>
|
||||
<string name="enter_gateway_url">Nhập URL cổng</string>
|
||||
<string name="connect_manual_url">Kết nối bằng URL thủ công</string>
|
||||
<string name="permissions">Quyền</string>
|
||||
<string name="done">Xong</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">网关连接</string>
|
||||
<string name="connect_gateway">连接网关</string>
|
||||
<string name="disconnect">断开连接</string>
|
||||
<string name="trust_this_gateway">信任此网关?</string>
|
||||
<string name="trust_and_continue">信任并继续</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="endpoint">端点</string>
|
||||
<string name="status">状态</string>
|
||||
<string name="connected_gateway_ready">你的网关已激活并准备就绪。</string>
|
||||
<string name="connect_gateway_get_started">连接到你的网关以开始使用。</string>
|
||||
<string name="copy_report_for_claw">复制 Claw 报告</string>
|
||||
<string name="advanced_controls">高级控制</string>
|
||||
<string name="connection_method">连接方式</string>
|
||||
<string name="setup_code">设置代码</string>
|
||||
<string name="manual">手动</string>
|
||||
<string name="paste_setup_code">粘贴设置代码</string>
|
||||
<string name="host">主机</string>
|
||||
<string name="use_tls">使用 TLS</string>
|
||||
<string name="token_optional">令牌(可选)</string>
|
||||
<string name="password">密码</string>
|
||||
<string name="run_onboarding_again">再次运行引导流程</string>
|
||||
<string name="resolved_endpoint">已解析的端点</string>
|
||||
<string name="gateway_setup">网关设置</string>
|
||||
<string name="connect_to_gateway">连接到你的网关</string>
|
||||
<string name="scan_setup_code">扫描设置代码</string>
|
||||
<string name="use_gateway_qr">使用你的网关 QR 码或设置代码</string>
|
||||
<string name="nearby_gateway">附近的网关</string>
|
||||
<string name="enter_gateway_url">输入网关 URL</string>
|
||||
<string name="connect_manual_url">使用手动 URL 连接</string>
|
||||
<string name="permissions">权限</string>
|
||||
<string name="done">完成</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">閘道連線</string>
|
||||
<string name="connect_gateway">連接閘道</string>
|
||||
<string name="disconnect">中斷連線</string>
|
||||
<string name="trust_this_gateway">信任此閘道?</string>
|
||||
<string name="trust_and_continue">信任並繼續</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="endpoint">端點</string>
|
||||
<string name="status">狀態</string>
|
||||
<string name="connected_gateway_ready">您的閘道已啟用並準備就緒。</string>
|
||||
<string name="connect_gateway_get_started">連接到您的閘道以開始使用。</string>
|
||||
<string name="copy_report_for_claw">複製 Claw 報告</string>
|
||||
<string name="advanced_controls">進階控制項</string>
|
||||
<string name="connection_method">連線方式</string>
|
||||
<string name="setup_code">設定碼</string>
|
||||
<string name="manual">手動</string>
|
||||
<string name="paste_setup_code">貼上設定碼</string>
|
||||
<string name="host">主機</string>
|
||||
<string name="use_tls">使用 TLS</string>
|
||||
<string name="token_optional">權杖(選填)</string>
|
||||
<string name="password">密碼</string>
|
||||
<string name="run_onboarding_again">再次執行新手導覽</string>
|
||||
<string name="resolved_endpoint">已解析的端點</string>
|
||||
<string name="gateway_setup">閘道設定</string>
|
||||
<string name="connect_to_gateway">連接到您的閘道</string>
|
||||
<string name="scan_setup_code">掃描設定碼</string>
|
||||
<string name="use_gateway_qr">使用您的 Gateway QR 或設定碼</string>
|
||||
<string name="nearby_gateway">附近的閘道</string>
|
||||
<string name="enter_gateway_url">輸入閘道 URL</string>
|
||||
<string name="connect_manual_url">使用手動 URL 連接</string>
|
||||
<string name="permissions">權限</string>
|
||||
<string name="done">完成</string>
|
||||
</resources>
|
||||
@@ -1,34 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Gateway Connection</string>
|
||||
<string name="connect_gateway">Connect Gateway</string>
|
||||
<string name="disconnect">Disconnect</string>
|
||||
<string name="trust_this_gateway">Trust this gateway?</string>
|
||||
<string name="trust_and_continue">Trust and continue</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Your gateway is active and ready.</string>
|
||||
<string name="connect_gateway_get_started">Connect to your gateway to get started.</string>
|
||||
<string name="copy_report_for_claw">Copy Report for Claw</string>
|
||||
<string name="advanced_controls">Advanced controls</string>
|
||||
<string name="connection_method">Connection method</string>
|
||||
<string name="setup_code">Setup Code</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Paste setup code</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Use TLS</string>
|
||||
<string name="token_optional">Token (optional)</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="run_onboarding_again">Run onboarding again</string>
|
||||
<string name="resolved_endpoint">Resolved endpoint</string>
|
||||
<string name="gateway_setup">Gateway Setup</string>
|
||||
<string name="connect_to_gateway">Connect to your Gateway</string>
|
||||
<string name="scan_setup_code">Scan setup code</string>
|
||||
<string name="use_gateway_qr">Use your Gateway QR or setup code</string>
|
||||
<string name="nearby_gateway">Nearby gateway</string>
|
||||
<string name="enter_gateway_url">Enter gateway URL</string>
|
||||
<string name="connect_manual_url">Connect using a manual URL</string>
|
||||
<string name="permissions">Permissions</string>
|
||||
<string name="done">Done</string>
|
||||
</resources>
|
||||
|
||||
@@ -7187,17 +7187,20 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let limit: Int?
|
||||
public let offset: Int?
|
||||
public let maxchars: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
limit: Int?,
|
||||
offset: Int? = nil,
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
self.maxchars = maxchars
|
||||
}
|
||||
|
||||
@@ -7205,6 +7208,7 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case limit
|
||||
case offset
|
||||
case maxchars = "maxChars"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,8 @@ tool-call XML payloads (including `<tool_call>...</tool_call>`,
|
||||
downgraded tool-call scaffolding / leaked ASCII/full-width model control
|
||||
tokens / malformed MiniMax tool-call XML from assistant recall, and can
|
||||
replace oversized rows with `[sessions_history omitted: message too large]`
|
||||
instead of returning a raw transcript dump.
|
||||
instead of returning a raw transcript dump. Use `nextOffset` when present to
|
||||
page backward through older transcript windows.
|
||||
|
||||
## Scaling pattern
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ results may be scope-limited.
|
||||
|
||||
`sessions_history` fetches the conversation transcript for a specific session.
|
||||
By default, tool results are excluded -- pass `includeTools: true` to see them.
|
||||
Use `limit` for the newest bounded tail. Pass `offset: 0` when you need
|
||||
pagination metadata, then pass returned `nextOffset` values to page backward
|
||||
through older OpenClaw transcript windows without reading raw transcript files.
|
||||
Explicit offset pages do not merge external CLI fallback imports; use the
|
||||
default newest-tail view when you need that merged display history.
|
||||
The returned view is intentionally bounded and safety-filtered:
|
||||
|
||||
- assistant text is normalized before recall:
|
||||
@@ -78,7 +83,7 @@ The returned view is intentionally bounded and safety-filtered:
|
||||
- very large histories can drop older rows or replace an oversized row with
|
||||
`[sessions_history omitted: message too large]`
|
||||
- the tool reports summary flags such as `truncated`, `droppedMessages`,
|
||||
`contentTruncated`, `contentRedacted`, and `bytes`
|
||||
`contentTruncated`, `contentRedacted`, `bytes`, and pagination metadata
|
||||
|
||||
Both tools accept either a **session key** (like `"main"`) or a **session ID**
|
||||
from a previous list call.
|
||||
|
||||
@@ -316,6 +316,11 @@ conversation bindings, or any non-Codex harness.
|
||||
plugin/app support for the Codex harness. Default: `false`.
|
||||
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
|
||||
default destructive-action policy for migrated plugin app elicitations.
|
||||
Use `true` to accept safe Codex approval schemas without prompting, `false`
|
||||
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
|
||||
plugin approvals, or `"always"` to ask for every plugin write/destructive
|
||||
action without durable approval. The `"always"` mode clears durable Codex
|
||||
per-tool approval overrides for the affected app before starting the thread.
|
||||
Default: `true`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
|
||||
migrated plugin entry when global `codexPlugins.enabled` is also true.
|
||||
@@ -326,7 +331,8 @@ conversation bindings, or any non-Codex harness.
|
||||
Codex plugin identity from migration, for example `"google-calendar"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
|
||||
per-plugin destructive-action override. When omitted, the global
|
||||
`allow_destructive_actions` value is used.
|
||||
`allow_destructive_actions` value is used. The per-plugin value accepts the
|
||||
same `true`, `false`, `"auto"`, or `"always"` policies.
|
||||
|
||||
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
|
||||
entries written by migration are the durable install and repair eligibility set.
|
||||
|
||||
@@ -200,11 +200,11 @@ enabled.
|
||||
|
||||
OpenClaw sets app-level `destructive_enabled` from the effective global or
|
||||
per-plugin `allow_destructive_actions` policy and lets Codex enforce
|
||||
destructive tool metadata from its native app tool annotations. `true` and
|
||||
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
|
||||
`_default` app config is disabled with `open_world_enabled: false`. Enabled
|
||||
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
|
||||
expose a separate plugin open-world policy knob and does not maintain
|
||||
destructive tool metadata from its native app tool annotations. `true`,
|
||||
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
|
||||
false. The `_default` app config is disabled with `open_world_enabled: false`.
|
||||
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
|
||||
not expose a separate plugin open-world policy knob and does not maintain
|
||||
per-plugin destructive tool-name deny lists.
|
||||
|
||||
Tool approval mode is automatic by default for plugin apps so non-destructive
|
||||
@@ -225,6 +225,10 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
|
||||
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
|
||||
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
|
||||
plugin approvals before returning the Codex approval response.
|
||||
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
|
||||
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
|
||||
app before the thread starts, and only offers one-shot approval or denial so
|
||||
durable approvals cannot suppress later write-action prompts.
|
||||
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
|
||||
id, or an unsafe elicitation schema declines instead of prompting.
|
||||
|
||||
@@ -272,8 +276,9 @@ Codex thread bindings keep the app config they started with until OpenClaw
|
||||
establishes a new harness session or replaces a stale binding.
|
||||
|
||||
**Destructive action is declined:** check the global and per-plugin
|
||||
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
|
||||
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
|
||||
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
|
||||
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
|
||||
closed.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -211,6 +211,18 @@ each carrier call should start with fresh context, for example reception,
|
||||
booking, IVR, or Google Meet bridge flows where the same phone number may
|
||||
represent different meetings.
|
||||
|
||||
Voice Call stores generated session keys under the configured agent namespace
|
||||
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
|
||||
canonicalization after restarts. Raw explicit integration keys use the same
|
||||
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
|
||||
and its main aliases honor core `session.mainKey` and global scope. Foreign or
|
||||
malformed `agent:*` input is scoped as an opaque key under the configured agent;
|
||||
`global` and `unknown` remain global sentinels. Gateway startup promotes older
|
||||
raw keys in default or `{agentId}`-templated stores where the path proves one
|
||||
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
|
||||
they do not contain enough information to choose an owner; new calls use
|
||||
canonical agent-scoped history.
|
||||
|
||||
## Realtime voice conversations
|
||||
|
||||
`realtime` selects a full-duplex realtime voice provider for live call
|
||||
|
||||
@@ -29,10 +29,11 @@ Use the path that matches your OpenClaw install state:
|
||||
openclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
On a VPS or over SSH, use device-code during onboarding:
|
||||
On a VPS or over SSH, select xAI OAuth directly; OpenClaw uses device-code
|
||||
verification and does not require a localhost callback:
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon --auth-choice xai-device-code
|
||||
openclaw onboard --install-daemon --auth-choice xai-oauth
|
||||
```
|
||||
|
||||
OAuth does not require an xAI API key. OpenClaw does not require the Grok
|
||||
@@ -48,13 +49,6 @@ Use the path that matches your OpenClaw install state:
|
||||
openclaw models auth login --provider xai --method oauth
|
||||
```
|
||||
|
||||
Use the device-code flow instead when the Gateway runs over SSH, Docker, or
|
||||
a VPS and a localhost browser callback is awkward:
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider xai --device-code
|
||||
```
|
||||
|
||||
To make Grok the default model after signing in, apply it separately:
|
||||
|
||||
```bash
|
||||
@@ -86,8 +80,7 @@ Use the path that matches your OpenClaw install state:
|
||||
|
||||
<Note>
|
||||
OpenClaw uses the xAI Responses API as the bundled xAI transport. The same
|
||||
credential from `openclaw models auth login --provider xai --method oauth`,
|
||||
`openclaw models auth login --provider xai --device-code`, or
|
||||
credential from `openclaw models auth login --provider xai --method oauth` or
|
||||
`openclaw models auth login --provider xai --method api-key` can also power first-class
|
||||
`web_search`, `x_search`, remote `code_execution`, and xAI image/video generation.
|
||||
Speech and transcription currently require `XAI_API_KEY` or provider config.
|
||||
@@ -102,8 +95,9 @@ and, by default, `x_search` through an operator xAI Responses proxy.
|
||||
|
||||
## OAuth troubleshooting
|
||||
|
||||
- If browser OAuth cannot reach `127.0.0.1:56121`, use
|
||||
`openclaw models auth login --provider xai --device-code`.
|
||||
- For SSH, Docker, VPS, or other remote setups, use
|
||||
`openclaw models auth login --provider xai --method oauth`; xAI OAuth uses
|
||||
device-code verification instead of a localhost callback.
|
||||
- If sign-in succeeds but Grok is not the default model, run
|
||||
`openclaw models set xai/grok-4.3`.
|
||||
- To inspect saved xAI auth profiles, run:
|
||||
@@ -117,9 +111,9 @@ and, by default, `x_search` through an operator xAI Responses proxy.
|
||||
eligible, try the API-key path or check the subscription on xAI's side.
|
||||
|
||||
<Tip>
|
||||
Use `xai-device-code` when signing in from SSH, Docker, or a VPS. OpenClaw
|
||||
prints an xAI URL and short code; finish sign-in in any local browser while the
|
||||
remote process polls xAI for the completed token exchange.
|
||||
Use `xai-oauth` when signing in from SSH, Docker, or a VPS. OpenClaw prints an
|
||||
xAI URL and short code; finish sign-in in any local browser while the remote
|
||||
process polls xAI for the completed token exchange.
|
||||
</Tip>
|
||||
|
||||
## Built-in catalog
|
||||
@@ -498,12 +492,10 @@ Legacy aliases still normalize to the canonical bundled ids:
|
||||
|
||||
<Accordion title="Known limits">
|
||||
- xAI auth can use an API key, environment variable, plugin config fallback,
|
||||
browser OAuth, or device-code OAuth with an eligible xAI account. Browser
|
||||
OAuth uses a local callback on `127.0.0.1:56121`; for remote hosts, use
|
||||
`xai-device-code` unless you want to forward that port before opening the
|
||||
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
|
||||
the consent page may show Grok Build even though OpenClaw does not require
|
||||
the Grok Build app.
|
||||
or OAuth with an eligible xAI account. OAuth uses device-code verification
|
||||
without a localhost callback. xAI decides which accounts can receive OAuth
|
||||
API tokens, and the consent page may show Grok Build even though OpenClaw
|
||||
does not require the Grok Build app.
|
||||
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
|
||||
serves these models through the Responses API, but they do not accept the
|
||||
client-side or custom tools used by OpenClaw's shared agent loop. See the
|
||||
|
||||
@@ -38,13 +38,13 @@ Do **not** use it when you need local files, your shell, your repo, or paired de
|
||||
<Steps>
|
||||
<Step title="Provide xAI credentials">
|
||||
Sign in with Grok OAuth using an eligible SuperGrok or X Premium subscription,
|
||||
use the remote-friendly device-code flow, or store an API key. OAuth works
|
||||
for `code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search
|
||||
config can also power Grok `web_search`.
|
||||
or store an API key. xAI OAuth uses device-code verification, so it works
|
||||
from remote hosts without a localhost callback. OAuth works for
|
||||
`code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search config
|
||||
can also power Grok `web_search`.
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider xai --method oauth
|
||||
openclaw models auth login --provider xai --device-code
|
||||
```
|
||||
|
||||
During a fresh install, the same auth choices are available inside
|
||||
@@ -52,7 +52,7 @@ Do **not** use it when you need local files, your shell, your repo, or paired de
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
openclaw onboard --install-daemon --auth-choice xai-device-code
|
||||
openclaw onboard --install-daemon --auth-choice xai-oauth
|
||||
```
|
||||
|
||||
Or use an API key:
|
||||
|
||||
@@ -523,6 +523,7 @@ should be rewritten in normal assistant voice.
|
||||
- Credential/token-like text is redacted.
|
||||
- Long blocks can be truncated.
|
||||
- Very large histories can drop older rows or replace an oversized row with `[sessions_history omitted: message too large]`.
|
||||
- Use `nextOffset` when present to page backward through older transcript windows.
|
||||
- Raw on-disk transcript inspection is the fallback when you need the full byte-for-byte transcript.
|
||||
|
||||
## Tool policy
|
||||
|
||||
@@ -192,6 +192,109 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const readScopedMcpEnv = (sessionKey: string) => {
|
||||
const delegate = (
|
||||
runtime as unknown as {
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
|
||||
}
|
||||
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
|
||||
options: {
|
||||
mcpServers?: Array<{
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
|
||||
};
|
||||
|
||||
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
|
||||
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
|
||||
value: "agent:worker:main",
|
||||
});
|
||||
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
|
||||
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
|
||||
value: "agent:research:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const exposedRuntime = runtime as unknown as {
|
||||
openclawToolsSessionDelegates: Map<string, unknown>;
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
|
||||
};
|
||||
|
||||
const firstDelegate =
|
||||
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
|
||||
|
||||
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
|
||||
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
|
||||
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
|
||||
firstDelegate,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
|
||||
const safeProbe = vi
|
||||
.spyOn(bridgeSafeDelegate, "probeAvailability")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
|
||||
expect(safeProbe).toHaveBeenCalledTimes(1);
|
||||
expect(defaultProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
@@ -1163,6 +1266,46 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
expect(baseStore["load"]).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases managed OpenClaw tools MCP delegates after close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const exposedRuntime = runtime as unknown as {
|
||||
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
|
||||
close: AcpRuntime["close"];
|
||||
};
|
||||
};
|
||||
const scopedDelegate =
|
||||
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
|
||||
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:main",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:main",
|
||||
},
|
||||
reason: "closed",
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({
|
||||
|
||||
@@ -50,6 +50,7 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
|
||||
openclawWrapperRoot?: string;
|
||||
openclawGatewayInstanceId?: string;
|
||||
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
|
||||
openclawToolsMcpBridgeEnabled?: boolean;
|
||||
};
|
||||
type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
openclawProcessCleanup?: AcpxProcessCleanupDeps;
|
||||
@@ -57,6 +58,10 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
|
||||
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
|
||||
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
|
||||
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
|
||||
|
||||
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
|
||||
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
|
||||
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
@@ -682,6 +687,33 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
|
||||
return Array.isArray(mcpServers) && mcpServers.length > 0;
|
||||
}
|
||||
|
||||
function withOpenClawToolsMcpSessionEnv(params: {
|
||||
enabled: boolean | undefined;
|
||||
mcpServers: AcpRuntimeOptions["mcpServers"];
|
||||
sessionKey: string;
|
||||
}): AcpRuntimeOptions["mcpServers"] {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
|
||||
return params.mcpServers;
|
||||
}
|
||||
let changed = false;
|
||||
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
|
||||
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
|
||||
return server;
|
||||
}
|
||||
changed = true;
|
||||
const env = [
|
||||
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
|
||||
{
|
||||
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
|
||||
value: sessionKey,
|
||||
},
|
||||
];
|
||||
return { ...server, env };
|
||||
});
|
||||
return changed ? nextServers : params.mcpServers;
|
||||
}
|
||||
|
||||
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
|
||||
export class AcpxRuntime implements AcpRuntime {
|
||||
private readonly sessionStore: ResetAwareSessionStore;
|
||||
@@ -693,6 +725,10 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
private readonly delegate: BaseAcpxRuntime;
|
||||
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
|
||||
private readonly probeDelegate: BaseAcpxRuntime;
|
||||
private readonly delegateOptions: AcpRuntimeOptions;
|
||||
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
|
||||
private readonly openclawToolsMcpBridgeEnabled: boolean;
|
||||
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
|
||||
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
|
||||
private readonly wrapperRoot: string | undefined;
|
||||
private readonly gatewayInstanceId: string | undefined;
|
||||
@@ -706,6 +742,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.wrapperRoot = options.openclawWrapperRoot;
|
||||
this.gatewayInstanceId = options.openclawGatewayInstanceId;
|
||||
this.processLeaseStore = options.openclawProcessLeaseStore;
|
||||
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
|
||||
this.cwd = options.cwd;
|
||||
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
|
||||
gatewayInstanceId: this.gatewayInstanceId,
|
||||
@@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
sessionStore: this.sessionStore,
|
||||
agentRegistry: this.scopedAgentRegistry,
|
||||
};
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
sharedOptions,
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
);
|
||||
this.delegateOptions = sharedOptions;
|
||||
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
|
||||
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
|
||||
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
|
||||
? new BaseAcpxRuntime(
|
||||
{
|
||||
...sharedOptions,
|
||||
mcpServers: [],
|
||||
},
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
this.delegateTestOptions,
|
||||
)
|
||||
: this.delegate;
|
||||
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
|
||||
? this.bridgeSafeDelegate
|
||||
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
}
|
||||
|
||||
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
|
||||
@@ -751,6 +789,57 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
|
||||
}
|
||||
|
||||
private resolveDelegateForSession(params: {
|
||||
command: string | undefined;
|
||||
sessionKey: string;
|
||||
}): BaseAcpxRuntime {
|
||||
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
|
||||
return this.bridgeSafeDelegate;
|
||||
}
|
||||
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
|
||||
}
|
||||
|
||||
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
|
||||
if (!this.openclawToolsMcpBridgeEnabled) {
|
||||
return this.delegate;
|
||||
}
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return this.delegate;
|
||||
}
|
||||
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Upstream acpx captures mcpServers at runtime construction. The managed
|
||||
// OpenClaw tools bridge needs per-session identity, so cache one delegate
|
||||
// per session with the scoped MCP env already embedded.
|
||||
const delegate = new BaseAcpxRuntime(
|
||||
{
|
||||
...this.delegateOptions,
|
||||
mcpServers: withOpenClawToolsMcpSessionEnv({
|
||||
enabled: this.openclawToolsMcpBridgeEnabled,
|
||||
mcpServers: this.delegateOptions.mcpServers,
|
||||
sessionKey: normalizedSessionKey,
|
||||
}),
|
||||
},
|
||||
this.delegateTestOptions,
|
||||
);
|
||||
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
|
||||
return delegate;
|
||||
}
|
||||
|
||||
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
|
||||
if (!this.openclawToolsMcpBridgeEnabled) {
|
||||
return;
|
||||
}
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return;
|
||||
}
|
||||
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
|
||||
}
|
||||
|
||||
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
|
||||
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
|
||||
return this.resolveDelegateForLoadedRecord(handle, record);
|
||||
@@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
): BaseAcpxRuntime {
|
||||
const recordCommand = readAgentCommandFromRecord(record);
|
||||
if (recordCommand) {
|
||||
return this.resolveDelegateForCommand(recordCommand);
|
||||
return this.resolveDelegateForSession({
|
||||
command: recordCommand,
|
||||
sessionKey: handle.sessionKey,
|
||||
});
|
||||
}
|
||||
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
|
||||
const agentName = readAgentFromHandle(handle);
|
||||
const command = resolveAgentCommandForName({
|
||||
agentName,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
|
||||
}
|
||||
|
||||
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
|
||||
@@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
agentName: input.agent,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
const delegate = this.resolveDelegateForCommand(command);
|
||||
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
|
||||
const claudeModelOverride = isClaudeAcpCommand(command)
|
||||
? normalizeClaudeAcpModelOverride(input.model)
|
||||
: undefined;
|
||||
@@ -1264,6 +1361,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
}
|
||||
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
// Fresh reset has no ACP handle to close the delegate's upstream client.
|
||||
// Keep the scoped delegate reachable so the next ensure can replace it;
|
||||
// close() owns cache release when the session lifecycle ends.
|
||||
this.sessionStore.markFresh(input.sessionKey);
|
||||
}
|
||||
|
||||
@@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
);
|
||||
let closeSucceeded;
|
||||
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
|
||||
try {
|
||||
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
|
||||
await delegate.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
@@ -1282,6 +1383,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
} finally {
|
||||
await this.cleanupProcessTreeForRecord(input.handle, record);
|
||||
}
|
||||
if (closeSucceeded) {
|
||||
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
|
||||
}
|
||||
if (closeSucceeded && input.discardPersistentState) {
|
||||
this.sessionStore.markFresh(input.handle.sessionKey);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
|
||||
}),
|
||||
probeAgent: params.pluginConfig.probeAgent,
|
||||
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
|
||||
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
|
||||
permissionMode: params.pluginConfig.permissionMode,
|
||||
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
|
||||
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),
|
||||
|
||||
@@ -1,6 +1,81 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createClickClackClient } from "./http-client.js";
|
||||
|
||||
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
|
||||
|
||||
async function listenLoopbackServer(server: Server): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("expected loopback TCP address"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
|
||||
let resolveClosed: (sentBytes: number) => void = () => {};
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
let sentBytes = 0;
|
||||
let stopped = false;
|
||||
let prefixSent = false;
|
||||
const prefixChunk = Buffer.from('{"user":{"id":"');
|
||||
const bodyChunk = Buffer.alloc(64 * 1024, 0x61);
|
||||
const suffixChunk = Buffer.from('"}}');
|
||||
const writeBuffer = (buffer: Buffer) => {
|
||||
sentBytes += buffer.length;
|
||||
if (!res.write(buffer)) {
|
||||
res.once("drain", writeChunks);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const writeChunks = () => {
|
||||
if (!prefixSent) {
|
||||
prefixSent = true;
|
||||
if (!writeBuffer(prefixChunk)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) {
|
||||
break;
|
||||
}
|
||||
if (!writeBuffer(bodyChunk)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!stopped) {
|
||||
sentBytes += suffixChunk.length;
|
||||
res.end(suffixChunk);
|
||||
}
|
||||
};
|
||||
res.writeHead(200, { connection: "close", "content-type": "application/json" });
|
||||
res.on("close", () => {
|
||||
stopped = true;
|
||||
resolveClosed(sentBytes);
|
||||
});
|
||||
req.on("aborted", () => {
|
||||
stopped = true;
|
||||
res.destroy();
|
||||
});
|
||||
writeChunks();
|
||||
});
|
||||
return { server, closed };
|
||||
}
|
||||
|
||||
function streamedErrorResponse(body: string, limit: number) {
|
||||
const encoded = new TextEncoder().encode(body);
|
||||
let readCount = 0;
|
||||
@@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) {
|
||||
}
|
||||
|
||||
describe("ClickClack HTTP client", () => {
|
||||
it("bounds oversized success JSON responses and closes the stream early", async () => {
|
||||
const { server, closed } = createOversizedJsonServer();
|
||||
const port = await listenLoopbackServer(server);
|
||||
const client = createClickClackClient({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
token: "test-token",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(client.me()).rejects.toThrow(
|
||||
"ClickClack response: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
const sentBytes = await closed;
|
||||
expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds error response bodies without using raw response.text()", async () => {
|
||||
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
|
||||
const fetchMock = vi.fn(async () => streamed.response);
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
|
||||
* delivery code.
|
||||
*/
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { WebSocket } from "ws";
|
||||
import type {
|
||||
ClickClackChannel,
|
||||
@@ -44,7 +47,7 @@ export function createClickClackClient(options: ClientOptions) {
|
||||
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
|
||||
throw new Error(`ClickClack ${response.status}: ${detail}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
return await readProviderJsonResponse<T>(response, "ClickClack response");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,6 +36,14 @@ describe("codex doctor contract", () => {
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyConfigRules[1]?.match({
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": { allow_destructive_actions: "always" },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the retired dynamic tools profile without dropping other Codex config", () => {
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"default": false
|
||||
},
|
||||
"allow_destructive_actions": {
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
|
||||
"default": true
|
||||
},
|
||||
"plugins": {
|
||||
@@ -121,7 +121,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"allow_destructive_actions": {
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,7 @@
|
||||
},
|
||||
"codexPlugins.allow_destructive_actions": {
|
||||
"label": "Allow Destructive Plugin Actions",
|
||||
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
|
||||
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
|
||||
"advanced": true
|
||||
},
|
||||
"codexPlugins.plugins": {
|
||||
|
||||
@@ -346,6 +346,7 @@ export async function startCodexAttemptThread(params: {
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal,
|
||||
}),
|
||||
configCwd: startupExecutionCwd,
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey: pluginAppCacheKey,
|
||||
}),
|
||||
|
||||
@@ -1192,6 +1192,52 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
});
|
||||
|
||||
it("parses always native Codex plugin destructive policy", () => {
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
slack: {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
allow_destructive_actions: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
|
||||
expect(resolveCodexPluginsPolicy(config)).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
pluginPolicies: [
|
||||
{
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
{
|
||||
configKey: "slack",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported native Codex plugin destructive policy strings", () => {
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
|
||||
@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
|
||||
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
|
||||
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
|
||||
export type CodexDynamicToolsLoading = "searchable" | "direct";
|
||||
export type CodexPluginDestructivePolicy = boolean | "auto";
|
||||
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
|
||||
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
|
||||
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
|
||||
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
|
||||
|
||||
@@ -311,7 +311,11 @@ const codexAppServerApprovalPolicySchema = z.enum([
|
||||
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
|
||||
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
|
||||
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
|
||||
const codexPluginDestructivePolicySchema = z.union([
|
||||
z.boolean(),
|
||||
z.literal("auto"),
|
||||
z.literal("always"),
|
||||
]);
|
||||
const codexAppServerServiceTierSchema = z
|
||||
.preprocess(
|
||||
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
|
||||
@@ -495,8 +499,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
|
||||
allowDestructiveActions: boolean;
|
||||
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
|
||||
} {
|
||||
if (policy === "auto") {
|
||||
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
|
||||
if (policy === "auto" || policy === "always") {
|
||||
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
|
||||
}
|
||||
return {
|
||||
allowDestructiveActions: policy,
|
||||
|
||||
@@ -1102,6 +1102,585 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps normalized explicit source routes terminal", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "sms",
|
||||
plugin: {
|
||||
id: "sms",
|
||||
messaging: {
|
||||
normalizeTarget: (raw: string) => {
|
||||
const digits = raw.replace(/\D/gu, "");
|
||||
return digits.length === 11 && digits.startsWith("1") ? `+${digits}` : raw.trim();
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "sms-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "sms",
|
||||
currentChannelId: "sms:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "sms",
|
||||
target: "+1 (206) 910-6512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "sms",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not let dry-run reply receipts terminate message-tool-only source replies", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
deliveryStatus: "dry_run",
|
||||
dryRun: true,
|
||||
replyToId: "provider-guid-862",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-862",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "862",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record dry-run reply actions as committed sends", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Dry run.", {
|
||||
deliveryStatus: "dry_run",
|
||||
dryRun: true,
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-862",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "862",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Dry run."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(false);
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "thread reply from top-level source",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let provider target aliases override source routes", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
id: "slack",
|
||||
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
|
||||
actions: {
|
||||
messageActionTargetAliases: {
|
||||
reply: {
|
||||
aliases: ["chatGuid"],
|
||||
deliveryTargetAliases: ["chatGuid"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "channel:c1",
|
||||
currentMessagingTarget: "channel:c1",
|
||||
currentMessageId: "provider-guid-854",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
chatGuid: "Channel:C2",
|
||||
messageId: "854",
|
||||
message: "cross-chat reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "slack",
|
||||
to: "channel:c2",
|
||||
text: "cross-chat reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isDeliveredMessagingToolResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -63,9 +65,11 @@ type CodexDynamicToolHookContext = {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
@@ -100,6 +104,225 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | number | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function explicitRouteTokensMismatchCurrent(
|
||||
args: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
currentToken: string | number | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrent = normalizeRouteToken(currentToken);
|
||||
if (!normalizedCurrent) {
|
||||
return false;
|
||||
}
|
||||
return keys.some((key) => {
|
||||
const normalized = normalizeRouteToken(readRouteToken(args, key));
|
||||
return normalized !== undefined && normalized !== normalizedCurrent;
|
||||
});
|
||||
}
|
||||
|
||||
function explicitThreadRouteTargetsNonSource(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
|
||||
const explicitThreadTokens = [
|
||||
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
|
||||
normalizeRouteToken(messagingTarget?.threadId),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
|
||||
if (explicitThreadTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedCurrentThread === undefined ||
|
||||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
explicitRouteTokensMismatchCurrent(
|
||||
args,
|
||||
EXPLICIT_MESSAGE_REPLY_KEYS,
|
||||
hookContext?.currentMessageId,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
messagingTarget?.to !== undefined &&
|
||||
!routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (messagingTarget?.to !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -114,6 +337,7 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -132,6 +356,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
|
||||
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -176,6 +404,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -333,10 +562,9 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -358,12 +586,53 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
isDeliveredMessagingToolResult({
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
}) &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result),
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -801,9 +1070,22 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMessagingTool(params.toolName)) {
|
||||
return;
|
||||
}
|
||||
const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args);
|
||||
if (!isMessagingSendAction && !params.messagingTarget) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
!isMessagingSendAction &&
|
||||
!isDeliveredMessagingToolResult({
|
||||
toolName: params.toolName,
|
||||
args: params.args,
|
||||
result: params.result,
|
||||
hookResult: params.mediaTrustResult,
|
||||
isError: params.isError,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
|
||||
function createPluginAppPolicyContext(
|
||||
params: {
|
||||
allowDestructiveActions?: boolean;
|
||||
destructiveApprovalMode?: "allow" | "deny" | "auto";
|
||||
destructiveApprovalMode?: "allow" | "deny" | "auto" | "always";
|
||||
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
|
||||
} = {},
|
||||
) {
|
||||
@@ -1017,6 +1017,96 @@ describe("Codex app-server elicitation bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose allow-always for always plugin policy", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always-policy", status: "accepted" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-always-policy",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerElicitationRequest({
|
||||
requestParams: buildConnectorPluginApprovalElicitation({
|
||||
_meta: {
|
||||
codex_approval_kind: "mcp_tool_call",
|
||||
source: "connector",
|
||||
connector_id: "connector_google_calendar",
|
||||
connector_name: "Google Calendar",
|
||||
persist: ["session", "always"],
|
||||
tool_title: "create_event",
|
||||
},
|
||||
}),
|
||||
paramsForRun: createParams(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
pluginAppPolicyContext: createPluginAppPolicyContext({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
apps: [
|
||||
{
|
||||
appId: "connector_google_calendar",
|
||||
pluginName: "google-calendar",
|
||||
mcpServerNames: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
expect(gatewayToolArg(0, 2)).toMatchObject({
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unexpected allow-always decisions to one-shot for always plugin policy", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-unexpected-always",
|
||||
status: "accepted",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-unexpected-always",
|
||||
decision: "allow-always",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerElicitationRequest({
|
||||
requestParams: buildConnectorPluginApprovalElicitation({
|
||||
_meta: {
|
||||
codex_approval_kind: "mcp_tool_call",
|
||||
source: "connector",
|
||||
connector_id: "connector_google_calendar",
|
||||
connector_name: "Google Calendar",
|
||||
persist: ["session", "always"],
|
||||
tool_title: "create_event",
|
||||
},
|
||||
}),
|
||||
paramsForRun: createParams(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
pluginAppPolicyContext: createPluginAppPolicyContext({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
apps: [
|
||||
{
|
||||
appId: "connector_google_calendar",
|
||||
pluginName: "google-calendar",
|
||||
mcpServerNames: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("declines denied auto plugin app approvals", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })
|
||||
|
||||
@@ -318,10 +318,13 @@ async function buildPluginPolicyElicitationResponse(params: {
|
||||
paramsForRun: params.paramsForRun,
|
||||
title: approvalPrompt.title,
|
||||
description: approvalPrompt.description,
|
||||
allowedDecisions: approvalPrompt.allowedDecisions,
|
||||
allowedDecisions: allowedPluginPolicyApprovalDecisions(mode, approvalPrompt),
|
||||
signal: params.signal,
|
||||
});
|
||||
return buildElicitationResponse(approvalPrompt, outcome);
|
||||
return buildElicitationResponse(
|
||||
approvalPrompt,
|
||||
oneShotPluginPolicyApprovalOutcome(mode, outcome),
|
||||
);
|
||||
}
|
||||
logPluginElicitationDecline("unmappable_schema", params.requestParams);
|
||||
return declineElicitationResponse();
|
||||
@@ -329,10 +332,28 @@ async function buildPluginPolicyElicitationResponse(params: {
|
||||
|
||||
function resolvePluginDestructiveApprovalMode(
|
||||
entry: PluginAppPolicyContextEntry,
|
||||
): "allow" | "deny" | "auto" {
|
||||
): "allow" | "deny" | "auto" | "always" {
|
||||
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
|
||||
}
|
||||
|
||||
function allowedPluginPolicyApprovalDecisions(
|
||||
mode: "allow" | "deny" | "auto" | "always",
|
||||
approvalPrompt: BridgeableApprovalElicitation,
|
||||
): ExecApprovalDecision[] {
|
||||
const allowedDecisions = approvalPrompt.allowedDecisions ?? ["allow-once", "deny"];
|
||||
if (mode !== "always") {
|
||||
return allowedDecisions;
|
||||
}
|
||||
return allowedDecisions.filter((decision) => decision !== "allow-always");
|
||||
}
|
||||
|
||||
function oneShotPluginPolicyApprovalOutcome(
|
||||
mode: "allow" | "deny" | "auto" | "always",
|
||||
outcome: AppServerApprovalOutcome,
|
||||
): AppServerApprovalOutcome {
|
||||
return mode === "always" && outcome === "approved-session" ? "approved-once" : outcome;
|
||||
}
|
||||
|
||||
function readPluginApprovalElicitation(
|
||||
entry: PluginAppPolicyContextEntry,
|
||||
requestParams: JsonObject,
|
||||
|
||||
@@ -836,6 +836,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -53,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -411,6 +412,8 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
|
||||
@@ -170,6 +170,379 @@ describe("Codex plugin thread config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes destructive app access while clearing only durable approval overrides for always mode", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
let configReadCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
configReadCount += 1;
|
||||
if (configReadCount > 1) {
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/read": {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
enabled: false,
|
||||
},
|
||||
"calendar/read": {
|
||||
enabled: false,
|
||||
},
|
||||
"calendar/update": {
|
||||
approvalMode: "approve",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
|
||||
expect(apps?.["google-calendar-app"]).toEqual({
|
||||
enabled: true,
|
||||
destructive_enabled: true,
|
||||
open_world_enabled: true,
|
||||
default_tools_approval_mode: "auto",
|
||||
});
|
||||
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("config/read", { includeLayers: false });
|
||||
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools."calendar/create".approval_mode',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools."calendar/update".approval_mode',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits always policy apps when cwd effective approval overrides remain after cleanup", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
let configReadCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
configReadCount += 1;
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
source: configReadCount === 1 ? "user" : "project",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
configCwd: "/repo/project",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(request).toHaveBeenCalledWith("config/read", {
|
||||
includeLayers: false,
|
||||
cwd: "/repo/project",
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: effective approval overrides remain for calendar/create",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits always policy apps when approval override writes are overridden", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return { status: "okOverridden" };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
configCwd: "/repo/project",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: approval override for calendar/create is controlled by another config layer",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits always policy apps when durable approval override cleanup fails", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request: async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
throw new Error("readonly config");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: readonly config",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a restrictive app config when native plugin support is disabled", async () => {
|
||||
expect(
|
||||
shouldBuildCodexPluginThreadConfig({
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type CodexPluginOwnedApp,
|
||||
type CodexPluginRuntimeRequest,
|
||||
} from "./plugin-inventory.js";
|
||||
import type { JsonObject, JsonValue } from "./protocol.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
|
||||
/** Policy context for one app id exposed by a configured Codex plugin. */
|
||||
export type PluginAppPolicyContextEntry = {
|
||||
@@ -52,7 +52,7 @@ export type PluginAppPolicyContext = {
|
||||
export type CodexPluginThreadConfigDiagnostic =
|
||||
| CodexPluginInventoryDiagnostic
|
||||
| {
|
||||
code: "plugin_activation_failed" | "app_not_ready";
|
||||
code: "plugin_activation_failed" | "app_not_ready" | "approval_overrides_clear_failed";
|
||||
plugin?: ResolvedCodexPluginPolicy;
|
||||
message: string;
|
||||
};
|
||||
@@ -72,6 +72,7 @@ export type CodexPluginThreadConfig = {
|
||||
export type BuildCodexPluginThreadConfigParams = {
|
||||
pluginConfig?: unknown;
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
appCache?: CodexAppInventoryCache;
|
||||
appCacheKey: string;
|
||||
nowMs?: number;
|
||||
@@ -250,6 +251,18 @@ export async function buildCodexPluginThreadConfig(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
record.policy.destructiveApprovalMode === "always" &&
|
||||
!(await clearPersistedAppToolApprovalOverrides({
|
||||
request: params.request,
|
||||
configCwd: params.configCwd,
|
||||
plugin: record.policy,
|
||||
app,
|
||||
diagnostics,
|
||||
}))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const appConfig: JsonObject = {
|
||||
enabled: true,
|
||||
destructive_enabled: record.policy.allowDestructiveActions,
|
||||
@@ -367,6 +380,86 @@ function buildPluginAppPolicyContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function clearPersistedAppToolApprovalOverrides(params: {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
plugin: ResolvedCodexPluginPolicy;
|
||||
app: CodexPluginOwnedApp;
|
||||
diagnostics: CodexPluginThreadConfigDiagnostic[];
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const overrideNames = await readPersistedAppToolApprovalOverrideNames(params);
|
||||
for (const toolName of overrideNames) {
|
||||
const response = await params.request("config/value/write", {
|
||||
keyPath: `apps.${quoteConfigKeyPathSegment(params.app.id)}.tools.${quoteConfigKeyPathSegment(
|
||||
toolName,
|
||||
)}.approval_mode`,
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
if (isOverriddenConfigWriteResponse(response)) {
|
||||
throw new Error(`approval override for ${toolName} is controlled by another config layer`);
|
||||
}
|
||||
}
|
||||
const remainingOverrideNames = await readPersistedAppToolApprovalOverrideNames(params);
|
||||
if (remainingOverrideNames.length > 0) {
|
||||
throw new Error(
|
||||
`effective approval overrides remain for ${remainingOverrideNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
params.diagnostics.push({
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: params.plugin,
|
||||
message: `Could not clear durable Codex app approval overrides for ${params.app.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPersistedAppToolApprovalOverrideNames(params: {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
app: CodexPluginOwnedApp;
|
||||
}): Promise<string[]> {
|
||||
const response = await params.request("config/read", {
|
||||
includeLayers: false,
|
||||
...(params.configCwd ? { cwd: params.configCwd } : {}),
|
||||
});
|
||||
const config = isJsonObject(response) ? response.config : undefined;
|
||||
const appsRoot = isJsonObject(config) ? config.apps : undefined;
|
||||
const nestedApps = isJsonObject(appsRoot) ? appsRoot.apps : undefined;
|
||||
const appConfig = isJsonObject(appsRoot)
|
||||
? (appsRoot[params.app.id] ??
|
||||
(isJsonObject(nestedApps) ? nestedApps[params.app.id] : undefined))
|
||||
: undefined;
|
||||
const tools = isJsonObject(appConfig) ? appConfig.tools : undefined;
|
||||
if (!isJsonObject(tools)) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(tools)
|
||||
.filter(([, value]) => hasPersistedToolApprovalOverride(value))
|
||||
.map(([toolName]) => toolName)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function hasPersistedToolApprovalOverride(value: JsonValue): boolean {
|
||||
return (
|
||||
isJsonObject(value) && (value.approval_mode !== undefined || value.approvalMode !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function isOverriddenConfigWriteResponse(response: unknown): boolean {
|
||||
return isJsonObject(response) && response.status === "okOverridden";
|
||||
}
|
||||
|
||||
function quoteConfigKeyPathSegment(segment: string): string {
|
||||
return `"${segment.replace(/["\\]/g, (char) => `\\${char}`)}"`;
|
||||
}
|
||||
|
||||
function shouldWaitForInitialAppInventory(
|
||||
params: BuildCodexPluginThreadConfigParams,
|
||||
policy: ResolvedCodexPluginsPolicy,
|
||||
|
||||
@@ -575,6 +575,8 @@ type CodexAppServerRequestResultMap = {
|
||||
"account/read": CodexGetAccountResponse;
|
||||
"app/list": CodexAppsListResponse;
|
||||
"config/mcpServer/reload": JsonValue;
|
||||
"config/read": JsonValue;
|
||||
"config/value/write": JsonValue;
|
||||
"environment/add": JsonValue;
|
||||
"experimentalFeature/enablement/set": JsonValue;
|
||||
"feedback/upload": JsonValue;
|
||||
|
||||
@@ -112,6 +112,44 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows config value writes in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ ok: true }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = {
|
||||
keyPath: 'apps."google-calendar-app".tools',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "config/value/write",
|
||||
requestParams: params,
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
sessionKey: "sandboxed-session",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows config reads in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ config: { apps: { apps: {} } } }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = { includeLayers: false };
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "config/read",
|
||||
requestParams: params,
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
sessionKey: "sandboxed-session",
|
||||
}),
|
||||
).resolves.toEqual({ config: { apps: { apps: {} } } });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config/read", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
|
||||
@@ -841,9 +841,11 @@ export async function runCodexAppServerAttempt(
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
|
||||
["account/read", "allowed-control-plane"],
|
||||
["app/list", "allowed-control-plane"],
|
||||
["config/mcpServer/reload", "allowed-control-plane"],
|
||||
["config/read", "allowed-control-plane"],
|
||||
["config/value/write", "allowed-control-plane"],
|
||||
["environment/add", "allowed-control-plane"],
|
||||
["experimentalFeature/enablement/set", "allowed-control-plane"],
|
||||
["feedback/upload", "allowed-control-plane"],
|
||||
|
||||
@@ -145,6 +145,35 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("round-trips always plugin app policy context destructive approval mode", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
const pluginAppPolicyContext = {
|
||||
fingerprint: "plugin-policy-always",
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated" as const,
|
||||
pluginName: "google-calendar",
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always" as const,
|
||||
mcpServerNames: ["google-calendar"],
|
||||
},
|
||||
},
|
||||
pluginAppIds: {
|
||||
"google-calendar": ["google-calendar-app"],
|
||||
},
|
||||
};
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-123",
|
||||
cwd: tempDir,
|
||||
pluginAppPolicyContext,
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("normalizes v1 plugin app policy context destructive approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -421,6 +421,9 @@ function readDestructiveApprovalMode(
|
||||
if (value === "auto") {
|
||||
return bindingSchemaVersion === 1 ? "allow" : "auto";
|
||||
}
|
||||
if (value === "always" && bindingSchemaVersion === 2) {
|
||||
return "always";
|
||||
}
|
||||
if (value === "on-request" && bindingSchemaVersion === 1) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCodexTrajectoryRecorder,
|
||||
recordCodexTrajectoryCompletion,
|
||||
recordCodexTrajectoryContext,
|
||||
resolveCodexTrajectoryAppendFlags,
|
||||
resolveCodexTrajectoryPointerFlags,
|
||||
@@ -80,7 +81,9 @@ describe("Codex trajectory recorder", () => {
|
||||
expect(content).not.toContain("secret");
|
||||
expect(content).not.toContain("sk-test-secret-token");
|
||||
expect(content).not.toContain("sk-other-secret-token");
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== "win32") {
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -253,4 +256,235 @@ describe("Codex trajectory recorder", () => {
|
||||
expect(parsed.data?.truncated).toBe(true);
|
||||
expect(parsed.data?.reason).toBe("trajectory-event-size-limit");
|
||||
});
|
||||
|
||||
it("preserves usage when truncating oversized model completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const usage = {
|
||||
input: 384_954,
|
||||
output: 5_624,
|
||||
cacheRead: 333_824,
|
||||
reasoningTokens: 2_038,
|
||||
total: 724_402,
|
||||
};
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: usage,
|
||||
assistantTexts: ["done"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
expect(parsed.type).toBe("model.completed");
|
||||
expect(parsed.data).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
usage,
|
||||
});
|
||||
expect(parsed.data.messagesSnapshot).toBeUndefined();
|
||||
expect(parsed.data.droppedFields).toContain("messagesSnapshot");
|
||||
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("drops oversized preserved fields when needed to keep completion events bounded", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const oversizedUsage = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_value, index) => [`field-${index}`, "x".repeat(5_000)]),
|
||||
);
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: oversizedUsage,
|
||||
assistantTexts: ["x".repeat(32_000)],
|
||||
messagesSnapshot: [{ role: "assistant", content: "x".repeat(32_000) }],
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
expect(parsed.data).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
});
|
||||
expect(parsed.data.usage).toBeUndefined();
|
||||
expect(parsed.data.droppedFields).toEqual(
|
||||
expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]),
|
||||
);
|
||||
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("preserves usage on non-final oversized model completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const firstUsage = {
|
||||
input: 384_954,
|
||||
output: 5_624,
|
||||
cacheRead: 333_824,
|
||||
reasoningTokens: 2_038,
|
||||
total: 724_402,
|
||||
};
|
||||
const secondUsage = { input: 12, output: 3, total: 15 };
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: firstUsage,
|
||||
assistantTexts: ["first"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-2",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: secondUsage,
|
||||
assistantTexts: ["final answer"],
|
||||
messagesSnapshot: [{ role: "assistant", content: "final answer" }],
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const events = fs
|
||||
.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8")
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].data).toMatchObject({
|
||||
truncated: true,
|
||||
usage: firstUsage,
|
||||
});
|
||||
expect(events[1].data).toMatchObject({
|
||||
turnId: "turn-2",
|
||||
usage: secondUsage,
|
||||
assistantTexts: ["final answer"],
|
||||
});
|
||||
expect(events[1].data.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts secrets before preserving usage in truncated completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: {
|
||||
total: 1,
|
||||
apiKey: "sk-test-secret-token",
|
||||
authorization: "Bearer sk-other-secret-token",
|
||||
},
|
||||
assistantTexts: ["done"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
const preservedUsage = JSON.stringify(parsed.data.usage);
|
||||
expect(parsed.data.truncated).toBe(true);
|
||||
expect(preservedUsage).toContain("redacted");
|
||||
expect(preservedUsage).not.toContain("sk-test-secret-token");
|
||||
expect(preservedUsage).not.toContain("sk-other-secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]
|
||||
const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu;
|
||||
const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
|
||||
const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const;
|
||||
|
||||
type CodexTrajectoryOpenFlagConstants = Pick<
|
||||
typeof nodeFs.constants,
|
||||
@@ -82,19 +83,57 @@ function boundedTrajectoryLine(event: Record<string, unknown>): string | undefin
|
||||
if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${line}\n`;
|
||||
}
|
||||
const truncated = JSON.stringify({
|
||||
...event,
|
||||
data: {
|
||||
truncated: true,
|
||||
originalBytes: bytes,
|
||||
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
reason: "trajectory-event-size-limit",
|
||||
},
|
||||
});
|
||||
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${truncated}\n`;
|
||||
|
||||
const originalData =
|
||||
event.data && typeof event.data === "object" && !Array.isArray(event.data)
|
||||
? (event.data as Record<string, unknown>)
|
||||
: {};
|
||||
const originalDataKeys = Object.keys(originalData);
|
||||
const preservedDataKeys = new Set<string>();
|
||||
const baseData = {
|
||||
truncated: true,
|
||||
originalBytes: bytes,
|
||||
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
reason: "trajectory-event-size-limit",
|
||||
};
|
||||
const buildTruncatedLine = (includeDroppedFields: boolean): string | undefined => {
|
||||
const data: Record<string, unknown> = { ...baseData };
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
if (preservedDataKeys.has(key)) {
|
||||
data[key] = originalData[key];
|
||||
}
|
||||
}
|
||||
if (includeDroppedFields) {
|
||||
const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key));
|
||||
if (droppedFields.length > 0) {
|
||||
data.droppedFields = droppedFields;
|
||||
}
|
||||
}
|
||||
const truncated = JSON.stringify({ ...event, data });
|
||||
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${truncated}\n`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let best = buildTruncatedLine(true) ?? buildTruncatedLine(false);
|
||||
if (!best) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
if (!Object.hasOwn(originalData, key)) {
|
||||
continue;
|
||||
}
|
||||
preservedDataKeys.add(key);
|
||||
const next = buildTruncatedLine(true) ?? buildTruncatedLine(false);
|
||||
if (next) {
|
||||
best = next;
|
||||
continue;
|
||||
}
|
||||
preservedDataKeys.delete(key);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function resolveTrajectoryPointerFilePath(sessionFile: string): string {
|
||||
|
||||
@@ -23,7 +23,7 @@ export type CodexPluginConfigEntry = {
|
||||
enabled?: boolean;
|
||||
marketplaceName?: string;
|
||||
pluginName?: string;
|
||||
allow_destructive_actions?: boolean | "auto";
|
||||
allow_destructive_actions?: boolean | "auto" | "always";
|
||||
};
|
||||
|
||||
export type CodexPluginsConfigBlock = {
|
||||
|
||||
@@ -43,7 +43,7 @@ export type CodexPluginMigrationConfigEntry = {
|
||||
configKey: string;
|
||||
pluginName: string;
|
||||
enabled: boolean;
|
||||
allowDestructiveActions?: "auto";
|
||||
allowDestructiveActions?: "auto" | "always";
|
||||
};
|
||||
|
||||
type CodexPluginMigrationBlockSkipDetails = {
|
||||
@@ -168,15 +168,18 @@ function isLegacyDestructivePolicyRepair(
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacyDestructivePolicyConfigEntryRepair(
|
||||
function readExistingPluginAllowDestructiveActions(
|
||||
existing: unknown,
|
||||
pluginName: string,
|
||||
): boolean {
|
||||
): "auto" | "always" | undefined {
|
||||
const existingEntry = isRecord(existing) ? existing : undefined;
|
||||
return (
|
||||
existingEntry?.allow_destructive_actions === "on-request" &&
|
||||
existingEntry.pluginName === pluginName
|
||||
if (existingEntry?.pluginName !== pluginName) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeExistingAllowDestructiveActions(
|
||||
existingEntry.allow_destructive_actions,
|
||||
);
|
||||
return normalized === "auto" || normalized === "always" ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildPluginItems(
|
||||
@@ -203,12 +206,15 @@ function buildPluginItems(
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: plugin.pluginName,
|
||||
...(isLegacyDestructivePolicyConfigEntryRepair(
|
||||
existingPluginEntries[configKey],
|
||||
plugin.pluginName,
|
||||
)
|
||||
? { allow_destructive_actions: "auto" }
|
||||
: {}),
|
||||
...(() => {
|
||||
const allowDestructiveActions = readExistingPluginAllowDestructiveActions(
|
||||
existingPluginEntries[configKey],
|
||||
plugin.pluginName,
|
||||
);
|
||||
return allowDestructiveActions
|
||||
? { allow_destructive_actions: allowDestructiveActions }
|
||||
: {};
|
||||
})(),
|
||||
};
|
||||
const conflict =
|
||||
!ctx.overwrite &&
|
||||
@@ -234,8 +240,9 @@ function buildPluginItems(
|
||||
pluginName: plugin.pluginName,
|
||||
sourceInstalled: plugin.installed === true,
|
||||
sourceEnabled: plugin.enabled === true,
|
||||
...(plannedEntry.allow_destructive_actions === "auto"
|
||||
? { allowDestructiveActions: "auto" }
|
||||
...(plannedEntry.allow_destructive_actions === "auto" ||
|
||||
plannedEntry.allow_destructive_actions === "always"
|
||||
? { allowDestructiveActions: plannedEntry.allow_destructive_actions }
|
||||
: {}),
|
||||
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
|
||||
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
|
||||
@@ -310,13 +317,15 @@ export function readCodexPluginMigrationConfigEntry(
|
||||
configKey,
|
||||
pluginName,
|
||||
enabled,
|
||||
...(allowDestructiveActions === "auto" ? { allowDestructiveActions: "auto" } : {}),
|
||||
...(allowDestructiveActions === "auto" || allowDestructiveActions === "always"
|
||||
? { allowDestructiveActions }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readExistingAllowDestructiveActions(
|
||||
config: MigrationProviderContext["config"],
|
||||
): boolean | "auto" | undefined {
|
||||
): boolean | "auto" | "always" | undefined {
|
||||
const value = readMigrationConfigPath(config as Record<string, unknown>, [
|
||||
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
|
||||
"allow_destructive_actions",
|
||||
@@ -324,8 +333,16 @@ function readExistingAllowDestructiveActions(
|
||||
return normalizeExistingAllowDestructiveActions(value);
|
||||
}
|
||||
|
||||
function normalizeExistingAllowDestructiveActions(value: unknown): boolean | "auto" | undefined {
|
||||
return value === "auto" || value === "on-request" ? "auto" : asBoolean(value);
|
||||
function normalizeExistingAllowDestructiveActions(
|
||||
value: unknown,
|
||||
): boolean | "auto" | "always" | undefined {
|
||||
if (value === "auto" || value === "on-request") {
|
||||
return "auto";
|
||||
}
|
||||
if (value === "always") {
|
||||
return "always";
|
||||
}
|
||||
return asBoolean(value);
|
||||
}
|
||||
|
||||
function readExistingPluginPolicyRepairs(
|
||||
|
||||
@@ -2108,6 +2108,76 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves global always destructive plugin policy during migration", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] } satisfies v2.SkillsListResponse;
|
||||
}
|
||||
if (method === "hooks/list") {
|
||||
return { data: [] } satisfies v2.HooksListResponse;
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
runtime: createConfigRuntime(configState),
|
||||
});
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
config: configState,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records auth-required plugin installs as disabled explicit config entries", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
|
||||
@@ -207,4 +207,65 @@ describe("codex cli node sessions", () => {
|
||||
}),
|
||||
).rejects.toThrow("Codex CLI node command returned malformed payloadJSON.");
|
||||
});
|
||||
|
||||
it("keeps Codex history session previews on UTF-16 code point boundaries", async () => {
|
||||
const sessionId = "019e2007-1f7e-7eb1-a42b-8c01f4b9b5ce";
|
||||
const text = `${"a".repeat(136)}🤖tail`;
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "history.jsonl"),
|
||||
JSON.stringify({ session_id: sessionId, ts: 1778678322, text }),
|
||||
);
|
||||
|
||||
const command = createCodexCliSessionNodeHostCommands().find(
|
||||
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
|
||||
);
|
||||
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
|
||||
const parsed = JSON.parse(raw ?? "{}") as {
|
||||
sessions?: Array<{ lastMessage?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"a".repeat(136)}...`);
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
|
||||
});
|
||||
|
||||
it("keeps Codex session-file previews on UTF-16 code point boundaries", async () => {
|
||||
const sessionId = "019e23d1-f33d-78e3-959e-0f56f30a5248";
|
||||
const sessionDir = path.join(tempDir, "sessions", "2026", "05", "14");
|
||||
const sessionFile = path.join(sessionDir, `rollout-2026-05-14T00-10-22-${sessionId}.jsonl`);
|
||||
const text = `${"b".repeat(136)}🤖tail`;
|
||||
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-14T00:10:23.618Z",
|
||||
type: "session_meta",
|
||||
payload: { id: sessionId, cwd: "/tmp/codex-work" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-14T00:10:23.619Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const command = createCodexCliSessionNodeHostCommands().find(
|
||||
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
|
||||
);
|
||||
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
|
||||
const parsed = JSON.parse(raw ?? "{}") as {
|
||||
sessions?: Array<{ lastMessage?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"b".repeat(136)}...`);
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgram,
|
||||
@@ -691,7 +692,10 @@ function normalizeTimeoutMs(value: unknown): number {
|
||||
}
|
||||
|
||||
function truncateText(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 3)}...` : value;
|
||||
if (value.length <= max) {
|
||||
return value;
|
||||
}
|
||||
return `${truncateUtf16Safe(value, Math.max(0, max - 3))}...`;
|
||||
}
|
||||
|
||||
function compareOptionalStringsDesc(a?: string, b?: string): number {
|
||||
|
||||
@@ -36,6 +36,10 @@ type DuckDuckGoResult = {
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
function isDecodableCodePoint(cp: number): boolean {
|
||||
return Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(
|
||||
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
|
||||
@@ -72,10 +76,12 @@ function decodeHtmlEntities(text: string): string {
|
||||
return "&";
|
||||
}
|
||||
if (normalized.startsWith("&#x")) {
|
||||
return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
|
||||
const codePoint = Number.parseInt(normalized.slice(3, -1), 16);
|
||||
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
||||
}
|
||||
if (normalized.startsWith("&#")) {
|
||||
return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
|
||||
const codePoint = Number.parseInt(normalized.slice(2, -1), 10);
|
||||
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
||||
}
|
||||
return entity;
|
||||
},
|
||||
|
||||
@@ -205,6 +205,20 @@ describe("duckduckgo web search provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out-of-range numeric html entities intact instead of throwing", () => {
|
||||
expect(() => ddgClientTesting.decodeHtmlEntities("Result � end")).not.toThrow();
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Result � end")).toBe(
|
||||
"Result � end",
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Hex � tail")).toBe("Hex � tail");
|
||||
// Surrogate-range entities would decode to lone UTF-16 surrogates; keep them intact.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
// A valid supplementary-plane entity still decodes.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Smile 😀")).toBe("Smile 😀");
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// A result whose text literally shows "<" arrives double-encoded as
|
||||
// "&lt;". Decoding & first would re-decode it into "<", corrupting
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
type ProviderRequestTransportOverrides,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
@@ -97,11 +98,11 @@ async function generateGeminiInlineDataText(params: {
|
||||
try {
|
||||
await assertOkOrThrowProviderError(res, params.httpErrorLabel);
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
const payload = await readProviderJsonResponse<{
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ text?: string }> };
|
||||
}>;
|
||||
};
|
||||
}>(res, params.httpErrorLabel);
|
||||
const parts = payload.candidates?.[0]?.content?.parts ?? [];
|
||||
const text = parts
|
||||
.map((part) => part?.text?.trim())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Google tests cover media understanding provider.video plugin behavior.
|
||||
import { createServer, type Server } from "node:http";
|
||||
import {
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
@@ -10,6 +11,49 @@ import { resolveGoogleGenerativeAiHttpRequestConfig } from "./runtime-api.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
|
||||
|
||||
async function listenLoopbackServer(server: Server): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("expected loopback TCP address"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
|
||||
let resolveClosed: (sentBytes: number) => void = () => {};
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
const server = createServer((_req, res) => {
|
||||
let sentBytes = 0;
|
||||
const chunk = Buffer.alloc(64 * 1024, 0x20);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
const timer = setInterval(() => {
|
||||
if (sentBytes >= LOOPBACK_RESPONSE_BYTES) {
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
sentBytes += chunk.length;
|
||||
res.write(chunk);
|
||||
}, 1);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
resolveClosed(sentBytes);
|
||||
});
|
||||
});
|
||||
return { server, closed };
|
||||
}
|
||||
|
||||
describe("describeGeminiVideo", () => {
|
||||
it("respects case-insensitive x-goog-api-key overrides", async () => {
|
||||
let seenKey: string | null = null;
|
||||
@@ -114,6 +158,29 @@ describe("describeGeminiVideo", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds oversized video JSON responses and closes the stream early", async () => {
|
||||
const { server, closed } = createOversizedJsonServer();
|
||||
const port = await listenLoopbackServer(server);
|
||||
const fetchFn = withFetchPreconnect(async () =>
|
||||
fetch(`http://127.0.0.1:${port}/google-video-json`),
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
describeGeminiVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
fetchFn,
|
||||
}),
|
||||
).rejects.toThrow(/JSON response exceeds 16777216 bytes/u);
|
||||
await expect(closed).resolves.toBeLessThan(LOOPBACK_RESPONSE_BYTES);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-Google video base URLs before sending authenticated requests", async () => {
|
||||
await expect(
|
||||
describeGeminiVideo({
|
||||
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
let buildGoogleSpeechProvider: typeof import("./speech-provider.js").buildGoogleSpeechProvider;
|
||||
let testing: typeof import("./speech-provider.js").testing;
|
||||
|
||||
const GOOGLE_TTS_JSON_CAP_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildGoogleSpeechProvider, testing } = await import("./speech-provider.js"));
|
||||
});
|
||||
@@ -56,6 +58,26 @@ function installGoogleTtsRequestMock(pcm = Buffer.from([1, 0, 2, 0])) {
|
||||
return postJsonRequestMock;
|
||||
}
|
||||
|
||||
function oversizedGoogleTtsJsonResponse(onCancel: () => void): Response {
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(GOOGLE_TTS_JSON_CAP_BYTES + 1));
|
||||
},
|
||||
cancel() {
|
||||
onCancel();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" }, status: 200 },
|
||||
);
|
||||
Object.defineProperty(response, "json", {
|
||||
value: async () => {
|
||||
throw new Error("unbounded json reader was used");
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Expected record");
|
||||
@@ -149,6 +171,39 @@ describe("Google speech provider", () => {
|
||||
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds oversized Gemini TTS success JSON responses and cancels the stream", async () => {
|
||||
let cancelCount = 0;
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
response: oversizedGoogleTtsJsonResponse(() => {
|
||||
cancelCount += 1;
|
||||
}),
|
||||
release,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: oversizedGoogleTtsJsonResponse(() => {
|
||||
cancelCount += 1;
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "oversized tts response",
|
||||
cfg: {},
|
||||
providerConfig: {
|
||||
apiKey: "google-test-key",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 12_000,
|
||||
}),
|
||||
).rejects.toThrow("Google TTS response: JSON response exceeds 16777216 bytes");
|
||||
expect(cancelCount).toBe(2);
|
||||
expect(release).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("transcodes Gemini PCM to Opus for voice-note targets", async () => {
|
||||
installGoogleTtsRequestMock(Buffer.from([5, 0, 6, 0]));
|
||||
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("google-opus"));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
@@ -503,7 +504,11 @@ async function synthesizeGoogleTtsPcmOnce(params: {
|
||||
}
|
||||
}
|
||||
try {
|
||||
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
|
||||
const payload = await readProviderJsonResponse<GoogleGenerateSpeechResponse>(
|
||||
res,
|
||||
"Google TTS response",
|
||||
);
|
||||
return extractGoogleSpeechPcm(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new GoogleTtsRetryableError(message);
|
||||
|
||||
@@ -476,6 +476,45 @@ describe("google transport stream", () => {
|
||||
expect(result.content[2]).toHaveProperty("thoughtSignature", "Y2FsbF9zaWdfMQ==");
|
||||
});
|
||||
|
||||
it("preserves MAX_TOKENS when the partial response contains a function call", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
buildSseResponse([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ functionCall: { name: "lookup", args: { q: "hello" } } }],
|
||||
},
|
||||
finishReason: "MAX_TOKENS",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const streamFn = createGoogleGenerativeAiTransportStreamFn();
|
||||
const stream = await Promise.resolve(
|
||||
streamFn(
|
||||
buildGeminiModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof streamFn>[1],
|
||||
{ apiKey: "gemini-api-key" } as Parameters<typeof streamFn>[2],
|
||||
),
|
||||
);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("length");
|
||||
expect(result.content).toEqual([expect.objectContaining({ type: "toolCall", name: "lookup" })]);
|
||||
});
|
||||
|
||||
it("strips redundant google provider prefixes from Gemini API model paths", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));
|
||||
|
||||
|
||||
@@ -1404,7 +1404,12 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea
|
||||
}
|
||||
if (typeof candidate?.finishReason === "string") {
|
||||
output.stopReason = mapStopReasonString(candidate.finishReason);
|
||||
if (output.content.some((block) => block.type === "toolCall")) {
|
||||
// MAX_TOKENS can leave a complete-looking partial call. Only a normal
|
||||
// Google stop may promote parsed calls into an executable tool-use turn.
|
||||
if (
|
||||
output.stopReason === "stop" &&
|
||||
output.content.some((block) => block.type === "toolCall")
|
||||
) {
|
||||
output.stopReason = "toolUse";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
@@ -13,11 +17,7 @@ const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
||||
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
||||
|
||||
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
return readProviderJsonResponse<T>(response, label);
|
||||
}
|
||||
|
||||
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||
@@ -57,7 +57,7 @@ async function withGoogleChatResponse<T>(params: {
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
const text = await readResponseTextLimited(response).catch(() => "");
|
||||
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
return await handleResponse(response);
|
||||
|
||||
@@ -110,6 +110,45 @@ function createDeferred<T>(): {
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
type CardPayloadWithTextWidgets = {
|
||||
cardsV2: Array<{
|
||||
card: {
|
||||
sections?: Array<{
|
||||
header?: string;
|
||||
widgets?: Array<{ textParagraph?: { text: string } }>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
function getTextParagraphText(payload: unknown, header: string): string {
|
||||
const text = (payload as CardPayloadWithTextWidgets).cardsV2[0]?.card.sections?.find(
|
||||
(section) => section.header === header,
|
||||
)?.widgets?.[0]?.textParagraph?.text;
|
||||
if (typeof text !== "string") {
|
||||
throw new Error(`Expected ${header} text paragraph`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function isUtf16WellFormed(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const nextCodeUnit = index + 1 < value.length ? value.charCodeAt(index + 1) : -1;
|
||||
if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("googleChatApprovalNativeRuntime", () => {
|
||||
async function preparePendingDelivery(view = createPendingView()) {
|
||||
const nowMs = Date.now();
|
||||
@@ -149,6 +188,31 @@ describe("googleChatApprovalNativeRuntime", () => {
|
||||
return { pendingPayload, plannedTarget, prepared, request, view };
|
||||
}
|
||||
|
||||
it("keeps truncated pending command card text UTF-16 well formed", async () => {
|
||||
const view = createPendingView();
|
||||
view.commandText = `${"a".repeat(1796)}😀${"b".repeat(100)}`;
|
||||
|
||||
const { pendingPayload } = await preparePendingDelivery(view);
|
||||
const commandText = getTextParagraphText(pendingPayload, "Command");
|
||||
|
||||
expect(commandText.length).toBeLessThanOrEqual(1800);
|
||||
expect(commandText.endsWith("...")).toBe(true);
|
||||
expect(isUtf16WellFormed(commandText)).toBe(true);
|
||||
expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("\\ud83d");
|
||||
});
|
||||
|
||||
it("preserves a complete astral character when it fits before the truncation suffix", async () => {
|
||||
const view = createPendingView();
|
||||
view.commandText = `${"a".repeat(1795)}😀${"b".repeat(100)}`;
|
||||
|
||||
const { pendingPayload } = await preparePendingDelivery(view);
|
||||
const commandText = getTextParagraphText(pendingPayload, "Command");
|
||||
|
||||
expect(commandText).toBe(`${"a".repeat(1795)}😀...`);
|
||||
expect(commandText.length).toBe(1800);
|
||||
expect(isUtf16WellFormed(commandText)).toBe(true);
|
||||
});
|
||||
|
||||
it("sends pending cards and updates the delivered message without buttons", async () => {
|
||||
sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });
|
||||
updateGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approva
|
||||
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
|
||||
import {
|
||||
@@ -87,7 +88,7 @@ function escapeGoogleChatText(text: string): string {
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string {
|
||||
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`;
|
||||
return text.length <= maxChars ? text : `${truncateUtf16Safe(text, maxChars - 3)}...`;
|
||||
}
|
||||
|
||||
function buildMetadataText(metadata: readonly { label: string; value: string }[]): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Googlechat plugin module implements auth behavior.
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
@@ -17,11 +18,10 @@ const CHAT_CERTS_URL =
|
||||
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
|
||||
|
||||
async function readGoogleChatCertsResponse(response: Response): Promise<Record<string, string>> {
|
||||
try {
|
||||
return (await response.json()) as Record<string, string>;
|
||||
} catch (cause) {
|
||||
throw new Error("Google Chat cert fetch failed: malformed JSON response", { cause });
|
||||
}
|
||||
return readProviderJsonResponse<Record<string, string>>(
|
||||
response,
|
||||
"Google Chat cert fetch failed",
|
||||
);
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
|
||||
|
||||
@@ -568,4 +568,137 @@ describe("verifyGoogleChatRequest", () => {
|
||||
});
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("bounded JSON read (readProviderJsonResponse delegation)", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("cancels oversized cert fetch JSON body via the 16 MiB provider cap", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedJson = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedJson,
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/JSON response exceeds 16777216 bytes/);
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects oversized sendMessage JSON body via the 16 MiB provider cap", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedJson = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedJson,
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow(/Google Chat API request failed: JSON response exceeds 16777216 bytes/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
});
|
||||
|
||||
it("caps non-OK sendMessage error bodies before formatting the API error", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new TextEncoder().encode("x".repeat(ONE_MIB));
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedError = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 500, statusText: "Internal Server Error" },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedError,
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow(/^Google Chat API 500: x+/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,3 +15,21 @@ describe("irc outbound chunking", () => {
|
||||
expect(ircOutboundBaseAdapter.textChunkLimit).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("irc outbound sanitizeText", () => {
|
||||
afterEach(() => {
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("strips internal tool-trace banners before outbound delivery", () => {
|
||||
const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed";
|
||||
|
||||
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe("Done.");
|
||||
});
|
||||
|
||||
it("preserves ordinary assistant prose while sanitizing", () => {
|
||||
const text = "The pipeline has 3 open deals.";
|
||||
|
||||
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Irc plugin module implements outbound base behavior.
|
||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { chunkTextForOutbound } from "./channel-api.js";
|
||||
|
||||
export const ircOutboundBaseAdapter = {
|
||||
@@ -7,5 +8,9 @@ export const ircOutboundBaseAdapter = {
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
textChunkLimit: 350,
|
||||
sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
|
||||
// IRC's plain-text pass does not remove assistant scaffolding. Run the
|
||||
// canonical delivery sanitizer first so internal tool traces are dropped
|
||||
// before channel formatting.
|
||||
sanitizeText: ({ text }: { text: string }) =>
|
||||
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Msteams plugin module implements feedback reflection prompt behavior.
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
/** Max chars of the thumbed-down response to include in the reflection prompt. */
|
||||
const MAX_RESPONSE_CHARS = 500;
|
||||
@@ -19,7 +20,7 @@ export function buildReflectionPrompt(params: {
|
||||
if (params.thumbedDownResponse) {
|
||||
const truncated =
|
||||
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
|
||||
? `${params.thumbedDownResponse.slice(0, MAX_RESPONSE_CHARS)}...`
|
||||
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
|
||||
: params.thumbedDownResponse;
|
||||
parts.push(`\nYour response was:\n> ${truncated}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ import { msteamsRuntimeStub } from "./test-support/runtime.js";
|
||||
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
|
||||
// on the ES2024 String.prototype.isWellFormed() runtime API.
|
||||
const UNPAIRED_SURROGATE_RE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
|
||||
describe("buildFeedbackEvent", () => {
|
||||
it("builds a well-formed custom event", () => {
|
||||
const event = buildFeedbackEvent({
|
||||
@@ -73,6 +78,26 @@ describe("buildReflectionPrompt", () => {
|
||||
expect(prompt.length).toBeLessThan(longResponse.length + 500);
|
||||
});
|
||||
|
||||
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
|
||||
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
|
||||
|
||||
const prompt = buildReflectionPrompt({ thumbedDownResponse });
|
||||
|
||||
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
|
||||
expect(prompt).toContain(`${"a".repeat(499)}...`);
|
||||
expect(prompt).not.toContain("\ud83e");
|
||||
expect(prompt).not.toContain("\udd9e");
|
||||
});
|
||||
|
||||
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
|
||||
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
|
||||
|
||||
const prompt = buildReflectionPrompt({ thumbedDownResponse });
|
||||
|
||||
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
|
||||
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
|
||||
});
|
||||
|
||||
it("includes user comment when provided", () => {
|
||||
const prompt = buildReflectionPrompt({
|
||||
thumbedDownResponse: "Some response",
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
summarizeParentMessage,
|
||||
} from "./thread-parent-context.js";
|
||||
|
||||
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
|
||||
// on the ES2024 String.prototype.isWellFormed() runtime API.
|
||||
const UNPAIRED_SURROGATE_RE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
|
||||
describe("summarizeParentMessage", () => {
|
||||
it("returns undefined for missing message", () => {
|
||||
expect(summarizeParentMessage(undefined)).toBeUndefined();
|
||||
@@ -81,6 +86,20 @@ describe("summarizeParentMessage", () => {
|
||||
expect(summary?.text.length).toBeLessThanOrEqual(400);
|
||||
expect(summary?.text.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps truncated parent text well-formed when truncating surrogate pairs", () => {
|
||||
const msg: GraphThreadMessage = {
|
||||
id: "p1",
|
||||
from: { user: { displayName: "Dana" } },
|
||||
body: { content: `${"a".repeat(398)}🦞${"b".repeat(50)}`, contentType: "text" },
|
||||
};
|
||||
|
||||
const summary = summarizeParentMessage(msg);
|
||||
|
||||
expect(summary?.text).not.toMatch(UNPAIRED_SURROGATE_RE);
|
||||
expect(summary?.text).toBe(`${"a".repeat(398)}…`);
|
||||
expect(summary?.text.endsWith("\ud83e…")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatParentContextEvent", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
|
||||
import type { GraphThreadMessage } from "./graph-thread.js";
|
||||
|
||||
@@ -138,7 +139,9 @@ export function summarizeParentMessage(
|
||||
return {
|
||||
sender,
|
||||
text:
|
||||
text.length > PARENT_TEXT_MAX_CHARS ? `${text.slice(0, PARENT_TEXT_MAX_CHARS - 1)}…` : text,
|
||||
text.length > PARENT_TEXT_MAX_CHARS
|
||||
? `${truncateUtf16Safe(text, PARENT_TEXT_MAX_CHARS - 1)}…`
|
||||
: text,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,55 @@ describe("SeenTracker", () => {
|
||||
tracker.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([-1, 0])("falls back to default TTL for non-positive ttlMs %s", (ttlMs) => {
|
||||
vi.useFakeTimers();
|
||||
const tracker = createTracker({ ttlMs, pruneIntervalMs: 10 * 60 * 1000 });
|
||||
|
||||
try {
|
||||
tracker.add("id1");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(tracker.peek("id1")).toBe(true);
|
||||
} finally {
|
||||
tracker.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default TTL for infinite ttlMs", () => {
|
||||
vi.useFakeTimers();
|
||||
const tracker = createTracker({
|
||||
ttlMs: Number.POSITIVE_INFINITY,
|
||||
pruneIntervalMs: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
try {
|
||||
tracker.add("id1");
|
||||
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
|
||||
expect(tracker.peek("id1")).toBe(false);
|
||||
} finally {
|
||||
tracker.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([-1, 0, Number.POSITIVE_INFINITY])(
|
||||
"uses the default prune interval for unsafe pruneIntervalMs %s",
|
||||
(pruneIntervalMs) => {
|
||||
vi.useFakeTimers();
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
const tracker = createTracker({ pruneIntervalMs });
|
||||
|
||||
try {
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setIntervalSpy.mock.calls[0]?.[1]).toBe(10 * 60 * 1000);
|
||||
} finally {
|
||||
tracker.stop();
|
||||
setIntervalSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* Prevents unbounded memory growth under high load or abuse.
|
||||
*/
|
||||
|
||||
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
resolveIntegerOption,
|
||||
resolvePositiveTimerTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
interface SeenTrackerOptions {
|
||||
/** Maximum number of entries to track (default: 100,000) */
|
||||
@@ -45,8 +48,8 @@ interface Entry {
|
||||
*/
|
||||
export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
|
||||
const maxEntries = resolveIntegerOption(options?.maxEntries, 100_000, { min: 1 });
|
||||
const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour
|
||||
const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes
|
||||
const ttlMs = resolvePositiveTimerTimeoutMs(options?.ttlMs, 60 * 60 * 1000);
|
||||
const pruneIntervalMs = resolvePositiveTimerTimeoutMs(options?.pruneIntervalMs, 10 * 60 * 1000);
|
||||
|
||||
// Main storage
|
||||
const entries = new Map<string, Entry>();
|
||||
|
||||
@@ -98,13 +98,13 @@ describe("buildAssistantMessage", () => {
|
||||
expect(msg.stopReason).toBe("length");
|
||||
});
|
||||
|
||||
it("keeps tool use authoritative over a length stop", () => {
|
||||
it("keeps a length stop authoritative over complete-looking tool calls", () => {
|
||||
const response = makeOllamaResponse({
|
||||
done_reason: "length",
|
||||
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.stopReason).toBe("toolUse");
|
||||
expect(msg.stopReason).toBe("length");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,6 +282,32 @@ describe("createOllamaStreamFn thinking events", () => {
|
||||
expect(done.message?.stopReason).toBe("length");
|
||||
});
|
||||
|
||||
it("preserves a native length stop when the partial response contains tool calls", async () => {
|
||||
const events = await streamOllamaEvents(
|
||||
[
|
||||
makeOllamaResponse({
|
||||
done_reason: "length",
|
||||
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
|
||||
}),
|
||||
],
|
||||
{},
|
||||
{
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
|
||||
} as never,
|
||||
);
|
||||
|
||||
const done = events.find((event) => event.type === "done") as {
|
||||
reason?: string;
|
||||
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
|
||||
};
|
||||
expect(done.reason).toBe("length");
|
||||
expect(done.message?.stopReason).toBe("length");
|
||||
expect(done.message?.content).toEqual([
|
||||
expect.objectContaining({ type: "toolCall", name: "read" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses generic stream timeout for Ollama request timeout", async () => {
|
||||
await streamOllamaEvents([makeOllamaResponse({ content: "ok" })], { timeoutMs: 2500 });
|
||||
|
||||
|
||||
@@ -656,10 +656,15 @@ function estimateTokensFromChars(chars: number): number {
|
||||
}
|
||||
|
||||
function resolveOllamaStopReason(response: OllamaChatResponse) {
|
||||
// Ollama's length terminal means generation hit its token limit, even when
|
||||
// the partial response already contains a complete-looking tool call.
|
||||
if (response.done_reason === "length") {
|
||||
return "length" as const;
|
||||
}
|
||||
if (response.message.tool_calls?.length) {
|
||||
return "toolUse" as const;
|
||||
}
|
||||
return response.done_reason === "length" ? ("length" as const) : ("stop" as const);
|
||||
return "stop" as const;
|
||||
}
|
||||
|
||||
function estimateOllamaPromptTokens(params: {
|
||||
|
||||
@@ -713,4 +713,100 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
|
||||
controller.end();
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
|
||||
// Regression for https://github.com/openclaw/openclaw/issues/96518:
|
||||
// the idle timer must re-arm on block-boundary events (text_end,
|
||||
// thinking_end, toolcall_start, toolcall_end), not only on token
|
||||
// deltas. A stream that keeps producing boundary events between
|
||||
// deltas is demonstrably alive and must not be aborted.
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const idleTimeoutMs = 5_000;
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
|
||||
|
||||
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
controller.emit({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: 0,
|
||||
delta: "{",
|
||||
partial,
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The model finalizes the tool call and deliberates on the next one,
|
||||
// emitting real block-boundary events that prove the SSE socket is alive.
|
||||
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
controller.emit({
|
||||
type: "toolcall_end",
|
||||
contentIndex: 0,
|
||||
toolCall: { name: "f", arguments: "{}" },
|
||||
partial,
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
controller.emit({
|
||||
type: "toolcall_start",
|
||||
contentIndex: 1,
|
||||
partial,
|
||||
} as any);
|
||||
|
||||
// Advance to 5s after the last delta, but only 2s after the last
|
||||
// boundary event. The idle timer should have been re-armed by the
|
||||
// boundary events, so it must NOT fire yet.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
// The provider's completed answer arrives right after.
|
||||
controller.emit({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
...partial,
|
||||
content: [{ type: "text", text: "final answer" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
} as any);
|
||||
controller.end();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await consumer;
|
||||
|
||||
const hasDone = received.some((e) => e.type === "done");
|
||||
const hasStalledError = received.some(
|
||||
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
|
||||
);
|
||||
|
||||
expect(abortCalled).toBe(false);
|
||||
expect(hasDone).toBe(true);
|
||||
expect(hasStalledError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,11 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
|
||||
return (
|
||||
event.type === "text_delta" ||
|
||||
event.type === "thinking_delta" ||
|
||||
event.type === "toolcall_delta"
|
||||
event.type === "toolcall_delta" ||
|
||||
event.type === "text_end" ||
|
||||
event.type === "thinking_end" ||
|
||||
event.type === "toolcall_start" ||
|
||||
event.type === "toolcall_end"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,4 +68,30 @@ describe("qa live timeout policy", () => {
|
||||
),
|
||||
).toBe(240_000);
|
||||
});
|
||||
|
||||
it("uses the anthropic floor for claude-cli sonnet turns", () => {
|
||||
expect(
|
||||
resolveQaLiveTurnTimeoutMs(
|
||||
{
|
||||
providerMode: "live-frontier",
|
||||
primaryModel: "claude-cli/claude-sonnet-4-6",
|
||||
alternateModel: "claude-cli/claude-opus-4-8",
|
||||
},
|
||||
30_000,
|
||||
),
|
||||
).toBe(180_000);
|
||||
});
|
||||
|
||||
it("uses the opus floor for claude-cli opus turns", () => {
|
||||
expect(
|
||||
resolveQaLiveTurnTimeoutMs(
|
||||
{
|
||||
providerMode: "live-frontier",
|
||||
primaryModel: "claude-cli/claude-opus-4-8",
|
||||
alternateModel: "claude-cli/claude-opus-4-8",
|
||||
},
|
||||
30_000,
|
||||
),
|
||||
).toBe(240_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,13 @@ function isAnthropicModel(modelRef: string) {
|
||||
return modelRef.startsWith("anthropic/");
|
||||
}
|
||||
|
||||
// claude-cli is an Anthropic-backed Claude runtime, so it shares the Anthropic
|
||||
// turn-timeout floors; mirror the claude-cli==anthropic precedent in the aimock
|
||||
// and mock-openai servers.
|
||||
function isAnthropicFamilyModel(modelRef: string) {
|
||||
return isAnthropicModel(modelRef) || modelRef.startsWith("claude-cli/");
|
||||
}
|
||||
|
||||
function isQaFastModeModelRef(modelRef: string) {
|
||||
return isOpenAiModel(modelRef);
|
||||
}
|
||||
@@ -18,7 +25,7 @@ function isGptFiveModel(modelRef: string) {
|
||||
}
|
||||
|
||||
function isClaudeOpusModel(modelRef: string) {
|
||||
return isAnthropicModel(modelRef) && modelRef.includes("claude-opus");
|
||||
return isAnthropicFamilyModel(modelRef) && modelRef.includes("claude-opus");
|
||||
}
|
||||
|
||||
export const liveFrontierProviderDefinition: QaProviderDefinition = {
|
||||
@@ -39,7 +46,7 @@ export const liveFrontierProviderDefinition: QaProviderDefinition = {
|
||||
if (isClaudeOpusModel(modelRef)) {
|
||||
return Math.max(fallbackMs, 240_000);
|
||||
}
|
||||
if (isAnthropicModel(modelRef)) {
|
||||
if (isAnthropicFamilyModel(modelRef)) {
|
||||
return Math.max(fallbackMs, 180_000);
|
||||
}
|
||||
if (isGptFiveModel(modelRef)) {
|
||||
|
||||
@@ -97,11 +97,45 @@ describe("engine/tools/remind-logic", () => {
|
||||
expect(generateJobName("drink water")).toBe("Reminder: drink water");
|
||||
});
|
||||
|
||||
it("truncates long content", () => {
|
||||
const long = "a very long reminder content that exceeds twenty characters";
|
||||
const name = generateJobName(long);
|
||||
expect(name.length).toBeLessThan(40);
|
||||
expect(name).toContain("…");
|
||||
it("truncates long content to a 20 UTF-16-unit budget with an ellipsis", () => {
|
||||
expect(generateJobName("a very long reminder content")).toBe(
|
||||
"Reminder: a very long reminder…",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an exactly-fitting all-emoji content unchanged", () => {
|
||||
// 10 emoji = 20 UTF-16 units, exactly at the budget, so no truncation.
|
||||
expect(generateJobName("😀".repeat(10))).toBe(`Reminder: ${"😀".repeat(10)}`);
|
||||
});
|
||||
|
||||
it("does not split surrogate pairs when truncating", () => {
|
||||
const hasLoneSurrogate = (value: string): boolean => {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
||||
return true;
|
||||
}
|
||||
index++;
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 11 emoji = 22 UTF-16 units > 20; the 11th emoji straddles the cap and is
|
||||
// dropped whole rather than split into a lone surrogate.
|
||||
const allEmoji = generateJobName("😀".repeat(11));
|
||||
expect(allEmoji).toBe(`Reminder: ${"😀".repeat(10)}…`);
|
||||
expect(hasLoneSurrogate(allEmoji)).toBe(false);
|
||||
|
||||
// 19 ASCII + emoji: the emoji's high surrogate would land at unit 20, so the
|
||||
// whole pair is dropped to stay within the 20-unit budget.
|
||||
const name = generateJobName(`${"x".repeat(19)}😀tail`);
|
||||
expect(name).toBe(`Reminder: ${"x".repeat(19)}…`);
|
||||
expect(hasLoneSurrogate(name)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Qqbot plugin module implements remind logic behavior.
|
||||
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
/**
|
||||
* QQBot reminder tool core logic.
|
||||
@@ -171,7 +172,7 @@ export function isCronExpression(timeStr: string): boolean {
|
||||
*/
|
||||
export function generateJobName(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}…` : trimmed;
|
||||
const short = trimmed.length > 20 ? `${truncateUtf16Safe(trimmed, 20)}…` : trimmed;
|
||||
return `Reminder: ${short}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Qqbot tests cover stt plugin behavior.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ssrfRuntimeMocks = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
@@ -41,6 +41,36 @@ function cancelTrackedResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function largeTranscriptionJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
} {
|
||||
let chunkIndex = 0;
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [
|
||||
'{"text":"',
|
||||
...Array.from({ length: params.chunkCount }, () => "a".repeat(params.chunkSize)),
|
||||
'"}',
|
||||
];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (chunkIndex >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(chunks[chunkIndex]));
|
||||
chunkIndex += 1;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
getReadCount: () => chunkIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function requireFirstSsrfRequest(): {
|
||||
url?: unknown;
|
||||
auditContext?: unknown;
|
||||
@@ -177,6 +207,44 @@ describe("engine/utils/stt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds successful STT JSON responses before parsing", async () => {
|
||||
await withTempDir("openclaw-qqbot-stt-success-limit-", async (tmpDir) => {
|
||||
const audioPath = path.join(tmpDir, "voice.wav");
|
||||
fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4]));
|
||||
|
||||
const release = vi.fn(async () => {});
|
||||
const streamed = largeTranscriptionJsonResponse({
|
||||
chunkCount: 18,
|
||||
chunkSize: 1024 * 1024,
|
||||
});
|
||||
ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: streamed.response,
|
||||
release,
|
||||
});
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await transcribeAudio(audioPath, {
|
||||
channels: {
|
||||
qqbot: {
|
||||
stt: {
|
||||
baseUrl: "https://api.example.test/v1/",
|
||||
apiKey: "secret",
|
||||
model: "whisper-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(String(error)).toContain("qqbot.stt: JSON response exceeds 16777216 bytes");
|
||||
expect(streamed.getReadCount()).toBeLessThan(20);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds STT error bodies without using response.text()", async () => {
|
||||
await withTempDir("openclaw-qqbot-stt-error-", async (tmpDir) => {
|
||||
const audioPath = path.join(tmpDir, "voice.wav");
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
@@ -100,7 +103,7 @@ export async function transcribeAudio(
|
||||
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const result = (await resp.json()) as { text?: string };
|
||||
const result = await readProviderJsonResponse<{ text?: string }>(resp, "qqbot.stt");
|
||||
return normalizeOptionalString(result.text) ?? null;
|
||||
} finally {
|
||||
await release();
|
||||
|
||||
@@ -33,11 +33,190 @@ function readChatUpdatePayload(
|
||||
return payload as ChatUpdatePayload;
|
||||
}
|
||||
|
||||
const UNPAIRED_SURROGATE_RE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
|
||||
function readMrkdwnTexts(blocks: unknown): string[] {
|
||||
if (!Array.isArray(blocks)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const block of blocks) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (
|
||||
text &&
|
||||
typeof text === "object" &&
|
||||
(text as { type?: unknown }).type === "mrkdwn" &&
|
||||
typeof (text as { text?: unknown }).text === "string"
|
||||
) {
|
||||
texts.push((text as { text: string }).text);
|
||||
}
|
||||
|
||||
const elements = (block as { elements?: unknown }).elements;
|
||||
if (!Array.isArray(elements)) {
|
||||
continue;
|
||||
}
|
||||
for (const element of elements) {
|
||||
if (
|
||||
element &&
|
||||
typeof element === "object" &&
|
||||
(element as { type?: unknown }).type === "mrkdwn" &&
|
||||
typeof (element as { text?: unknown }).text === "string"
|
||||
) {
|
||||
texts.push((element as { text: string }).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return texts;
|
||||
}
|
||||
|
||||
function findApprovalMrkdwn(payload: SlackPayload, prefix: string): string {
|
||||
const text = readMrkdwnTexts(payload.blocks).find((entry) => entry.startsWith(prefix));
|
||||
if (!text) {
|
||||
throw new Error(`Expected Slack mrkdwn block starting with ${prefix}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
describe("slackApprovalNativeRuntime", () => {
|
||||
it("subscribes to plugin approval events", () => {
|
||||
expect(slackApprovalNativeRuntime.eventKinds).toEqual(["exec", "plugin"]);
|
||||
});
|
||||
|
||||
it("does not leave dangling surrogates when truncating exec approval command mrkdwn", async () => {
|
||||
const commandText = `${"a".repeat(2598)}😀tail`;
|
||||
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
app: {} as never,
|
||||
config: {} as never,
|
||||
},
|
||||
request: {
|
||||
id: "req-surrogate",
|
||||
request: {
|
||||
command: commandText,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "exec",
|
||||
approvalId: "req-surrogate",
|
||||
commandText,
|
||||
metadata: [],
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve req-surrogate allow-once",
|
||||
style: "success",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
})) as SlackPayload;
|
||||
|
||||
const commandMrkdwn = findApprovalMrkdwn(payload, "*Command*");
|
||||
expect(commandMrkdwn).toMatch(/…\n```$/);
|
||||
expect(UNPAIRED_SURROGATE_RE.test(commandMrkdwn)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not leave dangling surrogates when truncating plugin approval request mrkdwn", async () => {
|
||||
const title = `${"a".repeat(2598)}😀tail`;
|
||||
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
app: {} as never,
|
||||
config: {} as never,
|
||||
},
|
||||
request: {
|
||||
id: "plugin:req-surrogate",
|
||||
request: {
|
||||
title,
|
||||
description: "Needs approval.",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "plugin",
|
||||
phase: "pending",
|
||||
approvalId: "plugin:req-surrogate",
|
||||
title,
|
||||
description: "Needs approval.",
|
||||
severity: "warning",
|
||||
pluginId: "test-plugin",
|
||||
toolName: "test-tool",
|
||||
metadata: [],
|
||||
actions: [
|
||||
{
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve plugin:req-surrogate deny",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
expiresAtMs: 60_000,
|
||||
} as never,
|
||||
})) as SlackPayload;
|
||||
|
||||
const requestMrkdwn = findApprovalMrkdwn(payload, "*Request*");
|
||||
expect(requestMrkdwn).toMatch(/…$/);
|
||||
expect(UNPAIRED_SURROGATE_RE.test(requestMrkdwn)).toBe(false);
|
||||
});
|
||||
|
||||
it("still truncates plain BMP approval mrkdwn at the Slack approval preview limit", async () => {
|
||||
const commandText = "b".repeat(2700);
|
||||
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
app: {} as never,
|
||||
config: {} as never,
|
||||
},
|
||||
request: {
|
||||
id: "req-bmp",
|
||||
request: {
|
||||
command: commandText,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "exec",
|
||||
approvalId: "req-bmp",
|
||||
commandText,
|
||||
metadata: [],
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve req-bmp allow-once",
|
||||
style: "success",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
})) as SlackPayload;
|
||||
|
||||
const commandMrkdwn = findApprovalMrkdwn(payload, "*Command*");
|
||||
expect(commandMrkdwn).toMatch(/…\n```$/);
|
||||
expect(commandMrkdwn).toContain(`${"b".repeat(2599)}…`);
|
||||
expect(UNPAIRED_SURROGATE_RE.test(commandMrkdwn)).toBe(false);
|
||||
});
|
||||
|
||||
it("renders only the allowed pending actions", async () => {
|
||||
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logError } from "openclaw/plugin-sdk/logging-core";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
isSlackAnyNativeApprovalClientEnabled,
|
||||
resolveSlackApprovalKind,
|
||||
@@ -73,7 +74,14 @@ function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext):
|
||||
}
|
||||
|
||||
function truncateSlackMrkdwn(text: string, maxChars: number): string {
|
||||
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
|
||||
const limit = Math.max(0, Math.floor(maxChars));
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
if (limit <= 1) {
|
||||
return truncateUtf16Safe(text, limit);
|
||||
}
|
||||
return `${truncateUtf16Safe(text, limit - 1)}…`;
|
||||
}
|
||||
|
||||
function buildSlackCodeBlock(text: string): string {
|
||||
|
||||
@@ -433,6 +433,31 @@ describe("synology-chat security helpers", () => {
|
||||
expect(result).toContain("[truncated]");
|
||||
});
|
||||
|
||||
it("truncates long inputs without splitting a surrogate pair", () => {
|
||||
const loneSurrogatePattern =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
|
||||
const input = "a".repeat(3999) + "\u{1F600}" + "b".repeat(2000);
|
||||
|
||||
const result = sanitizeInput(input);
|
||||
|
||||
expect(result).toContain("[truncated]");
|
||||
expect(result).not.toMatch(loneSurrogatePattern);
|
||||
expect(result).toBe(`${"a".repeat(3999)}... [truncated]`);
|
||||
});
|
||||
|
||||
it("keeps complete supplementary-plane characters that fit before truncation", () => {
|
||||
const loneSurrogatePattern =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
|
||||
const emoji = "\u{1F600}";
|
||||
const input = "a".repeat(3998) + emoji + "b".repeat(2000);
|
||||
|
||||
const result = sanitizeInput(input);
|
||||
|
||||
expect(result).toContain("[truncated]");
|
||||
expect(result.startsWith(`${"a".repeat(3998)}${emoji}`)).toBe(true);
|
||||
expect(result).not.toMatch(loneSurrogatePattern);
|
||||
});
|
||||
|
||||
it("rate limits per user and caps tracked state", () => {
|
||||
const limiter = new RateLimiter(3, 60);
|
||||
expect(limiter.check("user1")).toBe(true);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user