mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 18:01:53 +08:00
Compare commits
13 Commits
codex/i18n
...
codex/slac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a662a7eed | ||
|
|
552ec2b49d | ||
|
|
4d0f19a968 | ||
|
|
072d3ed7b5 | ||
|
|
1bccd29304 | ||
|
|
498567190d | ||
|
|
5880e0afc4 | ||
|
|
65fec9d787 | ||
|
|
4d9cd7d227 | ||
|
|
12ea61a08d | ||
|
|
4932366b92 | ||
|
|
4f3d81b918 | ||
|
|
e09b9dfc1b |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -848,32 +848,6 @@ jobs:
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
native-i18n:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check native app i18n inventory
|
||||
run: pnpm native:i18n:check
|
||||
|
||||
- name: Check Android app i18n resources
|
||||
if: needs.preflight.outputs.run_android == 'true'
|
||||
run: pnpm android:i18n:check
|
||||
|
||||
checks-fast-core:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -52,7 +51,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -102,7 +100,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text(stringResource(R.string.trust_this_gateway), style = mobileHeadline, color = mobileText) },
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
text = {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
@@ -121,7 +119,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
Text(stringResource(R.string.trust_and_continue))
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@@ -129,7 +127,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -160,10 +158,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.gateway_connection), style = mobileTitle1, color = mobileText)
|
||||
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
|
||||
Text(
|
||||
if (isConnected) stringResource(R.string.connected_gateway_ready)
|
||||
else stringResource(R.string.connect_gateway_get_started),
|
||||
if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
@@ -194,7 +191,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.endpoint), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
}
|
||||
}
|
||||
@@ -216,7 +213,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.status), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText)
|
||||
}
|
||||
}
|
||||
@@ -241,7 +238,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.disconnect), style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
@@ -310,7 +307,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.connect_gateway), style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +354,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.copy_report_for_claw), style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,7 +373,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.advanced_controls), style = mobileHeadline, color = mobileText)
|
||||
Text("Advanced controls", style = mobileHeadline, color = mobileText)
|
||||
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
|
||||
}
|
||||
Icon(
|
||||
@@ -398,15 +395,15 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.connection_method), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MethodChip(
|
||||
label = stringResource(R.string.setup_code),
|
||||
label = "Setup Code",
|
||||
active = inputMode == ConnectInputMode.SetupCode,
|
||||
onClick = { inputMode = ConnectInputMode.SetupCode },
|
||||
)
|
||||
MethodChip(
|
||||
label = stringResource(R.string.manual),
|
||||
label = "Manual",
|
||||
active = inputMode == ConnectInputMode.Manual,
|
||||
onClick = { inputMode = ConnectInputMode.Manual },
|
||||
)
|
||||
@@ -422,14 +419,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
Text(stringResource(R.string.setup_code), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = setupCode,
|
||||
onValueChange = {
|
||||
setupCode = it
|
||||
validationText = null
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.paste_setup_code), style = mobileBody, color = mobileTextTertiary) },
|
||||
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
@@ -463,7 +460,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.host), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualHostInput,
|
||||
onValueChange = {
|
||||
@@ -505,7 +502,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(stringResource(R.string.use_tls), style = mobileHeadline, color = mobileText)
|
||||
Text("Use TLS", style = mobileHeadline, color = mobileText)
|
||||
Text(
|
||||
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
|
||||
style = mobileCallout,
|
||||
@@ -528,7 +525,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayToken,
|
||||
onValueChange = { viewModel.setGatewayToken(it) },
|
||||
@@ -549,7 +546,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
OutlinedTextField(
|
||||
value = passwordInput,
|
||||
onValueChange = { passwordInput = it },
|
||||
placeholder = { Text(stringResource(R.string.password), style = mobileBody, color = mobileTextTertiary) },
|
||||
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
@@ -566,7 +563,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
|
||||
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
|
||||
Text(stringResource(R.string.run_onboarding_again), style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -213,13 +212,7 @@ fun OnboardingFlow(
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::declineGatewayTrustPrompt,
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.trust_this_gateway),
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
},
|
||||
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
text = {
|
||||
Text(
|
||||
"Verify the certificate fingerprint before continuing.\n\n${prompt.fingerprintSha256}",
|
||||
@@ -229,12 +222,12 @@ fun OnboardingFlow(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::acceptGatewayTrustPrompt) {
|
||||
Text(stringResource(R.string.trust_and_continue))
|
||||
Text("Trust")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::declineGatewayTrustPrompt) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -541,24 +534,20 @@ private fun GatewaySetupScreen(
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
item {
|
||||
OnboardingHeader(
|
||||
title = stringResource(R.string.gateway_setup),
|
||||
subtitle = stringResource(R.string.connect_to_gateway),
|
||||
onBack = onBack,
|
||||
)
|
||||
OnboardingHeader(title = "Gateway Setup", subtitle = "Connect to your Gateway", onBack = onBack)
|
||||
}
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.QrCode2,
|
||||
title = stringResource(R.string.scan_setup_code),
|
||||
subtitle = stringResource(R.string.use_gateway_qr),
|
||||
title = "Scan setup code",
|
||||
subtitle = "Use your Gateway QR or setup code",
|
||||
onClick = onScan,
|
||||
)
|
||||
}
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.WifiTethering,
|
||||
title = stringResource(R.string.nearby_gateway),
|
||||
title = "Nearby gateway",
|
||||
subtitle = nearbyGateway.subtitle,
|
||||
status = nearbyGateway.status,
|
||||
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
|
||||
@@ -567,8 +556,8 @@ private fun GatewaySetupScreen(
|
||||
item {
|
||||
GatewayOption(
|
||||
icon = Icons.Default.Link,
|
||||
title = stringResource(R.string.enter_gateway_url),
|
||||
subtitle = stringResource(R.string.connect_manual_url),
|
||||
title = "Enter gateway URL",
|
||||
subtitle = "Connect using a manual URL",
|
||||
onClick = { advancedOpen = true },
|
||||
)
|
||||
}
|
||||
@@ -649,7 +638,7 @@ private fun GatewayRecoveryScreen(
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
OnboardingHeader(title = stringResource(R.string.gateway_setup), onBack = onBack)
|
||||
OnboardingHeader(title = "Gateway Recovery", onBack = onBack)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(
|
||||
@@ -934,9 +923,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHelp = false },
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = {
|
||||
Text(stringResource(R.string.permissions), style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
},
|
||||
title = { Text("Permissions", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
text = {
|
||||
Text(
|
||||
"Choose what this phone can share with OpenClaw. You can change these later in Settings.",
|
||||
@@ -946,7 +933,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showHelp = false }) {
|
||||
Text(stringResource(R.string.done))
|
||||
Text("Done")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">اتصال البوابة</string>
|
||||
<string name="connect_gateway">توصيل البوابة</string>
|
||||
<string name="disconnect">قطع الاتصال</string>
|
||||
<string name="trust_this_gateway">هل تثق بهذه البوابة؟</string>
|
||||
<string name="trust_and_continue">الثقة والمتابعة</string>
|
||||
<string name="cancel">إلغاء</string>
|
||||
<string name="endpoint">نقطة النهاية</string>
|
||||
<string name="status">الحالة</string>
|
||||
<string name="connected_gateway_ready">بوابتك نشطة وجاهزة.</string>
|
||||
<string name="connect_gateway_get_started">اتصل ببوابتك للبدء.</string>
|
||||
<string name="copy_report_for_claw">نسخ التقرير لـ Claw</string>
|
||||
<string name="advanced_controls">عناصر التحكم المتقدمة</string>
|
||||
<string name="connection_method">طريقة الاتصال</string>
|
||||
<string name="setup_code">رمز الإعداد</string>
|
||||
<string name="manual">يدوي</string>
|
||||
<string name="paste_setup_code">الصق رمز الإعداد</string>
|
||||
<string name="host">المضيف</string>
|
||||
<string name="use_tls">استخدام TLS</string>
|
||||
<string name="token_optional">الرمز المميز (اختياري)</string>
|
||||
<string name="password">كلمة المرور</string>
|
||||
<string name="run_onboarding_again">تشغيل الإعداد الأولي مرة أخرى</string>
|
||||
<string name="resolved_endpoint">نقطة النهاية التي تم حلها</string>
|
||||
<string name="gateway_setup">إعداد البوابة</string>
|
||||
<string name="connect_to_gateway">الاتصال ببوابتك</string>
|
||||
<string name="scan_setup_code">مسح رمز الإعداد</string>
|
||||
<string name="use_gateway_qr">استخدم رمز QR الخاص ببوابتك أو رمز الإعداد</string>
|
||||
<string name="nearby_gateway">بوابة قريبة</string>
|
||||
<string name="enter_gateway_url">أدخل عنوان URL للبوابة</string>
|
||||
<string name="connect_manual_url">الاتصال باستخدام عنوان URL يدوي</string>
|
||||
<string name="permissions">الأذونات</string>
|
||||
<string name="done">تم</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Gateway-Verbindung</string>
|
||||
<string name="connect_gateway">Gateway verbinden</string>
|
||||
<string name="disconnect">Trennen</string>
|
||||
<string name="trust_this_gateway">Diesem Gateway vertrauen?</string>
|
||||
<string name="trust_and_continue">Vertrauen und fortfahren</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="endpoint">Endpunkt</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Ihr Gateway ist aktiv und bereit.</string>
|
||||
<string name="connect_gateway_get_started">Verbinden Sie sich mit Ihrem Gateway, um loszulegen.</string>
|
||||
<string name="copy_report_for_claw">Bericht für Claw kopieren</string>
|
||||
<string name="advanced_controls">Erweiterte Steuerungen</string>
|
||||
<string name="connection_method">Verbindungsmethode</string>
|
||||
<string name="setup_code">Einrichtungscode</string>
|
||||
<string name="manual">Manuell</string>
|
||||
<string name="paste_setup_code">Einrichtungscode einfügen</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">TLS verwenden</string>
|
||||
<string name="token_optional">Token (optional)</string>
|
||||
<string name="password">Passwort</string>
|
||||
<string name="run_onboarding_again">Onboarding erneut ausführen</string>
|
||||
<string name="resolved_endpoint">Aufgelöster Endpunkt</string>
|
||||
<string name="gateway_setup">Gateway-Einrichtung</string>
|
||||
<string name="connect_to_gateway">Mit Ihrem Gateway verbinden</string>
|
||||
<string name="scan_setup_code">Einrichtungscode scannen</string>
|
||||
<string name="use_gateway_qr">Verwenden Sie Ihren Gateway-QR- oder Einrichtungscode</string>
|
||||
<string name="nearby_gateway">Gateway in der Nähe</string>
|
||||
<string name="enter_gateway_url">Gateway-URL eingeben</string>
|
||||
<string name="connect_manual_url">Über eine manuelle URL verbinden</string>
|
||||
<string name="permissions">Berechtigungen</string>
|
||||
<string name="done">Fertig</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Conexión de Gateway</string>
|
||||
<string name="connect_gateway">Conectar Gateway</string>
|
||||
<string name="disconnect">Desconectar</string>
|
||||
<string name="trust_this_gateway">¿Confiar en este gateway?</string>
|
||||
<string name="trust_and_continue">Confiar y continuar</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Estado</string>
|
||||
<string name="connected_gateway_ready">Tu gateway está activo y listo.</string>
|
||||
<string name="connect_gateway_get_started">Conéctate a tu gateway para empezar.</string>
|
||||
<string name="copy_report_for_claw">Copiar informe para Claw</string>
|
||||
<string name="advanced_controls">Controles avanzados</string>
|
||||
<string name="connection_method">Método de conexión</string>
|
||||
<string name="setup_code">Código de configuración</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Pegar código de configuración</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Usar TLS</string>
|
||||
<string name="token_optional">Token (opcional)</string>
|
||||
<string name="password">Contraseña</string>
|
||||
<string name="run_onboarding_again">Ejecutar la incorporación de nuevo</string>
|
||||
<string name="resolved_endpoint">Endpoint resuelto</string>
|
||||
<string name="gateway_setup">Configuración de Gateway</string>
|
||||
<string name="connect_to_gateway">Conéctate a tu Gateway</string>
|
||||
<string name="scan_setup_code">Escanear código de configuración</string>
|
||||
<string name="use_gateway_qr">Usa el QR o código de configuración de tu Gateway</string>
|
||||
<string name="nearby_gateway">Gateway cercano</string>
|
||||
<string name="enter_gateway_url">Introduce la URL del gateway</string>
|
||||
<string name="connect_manual_url">Conectar usando una URL manual</string>
|
||||
<string name="permissions">Permisos</string>
|
||||
<string name="done">Listo</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">اتصال دروازه</string>
|
||||
<string name="connect_gateway">اتصال به دروازه</string>
|
||||
<string name="disconnect">قطع اتصال</string>
|
||||
<string name="trust_this_gateway">به این دروازه اعتماد دارید؟</string>
|
||||
<string name="trust_and_continue">اعتماد و ادامه</string>
|
||||
<string name="cancel">لغو</string>
|
||||
<string name="endpoint">نقطه پایانی</string>
|
||||
<string name="status">وضعیت</string>
|
||||
<string name="connected_gateway_ready">دروازه شما فعال و آماده است.</string>
|
||||
<string name="connect_gateway_get_started">برای شروع، به دروازه خود متصل شوید.</string>
|
||||
<string name="copy_report_for_claw">کپی گزارش برای Claw</string>
|
||||
<string name="advanced_controls">کنترلهای پیشرفته</string>
|
||||
<string name="connection_method">روش اتصال</string>
|
||||
<string name="setup_code">کد راهاندازی</string>
|
||||
<string name="manual">دستی</string>
|
||||
<string name="paste_setup_code">کد راهاندازی را جایگذاری کنید</string>
|
||||
<string name="host">میزبان</string>
|
||||
<string name="use_tls">استفاده از TLS</string>
|
||||
<string name="token_optional">توکن (اختیاری)</string>
|
||||
<string name="password">رمز عبور</string>
|
||||
<string name="run_onboarding_again">اجرای دوباره فرایند شروع به کار</string>
|
||||
<string name="resolved_endpoint">نقطه پایانی حلشده</string>
|
||||
<string name="gateway_setup">راهاندازی دروازه</string>
|
||||
<string name="connect_to_gateway">به دروازه خود متصل شوید</string>
|
||||
<string name="scan_setup_code">اسکن کد راهاندازی</string>
|
||||
<string name="use_gateway_qr">از QR دروازه یا کد راهاندازی خود استفاده کنید</string>
|
||||
<string name="nearby_gateway">دروازه نزدیک</string>
|
||||
<string name="enter_gateway_url">URL دروازه را وارد کنید</string>
|
||||
<string name="connect_manual_url">اتصال با استفاده از URL دستی</string>
|
||||
<string name="permissions">مجوزها</string>
|
||||
<string name="done">انجام شد</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Connexion à la passerelle</string>
|
||||
<string name="connect_gateway">Connecter la passerelle</string>
|
||||
<string name="disconnect">Déconnecter</string>
|
||||
<string name="trust_this_gateway">Faire confiance à cette passerelle ?</string>
|
||||
<string name="trust_and_continue">Faire confiance et continuer</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="endpoint">Point de terminaison</string>
|
||||
<string name="status">État</string>
|
||||
<string name="connected_gateway_ready">Votre passerelle est active et prête.</string>
|
||||
<string name="connect_gateway_get_started">Connectez-vous à votre passerelle pour commencer.</string>
|
||||
<string name="copy_report_for_claw">Copier le rapport pour Claw</string>
|
||||
<string name="advanced_controls">Contrôles avancés</string>
|
||||
<string name="connection_method">Méthode de connexion</string>
|
||||
<string name="setup_code">Code de configuration</string>
|
||||
<string name="manual">Manuel</string>
|
||||
<string name="paste_setup_code">Coller le code de configuration</string>
|
||||
<string name="host">Hôte</string>
|
||||
<string name="use_tls">Utiliser TLS</string>
|
||||
<string name="token_optional">Jeton (facultatif)</string>
|
||||
<string name="password">Mot de passe</string>
|
||||
<string name="run_onboarding_again">Relancer l’intégration</string>
|
||||
<string name="resolved_endpoint">Point de terminaison résolu</string>
|
||||
<string name="gateway_setup">Configuration de la passerelle</string>
|
||||
<string name="connect_to_gateway">Connectez-vous à votre Gateway</string>
|
||||
<string name="scan_setup_code">Scanner le code de configuration</string>
|
||||
<string name="use_gateway_qr">Utilisez le QR de votre Gateway ou le code de configuration</string>
|
||||
<string name="nearby_gateway">Passerelle à proximité</string>
|
||||
<string name="enter_gateway_url">Saisir l’URL de la passerelle</string>
|
||||
<string name="connect_manual_url">Se connecter avec une URL manuelle</string>
|
||||
<string name="permissions">Autorisations</string>
|
||||
<string name="done">Terminé</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">गेटवे कनेक्शन</string>
|
||||
<string name="connect_gateway">गेटवे कनेक्ट करें</string>
|
||||
<string name="disconnect">डिस्कनेक्ट करें</string>
|
||||
<string name="trust_this_gateway">इस गेटवे पर भरोसा करें?</string>
|
||||
<string name="trust_and_continue">भरोसा करें और जारी रखें</string>
|
||||
<string name="cancel">रद्द करें</string>
|
||||
<string name="endpoint">एंडपॉइंट</string>
|
||||
<string name="status">स्थिति</string>
|
||||
<string name="connected_gateway_ready">आपका गेटवे सक्रिय और तैयार है।</string>
|
||||
<string name="connect_gateway_get_started">शुरू करने के लिए अपने गेटवे से कनेक्ट करें।</string>
|
||||
<string name="copy_report_for_claw">Claw के लिए रिपोर्ट कॉपी करें</string>
|
||||
<string name="advanced_controls">उन्नत नियंत्रण</string>
|
||||
<string name="connection_method">कनेक्शन विधि</string>
|
||||
<string name="setup_code">सेटअप कोड</string>
|
||||
<string name="manual">मैन्युअल</string>
|
||||
<string name="paste_setup_code">सेटअप कोड पेस्ट करें</string>
|
||||
<string name="host">होस्ट</string>
|
||||
<string name="use_tls">TLS का उपयोग करें</string>
|
||||
<string name="token_optional">टोकन (वैकल्पिक)</string>
|
||||
<string name="password">पासवर्ड</string>
|
||||
<string name="run_onboarding_again">ऑनबोर्डिंग फिर से चलाएँ</string>
|
||||
<string name="resolved_endpoint">रिज़ॉल्व किया गया एंडपॉइंट</string>
|
||||
<string name="gateway_setup">गेटवे सेटअप</string>
|
||||
<string name="connect_to_gateway">अपने गेटवे से कनेक्ट करें</string>
|
||||
<string name="scan_setup_code">सेटअप कोड स्कैन करें</string>
|
||||
<string name="use_gateway_qr">अपने गेटवे QR या सेटअप कोड का उपयोग करें</string>
|
||||
<string name="nearby_gateway">नज़दीकी गेटवे</string>
|
||||
<string name="enter_gateway_url">गेटवे URL दर्ज करें</string>
|
||||
<string name="connect_manual_url">मैन्युअल URL का उपयोग करके कनेक्ट करें</string>
|
||||
<string name="permissions">अनुमतियाँ</string>
|
||||
<string name="done">हो गया</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Koneksi Gateway</string>
|
||||
<string name="connect_gateway">Hubungkan Gateway</string>
|
||||
<string name="disconnect">Putuskan koneksi</string>
|
||||
<string name="trust_this_gateway">Percayai gateway ini?</string>
|
||||
<string name="trust_and_continue">Percayai dan lanjutkan</string>
|
||||
<string name="cancel">Batal</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Gateway Anda aktif dan siap.</string>
|
||||
<string name="connect_gateway_get_started">Hubungkan ke gateway Anda untuk memulai.</string>
|
||||
<string name="copy_report_for_claw">Salin Laporan untuk Claw</string>
|
||||
<string name="advanced_controls">Kontrol lanjutan</string>
|
||||
<string name="connection_method">Metode koneksi</string>
|
||||
<string name="setup_code">Kode Penyiapan</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Tempel kode penyiapan</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Gunakan TLS</string>
|
||||
<string name="token_optional">Token (opsional)</string>
|
||||
<string name="password">Kata sandi</string>
|
||||
<string name="run_onboarding_again">Jalankan onboarding lagi</string>
|
||||
<string name="resolved_endpoint">Endpoint yang diselesaikan</string>
|
||||
<string name="gateway_setup">Penyiapan Gateway</string>
|
||||
<string name="connect_to_gateway">Hubungkan ke Gateway Anda</string>
|
||||
<string name="scan_setup_code">Pindai kode penyiapan</string>
|
||||
<string name="use_gateway_qr">Gunakan QR Gateway atau kode penyiapan Anda</string>
|
||||
<string name="nearby_gateway">Gateway terdekat</string>
|
||||
<string name="enter_gateway_url">Masukkan URL gateway</string>
|
||||
<string name="connect_manual_url">Hubungkan menggunakan URL manual</string>
|
||||
<string name="permissions">Izin</string>
|
||||
<string name="done">Selesai</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Connessione al gateway</string>
|
||||
<string name="connect_gateway">Connetti gateway</string>
|
||||
<string name="disconnect">Disconnetti</string>
|
||||
<string name="trust_this_gateway">Considerare attendibile questo gateway?</string>
|
||||
<string name="trust_and_continue">Considera attendibile e continua</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Stato</string>
|
||||
<string name="connected_gateway_ready">Il tuo gateway è attivo e pronto.</string>
|
||||
<string name="connect_gateway_get_started">Connettiti al tuo gateway per iniziare.</string>
|
||||
<string name="copy_report_for_claw">Copia report per Claw</string>
|
||||
<string name="advanced_controls">Controlli avanzati</string>
|
||||
<string name="connection_method">Metodo di connessione</string>
|
||||
<string name="setup_code">Codice di configurazione</string>
|
||||
<string name="manual">Manuale</string>
|
||||
<string name="paste_setup_code">Incolla codice di configurazione</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Usa TLS</string>
|
||||
<string name="token_optional">Token (opzionale)</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="run_onboarding_again">Esegui di nuovo l'onboarding</string>
|
||||
<string name="resolved_endpoint">Endpoint risolto</string>
|
||||
<string name="gateway_setup">Configurazione gateway</string>
|
||||
<string name="connect_to_gateway">Connettiti al tuo Gateway</string>
|
||||
<string name="scan_setup_code">Scansiona codice di configurazione</string>
|
||||
<string name="use_gateway_qr">Usa il QR del tuo Gateway o il codice di configurazione</string>
|
||||
<string name="nearby_gateway">Gateway nelle vicinanze</string>
|
||||
<string name="enter_gateway_url">Inserisci URL del gateway</string>
|
||||
<string name="connect_manual_url">Connetti usando un URL manuale</string>
|
||||
<string name="permissions">Autorizzazioni</string>
|
||||
<string name="done">Fine</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">ゲートウェイ接続</string>
|
||||
<string name="connect_gateway">ゲートウェイに接続</string>
|
||||
<string name="disconnect">切断</string>
|
||||
<string name="trust_this_gateway">このゲートウェイを信頼しますか?</string>
|
||||
<string name="trust_and_continue">信頼して続行</string>
|
||||
<string name="cancel">キャンセル</string>
|
||||
<string name="endpoint">エンドポイント</string>
|
||||
<string name="status">ステータス</string>
|
||||
<string name="connected_gateway_ready">ゲートウェイはアクティブで準備完了です。</string>
|
||||
<string name="connect_gateway_get_started">開始するにはゲートウェイに接続してください。</string>
|
||||
<string name="copy_report_for_claw">Claw 用レポートをコピー</string>
|
||||
<string name="advanced_controls">詳細コントロール</string>
|
||||
<string name="connection_method">接続方法</string>
|
||||
<string name="setup_code">セットアップコード</string>
|
||||
<string name="manual">手動</string>
|
||||
<string name="paste_setup_code">セットアップコードを貼り付け</string>
|
||||
<string name="host">ホスト</string>
|
||||
<string name="use_tls">TLS を使用</string>
|
||||
<string name="token_optional">トークン(任意)</string>
|
||||
<string name="password">パスワード</string>
|
||||
<string name="run_onboarding_again">オンボーディングを再実行</string>
|
||||
<string name="resolved_endpoint">解決済みエンドポイント</string>
|
||||
<string name="gateway_setup">ゲートウェイ設定</string>
|
||||
<string name="connect_to_gateway">ゲートウェイに接続</string>
|
||||
<string name="scan_setup_code">セットアップコードをスキャン</string>
|
||||
<string name="use_gateway_qr">ゲートウェイの QR またはセットアップコードを使用</string>
|
||||
<string name="nearby_gateway">近くのゲートウェイ</string>
|
||||
<string name="enter_gateway_url">ゲートウェイ URL を入力</string>
|
||||
<string name="connect_manual_url">手動 URL で接続</string>
|
||||
<string name="permissions">権限</string>
|
||||
<string name="done">完了</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">게이트웨이 연결</string>
|
||||
<string name="connect_gateway">게이트웨이 연결</string>
|
||||
<string name="disconnect">연결 해제</string>
|
||||
<string name="trust_this_gateway">이 게이트웨이를 신뢰하시겠습니까?</string>
|
||||
<string name="trust_and_continue">신뢰하고 계속</string>
|
||||
<string name="cancel">취소</string>
|
||||
<string name="endpoint">엔드포인트</string>
|
||||
<string name="status">상태</string>
|
||||
<string name="connected_gateway_ready">게이트웨이가 활성화되어 준비되었습니다.</string>
|
||||
<string name="connect_gateway_get_started">시작하려면 게이트웨이에 연결하세요.</string>
|
||||
<string name="copy_report_for_claw">Claw용 보고서 복사</string>
|
||||
<string name="advanced_controls">고급 제어</string>
|
||||
<string name="connection_method">연결 방법</string>
|
||||
<string name="setup_code">설정 코드</string>
|
||||
<string name="manual">수동</string>
|
||||
<string name="paste_setup_code">설정 코드 붙여넣기</string>
|
||||
<string name="host">호스트</string>
|
||||
<string name="use_tls">TLS 사용</string>
|
||||
<string name="token_optional">토큰(선택 사항)</string>
|
||||
<string name="password">비밀번호</string>
|
||||
<string name="run_onboarding_again">온보딩 다시 실행</string>
|
||||
<string name="resolved_endpoint">확인된 엔드포인트</string>
|
||||
<string name="gateway_setup">게이트웨이 설정</string>
|
||||
<string name="connect_to_gateway">게이트웨이에 연결</string>
|
||||
<string name="scan_setup_code">설정 코드 스캔</string>
|
||||
<string name="use_gateway_qr">게이트웨이 QR 또는 설정 코드 사용</string>
|
||||
<string name="nearby_gateway">주변 게이트웨이</string>
|
||||
<string name="enter_gateway_url">게이트웨이 URL 입력</string>
|
||||
<string name="connect_manual_url">수동 URL을 사용하여 연결</string>
|
||||
<string name="permissions">권한</string>
|
||||
<string name="done">완료</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Gatewayverbinding</string>
|
||||
<string name="connect_gateway">Gateway verbinden</string>
|
||||
<string name="disconnect">Verbinding verbreken</string>
|
||||
<string name="trust_this_gateway">Deze gateway vertrouwen?</string>
|
||||
<string name="trust_and_continue">Vertrouwen en doorgaan</string>
|
||||
<string name="cancel">Annuleren</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Je gateway is actief en klaar voor gebruik.</string>
|
||||
<string name="connect_gateway_get_started">Verbind met je gateway om te beginnen.</string>
|
||||
<string name="copy_report_for_claw">Rapport voor Claw kopiëren</string>
|
||||
<string name="advanced_controls">Geavanceerde bediening</string>
|
||||
<string name="connection_method">Verbindingsmethode</string>
|
||||
<string name="setup_code">Setupcode</string>
|
||||
<string name="manual">Handmatig</string>
|
||||
<string name="paste_setup_code">Setupcode plakken</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">TLS gebruiken</string>
|
||||
<string name="token_optional">Token (optioneel)</string>
|
||||
<string name="password">Wachtwoord</string>
|
||||
<string name="run_onboarding_again">Onboarding opnieuw uitvoeren</string>
|
||||
<string name="resolved_endpoint">Opgelost endpoint</string>
|
||||
<string name="gateway_setup">Gateway instellen</string>
|
||||
<string name="connect_to_gateway">Verbinden met je Gateway</string>
|
||||
<string name="scan_setup_code">Setupcode scannen</string>
|
||||
<string name="use_gateway_qr">Gebruik je Gateway-QR-code of setupcode</string>
|
||||
<string name="nearby_gateway">Gateway in de buurt</string>
|
||||
<string name="enter_gateway_url">Gateway-URL invoeren</string>
|
||||
<string name="connect_manual_url">Verbinden met een handmatige URL</string>
|
||||
<string name="permissions">Machtigingen</string>
|
||||
<string name="done">Gereed</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Połączenie z bramą</string>
|
||||
<string name="connect_gateway">Połącz z bramą</string>
|
||||
<string name="disconnect">Rozłącz</string>
|
||||
<string name="trust_this_gateway">Ufać tej bramie?</string>
|
||||
<string name="trust_and_continue">Zaufaj i kontynuuj</string>
|
||||
<string name="cancel">Anuluj</string>
|
||||
<string name="endpoint">Punkt końcowy</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Twoja brama jest aktywna i gotowa.</string>
|
||||
<string name="connect_gateway_get_started">Połącz się ze swoją bramą, aby rozpocząć.</string>
|
||||
<string name="copy_report_for_claw">Kopiuj raport dla Claw</string>
|
||||
<string name="advanced_controls">Zaawansowane ustawienia</string>
|
||||
<string name="connection_method">Metoda połączenia</string>
|
||||
<string name="setup_code">Kod konfiguracji</string>
|
||||
<string name="manual">Ręcznie</string>
|
||||
<string name="paste_setup_code">Wklej kod konfiguracji</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Użyj TLS</string>
|
||||
<string name="token_optional">Token (opcjonalnie)</string>
|
||||
<string name="password">Hasło</string>
|
||||
<string name="run_onboarding_again">Uruchom ponownie wdrażanie</string>
|
||||
<string name="resolved_endpoint">Rozpoznany punkt końcowy</string>
|
||||
<string name="gateway_setup">Konfiguracja bramy</string>
|
||||
<string name="connect_to_gateway">Połącz ze swoją bramą</string>
|
||||
<string name="scan_setup_code">Zeskanuj kod konfiguracji</string>
|
||||
<string name="use_gateway_qr">Użyj kodu QR bramy lub kodu konfiguracji</string>
|
||||
<string name="nearby_gateway">Pobliska brama</string>
|
||||
<string name="enter_gateway_url">Wprowadź URL bramy</string>
|
||||
<string name="connect_manual_url">Połącz, używając ręcznego URL</string>
|
||||
<string name="permissions">Uprawnienia</string>
|
||||
<string name="done">Gotowe</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Conexão do Gateway</string>
|
||||
<string name="connect_gateway">Conectar Gateway</string>
|
||||
<string name="disconnect">Desconectar</string>
|
||||
<string name="trust_this_gateway">Confiar neste gateway?</string>
|
||||
<string name="trust_and_continue">Confiar e continuar</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Seu gateway está ativo e pronto.</string>
|
||||
<string name="connect_gateway_get_started">Conecte-se ao seu gateway para começar.</string>
|
||||
<string name="copy_report_for_claw">Copiar relatório para o Claw</string>
|
||||
<string name="advanced_controls">Controles avançados</string>
|
||||
<string name="connection_method">Método de conexão</string>
|
||||
<string name="setup_code">Código de configuração</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Colar código de configuração</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Usar TLS</string>
|
||||
<string name="token_optional">Token (opcional)</string>
|
||||
<string name="password">Senha</string>
|
||||
<string name="run_onboarding_again">Executar integração novamente</string>
|
||||
<string name="resolved_endpoint">Endpoint resolvido</string>
|
||||
<string name="gateway_setup">Configuração do Gateway</string>
|
||||
<string name="connect_to_gateway">Conecte-se ao seu Gateway</string>
|
||||
<string name="scan_setup_code">Escanear código de configuração</string>
|
||||
<string name="use_gateway_qr">Use o QR do seu Gateway ou o código de configuração</string>
|
||||
<string name="nearby_gateway">Gateway próximo</string>
|
||||
<string name="enter_gateway_url">Inserir URL do gateway</string>
|
||||
<string name="connect_manual_url">Conectar usando uma URL manual</string>
|
||||
<string name="permissions">Permissões</string>
|
||||
<string name="done">Concluído</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Подключение к шлюзу</string>
|
||||
<string name="connect_gateway">Подключить шлюз</string>
|
||||
<string name="disconnect">Отключить</string>
|
||||
<string name="trust_this_gateway">Доверять этому шлюзу?</string>
|
||||
<string name="trust_and_continue">Доверять и продолжить</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="endpoint">Конечная точка</string>
|
||||
<string name="status">Статус</string>
|
||||
<string name="connected_gateway_ready">Ваш шлюз активен и готов.</string>
|
||||
<string name="connect_gateway_get_started">Подключитесь к своему шлюзу, чтобы начать.</string>
|
||||
<string name="copy_report_for_claw">Скопировать отчет для Claw</string>
|
||||
<string name="advanced_controls">Расширенные настройки</string>
|
||||
<string name="connection_method">Способ подключения</string>
|
||||
<string name="setup_code">Код настройки</string>
|
||||
<string name="manual">Вручную</string>
|
||||
<string name="paste_setup_code">Вставьте код настройки</string>
|
||||
<string name="host">Хост</string>
|
||||
<string name="use_tls">Использовать TLS</string>
|
||||
<string name="token_optional">Токен (необязательно)</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="run_onboarding_again">Запустить настройку заново</string>
|
||||
<string name="resolved_endpoint">Разрешенная конечная точка</string>
|
||||
<string name="gateway_setup">Настройка шлюза</string>
|
||||
<string name="connect_to_gateway">Подключитесь к своему шлюзу</string>
|
||||
<string name="scan_setup_code">Сканировать код настройки</string>
|
||||
<string name="use_gateway_qr">Используйте QR-код или код настройки вашего шлюза</string>
|
||||
<string name="nearby_gateway">Шлюз поблизости</string>
|
||||
<string name="enter_gateway_url">Введите URL шлюза</string>
|
||||
<string name="connect_manual_url">Подключиться с помощью URL вручную</string>
|
||||
<string name="permissions">Разрешения</string>
|
||||
<string name="done">Готово</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">การเชื่อมต่อเกตเวย์</string>
|
||||
<string name="connect_gateway">เชื่อมต่อเกตเวย์</string>
|
||||
<string name="disconnect">ตัดการเชื่อมต่อ</string>
|
||||
<string name="trust_this_gateway">เชื่อถือเกตเวย์นี้หรือไม่?</string>
|
||||
<string name="trust_and_continue">เชื่อถือและดำเนินการต่อ</string>
|
||||
<string name="cancel">ยกเลิก</string>
|
||||
<string name="endpoint">เอนด์พอยต์</string>
|
||||
<string name="status">สถานะ</string>
|
||||
<string name="connected_gateway_ready">เกตเวย์ของคุณเปิดใช้งานและพร้อมใช้งานแล้ว</string>
|
||||
<string name="connect_gateway_get_started">เชื่อมต่อกับเกตเวย์ของคุณเพื่อเริ่มต้นใช้งาน</string>
|
||||
<string name="copy_report_for_claw">คัดลอกรายงานสำหรับ Claw</string>
|
||||
<string name="advanced_controls">การควบคุมขั้นสูง</string>
|
||||
<string name="connection_method">วิธีการเชื่อมต่อ</string>
|
||||
<string name="setup_code">รหัสตั้งค่า</string>
|
||||
<string name="manual">ด้วยตนเอง</string>
|
||||
<string name="paste_setup_code">วางรหัสตั้งค่า</string>
|
||||
<string name="host">โฮสต์</string>
|
||||
<string name="use_tls">ใช้ TLS</string>
|
||||
<string name="token_optional">โทเค็น (ไม่บังคับ)</string>
|
||||
<string name="password">รหัสผ่าน</string>
|
||||
<string name="run_onboarding_again">เรียกใช้การเริ่มต้นใช้งานอีกครั้ง</string>
|
||||
<string name="resolved_endpoint">เอนด์พอยต์ที่แก้ไขแล้ว</string>
|
||||
<string name="gateway_setup">การตั้งค่าเกตเวย์</string>
|
||||
<string name="connect_to_gateway">เชื่อมต่อกับเกตเวย์ของคุณ</string>
|
||||
<string name="scan_setup_code">สแกนรหัสตั้งค่า</string>
|
||||
<string name="use_gateway_qr">ใช้ QR ของเกตเวย์หรือรหัสตั้งค่าของคุณ</string>
|
||||
<string name="nearby_gateway">เกตเวย์ใกล้เคียง</string>
|
||||
<string name="enter_gateway_url">ป้อน URL เกตเวย์</string>
|
||||
<string name="connect_manual_url">เชื่อมต่อโดยใช้ URL ด้วยตนเอง</string>
|
||||
<string name="permissions">สิทธิ์</string>
|
||||
<string name="done">เสร็จสิ้น</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Ağ Geçidi Bağlantısı</string>
|
||||
<string name="connect_gateway">Ağ Geçidine Bağlan</string>
|
||||
<string name="disconnect">Bağlantıyı Kes</string>
|
||||
<string name="trust_this_gateway">Bu ağ geçidine güvenilsin mi?</string>
|
||||
<string name="trust_and_continue">Güven ve devam et</string>
|
||||
<string name="cancel">İptal</string>
|
||||
<string name="endpoint">Uç nokta</string>
|
||||
<string name="status">Durum</string>
|
||||
<string name="connected_gateway_ready">Ağ geçidiniz etkin ve hazır.</string>
|
||||
<string name="connect_gateway_get_started">Başlamak için ağ geçidinize bağlanın.</string>
|
||||
<string name="copy_report_for_claw">Claw için Raporu Kopyala</string>
|
||||
<string name="advanced_controls">Gelişmiş kontroller</string>
|
||||
<string name="connection_method">Bağlantı yöntemi</string>
|
||||
<string name="setup_code">Kurulum Kodu</string>
|
||||
<string name="manual">Manuel</string>
|
||||
<string name="paste_setup_code">Kurulum kodunu yapıştır</string>
|
||||
<string name="host">Ana makine</string>
|
||||
<string name="use_tls">TLS kullan</string>
|
||||
<string name="token_optional">Token (isteğe bağlı)</string>
|
||||
<string name="password">Parola</string>
|
||||
<string name="run_onboarding_again">Başlangıç sürecini tekrar çalıştır</string>
|
||||
<string name="resolved_endpoint">Çözümlenen uç nokta</string>
|
||||
<string name="gateway_setup">Ağ Geçidi Kurulumu</string>
|
||||
<string name="connect_to_gateway">Ağ Geçidinize Bağlanın</string>
|
||||
<string name="scan_setup_code">Kurulum kodunu tara</string>
|
||||
<string name="use_gateway_qr">Gateway QR kodunuzu veya kurulum kodunuzu kullanın</string>
|
||||
<string name="nearby_gateway">Yakındaki ağ geçidi</string>
|
||||
<string name="enter_gateway_url">Ağ geçidi URL'sini girin</string>
|
||||
<string name="connect_manual_url">Manuel URL kullanarak bağlan</string>
|
||||
<string name="permissions">İzinler</string>
|
||||
<string name="done">Bitti</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Підключення до шлюзу</string>
|
||||
<string name="connect_gateway">Підключити шлюз</string>
|
||||
<string name="disconnect">Відключити</string>
|
||||
<string name="trust_this_gateway">Довіряти цьому шлюзу?</string>
|
||||
<string name="trust_and_continue">Довіряти й продовжити</string>
|
||||
<string name="cancel">Скасувати</string>
|
||||
<string name="endpoint">Кінцева точка</string>
|
||||
<string name="status">Стан</string>
|
||||
<string name="connected_gateway_ready">Ваш шлюз активний і готовий.</string>
|
||||
<string name="connect_gateway_get_started">Підключіться до свого шлюзу, щоб почати.</string>
|
||||
<string name="copy_report_for_claw">Скопіювати звіт для Claw</string>
|
||||
<string name="advanced_controls">Розширені елементи керування</string>
|
||||
<string name="connection_method">Спосіб підключення</string>
|
||||
<string name="setup_code">Код налаштування</string>
|
||||
<string name="manual">Вручну</string>
|
||||
<string name="paste_setup_code">Вставте код налаштування</string>
|
||||
<string name="host">Хост</string>
|
||||
<string name="use_tls">Використовувати TLS</string>
|
||||
<string name="token_optional">Токен (необов’язково)</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="run_onboarding_again">Запустити адаптацію знову</string>
|
||||
<string name="resolved_endpoint">Визначена кінцева точка</string>
|
||||
<string name="gateway_setup">Налаштування шлюзу</string>
|
||||
<string name="connect_to_gateway">Підключіться до свого шлюзу</string>
|
||||
<string name="scan_setup_code">Сканувати код налаштування</string>
|
||||
<string name="use_gateway_qr">Використайте QR-код свого шлюзу або код налаштування</string>
|
||||
<string name="nearby_gateway">Шлюз поблизу</string>
|
||||
<string name="enter_gateway_url">Введіть URL-адресу шлюзу</string>
|
||||
<string name="connect_manual_url">Підключитися за допомогою URL-адреси вручну</string>
|
||||
<string name="permissions">Дозволи</string>
|
||||
<string name="done">Готово</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Kết nối cổng</string>
|
||||
<string name="connect_gateway">Kết nối cổng</string>
|
||||
<string name="disconnect">Ngắt kết nối</string>
|
||||
<string name="trust_this_gateway">Tin cậy cổng này?</string>
|
||||
<string name="trust_and_continue">Tin cậy và tiếp tục</string>
|
||||
<string name="cancel">Hủy</string>
|
||||
<string name="endpoint">Điểm cuối</string>
|
||||
<string name="status">Trạng thái</string>
|
||||
<string name="connected_gateway_ready">Cổng của bạn đang hoạt động và sẵn sàng.</string>
|
||||
<string name="connect_gateway_get_started">Kết nối với cổng của bạn để bắt đầu.</string>
|
||||
<string name="copy_report_for_claw">Sao chép báo cáo cho Claw</string>
|
||||
<string name="advanced_controls">Điều khiển nâng cao</string>
|
||||
<string name="connection_method">Phương thức kết nối</string>
|
||||
<string name="setup_code">Mã thiết lập</string>
|
||||
<string name="manual">Thủ công</string>
|
||||
<string name="paste_setup_code">Dán mã thiết lập</string>
|
||||
<string name="host">Máy chủ</string>
|
||||
<string name="use_tls">Sử dụng TLS</string>
|
||||
<string name="token_optional">Token (tùy chọn)</string>
|
||||
<string name="password">Mật khẩu</string>
|
||||
<string name="run_onboarding_again">Chạy hướng dẫn thiết lập lại</string>
|
||||
<string name="resolved_endpoint">Điểm cuối đã phân giải</string>
|
||||
<string name="gateway_setup">Thiết lập cổng</string>
|
||||
<string name="connect_to_gateway">Kết nối với Gateway của bạn</string>
|
||||
<string name="scan_setup_code">Quét mã thiết lập</string>
|
||||
<string name="use_gateway_qr">Sử dụng mã QR Gateway hoặc mã thiết lập của bạn</string>
|
||||
<string name="nearby_gateway">Cổng gần đây</string>
|
||||
<string name="enter_gateway_url">Nhập URL cổng</string>
|
||||
<string name="connect_manual_url">Kết nối bằng URL thủ công</string>
|
||||
<string name="permissions">Quyền</string>
|
||||
<string name="done">Xong</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">网关连接</string>
|
||||
<string name="connect_gateway">连接网关</string>
|
||||
<string name="disconnect">断开连接</string>
|
||||
<string name="trust_this_gateway">信任此网关?</string>
|
||||
<string name="trust_and_continue">信任并继续</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="endpoint">端点</string>
|
||||
<string name="status">状态</string>
|
||||
<string name="connected_gateway_ready">你的网关已激活并准备就绪。</string>
|
||||
<string name="connect_gateway_get_started">连接到你的网关以开始使用。</string>
|
||||
<string name="copy_report_for_claw">复制 Claw 报告</string>
|
||||
<string name="advanced_controls">高级控制</string>
|
||||
<string name="connection_method">连接方式</string>
|
||||
<string name="setup_code">设置代码</string>
|
||||
<string name="manual">手动</string>
|
||||
<string name="paste_setup_code">粘贴设置代码</string>
|
||||
<string name="host">主机</string>
|
||||
<string name="use_tls">使用 TLS</string>
|
||||
<string name="token_optional">令牌(可选)</string>
|
||||
<string name="password">密码</string>
|
||||
<string name="run_onboarding_again">再次运行引导流程</string>
|
||||
<string name="resolved_endpoint">已解析的端点</string>
|
||||
<string name="gateway_setup">网关设置</string>
|
||||
<string name="connect_to_gateway">连接到你的网关</string>
|
||||
<string name="scan_setup_code">扫描设置代码</string>
|
||||
<string name="use_gateway_qr">使用你的网关 QR 码或设置代码</string>
|
||||
<string name="nearby_gateway">附近的网关</string>
|
||||
<string name="enter_gateway_url">输入网关 URL</string>
|
||||
<string name="connect_manual_url">使用手动 URL 连接</string>
|
||||
<string name="permissions">权限</string>
|
||||
<string name="done">完成</string>
|
||||
</resources>
|
||||
@@ -1,34 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">閘道連線</string>
|
||||
<string name="connect_gateway">連接閘道</string>
|
||||
<string name="disconnect">中斷連線</string>
|
||||
<string name="trust_this_gateway">信任此閘道?</string>
|
||||
<string name="trust_and_continue">信任並繼續</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="endpoint">端點</string>
|
||||
<string name="status">狀態</string>
|
||||
<string name="connected_gateway_ready">您的閘道已啟用並準備就緒。</string>
|
||||
<string name="connect_gateway_get_started">連接到您的閘道以開始使用。</string>
|
||||
<string name="copy_report_for_claw">複製 Claw 報告</string>
|
||||
<string name="advanced_controls">進階控制項</string>
|
||||
<string name="connection_method">連線方式</string>
|
||||
<string name="setup_code">設定碼</string>
|
||||
<string name="manual">手動</string>
|
||||
<string name="paste_setup_code">貼上設定碼</string>
|
||||
<string name="host">主機</string>
|
||||
<string name="use_tls">使用 TLS</string>
|
||||
<string name="token_optional">權杖(選填)</string>
|
||||
<string name="password">密碼</string>
|
||||
<string name="run_onboarding_again">再次執行新手導覽</string>
|
||||
<string name="resolved_endpoint">已解析的端點</string>
|
||||
<string name="gateway_setup">閘道設定</string>
|
||||
<string name="connect_to_gateway">連接到您的閘道</string>
|
||||
<string name="scan_setup_code">掃描設定碼</string>
|
||||
<string name="use_gateway_qr">使用您的 Gateway QR 或設定碼</string>
|
||||
<string name="nearby_gateway">附近的閘道</string>
|
||||
<string name="enter_gateway_url">輸入閘道 URL</string>
|
||||
<string name="connect_manual_url">使用手動 URL 連接</string>
|
||||
<string name="permissions">權限</string>
|
||||
<string name="done">完成</string>
|
||||
</resources>
|
||||
@@ -1,34 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw Node</string>
|
||||
<string name="gateway_connection">Gateway Connection</string>
|
||||
<string name="connect_gateway">Connect Gateway</string>
|
||||
<string name="disconnect">Disconnect</string>
|
||||
<string name="trust_this_gateway">Trust this gateway?</string>
|
||||
<string name="trust_and_continue">Trust and continue</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="connected_gateway_ready">Your gateway is active and ready.</string>
|
||||
<string name="connect_gateway_get_started">Connect to your gateway to get started.</string>
|
||||
<string name="copy_report_for_claw">Copy Report for Claw</string>
|
||||
<string name="advanced_controls">Advanced controls</string>
|
||||
<string name="connection_method">Connection method</string>
|
||||
<string name="setup_code">Setup Code</string>
|
||||
<string name="manual">Manual</string>
|
||||
<string name="paste_setup_code">Paste setup code</string>
|
||||
<string name="host">Host</string>
|
||||
<string name="use_tls">Use TLS</string>
|
||||
<string name="token_optional">Token (optional)</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="run_onboarding_again">Run onboarding again</string>
|
||||
<string name="resolved_endpoint">Resolved endpoint</string>
|
||||
<string name="gateway_setup">Gateway Setup</string>
|
||||
<string name="connect_to_gateway">Connect to your Gateway</string>
|
||||
<string name="scan_setup_code">Scan setup code</string>
|
||||
<string name="use_gateway_qr">Use your Gateway QR or setup code</string>
|
||||
<string name="nearby_gateway">Nearby gateway</string>
|
||||
<string name="enter_gateway_url">Enter gateway URL</string>
|
||||
<string name="connect_manual_url">Connect using a manual URL</string>
|
||||
<string name="permissions">Permissions</string>
|
||||
<string name="done">Done</string>
|
||||
</resources>
|
||||
|
||||
@@ -211,6 +211,18 @@ each carrier call should start with fresh context, for example reception,
|
||||
booking, IVR, or Google Meet bridge flows where the same phone number may
|
||||
represent different meetings.
|
||||
|
||||
Voice Call stores generated session keys under the configured agent namespace
|
||||
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
|
||||
canonicalization after restarts. Raw explicit integration keys use the same
|
||||
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
|
||||
and its main aliases honor core `session.mainKey` and global scope. Foreign or
|
||||
malformed `agent:*` input is scoped as an opaque key under the configured agent;
|
||||
`global` and `unknown` remain global sentinels. Gateway startup promotes older
|
||||
raw keys in default or `{agentId}`-templated stores where the path proves one
|
||||
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
|
||||
they do not contain enough information to choose an owner; new calls use
|
||||
canonical agent-scoped history.
|
||||
|
||||
## Realtime voice conversations
|
||||
|
||||
`realtime` selects a full-duplex realtime voice provider for live call
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
@@ -13,11 +17,7 @@ const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
||||
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
||||
|
||||
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
return readProviderJsonResponse<T>(response, label);
|
||||
}
|
||||
|
||||
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||
@@ -57,7 +57,7 @@ async function withGoogleChatResponse<T>(params: {
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
const text = await readResponseTextLimited(response).catch(() => "");
|
||||
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
return await handleResponse(response);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Googlechat plugin module implements auth behavior.
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
@@ -17,11 +18,10 @@ const CHAT_CERTS_URL =
|
||||
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
|
||||
|
||||
async function readGoogleChatCertsResponse(response: Response): Promise<Record<string, string>> {
|
||||
try {
|
||||
return (await response.json()) as Record<string, string>;
|
||||
} catch (cause) {
|
||||
throw new Error("Google Chat cert fetch failed: malformed JSON response", { cause });
|
||||
}
|
||||
return readProviderJsonResponse<Record<string, string>>(
|
||||
response,
|
||||
"Google Chat cert fetch failed",
|
||||
);
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
|
||||
|
||||
@@ -568,4 +568,137 @@ describe("verifyGoogleChatRequest", () => {
|
||||
});
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("bounded JSON read (readProviderJsonResponse delegation)", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("cancels oversized cert fetch JSON body via the 16 MiB provider cap", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedJson = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedJson,
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/JSON response exceeds 16777216 bytes/);
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects oversized sendMessage JSON body via the 16 MiB provider cap", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedJson = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedJson,
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow(/Google Chat API request failed: JSON response exceeds 16777216 bytes/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
});
|
||||
|
||||
it("caps non-OK sendMessage error bodies before formatting the API error", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new TextEncoder().encode("x".repeat(ONE_MIB));
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedError = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 500, statusText: "Internal Server Error" },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedError,
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow(/^Google Chat API 500: x+/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -713,4 +713,100 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
|
||||
controller.end();
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
|
||||
// Regression for https://github.com/openclaw/openclaw/issues/96518:
|
||||
// the idle timer must re-arm on block-boundary events (text_end,
|
||||
// thinking_end, toolcall_start, toolcall_end), not only on token
|
||||
// deltas. A stream that keeps producing boundary events between
|
||||
// deltas is demonstrably alive and must not be aborted.
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const idleTimeoutMs = 5_000;
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
|
||||
|
||||
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
controller.emit({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: 0,
|
||||
delta: "{",
|
||||
partial,
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The model finalizes the tool call and deliberates on the next one,
|
||||
// emitting real block-boundary events that prove the SSE socket is alive.
|
||||
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
controller.emit({
|
||||
type: "toolcall_end",
|
||||
contentIndex: 0,
|
||||
toolCall: { name: "f", arguments: "{}" },
|
||||
partial,
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
controller.emit({
|
||||
type: "toolcall_start",
|
||||
contentIndex: 1,
|
||||
partial,
|
||||
} as any);
|
||||
|
||||
// Advance to 5s after the last delta, but only 2s after the last
|
||||
// boundary event. The idle timer should have been re-armed by the
|
||||
// boundary events, so it must NOT fire yet.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
// The provider's completed answer arrives right after.
|
||||
controller.emit({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
...partial,
|
||||
content: [{ type: "text", text: "final answer" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
} as any);
|
||||
controller.end();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await consumer;
|
||||
|
||||
const hasDone = received.some((e) => e.type === "done");
|
||||
const hasStalledError = received.some(
|
||||
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
|
||||
);
|
||||
|
||||
expect(abortCalled).toBe(false);
|
||||
expect(hasDone).toBe(true);
|
||||
expect(hasStalledError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,11 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
|
||||
return (
|
||||
event.type === "text_delta" ||
|
||||
event.type === "thinking_delta" ||
|
||||
event.type === "toolcall_delta"
|
||||
event.type === "toolcall_delta" ||
|
||||
event.type === "text_end" ||
|
||||
event.type === "thinking_end" ||
|
||||
event.type === "toolcall_start" ||
|
||||
event.type === "toolcall_end"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { z } from "zod";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { validateSlackBlocksArray } from "./blocks-input.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient, getSlackWriteClient } from "./client.js";
|
||||
import { buildSlackEditTextPayload } from "./edit-text.js";
|
||||
import { resolveSlackMedia } from "./monitor/media.js";
|
||||
@@ -71,6 +72,22 @@ function resolveToken(explicit?: string, accountId?: string, cfg?: OpenClawConfi
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveSlackActionClientOptions(opts: SlackActionClientOpts) {
|
||||
if (!opts.cfg) {
|
||||
return createSlackApiUrlClientOptions();
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Slack actions");
|
||||
resolveSlackAccount({ cfg, accountId: opts.accountId });
|
||||
return createSlackApiUrlClientOptions();
|
||||
}
|
||||
|
||||
function slackActionClientOptionArgs(
|
||||
opts: SlackActionClientOpts,
|
||||
): [] | [ReturnType<typeof createSlackApiUrlClientOptions>] {
|
||||
const clientOptions = resolveSlackActionClientOptions(opts);
|
||||
return clientOptions.slackApiUrl ? [clientOptions] : [];
|
||||
}
|
||||
|
||||
function normalizeEmoji(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -131,7 +148,10 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write
|
||||
return opts.client;
|
||||
}
|
||||
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
|
||||
return mode === "write" ? getSlackWriteClient(token) : createSlackWebClient(token);
|
||||
const clientOptionArgs = slackActionClientOptionArgs(opts);
|
||||
return mode === "write"
|
||||
? getSlackWriteClient(token, ...clientOptionArgs)
|
||||
: createSlackWebClient(token, ...clientOptionArgs);
|
||||
}
|
||||
|
||||
async function resolveBotUserId(client: WebClient) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { normalizeAllowListLower } from "./monitor/allow-list.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -76,7 +77,7 @@ export async function resolveSlackConversationInfo(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createSlackWebClient(token);
|
||||
const client = createSlackWebClient(token, createSlackApiUrlClientOptions());
|
||||
if (isNativeImChannel) {
|
||||
const opened = await client.conversations.open({
|
||||
channel: channelId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Slack plugin module implements channel behavior.
|
||||
import {
|
||||
buildLegacyDmAccountAllowlistAdapter,
|
||||
createAccountScopedAllowlistNameResolver,
|
||||
createFlatAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
@@ -52,6 +51,7 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import { resolveSlackChannelType, resolveSlackConversationInfo } from "./channel-type.js";
|
||||
import { createSlackApiUrlClientOptions, type SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
|
||||
import {
|
||||
@@ -405,19 +405,40 @@ function formatSlackScopeDiagnostic(params: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function slackApiUrlOptionArgs(): [] | [SlackApiUrlClientOptions] {
|
||||
const options = createSlackApiUrlClientOptions();
|
||||
return options.slackApiUrl ? [options] : [];
|
||||
}
|
||||
|
||||
const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({
|
||||
resolveRecord: (account: ResolvedSlackAccount) => account.channels,
|
||||
label: (key) => key,
|
||||
resolveEntries: (value) => value?.users,
|
||||
});
|
||||
|
||||
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
|
||||
resolveAccount: resolveSlackAccount,
|
||||
resolveToken: (account: ResolvedSlackAccount) =>
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
resolveNames: async ({ token, entries }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
|
||||
});
|
||||
const resolveSlackAllowlistNames = async ({
|
||||
accountId,
|
||||
cfg,
|
||||
entries,
|
||||
}: {
|
||||
accountId?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
entries: string[];
|
||||
}) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token =
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
return await (
|
||||
await loadSlackResolveUsersModule()
|
||||
).resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
});
|
||||
};
|
||||
|
||||
const slackChannelOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
@@ -654,6 +675,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
(await loadSlackResolveChannelsModule()).resolveSlackChannelAllowlist({
|
||||
token,
|
||||
entries: inputsValue,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
}),
|
||||
mapResolved: (entry) =>
|
||||
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
|
||||
@@ -661,14 +683,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
}
|
||||
return resolveTargetsWithOptionalToken({
|
||||
token:
|
||||
normalizeOptionalString(account.userToken) ??
|
||||
normalizeOptionalString(account.botToken),
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
inputs,
|
||||
missingTokenNote: "missing Slack token",
|
||||
resolveWithToken: async ({ token, inputs: inputsLocal }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries: inputsLocal,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
}),
|
||||
mapResolved: (entry) => toResolvedTarget(entry, entry.note),
|
||||
});
|
||||
@@ -695,7 +717,9 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
if (!token) {
|
||||
return { ok: false, error: "missing token" };
|
||||
}
|
||||
return await (await loadSlackProbeModule()).probeSlack(token, timeoutMs);
|
||||
return await (
|
||||
await loadSlackProbeModule()
|
||||
).probeSlack(token, timeoutMs, ...slackApiUrlOptionArgs());
|
||||
},
|
||||
formatCapabilitiesProbe: ({ probe }) => {
|
||||
const slackProbe = probe as SlackProbe | undefined;
|
||||
@@ -715,13 +739,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const botToken = account.botToken?.trim();
|
||||
const userToken = account.userToken?.trim();
|
||||
const { fetchSlackScopes } = await loadSlackScopesModule();
|
||||
const apiUrlOptionArgs = slackApiUrlOptionArgs();
|
||||
const botScopes: SlackScopesResultShape = botToken
|
||||
? await fetchSlackScopes(botToken, timeoutMs)
|
||||
? await fetchSlackScopes(botToken, timeoutMs, ...apiUrlOptionArgs)
|
||||
: { ok: false, error: "Slack bot token missing." };
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
|
||||
details.botScopes = botScopes;
|
||||
if (userToken) {
|
||||
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
|
||||
const userScopes = await fetchSlackScopes(userToken, timeoutMs, ...apiUrlOptionArgs);
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
|
||||
details.userScopes = userScopes;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Agent } from "node:http";
|
||||
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
|
||||
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
|
||||
export type SlackApiUrlClientOptions = Pick<WebClientOptions, "slackApiUrl">;
|
||||
|
||||
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
retries: 2,
|
||||
factor: 2,
|
||||
@@ -30,12 +32,11 @@ export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
|
||||
* Returns `undefined` when no proxy env var is configured or when Slack hosts
|
||||
* are excluded by `NO_PROXY`.
|
||||
*/
|
||||
function resolveSlackProxyAgent(): Agent | undefined {
|
||||
function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
|
||||
try {
|
||||
return createNodeProxyAgent({
|
||||
mode: "env",
|
||||
targetUrl: "https://slack.com/",
|
||||
protocol: "https",
|
||||
targetUrl,
|
||||
});
|
||||
} catch {
|
||||
// Malformed proxy URL; degrade gracefully to direct connection.
|
||||
@@ -43,19 +44,38 @@ function resolveSlackProxyAgent(): Agent | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlackApiUrlFromOptions(
|
||||
options: Pick<WebClientOptions, "slackApiUrl">,
|
||||
): string | undefined {
|
||||
const explicit = options.slackApiUrl?.trim();
|
||||
const envDefault = process.env.SLACK_API_URL?.trim();
|
||||
return explicit || envDefault || undefined;
|
||||
}
|
||||
|
||||
export function createSlackApiUrlClientOptions(): SlackApiUrlClientOptions {
|
||||
const slackApiUrl = process.env.SLACK_API_URL?.trim();
|
||||
return slackApiUrl ? { slackApiUrl } : {};
|
||||
}
|
||||
|
||||
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
|
||||
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
|
||||
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
|
||||
return {
|
||||
...options,
|
||||
agent: options.agent ?? resolveSlackProxyAgent(),
|
||||
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
|
||||
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
|
||||
...(slackApiUrl ? { slackApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
|
||||
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
|
||||
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
|
||||
return {
|
||||
...options,
|
||||
agent: options.agent ?? resolveSlackProxyAgent(),
|
||||
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
|
||||
retryConfig: options.retryConfig ?? SLACK_WRITE_RETRY_OPTIONS,
|
||||
maxRequestConcurrency: options.maxRequestConcurrency ?? 1,
|
||||
...(slackApiUrl ? { slackApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETR
|
||||
let SLACK_WRITE_RETRY_OPTIONS: typeof import("./client.js").SLACK_WRITE_RETRY_OPTIONS;
|
||||
let WebClient: ReturnType<typeof vi.fn>;
|
||||
|
||||
const SLACK_API_URL_KEYS = ["SLACK_API_URL", "OPENCLAW_SLACK_API_URL"] as const;
|
||||
const PROXY_KEYS = [
|
||||
"HTTPS_PROXY",
|
||||
"HTTP_PROXY",
|
||||
@@ -56,6 +57,22 @@ function restoreProxyEnvForTest() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSlackApiUrlEnvForTest() {
|
||||
for (const key of SLACK_API_URL_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSlackApiUrlEnvForTest() {
|
||||
for (const key of SLACK_API_URL_KEYS) {
|
||||
if (originalEnv[key] !== undefined) {
|
||||
process.env[key] = originalEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireAgent<T extends { agent?: unknown }>(options: T): NonNullable<T["agent"]> {
|
||||
if (!options.agent) {
|
||||
throw new Error("expected proxy agent");
|
||||
@@ -90,6 +107,11 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
WebClient.mockClear();
|
||||
clearSlackWriteClientCacheForTest();
|
||||
clearSlackApiUrlEnvForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreSlackApiUrlEnvForTest();
|
||||
});
|
||||
|
||||
describe("slack web client config", () => {
|
||||
@@ -106,6 +128,40 @@ describe("slack web client config", () => {
|
||||
expect(options.retryConfig).toBe(customRetry);
|
||||
});
|
||||
|
||||
it("uses explicit Slack API URL as the Slack Web API root", () => {
|
||||
expect(
|
||||
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49152/api/");
|
||||
expect(
|
||||
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49152/api/");
|
||||
});
|
||||
|
||||
it("uses SLACK_API_URL as the default Slack Web API root", () => {
|
||||
process.env.SLACK_API_URL = " http://127.0.0.1:49152/api/ ";
|
||||
|
||||
expect(resolveSlackWebClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
|
||||
expect(resolveSlackWriteClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
|
||||
});
|
||||
|
||||
it("does not read OPENCLAW_SLACK_API_URL as a default Slack Web API root", () => {
|
||||
process.env.OPENCLAW_SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
|
||||
expect(resolveSlackWebClientOptions().slackApiUrl).toBeUndefined();
|
||||
expect(resolveSlackWriteClientOptions().slackApiUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers explicit Slack API URL over SLACK_API_URL", () => {
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
|
||||
expect(
|
||||
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49153/api/");
|
||||
expect(
|
||||
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49153/api/");
|
||||
});
|
||||
|
||||
it("passes merged options into WebClient", () => {
|
||||
const customAgent = {} as never;
|
||||
|
||||
@@ -190,6 +246,38 @@ describe("slack web client config", () => {
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps write clients separated by Slack API URL", () => {
|
||||
clearProxyEnvForTest();
|
||||
try {
|
||||
const first = getSlackWriteClient("xoxb-test", {
|
||||
slackApiUrl: "http://127.0.0.1:49152/api/",
|
||||
});
|
||||
const second = getSlackWriteClient("xoxb-test", {
|
||||
slackApiUrl: "http://127.0.0.1:49153/api/",
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
restoreProxyEnvForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps write clients separated by SLACK_API_URL", () => {
|
||||
clearProxyEnvForTest();
|
||||
try {
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
const first = getSlackWriteClient("xoxb-test");
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49153/api/";
|
||||
const second = getSlackWriteClient("xoxb-test");
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
restoreProxyEnvForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds stable non-secret token cache keys", () => {
|
||||
const token = "xoxb-sensitive-token";
|
||||
const first = createSlackTokenCacheKey(token);
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
// Slack plugin module implements client behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { type WebClientOptions, WebClient } from "@slack/web-api";
|
||||
import { resolveSlackWebClientOptions, resolveSlackWriteClientOptions } from "./client-options.js";
|
||||
import {
|
||||
resolveSlackWebClientOptions,
|
||||
resolveSlackWriteClientOptions,
|
||||
type SlackApiUrlClientOptions,
|
||||
} from "./client-options.js";
|
||||
|
||||
const SLACK_WRITE_CLIENT_CACHE_MAX = 32;
|
||||
const slackWriteClientCache = new Map<string, WebClient>();
|
||||
|
||||
export type SlackWriteClientCacheOptions = SlackApiUrlClientOptions;
|
||||
|
||||
export {
|
||||
createSlackApiUrlClientOptions,
|
||||
resolveSlackWebClientOptions,
|
||||
resolveSlackWriteClientOptions,
|
||||
type SlackApiUrlClientOptions,
|
||||
SLACK_DEFAULT_RETRY_OPTIONS,
|
||||
SLACK_WRITE_RETRY_OPTIONS,
|
||||
} from "./client-options.js";
|
||||
@@ -25,15 +33,27 @@ export function createSlackTokenCacheKey(token: string): string {
|
||||
return `sha256:${createHash("sha256").update(token).digest("base64url")}`;
|
||||
}
|
||||
|
||||
export function getSlackWriteClient(token: string): WebClient {
|
||||
function createSlackWriteClientCacheKey(
|
||||
token: string,
|
||||
options: SlackWriteClientCacheOptions,
|
||||
): string {
|
||||
const tokenKey = createSlackTokenCacheKey(token);
|
||||
return options.slackApiUrl ? `${tokenKey}:api:${options.slackApiUrl}` : tokenKey;
|
||||
}
|
||||
|
||||
export function getSlackWriteClient(
|
||||
token: string,
|
||||
options: SlackWriteClientCacheOptions = {},
|
||||
): WebClient {
|
||||
const resolvedOptions = resolveSlackWriteClientOptions(options);
|
||||
const tokenKey = createSlackWriteClientCacheKey(token, resolvedOptions);
|
||||
const cached = slackWriteClientCache.get(tokenKey);
|
||||
if (cached) {
|
||||
slackWriteClientCache.delete(tokenKey);
|
||||
slackWriteClientCache.set(tokenKey, cached);
|
||||
return cached;
|
||||
}
|
||||
const client = createSlackWriteClient(token);
|
||||
const client = new WebClient(token, resolvedOptions);
|
||||
if (slackWriteClientCache.size >= SLACK_WRITE_CLIENT_CACHE_MAX) {
|
||||
const oldestTokenKey = slackWriteClientCache.keys().next().value;
|
||||
if (oldestTokenKey) {
|
||||
|
||||
@@ -38,6 +38,22 @@ describe("slack config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Slack Web API URL config overrides", () => {
|
||||
const res = SlackConfigSchema.safeParse({
|
||||
apiUrl: "http://127.0.0.1:49152/api/",
|
||||
accounts: { ops: { apiUrl: "http://127.0.0.1:49153/api/" } },
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
if (!res.success) {
|
||||
expect(
|
||||
res.error.issues.some(
|
||||
(issue) => issue.code === "unrecognized_keys" && issue.keys.includes("apiUrl"),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts unfurl controls at root and account level", () => {
|
||||
const res = SlackConfigSchema.safeParse({
|
||||
unfurlLinks: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
type SlackUser = {
|
||||
@@ -50,9 +51,10 @@ type SlackAuthTestResponse = {
|
||||
team?: string;
|
||||
};
|
||||
|
||||
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
|
||||
function createSlackDirectoryClient(params: DirectoryConfigParams) {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
return account.userToken ?? account.botToken?.trim();
|
||||
const token = account.userToken ?? account.botToken?.trim();
|
||||
return token ? createSlackWebClient(token, createSlackApiUrlClientOptions()) : null;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
@@ -101,11 +103,10 @@ function slackUserToDirectoryEntry(
|
||||
export async function getSlackDirectorySelfLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry | null> {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const auth = (await client.auth.test()) as SlackAuthTestResponse;
|
||||
const userId = normalizeOptionalString(auth.user_id);
|
||||
if (!userId) {
|
||||
@@ -125,11 +126,10 @@ export async function getSlackDirectorySelfLive(
|
||||
export async function listSlackDirectoryPeersLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const members: SlackUser[] = [];
|
||||
let cursor: string | undefined;
|
||||
@@ -172,11 +172,10 @@ export async function listSlackDirectoryPeersLive(
|
||||
export async function listSlackDirectoryGroupsLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const channels: SlackChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
resolveSlackAccountDmPolicy,
|
||||
} from "../accounts.js";
|
||||
import { isSlackAnyNativeApprovalClientEnabled } from "../approval-native-gates.js";
|
||||
import { resolveSlackWebClientOptions } from "../client-options.js";
|
||||
import { createSlackApiUrlClientOptions, resolveSlackWebClientOptions } from "../client-options.js";
|
||||
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
|
||||
import { SLACK_TEXT_LIMIT } from "../limits.js";
|
||||
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
|
||||
@@ -288,7 +288,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const clientOptions = resolveSlackWebClientOptions();
|
||||
const slackApiUrlClientOptions = createSlackApiUrlClientOptions();
|
||||
const clientOptions = resolveSlackWebClientOptions(slackApiUrlClientOptions);
|
||||
const { app, receiver, socketModeLogger } = createSlackBoltApp({
|
||||
interop: await getSlackBoltInterop(),
|
||||
slackMode,
|
||||
@@ -462,6 +463,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolved = await resolveSlackChannelAllowlist({
|
||||
token: resolveToken,
|
||||
entries,
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const nextChannels = { ...channelsConfig };
|
||||
const mapping: string[] = [];
|
||||
@@ -507,6 +509,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolvedUsers = await resolveSlackUserAllowlist({
|
||||
token: resolveToken,
|
||||
entries: allowEntries,
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(
|
||||
resolvedUsers,
|
||||
@@ -553,6 +556,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolvedUsers = await resolveSlackUserAllowlist({
|
||||
token: resolveToken,
|
||||
entries: Array.from(userEntries),
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(
|
||||
resolvedUsers,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Slack plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
|
||||
import { formatSlackError } from "./errors.js";
|
||||
|
||||
export type SlackProbe = BaseProbeResult & {
|
||||
@@ -11,8 +11,14 @@ export type SlackProbe = BaseProbeResult & {
|
||||
team?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> {
|
||||
const client = createSlackWebClient(token);
|
||||
export async function probeSlack(
|
||||
token: string,
|
||||
timeoutMs = 2500,
|
||||
options: SlackApiUrlClientOptions = {},
|
||||
): Promise<SlackProbe> {
|
||||
const client = options.slackApiUrl
|
||||
? createSlackWebClient(token, options)
|
||||
: createSlackWebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Slack plugin module implements resolve channels behavior.
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import {
|
||||
collectSlackCursorItems,
|
||||
@@ -101,8 +102,14 @@ export async function resolveSlackChannelAllowlist(params: {
|
||||
token: string;
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
|
||||
}): Promise<SlackChannelResolution[]> {
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const client =
|
||||
params.client ??
|
||||
createSlackWebClient(
|
||||
params.token,
|
||||
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
|
||||
);
|
||||
const channels = await listSlackChannels(client);
|
||||
return resolveSlackAllowlistEntries<
|
||||
{ id?: string; name?: string },
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import {
|
||||
collectSlackCursorItems,
|
||||
@@ -153,8 +154,14 @@ export async function resolveSlackUserAllowlist(params: {
|
||||
token: string;
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
|
||||
}): Promise<SlackUserResolution[]> {
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const client =
|
||||
params.client ??
|
||||
createSlackWebClient(
|
||||
params.token,
|
||||
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
|
||||
);
|
||||
const users = await listSlackUsers(client);
|
||||
return resolveSlackAllowlistEntries<
|
||||
{ id?: string; name?: string; email?: string },
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
sortUniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
|
||||
import { formatSlackError } from "./errors.js";
|
||||
|
||||
export type SlackScopesResult = {
|
||||
@@ -95,8 +95,9 @@ async function callSlack(
|
||||
export async function fetchSlackScopes(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
options: SlackApiUrlClientOptions = {},
|
||||
): Promise<SlackScopesResult> {
|
||||
const client = createSlackWebClient(token, { timeout: timeoutMs });
|
||||
const client = createSlackWebClient(token, { ...options, timeout: timeoutMs });
|
||||
const attempts: SlackScopesMethod[] = ["auth.test", "auth.scopes", "apps.permissions.info"];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { SlackTokenSource } from "./accounts.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
|
||||
import { validateSlackBlocksArray } from "./blocks-input.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
@@ -750,7 +751,7 @@ async function sendMessageSlackQueuedInner(params: {
|
||||
blocks?: (Block | KnownBlock)[];
|
||||
}): Promise<SlackSendResult> {
|
||||
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
|
||||
const client = opts.client ?? getSlackWriteClient(token);
|
||||
const client = opts.client ?? getSlackWriteClient(token, createSlackApiUrlClientOptions());
|
||||
const identity = resolveSlackSendIdentity({
|
||||
accountId: account.accountId,
|
||||
explicit: opts.identity,
|
||||
|
||||
@@ -957,7 +957,7 @@ describe("resolveTelegramFetch", () => {
|
||||
expect(eighthDispatcher).toBe(firstDispatcher);
|
||||
expect(ninthDispatcher).toBe(firstDispatcher);
|
||||
expectPinnedFallbackIpDispatcher(3);
|
||||
expectLoggerMessageContaining(loggerWarn, "fetch fallback: DNS-resolved IP unreachable");
|
||||
expectLoggerMessageContaining(loggerWarn, "fetch fallback: primary connection path failed");
|
||||
expectLoggerMessageContaining(
|
||||
loggerDebug,
|
||||
"fetch fallback: recovered from attempt 2 to attempt 0",
|
||||
@@ -1193,6 +1193,31 @@ 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,9 +488,10 @@ export type TelegramTransport = {
|
||||
dispatcherAttempts?: TelegramDispatcherAttempt[];
|
||||
/**
|
||||
* Promote this transport to its next fallback dispatcher before the next
|
||||
* request. Returns false when no fallback path exists.
|
||||
* request. The original error, when available, is retained in diagnostics.
|
||||
* Returns false when no fallback path exists.
|
||||
*/
|
||||
forceFallback?: (reason: string) => boolean;
|
||||
forceFallback?: (reason: string, err?: unknown) => boolean;
|
||||
/**
|
||||
* Release all dispatchers owned by this transport and the TCP sockets they
|
||||
* hold. Safe to call multiple times; subsequent calls resolve immediately.
|
||||
@@ -563,7 +564,8 @@ function createTelegramTransportAttempts(params: {
|
||||
},
|
||||
exportAttempt: { dispatcherPolicy: fallbackIpPolicy },
|
||||
logLevel: "warn",
|
||||
logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP",
|
||||
logMessage:
|
||||
"fetch fallback: primary connection path failed; trying alternative Telegram API IP",
|
||||
});
|
||||
|
||||
return attempts;
|
||||
@@ -864,8 +866,8 @@ export function resolveTelegramTransport(
|
||||
fetch: resolvedFetch,
|
||||
sourceFetch,
|
||||
dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt),
|
||||
forceFallback: (reason: string) =>
|
||||
promoteStickyAttempt(stickyAttemptIndex + 1, new Error("forced fallback"), reason),
|
||||
forceFallback: (reason: string, err?: unknown) =>
|
||||
promoteStickyAttempt(stickyAttemptIndex + 1, err ?? 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");
|
||||
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error", timeoutError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 failed + 1 getMe success + 1 webhook
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -162,7 +162,8 @@ 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.
|
||||
transport.forceFallback?.("probe timeout/network error");
|
||||
// Keep the original socket code in transport fallback diagnostics.
|
||||
transport.forceFallback?.("probe timeout/network error", err);
|
||||
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 { stateMigrations } from "./doctor-contract-api.js";
|
||||
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
|
||||
import {
|
||||
createTestStorePath,
|
||||
makePersistedCall,
|
||||
@@ -68,6 +68,42 @@ 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,6 +2,8 @@
|
||||
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,
|
||||
@@ -81,6 +83,36 @@ 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,9 +2,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
resolveVoiceCallAgentSessionKey,
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKey,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
resolveVoiceCallSessionKey,
|
||||
validateProviderConfig,
|
||||
normalizeVoiceCallConfig,
|
||||
@@ -296,7 +298,23 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
callId: "call-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
}),
|
||||
).toBe("voice:15550001111");
|
||||
).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");
|
||||
});
|
||||
|
||||
it("can scope voice sessions to each call", () => {
|
||||
@@ -313,10 +331,10 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
callId: "call-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
}),
|
||||
).toBe("voice:call:call-123");
|
||||
).toBe("agent:main:voice:call:call-123");
|
||||
});
|
||||
|
||||
it("preserves explicit voice session keys", () => {
|
||||
it("scopes explicit voice session keys by configured agent", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
@@ -328,9 +346,135 @@ describe("resolveVoiceCallConfig session routing", () => {
|
||||
config,
|
||||
callId: "call-123",
|
||||
phone: "+1 (555) 000-1111",
|
||||
explicitSessionKey: "meet-room-1",
|
||||
explicitSessionKey: "Meet-Room-1",
|
||||
}),
|
||||
).toBe("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");
|
||||
});
|
||||
|
||||
it("resolves per-number inbound route overrides over global voice settings", () => {
|
||||
@@ -395,6 +539,35 @@ 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,11 +1,16 @@
|
||||
// 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";
|
||||
@@ -569,6 +574,22 @@ 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,
|
||||
@@ -695,21 +716,73 @@ export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCal
|
||||
};
|
||||
}
|
||||
|
||||
export type VoiceCallCoreSessionConfig = { mainKey?: string; scope?: SessionScope };
|
||||
|
||||
export function resolveVoiceCallSessionKey(params: {
|
||||
config: Pick<VoiceCallConfig, "sessionScope">;
|
||||
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
|
||||
callId: string;
|
||||
phone?: string;
|
||||
explicitSessionKey?: string;
|
||||
coreSession?: VoiceCallCoreSessionConfig;
|
||||
}): string {
|
||||
const explicit = params.explicitSessionKey?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
return resolveVoiceCallAgentSessionKey({
|
||||
config: params.config,
|
||||
sessionKey: explicit,
|
||||
coreSession: params.coreSession,
|
||||
});
|
||||
}
|
||||
// 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 `voice:call:${params.callId}`;
|
||||
return `${prefix}:call:${params.callId}`.toLowerCase();
|
||||
}
|
||||
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
||||
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// Voice Call plugin module implements core bridge behavior.
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import type { VoiceCallTtsConfig } from "./config.js";
|
||||
import type { VoiceCallCoreSessionConfig, 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?: {
|
||||
store?: string;
|
||||
};
|
||||
session?: VoiceCallCoreSessionConfig & { 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 } from "./config.js";
|
||||
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } 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,6 +82,7 @@ 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>();
|
||||
@@ -103,8 +104,13 @@ export class CallManager {
|
||||
*/
|
||||
streamSessionIssuer: StreamSessionIssuer | undefined;
|
||||
|
||||
constructor(config: VoiceCallConfig, storePath?: string) {
|
||||
constructor(
|
||||
config: VoiceCallConfig,
|
||||
storePath?: string,
|
||||
coreSession?: VoiceCallCoreSessionConfig,
|
||||
) {
|
||||
this.config = config;
|
||||
this.coreSession = coreSession;
|
||||
this.storePath = resolveDefaultStoreBase(config, storePath);
|
||||
}
|
||||
|
||||
@@ -353,6 +359,7 @@ 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 } from "../config.js";
|
||||
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "../config.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
import type { CallId, CallRecord } from "../types.js";
|
||||
|
||||
@@ -21,6 +21,7 @@ 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(`voice:call:${call.callId}`);
|
||||
expect(call.sessionKey).toBe(`agent:main:voice:call:${call.callId}`);
|
||||
});
|
||||
|
||||
it("applies per-number inbound greeting and stores the matched route key", () => {
|
||||
|
||||
@@ -155,11 +155,12 @@ 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", "session-1", {
|
||||
const result = await initiateCall(ctx as never, "+14155550123", "main", {
|
||||
mode: "notify",
|
||||
message: "hello there",
|
||||
});
|
||||
@@ -178,7 +179,7 @@ describe("voice-call outbound helpers", () => {
|
||||
inlineTwiml: "<Response />",
|
||||
});
|
||||
expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId);
|
||||
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("session-1");
|
||||
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("agent:main:work");
|
||||
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -203,7 +204,9 @@ 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(`voice:call:${result.callId}`);
|
||||
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
|
||||
`agent:main:voice:call:${result.callId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {
|
||||
@@ -404,6 +407,7 @@ describe("voice-call outbound helpers", () => {
|
||||
const call = {
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
direction: "inbound",
|
||||
state: "active",
|
||||
to: "+15550002222",
|
||||
metadata: { numberRouteKey: "+15550002222" },
|
||||
@@ -438,6 +442,40 @@ 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,6 +3,7 @@ import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
resolveVoiceCallSessionKey,
|
||||
type CallMode,
|
||||
} from "../config.js";
|
||||
@@ -34,6 +35,7 @@ type InitiateContext = Pick<
|
||||
| "providerCallIdMap"
|
||||
| "provider"
|
||||
| "config"
|
||||
| "coreSession"
|
||||
| "storePath"
|
||||
| "webhookUrl"
|
||||
| "streamSessionIssuer"
|
||||
@@ -190,6 +192,7 @@ export async function initiateCall(
|
||||
callId,
|
||||
phone: to,
|
||||
explicitSessionKey: sessionKey,
|
||||
coreSession: ctx.coreSession,
|
||||
}),
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
@@ -288,8 +291,7 @@ export async function speak(
|
||||
transitionState(call, "speaking");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
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 () => ({
|
||||
const runEmbeddedAgent = vi.fn(async (_args: EmbeddedAgentArgs) => ({
|
||||
payloads,
|
||||
meta: { durationMs: 12, aborted: false },
|
||||
}));
|
||||
@@ -233,7 +233,7 @@ describe("generateVoiceResponse", () => {
|
||||
const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Pinned model works."}' },
|
||||
]);
|
||||
sessionStore["voice:15550001111"] = {
|
||||
sessionStore["agent:main: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["voice:15550001111"];
|
||||
const pinnedSessionEntry = sessionStore["agent:main: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: "voice:15550001111",
|
||||
sessionKey: "agent:main: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("voice:15550001111");
|
||||
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
|
||||
});
|
||||
|
||||
it("uses the persisted per-call session key for classic responses", async () => {
|
||||
it("canonicalizes a restored legacy per-call key for classic responses", async () => {
|
||||
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Fresh call context."}' },
|
||||
]);
|
||||
@@ -302,15 +302,102 @@ describe("generateVoiceResponse", () => {
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Fresh call context.");
|
||||
const perCallSessionEntry = sessionStore["voice:call:call-123"];
|
||||
const perCallSessionEntry = sessionStore["agent:main: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("voice:call:call-123");
|
||||
expect(args.sessionKey).toBe("agent:main: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,
|
||||
@@ -337,17 +424,18 @@ describe("generateVoiceResponse", () => {
|
||||
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main");
|
||||
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main");
|
||||
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main");
|
||||
const defaultSessionEntry = sessionStore["voice:15550001111"];
|
||||
const defaultSessionEntry = sessionStore["agent:main: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: "voice:15550001111",
|
||||
sessionKey: "agent:main:voice:15550001111",
|
||||
storePath: "/tmp/openclaw/main/sessions.json",
|
||||
});
|
||||
expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111");
|
||||
@@ -385,17 +473,18 @@ describe("generateVoiceResponse", () => {
|
||||
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice");
|
||||
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice");
|
||||
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice");
|
||||
const voiceSessionEntry = sessionStore["voice:15550001111"];
|
||||
const voiceSessionEntry = sessionStore["agent:voice: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: "voice:15550001111",
|
||||
sessionKey: "agent:voice:voice:15550001111",
|
||||
storePath: "/tmp/openclaw/voice/sessions.json",
|
||||
});
|
||||
expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111");
|
||||
|
||||
@@ -234,6 +234,7 @@ export async function generateVoiceResponse(
|
||||
callId,
|
||||
phone: from,
|
||||
explicitSessionKey: sessionKey,
|
||||
coreSession: coreConfig.session,
|
||||
});
|
||||
const agentId = voiceConfig.agentId ?? "main";
|
||||
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
|
||||
|
||||
@@ -29,22 +29,42 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveVoiceCallSessionKey: (params: {
|
||||
config: Pick<VoiceCallConfig, "sessionScope">;
|
||||
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
|
||||
callId: string;
|
||||
phone?: string;
|
||||
explicitSessionKey?: string;
|
||||
}) => {
|
||||
const explicit = params.explicitSessionKey?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
const lower = explicit.toLowerCase();
|
||||
return lower === "global" || lower === "unknown" || lower.startsWith("agent:")
|
||||
? explicit
|
||||
: `agent:${params.config.agentId?.trim().toLowerCase() || "main"}:${explicit}`;
|
||||
}
|
||||
const agentId = params.config.agentId?.trim().toLowerCase() || "main";
|
||||
const prefix = `agent:${agentId}:voice`;
|
||||
if (params.config.sessionScope === "per-call") {
|
||||
return `voice:call:${params.callId}`;
|
||||
return `${prefix}:call:${params.callId}`.toLowerCase();
|
||||
}
|
||||
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
||||
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
|
||||
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 };
|
||||
},
|
||||
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig) => ({ config }),
|
||||
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
|
||||
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
|
||||
validateProviderConfig: mocks.validateProviderConfig,
|
||||
@@ -378,9 +398,13 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("wires the shared realtime agent consult tool and handler", async () => {
|
||||
it("wires realtime consults and keeps outbound calls off inbound number routes", 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 = [
|
||||
{
|
||||
@@ -446,7 +470,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
|
||||
"embedded OpenClaw consult params",
|
||||
);
|
||||
expect(consultParams.sessionKey).toBe("voice:15550009999");
|
||||
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
|
||||
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
|
||||
expect(consultParams.messageProvider).toBe("voice");
|
||||
expect(consultParams.lane).toBe("voice");
|
||||
@@ -465,7 +489,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
expect(consultParams.prompt).toContain("Caller: Also check the ETA.");
|
||||
});
|
||||
|
||||
it("uses persisted per-call session keys for realtime consults", async () => {
|
||||
it("canonicalizes restored legacy per-call keys for realtime consults", async () => {
|
||||
const config = createBaseConfig();
|
||||
config.inboundPolicy = "allowlist";
|
||||
config.realtime.enabled = true;
|
||||
@@ -513,7 +537,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
),
|
||||
"per-call embedded OpenClaw consult params",
|
||||
);
|
||||
expect(consultParams.sessionKey).toBe("voice:call:call-1");
|
||||
expect(consultParams.sessionKey).toBe("agent:main:voice:call:call-1");
|
||||
});
|
||||
|
||||
it("answers realtime consults from fast memory context before starting the full agent", async () => {
|
||||
@@ -582,7 +606,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
},
|
||||
sessionKey: "voice:15550001234",
|
||||
sessionKey: "agent:main:voice:15550001234",
|
||||
});
|
||||
expect(runEmbeddedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
resolveVoiceCallSessionKey,
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallConfig,
|
||||
@@ -111,20 +112,19 @@ 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,
|
||||
phone: call.direction === "outbound" ? call.to : call.from,
|
||||
explicitSessionKey: call.sessionKey,
|
||||
coreSession: call.coreSession,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function createVoiceCallRuntime(params: {
|
||||
if (stateRuntime) {
|
||||
setVoiceCallStateRuntime({ state: stateRuntime });
|
||||
}
|
||||
const manager = new CallManager(config);
|
||||
const manager = new CallManager(config, undefined, cfg.session);
|
||||
const realtimeProvider = config.realtime.enabled
|
||||
? await resolveRealtimeProvider({
|
||||
config,
|
||||
@@ -358,15 +358,13 @@ export async function createVoiceCallRuntime(params: {
|
||||
if (!call) {
|
||||
return { error: `Call "${callId}" not found` };
|
||||
}
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string"
|
||||
? call.metadata.numberRouteKey
|
||||
: call.to;
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
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,6 +31,7 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
return {
|
||||
generateVoiceResponse: vi.fn(async () => ({ text: null })),
|
||||
getRealtimeTranscriptionProvider: vi.fn<(...args: unknown[]) => unknown>(
|
||||
() => realtimeTranscriptionProvider,
|
||||
),
|
||||
@@ -43,6 +44,10 @@ 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" }),
|
||||
@@ -1646,6 +1651,46 @@ 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,6 +25,7 @@ import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
|
||||
import {
|
||||
normalizeVoiceCallConfig,
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
type VoiceCallConfig,
|
||||
} from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
@@ -1031,8 +1032,7 @@ export class VoiceCallWebhookServer {
|
||||
|
||||
try {
|
||||
const { generateVoiceResponse } = await loadResponseGeneratorModule();
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
|
||||
|
||||
const result = await generateVoiceResponse({
|
||||
|
||||
@@ -1695,6 +1695,7 @@
|
||||
"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",
|
||||
@@ -1951,9 +1952,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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();
|
||||
}
|
||||
232
scripts/check-policy-config-coverage.ts
Normal file
232
scripts/check-policy-config-coverage.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
#!/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,8 +595,6 @@ 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.3",
|
||||
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.6",
|
||||
"defaultChoice": "npm",
|
||||
"expectedIntegrity": "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",
|
||||
"minHostVersion": ">=2026.3.22"
|
||||
"expectedIntegrity": "sha512-qw9k3PLTiMWGNjjsknHgcTManH1w4j+Ji1ArWIaYLKCq3aFRsVwcqnPi127bvOoVMJGW4dbyJ8NECEMgoO+iRw==",
|
||||
"minHostVersion": ">=2026.5.12"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
761
scripts/lib/policy-config-coverage.jsonc
Normal file
761
scripts/lib/policy-config-coverage.jsonc
Normal file
@@ -0,0 +1,761 @@
|
||||
{
|
||||
// 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.",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -746,8 +746,6 @@ 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"],
|
||||
|
||||
46
src/agents/embedded-agent-runner/delivery-evidence.test.ts
Normal file
46
src/agents/embedded-agent-runner/delivery-evidence.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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,7 +80,19 @@ function collectStringValues(value: unknown, output: Set<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
function collectMediaUrlsFromRecord(record: Record<string, 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);
|
||||
collectStringValues(record.mediaUrl, output);
|
||||
collectStringValues(record.mediaUrls, output);
|
||||
collectStringValues(record.path, output);
|
||||
@@ -90,7 +102,7 @@ function collectMediaUrlsFromRecord(record: Record<string, unknown>, output: Set
|
||||
if (Array.isArray(attachments)) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment && typeof attachment === "object" && !Array.isArray(attachment)) {
|
||||
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output);
|
||||
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,6 +1130,37 @@ 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} ', {
|
||||
@@ -1338,6 +1369,102 @@ 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,6 +45,17 @@ 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 =
|
||||
@@ -102,6 +113,7 @@ 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();
|
||||
@@ -120,9 +132,17 @@ 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);
|
||||
}
|
||||
},
|
||||
@@ -157,6 +177,11 @@ 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);
|
||||
@@ -167,6 +192,7 @@ function sanitizeOpenAISdkSseResponse(
|
||||
if (hasReadableSseData(block)) {
|
||||
controller.enqueue(encoder.encode(`${block}${separator}`));
|
||||
enqueued += 1;
|
||||
return enqueued;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -178,6 +204,10 @@ 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();
|
||||
@@ -200,6 +230,7 @@ function sanitizeOpenAISdkSseResponse(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await reader?.cancel(error).catch(() => {});
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
87
src/agents/streaming-byte-guard.ts
Normal file
87
src/agents/streaming-byte-guard.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
81
src/agents/tool-display-common.test.ts
Normal file
81
src/agents/tool-display-common.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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,15 +3,14 @@
|
||||
* 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 = {
|
||||
@@ -136,7 +135,7 @@ function coerceDisplayValue(
|
||||
const firstLine = redactToolPayloadText(rawLine);
|
||||
if (firstLine.length > maxStringChars) {
|
||||
const half = Math.floor((maxStringChars - 1) / 2);
|
||||
return `${firstLine.slice(0, half)}…${firstLine.slice(-(maxStringChars - 1 - half))}`;
|
||||
return `${sliceUtf16Safe(firstLine, 0, half)}…${sliceUtf16Safe(firstLine, -(maxStringChars - 1 - half))}`;
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
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,
|
||||
@@ -442,7 +443,7 @@ function compactRawCommand(raw: string, maxLength = 120): string {
|
||||
return oneLine;
|
||||
}
|
||||
const half = Math.floor((maxLength - 1) / 2);
|
||||
return `${oneLine.slice(0, half)}…${oneLine.slice(-(maxLength - 1 - half))}`;
|
||||
return `${sliceUtf16Safe(oneLine, 0, half)}…${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`;
|
||||
}
|
||||
|
||||
export type ToolDetailMode = "explain" | "raw";
|
||||
|
||||
@@ -562,6 +562,28 @@ 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,6 +735,69 @@ 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,11 +57,7 @@ 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,
|
||||
parseAgentSessionKey,
|
||||
parseThreadSessionSuffix,
|
||||
} from "../../routing/session-key.js";
|
||||
import { normalizeAccountId, parseSessionDeliveryRoute } 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";
|
||||
@@ -863,7 +859,6 @@ 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 {
|
||||
@@ -876,44 +871,21 @@ function formatSessionDeliveryTarget(channel: string, peerKind: string, to: stri
|
||||
function inferDeliveryFromSessionKey(
|
||||
sessionKey: string | undefined,
|
||||
): InferredSessionDelivery | null {
|
||||
const parsedThread = parseThreadSessionSuffix(sessionKey);
|
||||
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
|
||||
const parsed = parseAgentSessionKey(baseSessionKey);
|
||||
if (!parsed) {
|
||||
const route = parseSessionDeliveryRoute(sessionKey);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const channel = normalizeMessageChannel(parts[0]);
|
||||
const channel = normalizeMessageChannel(route.channel);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined;
|
||||
return {
|
||||
accountId,
|
||||
channel,
|
||||
threadId: route.threadId,
|
||||
to: formatSessionDeliveryTarget(channel, route.peerKind, route.peerId),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): {
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
// 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", () => {
|
||||
@@ -50,14 +35,29 @@ describe("registerNodesCli plugin registration", () => {
|
||||
return program;
|
||||
}
|
||||
|
||||
it("routes plugin registration logs to stderr for nodes --json commands", async () => {
|
||||
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 () => {
|
||||
let forceStderrDuringRegistration = false;
|
||||
registerPluginCliCommandsFromValidatedConfig.mockImplementationOnce(async () => {
|
||||
forceStderrDuringRegistration = loggingState.forceConsoleToStderr;
|
||||
return {};
|
||||
});
|
||||
|
||||
const program = await registerWithArgv(["node", "openclaw", "nodes", "list", "--json"]);
|
||||
const program = await registerWithArgv([
|
||||
"node",
|
||||
"openclaw",
|
||||
"nodes",
|
||||
"canvas",
|
||||
"snapshot",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(registerPluginCliCommandsFromValidatedConfig).toHaveBeenCalledWith(
|
||||
program,
|
||||
@@ -69,6 +69,16 @@ 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 () => {
|
||||
@@ -76,7 +86,7 @@ describe("registerNodesCli plugin registration", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
await registerWithArgv(["node", "openclaw", "nodes", "invoke", "--", "--json"]);
|
||||
await registerWithArgv(["node", "openclaw", "nodes", "canvas", "--", "--json"]);
|
||||
|
||||
expect(forceStderrDuringRegistration).toBe(false);
|
||||
expect(loggingState.forceConsoleToStderr).toBe(false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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";
|
||||
@@ -42,6 +43,13 @@ 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,
|
||||
@@ -52,3 +60,19 @@ 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,6 +144,12 @@ 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,
|
||||
@@ -209,6 +215,13 @@ 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",
|
||||
@@ -515,6 +528,7 @@ vi.mock("./onboard-helpers.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./doctor-state-migrations.js", () => ({
|
||||
autoMigrateLegacyPluginDoctorState,
|
||||
autoMigrateLegacyState,
|
||||
autoMigrateLegacyStateDir,
|
||||
autoMigrateLegacyTaskStateSidecars,
|
||||
|
||||
@@ -217,11 +217,19 @@ function isMainScopeStaleDirectSessionKey(params: {
|
||||
if (!parsed || normalizeAgentId(parsed.agentId) !== normalizeAgentId(params.targetAgentId)) {
|
||||
return false;
|
||||
}
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
const parts = parsed.rest.split(":");
|
||||
// A nested agent wrapper is opaque plugin identity, never a stale DM route.
|
||||
if (parts[0] === "agent") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(parts.length === 2 && parts[0] === "direct") ||
|
||||
(parts.length === 3 && parts[1] === "direct") ||
|
||||
(parts.length === 4 && parts[2] === "direct")
|
||||
(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]))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +36,29 @@ 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,6 +35,10 @@ 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;
|
||||
@@ -43,7 +47,7 @@ function resolveOriginatingGroupTargetId(params: {
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const parts = target.split(":").filter(Boolean);
|
||||
const parts = target.split(":");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
@@ -54,13 +58,13 @@ function resolveOriginatingGroupTargetId(params: {
|
||||
const second = normalizeOptionalLowercaseString(parts[1]);
|
||||
const secondIsKind = second === "group" || second === "channel";
|
||||
if (secondIsKind && (head === params.provider || getGroupSurfaces().has(head))) {
|
||||
return parts.slice(2).join(":") || null;
|
||||
return joinOpaqueTail(parts, 2);
|
||||
}
|
||||
if (head === params.provider || head === "chat" || head === "room" || head === "group") {
|
||||
return parts.slice(1).join(":") || null;
|
||||
return joinOpaqueTail(parts, 1);
|
||||
}
|
||||
if (head === "channel") {
|
||||
return parts.slice(1).join(":") || null;
|
||||
return joinOpaqueTail(parts, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -134,7 +138,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
|
||||
|
||||
const providerHint = normalizeOptionalLowercaseString(ctx.Provider);
|
||||
|
||||
const parts = from.split(":").filter(Boolean);
|
||||
const parts = from.split(":");
|
||||
const head = normalizeLowercaseStringOrEmpty(parts[0]);
|
||||
const headIsSurface = head ? getGroupSurfaces().has(head) : false;
|
||||
|
||||
@@ -164,9 +168,12 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
|
||||
? originatingGroupTargetId
|
||||
: headIsSurface
|
||||
? secondIsKind
|
||||
? parts.slice(2).join(":")
|
||||
: parts.slice(1).join(":")
|
||||
? joinOpaqueTail(parts, 2)
|
||||
: joinOpaqueTail(parts, 1)
|
||||
: from;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const finalId = normalizeSessionPeerId({ channel: provider, peerKind: kind, peerId: id });
|
||||
if (!finalId) {
|
||||
return null;
|
||||
|
||||
@@ -97,6 +97,27 @@ 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,6 +597,10 @@ 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,
|
||||
@@ -614,8 +618,9 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
|
||||
const preview = dryRun.previewResults[0];
|
||||
expect(preview?.summary.dmScopeRetired).toBe(1);
|
||||
expect(preview?.summary.afterCount).toBe(1);
|
||||
expect(preview?.summary.afterCount).toBe(2);
|
||||
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);
|
||||
});
|
||||
@@ -625,6 +630,7 @@ 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(
|
||||
@@ -640,6 +646,11 @@ 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,
|
||||
@@ -648,6 +659,7 @@ 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" } },
|
||||
@@ -658,8 +670,10 @@ 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,7 +75,8 @@ export function listConfiguredSessionStoreAgentIds(cfg: OpenClawConfig): string[
|
||||
for (const agentId of cfg.acp?.allowedAgents ?? []) {
|
||||
addAcpAgentId(agentId);
|
||||
}
|
||||
for (const agent of cfg.agents?.list ?? []) {
|
||||
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const agent of configuredAgents) {
|
||||
if (agent.runtime?.type === "acp") {
|
||||
addAcpAgentId(agent.runtime.acp?.agent ?? agent.id);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ 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"),
|
||||
@@ -132,6 +134,11 @@ 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,
|
||||
}));
|
||||
@@ -379,6 +386,10 @@ 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();
|
||||
@@ -921,6 +932,28 @@ 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,6 +534,7 @@ 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,7 +1,10 @@
|
||||
/**
|
||||
* 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() {
|
||||
@@ -26,6 +29,47 @@ 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 { parseAgentSessionKey, parseThreadSessionSuffix } from "../../routing/session-key.js";
|
||||
import { parseSessionDeliveryRoute } 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,29 +19,13 @@ type InternalSourceReplySinkInput = {
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
};
|
||||
|
||||
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
|
||||
|
||||
function hasExternalSessionDeliveryRoute(sessionKey: string | undefined): boolean {
|
||||
const parsedThread = parseThreadSessionSuffix(sessionKey);
|
||||
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
|
||||
const parsed = parseAgentSessionKey(baseSessionKey);
|
||||
if (!parsed) {
|
||||
const route = parseSessionDeliveryRoute(sessionKey);
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
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())
|
||||
);
|
||||
const channel = normalizeMessageChannel(route.channel);
|
||||
return Boolean(channel && channel !== INTERNAL_MESSAGE_CHANNEL);
|
||||
}
|
||||
|
||||
function hasExplicitRouteParam(params: Record<string, unknown>): boolean {
|
||||
|
||||
@@ -143,6 +143,27 @@ 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 } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
@@ -89,6 +89,15 @@ 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({
|
||||
@@ -140,6 +149,17 @@ 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 () => {
|
||||
@@ -158,6 +178,397 @@ 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);
|
||||
@@ -265,7 +676,7 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legitimate agent:main:* keys in shared stores with both main and non-main agents", async () => {
|
||||
it("preserves legacy default-main aliases in shared stores", 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.
|
||||
@@ -275,21 +686,110 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
|
||||
});
|
||||
|
||||
await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
// 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:main:main").sessionId).toBe("main-session");
|
||||
expect(store["agent:main:work"]).toBeUndefined();
|
||||
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
|
||||
// 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);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the main agent claim bare main aliases in shared stores", async () => {
|
||||
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 () => {
|
||||
await withStateFixture(async ({ tmpDir, stateDir }) => {
|
||||
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
|
||||
writeStore(sharedStorePath, {
|
||||
@@ -297,12 +797,150 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
|
||||
});
|
||||
|
||||
await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
|
||||
|
||||
const store = readStore(sharedStorePath);
|
||||
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session");
|
||||
expect(store.main).toBeUndefined();
|
||||
expect(requireStoreEntry(store, "main").sessionId).toBe("main-session");
|
||||
expect(store["agent:main:work"]).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,7 +4,9 @@ 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 {
|
||||
@@ -106,10 +108,14 @@ 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 }) =>
|
||||
/^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())
|
||||
? `agent:${agentId}:mobileauth:${key.trim().toLowerCase()}`
|
||||
: null,
|
||||
: null;
|
||||
},
|
||||
},
|
||||
]),
|
||||
listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [
|
||||
@@ -445,23 +451,76 @@ 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([]);
|
||||
expect(result.warnings).toStrictEqual([
|
||||
`Preserved 1 ambiguous session key(s) while importing legacy sessions into ${targetStorePath}`,
|
||||
]);
|
||||
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 2 legacy session key(s)",
|
||||
"Canonicalized 3 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")}`,
|
||||
@@ -473,14 +532,29 @@ describe("state migrations", () => {
|
||||
path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, { sessionId: string }>;
|
||||
) as Record<string, { sessionId: string; sessionFile?: string; acp?: unknown }>;
|
||||
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"),
|
||||
@@ -513,6 +587,817 @@ 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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user