mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 18:01:53 +08:00
Compare commits
18 Commits
fix/slack-
...
codex/i18n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c0b82027 | ||
|
|
d3531495a4 | ||
|
|
15cae07699 | ||
|
|
afbbd2ab16 | ||
|
|
9580fad305 | ||
|
|
9df3467360 | ||
|
|
ac70e9ddda | ||
|
|
bfca9b2447 | ||
|
|
3d06c4bc24 | ||
|
|
8f9aca8aaa | ||
|
|
6f0d8c2097 | ||
|
|
c5884957ff | ||
|
|
22d0780a89 | ||
|
|
126fc2f0b4 | ||
|
|
67cf97ef55 | ||
|
|
8cbd6c78c8 | ||
|
|
1545198f8b | ||
|
|
20f5648a2e |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -848,6 +848,32 @@ 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
|
||||
|
||||
17117
apps/.i18n/native-source.json
Normal file
17117
apps/.i18n/native-source.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ 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
|
||||
@@ -51,6 +52,7 @@ 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
|
||||
@@ -100,7 +102,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
title = { Text(stringResource(R.string.trust_this_gateway), style = mobileHeadline, color = mobileText) },
|
||||
text = {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
@@ -119,7 +121,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
Text("Trust and continue")
|
||||
Text(stringResource(R.string.trust_and_continue))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@@ -127,7 +129,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -158,9 +160,10 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
|
||||
Text(stringResource(R.string.gateway_connection), style = mobileTitle1, color = mobileText)
|
||||
Text(
|
||||
if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.",
|
||||
if (isConnected) stringResource(R.string.connected_gateway_ready)
|
||||
else stringResource(R.string.connect_gateway_get_started),
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
@@ -191,7 +194,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(stringResource(R.string.endpoint), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
}
|
||||
}
|
||||
@@ -213,7 +216,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(stringResource(R.string.status), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +241,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
Text(stringResource(R.string.disconnect), style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
@@ -307,7 +310,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
Text(stringResource(R.string.connect_gateway), style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +357,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
Text(stringResource(R.string.copy_report_for_claw), style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +376,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Advanced controls", style = mobileHeadline, color = mobileText)
|
||||
Text(stringResource(R.string.advanced_controls), style = mobileHeadline, color = mobileText)
|
||||
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
|
||||
}
|
||||
Icon(
|
||||
@@ -395,15 +398,15 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(stringResource(R.string.connection_method), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MethodChip(
|
||||
label = "Setup Code",
|
||||
label = stringResource(R.string.setup_code),
|
||||
active = inputMode == ConnectInputMode.SetupCode,
|
||||
onClick = { inputMode = ConnectInputMode.SetupCode },
|
||||
)
|
||||
MethodChip(
|
||||
label = "Manual",
|
||||
label = stringResource(R.string.manual),
|
||||
active = inputMode == ConnectInputMode.Manual,
|
||||
onClick = { inputMode = ConnectInputMode.Manual },
|
||||
)
|
||||
@@ -419,14 +422,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(stringResource(R.string.setup_code), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = setupCode,
|
||||
onValueChange = {
|
||||
setupCode = it
|
||||
validationText = null
|
||||
},
|
||||
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
|
||||
placeholder = { Text(stringResource(R.string.paste_setup_code), style = mobileBody, color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
@@ -460,7 +463,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(stringResource(R.string.host), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualHostInput,
|
||||
onValueChange = {
|
||||
@@ -502,7 +505,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = mobileHeadline, color = mobileText)
|
||||
Text(stringResource(R.string.use_tls), style = mobileHeadline, color = mobileText)
|
||||
Text(
|
||||
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
|
||||
style = mobileCallout,
|
||||
@@ -525,7 +528,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayToken,
|
||||
onValueChange = { viewModel.setGatewayToken(it) },
|
||||
@@ -546,7 +549,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
OutlinedTextField(
|
||||
value = passwordInput,
|
||||
onValueChange = { passwordInput = it },
|
||||
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
|
||||
placeholder = { Text(stringResource(R.string.password), style = mobileBody, color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
@@ -563,7 +566,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
|
||||
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
|
||||
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
Text(stringResource(R.string.run_onboarding_again), style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ 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
|
||||
@@ -212,7 +213,13 @@ fun OnboardingFlow(
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::declineGatewayTrustPrompt,
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.trust_this_gateway),
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Verify the certificate fingerprint before continuing.\n\n${prompt.fingerprintSha256}",
|
||||
@@ -222,12 +229,12 @@ fun OnboardingFlow(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::acceptGatewayTrustPrompt) {
|
||||
Text("Trust")
|
||||
Text(stringResource(R.string.trust_and_continue))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::declineGatewayTrustPrompt) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -534,20 +541,24 @@ private fun GatewaySetupScreen(
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
item {
|
||||
OnboardingHeader(title = "Gateway Setup", subtitle = "Connect to your Gateway", onBack = onBack)
|
||||
OnboardingHeader(
|
||||
title = stringResource(R.string.gateway_setup),
|
||||
subtitle = stringResource(R.string.connect_to_gateway),
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.QrCode2,
|
||||
title = "Scan setup code",
|
||||
subtitle = "Use your Gateway QR or setup code",
|
||||
title = stringResource(R.string.scan_setup_code),
|
||||
subtitle = stringResource(R.string.use_gateway_qr),
|
||||
onClick = onScan,
|
||||
)
|
||||
}
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.WifiTethering,
|
||||
title = "Nearby gateway",
|
||||
title = stringResource(R.string.nearby_gateway),
|
||||
subtitle = nearbyGateway.subtitle,
|
||||
status = nearbyGateway.status,
|
||||
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
|
||||
@@ -556,8 +567,8 @@ private fun GatewaySetupScreen(
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.Link,
|
||||
title = "Enter gateway URL",
|
||||
subtitle = "Connect using a manual URL",
|
||||
title = stringResource(R.string.enter_gateway_url),
|
||||
subtitle = stringResource(R.string.connect_manual_url),
|
||||
onClick = { advancedOpen = true },
|
||||
)
|
||||
}
|
||||
@@ -638,7 +649,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 = "Gateway Recovery", onBack = onBack)
|
||||
OnboardingHeader(title = stringResource(R.string.gateway_setup), onBack = onBack)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(
|
||||
@@ -923,7 +934,9 @@ private fun PermissionTopBar(onBack: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHelp = false },
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = { Text("Permissions", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
title = {
|
||||
Text(stringResource(R.string.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.",
|
||||
@@ -933,7 +946,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showHelp = false }) {
|
||||
Text("Done")
|
||||
Text(stringResource(R.string.done))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
34
apps/android/app/src/main/res/values-ar/strings.xml
Normal file
34
apps/android/app/src/main/res/values-ar/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-de/strings.xml
Normal file
34
apps/android/app/src/main/res/values-de/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-es/strings.xml
Normal file
34
apps/android/app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-fa/strings.xml
Normal file
34
apps/android/app/src/main/res/values-fa/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-fr/strings.xml
Normal file
34
apps/android/app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-hi/strings.xml
Normal file
34
apps/android/app/src/main/res/values-hi/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-id/strings.xml
Normal file
34
apps/android/app/src/main/res/values-id/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-it/strings.xml
Normal file
34
apps/android/app/src/main/res/values-it/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-ja/strings.xml
Normal file
34
apps/android/app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-ko/strings.xml
Normal file
34
apps/android/app/src/main/res/values-ko/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-nl/strings.xml
Normal file
34
apps/android/app/src/main/res/values-nl/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-pl/strings.xml
Normal file
34
apps/android/app/src/main/res/values-pl/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-pt-rBR/strings.xml
Normal file
34
apps/android/app/src/main/res/values-pt-rBR/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-ru/strings.xml
Normal file
34
apps/android/app/src/main/res/values-ru/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-th/strings.xml
Normal file
34
apps/android/app/src/main/res/values-th/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-tr/strings.xml
Normal file
34
apps/android/app/src/main/res/values-tr/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-uk/strings.xml
Normal file
34
apps/android/app/src/main/res/values-uk/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-vi/strings.xml
Normal file
34
apps/android/app/src/main/res/values-vi/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
34
apps/android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
34
apps/android/app/src/main/res/values-zh-rTW/strings.xml
Normal file
34
apps/android/app/src/main/res/values-zh-rTW/strings.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<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,3 +1,34 @@
|
||||
<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>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
d6953afecef50712face2a38f54744af6121bab670695f2ad85ae0048b1105a3 plugin-sdk-api-baseline.json
|
||||
67485084391dada9372630d8cefcd0562461b2af906f5312763a7f9d77a4a29d plugin-sdk-api-baseline.jsonl
|
||||
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
|
||||
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -588,7 +588,7 @@ releases.
|
||||
| `plugin-sdk/reply-history` | Reply-history helpers | `createChannelHistoryWindow`; deprecated map-helper compatibility exports such as `buildPendingHistoryContextFromMap`, `recordPendingHistoryEntry`, and `clearHistoryEntriesIfEnabled` |
|
||||
| `plugin-sdk/reply-reference` | Reply reference planning | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Reply chunk helpers | Text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session store helpers | Store path, updated-at, and reset-freshness helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session store helpers | Store path + updated-at helpers |
|
||||
| `plugin-sdk/state-paths` | State path helpers | State and OAuth dir helpers |
|
||||
| `plugin-sdk/routing` | Routing/session-key helpers | `resolveAgentRoute`, `buildAgentSessionKey`, `resolveDefaultAgentBoundAccountId`, session-key normalization helpers |
|
||||
| `plugin-sdk/status-helpers` | Channel status helpers | Channel/account status summary builders, runtime-state defaults, issue metadata helpers |
|
||||
|
||||
@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), reset freshness resolution for one session entry, bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
@@ -307,7 +307,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/inline-image-data-url-runtime` | Inline image data URL sanitizer and signature sniffing helpers without the broad media runtime surface |
|
||||
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
|
||||
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
|
||||
| `plugin-sdk/session-store-runtime` | Session-store and reset-freshness helpers without broad config writes/maintenance imports |
|
||||
| `plugin-sdk/session-store-runtime` | Session-store helpers without broad config writes/maintenance imports |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers without database lifecycle controls |
|
||||
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
|
||||
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
|
||||
|
||||
@@ -211,18 +211,6 @@ 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
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
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";
|
||||
@@ -17,7 +13,11 @@ 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> {
|
||||
return readProviderJsonResponse<T>(response, label);
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||
@@ -57,7 +57,7 @@ async function withGoogleChatResponse<T>(params: {
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await readResponseTextLimited(response).catch(() => "");
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
return await handleResponse(response);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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";
|
||||
@@ -18,10 +17,11 @@ 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>> {
|
||||
return readProviderJsonResponse<Record<string, string>>(
|
||||
response,
|
||||
"Google Chat cert fetch failed",
|
||||
);
|
||||
try {
|
||||
return (await response.json()) as Record<string, string>;
|
||||
} catch (cause) {
|
||||
throw new Error("Google Chat cert fetch failed: malformed JSON response", { cause });
|
||||
}
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
|
||||
|
||||
@@ -568,137 +568,4 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -713,100 +713,4 @@ 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,11 +55,7 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
|
||||
return (
|
||||
event.type === "text_delta" ||
|
||||
event.type === "thinking_delta" ||
|
||||
event.type === "toolcall_delta" ||
|
||||
event.type === "text_end" ||
|
||||
event.type === "thinking_end" ||
|
||||
event.type === "toolcall_start" ||
|
||||
event.type === "toolcall_end"
|
||||
event.type === "toolcall_delta"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export {
|
||||
readSessionUpdatedAt,
|
||||
resolveChannelResetConfig,
|
||||
resolveSessionEntryFreshness,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
updateLastRoute,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import { resolveSlackAllowListMatch } from "../allow-list.js";
|
||||
import { resolveChannelResetConfig, resolveSessionEntryFreshness } from "../config.runtime.js";
|
||||
import { readSessionUpdatedAt } from "../config.runtime.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackMediaResult } from "../media-types.js";
|
||||
import { resolveSlackThreadHistory, type SlackThreadStarter } from "../thread.js";
|
||||
@@ -35,7 +35,7 @@ function loadSlackMediaModule(): Promise<SlackMediaModule> {
|
||||
type SlackThreadContextData = {
|
||||
threadStarterBody: string | undefined;
|
||||
threadHistoryBody: string | undefined;
|
||||
shouldSeedInitialThreadContext: boolean;
|
||||
threadSessionPreviousTimestamp: number | undefined;
|
||||
threadLabel: string | undefined;
|
||||
threadStarterMedia: SlackMediaResult[] | null;
|
||||
};
|
||||
@@ -125,32 +125,19 @@ export async function resolveSlackThreadContextData(params: {
|
||||
let threadHistoryBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let threadStarterMedia: SlackMediaResult[] | null = null;
|
||||
const threadSessionFreshness =
|
||||
const threadSessionPreviousTimestamp =
|
||||
params.isThreadReply && params.threadTs
|
||||
? resolveSessionEntryFreshness({
|
||||
? readSessionUpdatedAt({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionCfg: params.ctx.cfg.session,
|
||||
resetType: "thread",
|
||||
resetOverride: resolveChannelResetConfig({
|
||||
sessionCfg: params.ctx.cfg.session,
|
||||
channel: "slack",
|
||||
}),
|
||||
})
|
||||
: undefined;
|
||||
const shouldSeedInitialThreadContext = Boolean(
|
||||
params.isThreadReply &&
|
||||
params.threadTs &&
|
||||
(!threadSessionFreshness || threadSessionFreshness.state !== "fresh"),
|
||||
);
|
||||
const shouldLoadInitialThreadHistory =
|
||||
shouldSeedInitialThreadContext || params.forceInitialHistory === true;
|
||||
|
||||
if (!params.isThreadReply || !params.threadTs) {
|
||||
return {
|
||||
threadStarterBody,
|
||||
threadHistoryBody,
|
||||
shouldSeedInitialThreadContext,
|
||||
threadSessionPreviousTimestamp,
|
||||
threadLabel,
|
||||
threadStarterMedia,
|
||||
};
|
||||
@@ -208,9 +195,10 @@ export async function resolveSlackThreadContextData(params: {
|
||||
threadLabel = `Slack thread ${params.roomLabel}`;
|
||||
}
|
||||
|
||||
const isNewThreadSession = !threadSessionPreviousTimestamp;
|
||||
const includeBotStarterAsRootContext = shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot,
|
||||
isNewThreadSession: shouldSeedInitialThreadContext,
|
||||
isNewThreadSession,
|
||||
hasStarterText: Boolean(starter?.text),
|
||||
});
|
||||
|
||||
@@ -230,7 +218,10 @@ export async function resolveSlackThreadContextData(params: {
|
||||
|
||||
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
|
||||
|
||||
if (threadInitialHistoryLimit > 0 && shouldLoadInitialThreadHistory) {
|
||||
if (
|
||||
threadInitialHistoryLimit > 0 &&
|
||||
(!threadSessionPreviousTimestamp || params.forceInitialHistory)
|
||||
) {
|
||||
const currentBotRootTs = starter?.ts ?? params.threadTs;
|
||||
const threadHistory = await resolveSlackThreadHistory({
|
||||
channelId: params.message.channel,
|
||||
@@ -342,7 +333,7 @@ export async function resolveSlackThreadContextData(params: {
|
||||
return {
|
||||
threadStarterBody,
|
||||
threadHistoryBody,
|
||||
shouldSeedInitialThreadContext,
|
||||
threadSessionPreviousTimestamp,
|
||||
threadLabel,
|
||||
threadStarterMedia,
|
||||
};
|
||||
|
||||
@@ -1870,15 +1870,12 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "200.000",
|
||||
});
|
||||
const now = Date.now();
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "existing-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now,
|
||||
lastInteractionAt: now,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
@@ -1908,208 +1905,6 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads bounded thread history for existing thread sessions stale under reset policy", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "300.000",
|
||||
});
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "stale-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const replies = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "300.000" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{ text: "starter", user: "U2", ts: "300.000" },
|
||||
{ text: "assistant prior output", bot_id: "B1", ts: "300.500" },
|
||||
{ text: "prior human context", user: "U1", ts: "300.800" },
|
||||
{ text: "current post-reset message", user: "U1", ts: "301.000" },
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.threadInheritParent = true;
|
||||
slackCtx.resolveUserName = async (id: string) => ({
|
||||
name: id === "U1" ? "Alice" : "Bob",
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createSlackAccount({
|
||||
replyToMode: "all",
|
||||
thread: { initialHistoryLimit: 10, inheritParent: true },
|
||||
}),
|
||||
createThreadReplyMessage({
|
||||
text: "current post-reset message",
|
||||
ts: "301.000",
|
||||
thread_ts: "300.000",
|
||||
}),
|
||||
);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.ctxPayload.SessionKey).toBe(threadKeys.sessionKey);
|
||||
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
|
||||
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).toContain("prior human context");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant prior output");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current post-reset message");
|
||||
expect(prepared.ctxPayload.ParentSessionKey).toBe(route.sessionKey);
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
expect(replies).toHaveBeenLastCalledWith({
|
||||
channel: "C123",
|
||||
ts: "300.000",
|
||||
limit: 200,
|
||||
inclusive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-owned thread sessions existing when reset policy is implicit", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "350.000",
|
||||
});
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "provider-owned-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
providerOverride: "claude-cli",
|
||||
cliSessionBindings: {
|
||||
"claude-cli": { sessionId: "claude-cli-thread-session" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const replies = vi.fn().mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "350.000" }],
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createSlackAccount({
|
||||
replyToMode: "all",
|
||||
thread: { initialHistoryLimit: 10 },
|
||||
}),
|
||||
createThreadReplyMessage({
|
||||
text: "reply after implicit reset boundary",
|
||||
ts: "351.000",
|
||||
thread_ts: "350.000",
|
||||
}),
|
||||
);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
|
||||
expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps initialHistoryLimit zero as a hard disable for stale thread sessions", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "400.000",
|
||||
});
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "stale-zero-history-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const replies = vi.fn().mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "400.000" }],
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createSlackAccount({
|
||||
replyToMode: "all",
|
||||
thread: { initialHistoryLimit: 0 },
|
||||
}),
|
||||
createThreadReplyMessage({
|
||||
text: "current post-reset message",
|
||||
ts: "401.000",
|
||||
thread_ts: "400.000",
|
||||
}),
|
||||
);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
|
||||
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops ambiguous thread replies instead of treating them as root messages", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const cfg = {
|
||||
|
||||
@@ -1225,7 +1225,7 @@ export async function prepareSlackMessage(params: {
|
||||
const {
|
||||
threadStarterBody,
|
||||
threadHistoryBody,
|
||||
shouldSeedInitialThreadContext,
|
||||
threadSessionPreviousTimestamp,
|
||||
threadLabel,
|
||||
threadStarterMedia,
|
||||
} = await resolveSlackThreadContextData({
|
||||
@@ -1320,7 +1320,7 @@ export async function prepareSlackMessage(params: {
|
||||
thread: {
|
||||
// Only include thread starter body for NEW sessions (existing sessions already have it in their transcript)
|
||||
starterBody:
|
||||
!directThreadRoutedToDmSession && shouldSeedInitialThreadContext
|
||||
!directThreadRoutedToDmSession && !threadSessionPreviousTimestamp
|
||||
? threadStarterBody
|
||||
: undefined,
|
||||
historyBody: supplementalThreadHistoryBody,
|
||||
@@ -1340,7 +1340,7 @@ export async function prepareSlackMessage(params: {
|
||||
isThreadReply &&
|
||||
threadTs &&
|
||||
!directThreadRoutedToDmSession &&
|
||||
shouldSeedInitialThreadContext
|
||||
!threadSessionPreviousTimestamp
|
||||
? true
|
||||
: undefined,
|
||||
...buildSlackMentionContextPayload({
|
||||
|
||||
@@ -957,7 +957,7 @@ describe("resolveTelegramFetch", () => {
|
||||
expect(eighthDispatcher).toBe(firstDispatcher);
|
||||
expect(ninthDispatcher).toBe(firstDispatcher);
|
||||
expectPinnedFallbackIpDispatcher(3);
|
||||
expectLoggerMessageContaining(loggerWarn, "fetch fallback: primary connection path failed");
|
||||
expectLoggerMessageContaining(loggerWarn, "fetch fallback: DNS-resolved IP unreachable");
|
||||
expectLoggerMessageContaining(
|
||||
loggerDebug,
|
||||
"fetch fallback: recovered from attempt 2 to attempt 0",
|
||||
@@ -1193,31 +1193,6 @@ describe("resolveTelegramFetch", () => {
|
||||
expect(undiciFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not automatically retry structured EADDRNOTAVAIL fetch failures", async () => {
|
||||
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
|
||||
undiciFetch.mockRejectedValue(fetchError);
|
||||
|
||||
const resolved = resolveTelegramFetchOrThrow(undefined, STICKY_IPV4_FALLBACK_NETWORK);
|
||||
|
||||
await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow(
|
||||
"fetch failed",
|
||||
);
|
||||
|
||||
expect(undiciFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves EADDRNOTAVAIL in forced fallback diagnostics", () => {
|
||||
const transport = resolveTelegramTransport(undefined, STICKY_IPV4_FALLBACK_NETWORK);
|
||||
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
|
||||
|
||||
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
|
||||
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
|
||||
|
||||
expectLoggerMessageContaining(loggerWarn, "primary connection path failed");
|
||||
expectLoggerMessageContaining(loggerWarn, "codes=EADDRNOTAVAIL");
|
||||
expectNoLoggerMessageContaining(loggerWarn, "DNS-resolved IP unreachable");
|
||||
});
|
||||
|
||||
it("retries sticky fallback when the local network is down during connect", async () => {
|
||||
undiciFetch
|
||||
.mockRejectedValueOnce(buildFetchFallbackError("ENETDOWN"))
|
||||
|
||||
@@ -488,10 +488,9 @@ export type TelegramTransport = {
|
||||
dispatcherAttempts?: TelegramDispatcherAttempt[];
|
||||
/**
|
||||
* Promote this transport to its next fallback dispatcher before the next
|
||||
* request. The original error, when available, is retained in diagnostics.
|
||||
* Returns false when no fallback path exists.
|
||||
* request. Returns false when no fallback path exists.
|
||||
*/
|
||||
forceFallback?: (reason: string, err?: unknown) => boolean;
|
||||
forceFallback?: (reason: string) => boolean;
|
||||
/**
|
||||
* Release all dispatchers owned by this transport and the TCP sockets they
|
||||
* hold. Safe to call multiple times; subsequent calls resolve immediately.
|
||||
@@ -564,8 +563,7 @@ function createTelegramTransportAttempts(params: {
|
||||
},
|
||||
exportAttempt: { dispatcherPolicy: fallbackIpPolicy },
|
||||
logLevel: "warn",
|
||||
logMessage:
|
||||
"fetch fallback: primary connection path failed; trying alternative Telegram API IP",
|
||||
logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP",
|
||||
});
|
||||
|
||||
return attempts;
|
||||
@@ -866,8 +864,8 @@ export function resolveTelegramTransport(
|
||||
fetch: resolvedFetch,
|
||||
sourceFetch,
|
||||
dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt),
|
||||
forceFallback: (reason: string, err?: unknown) =>
|
||||
promoteStickyAttempt(stickyAttemptIndex + 1, err ?? new Error("forced fallback"), reason),
|
||||
forceFallback: (reason: string) =>
|
||||
promoteStickyAttempt(stickyAttemptIndex + 1, new Error("forced fallback"), reason),
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ describe("probeTelegram retry logic", () => {
|
||||
|
||||
const result = await probePromise;
|
||||
expect(result.ok).toBe(true);
|
||||
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error", timeoutError);
|
||||
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 failed + 1 getMe success + 1 webhook
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -162,8 +162,7 @@ export async function probeTelegram(
|
||||
// On timeout or network error, promote the transport to its IPv4
|
||||
// fallback dispatcher so the next retry (and all future probes
|
||||
// sharing this cached transport) skip the stalled IPv6 path.
|
||||
// Keep the original socket code in transport fallback diagnostics.
|
||||
transport.forceFallback?.("probe timeout/network error", err);
|
||||
transport.forceFallback?.("probe timeout/network error");
|
||||
if (i < 2) {
|
||||
const remainingAfterAttemptMs = resolveRemainingBudgetMs();
|
||||
if (remainingAfterAttemptMs <= 0) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
|
||||
import { stateMigrations } from "./doctor-contract-api.js";
|
||||
import {
|
||||
createTestStorePath,
|
||||
makePersistedCall,
|
||||
@@ -68,42 +68,6 @@ describe("voice-call doctor state migration", () => {
|
||||
await fs.rm(storePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reports top-level and per-number session-store agents", () => {
|
||||
expect(
|
||||
resolveSessionStoreAgentIds({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
agentId: "Voice",
|
||||
numbers: {
|
||||
"+15550001111": { agentId: "Cards" },
|
||||
"+15550002222": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual(["cards", "voice"]);
|
||||
expect(
|
||||
resolveSessionStoreAgentIds({
|
||||
cfg: {
|
||||
plugins: { entries: { "@openclaw/voice-call": { config: {} } } },
|
||||
},
|
||||
}),
|
||||
).toEqual(["main"]);
|
||||
expect(
|
||||
resolveSessionStoreAgentIds({
|
||||
cfg: {
|
||||
plugins: { entries: { "voice-call": { enabled: true } } },
|
||||
},
|
||||
}),
|
||||
).toEqual(["main"]);
|
||||
});
|
||||
|
||||
it("imports legacy calls.jsonl into plugin state", async () => {
|
||||
const sourcePath = path.join(storePath, "calls.jsonl");
|
||||
const call = makePersistedCall({
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import type {
|
||||
PluginDoctorStateMigration,
|
||||
PluginStateKeyedStore,
|
||||
@@ -83,36 +81,6 @@ type PluginDoctorStateMigrationParams = Parameters<
|
||||
PluginDoctorStateMigration["detectLegacyState"]
|
||||
>[0];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Return Voice Call agents whose templated core session stores need migration. */
|
||||
export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] {
|
||||
const agentIds = new Set<string>();
|
||||
for (const pluginId of ["voice-call", "@openclaw/voice-call"]) {
|
||||
const entry = params.cfg.plugins?.entries?.[pluginId];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const config = entry.config === undefined ? {} : asRecord(entry.config);
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined));
|
||||
const numbers = asRecord(config.numbers);
|
||||
for (const route of Object.values(numbers ?? {})) {
|
||||
const agentId = asRecord(route)?.agentId;
|
||||
if (typeof agentId === "string") {
|
||||
agentIds.add(normalizeAgentId(agentId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...agentIds].toSorted();
|
||||
}
|
||||
|
||||
/** Resolve the voice-call store path used by legacy and plugin-state call records. */
|
||||
function resolveVoiceCallStorePath(params: {
|
||||
config: PluginDoctorStateMigrationParams["config"];
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
resolveVoiceCallAgentSessionKey,
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKey,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
resolveVoiceCallSessionKey,
|
||||
validateProviderConfig,
|
||||
normalizeVoiceCallConfig,
|
||||
@@ -298,23 +296,7 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
callId: "call-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
}),
|
||||
).toBe("agent:main:voice:15550001111");
|
||||
});
|
||||
|
||||
it("scopes generated voice session keys by configured agent", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
agentId: "Voice",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVoiceCallSessionKey({
|
||||
config,
|
||||
callId: "CALL-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
}),
|
||||
).toBe("agent:voice:voice:15550001111");
|
||||
).toBe("voice:15550001111");
|
||||
});
|
||||
|
||||
it("can scope voice sessions to each call", () => {
|
||||
@@ -331,10 +313,10 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
callId: "call-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
}),
|
||||
).toBe("agent:main:voice:call:call-123");
|
||||
).toBe("voice:call:call-123");
|
||||
});
|
||||
|
||||
it("scopes explicit voice session keys by configured agent", () => {
|
||||
it("preserves explicit voice session keys", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
@@ -346,135 +328,9 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
config,
|
||||
callId: "call-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
explicitSessionKey: "Meet-Room-1",
|
||||
explicitSessionKey: "meet-room-1",
|
||||
}),
|
||||
).toBe("agent:main:meet-room-1");
|
||||
});
|
||||
|
||||
it("scopes persisted and explicit keys at the agent session boundary", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
agentId: "Voice",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "voice:call:legacy-call",
|
||||
}),
|
||||
).toBe("agent:voice:voice:call:legacy-call");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "meet-room-1",
|
||||
}),
|
||||
).toBe("agent:voice:meet-room-1");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:main:shared-room",
|
||||
}),
|
||||
).toBe("agent:voice:agent:main:shared-room");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:other:Matrix:Channel:!RoomAbC:example.org",
|
||||
}),
|
||||
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org",
|
||||
}),
|
||||
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "Signal:Group:AbC123=",
|
||||
}),
|
||||
).toBe("agent:voice:signal:group:AbC123=");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:broken",
|
||||
}),
|
||||
).toBe("agent:voice:agent:broken");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent::broken",
|
||||
}),
|
||||
).toBe("agent:voice:agent::broken");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent::Matrix:Channel:!RoomAbC:example.org",
|
||||
}),
|
||||
).toBe("agent:voice:agent::matrix:channel:!RoomAbC:example.org");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:other:room::part",
|
||||
}),
|
||||
).toBe("agent:voice:agent:other:room::part");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:voice:room::part",
|
||||
}),
|
||||
).toBe("agent:voice:room::part");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:voice::Matrix:Channel:!RoomAbC:example.org",
|
||||
}),
|
||||
).toBe("agent:voice:agent:voice::matrix:channel:!RoomAbC:example.org");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:bad/id:room",
|
||||
}),
|
||||
).toBe("agent:voice:agent:bad/id:room");
|
||||
});
|
||||
|
||||
it("canonicalizes raw and scoped main aliases with the core session config", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
agentId: "Voice",
|
||||
});
|
||||
|
||||
for (const sessionKey of ["main", "agent:voice:main"]) {
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey,
|
||||
coreSession: { mainKey: "work" },
|
||||
}),
|
||||
).toBe("agent:voice:work");
|
||||
}
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "main",
|
||||
coreSession: { scope: "global" },
|
||||
}),
|
||||
).toBe("global");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:main:main",
|
||||
coreSession: { mainKey: "work" },
|
||||
}),
|
||||
).toBe("agent:voice:agent:main:main");
|
||||
expect(
|
||||
resolveVoiceCallAgentSessionKey({
|
||||
config,
|
||||
sessionKey: "agent:main:main",
|
||||
coreSession: { scope: "global" },
|
||||
}),
|
||||
).toBe("agent:voice:agent:main:main");
|
||||
).toBe("meet-room-1");
|
||||
});
|
||||
|
||||
it("resolves per-number inbound route overrides over global voice settings", () => {
|
||||
@@ -539,35 +395,6 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
expect(effective.config).toBe(config);
|
||||
expect(effective.config.inboundGreeting).toBe("Hello from global.");
|
||||
});
|
||||
|
||||
it("uses dialed-number fallback only for inbound calls", () => {
|
||||
expect(
|
||||
resolveVoiceCallNumberRouteKeyForCall({
|
||||
direction: "inbound",
|
||||
to: "+15550001111",
|
||||
}),
|
||||
).toBe("+15550001111");
|
||||
expect(
|
||||
resolveVoiceCallNumberRouteKeyForCall({
|
||||
direction: "outbound",
|
||||
to: "+15550001111",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveVoiceCallNumberRouteKeyForCall({
|
||||
direction: "inbound",
|
||||
to: "+15550001111",
|
||||
metadata: { numberRouteKey: "+15550002222" },
|
||||
}),
|
||||
).toBe("+15550002222");
|
||||
expect(
|
||||
resolveVoiceCallNumberRouteKeyForCall({
|
||||
direction: "outbound",
|
||||
to: "+15550001111",
|
||||
metadata: { numberRouteKey: "+15550002222" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeVoiceCallConfig", () => {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// Voice Call helper module supports config behavior.
|
||||
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
canonicalizeMainSessionAlias,
|
||||
type SessionScope,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { z } from "zod";
|
||||
import { TtsConfigSchema } from "../api.js";
|
||||
import { deepMergeDefined } from "./deep-merge.js";
|
||||
@@ -574,22 +569,6 @@ export function resolveVoiceCallNumberRouteKey(
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve inbound-only number routing from a persisted call record. */
|
||||
export function resolveVoiceCallNumberRouteKeyForCall(call: {
|
||||
direction?: "inbound" | "outbound";
|
||||
to?: string;
|
||||
metadata?: { numberRouteKey?: unknown };
|
||||
}): string | undefined {
|
||||
if (call.direction !== "inbound") {
|
||||
return undefined;
|
||||
}
|
||||
const storedRouteKey = call.metadata?.numberRouteKey;
|
||||
if (typeof storedRouteKey === "string") {
|
||||
return storedRouteKey;
|
||||
}
|
||||
return call.to;
|
||||
}
|
||||
|
||||
export function resolveVoiceCallEffectiveConfig(
|
||||
config: VoiceCallConfig,
|
||||
phoneOrRouteKey: string | undefined,
|
||||
@@ -716,73 +695,21 @@ export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCal
|
||||
};
|
||||
}
|
||||
|
||||
export type VoiceCallCoreSessionConfig = { mainKey?: string; scope?: SessionScope };
|
||||
|
||||
export function resolveVoiceCallSessionKey(params: {
|
||||
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
|
||||
config: Pick<VoiceCallConfig, "sessionScope">;
|
||||
callId: string;
|
||||
phone?: string;
|
||||
explicitSessionKey?: string;
|
||||
coreSession?: VoiceCallCoreSessionConfig;
|
||||
}): string {
|
||||
const explicit = params.explicitSessionKey?.trim();
|
||||
if (explicit) {
|
||||
return resolveVoiceCallAgentSessionKey({
|
||||
config: params.config,
|
||||
sessionKey: explicit,
|
||||
coreSession: params.coreSession,
|
||||
});
|
||||
return explicit;
|
||||
}
|
||||
// Startup migration promotes unambiguous shipped `voice:*` rows;
|
||||
// generate only canonical keys here so new history never needs repair.
|
||||
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
|
||||
if (params.config.sessionScope === "per-call") {
|
||||
return `${prefix}:call:${params.callId}`.toLowerCase();
|
||||
return `voice:call:${params.callId}`;
|
||||
}
|
||||
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
||||
return (
|
||||
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
|
||||
export function resolveVoiceCallAgentSessionKey(params: {
|
||||
config: Pick<VoiceCallConfig, "agentId">;
|
||||
sessionKey: string;
|
||||
coreSession?: VoiceCallCoreSessionConfig;
|
||||
}): string {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
throw new Error("Voice Call session key cannot be empty");
|
||||
}
|
||||
const lower = sessionKey.toLowerCase();
|
||||
const agentId = normalizeAgentId(params.config.agentId);
|
||||
if (lower === "global" || lower === "unknown") {
|
||||
return lower;
|
||||
}
|
||||
const parsedInput = parseAgentSessionKey(sessionKey);
|
||||
let normalizedScopedKey: string;
|
||||
if (
|
||||
parsedInput &&
|
||||
normalizeAgentId(parsedInput.agentId) === parsedInput.agentId &&
|
||||
parsedInput.agentId === agentId
|
||||
) {
|
||||
normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
|
||||
} else {
|
||||
// Voice Call's configured agent owns both the store and runtime. Foreign or
|
||||
// malformed agent-shaped input is an opaque integration key, not a route.
|
||||
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
|
||||
if (!wrappedInput) {
|
||||
throw new Error("Voice Call session key could not be normalized");
|
||||
}
|
||||
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
|
||||
}
|
||||
const canonicalMain = canonicalizeMainSessionAlias({
|
||||
cfg: { session: params.coreSession },
|
||||
agentId,
|
||||
sessionKey: normalizedScopedKey,
|
||||
});
|
||||
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
|
||||
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Voice Call plugin module implements core bridge behavior.
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import type { VoiceCallCoreSessionConfig, VoiceCallTtsConfig } from "./config.js";
|
||||
import type { VoiceCallTtsConfig } from "./config.js";
|
||||
|
||||
// Narrow core runtime/config contracts consumed by the voice-call plugin.
|
||||
|
||||
/** Core config subset read by voice-call helpers. */
|
||||
export type CoreConfig = {
|
||||
session?: VoiceCallCoreSessionConfig & { store?: string };
|
||||
session?: {
|
||||
store?: string;
|
||||
};
|
||||
messages?: {
|
||||
tts?: VoiceCallTtsConfig;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "./config.js";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { CallManagerContext, StreamSessionIssuer } from "./manager/context.js";
|
||||
import { processEvent as processManagerEvent } from "./manager/events.js";
|
||||
import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
|
||||
@@ -82,7 +82,6 @@ export class CallManager {
|
||||
private rejectedProviderCallIds = new Set<string>();
|
||||
private provider: VoiceCallProvider | null = null;
|
||||
private config: VoiceCallConfig;
|
||||
private coreSession: VoiceCallCoreSessionConfig | undefined;
|
||||
private storePath: string;
|
||||
private webhookUrl: string | null = null;
|
||||
private activeTurnCalls = new Set<CallId>();
|
||||
@@ -104,13 +103,8 @@ export class CallManager {
|
||||
*/
|
||||
streamSessionIssuer: StreamSessionIssuer | undefined;
|
||||
|
||||
constructor(
|
||||
config: VoiceCallConfig,
|
||||
storePath?: string,
|
||||
coreSession?: VoiceCallCoreSessionConfig,
|
||||
) {
|
||||
constructor(config: VoiceCallConfig, storePath?: string) {
|
||||
this.config = config;
|
||||
this.coreSession = coreSession;
|
||||
this.storePath = resolveDefaultStoreBase(config, storePath);
|
||||
}
|
||||
|
||||
@@ -359,7 +353,6 @@ export class CallManager {
|
||||
rejectedProviderCallIds: this.rejectedProviderCallIds,
|
||||
provider: this.provider,
|
||||
config: this.config,
|
||||
coreSession: this.coreSession,
|
||||
storePath: this.storePath,
|
||||
webhookUrl: this.webhookUrl,
|
||||
activeTurnCalls: this.activeTurnCalls,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Voice Call plugin module implements context behavior.
|
||||
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "../config.js";
|
||||
import type { VoiceCallConfig } from "../config.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
import type { CallId, CallRecord } from "../types.js";
|
||||
|
||||
@@ -21,7 +21,6 @@ type CallManagerRuntimeState = {
|
||||
type CallManagerRuntimeDeps = {
|
||||
provider: VoiceCallProvider | null;
|
||||
config: VoiceCallConfig;
|
||||
coreSession?: VoiceCallCoreSessionConfig;
|
||||
storePath: string;
|
||||
webhookUrl: string | null;
|
||||
};
|
||||
|
||||
@@ -633,7 +633,7 @@ describe("processEvent (functional)", () => {
|
||||
processEvent(ctx, event);
|
||||
|
||||
const call = requireFirstActiveCall(ctx);
|
||||
expect(call.sessionKey).toBe(`agent:main:voice:call:${call.callId}`);
|
||||
expect(call.sessionKey).toBe(`voice:call:${call.callId}`);
|
||||
});
|
||||
|
||||
it("applies per-number inbound greeting and stores the matched route key", () => {
|
||||
|
||||
@@ -155,12 +155,11 @@ describe("voice-call outbound helpers", () => {
|
||||
fromNumber: "+14155550100",
|
||||
tts: { provider: "openai", providers: { openai: { voice: "nova" } } },
|
||||
},
|
||||
coreSession: { mainKey: "work" },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
const result = await initiateCall(ctx as never, "+14155550123", "main", {
|
||||
const result = await initiateCall(ctx as never, "+14155550123", "session-1", {
|
||||
mode: "notify",
|
||||
message: "hello there",
|
||||
});
|
||||
@@ -179,7 +178,7 @@ describe("voice-call outbound helpers", () => {
|
||||
inlineTwiml: "<Response />",
|
||||
});
|
||||
expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId);
|
||||
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("agent:main:work");
|
||||
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("session-1");
|
||||
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -204,9 +203,7 @@ describe("voice-call outbound helpers", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.callId).toBeTypeOf("string");
|
||||
expect(result.callId).not.toBe("");
|
||||
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
|
||||
`agent:main:voice:call:${result.callId}`,
|
||||
);
|
||||
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(`voice:call:${result.callId}`);
|
||||
});
|
||||
|
||||
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {
|
||||
@@ -407,7 +404,6 @@ describe("voice-call outbound helpers", () => {
|
||||
const call = {
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
direction: "inbound",
|
||||
state: "active",
|
||||
to: "+15550002222",
|
||||
metadata: { numberRouteKey: "+15550002222" },
|
||||
@@ -442,40 +438,6 @@ describe("voice-call outbound helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps top-level TTS for outbound calls to a number with an inbound route", async () => {
|
||||
const call = {
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
direction: "outbound",
|
||||
state: "active",
|
||||
to: "+15550002222",
|
||||
};
|
||||
const playTts = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
activeCalls: new Map([["call-1", call]]),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: { name: "twilio", playTts },
|
||||
config: {
|
||||
tts: { provider: "openai", providers: { openai: { voice: "coral" } } },
|
||||
numbers: {
|
||||
"+15550002222": {
|
||||
tts: { providers: { openai: { voice: "alloy" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
};
|
||||
|
||||
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
|
||||
|
||||
expect(playTts).toHaveBeenCalledWith({
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
text: "hello",
|
||||
voice: "coral",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends DTMF through connected provider calls", async () => {
|
||||
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
|
||||
const sendDtmfProvider = vi.fn(async () => {});
|
||||
|
||||
@@ -3,7 +3,6 @@ import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
resolveVoiceCallSessionKey,
|
||||
type CallMode,
|
||||
} from "../config.js";
|
||||
@@ -35,7 +34,6 @@ type InitiateContext = Pick<
|
||||
| "providerCallIdMap"
|
||||
| "provider"
|
||||
| "config"
|
||||
| "coreSession"
|
||||
| "storePath"
|
||||
| "webhookUrl"
|
||||
| "streamSessionIssuer"
|
||||
@@ -192,7 +190,6 @@ export async function initiateCall(
|
||||
callId,
|
||||
phone: to,
|
||||
explicitSessionKey: sessionKey,
|
||||
coreSession: ctx.coreSession,
|
||||
}),
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
@@ -291,7 +288,8 @@ export async function speak(
|
||||
transitionState(call, "speaking");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
||||
const voice = resolvePreferredTtsVoice(
|
||||
resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config,
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
|
||||
sessionStore[params.sessionKey] = { ...params.entry };
|
||||
},
|
||||
);
|
||||
const runEmbeddedAgent = vi.fn(async (_args: EmbeddedAgentArgs) => ({
|
||||
const runEmbeddedAgent = vi.fn(async () => ({
|
||||
payloads,
|
||||
meta: { durationMs: 12, aborted: false },
|
||||
}));
|
||||
@@ -233,7 +233,7 @@ describe("generateVoiceResponse", () => {
|
||||
const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Pinned model works."}' },
|
||||
]);
|
||||
sessionStore["agent:main:voice:15550001111"] = {
|
||||
sessionStore["voice:15550001111"] = {
|
||||
sessionId: "existing-session",
|
||||
updatedAt: 100,
|
||||
model: "old-model",
|
||||
@@ -257,7 +257,7 @@ describe("generateVoiceResponse", () => {
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Pinned model works.");
|
||||
const pinnedSessionEntry = sessionStore["agent:main:voice:15550001111"];
|
||||
const pinnedSessionEntry = sessionStore["voice:15550001111"];
|
||||
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
|
||||
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
|
||||
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
|
||||
@@ -271,17 +271,17 @@ describe("generateVoiceResponse", () => {
|
||||
);
|
||||
expect(patchSessionEntryCall[0]).toMatchObject({
|
||||
storePath: "/tmp/openclaw/main/sessions.json",
|
||||
sessionKey: "agent:main:voice:15550001111",
|
||||
sessionKey: "voice:15550001111",
|
||||
replaceEntry: true,
|
||||
});
|
||||
expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function");
|
||||
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
|
||||
expect(args.provider).toBe("openai");
|
||||
expect(args.model).toBe("gpt-4.1-nano");
|
||||
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
|
||||
expect(args.sessionKey).toBe("voice:15550001111");
|
||||
});
|
||||
|
||||
it("canonicalizes a restored legacy per-call key for classic responses", async () => {
|
||||
it("uses the persisted per-call session key for classic responses", async () => {
|
||||
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Fresh call context."}' },
|
||||
]);
|
||||
@@ -302,102 +302,15 @@ describe("generateVoiceResponse", () => {
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Fresh call context.");
|
||||
const perCallSessionEntry = sessionStore["agent:main:voice:call:call-123"];
|
||||
const perCallSessionEntry = sessionStore["voice:call:call-123"];
|
||||
expect(perCallSessionEntry?.sessionId).toBeTypeOf("string");
|
||||
expect(perCallSessionEntry?.sessionId).not.toBe("");
|
||||
expect(sessionStore["voice:15550001111"]).toBeUndefined();
|
||||
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
|
||||
expect(args.sessionKey).toBe("agent:main:voice:call:call-123");
|
||||
expect(args.sessionKey).toBe("voice:call:call-123");
|
||||
expect(args.sandboxSessionKey).toBe("agent:main:voice:call:call-123");
|
||||
});
|
||||
|
||||
it("preserves an explicit call key while scoping its session-store identity", async () => {
|
||||
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Shared meeting context."}' },
|
||||
]);
|
||||
const voiceConfig = VoiceCallConfigSchema.parse({
|
||||
agentId: "voice",
|
||||
responseTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
await generateVoiceResponse({
|
||||
voiceConfig,
|
||||
coreConfig: {} as CoreConfig,
|
||||
agentRuntime: runtime,
|
||||
callId: "call-123",
|
||||
sessionKey: "meet-room-1",
|
||||
from: "+15550001111",
|
||||
transcript: [],
|
||||
userMessage: "hello there",
|
||||
});
|
||||
|
||||
expect(sessionStore["agent:voice:meet-room-1"]?.sessionId).toBeTypeOf("string");
|
||||
expect(sessionStore["meet-room-1"]).toBeUndefined();
|
||||
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:meet-room-1");
|
||||
});
|
||||
|
||||
it("keeps wrapped foreign Matrix identities stable across restore", async () => {
|
||||
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Matrix context."}' },
|
||||
]);
|
||||
const voiceConfig = VoiceCallConfigSchema.parse({
|
||||
agentId: "voice",
|
||||
responseTimeoutMs: 5000,
|
||||
});
|
||||
const canonical = "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org";
|
||||
const generate = (sessionKey: string) =>
|
||||
generateVoiceResponse({
|
||||
voiceConfig,
|
||||
coreConfig: {} as CoreConfig,
|
||||
agentRuntime: runtime,
|
||||
callId: "call-123",
|
||||
sessionKey,
|
||||
from: "+15550001111",
|
||||
transcript: [],
|
||||
userMessage: "hello there",
|
||||
});
|
||||
|
||||
await generate("agent:other:matrix:channel:!RoomAbC:example.org");
|
||||
await generate(canonical);
|
||||
await generate("agent:other:matrix:channel:!Roomabc:example.org");
|
||||
|
||||
expect(sessionStore[canonical]?.sessionId).toBeTypeOf("string");
|
||||
expect(
|
||||
sessionStore["agent:voice:agent:other:matrix:channel:!Roomabc:example.org"]?.sessionId,
|
||||
).toBeTypeOf("string");
|
||||
expect(Object.keys(sessionStore)).toHaveLength(2);
|
||||
const sessionKeys = runEmbeddedAgent.mock.calls.map(([args]) => args.sessionKey);
|
||||
expect(sessionKeys).toEqual([
|
||||
canonical,
|
||||
canonical,
|
||||
"agent:voice:agent:other:matrix:channel:!Roomabc:example.org",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured core main key for restored call aliases", async () => {
|
||||
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Main context."}' },
|
||||
]);
|
||||
const voiceConfig = VoiceCallConfigSchema.parse({
|
||||
agentId: "voice",
|
||||
responseTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
await generateVoiceResponse({
|
||||
voiceConfig,
|
||||
coreConfig: { session: { mainKey: "work" } },
|
||||
agentRuntime: runtime,
|
||||
callId: "call-123",
|
||||
sessionKey: "agent:voice:main",
|
||||
from: "+15550001111",
|
||||
transcript: [],
|
||||
userMessage: "hello there",
|
||||
});
|
||||
|
||||
expect(sessionStore["agent:voice:work"]?.sessionId).toBeTypeOf("string");
|
||||
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:work");
|
||||
});
|
||||
|
||||
it("uses the main agent workspace when voice config omits agentId", async () => {
|
||||
const {
|
||||
runtime,
|
||||
@@ -424,18 +337,17 @@ describe("generateVoiceResponse", () => {
|
||||
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main");
|
||||
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main");
|
||||
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main");
|
||||
const defaultSessionEntry = sessionStore["agent:main:voice:15550001111"];
|
||||
const defaultSessionEntry = sessionStore["voice:15550001111"];
|
||||
if (!defaultSessionEntry) {
|
||||
throw new Error("Expected default voice session entry");
|
||||
}
|
||||
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
|
||||
expect(args.agentDir).toBe("/tmp/openclaw/agents/main");
|
||||
expect(args.agentId).toBe("main");
|
||||
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
|
||||
expect(args.sessionTarget).toStrictEqual({
|
||||
agentId: "main",
|
||||
sessionId: defaultSessionEntry.sessionId,
|
||||
sessionKey: "agent:main:voice:15550001111",
|
||||
sessionKey: "voice:15550001111",
|
||||
storePath: "/tmp/openclaw/main/sessions.json",
|
||||
});
|
||||
expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111");
|
||||
@@ -473,18 +385,17 @@ describe("generateVoiceResponse", () => {
|
||||
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice");
|
||||
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice");
|
||||
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice");
|
||||
const voiceSessionEntry = sessionStore["agent:voice:voice:15550001111"];
|
||||
const voiceSessionEntry = sessionStore["voice:15550001111"];
|
||||
if (!voiceSessionEntry) {
|
||||
throw new Error("Expected routed voice session entry");
|
||||
}
|
||||
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
|
||||
expect(args.agentDir).toBe("/tmp/openclaw/agents/voice");
|
||||
expect(args.agentId).toBe("voice");
|
||||
expect(args.sessionKey).toBe("agent:voice:voice:15550001111");
|
||||
expect(args.sessionTarget).toStrictEqual({
|
||||
agentId: "voice",
|
||||
sessionId: voiceSessionEntry.sessionId,
|
||||
sessionKey: "agent:voice:voice:15550001111",
|
||||
sessionKey: "voice:15550001111",
|
||||
storePath: "/tmp/openclaw/voice/sessions.json",
|
||||
});
|
||||
expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111");
|
||||
|
||||
@@ -234,7 +234,6 @@ export async function generateVoiceResponse(
|
||||
callId,
|
||||
phone: from,
|
||||
explicitSessionKey: sessionKey,
|
||||
coreSession: coreConfig.session,
|
||||
});
|
||||
const agentId = voiceConfig.agentId ?? "main";
|
||||
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
|
||||
|
||||
@@ -29,42 +29,22 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveVoiceCallSessionKey: (params: {
|
||||
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
|
||||
config: Pick<VoiceCallConfig, "sessionScope">;
|
||||
callId: string;
|
||||
phone?: string;
|
||||
explicitSessionKey?: string;
|
||||
}) => {
|
||||
const explicit = params.explicitSessionKey?.trim();
|
||||
if (explicit) {
|
||||
const lower = explicit.toLowerCase();
|
||||
return lower === "global" || lower === "unknown" || lower.startsWith("agent:")
|
||||
? explicit
|
||||
: `agent:${params.config.agentId?.trim().toLowerCase() || "main"}:${explicit}`;
|
||||
return explicit;
|
||||
}
|
||||
const agentId = params.config.agentId?.trim().toLowerCase() || "main";
|
||||
const prefix = `agent:${agentId}:voice`;
|
||||
if (params.config.sessionScope === "per-call") {
|
||||
return `${prefix}:call:${params.callId}`.toLowerCase();
|
||||
return `voice:call:${params.callId}`;
|
||||
}
|
||||
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
||||
return (
|
||||
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
|
||||
).toLowerCase();
|
||||
},
|
||||
resolveVoiceCallNumberRouteKeyForCall: (call: {
|
||||
direction?: "inbound" | "outbound";
|
||||
to?: string;
|
||||
metadata?: { numberRouteKey?: unknown };
|
||||
}) =>
|
||||
call.direction === "inbound"
|
||||
? typeof call.metadata?.numberRouteKey === "string"
|
||||
? call.metadata.numberRouteKey
|
||||
: call.to
|
||||
: undefined,
|
||||
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig, numberRouteKey?: string) => {
|
||||
const route = numberRouteKey ? config.numbers[numberRouteKey] : undefined;
|
||||
return route ? { config: { ...config, ...route }, numberRouteKey } : { config };
|
||||
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
|
||||
},
|
||||
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig) => ({ config }),
|
||||
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
|
||||
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
|
||||
validateProviderConfig: mocks.validateProviderConfig,
|
||||
@@ -398,13 +378,9 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("wires realtime consults and keeps outbound calls off inbound number routes", async () => {
|
||||
it("wires the shared realtime agent consult tool and handler", async () => {
|
||||
const config = createBaseConfig();
|
||||
config.inboundPolicy = "allowlist";
|
||||
config.numbers["+15550009999"] = {
|
||||
agentId: "inbound-route",
|
||||
responseModel: "openai/gpt-5.5",
|
||||
};
|
||||
config.realtime.enabled = true;
|
||||
config.realtime.tools = [
|
||||
{
|
||||
@@ -470,7 +446,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
|
||||
"embedded OpenClaw consult params",
|
||||
);
|
||||
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
|
||||
expect(consultParams.sessionKey).toBe("voice:15550009999");
|
||||
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
|
||||
expect(consultParams.messageProvider).toBe("voice");
|
||||
expect(consultParams.lane).toBe("voice");
|
||||
@@ -489,7 +465,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
expect(consultParams.prompt).toContain("Caller: Also check the ETA.");
|
||||
});
|
||||
|
||||
it("canonicalizes restored legacy per-call keys for realtime consults", async () => {
|
||||
it("uses persisted per-call session keys for realtime consults", async () => {
|
||||
const config = createBaseConfig();
|
||||
config.inboundPolicy = "allowlist";
|
||||
config.realtime.enabled = true;
|
||||
@@ -537,7 +513,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
),
|
||||
"per-call embedded OpenClaw consult params",
|
||||
);
|
||||
expect(consultParams.sessionKey).toBe("agent:main:voice:call:call-1");
|
||||
expect(consultParams.sessionKey).toBe("voice:call:call-1");
|
||||
});
|
||||
|
||||
it("answers realtime consults from fast memory context before starting the full agent", async () => {
|
||||
@@ -606,7 +582,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
},
|
||||
sessionKey: "agent:main:voice:15550001234",
|
||||
sessionKey: "voice:15550001234",
|
||||
});
|
||||
expect(runEmbeddedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
resolveVoiceCallSessionKey,
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallConfig,
|
||||
@@ -112,19 +111,20 @@ function loadRealtimeHandler(): Promise<RealtimeHandlerModule> {
|
||||
|
||||
function resolveVoiceCallConsultSessionKey(call: {
|
||||
config: VoiceCallConfig;
|
||||
coreSession?: OpenClawConfig["session"];
|
||||
sessionKey?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
direction?: "inbound" | "outbound";
|
||||
callId: string;
|
||||
}): string {
|
||||
if (call.sessionKey) {
|
||||
return call.sessionKey;
|
||||
}
|
||||
const phone = call.direction === "outbound" ? call.to : call.from;
|
||||
return resolveVoiceCallSessionKey({
|
||||
config: call.config,
|
||||
callId: call.callId,
|
||||
phone: call.direction === "outbound" ? call.to : call.from,
|
||||
explicitSessionKey: call.sessionKey,
|
||||
coreSession: call.coreSession,
|
||||
phone,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function createVoiceCallRuntime(params: {
|
||||
if (stateRuntime) {
|
||||
setVoiceCallStateRuntime({ state: stateRuntime });
|
||||
}
|
||||
const manager = new CallManager(config, undefined, cfg.session);
|
||||
const manager = new CallManager(config);
|
||||
const realtimeProvider = config.realtime.enabled
|
||||
? await resolveRealtimeProvider({
|
||||
config,
|
||||
@@ -358,13 +358,15 @@ export async function createVoiceCallRuntime(params: {
|
||||
if (!call) {
|
||||
return { error: `Call "${callId}" not found` };
|
||||
}
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string"
|
||||
? call.metadata.numberRouteKey
|
||||
: call.to;
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
||||
const agentId = effectiveConfig.agentId ?? "main";
|
||||
const sessionKey = resolveVoiceCallConsultSessionKey({
|
||||
...call,
|
||||
config: effectiveConfig,
|
||||
coreSession: cfg.session,
|
||||
});
|
||||
const requesterSessionKey =
|
||||
typeof call.metadata?.requesterSessionKey === "string"
|
||||
|
||||
@@ -31,7 +31,6 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
return {
|
||||
generateVoiceResponse: vi.fn(async () => ({ text: null })),
|
||||
getRealtimeTranscriptionProvider: vi.fn<(...args: unknown[]) => unknown>(
|
||||
() => realtimeTranscriptionProvider,
|
||||
),
|
||||
@@ -44,10 +43,6 @@ vi.mock("./realtime-transcription.runtime.js", () => ({
|
||||
listRealtimeTranscriptionProviders: mocks.listRealtimeTranscriptionProviders,
|
||||
}));
|
||||
|
||||
vi.mock("./response-generator.js", () => ({
|
||||
generateVoiceResponse: mocks.generateVoiceResponse,
|
||||
}));
|
||||
|
||||
const provider: VoiceCallProvider = {
|
||||
name: "mock",
|
||||
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }),
|
||||
@@ -1651,46 +1646,6 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceCallWebhookServer classic response routing", () => {
|
||||
it("keeps outbound calls on the top-level agent when the dialed number has an inbound route", async () => {
|
||||
const call = createCall(Date.now());
|
||||
call.direction = "outbound";
|
||||
call.to = "+15550001111";
|
||||
call.sessionKey = "agent:top:voice:15550001111";
|
||||
const manager = {
|
||||
getCall: (callId: string) => (callId === call.callId ? call : undefined),
|
||||
speak: vi.fn(async () => ({ success: true })),
|
||||
} as unknown as CallManager;
|
||||
const config = createConfig({
|
||||
agentId: "top",
|
||||
numbers: {
|
||||
"+15550001111": { agentId: "inbound-route" },
|
||||
},
|
||||
});
|
||||
const server = new VoiceCallWebhookServer(
|
||||
config,
|
||||
manager,
|
||||
provider,
|
||||
{} as never,
|
||||
undefined,
|
||||
{} as never,
|
||||
);
|
||||
mocks.generateVoiceResponse.mockReset().mockResolvedValue({ text: null });
|
||||
|
||||
await (
|
||||
server as unknown as {
|
||||
handleInboundResponse: (callId: string, message: string) => Promise<void>;
|
||||
}
|
||||
).handleInboundResponse(call.callId, "hello");
|
||||
|
||||
const params = requireFirstMockCall(
|
||||
mocks.generateVoiceResponse.mock.calls,
|
||||
"classic voice response",
|
||||
)[0] as { voiceConfig?: VoiceCallConfig } | undefined;
|
||||
expect(params?.voiceConfig?.agentId).toBe("top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceCallWebhookServer response normalization", () => {
|
||||
it("preserves explicit empty provider response bodies", async () => {
|
||||
const responseProvider: VoiceCallProvider = {
|
||||
|
||||
@@ -25,7 +25,6 @@ import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
|
||||
import {
|
||||
normalizeVoiceCallConfig,
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
type VoiceCallConfig,
|
||||
} from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
@@ -1032,7 +1031,8 @@ export class VoiceCallWebhookServer {
|
||||
|
||||
try {
|
||||
const { generateVoiceResponse } = await loadResponseGeneratorModule();
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
|
||||
|
||||
const result = await generateVoiceResponse({
|
||||
|
||||
@@ -1695,7 +1695,6 @@
|
||||
"plugin-sdk:surface:check": "node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs --check",
|
||||
"plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs",
|
||||
"plugin-sdk:usage": "node --max-old-space-size=8192 --import tsx scripts/analyze-plugin-sdk-usage.ts",
|
||||
"policy:config-coverage": "node --import tsx scripts/check-policy-config-coverage.ts",
|
||||
"plugins:boundary-report": "node --import tsx scripts/plugin-boundary-report.ts",
|
||||
"plugins:boundary-report:ci": "node --import tsx scripts/plugin-boundary-report.ts --summary --fail-on-cross-owner --fail-on-unclassified-unused-reserved --fail-on-eligible-compat",
|
||||
"plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json",
|
||||
@@ -1952,6 +1951,9 @@
|
||||
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
|
||||
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
|
||||
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
|
||||
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
|
||||
"native:i18n:sync": "node --import tsx scripts/native-app-i18n.ts sync --write",
|
||||
"android:i18n:check": "node --import tsx scripts/android-app-i18n.ts check",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
"verify": "node scripts/verify.mjs"
|
||||
},
|
||||
|
||||
52
scripts/android-app-i18n.ts
Normal file
52
scripts/android-app-i18n.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { NATIVE_I18N_LOCALES } from "./native-app-i18n.ts";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "..");
|
||||
const RESOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main", "res");
|
||||
const ANDROID_QUALIFIERS: Record<string, string> = {
|
||||
"zh-CN": "zh-rCN",
|
||||
"zh-TW": "zh-rTW",
|
||||
"pt-BR": "pt-rBR",
|
||||
"ja-JP": "ja",
|
||||
};
|
||||
const localeDirectory = (locale: string) => `values-${ANDROID_QUALIFIERS[locale] ?? locale}`;
|
||||
const LOCALES = ["values", ...NATIVE_I18N_LOCALES.map(localeDirectory)] as const;
|
||||
const KEY_RE = /<string\s+name="([A-Za-z0-9_]+)"[^>]*>/gu;
|
||||
|
||||
async function readKeys(locale: string): Promise<Set<string>> {
|
||||
const source = await readFile(path.join(RESOURCE_ROOT, locale, "strings.xml"), "utf8");
|
||||
return new Set([...source.matchAll(KEY_RE)].map((match) => match[1]).filter(Boolean));
|
||||
}
|
||||
|
||||
export async function checkAndroidAppI18n() {
|
||||
const [base, ...translations] = await Promise.all(LOCALES.map(readKeys));
|
||||
const problems = translations.flatMap((keys, index) => {
|
||||
const locale = NATIVE_I18N_LOCALES[index];
|
||||
return [
|
||||
[`${locale} missing`, [...base].filter((key) => !keys.has(key))],
|
||||
[`${locale} extra`, [...keys].filter((key) => !base.has(key))],
|
||||
] as const;
|
||||
});
|
||||
if (problems.some(([, keys]) => keys.length)) {
|
||||
throw new Error(
|
||||
[
|
||||
"Android app i18n resources are out of sync.",
|
||||
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
process.stdout.write(
|
||||
`android-app-i18n: keys=${base.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
|
||||
const [command] = process.argv.slice(2);
|
||||
if (command !== "check") {
|
||||
throw new Error("usage: node --import tsx scripts/android-app-i18n.ts check");
|
||||
}
|
||||
await checkAndroidAppI18n();
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import JSON5 from "json5";
|
||||
import {
|
||||
renderConfigDocBaselineArtifacts,
|
||||
type ConfigDocBaselineEntry,
|
||||
} from "../src/config/doc-baseline.js";
|
||||
|
||||
type ClassificationStatus = "observed" | "ignored" | "out-of-scope" | "deferred";
|
||||
|
||||
type CoverageClassification = {
|
||||
readonly pattern: string;
|
||||
readonly status: ClassificationStatus;
|
||||
readonly area: string;
|
||||
readonly policy?: string;
|
||||
readonly reason: string;
|
||||
readonly allowNoSchemaPath?: boolean;
|
||||
};
|
||||
|
||||
type CoverageConfig = {
|
||||
readonly monitored: readonly string[];
|
||||
readonly classifications: readonly CoverageClassification[];
|
||||
};
|
||||
|
||||
type ConfigDocBaseline = {
|
||||
readonly coreEntries: readonly ConfigDocBaselineEntry[];
|
||||
readonly channelEntries: readonly ConfigDocBaselineEntry[];
|
||||
readonly pluginEntries: readonly ConfigDocBaselineEntry[];
|
||||
};
|
||||
|
||||
function flattenConfigDocBaselineEntries(
|
||||
baseline: ConfigDocBaseline,
|
||||
): readonly ConfigDocBaselineEntry[] {
|
||||
return [...baseline.coreEntries, ...baseline.channelEntries, ...baseline.pluginEntries];
|
||||
}
|
||||
|
||||
type ClassifiedEntry = {
|
||||
readonly path: string;
|
||||
readonly kind: ConfigDocBaselineEntry["kind"];
|
||||
readonly classification?: CoverageClassification;
|
||||
};
|
||||
|
||||
type UnmatchedMonitoredPattern = {
|
||||
readonly pattern: string;
|
||||
};
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const json = args.has("--json");
|
||||
const check = args.has("--check");
|
||||
const showCovered = args.has("--show-covered");
|
||||
|
||||
if (args.has("--help")) {
|
||||
console.log(`Usage: pnpm policy:config-coverage [--check] [--json] [--show-covered]
|
||||
|
||||
Internal maintainer report for Policy config coverage.
|
||||
|
||||
Default mode is report-only and exits 0 even when paths are unclassified.
|
||||
Use --check when a policy maintainer intentionally wants unclassified or stale
|
||||
coverage entries to fail locally.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const configPath = path.join(repoRoot, "scripts/lib/policy-config-coverage.jsonc");
|
||||
|
||||
const config = JSON5.parse(await fs.readFile(configPath, "utf8")) as CoverageConfig;
|
||||
const { baseline } = await renderConfigDocBaselineArtifacts();
|
||||
const monitoredEntries = flattenConfigDocBaselineEntries(baseline)
|
||||
.filter((entry) => !entry.hasChildren)
|
||||
.filter((entry) => matchesAny(config.monitored, entry.path))
|
||||
.toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
const leafEntries = flattenConfigDocBaselineEntries(baseline).filter((entry) => !entry.hasChildren);
|
||||
const unmatchedMonitored = config.monitored
|
||||
.filter(
|
||||
(pattern) =>
|
||||
!leafEntries.some((entry) => pathMatchesPattern(pattern, entry.path)) &&
|
||||
!config.classifications.some(
|
||||
(item) => item.allowNoSchemaPath === true && pathMatchesPattern(item.pattern, pattern),
|
||||
),
|
||||
)
|
||||
.map((pattern) => ({ pattern }))
|
||||
.toSorted((left, right) => left.pattern.localeCompare(right.pattern));
|
||||
|
||||
const classified: ClassifiedEntry[] = monitoredEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
kind: entry.kind,
|
||||
classification: config.classifications.find((item) =>
|
||||
pathMatchesPattern(item.pattern, entry.path),
|
||||
),
|
||||
}));
|
||||
const unclassified = classified.filter((entry) => entry.classification === undefined);
|
||||
const stale = config.classifications.filter(
|
||||
(item) =>
|
||||
item.allowNoSchemaPath !== true &&
|
||||
!monitoredEntries.some((entry) => pathMatchesPattern(item.pattern, entry.path)),
|
||||
);
|
||||
const summaryCounts = summarize(classified);
|
||||
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: unclassified.length === 0 && stale.length === 0 && unmatchedMonitored.length === 0,
|
||||
monitoredPaths: monitoredEntries.length,
|
||||
counts: summaryCounts,
|
||||
unclassified,
|
||||
unmatchedMonitored,
|
||||
stale,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
printTextReport({
|
||||
monitoredPaths: monitoredEntries.length,
|
||||
counts: summaryCounts,
|
||||
unclassified,
|
||||
unmatchedMonitored,
|
||||
stale,
|
||||
classified,
|
||||
});
|
||||
}
|
||||
|
||||
if (check && (unclassified.length > 0 || stale.length > 0 || unmatchedMonitored.length > 0)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printTextReport(input: {
|
||||
readonly monitoredPaths: number;
|
||||
readonly counts: Record<string, number>;
|
||||
readonly unclassified: readonly ClassifiedEntry[];
|
||||
readonly unmatchedMonitored: readonly UnmatchedMonitoredPattern[];
|
||||
readonly stale: readonly CoverageClassification[];
|
||||
readonly classified: readonly ClassifiedEntry[];
|
||||
}): void {
|
||||
console.log(`Policy config coverage: ${input.monitoredPaths} monitored config leaf paths`);
|
||||
for (const [key, count] of Object.entries(input.counts).toSorted(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
console.log(` ${key}: ${count}`);
|
||||
}
|
||||
|
||||
if (input.unclassified.length > 0) {
|
||||
console.log("\nUnclassified config paths:");
|
||||
for (const entry of input.unclassified) {
|
||||
console.log(` - ${entry.path} (${entry.kind})`);
|
||||
}
|
||||
console.log(
|
||||
"\nClassify each as observed, ignored, out-of-scope, or deferred in scripts/lib/policy-config-coverage.jsonc.",
|
||||
);
|
||||
} else {
|
||||
console.log("\nNo unclassified monitored config paths.");
|
||||
}
|
||||
|
||||
if (input.unmatchedMonitored.length > 0) {
|
||||
console.log("\nMonitored patterns with no matching config paths:");
|
||||
for (const entry of input.unmatchedMonitored) {
|
||||
console.log(` - ${entry.pattern}`);
|
||||
}
|
||||
} else {
|
||||
console.log("\nNo monitored patterns without matching config paths.");
|
||||
}
|
||||
|
||||
if (input.stale.length > 0) {
|
||||
console.log("\nStale coverage classifications:");
|
||||
for (const entry of input.stale) {
|
||||
console.log(` - ${entry.pattern} (${entry.area}, ${entry.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (showCovered) {
|
||||
console.log("\nCovered paths:");
|
||||
for (const entry of input.classified) {
|
||||
const classification = entry.classification;
|
||||
console.log(
|
||||
` - ${entry.path}: ${classification?.area ?? "unclassified"} / ${
|
||||
classification?.status ?? "unclassified"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(entries: readonly ClassifiedEntry[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const entry of entries) {
|
||||
const key =
|
||||
entry.classification === undefined
|
||||
? "unclassified"
|
||||
: `${entry.classification.area}.${entry.classification.status}`;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function matchesAny(patterns: readonly string[], value: string): boolean {
|
||||
return patterns.some((pattern) => pathMatchesPattern(pattern, value));
|
||||
}
|
||||
|
||||
function pathMatchesPattern(pattern: string, value: string): boolean {
|
||||
const patternParts = pattern.split(".");
|
||||
const valueParts = value.split(".");
|
||||
return matchesParts(patternParts, valueParts);
|
||||
}
|
||||
|
||||
function matchesParts(patternParts: readonly string[], valueParts: readonly string[]): boolean {
|
||||
if (patternParts.length === 0) {
|
||||
return valueParts.length === 0;
|
||||
}
|
||||
const [head, ...tail] = patternParts;
|
||||
if (head === "**") {
|
||||
if (tail.length === 0) {
|
||||
return true;
|
||||
}
|
||||
for (let index = 0; index <= valueParts.length; index += 1) {
|
||||
if (matchesParts(tail, valueParts.slice(index))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (valueParts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (head !== "*" && head !== valueParts[0]) {
|
||||
return false;
|
||||
}
|
||||
return matchesParts(tail, valueParts.slice(1));
|
||||
}
|
||||
@@ -595,6 +595,8 @@ function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntr
|
||||
"- The JSON must be an object whose keys exactly match the provided ids.",
|
||||
"- Translate all English prose; keep code, URLs, product names, CLI commands, config keys, and env vars in English.",
|
||||
"- Preserve placeholders exactly, including {count}, {time}, {shown}, {total}, and similar tokens.",
|
||||
"- Preserve Swift interpolation expressions such as \\(name) exactly, including the backslash and parentheses.",
|
||||
"- Preserve Kotlin interpolation expressions such as $name and ${value} exactly.",
|
||||
"- Preserve punctuation, ellipses, arrows, and casing when they are part of literal UI text.",
|
||||
"- Preserve Markdown, inline code, HTML tags, and slash commands when present.",
|
||||
"- Use fluent, neutral product UI language.",
|
||||
|
||||
@@ -114,10 +114,10 @@
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.6",
|
||||
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.3",
|
||||
"defaultChoice": "npm",
|
||||
"expectedIntegrity": "sha512-qw9k3PLTiMWGNjjsknHgcTManH1w4j+Ji1ArWIaYLKCq3aFRsVwcqnPi127bvOoVMJGW4dbyJ8NECEMgoO+iRw==",
|
||||
"minHostVersion": ">=2026.5.12"
|
||||
"expectedIntegrity": "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",
|
||||
"minHostVersion": ">=2026.3.22"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -117,9 +117,6 @@ export const pluginSdkDocMetadata = {
|
||||
"runtime-store": {
|
||||
category: "runtime",
|
||||
},
|
||||
"session-store-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
"session-transcript-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
|
||||
@@ -1,761 +0,0 @@
|
||||
{
|
||||
// Internal maintainer inventory for `pnpm policy:config-coverage`.
|
||||
// Keep this report-only by default: it helps policy maintainers notice config
|
||||
// drift without making every config PR author update Policy.
|
||||
"monitored": [
|
||||
"auth.profiles.*.mode",
|
||||
"auth.profiles.*.provider",
|
||||
"browser.ssrfPolicy.allowPrivateNetwork",
|
||||
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
|
||||
"channels.*.accounts.*.dmPolicy",
|
||||
"channels.*.accounts.*.groupPolicy",
|
||||
"channels.*.accounts.*.groups.*.requireMention",
|
||||
"channels.*.dmPolicy",
|
||||
"channels.*.enabled",
|
||||
"channels.*.groupPolicy",
|
||||
"channels.*.groups.*.requireMention",
|
||||
"diagnostics.otel.captureContent",
|
||||
"gateway.auth.mode",
|
||||
"gateway.auth.rateLimit.*",
|
||||
"gateway.bind",
|
||||
"gateway.controlUi.allowInsecureAuth",
|
||||
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth",
|
||||
"gateway.customBindHost",
|
||||
"gateway.http.endpoints.*.*.allowUrl",
|
||||
"gateway.http.endpoints.*.*.urlAllowlist.*",
|
||||
"gateway.http.endpoints.*.enabled",
|
||||
"gateway.mode",
|
||||
"gateway.remote.enabled",
|
||||
"gateway.tailscale.mode",
|
||||
"gateway.tailscale.preserveFunnel",
|
||||
"logging.redactSensitive",
|
||||
"memory.qmd.sessions.enabled",
|
||||
"mcp.servers.*.command",
|
||||
"mcp.servers.*.transport",
|
||||
"mcp.servers.*.url",
|
||||
"models.providers.*.type",
|
||||
"models.selected",
|
||||
"models.selectedByAgent.*",
|
||||
"models.selectedByChannel.*",
|
||||
"session.dmScope",
|
||||
"session.maintenance.mode",
|
||||
"secrets.defaults.provider",
|
||||
"secrets.providers.*.allowInsecureTransport",
|
||||
"secrets.providers.*.source",
|
||||
"tools.allow.*",
|
||||
"tools.alsoAllow.*",
|
||||
"tools.deny.*",
|
||||
"tools.elevated.allowFrom.*.*",
|
||||
"tools.elevated.enabled",
|
||||
"tools.exec.ask",
|
||||
"tools.exec.host",
|
||||
"tools.exec.security",
|
||||
"tools.fs.workspaceOnly",
|
||||
"tools.profile",
|
||||
"tools.sandbox.tools.allow.*",
|
||||
"tools.sandbox.tools.alsoAllow.*",
|
||||
"tools.sandbox.tools.deny.*",
|
||||
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
|
||||
"tools.web.fetch.ssrfPolicy.allowPrivateNetwork",
|
||||
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange",
|
||||
"tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork",
|
||||
"agents.defaults.memorySearch.enabled",
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory",
|
||||
"agents.defaults.memorySearch.sources.*",
|
||||
"agents.defaults.model.fallbacks.*",
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.models.*.alias",
|
||||
"agents.defaults.sandbox.backend",
|
||||
"agents.defaults.sandbox.browser.binds.*",
|
||||
"agents.defaults.sandbox.browser.cdpSourceRange",
|
||||
"agents.defaults.sandbox.docker.apparmorProfile",
|
||||
"agents.defaults.sandbox.docker.binds.*",
|
||||
"agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
|
||||
"agents.defaults.sandbox.docker.network",
|
||||
"agents.defaults.sandbox.docker.readOnlyRoot",
|
||||
"agents.defaults.sandbox.docker.seccompProfile",
|
||||
"agents.defaults.sandbox.mode",
|
||||
"agents.defaults.sandbox.workspaceAccess",
|
||||
"agents.defaults.tools.allow.*",
|
||||
"agents.defaults.tools.alsoAllow.*",
|
||||
"agents.defaults.tools.deny.*",
|
||||
"agents.defaults.tools.elevated.allowFrom.*.*",
|
||||
"agents.defaults.tools.elevated.enabled",
|
||||
"agents.defaults.tools.exec.ask",
|
||||
"agents.defaults.tools.exec.host",
|
||||
"agents.defaults.tools.exec.security",
|
||||
"agents.defaults.tools.fs.workspaceOnly",
|
||||
"agents.defaults.tools.profile",
|
||||
"agents.defaults.tools.sandbox.tools.allow.*",
|
||||
"agents.defaults.tools.sandbox.tools.alsoAllow.*",
|
||||
"agents.defaults.tools.sandbox.tools.deny.*",
|
||||
"agents.list.*.memorySearch.enabled",
|
||||
"agents.list.*.memorySearch.experimental.sessionMemory",
|
||||
"agents.list.*.memorySearch.sources.*",
|
||||
"agents.list.*.model.fallbacks.*",
|
||||
"agents.list.*.model.primary",
|
||||
"agents.list.*.models.*.alias",
|
||||
"agents.list.*.sandbox.backend",
|
||||
"agents.list.*.sandbox.browser.binds.*",
|
||||
"agents.list.*.sandbox.browser.cdpSourceRange",
|
||||
"agents.list.*.sandbox.docker.apparmorProfile",
|
||||
"agents.list.*.sandbox.docker.binds.*",
|
||||
"agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
|
||||
"agents.list.*.sandbox.docker.network",
|
||||
"agents.list.*.sandbox.docker.readOnlyRoot",
|
||||
"agents.list.*.sandbox.docker.seccompProfile",
|
||||
"agents.list.*.sandbox.mode",
|
||||
"agents.list.*.sandbox.workspaceAccess",
|
||||
"agents.list.*.tools.allow.*",
|
||||
"agents.list.*.tools.alsoAllow.*",
|
||||
"agents.list.*.tools.deny.*",
|
||||
"agents.list.*.tools.elevated.allowFrom.*.*",
|
||||
"agents.list.*.tools.elevated.enabled",
|
||||
"agents.list.*.tools.exec.ask",
|
||||
"agents.list.*.tools.exec.host",
|
||||
"agents.list.*.tools.exec.security",
|
||||
"agents.list.*.tools.fs.workspaceOnly",
|
||||
"agents.list.*.tools.profile",
|
||||
"agents.list.*.tools.sandbox.tools.allow.*",
|
||||
"agents.list.*.tools.sandbox.tools.alsoAllow.*",
|
||||
"agents.list.*.tools.sandbox.tools.deny.*",
|
||||
],
|
||||
"classifications": [
|
||||
{
|
||||
"pattern": "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
|
||||
"status": "observed",
|
||||
"area": "network",
|
||||
"policy": "network.privateNetwork.allow",
|
||||
"reason": "Policy observes private-network browser SSRF posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "browser.ssrfPolicy.allowPrivateNetwork",
|
||||
"status": "observed",
|
||||
"area": "network",
|
||||
"policy": "network.privateNetwork.allow",
|
||||
"reason": "Policy observes the legacy browser private-network toggle.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork",
|
||||
"status": "observed",
|
||||
"area": "network",
|
||||
"policy": "network.privateNetwork.allow",
|
||||
"reason": "Policy observes private-network web-fetch SSRF posture.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "tools.web.fetch.ssrfPolicy.allowPrivateNetwork",
|
||||
"status": "observed",
|
||||
"area": "network",
|
||||
"policy": "network.privateNetwork.allow",
|
||||
"reason": "Policy observes the legacy web-fetch private-network toggle.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange",
|
||||
"status": "observed",
|
||||
"area": "network",
|
||||
"policy": "network.privateNetwork.allow",
|
||||
"reason": "Policy treats RFC 2544 benchmark ranges as private-network posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
|
||||
"status": "observed",
|
||||
"area": "network",
|
||||
"policy": "network.privateNetwork.allow",
|
||||
"reason": "Policy treats IPv6 unique-local ranges as private-network posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "session.dmScope",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.session.requireDmScope",
|
||||
"reason": "Policy observes direct-message session isolation scope.",
|
||||
},
|
||||
{
|
||||
"pattern": "logging.redactSensitive",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.sensitiveLogging.requireRedaction",
|
||||
"reason": "Policy observes sensitive log redaction posture.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "diagnostics.otel.captureContent",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.telemetry.denyContentCapture",
|
||||
"reason": "Policy observes telemetry content-capture posture.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "session.maintenance.mode",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.retention.requireSessionMaintenance",
|
||||
"reason": "Policy observes session maintenance enforcement posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "memory.qmd.sessions.enabled",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes QMD session-transcript indexing.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.defaults.memorySearch.enabled",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes default memory-search session indexing enablement.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.defaults.memorySearch.experimental.sessionMemory",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes default memory-search session-memory toggle.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.defaults.memorySearch.sources.*",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes whether default memory-search sources include sessions.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.memorySearch.enabled",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes per-agent memory-search session indexing enablement.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.memorySearch.experimental.sessionMemory",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes per-agent memory-search session-memory toggle.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.memorySearch.sources.*",
|
||||
"status": "observed",
|
||||
"area": "dataHandling",
|
||||
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
"reason": "Policy observes whether per-agent memory-search sources include sessions.",
|
||||
},
|
||||
{
|
||||
"pattern": "auth.profiles.*.mode",
|
||||
"status": "observed",
|
||||
"area": "auth",
|
||||
"policy": "auth.profiles.allowModes",
|
||||
"reason": "Policy observes configured auth profile mode metadata.",
|
||||
},
|
||||
{
|
||||
"pattern": "auth.profiles.*.provider",
|
||||
"status": "observed",
|
||||
"area": "auth",
|
||||
"policy": "auth.profiles.requireMetadata",
|
||||
"reason": "Policy observes configured auth profile provider metadata.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.enabled",
|
||||
"status": "observed",
|
||||
"area": "channels",
|
||||
"policy": "channels.denyRules",
|
||||
"reason": "Provider deny rules only apply to enabled configured channels.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.accounts.*.dmPolicy",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.channels.allowDmPolicies",
|
||||
"reason": "Policy observes account-level direct-message access posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.dmPolicy",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.channels.allowDmPolicies",
|
||||
"reason": "Policy observes channel-level direct-message access posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.accounts.*.groupPolicy",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.channels.denyOpenGroups",
|
||||
"reason": "Policy observes account-level group access posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.groupPolicy",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.channels.denyOpenGroups",
|
||||
"reason": "Policy observes channel-level group access posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.accounts.*.groups.*.requireMention",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.channels.requireMentionInGroups",
|
||||
"reason": "Policy observes account group mention gates.",
|
||||
},
|
||||
{
|
||||
"pattern": "channels.*.groups.*.requireMention",
|
||||
"status": "observed",
|
||||
"area": "ingress",
|
||||
"policy": "ingress.channels.requireMentionInGroups",
|
||||
"reason": "Policy observes channel group mention gates.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.bind",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.exposure.allowNonLoopbackBind",
|
||||
"reason": "Policy observes Gateway bind exposure posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.customBindHost",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.exposure.allowNonLoopbackBind",
|
||||
"reason": "Policy observes custom bind host exposure posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.tailscale.mode",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.exposure.allowTailscaleFunnel",
|
||||
"reason": "Policy observes Tailscale serve/funnel mode when deriving Gateway exposure posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.tailscale.preserveFunnel",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.exposure.allowTailscaleFunnel",
|
||||
"reason": "Policy observes preserveFunnel because serve mode can preserve Funnel exposure.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.auth.mode",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.auth.requireAuth",
|
||||
"reason": "Policy observes Gateway auth mode posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.auth.rateLimit.*",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.auth.requireExplicitRateLimit",
|
||||
"reason": "Policy observes whether Gateway auth rate limiting is explicitly configured.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.controlUi.allowInsecureAuth",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.controlUi.allowInsecure",
|
||||
"reason": "Policy observes the Control UI insecure auth toggle.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.controlUi.dangerouslyDisableDeviceAuth",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.controlUi.allowInsecure",
|
||||
"reason": "Policy observes the Control UI device-auth disable toggle.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.controlUi.allowInsecure",
|
||||
"reason": "Policy observes the Control UI Host-header origin fallback toggle.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.mode",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.remote.allow",
|
||||
"reason": "Policy observes whether Gateway remote mode is enabled.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.remote.enabled",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.remote.allow",
|
||||
"reason": "Policy observes explicit remote Gateway enablement.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.http.endpoints.*.enabled",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.http.denyEndpoints",
|
||||
"reason": "Policy observes Gateway HTTP endpoint enablement.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.http.endpoints.*.*.allowUrl",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.http.requireUrlAllowlists",
|
||||
"reason": "Policy observes URL-fetch enablement on Gateway HTTP inputs.",
|
||||
},
|
||||
{
|
||||
"pattern": "gateway.http.endpoints.*.*.urlAllowlist.*",
|
||||
"status": "observed",
|
||||
"area": "gateway",
|
||||
"policy": "gateway.http.requireUrlAllowlists",
|
||||
"reason": "Policy observes URL-fetch allowlists on Gateway HTTP inputs.",
|
||||
},
|
||||
{
|
||||
"pattern": "mcp.servers.*.command",
|
||||
"status": "observed",
|
||||
"area": "mcp",
|
||||
"policy": "mcp.servers.allow / mcp.servers.deny",
|
||||
"reason": "Policy observes configured MCP server ids and command posture context.",
|
||||
},
|
||||
{
|
||||
"pattern": "mcp.servers.*.transport",
|
||||
"status": "observed",
|
||||
"area": "mcp",
|
||||
"policy": "mcp.servers.allow / mcp.servers.deny",
|
||||
"reason": "Policy observes configured MCP server transport posture context.",
|
||||
},
|
||||
{
|
||||
"pattern": "mcp.servers.*.url",
|
||||
"status": "observed",
|
||||
"area": "mcp",
|
||||
"policy": "mcp.servers.allow / mcp.servers.deny",
|
||||
"reason": "Policy observes configured MCP server URL posture context.",
|
||||
},
|
||||
{
|
||||
"pattern": "models.providers.*.type",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes configured provider ids.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "models.selected",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes selected model refs.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "models.selectedByAgent.*",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes agent-specific selected model refs.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "models.selectedByChannel.*",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes channel-specific selected model refs.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "agents.defaults.model.**",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes default agent model refs.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.defaults.models.*.alias",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes default agent model aliases.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.model.**",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes per-agent model refs.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.models.*.alias",
|
||||
"status": "observed",
|
||||
"area": "models",
|
||||
"policy": "models.providers.allow / models.providers.deny",
|
||||
"reason": "Policy observes per-agent model aliases.",
|
||||
},
|
||||
{
|
||||
"pattern": "secrets.defaults.provider",
|
||||
"status": "observed",
|
||||
"area": "secrets",
|
||||
"policy": "secrets.requireManagedProviders",
|
||||
"reason": "Policy observes default SecretRef provider provenance.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "secrets.providers.*.source",
|
||||
"status": "observed",
|
||||
"area": "secrets",
|
||||
"policy": "secrets.denySources",
|
||||
"reason": "Policy observes configured secret provider source type.",
|
||||
},
|
||||
{
|
||||
"pattern": "secrets.providers.*.allowInsecureTransport",
|
||||
"status": "observed",
|
||||
"area": "secrets",
|
||||
"policy": "secrets.allowInsecureProviders",
|
||||
"reason": "Policy observes insecure secret-provider transport posture.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "tools.profile",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.profiles.allow",
|
||||
"reason": "Policy observes global tool profile posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.fs.workspaceOnly",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.fs.requireWorkspaceOnly",
|
||||
"reason": "Policy observes global filesystem workspace-only posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.exec.security",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.exec.allowSecurity",
|
||||
"reason": "Policy observes global exec security posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.exec.ask",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.exec.requireAsk",
|
||||
"reason": "Policy observes global exec approval posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.exec.host",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.exec.allowHosts",
|
||||
"reason": "Policy observes global exec host routing posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.elevated.enabled",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.elevated.allow",
|
||||
"reason": "Policy observes global elevated tool posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.elevated.allowFrom.*.*",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.elevated.allow",
|
||||
"reason": "Policy observes global elevated provider allowlists.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.allow.*",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tool posture evidence",
|
||||
"reason": "Policy includes global tool allow posture in evidence for attestation drift.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.alsoAllow.*",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.alsoAllow.expected",
|
||||
"reason": "Policy observes global tools.alsoAllow posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.deny.*",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.denyTools",
|
||||
"reason": "Policy observes global tool deny posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "tools.sandbox.tools.*.*",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.denyTools",
|
||||
"reason": "Policy observes global sandbox tool posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.tools.**",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.* scoped by agentIds",
|
||||
"reason": "Policy observes default and per-agent tool posture overrides.",
|
||||
"allowNoSchemaPath": true,
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.tools.**",
|
||||
"status": "observed",
|
||||
"area": "tools",
|
||||
"policy": "tools.* scoped by agentIds",
|
||||
"reason": "Policy observes per-agent tool posture overrides.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.mode",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.requireMode",
|
||||
"reason": "Policy observes sandbox mode posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.mode",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.requireMode",
|
||||
"reason": "Policy observes per-agent sandbox mode posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.backend",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.allowBackends",
|
||||
"reason": "Policy observes sandbox backend posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.backend",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.allowBackends",
|
||||
"reason": "Policy observes per-agent sandbox backend posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.workspaceAccess",
|
||||
"status": "observed",
|
||||
"area": "agents",
|
||||
"policy": "agents.workspace.allowedAccess",
|
||||
"reason": "Policy observes sandbox workspace access posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.workspaceAccess",
|
||||
"status": "observed",
|
||||
"area": "agents",
|
||||
"policy": "agents.workspace.allowedAccess",
|
||||
"reason": "Policy observes per-agent sandbox workspace access posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.docker.network",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin",
|
||||
"reason": "Policy observes Docker container network posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.docker.network",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin",
|
||||
"reason": "Policy observes per-agent Docker container network posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.docker.binds.*",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts",
|
||||
"reason": "Policy observes Docker bind mount posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.docker.binds.*",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts",
|
||||
"reason": "Policy observes per-agent Docker bind mount posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.browser.binds.*",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.requireReadOnlyMounts",
|
||||
"reason": "Policy observes sandbox browser bind mount posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.browser.binds.*",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.requireReadOnlyMounts",
|
||||
"reason": "Policy observes per-agent sandbox browser bind mount posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.docker.apparmorProfile",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyUnconfinedProfiles",
|
||||
"reason": "Policy observes Docker AppArmor profile posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.docker.apparmorProfile",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyUnconfinedProfiles",
|
||||
"reason": "Policy observes per-agent Docker AppArmor profile posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.docker.seccompProfile",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyUnconfinedProfiles",
|
||||
"reason": "Policy observes Docker seccomp profile posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.docker.seccompProfile",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyUnconfinedProfiles",
|
||||
"reason": "Policy observes per-agent Docker seccomp profile posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyContainerNamespaceJoin",
|
||||
"reason": "Policy observes explicit Docker namespace-join escape posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.denyContainerNamespaceJoin",
|
||||
"reason": "Policy observes explicit per-agent Docker namespace-join escape posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.docker.readOnlyRoot",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.requireReadOnlyMounts",
|
||||
"reason": "Policy observes Docker read-only root posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.docker.readOnlyRoot",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.containers.requireReadOnlyMounts",
|
||||
"reason": "Policy observes per-agent Docker read-only root posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.*.sandbox.browser.cdpSourceRange",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.browser.requireCdpSourceRange",
|
||||
"reason": "Policy observes sandbox browser CDP source range posture.",
|
||||
},
|
||||
{
|
||||
"pattern": "agents.list.*.sandbox.browser.cdpSourceRange",
|
||||
"status": "observed",
|
||||
"area": "sandbox",
|
||||
"policy": "sandbox.browser.requireCdpSourceRange",
|
||||
"reason": "Policy observes per-agent sandbox browser CDP source range posture.",
|
||||
},
|
||||
],
|
||||
}
|
||||
362
scripts/native-app-i18n.ts
Normal file
362
scripts/native-app-i18n.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export type NativeI18nSurface = "android" | "apple";
|
||||
|
||||
export const NATIVE_I18N_LOCALES = [
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"pt-BR",
|
||||
"de",
|
||||
"es",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"fr",
|
||||
"hi",
|
||||
"ar",
|
||||
"it",
|
||||
"tr",
|
||||
"uk",
|
||||
"id",
|
||||
"pl",
|
||||
"th",
|
||||
"vi",
|
||||
"nl",
|
||||
"fa",
|
||||
"ru",
|
||||
] as const;
|
||||
|
||||
export type NativeI18nEntry = {
|
||||
id: string;
|
||||
kind: string;
|
||||
line: number;
|
||||
path: string;
|
||||
source: string;
|
||||
surface: NativeI18nSurface;
|
||||
};
|
||||
|
||||
type Candidate = Omit<NativeI18nEntry, "id">;
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "..");
|
||||
const OUTPUT_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
|
||||
const SOURCE_ROOTS: Record<NativeI18nSurface, string[]> = {
|
||||
android: [path.join(ROOT, "apps", "android", "app", "src", "main")],
|
||||
apple: [
|
||||
path.join(ROOT, "apps", "ios"),
|
||||
path.join(ROOT, "apps", "macos", "Sources"),
|
||||
path.join(ROOT, "apps", "shared", "OpenClawKit", "Sources"),
|
||||
],
|
||||
};
|
||||
|
||||
const ANDROID_EXTENSIONS = new Set([".kt", ".kts"]);
|
||||
const APPLE_EXTENSIONS = new Set([".swift", ".plist"]);
|
||||
const APPLE_UI_CALLS =
|
||||
/(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const APPLE_MODIFIER_CALLS =
|
||||
/\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_CALLS =
|
||||
/\b(?:Text|OutlinedTextField|BasicTextField|Button|IconButton|TopAppBar|Snackbar|AlertDialog)\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_PROPERTIES =
|
||||
/\b(?:contentDescription|label|placeholder|title|message|supportingText)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_WRAPPER_ARGS =
|
||||
/\b[A-Z][A-Za-z0-9_]*\s*\([^)\n]{0,160}?\b(?:text|title|label|message|contentDescription|placeholder)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_TOAST_ARGS =
|
||||
/\b(?:Toast\.makeText|Snackbar\.make)\s*\([^,\n]*,\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_DIALOG_CALLS =
|
||||
/\.(?:setTitle|setMessage|setPositiveButton|setNegativeButton|setNeutralButton)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_STATE_CALLS = /\b(?:MutableStateFlow|StateFlow|flowOf)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const CONDITIONAL_BRANCHES = [
|
||||
/\bif\s*\([^)]*\)\s*"((?:\\.|[^"\\])*)"\s*else\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
/\?\s*"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
];
|
||||
const ANDROID_RESOURCE_STRINGS = /<string\b[^>]*>([\s\S]*?)<\/string>/gu;
|
||||
const APPLE_NAMED_ARGUMENTS =
|
||||
/\b(?:title|subtitle|label|message|text|prompt|description|help)\s*:\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const APPLE_PLIST_STRINGS = /<string>([\s\S]*?)<\/string>/gu;
|
||||
const GENERATED_PATH_RE = /(?:^|[\\/])(?:build|\.gradle|\.build|DerivedData)(?:$|[\\/])/u;
|
||||
const EXCLUDED_PATH_RE = /(?:^|[\\/])(?:Tests?|UITests?|test|Preview(?:s)?)(?:$|[\\/])/u;
|
||||
const EXCLUDED_FILE_RE = /(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u;
|
||||
const BUILD_SETTING_RE = /\$\([A-Za-z0-9_.-]+\)/gu;
|
||||
|
||||
function isTranslatableCandidate(source: string, kind: string): boolean {
|
||||
if (BUILD_SETTING_RE.test(source)) {
|
||||
BUILD_SETTING_RE.lastIndex = 0;
|
||||
return false;
|
||||
}
|
||||
BUILD_SETTING_RE.lastIndex = 0;
|
||||
if (/^[a-z0-9_.:/$-]+$/u.test(source) || /^[A-Z0-9_.:/$-]+$/u.test(source)) {
|
||||
return false;
|
||||
}
|
||||
if (/[{}[\]]/u.test(source) && !/(?:\\\(|\$\{)/u.test(source)) {
|
||||
return false;
|
||||
}
|
||||
return kind !== "plist-string" || /\s/u.test(source);
|
||||
}
|
||||
|
||||
function extractSwiftInterpolations(source: string): string[] | null {
|
||||
const values: string[] = [];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source[index] !== "\\" || source[index + 1] !== "(") continue;
|
||||
const start = index;
|
||||
let depth = 1;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (index += 2; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') quoted = !quoted;
|
||||
else if (!quoted && character === "(") depth += 1;
|
||||
else if (!quoted && character === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
values.push(source.slice(start, index + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function extractKotlinInterpolations(source: string): string[] | null {
|
||||
const values = [...source.matchAll(/\$[A-Za-z_][A-Za-z0-9_]*/gu)].map((match) => match[0]);
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source[index] !== "$" || source[index + 1] !== "{") continue;
|
||||
const start = index;
|
||||
let depth = 1;
|
||||
for (index += 2; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
values.push(source.slice(start, index + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function lineNumber(source: string, offset: number): number {
|
||||
return source.slice(0, offset).split("\n").length;
|
||||
}
|
||||
|
||||
function decodeLiteral(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSource(source: string): string {
|
||||
return source;
|
||||
}
|
||||
|
||||
function addCandidate(
|
||||
entries: Candidate[],
|
||||
surface: NativeI18nSurface,
|
||||
repoPath: string,
|
||||
source: string,
|
||||
kind: string,
|
||||
line: number,
|
||||
) {
|
||||
const normalized = normalizeSource(decodeLiteral(source));
|
||||
if (!normalized.trim() || !/\p{L}/u.test(normalized)) {
|
||||
return;
|
||||
}
|
||||
if (!isTranslatableCandidate(normalized, kind)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
normalized.length > 500 ||
|
||||
extractSwiftInterpolations(normalized) === null ||
|
||||
extractKotlinInterpolations(normalized) === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
entries.push({ kind, line, path: repoPath, source: normalized, surface });
|
||||
}
|
||||
|
||||
function extractCandidates(
|
||||
surface: NativeI18nSurface,
|
||||
repoPath: string,
|
||||
source: string,
|
||||
): Candidate[] {
|
||||
const entries: Candidate[] = [];
|
||||
const patterns =
|
||||
surface === "apple"
|
||||
? [
|
||||
[APPLE_UI_CALLS, "ui-call"],
|
||||
[APPLE_MODIFIER_CALLS, "ui-modifier"],
|
||||
[APPLE_NAMED_ARGUMENTS, "ui-named-argument"],
|
||||
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
|
||||
]
|
||||
: [
|
||||
[ANDROID_CALLS, "ui-call"],
|
||||
[ANDROID_PROPERTIES, "ui-property"],
|
||||
[ANDROID_WRAPPER_ARGS, "ui-wrapper-argument"],
|
||||
[ANDROID_TOAST_ARGS, "ui-toast"],
|
||||
[ANDROID_DIALOG_CALLS, "ui-dialog"],
|
||||
[ANDROID_STATE_CALLS, "ui-state"],
|
||||
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
|
||||
];
|
||||
for (const [pattern, kind] of patterns) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const offset = match.index ?? 0;
|
||||
for (const value of match.slice(1)) {
|
||||
if (value) {
|
||||
addCandidate(entries, surface, repoPath, value, kind, lineNumber(source, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (surface === "android" && repoPath.endsWith("/res/values/strings.xml")) {
|
||||
for (const match of source.matchAll(ANDROID_RESOURCE_STRINGS)) {
|
||||
if (match[1])
|
||||
addCandidate(
|
||||
entries,
|
||||
surface,
|
||||
repoPath,
|
||||
match[1],
|
||||
"resource-string",
|
||||
lineNumber(source, match.index ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (surface === "apple" && repoPath.endsWith(".plist")) {
|
||||
for (const match of source.matchAll(APPLE_PLIST_STRINGS)) {
|
||||
if (match[1])
|
||||
addCandidate(
|
||||
entries,
|
||||
surface,
|
||||
repoPath,
|
||||
match[1],
|
||||
"plist-string",
|
||||
lineNumber(source, match.index ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function walkFiles(
|
||||
root: string,
|
||||
surface: NativeI18nSurface,
|
||||
out: string[] = [],
|
||||
): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
await walkFiles(fullPath, surface, out);
|
||||
continue;
|
||||
}
|
||||
const extension = path.extname(entry.name);
|
||||
const allowed =
|
||||
surface === "apple"
|
||||
? APPLE_EXTENSIONS
|
||||
: fullPath.endsWith(`${path.sep}res${path.sep}values${path.sep}strings.xml`)
|
||||
? new Set([...ANDROID_EXTENSIONS, ".xml"])
|
||||
: ANDROID_EXTENSIONS;
|
||||
if (entry.isFile() && allowed.has(extension) && !EXCLUDED_FILE_RE.test(entry.name)) {
|
||||
out.push(fullPath);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function withIds(entries: Candidate[]): NativeI18nEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const unique = [
|
||||
...new Map(
|
||||
entries.map((entry) => [`${entry.surface}\u0000${entry.path}\u0000${entry.source}`, entry]),
|
||||
).values(),
|
||||
];
|
||||
return unique
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
left.surface.localeCompare(right.surface) ||
|
||||
left.path.localeCompare(right.path) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.source.localeCompare(right.source),
|
||||
)
|
||||
.map((entry) => {
|
||||
const digest = createHash("sha256")
|
||||
.update([entry.surface, entry.path, entry.kind, entry.source].join("\u0000"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
let id = `native.${entry.surface}.${digest}`;
|
||||
if (seen.has(id)) {
|
||||
id = `${id}.${entry.line}`;
|
||||
}
|
||||
seen.add(id);
|
||||
return { ...entry, id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
|
||||
const entries: Candidate[] = [];
|
||||
for (const surface of ["android", "apple"] as const) {
|
||||
for (const sourceRoot of SOURCE_ROOTS[surface]) {
|
||||
const files = await walkFiles(sourceRoot, surface);
|
||||
for (const filePath of files.toSorted()) {
|
||||
const source = await readFile(filePath, "utf8");
|
||||
const repoPath = path.relative(ROOT, filePath).split(path.sep).join("/");
|
||||
entries.push(...extractCandidates(surface, repoPath, source));
|
||||
}
|
||||
}
|
||||
}
|
||||
return withIds(entries);
|
||||
}
|
||||
|
||||
function render(entries: NativeI18nEntry[]): string {
|
||||
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export async function syncNativeI18n(options: { checkOnly: boolean; write: boolean }) {
|
||||
const expected = render(await collectNativeI18nEntries());
|
||||
let current = "";
|
||||
try {
|
||||
current = await readFile(OUTPUT_PATH, "utf8");
|
||||
} catch {
|
||||
// The first sync creates the inventory.
|
||||
}
|
||||
if (current !== expected && options.checkOnly) {
|
||||
throw new Error(
|
||||
"native app i18n inventory drift detected. Run `pnpm native:i18n:sync` and commit apps/.i18n/native-source.json.",
|
||||
);
|
||||
}
|
||||
if (current !== expected && options.write) {
|
||||
await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
|
||||
await writeFile(OUTPUT_PATH, expected, "utf8");
|
||||
}
|
||||
const count = JSON.parse(expected).entries.length as number;
|
||||
process.stdout.write(`native-app-i18n: entries=${count} changed=${current !== expected}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command] = process.argv.slice(2);
|
||||
if (command !== "check" && command !== "sync") {
|
||||
throw new Error("usage: node --import tsx scripts/native-app-i18n.ts check|sync [--write]");
|
||||
}
|
||||
await syncNativeI18n({
|
||||
checkOnly: command === "check",
|
||||
write: command === "sync" && process.argv.includes("--write"),
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
|
||||
await main();
|
||||
}
|
||||
@@ -163,7 +163,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
|
||||
"channel-pairing-paths": 1,
|
||||
"channel-policy": 8,
|
||||
"channel-route": 5,
|
||||
"session-store-runtime": 2,
|
||||
"session-store-runtime": 1,
|
||||
"session-transcript-runtime": 1,
|
||||
"group-access": 13,
|
||||
"media-generation-runtime-shared": 3,
|
||||
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10392),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10388),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5214),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3247,
|
||||
|
||||
@@ -746,6 +746,8 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
|
||||
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
|
||||
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
|
||||
["scripts/native-app-i18n.ts", ["test/scripts/native-app-i18n.test.ts"]],
|
||||
["scripts/android-app-i18n.ts", ["test/scripts/android-app-i18n.test.ts"]],
|
||||
[
|
||||
"scripts/copy-bundled-plugin-metadata.mjs",
|
||||
["src/plugins/copy-bundled-plugin-metadata.test.ts", "src/infra/run-node.test.ts"],
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectDeliveredMediaUrls } from "./delivery-evidence.js";
|
||||
|
||||
describe("collectDeliveredMediaUrls attachment recursion", () => {
|
||||
it("collects media URLs across nested attachments", () => {
|
||||
const urls = collectDeliveredMediaUrls({
|
||||
payloads: [
|
||||
{
|
||||
url: "https://example.com/root.png",
|
||||
attachments: [
|
||||
{ mediaUrl: "https://example.com/child.png" },
|
||||
{ attachments: [{ filePath: "/tmp/grandchild.jpg" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(urls.toSorted()).toEqual([
|
||||
"/tmp/grandchild.jpg",
|
||||
"https://example.com/child.png",
|
||||
"https://example.com/root.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not overflow the stack on a self-referential attachments cycle", () => {
|
||||
// Payloads arrive as in-process `unknown` objects; a malformed self-referential
|
||||
// attachments chain previously recursed until the stack overflowed.
|
||||
const cyclic: Record<string, unknown> = { url: "https://example.com/loop.png" };
|
||||
cyclic.attachments = [cyclic];
|
||||
|
||||
let urls: string[] = [];
|
||||
expect(() => {
|
||||
urls = collectDeliveredMediaUrls({ payloads: [cyclic] });
|
||||
}).not.toThrow();
|
||||
expect(urls).toEqual(["https://example.com/loop.png"]);
|
||||
});
|
||||
|
||||
it("does not overflow on a mutual attachments cycle", () => {
|
||||
const a: Record<string, unknown> = { mediaUrl: "https://example.com/a.png" };
|
||||
const b: Record<string, unknown> = { mediaUrl: "https://example.com/b.png" };
|
||||
a.attachments = [b];
|
||||
b.attachments = [a];
|
||||
|
||||
const urls = collectDeliveredMediaUrls({ payloads: [a] });
|
||||
expect(urls.toSorted()).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
|
||||
});
|
||||
});
|
||||
@@ -80,19 +80,7 @@ function collectStringValues(value: unknown, output: Set<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
function collectMediaUrlsFromRecord(
|
||||
record: Record<string, unknown>,
|
||||
output: Set<string>,
|
||||
// Payloads arrive as in-process `unknown` objects, so a malformed
|
||||
// self-referential `attachments` chain would recurse until the stack
|
||||
// overflows. Track visited records to bound the descent, matching
|
||||
// redactStringsDeep in embedded-agent-subscribe.tools.ts.
|
||||
seen = new WeakSet<object>(),
|
||||
) {
|
||||
if (seen.has(record)) {
|
||||
return;
|
||||
}
|
||||
seen.add(record);
|
||||
function collectMediaUrlsFromRecord(record: Record<string, unknown>, output: Set<string>) {
|
||||
collectStringValues(record.mediaUrl, output);
|
||||
collectStringValues(record.mediaUrls, output);
|
||||
collectStringValues(record.path, output);
|
||||
@@ -102,7 +90,7 @@ function collectMediaUrlsFromRecord(
|
||||
if (Array.isArray(attachments)) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment && typeof attachment === "object" && !Array.isArray(attachment)) {
|
||||
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output, seen);
|
||||
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,37 +1130,6 @@ describe("buildGuardedModelFetch", () => {
|
||||
expect(items).toEqual([{ ok: true }]);
|
||||
});
|
||||
|
||||
it("handles a large transport chunk containing many valid small SSE events", async () => {
|
||||
// Regression: one TCP read can deliver >64 KiB of already-delimited SSE
|
||||
// events; the cap must apply only to the unterminated tail, not the full chunk.
|
||||
const eventCount = 5_000;
|
||||
const manyEvents = `data: ${JSON.stringify({ ok: true })}\n\n`.repeat(eventCount);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(manyEvents, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const model = {
|
||||
id: "gpt-5.4",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const response = await buildGuardedModelFetch(model)(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{ method: "POST" },
|
||||
);
|
||||
const items: unknown[] = [];
|
||||
for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
|
||||
items.push(item);
|
||||
}
|
||||
expect(items.length).toBe(eventCount);
|
||||
expect(items[0]).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("synthesizes SSE frames for JSON bodies returned to streaming OpenAI SDK requests", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(' {"ok": true} ', {
|
||||
@@ -1369,102 +1338,6 @@ describe("buildGuardedModelFetch", () => {
|
||||
expect(refreshTimeout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("errors on oversized SSE body without event boundary in sanitizer", async () => {
|
||||
const oversized = "x".repeat(65 * 1024);
|
||||
const encoder = new TextEncoder();
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const model = {
|
||||
id: "gpt-5.4",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const response = await buildGuardedModelFetch(model)(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader!.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeTruthy();
|
||||
expect(String(caught)).toMatch(/exceeded max buffer size/i);
|
||||
});
|
||||
|
||||
it("errors on oversized streaming JSON body without content-length in SSE synthesis", async () => {
|
||||
const CHUNK = 1024 * 1024;
|
||||
let sends = 0;
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sends < 17) {
|
||||
sends++;
|
||||
controller.enqueue(new Uint8Array(CHUNK));
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const model = {
|
||||
id: "moonshotai/kimi-k2.6",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const response = await buildGuardedModelFetch(model)(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "moonshotai/kimi-k2.6", stream: true }),
|
||||
},
|
||||
);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader!.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeTruthy();
|
||||
expect(String(caught)).toMatch(/exceeded.*bytes while synthesizing SSE/i);
|
||||
});
|
||||
|
||||
describe("long retry-after handling", () => {
|
||||
const anthropicModel = {
|
||||
id: "sonnet-4.6",
|
||||
|
||||
@@ -45,17 +45,6 @@ import {
|
||||
const DEFAULT_MAX_SDK_RETRY_WAIT_SECONDS = 60;
|
||||
const OPENAI_SDK_STREAM_CONTENT_SNIFF_BYTES = 2 * 1024;
|
||||
const log = createSubsystemLogger("provider-transport-fetch");
|
||||
|
||||
/** Max bytes for an entire JSON body synthesized into SSE frames. Prevents OOM
|
||||
* when a hostile streaming endpoint returns a never-ending JSON response
|
||||
* without Content-Length. */
|
||||
const SSE_SYNTHESIZE_JSON_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Max bytes for the internal SSE sanitization buffer between event boundaries.
|
||||
* A response that cannot find a \n\n boundary within this many characters is
|
||||
* almost certainly hostile or broken — cap the buffer rather than let it grow. */
|
||||
const SSE_SANITIZE_BUFFER_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]);
|
||||
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
|
||||
const RETRY_AFTER_HTTP_DATE_RE =
|
||||
@@ -113,7 +102,6 @@ function sanitizeOpenAISdkSseResponse(
|
||||
const encoder = new TextEncoder();
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
let buffer = "";
|
||||
let totalBytes = 0;
|
||||
const sseBody = new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
reader = source.getReader();
|
||||
@@ -132,17 +120,9 @@ function sanitizeOpenAISdkSseResponse(
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const nextTotalBytes = totalBytes + chunk.value.byteLength;
|
||||
if (nextTotalBytes > SSE_SYNTHESIZE_JSON_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Streaming JSON body exceeded ${SSE_SYNTHESIZE_JSON_MAX_BYTES} bytes while synthesizing SSE frames`,
|
||||
);
|
||||
}
|
||||
totalBytes = nextTotalBytes;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
} catch (error) {
|
||||
await reader?.cancel(error).catch(() => {});
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
@@ -177,11 +157,6 @@ function sanitizeOpenAISdkSseResponse(
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
if (buffer.length > SSE_SANITIZE_BUFFER_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`SSE response exceeded max buffer size (${SSE_SANITIZE_BUFFER_MAX_BYTES} bytes) without event boundary`,
|
||||
);
|
||||
}
|
||||
return enqueued;
|
||||
}
|
||||
const block = buffer.slice(0, boundary.index);
|
||||
@@ -192,7 +167,6 @@ function sanitizeOpenAISdkSseResponse(
|
||||
if (hasReadableSseData(block)) {
|
||||
controller.enqueue(encoder.encode(`${block}${separator}`));
|
||||
enqueued += 1;
|
||||
return enqueued;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -204,10 +178,6 @@ function sanitizeOpenAISdkSseResponse(
|
||||
async pull(controller) {
|
||||
try {
|
||||
for (;;) {
|
||||
const pending = enqueueSanitized(controller, "");
|
||||
if (pending > 0) {
|
||||
return;
|
||||
}
|
||||
const chunk = await reader?.read();
|
||||
if (!chunk || chunk.done) {
|
||||
const tail = decoder.decode();
|
||||
@@ -230,7 +200,6 @@ function sanitizeOpenAISdkSseResponse(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await reader?.cancel(error).catch(() => {});
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Bounded SSE / NDJSON stream reader guard.
|
||||
*
|
||||
* Wraps a `ReadableStreamDefaultReader<Uint8Array>` so the caller's existing
|
||||
* chunk-by-chunk parsing logic is unchanged, but accumulated bytes are tracked
|
||||
* against a hard cap. On overflow the underlying reader is cancelled and a
|
||||
* canonical error is thrown. Mirrors the `readResponseWithLimit` / bounded
|
||||
* JSON response pattern (see `src/agents/provider-http-errors.ts`).
|
||||
*
|
||||
* Internal helper for now. If extensions need it, promote to a plugin-SDK
|
||||
* subpath in a separate, dedicated PR with full SDK metadata sync.
|
||||
*/
|
||||
|
||||
export type SseStreamOverflow = {
|
||||
size: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
export type ReadSseStreamWithLimitOptions = {
|
||||
maxBytes: number;
|
||||
onOverflow?: (params: SseStreamOverflow) => Error;
|
||||
};
|
||||
|
||||
export type SseByteGuard = {
|
||||
read(): Promise<ReadableStreamReadResult<Uint8Array>>;
|
||||
cancel(reason?: unknown): Promise<void>;
|
||||
totalBytes(): number;
|
||||
overflowed(): boolean;
|
||||
cancelled(): boolean;
|
||||
};
|
||||
|
||||
export function createSseByteGuard(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
opts: ReadSseStreamWithLimitOptions,
|
||||
): SseByteGuard {
|
||||
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes < 0) {
|
||||
throw new RangeError(`maxBytes must be a non-negative finite number: ${opts.maxBytes}`);
|
||||
}
|
||||
const onOverflow =
|
||||
opts.onOverflow ??
|
||||
((params) =>
|
||||
new Error(`SSE stream exceeds ${params.maxBytes} bytes (received ${params.size})`));
|
||||
let total = 0;
|
||||
let overflowedFlag = false;
|
||||
let cancelledFlag = false;
|
||||
return {
|
||||
read: async () => {
|
||||
if (overflowedFlag || cancelledFlag) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
const chunkLen = result.value?.byteLength ?? 0;
|
||||
const next = total + chunkLen;
|
||||
if (next > opts.maxBytes) {
|
||||
overflowedFlag = true;
|
||||
cancelledFlag = true;
|
||||
const err = onOverflow({ size: next, maxBytes: opts.maxBytes });
|
||||
try {
|
||||
await reader.cancel(err);
|
||||
} catch {
|
||||
// best-effort cancellation; caller observes the overflow error
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
total = next;
|
||||
return result;
|
||||
},
|
||||
cancel: async (reason?: unknown) => {
|
||||
if (overflowedFlag) {
|
||||
// overflow already set cancelledFlag; do not overwrite
|
||||
return;
|
||||
}
|
||||
cancelledFlag = true;
|
||||
try {
|
||||
await reader.cancel(reason);
|
||||
} catch {
|
||||
// best-effort cancellation
|
||||
}
|
||||
},
|
||||
totalBytes: () => total,
|
||||
overflowed: () => overflowedFlag,
|
||||
cancelled: () => cancelledFlag,
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Regression coverage for surrogate-safe truncation in compact tool display
|
||||
* detail coercion (coerceDisplayValue, reached via resolveToolVerbAndDetailForArgs
|
||||
* -> resolveDetailFromKeys).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveToolVerbAndDetailForArgs } from "./tool-display-common.js";
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
||||
}
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
||||
}
|
||||
function hasLoneSurrogate(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const codeUnit = value.charCodeAt(i);
|
||||
if (isHighSurrogate(codeUnit)) {
|
||||
if (i + 1 >= value.length || !isLowSurrogate(value.charCodeAt(i + 1))) {
|
||||
return true;
|
||||
}
|
||||
} else if (isLowSurrogate(codeUnit)) {
|
||||
if (i === 0 || !isHighSurrogate(value.charCodeAt(i - 1))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("coerceDisplayValue surrogate-safe truncation", () => {
|
||||
it("does not split an emoji across the truncation boundary (default maxStringChars=160)", () => {
|
||||
// 200 UTF-16 units: 78 'a', an emoji (surrogate pair at indices 78-79), 120 'b'.
|
||||
// With maxStringChars=160, half = floor(159/2) = 79, so the naive
|
||||
// firstLine.slice(0, 79) keeps only the emoji's high surrogate at index 78.
|
||||
const detailValue = `${"a".repeat(78)}\u{1F600}${"b".repeat(120)}`;
|
||||
expect(detailValue.length).toBe(200);
|
||||
|
||||
const { detail } = resolveToolVerbAndDetailForArgs({
|
||||
toolKey: "custom_tool",
|
||||
args: { note: detailValue },
|
||||
fallbackDetailKeys: ["note"],
|
||||
detailMode: "first",
|
||||
});
|
||||
|
||||
expect(detail).toBeDefined();
|
||||
// The bug rendered a lone high surrogate (and possibly a lone low surrogate
|
||||
// at the tail head); the fix must drop the whole emoji at the cut.
|
||||
expect(hasLoneSurrogate(detail as string)).toBe(false);
|
||||
// Head keeps only the 78 leading 'a's (emoji dropped, not half-kept).
|
||||
expect((detail as string).split("…")[0]).toBe("a".repeat(78));
|
||||
// Tail must not begin mid-pair on a lone low surrogate.
|
||||
const tail = (detail as string).split("…")[1] ?? "";
|
||||
expect(isLowSurrogate(tail.charCodeAt(0))).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves plain (non-surrogate) long values truncated as before", () => {
|
||||
const detailValue = "x".repeat(300);
|
||||
|
||||
const { detail } = resolveToolVerbAndDetailForArgs({
|
||||
toolKey: "custom_tool",
|
||||
args: { note: detailValue },
|
||||
fallbackDetailKeys: ["note"],
|
||||
detailMode: "first",
|
||||
});
|
||||
|
||||
// Behavior-preserving for ASCII: half = 79, so 79 'x' + ellipsis + 80 'x'.
|
||||
expect(detail).toBe(`${"x".repeat(79)}…${"x".repeat(80)}`);
|
||||
expect(hasLoneSurrogate(detail as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns short values unchanged", () => {
|
||||
const { detail } = resolveToolVerbAndDetailForArgs({
|
||||
toolKey: "custom_tool",
|
||||
args: { note: "short value with no emoji" },
|
||||
fallbackDetailKeys: ["note"],
|
||||
detailMode: "first",
|
||||
});
|
||||
expect(detail).toBe("short value with no emoji");
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,15 @@
|
||||
* Redacts and summarizes arguments into short labels/details for chat and UI
|
||||
* tool update streams.
|
||||
*/
|
||||
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
asOptionalObjectRecord as asRecord,
|
||||
} from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { parseStrictFiniteNumber } from "../infra/parse-finite-number.js";
|
||||
import { redactToolPayloadText } from "../logging/redact.js";
|
||||
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
|
||||
import { resolveExecDetail, type ToolDetailMode } from "./tool-display-exec.js";
|
||||
|
||||
type ToolDisplayActionSpec = {
|
||||
@@ -135,7 +136,7 @@ function coerceDisplayValue(
|
||||
const firstLine = redactToolPayloadText(rawLine);
|
||||
if (firstLine.length > maxStringChars) {
|
||||
const half = Math.floor((maxStringChars - 1) / 2);
|
||||
return `${sliceUtf16Safe(firstLine, 0, half)}…${sliceUtf16Safe(firstLine, -(maxStringChars - 1 - half))}`;
|
||||
return `${firstLine.slice(0, half)}…${firstLine.slice(-(maxStringChars - 1 - half))}`;
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { redactToolPayloadText } from "../logging/redact.js";
|
||||
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
|
||||
import {
|
||||
binaryName,
|
||||
firstPositional,
|
||||
@@ -443,7 +442,7 @@ function compactRawCommand(raw: string, maxLength = 120): string {
|
||||
return oneLine;
|
||||
}
|
||||
const half = Math.floor((maxLength - 1) / 2);
|
||||
return `${sliceUtf16Safe(oneLine, 0, half)}…${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`;
|
||||
return `${oneLine.slice(0, half)}…${oneLine.slice(-(maxLength - 1 - half))}`;
|
||||
}
|
||||
|
||||
export type ToolDetailMode = "explain" | "raw";
|
||||
|
||||
@@ -562,28 +562,6 @@ describe("compactRawCommand middle truncation", () => {
|
||||
expect(result).not.toContain("AKIDABCDEFGHIJKLMNOP1234567890");
|
||||
expect(result).toContain("AKIDAB…7890");
|
||||
});
|
||||
|
||||
it("does not split a surrogate pair when the head boundary lands on an emoji", () => {
|
||||
// The one-line form is 140 UTF-16 units. With the default maxLength=120 the head
|
||||
// slice ends at index 59, but the 😀 emoji (U+1F600, a surrogate pair) occupies
|
||||
// indices 58-59 — so a raw .slice(0, 59) would keep the high surrogate and drop
|
||||
// its low half, leaving a lone surrogate that renders as the replacement char.
|
||||
const emoji = String.fromCodePoint(0x1f600);
|
||||
// Unknown binary so resolveExecDetail returns the compact raw form directly.
|
||||
const longCommand = `/opt/custom/bin/run ${"a".repeat(38)}${emoji}${"b".repeat(80)}`;
|
||||
const result = resolveExecDetail({ command: longCommand });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// The whole emoji is dropped at the boundary rather than half of it.
|
||||
expect(result).not.toContain(emoji);
|
||||
// No dangling/lone surrogate code units remain in the rendered detail.
|
||||
expect(result).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
|
||||
expect(result).not.toMatch(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
|
||||
// Start and end of the command are still preserved around the ellipsis.
|
||||
expect(result).toContain("/opt/custom/bin/run");
|
||||
expect(result).toContain("…");
|
||||
expect(result).toMatch(/b{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceDisplayValue middle truncation", () => {
|
||||
|
||||
@@ -735,69 +735,6 @@ describe("message tool secret scoping", () => {
|
||||
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.telegram.botToken"]);
|
||||
});
|
||||
|
||||
it("preserves empty opaque target segments in inferred session delivery", async () => {
|
||||
mockSendResult();
|
||||
|
||||
const input = await executeSend({
|
||||
action: { message: "hi" },
|
||||
toolOptions: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "webchat",
|
||||
agentSessionKey: "agent:main:telegram:group:room::part",
|
||||
},
|
||||
});
|
||||
|
||||
expect(input?.toolContext?.currentChannelProvider).toBe("telegram");
|
||||
expect(input?.toolContext?.currentChannelId).toBe("room::part");
|
||||
});
|
||||
|
||||
it("does not infer delivery from empty structural session segments", async () => {
|
||||
mockSendResult();
|
||||
|
||||
const input = await executeSend({
|
||||
action: { message: "hi" },
|
||||
toolOptions: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "webchat",
|
||||
agentSessionKey: "agent:main:telegram::group:room",
|
||||
},
|
||||
});
|
||||
|
||||
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
||||
expect(input?.toolContext?.currentChannelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not infer delivery from a nested opaque agent identity", async () => {
|
||||
mockSendResult();
|
||||
|
||||
const input = await executeSend({
|
||||
action: { message: "hi" },
|
||||
toolOptions: {
|
||||
config: {} as never,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "webchat",
|
||||
agentSessionKey: "agent:voice:agent:channel:room",
|
||||
},
|
||||
});
|
||||
|
||||
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
||||
expect(input?.toolContext?.currentChannelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves direct session keys as explicit user targets when ambient channel drifted to webchat", async () => {
|
||||
mockSendResult({ channel: "discord", to: "user:123456789" });
|
||||
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
|
||||
import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
|
||||
import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js";
|
||||
import {
|
||||
normalizeAccountId,
|
||||
parseAgentSessionKey,
|
||||
parseThreadSessionSuffix,
|
||||
} from "../../routing/session-key.js";
|
||||
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
@@ -859,6 +863,7 @@ type InferredSessionDelivery = {
|
||||
to: string;
|
||||
};
|
||||
|
||||
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
|
||||
const USER_PREFIXED_DIRECT_TARGET_CHANNELS = new Set(["discord", "mattermost", "msteams", "slack"]);
|
||||
|
||||
function formatSessionDeliveryTarget(channel: string, peerKind: string, to: string): string {
|
||||
@@ -871,21 +876,44 @@ function formatSessionDeliveryTarget(channel: string, peerKind: string, to: stri
|
||||
function inferDeliveryFromSessionKey(
|
||||
sessionKey: string | undefined,
|
||||
): InferredSessionDelivery | null {
|
||||
const route = parseSessionDeliveryRoute(sessionKey);
|
||||
if (!route) {
|
||||
const parsedThread = parseThreadSessionSuffix(sessionKey);
|
||||
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
|
||||
const parsed = parseAgentSessionKey(baseSessionKey);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const channel = normalizeMessageChannel(route.channel);
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const channel = normalizeMessageChannel(parts[0]);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined;
|
||||
return {
|
||||
accountId,
|
||||
channel,
|
||||
threadId: route.threadId,
|
||||
to: formatSessionDeliveryTarget(channel, route.peerKind, route.peerId),
|
||||
};
|
||||
if (parts.length >= 4 && (parts[2] === "direct" || parts[2] === "dm")) {
|
||||
const accountId = resolveAgentAccountId(parts[1]);
|
||||
const to = parts.slice(3).join(":").trim();
|
||||
return to
|
||||
? {
|
||||
accountId,
|
||||
channel,
|
||||
threadId: parsedThread.threadId,
|
||||
to: formatSessionDeliveryTarget(channel, parts[2], to),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
const peerKind = parts[1] ?? "";
|
||||
if (SESSION_DELIVERY_PEER_KINDS.has(peerKind)) {
|
||||
const to = parts.slice(2).join(":").trim();
|
||||
return to
|
||||
? {
|
||||
channel,
|
||||
threadId: parsedThread.threadId,
|
||||
to: formatSessionDeliveryTarget(channel, peerKind, to),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): {
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
// Nodes CLI plugin registration tests cover node command plugin registration.
|
||||
// Built-in node command registration runs for real so the guard is exercised against the actual
|
||||
// registered subcommand names; only the plugin-loader boundary is stubbed.
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
|
||||
const registerPluginCliCommandsFromValidatedConfig = vi.fn(async () => ({}));
|
||||
const registerNodesCameraCommands = vi.fn();
|
||||
const registerNodesInvokeCommands = vi.fn();
|
||||
const registerNodesLocationCommands = vi.fn();
|
||||
const registerNodesNotifyCommand = vi.fn();
|
||||
const registerNodesPairingCommands = vi.fn();
|
||||
const registerNodesPushCommand = vi.fn();
|
||||
const registerNodesScreenCommands = vi.fn();
|
||||
const registerNodesStatusCommands = vi.fn();
|
||||
|
||||
vi.mock("../plugins/cli.js", () => ({
|
||||
registerPluginCliCommandsFromValidatedConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./nodes-cli/register.camera.js", () => ({ registerNodesCameraCommands }));
|
||||
vi.mock("./nodes-cli/register.invoke.js", () => ({ registerNodesInvokeCommands }));
|
||||
vi.mock("./nodes-cli/register.location.js", () => ({ registerNodesLocationCommands }));
|
||||
vi.mock("./nodes-cli/register.notify.js", () => ({ registerNodesNotifyCommand }));
|
||||
vi.mock("./nodes-cli/register.pairing.js", () => ({ registerNodesPairingCommands }));
|
||||
vi.mock("./nodes-cli/register.push.js", () => ({ registerNodesPushCommand }));
|
||||
vi.mock("./nodes-cli/register.screen.js", () => ({ registerNodesScreenCommands }));
|
||||
vi.mock("./nodes-cli/register.status.js", () => ({ registerNodesStatusCommands }));
|
||||
|
||||
const { registerNodesCli } = await import("./nodes-cli/register.js");
|
||||
|
||||
describe("registerNodesCli plugin registration", () => {
|
||||
@@ -35,29 +50,14 @@ describe("registerNodesCli plugin registration", () => {
|
||||
return program;
|
||||
}
|
||||
|
||||
it("skips plugin CLI/runtime registration for built-in nodes subcommands", async () => {
|
||||
for (const subcommand of ["status", "list", "describe", "invoke", "pending", "camera"]) {
|
||||
registerPluginCliCommandsFromValidatedConfig.mockClear();
|
||||
await registerWithArgv(["node", "openclaw", "nodes", subcommand, "--json"]);
|
||||
expect(registerPluginCliCommandsFromValidatedConfig).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("registers plugin-provided node subcommands lazily and routes their logs to stderr", async () => {
|
||||
it("routes plugin registration logs to stderr for nodes --json commands", async () => {
|
||||
let forceStderrDuringRegistration = false;
|
||||
registerPluginCliCommandsFromValidatedConfig.mockImplementationOnce(async () => {
|
||||
forceStderrDuringRegistration = loggingState.forceConsoleToStderr;
|
||||
return {};
|
||||
});
|
||||
|
||||
const program = await registerWithArgv([
|
||||
"node",
|
||||
"openclaw",
|
||||
"nodes",
|
||||
"canvas",
|
||||
"snapshot",
|
||||
"--json",
|
||||
]);
|
||||
const program = await registerWithArgv(["node", "openclaw", "nodes", "list", "--json"]);
|
||||
|
||||
expect(registerPluginCliCommandsFromValidatedConfig).toHaveBeenCalledWith(
|
||||
program,
|
||||
@@ -69,16 +69,6 @@ describe("registerNodesCli plugin registration", () => {
|
||||
expect(loggingState.forceConsoleToStderr).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces plugin subcommands for bare `nodes` listing", async () => {
|
||||
const program = await registerWithArgv(["node", "openclaw", "nodes"]);
|
||||
expect(registerPluginCliCommandsFromValidatedConfig).toHaveBeenCalledWith(
|
||||
program,
|
||||
undefined,
|
||||
undefined,
|
||||
{ mode: "lazy", primary: "nodes" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not route pass-through --json after the terminator", async () => {
|
||||
let forceStderrDuringRegistration = true;
|
||||
registerPluginCliCommandsFromValidatedConfig.mockImplementationOnce(async () => {
|
||||
@@ -86,7 +76,7 @@ describe("registerNodesCli plugin registration", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
await registerWithArgv(["node", "openclaw", "nodes", "canvas", "--", "--json"]);
|
||||
await registerWithArgv(["node", "openclaw", "nodes", "invoke", "--", "--json"]);
|
||||
|
||||
expect(forceStderrDuringRegistration).toBe(false);
|
||||
expect(loggingState.forceConsoleToStderr).toBe(false);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { resolveCliArgvInvocation } from "../argv-invocation.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
import { withConsoleLogsRoutedToStderrForJson } from "../json-output-mode.js";
|
||||
import { registerNodesCameraCommands } from "./register.camera.js";
|
||||
@@ -43,13 +42,6 @@ export async function registerNodesCli(program: Command, argv: readonly string[]
|
||||
registerNodesScreenCommands(nodes);
|
||||
registerNodesLocationCommands(nodes);
|
||||
|
||||
// Built-in `nodes` subcommands (status/list/pairing/invoke/...) must stay on the lightweight
|
||||
// path: loading plugin CLI/runtime to resolve them only adds startup cost. Plugin-provided node
|
||||
// subcommands (e.g. `nodes canvas`) are not registered above, so only pay the plugin load when
|
||||
// the invoked subcommand is not already a built-in.
|
||||
if (!shouldRegisterNodesPluginCommands(nodes, argv)) {
|
||||
return;
|
||||
}
|
||||
const { registerPluginCliCommandsFromValidatedConfig } = await import("../../plugins/cli.js");
|
||||
await withConsoleLogsRoutedToStderrForJson(
|
||||
argv,
|
||||
@@ -60,19 +52,3 @@ export async function registerNodesCli(program: Command, argv: readonly string[]
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin node subcommands are only resolved when the invocation is not a built-in nodes command. */
|
||||
function shouldRegisterNodesPluginCommands(nodes: Command, argv: readonly string[]): boolean {
|
||||
const { commandPath } = resolveCliArgvInvocation([...argv]);
|
||||
if (commandPath[0] !== "nodes") {
|
||||
// Eager registration (root help/completion) needs the full command tree, plugins included.
|
||||
return true;
|
||||
}
|
||||
const requestedSubcommand = commandPath[1];
|
||||
if (!requestedSubcommand) {
|
||||
// Bare `openclaw nodes` listing should still surface plugin-provided subcommands.
|
||||
return true;
|
||||
}
|
||||
const builtInSubcommands = new Set(nodes.commands.map((command) => command.name()));
|
||||
return !builtInSubcommands.has(requestedSubcommand);
|
||||
}
|
||||
|
||||
@@ -144,12 +144,6 @@ const autoMigrateLegacyState = vi.fn().mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}) as unknown as MockFn;
|
||||
const autoMigrateLegacyPluginDoctorState = vi.fn().mockResolvedValue({
|
||||
migrated: false,
|
||||
skipped: false,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}) as unknown as MockFn;
|
||||
const autoMigrateLegacyTaskStateSidecars = vi.fn().mockResolvedValue({
|
||||
migrated: false,
|
||||
skipped: false,
|
||||
@@ -215,13 +209,6 @@ function createLegacyStateMigrationDetectionResult(params?: {
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: params?.hasLegacySessions ?? false,
|
||||
legacyKeys: [],
|
||||
preserveAmbiguousKeys: false,
|
||||
preserveForeignMainAliases: false,
|
||||
targetStoreAliases: {
|
||||
hasDistinctAliases: false,
|
||||
hasFinalSymlink: false,
|
||||
hasUnresolvedIdentity: false,
|
||||
},
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: "/tmp/state/agent",
|
||||
@@ -528,7 +515,6 @@ vi.mock("./onboard-helpers.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./doctor-state-migrations.js", () => ({
|
||||
autoMigrateLegacyPluginDoctorState,
|
||||
autoMigrateLegacyState,
|
||||
autoMigrateLegacyStateDir,
|
||||
autoMigrateLegacyTaskStateSidecars,
|
||||
|
||||
@@ -217,19 +217,11 @@ function isMainScopeStaleDirectSessionKey(params: {
|
||||
if (!parsed || normalizeAgentId(parsed.agentId) !== normalizeAgentId(params.targetAgentId)) {
|
||||
return false;
|
||||
}
|
||||
const parts = parsed.rest.split(":");
|
||||
// A nested agent wrapper is opaque plugin identity, never a stale DM route.
|
||||
if (parts[0] === "agent") {
|
||||
return false;
|
||||
}
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
return (
|
||||
(parts.length === 2 && parts[0] === "direct" && Boolean(parts[1])) ||
|
||||
(parts.length === 3 && Boolean(parts[0]) && parts[1] === "direct" && Boolean(parts[2])) ||
|
||||
(parts.length === 4 &&
|
||||
Boolean(parts[0]) &&
|
||||
Boolean(parts[1]) &&
|
||||
parts[2] === "direct" &&
|
||||
Boolean(parts[3]))
|
||||
(parts.length === 2 && parts[0] === "direct") ||
|
||||
(parts.length === 3 && parts[1] === "direct") ||
|
||||
(parts.length === 4 && parts[2] === "direct")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,29 +36,4 @@ describe("resolveGroupSessionKey", () => {
|
||||
chatType: "group",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves empty opaque segments in originating group ids", () => {
|
||||
const ctx = {
|
||||
Provider: "matrix",
|
||||
ChatType: "channel",
|
||||
From: "matrix:channel:!room:[2001:db8::1]",
|
||||
} satisfies Partial<MsgContext>;
|
||||
|
||||
expect(resolveGroupSessionKey(ctx as MsgContext)).toEqual({
|
||||
key: "matrix:channel:!room:[2001:db8::1]",
|
||||
channel: "matrix",
|
||||
id: "!room:[2001:db8::1]",
|
||||
chatType: "channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty structural group-route segments", () => {
|
||||
const ctx = {
|
||||
Provider: "telegram",
|
||||
ChatType: "group",
|
||||
From: "telegram::group:room",
|
||||
} satisfies Partial<MsgContext>;
|
||||
|
||||
expect(resolveGroupSessionKey(ctx as MsgContext)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,6 @@ function normalizeGroupLabel(raw?: string) {
|
||||
return normalizeHyphenSlug(raw);
|
||||
}
|
||||
|
||||
function joinOpaqueTail(parts: string[], start: number): string | null {
|
||||
return normalizeOptionalString(parts[start]) ? parts.slice(start).join(":") : null;
|
||||
}
|
||||
|
||||
function resolveOriginatingGroupTargetId(params: {
|
||||
ctx: MsgContext;
|
||||
provider: string;
|
||||
@@ -47,7 +43,7 @@ function resolveOriginatingGroupTargetId(params: {
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const parts = target.split(":");
|
||||
const parts = target.split(":").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,13 +54,13 @@ function resolveOriginatingGroupTargetId(params: {
|
||||
const second = normalizeOptionalLowercaseString(parts[1]);
|
||||
const secondIsKind = second === "group" || second === "channel";
|
||||
if (secondIsKind && (head === params.provider || getGroupSurfaces().has(head))) {
|
||||
return joinOpaqueTail(parts, 2);
|
||||
return parts.slice(2).join(":") || null;
|
||||
}
|
||||
if (head === params.provider || head === "chat" || head === "room" || head === "group") {
|
||||
return joinOpaqueTail(parts, 1);
|
||||
return parts.slice(1).join(":") || null;
|
||||
}
|
||||
if (head === "channel") {
|
||||
return joinOpaqueTail(parts, 1);
|
||||
return parts.slice(1).join(":") || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -138,7 +134,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
|
||||
|
||||
const providerHint = normalizeOptionalLowercaseString(ctx.Provider);
|
||||
|
||||
const parts = from.split(":");
|
||||
const parts = from.split(":").filter(Boolean);
|
||||
const head = normalizeLowercaseStringOrEmpty(parts[0]);
|
||||
const headIsSurface = head ? getGroupSurfaces().has(head) : false;
|
||||
|
||||
@@ -168,12 +164,9 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
|
||||
? originatingGroupTargetId
|
||||
: headIsSurface
|
||||
? secondIsKind
|
||||
? joinOpaqueTail(parts, 2)
|
||||
: joinOpaqueTail(parts, 1)
|
||||
? parts.slice(2).join(":")
|
||||
: parts.slice(1).join(":")
|
||||
: from;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const finalId = normalizeSessionPeerId({ channel: provider, peerKind: kind, peerId: id });
|
||||
if (!finalId) {
|
||||
return null;
|
||||
|
||||
@@ -97,27 +97,6 @@ describe("session accessor file-backed seam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps case-distinct Matrix sessions separate under nested agent ownership", async () => {
|
||||
const mixedKey = "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org";
|
||||
const lowerKey = "agent:voice:agent:other:matrix:channel:!Roomabc:example.org";
|
||||
|
||||
await upsertSessionEntry(
|
||||
{ sessionKey: mixedKey, storePath },
|
||||
{ sessionId: "mixed-session", updatedAt: 10 },
|
||||
);
|
||||
await upsertSessionEntry(
|
||||
{ sessionKey: lowerKey, storePath },
|
||||
{ sessionId: "lower-session", updatedAt: 20 },
|
||||
);
|
||||
|
||||
expect(loadSessionEntry({ sessionKey: mixedKey, storePath })?.sessionId).toBe("mixed-session");
|
||||
expect(loadSessionEntry({ sessionKey: lowerKey, storePath })?.sessionId).toBe("lower-session");
|
||||
expect(listSessionEntries({ storePath }).map((entry) => entry.sessionKey)).toEqual([
|
||||
mixedKey,
|
||||
lowerKey,
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks abort targets while canonicalizing legacy session keys", async () => {
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
|
||||
@@ -597,10 +597,6 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
lastChannel: "telegram",
|
||||
lastTo: "6101296751",
|
||||
},
|
||||
"agent:main:telegram::direct:malformed": {
|
||||
sessionId: "malformed-session",
|
||||
updatedAt: now,
|
||||
},
|
||||
} satisfies Record<string, SessionEntry>,
|
||||
null,
|
||||
2,
|
||||
@@ -618,9 +614,8 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
|
||||
const preview = dryRun.previewResults[0];
|
||||
expect(preview?.summary.dmScopeRetired).toBe(1);
|
||||
expect(preview?.summary.afterCount).toBe(2);
|
||||
expect(preview?.summary.afterCount).toBe(1);
|
||||
expect(preview?.dmScopeRetiredKeys.has("agent:main:telegram:direct:6101296751")).toBe(true);
|
||||
expect(preview?.dmScopeRetiredKeys.has("agent:main:telegram::direct:malformed")).toBe(false);
|
||||
expect(preview?.summary.unreferencedArtifacts.removedFiles).toBe(0);
|
||||
await expectPathExists(directTranscript);
|
||||
});
|
||||
@@ -630,7 +625,6 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
|
||||
const now = Date.now();
|
||||
const directTranscript = path.join(testDir, "direct-session.jsonl");
|
||||
const nestedTranscript = path.join(testDir, "nested-agent-session.jsonl");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
@@ -646,11 +640,6 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
lastChannel: "telegram",
|
||||
lastTo: "6101296751",
|
||||
},
|
||||
"agent:main:agent:direct:customer": {
|
||||
sessionId: "nested-agent-session",
|
||||
updatedAt: now,
|
||||
sessionFile: nestedTranscript,
|
||||
},
|
||||
} satisfies Record<string, SessionEntry>,
|
||||
null,
|
||||
2,
|
||||
@@ -659,7 +648,6 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
);
|
||||
await fs.writeFile(path.join(testDir, "main-session.jsonl"), "main", "utf-8");
|
||||
await fs.writeFile(directTranscript, "direct", "utf-8");
|
||||
await fs.writeFile(nestedTranscript, "nested", "utf-8");
|
||||
|
||||
const applied = await runSessionsCleanup({
|
||||
cfg: { session: { dmScope: "main" } },
|
||||
@@ -670,10 +658,8 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
expect(applied.appliedSummaries[0]?.dmScopeRetired).toBe(1);
|
||||
const persisted = loadSessionStore(storePath, { skipCache: true });
|
||||
expect(persisted).toHaveProperty("agent:main:main");
|
||||
expect(persisted).toHaveProperty("agent:main:agent:direct:customer");
|
||||
expect(persisted["agent:main:telegram:direct:6101296751"]).toBeUndefined();
|
||||
await expectPathMissing(directTranscript);
|
||||
await expectPathExists(nestedTranscript);
|
||||
const files = await fs.readdir(testDir);
|
||||
const archivedDirectTranscripts = files.filter((name) =>
|
||||
name.startsWith("direct-session.jsonl.deleted."),
|
||||
|
||||
@@ -75,8 +75,7 @@ export function listConfiguredSessionStoreAgentIds(cfg: OpenClawConfig): string[
|
||||
for (const agentId of cfg.acp?.allowedAgents ?? []) {
|
||||
addAcpAgentId(agentId);
|
||||
}
|
||||
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const agent of configuredAgents) {
|
||||
for (const agent of cfg.agents?.list ?? []) {
|
||||
if (agent.runtime?.type === "acp") {
|
||||
addAcpAgentId(agent.runtime.acp?.agent ?? agent.id);
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getHealthCheck: vi.fn(),
|
||||
registerHealthCheck: vi.fn(),
|
||||
noteChromeMcpBrowserReadiness: vi.fn(),
|
||||
detectLegacyStateMigrations: vi.fn(),
|
||||
runLegacyStateMigrations: vi.fn(),
|
||||
detectLegacyClawdBrowserProfileResidue: vi.fn(),
|
||||
maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"),
|
||||
@@ -134,11 +132,6 @@ vi.mock("../commands/doctor-auth-legacy-oauth.js", () => ({
|
||||
maybeRepairLegacyOAuthProfileIds: mocks.maybeRepairLegacyOAuthProfileIds,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor-state-migrations.js", () => ({
|
||||
detectLegacyStateMigrations: mocks.detectLegacyStateMigrations,
|
||||
runLegacyStateMigrations: mocks.runLegacyStateMigrations,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor-auth-oauth-sidecar.js", () => ({
|
||||
maybeRepairLegacyOAuthSidecarProfiles: mocks.maybeRepairLegacyOAuthSidecarProfiles,
|
||||
}));
|
||||
@@ -386,10 +379,6 @@ describe("doctor health contributions", () => {
|
||||
mocks.registerHealthCheck.mockReset();
|
||||
mocks.noteChromeMcpBrowserReadiness.mockReset();
|
||||
mocks.noteChromeMcpBrowserReadiness.mockResolvedValue(undefined);
|
||||
mocks.detectLegacyStateMigrations.mockReset();
|
||||
mocks.detectLegacyStateMigrations.mockResolvedValue({ preview: [], warnings: [] });
|
||||
mocks.runLegacyStateMigrations.mockReset();
|
||||
mocks.runLegacyStateMigrations.mockResolvedValue({ changes: [], warnings: [] });
|
||||
mocks.detectLegacyClawdBrowserProfileResidue.mockReset();
|
||||
mocks.detectLegacyClawdBrowserProfileResidue.mockReturnValue(null);
|
||||
mocks.maybeArchiveLegacyClawdBrowserProfileResidue.mockReset();
|
||||
@@ -932,28 +921,6 @@ describe("doctor health contributions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the active config into legacy state migration", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:legacy-state");
|
||||
const cfg = { session: { store: "/tmp/shared-sessions.json" } };
|
||||
const detected = { preview: ["legacy sessions"], warnings: [] };
|
||||
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
|
||||
const ctx = {
|
||||
cfg,
|
||||
sourceConfigValid: true,
|
||||
prompter: buildDoctorPrompter(true),
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: { nonInteractive: true },
|
||||
} as unknown as Parameters<(typeof contribution)["run"]>[0];
|
||||
|
||||
await contribution.run(ctx);
|
||||
|
||||
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
|
||||
detected,
|
||||
config: cfg,
|
||||
recoverCorruptTargetStore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips Gateway health probes for exec SecretRefs unless allow-exec is set", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:gateway-health");
|
||||
mocks.gatewaySecretInputPathCanWin.mockImplementation(
|
||||
|
||||
@@ -534,7 +534,6 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
}
|
||||
const migrated = await runLegacyStateMigrations({
|
||||
detected: legacyState,
|
||||
config: ctx.cfg,
|
||||
recoverCorruptTargetStore: ctx.options.repair === true || ctx.options.yes === true,
|
||||
});
|
||||
if (migrated.changes.length > 0) {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Gateway startup session migration tests.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { runStartupSessionMigration } from "./server-startup-session-migration.js";
|
||||
|
||||
function makeLog() {
|
||||
@@ -29,47 +26,6 @@ function firstLogMessage(log: ReturnType<typeof vi.fn>, label: string): string {
|
||||
}
|
||||
|
||||
describe("runStartupSessionMigration", () => {
|
||||
it("discovers plugin-owned agents during direct gateway startup", async () => {
|
||||
await withTempDir({ prefix: "openclaw-startup-migration-" }, async (tempDir) => {
|
||||
const storeTemplate = path.join(tempDir, "stores", "{agentId}", "sessions.json");
|
||||
const voiceStorePath = path.join(tempDir, "stores", "voice", "sessions.json");
|
||||
fs.mkdirSync(path.dirname(voiceStorePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
voiceStorePath,
|
||||
JSON.stringify({
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1 },
|
||||
}),
|
||||
);
|
||||
const cfg = {
|
||||
session: { store: storeTemplate },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: { "voice-call": { config: { agentId: "voice" } } },
|
||||
},
|
||||
} as ReturnType<typeof makeCfg>;
|
||||
const log = makeLog();
|
||||
|
||||
await runStartupSessionMigration({
|
||||
cfg,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tempDir,
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
|
||||
OPENCLAW_STATE_DIR: path.join(tempDir, "state"),
|
||||
},
|
||||
log,
|
||||
});
|
||||
|
||||
const store = JSON.parse(fs.readFileSync(voiceStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId?: string }
|
||||
>;
|
||||
expect(store["agent:voice:voice:15550001111"]?.sessionId).toBe("legacy-voice");
|
||||
expect(store["voice:15550001111"]).toBeUndefined();
|
||||
expect(log.info).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("logs changes when orphaned keys are canonicalized", async () => {
|
||||
const log = makeLog();
|
||||
const migrate = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
|
||||
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { parseSessionDeliveryRoute } from "../../routing/session-key.js";
|
||||
import { parseAgentSessionKey, parseThreadSessionSuffix } from "../../routing/session-key.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
|
||||
import { isConfiguredChannel, listConfiguredMessageChannels } from "./channel-selection.js";
|
||||
@@ -19,13 +19,29 @@ type InternalSourceReplySinkInput = {
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
};
|
||||
|
||||
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
|
||||
|
||||
function hasExternalSessionDeliveryRoute(sessionKey: string | undefined): boolean {
|
||||
const route = parseSessionDeliveryRoute(sessionKey);
|
||||
if (!route) {
|
||||
const parsedThread = parseThreadSessionSuffix(sessionKey);
|
||||
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
|
||||
const parsed = parseAgentSessionKey(baseSessionKey);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const channel = normalizeMessageChannel(route.channel);
|
||||
return Boolean(channel && channel !== INTERNAL_MESSAGE_CHANNEL);
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const channel = normalizeMessageChannel(parts[0]);
|
||||
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return false;
|
||||
}
|
||||
if (parts.length >= 4 && (parts[2] === "direct" || parts[2] === "dm")) {
|
||||
return Boolean(parts.slice(3).join(":").trim());
|
||||
}
|
||||
return (
|
||||
SESSION_DELIVERY_PEER_KINDS.has(parts[1] ?? "") && Boolean(parts.slice(2).join(":").trim())
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitRouteParam(params: Record<string, unknown>): boolean {
|
||||
|
||||
@@ -143,27 +143,6 @@ describe("runMessageAction send validation", () => {
|
||||
expect(JSON.stringify(result.toolResult?.content)).not.toContain("hello from codex");
|
||||
});
|
||||
|
||||
it.each(["agent:voice:agent:channel:room", "agent:main:telegram::group:room"])(
|
||||
"keeps malformed session route %s on the internal source sink",
|
||||
async (sessionKey) => {
|
||||
const result = await runMessageAction({
|
||||
cfg: emptyConfig,
|
||||
action: "send",
|
||||
params: { message: "private reply" },
|
||||
toolContext: { currentChannelProvider: "webchat" },
|
||||
sessionKey,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: "send",
|
||||
channel: "webchat",
|
||||
to: "current-run",
|
||||
handledBy: "internal-source",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("uses non-webchat current source context as the message-tool-only send sink", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: emptyConfig,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Tests migration cleanup for orphaned state keys.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
@@ -89,15 +89,6 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
mainKey: "work",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sessionStoreTextMayNeedCanonicalization({
|
||||
raw: JSON.stringify({
|
||||
"agent:archive:main": { sessionId: "retired-main", updatedAt: 1 },
|
||||
}),
|
||||
storeAgentIds: ["main"],
|
||||
mainKey: "work",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sessionStoreTextMayNeedCanonicalization({
|
||||
raw: JSON.stringify({
|
||||
@@ -149,17 +140,6 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
mainKey: "work",
|
||||
}),
|
||||
).toBe(true);
|
||||
for (const malformedKey of ["agent::room", "agent:_bad:room"]) {
|
||||
expect(
|
||||
sessionStoreTextMayNeedCanonicalization({
|
||||
raw: JSON.stringify({
|
||||
[malformedKey]: { sessionId: "opaque", updatedAt: 1 },
|
||||
}),
|
||||
storeAgentIds: ["voice"],
|
||||
mainKey: "main",
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renames orphaned raw key to canonical form", async () => {
|
||||
@@ -178,397 +158,6 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes legacy voice sessions before canonical runtime access", async () => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
writeStore(storePath, {
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2_000 },
|
||||
"agent:main:voice:15550001111": { sessionId: "stale-canonical", updatedAt: 1_000 },
|
||||
});
|
||||
|
||||
await migrateFixtureState(stateDir, {} as OpenClawConfig);
|
||||
|
||||
const store = readStore(storePath);
|
||||
expect(requireStoreEntry(store, "agent:main:voice:15550001111").sessionId).toBe(
|
||||
"legacy-voice",
|
||||
);
|
||||
expect(store["voice:15550001111"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a blank session store as the default per-agent store", async () => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
writeStore(storePath, {
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
|
||||
});
|
||||
|
||||
const result = await migrateFixtureState(stateDir, {
|
||||
session: { store: "" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig);
|
||||
|
||||
const store = readStore(storePath);
|
||||
expect(requireStoreEntry(store, "agent:main:voice:15550001111").sessionId).toBe(
|
||||
"legacy-voice",
|
||||
);
|
||||
expect(store["voice:15550001111"]).toBeUndefined();
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates plugin-owned agents in templated session stores", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const storeTemplate = path.join(tmpDir, "stores", "{agentId}", "sessions.json");
|
||||
const voiceStorePath = path.join(tmpDir, "stores", "voice", "sessions.json");
|
||||
writeStore(voiceStorePath, {
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
|
||||
"agent:voice:metadata": { updatedAt: 1500, groupActivation: "always" },
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: storeTemplate },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateOrphanedSessionKeys({
|
||||
cfg,
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
});
|
||||
|
||||
const store = readStore(voiceStorePath);
|
||||
expect(requireStoreEntry(store, "agent:voice:voice:15550001111").sessionId).toBe(
|
||||
"legacy-voice",
|
||||
);
|
||||
expect(store["agent:voice:metadata"]).toEqual({
|
||||
updatedAt: 1500,
|
||||
groupActivation: "always",
|
||||
});
|
||||
expect(store["voice:15550001111"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scope: undefined, canonicalMainKey: "agent:voice:main" },
|
||||
{ scope: "global" as const, canonicalMainKey: "global" },
|
||||
])(
|
||||
"preserves opaque foreign main aliases in plugin-owned $scope stores",
|
||||
async ({ scope, canonicalMainKey }) => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const storeTemplate = path.join(tmpDir, "stores", "{agentId}", "sessions.json");
|
||||
const voiceStorePath = path.join(tmpDir, "stores", "voice", "sessions.json");
|
||||
writeStore(voiceStorePath, {
|
||||
"agent:main:main": { sessionId: "explicit-foreign", updatedAt: 3000 },
|
||||
[canonicalMainKey]: { sessionId: "voice-main", updatedAt: 1000 },
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: storeTemplate, scope },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(voiceStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("explicit-foreign");
|
||||
expect(requireStoreEntry(store, canonicalMainKey).sessionId).toBe("voice-main");
|
||||
expect(requireStoreEntry(store, "agent:voice:voice:15550001111").sessionId).toBe(
|
||||
"legacy-voice",
|
||||
);
|
||||
expect(store["voice:15550001111"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves foreign main aliases before global canonicalization in shared plugin stores", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"agent:main:main": { sessionId: "ambiguous-main", updatedAt: 2000 },
|
||||
global: { sessionId: "real-global", updatedAt: 1000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: sharedStorePath, scope: "global" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("ambiguous-main");
|
||||
expect(requireStoreEntry(store, "global").sessionId).toBe("real-global");
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("warns on custom main aliases in fixed plugin stores", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"agent:main:work": { sessionId: "ambiguous-main", updatedAt: 2000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: sharedStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("ambiguous-main");
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Preserved 1 ambiguous session key(s) in potentially shared store ${sharedStorePath}`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces configured and standard paths that alias one store", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const standardStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
|
||||
writeStore(standardStorePath, {
|
||||
"agent:voice::matrix:channel:!room:example.org": {
|
||||
sessionId: "malformed-owner",
|
||||
updatedAt: 2000,
|
||||
},
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1000 },
|
||||
"agent:voice:MixedCase": { sessionId: "scoped", updatedAt: 1000 },
|
||||
});
|
||||
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
|
||||
fs.linkSync(standardStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
session: { store: configuredStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
const rerun = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred migration of 2 ambiguous session key(s) in aliased store ${configuredStorePath}; remove filesystem aliases or configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
expect(rerun).toEqual(result);
|
||||
expect(
|
||||
requireStoreEntry(
|
||||
readStore(standardStorePath),
|
||||
"agent:voice::matrix:channel:!room:example.org",
|
||||
).sessionId,
|
||||
).toBe("malformed-owner");
|
||||
expect(
|
||||
requireStoreEntry(readStore(standardStorePath), "agent:voice:MixedCase").sessionId,
|
||||
).toBe("scoped");
|
||||
expect(
|
||||
readStore(standardStorePath)["agent:ops:agent:voice::matrix:channel:!room:example.org"],
|
||||
).toBeUndefined();
|
||||
expect(fs.statSync(configuredStorePath).ino).toBe(fs.statSync(standardStorePath).ino);
|
||||
});
|
||||
});
|
||||
|
||||
it("warns from a readable alias when the configured path identity is inaccessible", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
|
||||
writeStore(configuredStorePath, {});
|
||||
const standardStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
|
||||
writeStore(standardStorePath, {
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: configuredStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const realStatSync = fs.statSync.bind(fs);
|
||||
const statSpy = vi.spyOn(fs, "statSync").mockImplementation((candidate) => {
|
||||
if (path.resolve(candidate.toString()) === configuredStorePath) {
|
||||
throw Object.assign(new Error("inaccessible store"), { code: "EACCES" });
|
||||
}
|
||||
return realStatSync(candidate);
|
||||
});
|
||||
|
||||
let result: Awaited<ReturnType<typeof migrateOrphanedSessionKeys>>;
|
||||
try {
|
||||
result = await migrateOrphanedSessionKeys({
|
||||
cfg,
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
additionalAgentIds: ["voice"],
|
||||
});
|
||||
} finally {
|
||||
statSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred session key migration for ${standardStorePath}; filesystem identity could not be established for every configured store path. Restore path access or configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
expect(requireStoreEntry(readStore(standardStorePath), "voice:15550001111").sessionId).toBe(
|
||||
"legacy-voice",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("defers migration through a final-component store symlink", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const standardStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
|
||||
writeStore(standardStorePath, {
|
||||
"agent:voice::matrix:channel:!room:example.org": {
|
||||
sessionId: "malformed-owner",
|
||||
updatedAt: 2000,
|
||||
},
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1000 },
|
||||
});
|
||||
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
|
||||
fs.symlinkSync(standardStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
session: { store: configuredStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred migration of 2 ambiguous session key(s) in aliased store ${configuredStorePath}; remove filesystem aliases or configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
expect(fs.lstatSync(configuredStorePath).isSymbolicLink()).toBe(true);
|
||||
expect(
|
||||
requireStoreEntry(
|
||||
readStore(standardStorePath),
|
||||
"agent:voice::matrix:channel:!room:example.org",
|
||||
).sessionId,
|
||||
).toBe("malformed-owner");
|
||||
});
|
||||
});
|
||||
|
||||
it("defers a singleton final-component store symlink", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const outsideStorePath = path.join(tmpDir, "outside-sessions.json");
|
||||
writeStore(outsideStorePath, {
|
||||
"voice:15550001111": { sessionId: "outside-voice", updatedAt: 1000 },
|
||||
});
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
||||
fs.symlinkSync(outsideStorePath, storePath);
|
||||
|
||||
const result = await migrateFixtureState(stateDir, {} as OpenClawConfig);
|
||||
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred session key migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
expect(fs.lstatSync(storePath).isSymbolicLink()).toBe(true);
|
||||
expect(requireStoreEntry(readStore(outsideStorePath), "voice:15550001111").sessionId).toBe(
|
||||
"outside-voice",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("defers an unambiguous rewrite through a singleton final symlink", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const outsideStorePath = path.join(tmpDir, "outside-sessions.json");
|
||||
writeStore(outsideStorePath, {
|
||||
"agent:main:main": { sessionId: "outside-global", updatedAt: 1000 },
|
||||
});
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
||||
fs.symlinkSync(outsideStorePath, storePath);
|
||||
const cfg = { session: { scope: "global" } } as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred session key migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
expect(fs.lstatSync(storePath).isSymbolicLink()).toBe(true);
|
||||
expect(requireStoreEntry(readStore(outsideStorePath), "agent:main:main").sessionId).toBe(
|
||||
"outside-global",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("defers global main aliases across hard-linked store paths", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const standardStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
writeStore(standardStorePath, {
|
||||
"agent:main:main": { sessionId: "legacy-global", updatedAt: 1000 },
|
||||
});
|
||||
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
|
||||
fs.linkSync(standardStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
session: { scope: "global", store: configuredStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
for (const storePath of [configuredStorePath, standardStorePath]) {
|
||||
expect(requireStoreEntry(readStore(storePath), "agent:main:main").sessionId).toBe(
|
||||
"legacy-global",
|
||||
);
|
||||
expect(readStore(storePath).global).toBeUndefined();
|
||||
}
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred session key migration in aliased store ${configuredStorePath}; atomic replacement cannot update distinct filesystem aliases as one operation. Remove filesystem aliases or configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes main aliases in a fixed single-owner store", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
writeStore(storePath, {
|
||||
"agent:main:main": { sessionId: "legacy-main", updatedAt: 1000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: storePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(storePath);
|
||||
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("legacy-main");
|
||||
expect(store["agent:main:main"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("renames same-agent main aliases when mainKey changes", async () => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const storePath = opsSessionStorePath(stateDir);
|
||||
@@ -676,7 +265,7 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy default-main aliases in shared stores", async () => {
|
||||
it("preserves legitimate agent:main:* keys in shared stores with both main and non-main agents", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
// When session.store lacks {agentId}, all agents resolve to the same file.
|
||||
// The "main" agent's keys must not be remapped into the "ops" namespace.
|
||||
@@ -686,110 +275,21 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
|
||||
});
|
||||
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("main-session");
|
||||
expect(store["agent:main:work"]).toBeUndefined();
|
||||
// main agent's session is canonicalised to use configured mainKey ("work"),
|
||||
// but stays in the "main" agent namespace — NOT remapped into "ops".
|
||||
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session");
|
||||
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
// The key must NOT have been merged into ops namespace
|
||||
expect(
|
||||
Object.keys(store).reduce((count, k) => count + (k.startsWith("agent:ops:") ? 1 : 0), 0),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes global main aliases in shared stores", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
global: { sessionId: "stale-global", updatedAt: 1000 },
|
||||
main: { sessionId: "bare-main", updatedAt: 2000 },
|
||||
"agent:main:main": { sessionId: "legacy-main", updatedAt: 3000 },
|
||||
"agent:main:work": { sessionId: "fresh-main", updatedAt: 4000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { scope: "global", mainKey: "work", store: sharedStorePath },
|
||||
agents: { list: [{ id: "main" }, { id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "global").sessionId).toBe("fresh-main");
|
||||
expect(store.main).toBeUndefined();
|
||||
expect(store["agent:main:main"]).toBeUndefined();
|
||||
expect(store["agent:main:work"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not assign legacy default-main aliases among non-main shared owners", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"agent:main:main": { sessionId: "ambiguous-session", updatedAt: 2000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: sharedStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }, { id: "research" }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("ambiguous-session");
|
||||
expect(store["agent:ops:work"]).toBeUndefined();
|
||||
expect(store["agent:research:work"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes non-main shared rows within their declared owners", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"agent:ops:main": { sessionId: "ops-session", updatedAt: 1000 },
|
||||
"agent:research:main": { sessionId: "research-session", updatedAt: 2000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: sharedStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }, { id: "research" }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
|
||||
expect(requireStoreEntry(store, "agent:research:work").sessionId).toBe("research-session");
|
||||
expect(store["agent:ops:main"]).toBeUndefined();
|
||||
expect(store["agent:research:main"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes main aliases for unlisted shared-store owners", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"agent:archive:main": { sessionId: "archive-session", updatedAt: 1000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: sharedStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:archive:work").sessionId).toBe("archive-session");
|
||||
expect(store["agent:archive:main"]).toBeUndefined();
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves bare main aliases when a store has multiple possible owners", async () => {
|
||||
it("lets the main agent claim bare main aliases in shared stores", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
@@ -797,150 +297,12 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
|
||||
});
|
||||
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "main").sessionId).toBe("main-session");
|
||||
expect(store["agent:main:work"]).toBeUndefined();
|
||||
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session");
|
||||
expect(store.main).toBeUndefined();
|
||||
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not guess the owner of raw keys in shared multi-agent stores", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
|
||||
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
|
||||
});
|
||||
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "voice:15550001111").sessionId).toBe("legacy-voice");
|
||||
expect(store["agent:main:voice:15550001111"]).toBeUndefined();
|
||||
expect(store["agent:ops:voice:15550001111"]).toBeUndefined();
|
||||
expect(result.warnings).toContain(
|
||||
`Preserved 1 ambiguous session key(s) in potentially shared store ${sharedStorePath}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves distinct ambiguous keys that differ only by surrounding whitespace", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
"voice:shared": { sessionId: "first-session", updatedAt: 1000 },
|
||||
" voice:shared ": { sessionId: "second-session", updatedAt: 2000 },
|
||||
});
|
||||
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "voice:shared").sessionId).toBe("first-session");
|
||||
expect(requireStoreEntry(store, " voice:shared ").sessionId).toBe("second-session");
|
||||
expect(result.changes).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves prototype-shaped keys when another shared-store row migrates", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
const source = Object.create(null) as Record<string, unknown>;
|
||||
Object.defineProperty(source, "__proto__", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: { sessionId: "prototype-session", updatedAt: 1000 },
|
||||
writable: true,
|
||||
});
|
||||
source["agent:ops:main"] = { sessionId: "ops-session", updatedAt: 2000 };
|
||||
writeStore(sharedStorePath, source);
|
||||
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(Object.hasOwn(store, "__proto__")).toBe(true);
|
||||
expect(requireStoreEntry(store, "__proto__").sessionId).toBe("prototype-session");
|
||||
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves mixed-case main aliases in a shared store", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
MAIN: { sessionId: "main-session", updatedAt: 2000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: sharedStorePath },
|
||||
agents: { list: [{ id: "main", default: true }, { id: "ops" }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const first = await migrateFixtureState(stateDir, cfg);
|
||||
const second = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "MAIN").sessionId).toBe("main-session");
|
||||
expect(store["agent:main:main"]).toBeUndefined();
|
||||
expect(first.changes).toHaveLength(0);
|
||||
expect(first.warnings).toHaveLength(1);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes raw keys in fixed custom stores with one configured agent", async () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const fixedStorePath = path.join(tmpDir, "custom-sessions.json");
|
||||
const discoveredOpsStorePath = opsSessionStorePath(stateDir);
|
||||
writeStore(fixedStorePath, {
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
|
||||
});
|
||||
writeStore(discoveredOpsStorePath, {
|
||||
"voice:15550002222": { sessionId: "ops-voice", updatedAt: 2000 },
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: fixedStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const first = await migrateFixtureState(stateDir, cfg);
|
||||
const second = await migrateFixtureState(stateDir, cfg);
|
||||
|
||||
const store = readStore(fixedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:voice:15550001111").sessionId).toBe(
|
||||
"legacy-voice",
|
||||
);
|
||||
expect(store["voice:15550001111"]).toBeUndefined();
|
||||
const opsStore = readStore(discoveredOpsStorePath);
|
||||
expect(requireStoreEntry(opsStore, "agent:ops:voice:15550002222").sessionId).toBe(
|
||||
"ops-voice",
|
||||
);
|
||||
expect(opsStore["voice:15550002222"]).toBeUndefined();
|
||||
expect(first.changes).toHaveLength(2);
|
||||
expect(first.warnings).toHaveLength(0);
|
||||
expect(second).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes mixed-case scoped main aliases on the first run", async () => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const storePath = opsSessionStorePath(stateDir);
|
||||
writeStore(storePath, {
|
||||
"Agent:OPS:MAIN": { sessionId: "ops-session", updatedAt: 2000 },
|
||||
});
|
||||
|
||||
const first = await migrateFixtureState(stateDir);
|
||||
const second = await migrateFixtureState(stateDir);
|
||||
|
||||
const store = readStore(storePath);
|
||||
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
|
||||
expect(store["Agent:OPS:MAIN"]).toBeUndefined();
|
||||
expect(first.changes).toHaveLength(1);
|
||||
expect(second).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import * as sessionStore from "../config/sessions.js";
|
||||
import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
@@ -108,14 +106,10 @@ vi.mock("../channels/plugins/bundled.js", () => {
|
||||
listBundledChannelLegacySessionSurfaces: vi.fn(() => [
|
||||
{
|
||||
isLegacyGroupSessionKey: (key: string) => /^group:mobile-/i.test(key.trim()),
|
||||
canonicalizeLegacySessionKey: ({ key, agentId }: { key: string; agentId: string }) => {
|
||||
if (key === "legacy-prototype") {
|
||||
return "__proto__";
|
||||
}
|
||||
return /^group:mobile-/i.test(key.trim())
|
||||
canonicalizeLegacySessionKey: ({ key, agentId }: { key: string; agentId: string }) =>
|
||||
/^group:mobile-/i.test(key.trim())
|
||||
? `agent:${agentId}:mobileauth:${key.trim().toLowerCase()}`
|
||||
: null;
|
||||
},
|
||||
: null,
|
||||
},
|
||||
]),
|
||||
listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [
|
||||
@@ -451,76 +445,23 @@ describe("state migrations", () => {
|
||||
|
||||
it("runs legacy state migrations and canonicalizes the merged session store", async () => {
|
||||
const { root, stateDir, env, cfg } = await createLegacyStateFixture({ includePreKey: true });
|
||||
cfg.session = { ...cfg.session, mainKey: "Desk" };
|
||||
const targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json");
|
||||
const targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
targetStore["agent:main:desk"] = { sessionId: "explicit-foreign", updatedAt: 30 };
|
||||
targetStore["voice:15550001111"] = {
|
||||
sessionId: "shared-voice",
|
||||
updatedAt: 20,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "worker-1",
|
||||
runtimeSessionName: "shared-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 20,
|
||||
},
|
||||
};
|
||||
targetStore["agent:worker-1:acp:task"] = {
|
||||
sessionId: "canonical-acp",
|
||||
updatedAt: 15,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "worker-1",
|
||||
runtimeSessionName: "canonical-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 15,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(targetStorePath, `${JSON.stringify(targetStore, null, 2)}\n`, "utf8");
|
||||
cfg.session = { ...cfg.session, store: targetStorePath };
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const legacyStore = JSON.parse(await fs.readFile(legacyStorePath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
legacyStore["Agent:main:desk"] = { sessionId: "mixed-case-foreign", updatedAt: 40 };
|
||||
legacyStore["legacy-prototype"] = {
|
||||
sessionId: "prototype-row",
|
||||
updatedAt: 10,
|
||||
sessionFile: "trace.jsonl",
|
||||
};
|
||||
await fs.writeFile(legacyStorePath, `${JSON.stringify(legacyStore, null, 2)}\n`, "utf8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env,
|
||||
homedir: () => root,
|
||||
pluginSessionStoreAgentIds: ["worker-1"],
|
||||
});
|
||||
expect(detected.sessions.preserveAmbiguousKeys).toBe(false);
|
||||
expect(detected.sessions.preserveForeignMainAliases).toBe(true);
|
||||
expect(detected.sessions.targetStoreAliases.hasDistinctAliases).toBe(false);
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
config: cfg,
|
||||
now: () => 1234,
|
||||
});
|
||||
expect(result.warnings).toStrictEqual([
|
||||
`Preserved 1 ambiguous session key(s) while importing legacy sessions into ${targetStorePath}`,
|
||||
]);
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
`Migrated latest direct-chat session → agent:worker-1:desk`,
|
||||
`Merged sessions store → ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`,
|
||||
"Canonicalized 3 legacy session key(s)",
|
||||
"Canonicalized 2 legacy session key(s)",
|
||||
"Moved trace.jsonl → agents/worker-1/sessions",
|
||||
"Rewrote migrated session transcript paths",
|
||||
"Migrated 2 ACP session metadata rows → shared SQLite state",
|
||||
"Moved agent file settings.json → agents/worker-1/agent",
|
||||
`Moved MobileAuth auth creds.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "creds.json")}`,
|
||||
`Moved MobileAuth auth pre-key-1.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "pre-key-1.json")}`,
|
||||
@@ -532,29 +473,14 @@ describe("state migrations", () => {
|
||||
path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, { sessionId: string; sessionFile?: string; acp?: unknown }>;
|
||||
) as Record<string, { sessionId: string }>;
|
||||
expect(mergedStore["agent:worker-1:desk"]?.sessionId).toBe("legacy-direct");
|
||||
expect(mergedStore["group:mobile-room"]).toBeUndefined();
|
||||
expect(mergedStore["group:legacy-room"]).toBeUndefined();
|
||||
expect(mergedStore["agent:worker-1:mobileauth:group:mobile-room"]?.sessionId).toBe(
|
||||
"group-session",
|
||||
);
|
||||
expect(mergedStore["agent:worker-1:unknown:group:legacy-room"]?.sessionId).toBe(
|
||||
"generic-group-session",
|
||||
);
|
||||
expect(mergedStore["agent:main:desk"]?.sessionId).toBe("explicit-foreign");
|
||||
expect(mergedStore["Agent:main:desk"]?.sessionId).toBe("mixed-case-foreign");
|
||||
expect(mergedStore["voice:15550001111"]).toBeUndefined();
|
||||
expect(mergedStore["agent:worker-1:voice:15550001111"]?.sessionId).toBe("shared-voice");
|
||||
expect(mergedStore["agent:worker-1:voice:15550001111"]?.acp).toBeUndefined();
|
||||
expect(Object.hasOwn(mergedStore, "__proto__")).toBe(true);
|
||||
expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value.sessionId).toBe(
|
||||
"prototype-row",
|
||||
);
|
||||
expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value.sessionFile).toBe(
|
||||
path.join(stateDir, "agents", "worker-1", "sessions", "trace.jsonl"),
|
||||
);
|
||||
expect(mergedStore["agent:worker-1:acp:task"]?.acp).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(stateDir, "agents", "worker-1", "sessions", "trace.jsonl"), "utf8"),
|
||||
@@ -587,817 +513,6 @@ describe("state migrations", () => {
|
||||
await expectMissingPath(resolveChannelAllowFromPath("chatapp", env, "beta"));
|
||||
});
|
||||
|
||||
it("canonicalizes parsed owners before removing the legacy store", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({
|
||||
"agent:archive:main": { sessionId: "archive-session", updatedAt: 20 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "work" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
|
||||
await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
|
||||
|
||||
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
const store = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string }
|
||||
>;
|
||||
expect(store["agent:archive:work"]?.sessionId).toBe("archive-session");
|
||||
expect(store["agent:archive:main"]).toBeUndefined();
|
||||
await expectMissingPath(legacyStorePath);
|
||||
});
|
||||
|
||||
it("defers non-main owner merges across hard-linked stores", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const targetStorePath = path.join(stateDir, "agents", "ops", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
targetStorePath,
|
||||
JSON.stringify({
|
||||
"agent:ops:main": { sessionId: "ops-session", updatedAt: 10 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const configuredStorePath = path.join(root, "configured-sessions.json");
|
||||
await fs.link(targetStorePath, configuredStorePath);
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({
|
||||
"agent:research:main": { sessionId: "research-session", updatedAt: 20 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: configuredStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }, { id: "research" }] },
|
||||
} as OpenClawConfig;
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
expect(detected.sessions.preserveAmbiguousKeys).toBe(true);
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
|
||||
|
||||
for (const storePath of [targetStorePath, configuredStorePath]) {
|
||||
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string }
|
||||
>;
|
||||
expect(store["agent:ops:main"]?.sessionId).toBe("ops-session");
|
||||
expect(store["agent:ops:work"]).toBeUndefined();
|
||||
expect(store["agent:research:main"]).toBeUndefined();
|
||||
}
|
||||
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("research-session");
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.stringContaining("atomic replacement cannot update distinct filesystem aliases"),
|
||||
);
|
||||
});
|
||||
|
||||
it("defers an unambiguous legacy merge through a final store symlink", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const outsideStorePath = path.join(root, "outside-sessions.json");
|
||||
await fs.writeFile(outsideStorePath, "{}\n", "utf8");
|
||||
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.symlink(outsideStorePath, targetStorePath);
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:task": { sessionId: "legacy-task", updatedAt: 10 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
|
||||
|
||||
expect((await fs.lstat(targetStorePath)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.readFile(outsideStorePath, "utf8")).resolves.toBe("{}\n");
|
||||
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("legacy-task");
|
||||
expect(result.warnings).toContain(
|
||||
`Deferred legacy session migration in final-component symlink store ${targetStorePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
);
|
||||
});
|
||||
|
||||
it("defers legacy migration when configured store identity is inaccessible", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.writeFile(targetStorePath, "{}\n", "utf8");
|
||||
const configuredStorePath = path.join(root, "configured-sessions.json");
|
||||
await fs.writeFile(configuredStorePath, "{}\n", "utf8");
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({ "agent:main:task": { sessionId: "legacy", updatedAt: 10 } }),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { store: configuredStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const realStatSync = fsSync.statSync.bind(fsSync);
|
||||
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((candidate) => {
|
||||
if (path.resolve(candidate.toString()) === configuredStorePath) {
|
||||
throw Object.assign(new Error("inaccessible store"), { code: "EACCES" });
|
||||
}
|
||||
return realStatSync(candidate);
|
||||
});
|
||||
let detected: Awaited<ReturnType<typeof detectLegacyStateMigrations>>;
|
||||
try {
|
||||
detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
} finally {
|
||||
statSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(detected.sessions.targetStoreAliases.hasUnresolvedIdentity).toBe(true);
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
|
||||
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.stringContaining("filesystem identity could not be established"),
|
||||
);
|
||||
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("legacy");
|
||||
await expect(fs.readFile(targetStorePath, "utf8")).resolves.toBe("{}\n");
|
||||
});
|
||||
|
||||
it("keeps the legacy source when its store write fails", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.writeFile(targetStorePath, "{}\n", "utf8");
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({ "agent:main:task": { sessionId: "legacy", updatedAt: 10 } }),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const realSaveSessionStore = sessionStore.saveSessionStore;
|
||||
let sawRequiredWrite = false;
|
||||
const saveSpy = vi
|
||||
.spyOn(sessionStore, "saveSessionStore")
|
||||
.mockImplementation(async (storePath, store, options) => {
|
||||
sawRequiredWrite ||= options?.requireWriteSuccess === true;
|
||||
if (storePath === targetStorePath) {
|
||||
if (options?.requireWriteSuccess) {
|
||||
throw new Error("simulated alias write failure");
|
||||
}
|
||||
return;
|
||||
}
|
||||
await realSaveSessionStore(storePath, store, options);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 }),
|
||||
).rejects.toThrow("simulated alias write failure");
|
||||
} finally {
|
||||
saveSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(sawRequiredWrite).toBe(true);
|
||||
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("legacy");
|
||||
});
|
||||
|
||||
it("preserves shared ownership through missing parent-symlink store paths", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const agentsDir = path.join(stateDir, "agents");
|
||||
await fs.mkdir(agentsDir, { recursive: true });
|
||||
const aliasAgentsDir = path.join(root, "agents-alias");
|
||||
await fs.symlink(agentsDir, aliasAgentsDir, "dir");
|
||||
const configuredStorePath = path.join(aliasAgentsDir, "ops", "sessions", "sessions.json");
|
||||
const targetStorePath = path.join(agentsDir, "ops", "sessions", "sessions.json");
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:work": { sessionId: "foreign-main", updatedAt: 10 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: configuredStorePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env,
|
||||
homedir: () => root,
|
||||
pluginSessionStoreAgentIds: ["voice"],
|
||||
});
|
||||
expect(detected.sessions.preserveAmbiguousKeys).toBe(true);
|
||||
expect(detected.sessions.preserveForeignMainAliases).toBe(true);
|
||||
|
||||
await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
|
||||
|
||||
const store = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string }
|
||||
>;
|
||||
expect(store["agent:main:work"]?.sessionId).toBe("foreign-main");
|
||||
expect(store["agent:ops:work"]).toBeUndefined();
|
||||
await expect(fs.readFile(configuredStorePath, "utf8")).resolves.toBe(
|
||||
await fs.readFile(targetStorePath, "utf8"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves plugin ownership captured before an aliased store rewrite", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
targetStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:desk": { sessionId: "foreign-main", updatedAt: 30 },
|
||||
"agent:worker-1:main": {
|
||||
sessionId: "worker-main",
|
||||
updatedAt: 20,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "worker-1",
|
||||
runtimeSessionName: "legacy-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 20,
|
||||
},
|
||||
},
|
||||
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 10 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const configuredStorePath = path.join(root, "configured-sessions.json");
|
||||
await fs.link(targetStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "worker-1", default: true }] },
|
||||
session: { mainKey: "desk", store: configuredStorePath },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "worker-1" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
const targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string }
|
||||
>;
|
||||
expect(targetStore["agent:main:desk"]?.sessionId).toBe("foreign-main");
|
||||
expect(targetStore["agent:worker-1:main"]?.sessionId).toBe("worker-main");
|
||||
expect(targetStore["agent:worker-1:desk"]).toBeUndefined();
|
||||
expect(targetStore["agent:worker-1:main"]).toHaveProperty("acp");
|
||||
expect(fsSync.statSync(configuredStorePath).ino).toBe(fsSync.statSync(targetStorePath).ino);
|
||||
expect(result.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(`aliased store ${configuredStorePath}`),
|
||||
expect.stringContaining(`aliased store ${targetStorePath}`),
|
||||
expect.stringContaining("Deferred ACP metadata migration"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a singleton final symlink through all session migration phases", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const outsideStorePath = path.join(root, "outside-sessions.json");
|
||||
await fs.writeFile(
|
||||
outsideStorePath,
|
||||
JSON.stringify({
|
||||
"voice:15550001111": { sessionId: "outside-voice", updatedAt: 10 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.symlink(outsideStorePath, storePath);
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
expect((await fs.lstat(storePath)).isSymbolicLink()).toBe(true);
|
||||
const outsideStore = JSON.parse(await fs.readFile(outsideStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string }
|
||||
>;
|
||||
expect(outsideStore["voice:15550001111"]?.sessionId).toBe("outside-voice");
|
||||
expect(result.warnings).toEqual([
|
||||
`Deferred session key migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
`Deferred legacy session migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves ACP metadata through a singleton fixed-store symlink", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const outsideStorePath = path.join(root, "outside-sessions.json");
|
||||
await fs.writeFile(
|
||||
outsideStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:task": {
|
||||
sessionId: "canonical-acp",
|
||||
updatedAt: 10,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "main",
|
||||
runtimeSessionName: "outside-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const configuredStorePath = path.join(root, "configured-sessions.json");
|
||||
await fs.symlink(outsideStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
session: { store: configuredStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
expect((await fs.lstat(configuredStorePath)).isSymbolicLink()).toBe(true);
|
||||
const outsideStore = JSON.parse(await fs.readFile(outsideStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string; acp?: unknown }
|
||||
>;
|
||||
expect(outsideStore["agent:main:task"]?.acp).toBeDefined();
|
||||
expect(result.warnings).toContain(
|
||||
`Deferred ACP metadata migration in final-component symlink store ${configuredStorePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
);
|
||||
expect(result.changes).not.toContain(
|
||||
"Migrated 1 ACP session metadata row → shared SQLite state",
|
||||
);
|
||||
});
|
||||
|
||||
it("defers ACP metadata migration across hard-linked store paths", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
targetStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:task": {
|
||||
sessionId: "canonical-acp",
|
||||
updatedAt: 10,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "main",
|
||||
runtimeSessionName: "hardlink-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const configuredStorePath = path.join(root, "configured-sessions.json");
|
||||
await fs.link(targetStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
session: { store: configuredStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
for (const storePath of [targetStorePath, configuredStorePath]) {
|
||||
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ acp?: unknown }
|
||||
>;
|
||||
expect(store["agent:main:task"]?.acp).toBeDefined();
|
||||
}
|
||||
expect(result.changes).not.toContain(
|
||||
"Migrated 1 ACP session metadata row → shared SQLite state",
|
||||
);
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.stringContaining("atomic replacement cannot update distinct filesystem aliases"),
|
||||
);
|
||||
});
|
||||
|
||||
it("defers global main aliases across hard-linked store paths", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
targetStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:main": {
|
||||
sessionId: "legacy-global",
|
||||
updatedAt: 20,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "main",
|
||||
runtimeSessionName: "global-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 20,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const configuredStorePath = path.join(root, "configured-sessions.json");
|
||||
await fs.link(targetStorePath, configuredStorePath);
|
||||
const cfg = {
|
||||
session: { scope: "global", store: configuredStorePath },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
for (const storePath of [configuredStorePath, targetStorePath]) {
|
||||
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string; acp?: unknown }
|
||||
>;
|
||||
expect(store["agent:main:main"]?.sessionId).toBe("legacy-global");
|
||||
expect(store["agent:main:main"]?.acp).toBeDefined();
|
||||
expect(store.global).toBeUndefined();
|
||||
}
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.stringContaining("atomic replacement cannot update distinct filesystem aliases"),
|
||||
);
|
||||
expect(result.changes).not.toContain(
|
||||
"Migrated 1 ACP session metadata row → shared SQLite state",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "default", templated: false },
|
||||
{ name: "templated plugin", templated: true },
|
||||
])("preserves foreign ACP aliases in $name stores", async ({ templated }) => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const storeTemplate = path.join(root, "stores", "{agentId}", "sessions.json");
|
||||
const storePath = templated
|
||||
? path.join(root, "stores", "voice", "sessions.json")
|
||||
: path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:main": {
|
||||
sessionId: "foreign-main",
|
||||
updatedAt: 20,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "voice",
|
||||
runtimeSessionName: "foreign-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 20,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { scope: "global", ...(templated ? { store: storeTemplate } : {}) },
|
||||
agents: { list: [{ id: templated ? "main" : "voice", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string; acp?: unknown }
|
||||
>;
|
||||
expect(store["agent:main:main"]?.sessionId).toBe("foreign-main");
|
||||
expect(store["agent:main:main"]?.acp).toBeDefined();
|
||||
expect(store.global).toBeUndefined();
|
||||
expect(result.changes).not.toContain(
|
||||
"Migrated 1 ACP session metadata row → shared SQLite state",
|
||||
);
|
||||
const acpWarningPrefix =
|
||||
"Preserved ACP metadata for 1 ambiguous session key(s) in potentially shared store ";
|
||||
expect(result.warnings.filter((warning) => warning.startsWith(acpWarningPrefix))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates malformed agent-shaped rows in single-owner plugin stores", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const storeTemplate = path.join(root, "stores", "{agentId}", "sessions.json");
|
||||
const storePath = path.join(root, "stores", "voice", "sessions.json");
|
||||
const cases = [
|
||||
{
|
||||
legacyKey: "agent::matrix:channel:!RoomAbC:example.org",
|
||||
canonicalKey: "agent:voice:agent::matrix:channel:!RoomAbC:example.org",
|
||||
sessionId: "malformed-owner",
|
||||
runtimeSessionName: "malformed-runtime",
|
||||
},
|
||||
{
|
||||
legacyKey: "agent:_bad:opaque",
|
||||
canonicalKey: "agent:voice:agent:_bad:opaque",
|
||||
sessionId: "invalid-owner",
|
||||
runtimeSessionName: "invalid-runtime",
|
||||
},
|
||||
];
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
cases.map(({ legacyKey, sessionId, runtimeSessionName }) => [
|
||||
legacyKey,
|
||||
{
|
||||
sessionId,
|
||||
updatedAt: 10,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "voice",
|
||||
runtimeSessionName,
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 10,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { store: storeTemplate },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { config: { agentId: "voice" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string; acp?: unknown }
|
||||
>;
|
||||
for (const { legacyKey, canonicalKey, sessionId, runtimeSessionName } of cases) {
|
||||
expect(store[legacyKey]).toBeUndefined();
|
||||
expect(store[canonicalKey]).toEqual({ sessionId, updatedAt: 10 });
|
||||
expect(
|
||||
readAcpSessionMetaForEntry({
|
||||
sessionKey: canonicalKey,
|
||||
entry: { sessionId },
|
||||
env,
|
||||
})?.runtimeSessionName,
|
||||
).toBe(runtimeSessionName);
|
||||
expect(
|
||||
readAcpSessionMetaForEntry({
|
||||
sessionKey: legacyKey,
|
||||
entry: { sessionId },
|
||||
env,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(result.changes).toContain("Migrated 2 ACP session metadata rows → shared SQLite state");
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves multi-owner rows through coalesced templated-store migration", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const storeTemplate = path.join(
|
||||
stateDir,
|
||||
"agents",
|
||||
"{agentId}",
|
||||
"..",
|
||||
"main",
|
||||
"sessions",
|
||||
"sessions.json",
|
||||
);
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"voice:15550001111": {
|
||||
sessionId: "shared-voice",
|
||||
updatedAt: 20,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "voice",
|
||||
runtimeSessionName: "shared-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 20,
|
||||
},
|
||||
},
|
||||
"agent:voice::matrix:channel:!room:example.org": {
|
||||
sessionId: "malformed-owner",
|
||||
updatedAt: 10,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "voice",
|
||||
runtimeSessionName: "malformed-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 10,
|
||||
},
|
||||
},
|
||||
"agent:_bad:opaque": {
|
||||
sessionId: "invalid-owner",
|
||||
updatedAt: 5,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "voice",
|
||||
runtimeSessionName: "invalid-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(legacyStorePath, "{}\n", "utf8");
|
||||
const cfg = {
|
||||
session: { store: storeTemplate },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
acp: { allowedAgents: ["voice"] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ sessionId: string; acp?: unknown }
|
||||
>;
|
||||
expect(store["voice:15550001111"]?.sessionId).toBe("shared-voice");
|
||||
expect(store["voice:15550001111"]?.acp).toBeDefined();
|
||||
expect(store["agent:voice::matrix:channel:!room:example.org"]?.sessionId).toBe(
|
||||
"malformed-owner",
|
||||
);
|
||||
expect(store["agent:voice::matrix:channel:!room:example.org"]?.acp).toBeDefined();
|
||||
expect(store["agent:_bad:opaque"]?.sessionId).toBe("invalid-owner");
|
||||
expect(store["agent:_bad:opaque"]?.acp).toBeDefined();
|
||||
expect(store["agent:main:voice:15550001111"]).toBeUndefined();
|
||||
expect(store["agent:voice:voice:15550001111"]).toBeUndefined();
|
||||
expect(store["agent:main:agent:voice::matrix:channel:!room:example.org"]).toBeUndefined();
|
||||
expect(result.changes).not.toContain(
|
||||
"Migrated 1 ACP session metadata row → shared SQLite state",
|
||||
);
|
||||
const acpWarningPrefix =
|
||||
"Preserved ACP metadata for 3 ambiguous session key(s) in potentially shared store ";
|
||||
expect(result.warnings.filter((warning) => warning.startsWith(acpWarningPrefix))).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not process ACP stores rejected by target validation", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const outsideStorePath = path.join(root, "outside-sessions.json");
|
||||
await fs.writeFile(
|
||||
outsideStorePath,
|
||||
JSON.stringify({
|
||||
"agent:main:opaque": {
|
||||
sessionId: "outside-session",
|
||||
updatedAt: 10,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "main",
|
||||
runtimeSessionName: "outside-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.symlink(outsideStorePath, storePath);
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
expect((await fs.lstat(storePath)).isSymbolicLink()).toBe(true);
|
||||
const outsideStore = JSON.parse(await fs.readFile(outsideStorePath, "utf8")) as Record<
|
||||
string,
|
||||
{ acp?: unknown }
|
||||
>;
|
||||
expect(outsideStore["agent:main:opaque"]?.acp).toBeDefined();
|
||||
expect(result.changes).not.toContain(
|
||||
"Migrated 1 ACP session metadata row → shared SQLite state",
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes imported ACP aliases with their session row owner", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const storeTemplate = path.join(
|
||||
stateDir,
|
||||
"agents",
|
||||
"{agentId}",
|
||||
"..",
|
||||
"main",
|
||||
"sessions",
|
||||
"sessions.json",
|
||||
);
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, "{}\n", "utf8");
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyStorePath,
|
||||
JSON.stringify({
|
||||
"agent:voice:main": {
|
||||
sessionId: "voice-main",
|
||||
updatedAt: 10,
|
||||
acp: {
|
||||
backend: "test",
|
||||
agent: "voice",
|
||||
runtimeSessionName: "voice-runtime",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "desk", store: storeTemplate },
|
||||
agents: { list: [{ id: "main", default: true }, { id: "voice" }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
|
||||
|
||||
expect(
|
||||
readAcpSessionMetaForEntry({
|
||||
sessionKey: "agent:voice:desk",
|
||||
entry: { sessionId: "voice-main" },
|
||||
env,
|
||||
})?.runtimeSessionName,
|
||||
).toBe("voice-runtime");
|
||||
expect(
|
||||
readAcpSessionMetaForEntry({
|
||||
sessionKey: "agent:voice:main",
|
||||
entry: { sessionId: "voice-main" },
|
||||
env,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(result.changes).toContain("Migrated 1 ACP session metadata row → shared SQLite state");
|
||||
});
|
||||
|
||||
it("migrates legacy delivery queue files into shared SQLite state", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-b
|
||||
import type { Context, Model } from "../types.js";
|
||||
import {
|
||||
extractOpenAICodexAccountId,
|
||||
parseSSEForTest,
|
||||
resetOpenAICodexWebSocketDebugStats,
|
||||
streamOpenAICodexResponses,
|
||||
} from "./openai-chatgpt-responses.js";
|
||||
@@ -561,95 +560,4 @@ describe("streamOpenAICodexResponses transport", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("bounds non-OK ChatGPT response bodies before formatting API errors", async () => {
|
||||
const chunkSize = 1024 * 1024;
|
||||
const totalChunks = 32;
|
||||
const chunk = new TextEncoder()
|
||||
.encode("usage limit ".repeat(Math.ceil(chunkSize / "usage limit ".length)))
|
||||
.subarray(0, chunkSize);
|
||||
let pullCount = 0;
|
||||
let canceled = false;
|
||||
const overflowing = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > totalChunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
new Response(overflowing, {
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const stream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-1",
|
||||
},
|
||||
}),
|
||||
transport: "sse",
|
||||
});
|
||||
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("usage limit");
|
||||
expect(result.errorMessage?.length).toBeLessThanOrEqual(16 * 1024);
|
||||
expect(canceled).toBe(true);
|
||||
expect(pullCount).toBeGreaterThanOrEqual(1);
|
||||
expect(pullCount).toBeLessThanOrEqual(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSSEForTest", () => {
|
||||
it("bounds streamed OpenAI ChatGPT Responses success bodies without content-length", async () => {
|
||||
// 1 MiB chunks; cap is 16 MiB so the bounded reader cancels well before
|
||||
// draining the full 32 MiB advertised body.
|
||||
const CHUNK = 1024 * 1024;
|
||||
const TOTAL = 32;
|
||||
let pullCount = 0;
|
||||
let cancelReason: unknown;
|
||||
const overflowing = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > TOTAL) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(new Uint8Array(CHUNK));
|
||||
},
|
||||
cancel(reason) {
|
||||
cancelReason = reason;
|
||||
},
|
||||
});
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
// parseSSE expects a Response-like; pass the streaming body directly
|
||||
// through a minimal Response shim that only exposes .body.
|
||||
const response = { body: overflowing } as unknown as Response;
|
||||
for await (const event of parseSSEForTest(response)) {
|
||||
expect(event).toBeDefined();
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err as Error;
|
||||
}
|
||||
expect(caught?.message).toMatch(
|
||||
/OpenAI ChatGPT Responses success body exceeded 16777216 bytes/,
|
||||
);
|
||||
expect(cancelReason).toBeInstanceOf(Error);
|
||||
// 16 MiB + a couple of overshoot pulls, well under 32.
|
||||
expect(pullCount).toBeGreaterThanOrEqual(17);
|
||||
expect(pullCount).toBeLessThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
resolveTimerTimeoutMs,
|
||||
clampTimerTimeoutMs,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { createSseByteGuard } from "../../agents/streaming-byte-guard.js";
|
||||
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
|
||||
import { getEnvApiKey } from "../env-api-keys.js";
|
||||
import { clampThinkingLevel } from "../model-utils.js";
|
||||
@@ -67,8 +66,6 @@ const RETRY_AFTER_HTTP_DATE_RE =
|
||||
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/;
|
||||
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]);
|
||||
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
||||
const OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES = 16 * 1024;
|
||||
const OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
|
||||
"completed",
|
||||
@@ -342,7 +339,7 @@ export const streamOpenAICodexResponses: StreamFunction<
|
||||
break;
|
||||
}
|
||||
|
||||
const errorText = await readChatGptResponsesErrorTextLimited(response);
|
||||
const errorText = await response.text();
|
||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||
let delayMs = BASE_DELAY_MS * 2 ** attempt;
|
||||
|
||||
@@ -725,23 +722,12 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
|
||||
// non-streaming `readProviderJsonResponse` cap so a hostile or
|
||||
// malfunctioning ChatGPT Responses endpoint cannot exhaust memory by
|
||||
// streaming an unbounded SSE body.
|
||||
const guard = createSseByteGuard(reader, {
|
||||
maxBytes: OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES,
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(
|
||||
`OpenAI ChatGPT Responses success body exceeded ${maxBytes} bytes (received ${size})`,
|
||||
),
|
||||
});
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await guard.read();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
@@ -774,7 +760,7 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await guard.cancel();
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
@@ -782,10 +768,6 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only re-export of the bounded SSE parser. Mirrors
|
||||
// `parseAnthropicSseBodyForTest` / `iterateSseMessagesForTest` patterns.
|
||||
export const parseSSEForTest = parseSSE;
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Parsing
|
||||
// ============================================================================
|
||||
@@ -1539,57 +1521,10 @@ async function processWebSocketStream(
|
||||
// Error Handling
|
||||
// ============================================================================
|
||||
|
||||
async function readChatGptResponsesErrorTextLimited(response: Response): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let text = "";
|
||||
let reachedLimit = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value || value.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
const remaining = OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES - total;
|
||||
if (remaining <= 0) {
|
||||
reachedLimit = true;
|
||||
break;
|
||||
}
|
||||
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
||||
total += chunk.byteLength;
|
||||
text += decoder.decode(chunk, { stream: true });
|
||||
if (total >= OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES) {
|
||||
reachedLimit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
if (reachedLimit) {
|
||||
// This provider module is browser-safe, so keep error-body capping on Web APIs.
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function parseErrorResponse(
|
||||
response: Response,
|
||||
): Promise<{ message: string; friendlyMessage?: string }> {
|
||||
const raw = await readChatGptResponsesErrorTextLimited(response);
|
||||
const raw = await response.text();
|
||||
let message = raw || response.statusText || "Request failed";
|
||||
let friendlyMessage: string | undefined;
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import * as jsonFiles from "../infra/json-files.js";
|
||||
import {
|
||||
cleanupSessionLifecycleArtifacts,
|
||||
@@ -11,7 +9,6 @@ import {
|
||||
listSessionEntries,
|
||||
patchSessionEntry,
|
||||
readSessionUpdatedAt,
|
||||
resolveSessionEntryFreshness,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
@@ -30,7 +27,6 @@ describe("session-store-runtime compatibility surface", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -74,262 +70,6 @@ describe("session-store-runtime compatibility surface", () => {
|
||||
expect(getSessionEntry({ sessionKey, storePath })?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns missing state with a resolved reset policy for absent entries", () => {
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey: "agent:main:missing:thread:100.000",
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now: new Date("2026-01-02T12:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: "missing",
|
||||
entry: undefined,
|
||||
freshness: undefined,
|
||||
resetType: "thread",
|
||||
resetPolicy: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves stale daily freshness from lifecycle timestamps instead of activity", async () => {
|
||||
const sessionKey = "agent:main:main:thread:100.000";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-stale-thread",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * DAY_MS,
|
||||
lastInteractionAt: now - 2 * DAY_MS,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.entry?.sessionId).toBe("session-stale-thread");
|
||||
expect(result.resetType).toBe("thread");
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-owned sessions fresh when reset policy is implicit", async () => {
|
||||
const sessionKey = "agent:main:main:thread:provider-owned";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-provider-owned",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * DAY_MS,
|
||||
lastInteractionAt: now - 2 * DAY_MS,
|
||||
providerOverride: "claude-cli",
|
||||
cliSessionBindings: {
|
||||
"claude-cli": { sessionId: "cli-session-provider-owned" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("fresh");
|
||||
expect(result.freshness).toMatchObject({ fresh: true });
|
||||
});
|
||||
|
||||
it("applies configured reset policies to provider-owned sessions", async () => {
|
||||
const sessionKey = "agent:main:main:thread:provider-owned-configured";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-provider-owned-configured",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * DAY_MS,
|
||||
lastInteractionAt: now - 2 * DAY_MS,
|
||||
providerOverride: "claude-cli",
|
||||
cliSessionBindings: {
|
||||
"claude-cli": { sessionId: "cli-session-provider-owned-configured" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: { reset: { mode: "daily" } },
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves fresh daily freshness for active lifecycle timestamps", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-fresh",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 60_000,
|
||||
lastInteractionAt: now - 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "direct",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("fresh");
|
||||
expect(result.entry?.sessionId).toBe("session-fresh");
|
||||
expect(result.resetType).toBe("direct");
|
||||
expect(result.freshness).toMatchObject({ fresh: true });
|
||||
});
|
||||
|
||||
it("honors reset overrides when resolving entry freshness", async () => {
|
||||
const sessionKey = "agent:main:main:thread:idle";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-idle-stale",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now,
|
||||
lastInteractionAt: now - 60 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: { reset: { mode: "daily" } },
|
||||
resetOverride: { mode: "idle", idleMinutes: 30 },
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.resetPolicy).toMatchObject({
|
||||
mode: "idle",
|
||||
idleMinutes: 30,
|
||||
});
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses runtime session config when store path and session config are omitted", async () => {
|
||||
const sessionKey = "agent:main:main:thread:runtime-config";
|
||||
const runtimeStorePath = path.join(tempDir, "runtime-sessions.json");
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
setRuntimeConfigSnapshot({
|
||||
session: {
|
||||
store: runtimeStorePath,
|
||||
reset: { mode: "idle", idleMinutes: 30 },
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath: runtimeStorePath,
|
||||
entry: {
|
||||
sessionId: "session-runtime-config",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now,
|
||||
lastInteractionAt: now - 60 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.entry?.sessionId).toBe("session-runtime-config");
|
||||
expect(result.resetPolicy).toMatchObject({
|
||||
mode: "idle",
|
||||
idleMinutes: 30,
|
||||
});
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses transcript header startedAt when entry lifecycle metadata is missing", async () => {
|
||||
const sessionKey = "agent:main:main:thread:header";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
const headerTimestamp = new Date(now - 2 * DAY_MS).toISOString();
|
||||
const transcriptPath = path.join(tempDir, "session-header-fallback.jsonl");
|
||||
fs.writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
id: "session-header-fallback",
|
||||
timestamp: headerTimestamp,
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionFile: transcriptPath,
|
||||
sessionId: "session-header-fallback",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.lifecycleTimestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp));
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the public entry mutation signature while delegating to the seam", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
|
||||
@@ -598,9 +338,7 @@ describe("session-store-runtime compatibility surface", () => {
|
||||
sessionId: "regular",
|
||||
});
|
||||
expect(
|
||||
fs
|
||||
.readdirSync(tempDir)
|
||||
.filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
|
||||
fs.readdirSync(tempDir).filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
// Narrow session-store helpers for channel hot paths.
|
||||
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { resolveSessionLifecycleTimestamps } from "../config/sessions/lifecycle.js";
|
||||
import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js";
|
||||
import {
|
||||
evaluateSessionFreshness as evaluateSessionFreshnessImpl,
|
||||
resolveSessionResetPolicy as resolveSessionResetPolicyImpl,
|
||||
type SessionFreshness,
|
||||
type SessionResetPolicy,
|
||||
type SessionResetType,
|
||||
} from "../config/sessions/reset.js";
|
||||
import {
|
||||
cleanupSessionLifecycleArtifacts as cleanupAccessorSessionLifecycleArtifacts,
|
||||
listSessionEntries as listAccessorSessionEntries,
|
||||
@@ -26,8 +15,6 @@ import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/sto
|
||||
import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js";
|
||||
import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { SessionConfig, SessionResetConfig } from "../config/types.base.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
type SessionStoreReadParams = {
|
||||
agentId?: string;
|
||||
@@ -64,36 +51,6 @@ type PatchSessionEntryParams = SessionStoreReadParams & {
|
||||
|
||||
type ReadSessionUpdatedAtParams = SessionStoreReadParams;
|
||||
|
||||
export type ResolveSessionEntryFreshnessParams = SessionStoreReadParams & {
|
||||
now?: number;
|
||||
resetOverride?: SessionResetConfig;
|
||||
resetType: SessionResetType;
|
||||
sessionCfg?: SessionConfig;
|
||||
};
|
||||
|
||||
export type SessionEntryLifecycleTimestamps = {
|
||||
sessionStartedAt?: number;
|
||||
lastInteractionAt?: number;
|
||||
};
|
||||
|
||||
export type ResolvedSessionEntryFreshness =
|
||||
| {
|
||||
state: "missing";
|
||||
entry: undefined;
|
||||
freshness: undefined;
|
||||
lifecycleTimestamps: SessionEntryLifecycleTimestamps;
|
||||
resetPolicy: SessionResetPolicy;
|
||||
resetType: SessionResetType;
|
||||
}
|
||||
| {
|
||||
state: "fresh" | "stale";
|
||||
entry: SessionEntry;
|
||||
freshness: SessionFreshness;
|
||||
lifecycleTimestamps: SessionEntryLifecycleTimestamps;
|
||||
resetPolicy: SessionResetPolicy;
|
||||
resetType: SessionResetType;
|
||||
};
|
||||
|
||||
type UpdateSessionStoreEntryParams = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
@@ -124,24 +81,6 @@ type SessionLifecycleArtifactsCleanupResult = {
|
||||
removedEntries: number;
|
||||
};
|
||||
|
||||
function hasProviderOwnedSession(entry: SessionEntry | undefined): boolean {
|
||||
const provider = normalizeOptionalString(entry?.providerOverride ?? entry?.modelProvider);
|
||||
if (!entry || !provider) {
|
||||
return false;
|
||||
}
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (normalizeOptionalString(entry.cliSessionBindings?.[normalizedProvider]?.sessionId)) {
|
||||
return true;
|
||||
}
|
||||
if (normalizeOptionalString(entry.cliSessionIds?.[normalizedProvider])) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalizedProvider === "claude-cli" &&
|
||||
Boolean(normalizeOptionalString(entry.claudeCliSessionId))
|
||||
);
|
||||
}
|
||||
|
||||
function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScope {
|
||||
// Maintainer note: keep this adapter narrow so plugin callers retain the
|
||||
// object-parameter API while internal accessor-only options stay private.
|
||||
@@ -204,67 +143,6 @@ export function readSessionUpdatedAt(params: ReadSessionUpdatedAtParams): number
|
||||
return readAccessorSessionUpdatedAt(toSessionAccessScope(params));
|
||||
}
|
||||
|
||||
/** Resolves one session entry's reset freshness using the runtime lifecycle rules. */
|
||||
export function resolveSessionEntryFreshness(
|
||||
params: ResolveSessionEntryFreshnessParams,
|
||||
): ResolvedSessionEntryFreshness {
|
||||
const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const sessionCfg = params.sessionCfg ?? getRuntimeConfig().session;
|
||||
const storePath =
|
||||
params.storePath ??
|
||||
resolveSessionStorePath(sessionCfg?.store, {
|
||||
agentId,
|
||||
env: params.env,
|
||||
});
|
||||
const entry = loadSessionEntry(
|
||||
toSessionAccessScope({
|
||||
...params,
|
||||
agentId,
|
||||
storePath,
|
||||
}),
|
||||
);
|
||||
const resetType = params.resetType;
|
||||
const resetPolicy = resolveSessionResetPolicyImpl({
|
||||
sessionCfg,
|
||||
resetType,
|
||||
resetOverride: params.resetOverride,
|
||||
});
|
||||
const lifecycleTimestamps = resolveSessionLifecycleTimestamps({
|
||||
entry,
|
||||
agentId,
|
||||
storePath,
|
||||
});
|
||||
const base = {
|
||||
lifecycleTimestamps,
|
||||
resetPolicy,
|
||||
resetType,
|
||||
};
|
||||
if (!entry) {
|
||||
return {
|
||||
state: "missing",
|
||||
entry: undefined,
|
||||
freshness: undefined,
|
||||
...base,
|
||||
};
|
||||
}
|
||||
const freshness =
|
||||
resetPolicy.configured !== true && hasProviderOwnedSession(entry)
|
||||
? ({ fresh: true } satisfies SessionFreshness)
|
||||
: evaluateSessionFreshnessImpl({
|
||||
updatedAt: entry.updatedAt,
|
||||
sessionStartedAt: lifecycleTimestamps.sessionStartedAt,
|
||||
lastInteractionAt: lifecycleTimestamps.lastInteractionAt,
|
||||
now: params.now ?? Date.now(),
|
||||
policy: resetPolicy,
|
||||
});
|
||||
return {
|
||||
state: freshness.fresh ? "fresh" : "stale",
|
||||
entry,
|
||||
freshness,
|
||||
...base,
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates an existing session entry by store path and session key. */
|
||||
export async function updateSessionStoreEntry(
|
||||
params: UpdateSessionStoreEntryParams,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user