Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Dallin Romney
2026-08-20 22:08:39 -07:00
135 changed files with 3701 additions and 1472 deletions
@@ -87,8 +87,9 @@ Iterate within the three-attempt budget; all attempts remain recorded.
Build `mantis-evidence.json` with
`scripts/mantis/build-telegram-desktop-proof-evidence.mts` as before, using each
lane's generated `telegram-user-crabbox-session-summary.json`. Edit only the
human summary/expected wording. A failure or block sets `comparison.pass: false`
and names the concrete product defect or missing primitive.
human summary/expected wording. Name the concrete product defect or missing
primitive when a lane fails or blocks; the workflow derives the outcome from
trusted lane facts.
```bash
node --import tsx scripts/mantis/build-telegram-desktop-proof-evidence.mts \
+42
View File
@@ -87,6 +87,9 @@ jobs:
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
# docker-seed-e2e-contract-v1: frozen targets without this marker skip the lane.
run_docker_seed_e2e: ${{ steps.manifest.outputs.run_docker_seed_e2e }}
docker_seed_lanes: ${{ steps.manifest.outputs.docker_seed_lanes }}
run_macos: ${{ steps.manifest.outputs.run_macos }}
run_android: ${{ steps.manifest.outputs.run_android }}
run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
@@ -636,6 +639,7 @@ jobs:
: "";
const supportsOpenClawKitTests = targetWorkflow.includes("openclawkit-tests-contract-v1");
const supportsCurrentAndroidCi = targetWorkflow.includes("android-ci-contract-v2");
const supportsDockerSeedE2e = targetWorkflow.includes("docker-seed-e2e-contract-v1");
const useCompatibleAndroidCi = compatibilityTarget && !supportsCurrentAndroidCi;
const supportsFormatCheck =
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
@@ -671,6 +675,13 @@ jobs:
}
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
const dockerSeedLanes =
compactPullRequest &&
changedPaths &&
supportsDockerSeedE2e &&
typeof changedNodeTestPlan.resolveChangedDockerSeedLanes === "function"
? changedNodeTestPlan.resolveChangedDockerSeedLanes(changedPaths)
: [];
// Canonical pushes also use compact bins: 80+ single-group jobs
// drain the runner pool for minutes, and per-shard check names on
// main have no branch-protection consumers. Dispatch (release
@@ -808,6 +819,8 @@ jobs:
docs_only: docsOnly,
docs_changed: docsChanged,
run_node: runNode,
run_docker_seed_e2e: dockerSeedLanes.length > 0,
docker_seed_lanes: dockerSeedLanes.join(" "),
run_macos: runMacos,
run_android: runAndroid,
run_skills_python: runSkillsPython,
@@ -4060,6 +4073,33 @@ jobs:
;;
esac
docker-seed-e2e:
permissions:
contents: read
name: docker-seed-e2e
needs: [preflight]
if: needs.preflight.outputs.run_docker_seed_e2e == 'true'
runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.event_name == 'pull_request' && (github.run_attempt > 1 || github.event.pull_request.head.repo.full_name != github.repository)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 60
steps:
- *linux_node_checkout_step
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "24.x"
install-bun: "false"
dependency-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }}
use-actions-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'true' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true') }}
- name: Run changed Docker seed owner lanes
env:
OPENCLAW_DOCKER_ALL_LANES: ${{ needs.preflight.outputs.docker_seed_lanes }}
OPENCLAW_DOCKER_ALL_LIVE_MODE: skip
OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "1"
OPENCLAW_DOCKER_ALL_PARALLELISM: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }}
OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }}
run: pnpm test:docker:all
ci-gate:
permissions:
contents: read
@@ -4092,6 +4132,7 @@ jobs:
- macos-swift
- ios-build
- android
- docker-seed-e2e
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -4128,6 +4169,7 @@ jobs:
macos-swift=${{ needs.macos-swift.result }}
ios-build=${{ needs.ios-build.result }}
android=${{ needs.android.result }}
docker-seed-e2e=${{ needs.docker-seed-e2e.result }}
run: |
set -euo pipefail
@@ -740,21 +740,23 @@ jobs:
sudo install -m 0755 "$tdlib_dir/tdlib-v1.8.0-linux-x64/lib/libtdjson.so" /usr/local/lib/libtdjson.so
# The Convex credential is the real mutex for the shared Telegram account:
# a concurrent holder fails this acquire, so no separate run-level lock is
# needed. Retry while another run finishes, then fail with a clear reason.
# needed. Observed 2026-08: a complete proof held the account for 34m;
# four hours covers burst backfills while reserving roughly two hours
# of the job limit for proof, publication, and cleanup.
echo "lease_file=$credential_dir/lease.json" >> "$GITHUB_OUTPUT"
deadline=$(( SECONDS + 15 * 60 ))
lease_deadline=$(( SECONDS + 4 * 60 * 60 ))
until node --import tsx scripts/e2e/telegram-user-credential.ts lease-restore \
--user-driver-dir "$credential_dir/user-driver" \
--desktop-workdir "$credential_dir/desktop" \
--lease-file "$credential_dir/lease.json" \
--payload-output "$credential_dir/payload.json" \
--credential-role ci; do
if (( SECONDS >= deadline )); then
echo "::error::The shared QA Telegram account is still leased by another run after 15 minutes." >&2
if (( SECONDS >= lease_deadline )); then
echo "::error::The shared QA Telegram account remained busy for four hours." >&2
exit 1
fi
echo "Shared QA Telegram account is busy; retrying in 60s." >&2
sleep 60
echo "Shared QA Telegram account is busy; retrying in 15s." >&2
sleep 15
done
chmod 0700 "$credential_dir" "$credential_dir/user-driver"
sut_credential_dir="/tmp/openclaw-mantis-sut-credential-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
@@ -975,20 +977,16 @@ jobs:
select((.summary | type) == "string" and (.summary | length) <= 4000) |
select((.comparison.baseline.expected | type) == "string" and (.comparison.baseline.expected | length) <= 1000) |
select((.comparison.candidate.expected | type) == "string" and (.comparison.candidate.expected | length) <= 1000) |
select((.comparison.pass | type) == "boolean") |
select((.comparison.candidate.fixed | type) == "boolean") |
{
summary,
baselineExpected: .comparison.baseline.expected,
candidateExpected: .comparison.candidate.expected,
comparisonPass: .comparison.pass,
candidateFixed: .comparison.candidate.fixed
candidateExpected: .comparison.candidate.expected
}
' "$agent_manifest" > "$judgment"
baseline_status="$(sudo jq -r '.comparison.baseline.status' "$agent_manifest")"
candidate_status="$(sudo jq -r '.comparison.candidate.status' "$agent_manifest")"
[[ "$baseline_status" == "pass" || "$baseline_status" == "fail" ]]
[[ "$candidate_status" == "pass" || "$candidate_status" == "fail" ]]
[[ "$baseline_status" == "pass" || "$baseline_status" == "fail" || "$baseline_status" == "blocked" ]]
[[ "$candidate_status" == "pass" || "$candidate_status" == "fail" || "$candidate_status" == "blocked" ]]
copy_verified_artifacts() {
local lane="$1"
local facts_file="$2"
@@ -1047,6 +1045,18 @@ jobs:
(.artifacts.trimmedVideoCropped.bytes > 10000)
' "$verdict" >/dev/null
fi
if [[ "$fact_status" == "blocked" ]]; then
sudo jq -e '
(.blocked.name | type) == "string" and (.blocked.name | length) > 0 and (.blocked.name | length) <= 200 and
(.blocked.reason | type) == "string" and (.blocked.reason | length) > 0 and (.blocked.reason | length) <= 2000
' "$verdict" >/dev/null
lane_status="blocked"
elif [[ "$fact_status" == "complete" ]]; then
[[ "$lane_status" == "pass" || "$lane_status" == "fail" ]]
else
lane_status="fail"
fi
printf -v "${lane}_status" '%s' "$lane_status"
if [[ "$pre_attestation_failure" != "true" ]]; then
sudo jq -e --arg lane "$lane" --arg sha "$expected_sha" \
'.lane == $lane and .sha == $sha' \
@@ -1104,15 +1114,16 @@ jobs:
sudo jq --slurpfile judgment "$judgment" '
.summary = $judgment[0].summary |
.comparison.baseline.expected = $judgment[0].baselineExpected |
.comparison.candidate.expected = $judgment[0].candidateExpected |
.comparison.pass = $judgment[0].comparisonPass |
.comparison.candidate.fixed = $judgment[0].candidateFixed
.comparison.candidate.expected = $judgment[0].candidateExpected
' "$manifest" | sudo tee "$trusted_manifest" >/dev/null
sudo mv "$trusted_manifest" "$manifest"
jq -e '
(.comparison.pass == false) or
(.comparison.baseline.status == "pass" and .comparison.candidate.status == "pass")
(.comparison.outcome == "pass" and .comparison.pass == true and
.comparison.baseline.status == "pass" and .comparison.candidate.status == "pass") or
(.comparison.outcome == "blocked" and .comparison.pass == false and
(.comparison.baseline.status == "blocked" or .comparison.candidate.status == "blocked")) or
(.comparison.outcome == "fail" and .comparison.pass == false)
' "$manifest" >/dev/null
token="$(sudo jq -r '.sutToken' "$SUT_CREDENTIAL_DIR/credential.json")"
@@ -1223,7 +1234,7 @@ jobs:
echo "Mantis agent did not produce ${manifest}." >&2
exit 1
fi
comparison_status="$(jq -r 'if .comparison.pass then "pass" else "fail" end' "$manifest")"
comparison_status="$(jq -er '.comparison.outcome | select(. == "pass" or . == "fail" or . == "blocked")' "$manifest")"
echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT"
- name: Upload Mantis Telegram desktop artifacts
@@ -1326,7 +1337,7 @@ jobs:
}
- name: Fail when Mantis Telegram desktop proof failed
if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' }}
if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' && steps.inspect.outputs.comparison_status != 'blocked' }}
env:
COMPARISON_STATUS: ${{ steps.inspect.outputs.comparison_status }}
run: |
@@ -47,19 +47,25 @@ enum CommandResolver {
return ["/bin/sh", "-c", script]
}
static func projectRoot() -> URL {
if let stored = AppDefaults.standard.string(forKey: projectRootDefaultsKey),
static func projectRoot(
defaults: UserDefaults = AppDefaults.standard,
profile: AppProfile = .current,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL
{
if let stored = defaults.string(forKey: projectRootDefaultsKey),
let url = expandPath(stored),
FileManager().fileExists(atPath: url.path)
{
return url
}
let fallback = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/openclaw")
if profile.isActive {
return profile.stateDirectoryURL(homeDirectory: homeDirectory)
}
let fallback = homeDirectory.appendingPathComponent("Projects/openclaw")
if FileManager().fileExists(atPath: fallback.path) {
return fallback
}
return FileManager().homeDirectoryForCurrentUser
return homeDirectory
}
static func setProjectRoot(_ path: String) {
@@ -211,7 +211,7 @@ final class ControlChannel {
self.setStateThrottled(.connected)
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
}
}
@@ -237,7 +237,7 @@ final class ControlChannel {
self.setStateThrottled(.connected)
return payload
} catch {
let message = self.friendlyGatewayMessage(error)
let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
@@ -266,7 +266,7 @@ final class ControlChannel {
self.setStateThrottled(.connected)
return data
} catch {
let message = self.friendlyGatewayMessage(error)
let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
@@ -283,13 +283,13 @@ final class ControlChannel {
self.setStateThrottled(.connected)
return data
} catch {
let message = self.friendlyGatewayMessage(error)
let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
}
private func friendlyGatewayMessage(_ error: Error) -> String {
static func friendlyGatewayMessage(_ error: Error, configRoot: [String: Any]) -> String {
// Map URLSession/WS errors into user-facing, actionable text.
if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription {
return desc
@@ -299,12 +299,24 @@ final class ControlChannel {
return authIssue.statusMessage
}
let mode = ConnectionModeResolver.resolve(root: configRoot).mode
let transport = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
let localPort = GatewayEnvironment.gatewayPort()
let directURL = mode == .remote && transport.transport == .direct ? transport.directURL : nil
let endpoint = if let url = directURL, let host = url.host,
let port = GatewayRemoteConfig.defaultPort(for: url)
{
"\(host.contains(":") && !host.hasPrefix("[") ? "[" + host + "]" : host):\(port)"
} else {
"localhost:\(localPort)"
}
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
let tokenKey = CommandResolver.connectionModeIsRemote()
let tokenKey = mode == .remote
? "gateway.remote.token"
: "gateway.auth.token"
return
@@ -320,34 +332,37 @@ final class ControlChannel {
if nsError.domain == "Gateway",
nsError.localizedDescription.contains("hello failed (unexpected response)")
{
let port = GatewayEnvironment.gatewayPort()
if directURL != nil {
return "Gateway handshake got non-gateway data on \(endpoint); check the Gateway URL and server."
}
return """
Gateway handshake got non-gateway data on localhost:\(port).
Gateway handshake got non-gateway data on \(endpoint).
Another process is using that port or the SSH forward failed.
Stop the local gateway/port-forward on \(port) and retry Remote mode.
Stop the local gateway/port-forward on \(localPort) and retry Remote mode.
"""
}
if let urlError = error as? URLError {
let port = GatewayEnvironment.gatewayPort()
switch urlError.code {
case .cancelled:
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
return "Gateway connection was closed; start the gateway (\(endpoint)) and retry."
case .cannotFindHost, .cannotConnectToHost:
let isRemote = CommandResolver.connectionModeIsRemote()
if isRemote {
if directURL != nil {
return "Cannot reach gateway at \(endpoint); check the Gateway URL and remote gateway."
}
if mode == .remote {
return """
Cannot reach gateway at localhost:\(port).
Cannot reach gateway at \(endpoint).
Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running.
"""
}
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
return "Cannot reach gateway at \(endpoint); ensure the gateway is running."
case .networkConnectionLost:
return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut:
return "Gateway request timed out; check gateway on localhost:\(port)."
return "Gateway request timed out; check gateway on \(endpoint)."
case .notConnectedToInternet:
if Self.isLikelyLocalNetworkPermissionBlock() {
if Self.isLikelyLocalNetworkPermissionBlock(configRoot: configRoot) {
return """
macOS is blocking OpenClaw Local Network access.
Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app.
@@ -360,8 +375,7 @@ final class ControlChannel {
}
if nsError.domain == "Gateway", nsError.code == 5 {
let port = GatewayEnvironment.gatewayPort()
return "Gateway request timed out; check the gateway process on localhost:\(port)."
return "Gateway request timed out; check the gateway process on \(endpoint)."
}
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
@@ -370,10 +384,9 @@ final class ControlChannel {
return "Gateway error: \(trimmed)"
}
private static func isLikelyLocalNetworkPermissionBlock() -> Bool {
let root = OpenClawConfigFile.loadDict()
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
guard ConnectionModeResolver.resolve(root: root).mode == .remote,
private static func isLikelyLocalNetworkPermissionBlock(configRoot: [String: Any]) -> Bool {
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
guard ConnectionModeResolver.resolve(root: configRoot).mode == .remote,
resolution.transport == .direct,
let url = resolution.directURL,
url.scheme?.lowercased() == "ws",
@@ -412,10 +425,10 @@ final class ControlChannel {
if mode == .remote {
do {
let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)")
self.logger.info("control channel recovery ensured remote endpoint port=\(port, privacy: .public)")
} catch {
self.logger.error(
"control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)")
"control channel remote endpoint failed \(error.localizedDescription, privacy: .public)")
}
}
@@ -22,6 +22,30 @@ import Testing
return (tmp, pnpmPath)
}
@Test func `named profiles do not inherit the default development checkout`() throws {
let home = try makeTempDirForTests()
let checkout = home.appendingPathComponent("Projects/openclaw")
try FileManager.default.createDirectory(at: checkout, withIntermediateDirectories: true)
let defaults = self.makeDefaults()
let defaultProfile = AppProfile(environment: [:])
let namedProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "isolated"])
#expect(CommandResolver.projectRoot(
defaults: defaults,
profile: defaultProfile,
homeDirectory: home).path == checkout.path)
#expect(CommandResolver.projectRoot(
defaults: defaults,
profile: namedProfile,
homeDirectory: home).path == home.appendingPathComponent(".openclaw-isolated").path)
defaults.set(checkout.path, forKey: "openclaw.gatewayProjectRootPath")
#expect(CommandResolver.projectRoot(
defaults: defaults,
profile: namedProfile,
homeDirectory: home).path == checkout.path)
}
@Test func `prefers open claw binary`() async throws {
let defaults = self.makeLocalDefaults()
@@ -4,7 +4,7 @@ import Testing
struct ControlChannelStateDebouncerTests {
@Test func `terminal states apply immediately`() {
let start = Date(timeIntervalSince1970: 1_000)
let start = Date(timeIntervalSince1970: 1000)
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
let degradedDelay = debouncer.delayBeforeApplying(
@@ -27,7 +27,7 @@ struct ControlChannelStateDebouncerTests {
}
@Test func `nonterminal states are debounced within interval`() {
let start = Date(timeIntervalSince1970: 1_000)
let start = Date(timeIntervalSince1970: 1000)
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
let soonDelay = debouncer.delayBeforeApplying(
@@ -45,7 +45,7 @@ struct ControlChannelStateDebouncerTests {
}
@Test func `deferred apply resets debounce window`() {
let start = Date(timeIntervalSince1970: 1_000)
let start = Date(timeIntervalSince1970: 1000)
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
debouncer.recordDeferredApply(at: start.addingTimeInterval(0.5))
@@ -58,3 +58,134 @@ struct ControlChannelStateDebouncerTests {
#expect(abs((delayAfterDeferredUpdate ?? 0) - 0.3) < 0.001)
}
}
@MainActor
struct ControlChannelGatewayMessageTests {
@Test(arguments: [
URLError.Code.cannotFindHost,
URLError.Code.cannotConnectToHost,
URLError.Code.cancelled,
URLError.Code.timedOut,
])
func `direct gateway failures identify their actual endpoint`(code: URLError.Code) {
let root: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "ws://127.0.0.1:42674",
],
],
]
let message = ControlChannel.friendlyGatewayMessage(URLError(code), configRoot: root)
#expect(message.contains("127.0.0.1:42674"))
#expect(!message.contains("SSH"))
}
@Test func `direct gateway diagnostics never expose URL credentials`() {
let root: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://user:secret@gateway.example:9443/path?token=private",
],
],
]
let message = ControlChannel.friendlyGatewayMessage(
URLError(.cannotConnectToHost),
configRoot: root)
#expect(message.contains("gateway.example:9443"))
#expect(!message.contains("secret"))
#expect(!message.contains("private"))
}
@Test func `direct secure gateway diagnostics use the default TLS port`() {
let root: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway.example",
],
],
]
let message = ControlChannel.friendlyGatewayMessage(
URLError(.cannotConnectToHost),
configRoot: root)
#expect(message.contains("gateway.example:443"))
}
@Test func `direct IPv6 gateway diagnostics bracket the endpoint host`() {
let root: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://[fd12:3456:789a::1]:9443",
],
],
]
let message = ControlChannel.friendlyGatewayMessage(
URLError(.cannotConnectToHost),
configRoot: root)
#expect(message.contains("[fd12:3456:789a::1]:9443"))
}
@Test func `SSH gateway failures preserve tunnel recovery guidance`() {
let root: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": ["transport": "ssh"],
],
]
let message = ControlChannel.friendlyGatewayMessage(
URLError(.cannotConnectToHost),
configRoot: root)
#expect(message.contains("localhost:"))
#expect(message.contains("SSH tunnel"))
}
@Test func `direct gateway handshake failures identify the remote endpoint`() {
let root: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway.example:9443",
],
],
]
let error = NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"])
let message = ControlChannel.friendlyGatewayMessage(error, configRoot: root)
#expect(message.contains("gateway.example:9443"))
#expect(!message.contains("SSH"))
}
@Test func `local gateway failures preserve local recovery guidance`() {
let root: [String: Any] = ["gateway": ["mode": "local"]]
let message = ControlChannel.friendlyGatewayMessage(
URLError(.cannotConnectToHost),
configRoot: root)
#expect(message.contains("localhost:"))
#expect(message.contains("ensure the gateway is running"))
#expect(!message.contains("SSH"))
}
}
+1 -1
View File
@@ -850,7 +850,7 @@ Disable automations: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
</Accordion>
<Accordion title="Maintenance">
`cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Run history keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
`cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
</Accordion>
<Accordion title="Legacy store migration">
On upgrade, run `openclaw doctor --fix` to import historical `~/.openclaw/cron/jobs.json`, `jobs-state.json`, `jobs-quarantine.json`, and `runs/*.jsonl` files into SQLite and archive the originals with a `.migrated` suffix. Malformed job rows remain recoverable in SQLite while valid jobs keep running.
+9
View File
@@ -42,6 +42,7 @@ dispatch.
| `checks-fast-contracts-plugins-*` | Two weighted plugin contract shards | Node-relevant changes |
| `checks-fast-contracts-channels-*` | Two weighted channel contract shards | Node-relevant changes |
| `checks-node-*` | Changed-target Node tests on pull requests; compact integration shards on `main`; metadata-complete compact fallback on broad PRs; full named shards on manual and release runs | Node-relevant changes |
| `docker-seed-e2e` | One Docker scheduler job for the executable `mcp-channels`, `cron-mcp-cleanup`, and `mcp-code-mode-gateway` owner lanes | PR changes to their seed helpers or CI gate owners |
| `check-*` | Sharded main local gate equivalent: guards, transient npm-lock validation, bundled-channel config metadata, prod types, lint, dependencies, test types | Node-relevant changes |
| `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture; the pure-reporting plugin SDK API diff runs on manual and release dispatches only | Node-relevant changes |
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Full Release Validation and manual dispatches only |
@@ -57,6 +58,14 @@ dispatch.
| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch |
The rare path-triggered `docker-seed-e2e` job selects only the executable
owners of changed seed helpers and runs them through one scheduler invocation.
Trusted same-repository pull requests use one 16-vCPU Blacksmith runner with
main and tail parallelism set to 3; GitHub-hosted, fork, and retry paths run the
same selected lanes serially. The job is part of `openclaw/ci-gate`. It adds at
most one runner registration during an affected pull-request window and adds no
registrations for unrelated pull requests.
Standalone Periphery workflows enforce zero dead-code findings for the iOS and macOS apps. The shared OpenClawKit workflow scans both consumers in parallel and reports a declaration only when Periphery emits the same Swift USR from both builds. Its generated `OpenClawProtocol/GatewayModels.swift` schema contract is retained as generator-owned code rather than treated as app-local dead code.
## Fail-fast order
+1 -1
View File
@@ -233,7 +233,7 @@ The scheduler does not classify final-output prose or approval-looking refusal p
Retention behavior:
- `cron.sessionRetention` (default `24h`, or `false` to disable; a zero duration such as `"0h"` also disables) prunes completed isolated run sessions.
- Run history keeps the newest 2000 terminal rows per job. Lost rows retain the standard 24-hour lost-task cleanup window.
- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
## Migrating older jobs
+4 -2
View File
@@ -215,8 +215,10 @@ openclaw sessions cleanup --json
- Scope note: `openclaw sessions cleanup` maintains session stores,
transcripts, trajectory rows, and legacy trajectory sidecars. It does not
prune cron run history, which automatically keeps the newest 2000 rows per job
([Cron configuration](/automation/cron-jobs#configuration)).
prune cron run history. Task maintenance retains terminal cron history for 7
days (`lost` rows for 24 hours) and enforces the newest 2000 rows per job and
history class as an additional ceiling ([Task maintenance](/automation/tasks#automatic-maintenance),
[Cron configuration](/automation/cron-jobs#configuration)).
- Cleanup also prunes unreferenced legacy/archive transcript artifacts,
compaction checkpoints, and trajectory sidecars older than
`session.maintenance.pruneAfter`; artifacts still referenced by SQLite
+1 -1
View File
@@ -1646,7 +1646,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
- `enabled`: execute stored automation jobs (default: `true`). Set `false` to pause all automation execution without deleting jobs.
- `triggers.enabled`: run event-driven automation triggers (default: `true`). Set `false` to disable condition triggers, script payloads, and stream schedules.
- `sessionRetention`: how long to keep completed isolated automation run sessions before pruning SQLite session rows. Also controls cleanup of archived deleted automation transcripts. Default: `24h`; set `false` or a zero duration such as `"0h"` to disable (negative durations are invalid).
- Run history automatically keeps the newest 2000 terminal rows per job. Lost rows retain their 24-hour cleanup window.
- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
- `webhookToken`: bearer token used for automation webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhookSsrfPolicy`: shared outbound SSRF policy for primary, completion, failure-destination, and failure-alert webhooks. Private/internal targets are blocked when omitted. Prefer exact `allowedHostnames`; use `dangerouslyAllowPrivateNetwork: true` only for trusted private-network receivers. The narrow fake-IP proxy flags are `allowRfc2544BenchmarkRange` and `allowIpv6UniqueLocalRange`.
+1 -1
View File
@@ -415,7 +415,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
```
- `sessionRetention`: prune completed isolated run sessions from SQLite session rows (default `24h`; set `false` or a zero duration such as `"0h"` to disable).
- Run history automatically keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
</Accordion>
@@ -110,7 +110,7 @@ artifacts before importing.
Isolated cron runs create their own session entries/transcripts with dedicated retention:
- `cron.sessionRetention` (default `"24h"`) prunes old isolated cron run sessions from the store; `false` or a zero duration such as `"0h"` disables.
- Run history keeps the newest 2000 terminal rows per cron job. Lost rows retain their 24-hour cleanup window.
- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
When cron force-creates a new isolated run session, it sanitizes the previous `cron:<jobId>` session entry before writing the new row: it carries safe preferences (thinking/fast/verbose/reasoning settings, labels, display name) and explicit user-selected model/auth overrides, but drops ambient conversation context (channel/group routing, send/queue policy, elevation, origin, ACP runtime binding) so a fresh isolated run cannot inherit stale delivery or runtime authority from an older run.
@@ -0,0 +1,27 @@
import { expect, it, vi } from "vitest";
const cleanupMocks = vi.hoisted(() => ({
ensureBrowserProxyUploadCleanup: vi.fn(async () => undefined),
}));
vi.mock("./register.runtime.js", () => {
throw new Error("node-host availability must not load the broad browser runtime");
});
vi.mock("./src/browser-proxy-upload-cleanup.runtime.js", () => ({
ensureBrowserProxyUploadCleanup: cleanupMocks.ensureBrowserProxyUploadCleanup,
}));
const { browserPluginNodeHostCommands } = await import("./plugin-registration.js");
it("starts node-host upload cleanup without loading the broad browser runtime", async () => {
const uploadCommand = browserPluginNodeHostCommands.find(
(command) => command.command === "browser.proxy.upload.v1",
);
uploadCommand?.watchAvailability?.({ config: {}, env: {} }, vi.fn());
await vi.waitFor(() => {
expect(cleanupMocks.ensureBrowserProxyUploadCleanup).toHaveBeenCalledOnce();
});
});
+4 -1
View File
@@ -45,6 +45,9 @@ const logger = createSubsystemLogger("browser");
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule(
() => import("./register.runtime.js"),
);
const loadBrowserUploadCleanupRuntimeModule = createLazyRuntimeModule(
() => import("./src/browser-proxy-upload-cleanup.runtime.js"),
);
function deriveChatTypeFromSessionKey(
sessionKey: string | undefined,
@@ -201,7 +204,7 @@ function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeH
...(command === BROWSER_PROXY_UPLOAD_COMMAND
? {
watchAvailability: () => {
void loadBrowserRegistrationRuntimeModule()
void loadBrowserUploadCleanupRuntimeModule()
.then(({ ensureBrowserProxyUploadCleanup }) => ensureBrowserProxyUploadCleanup())
.catch((error: unknown) => {
logger.warn(`browser proxy upload cleanup startup failed: ${String(error)}`);
-1
View File
@@ -3,7 +3,6 @@
* registration lazy-load these exports when browser runtime behavior is needed.
*/
export { createBrowserTool } from "./src/browser-tool.js";
export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { createBrowserPluginService } from "./src/plugin-service.js";
@@ -0,0 +1 @@
export { ensureBrowserProxyUploadCleanup } from "./browser-proxy-upload.js";
+116 -100
View File
@@ -37,6 +37,38 @@ describe("qa confidence report", () => {
return filePath;
}
async function buildStrictSuiteReport(payload: Record<string, unknown>, withBackfill = false) {
await writeJson("report-only/qa-suite-summary.json", payload);
const lanes: QaConfidenceManifest["lanes"] = [
{
id: "report-only",
title: "Report-only",
kind: "qa-suite-summary",
artifact: "report-only/qa-suite-summary.json",
required: true,
...(withBackfill ? { skipBackfillLane: "backfill" } : {}),
},
];
if (withBackfill) {
await writeJson("backfill/qa-suite-summary.json", {
counts: { total: 1, passed: 1, failed: 0, skipped: 0 },
});
lanes.push({
id: "backfill",
title: "Passing backfill",
kind: "qa-suite-summary",
artifact: "backfill/qa-suite-summary.json",
required: true,
});
}
return buildQaConfidenceReport({
manifest: { version: 1, profile: "confidence-regression", lanes },
artifactRoot: tempRoot,
strictZeroUnknowns: true,
strictGlobalPass: true,
});
}
it("passes strict zero-unknowns when every lane passes or has an allowed blocked verdict", async () => {
await writeJson("tool-defaults/qa-suite-summary.json", {
counts: { total: 20, passed: 18, skipped: 2, failed: 0 },
@@ -276,31 +308,11 @@ describe("qa confidence report", () => {
});
it("fails strict global pass for skipped suite rows until a backfill lane passes", async () => {
await writeJson("report-only/qa-suite-summary.json", {
const report = await buildStrictSuiteReport({
counts: { total: 3, passed: 2, skipped: 1, failed: 0 },
scenarios: [],
});
const report = await buildQaConfidenceReport({
manifest: {
version: 1,
profile: "codex-100",
lanes: [
{
id: "report-only",
title: "Report-only",
kind: "qa-suite-summary",
artifact: "report-only/qa-suite-summary.json",
required: true,
},
],
},
artifactRoot: tempRoot,
strictZeroUnknowns: true,
strictGlobalPass: true,
generatedAt: "2026-05-12T00:00:00.000Z",
});
expect(report.zeroUnknowns).toBe(true);
expect(report.globalPass).toBe(false);
expect(report.failures).toEqual([
@@ -308,6 +320,82 @@ describe("qa confidence report", () => {
]);
});
it.each([
["count-backed", "skip"],
["count-backed", "skipped"],
["legacy", "skip"],
["legacy", "skipped"],
["unverified-pass-count", "skip"],
["unverified-pass-count", "skipped"],
] as const)(
"rejects %s suites containing only %s scenarios despite a passing backfill",
async (format, skippedStatus) => {
const report = await buildStrictSuiteReport(
{
...(format === "count-backed"
? { counts: { total: 1, passed: 0, failed: 0, skipped: 1 } }
: format === "unverified-pass-count"
? { counts: { passed: 1, failed: 0 } }
: {}),
scenarios: [{ name: "never executed", status: skippedStatus }],
},
true,
);
expect(report.pass).toBe(false);
expect(report.globalPass).toBe(false);
expect(report.lanes[0]).toMatchObject({ status: "unknown" });
expect(report.lanes[0]?.details).toContain("no executed scenarios");
expect(report.lanes[1]).toMatchObject({ status: "pass" });
},
);
it.each([
["skip", undefined],
["skipped", undefined],
["count-reported skip", 1],
] as const)(
"requires a passing backfill for legacy suites containing a pass and %s",
async (skippedStatus, explicitSkippedCount) => {
const artifact = {
...(explicitSkippedCount === undefined
? {}
: { counts: { skipped: explicitSkippedCount } }),
scenarios: [
{ name: "executed", status: "pass" },
...(explicitSkippedCount === undefined
? [{ name: "not executed", status: skippedStatus }]
: []),
],
};
for (const hasBackfill of [false, true]) {
const report = await buildStrictSuiteReport(artifact, hasBackfill);
expect(report.pass).toBe(hasBackfill);
expect(report.globalPass).toBe(hasBackfill);
expect(report.lanes[0]).toMatchObject({ status: "pass", skippedCount: 1 });
if (hasBackfill) {
expect(report.lanes[0]).toMatchObject({ skipBackfilled: true });
} else {
expect(report.failures).toEqual([
"report-only has 1 skipped row(s) with no passing backfill lane",
]);
}
}
},
);
it("accepts a positive count-only suite without scenario rows", async () => {
const report = await buildStrictSuiteReport({
counts: { total: 1, passed: 1, failed: 0, skipped: 0 },
});
expect(report.pass).toBe(true);
expect(report.globalPass).toBe(true);
expect(report.lanes[0]).toMatchObject({ status: "pass" });
});
it("infers skipped suite rows from totals and scenario status", async () => {
for (const [artifact, expectedDetail] of [
[{ counts: { total: 3, passed: 2, failed: 0 }, scenarios: [] }, "counts.skipped=1"],
@@ -322,27 +410,7 @@ describe("qa confidence report", () => {
"counts.skipped=1",
],
] as const) {
await writeJson("report-only/qa-suite-summary.json", artifact);
const report = await buildQaConfidenceReport({
manifest: {
version: 1,
profile: "codex-100",
lanes: [
{
id: "report-only",
title: "Report-only",
kind: "qa-suite-summary",
artifact: "report-only/qa-suite-summary.json",
required: true,
},
],
},
artifactRoot: tempRoot,
strictZeroUnknowns: true,
strictGlobalPass: true,
generatedAt: "2026-05-12T00:00:00.000Z",
});
const report = await buildStrictSuiteReport(artifact);
expect(report.globalPass).toBe(false);
expect(report.failures).toEqual([
@@ -369,27 +437,7 @@ describe("qa confidence report", () => {
"unsupported non-pass status",
],
] as const) {
await writeJson("report-only/qa-suite-summary.json", artifact);
const report = await buildQaConfidenceReport({
manifest: {
version: 1,
profile: "codex-100",
lanes: [
{
id: "report-only",
title: "Report-only",
kind: "qa-suite-summary",
artifact: "report-only/qa-suite-summary.json",
required: true,
},
],
},
artifactRoot: tempRoot,
strictZeroUnknowns: true,
strictGlobalPass: true,
generatedAt: "2026-05-12T00:00:00.000Z",
});
const report = await buildStrictSuiteReport(artifact);
expect(report.pass).toBe(false);
expect(report.globalPass).toBe(false);
@@ -468,49 +516,17 @@ describe("qa confidence report", () => {
});
it("passes strict global pass when skipped suite rows are backfilled by a passing lane", async () => {
await writeJson("report-only/qa-suite-summary.json", {
counts: { total: 3, passed: 2, skipped: 1, failed: 0 },
scenarios: [],
});
await writeJson("live-backfill/qa-suite-summary.json", {
counts: { total: 1, passed: 1, skipped: 0, failed: 0 },
scenarios: [],
});
const report = await buildQaConfidenceReport({
manifest: {
version: 1,
profile: "codex-100",
lanes: [
{
id: "report-only",
title: "Report-only",
kind: "qa-suite-summary",
artifact: "report-only/qa-suite-summary.json",
required: true,
skipBackfillLane: "live-backfill",
},
{
id: "live-backfill",
title: "Live backfill",
kind: "qa-suite-summary",
artifact: "live-backfill/qa-suite-summary.json",
required: true,
},
],
},
artifactRoot: tempRoot,
strictZeroUnknowns: true,
strictGlobalPass: true,
generatedAt: "2026-05-12T00:00:00.000Z",
});
const report = await buildStrictSuiteReport(
{ counts: { total: 3, passed: 2, skipped: 1, failed: 0 }, scenarios: [] },
true,
);
expect(report.pass).toBe(true);
expect(report.zeroUnknowns).toBe(true);
expect(report.globalPass).toBe(true);
expect(report.lanes[0]).toMatchObject({
skippedCount: 1,
skipBackfillLane: "live-backfill",
skipBackfillLane: "backfill",
skipBackfilled: true,
});
});
+49 -64
View File
@@ -390,9 +390,8 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
const failedCount = readCount(counts?.failed);
const explicitSkippedCount = readCount(counts?.skipped);
const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : undefined;
const failedScenarios = scenarios?.filter(
(scenario) => isRecord(scenario) && scenario.status === "fail",
);
const failedScenarioCount =
scenarios?.filter((scenario) => isRecord(scenario) && scenario.status === "fail").length ?? 0;
const skippedScenarioCount =
scenarios?.filter(
(scenario) =>
@@ -407,14 +406,19 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
scenario.status !== "skip" &&
scenario.status !== "skipped"),
).length ?? 0;
const hasScenarioRows = scenarios !== undefined && scenarios.length > 0;
const hasExecutedScenarios =
(failedCount ?? 0) > 0 ||
scenarios?.some(
(scenario) =>
isRecord(scenario) && (scenario.status === "pass" || scenario.status === "fail"),
) === true ||
((scenarios?.length ?? 0) === 0 && (passedCount ?? 0) > 0);
const gatewayLogSentinels = collectGatewayLogSentinels(payload);
if (gatewayLogSentinels.length > 0) {
const allEnvironmentBlocked = gatewayLogSentinels.every(
(finding) => finding.verdict === "environment-blocked",
);
const suiteHasFailures =
(failedCount !== undefined && failedCount > 0) || (failedScenarios?.length ?? 0) > 0;
const suiteHasFailures = (failedCount ?? 0) > 0 || failedScenarioCount > 0;
if (allEnvironmentBlocked && suiteHasFailures) {
return {
passed: false,
@@ -436,31 +440,42 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
details: `gateway log sentinel(s): ${formatGatewayLogSentinelSummary(gatewayLogSentinels)}`,
};
}
if (
failedCount !== undefined &&
scenarios !== undefined &&
Math.floor(failedCount) !== failedScenarioCount
) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary count/scenario mismatch: counts.failed=${Math.max(
0,
Math.floor(failedCount),
)}, failed scenarios=${failedScenarioCount}`,
};
}
if (unknownBlockingScenarioCount > 0) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`,
};
}
if (failedCount === undefined && scenarios === undefined) {
return {
passed: false,
status: "unknown",
details: "qa-suite-summary missing counts.failed and scenarios[]",
};
}
if (!hasExecutedScenarios) {
return {
passed: false,
status: "unknown",
details: "qa-suite-summary has no executed scenarios",
};
}
if (failedCount !== undefined) {
if (failedCount === 0 && !(totalCount !== undefined && totalCount > 0) && !hasScenarioRows) {
return {
passed: false,
status: "unknown",
details: "qa-suite-summary has no executed scenarios",
};
}
if (failedScenarios !== undefined && Math.floor(failedCount) !== failedScenarios.length) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary count/scenario mismatch: counts.failed=${Math.max(
0,
Math.floor(failedCount),
)}, failed scenarios=${failedScenarios.length}`,
};
}
if (unknownBlockingScenarioCount > 0) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`,
};
}
const inferredSkippedCount =
totalCount === undefined || passedCount === undefined
? undefined
@@ -483,41 +498,11 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
...(skippedCount === 0 ? {} : { skippedCount: Math.max(0, Math.floor(skippedCount)) }),
};
}
if (!Array.isArray(payload.scenarios)) {
return {
passed: false,
status: "unknown",
details: "qa-suite-summary missing counts.failed and scenarios[]",
};
}
if (payload.scenarios.length === 0) {
return {
passed: false,
status: "unknown",
details: "qa-suite-summary has no executed scenarios",
};
}
const fallbackFailedScenarios = payload.scenarios.filter(
(scenario) => isRecord(scenario) && scenario.status === "fail",
);
const fallbackUnknownBlockingScenarios = payload.scenarios.filter(
(scenario) =>
!isRecord(scenario) ||
(scenario.status !== "pass" &&
scenario.status !== "fail" &&
scenario.status !== "skip" &&
scenario.status !== "skipped"),
);
if (fallbackUnknownBlockingScenarios.length > 0) {
return {
passed: false,
status: "unknown",
details: `qa-suite-summary has ${fallbackUnknownBlockingScenarios.length} scenario row(s) with unsupported non-pass status`,
};
}
const skippedCount = Math.max(explicitSkippedCount ?? 0, skippedScenarioCount);
return {
passed: fallbackFailedScenarios.length === 0,
details: `qa-suite-summary failed scenarios=${fallbackFailedScenarios.length}`,
passed: failedScenarioCount === 0,
details: `qa-suite-summary failed scenarios=${failedScenarioCount}`,
...(skippedCount === 0 ? {} : { skippedCount }),
};
}
@@ -52,6 +52,28 @@ describe("taxonomy profile scenario selection", () => {
).not.toContainEqual(expect.objectContaining({ sourcePath: unrelatedRefs[0] }));
});
it("selects the packaged first-run journey for release container setup", () => {
const catalog = readQaScenarioPack().scenarios;
const report = readQaScorecardTaxonomyReport(catalog);
const onboarding = catalog.find(
(scenario) => scenario.id === "docker-npm-onboard-channel-agent",
);
const systemAgent = catalog.find((scenario) => scenario.id === "docker-system-agent-first-run");
expect(onboarding?.coverage?.primary).toContain("containers.first-run-onboarding");
expect(systemAgent?.coverage?.primary).not.toContain("containers.first-run-onboarding");
expect(systemAgent?.coverage?.secondary).toContain("containers.first-run-onboarding");
expect(report.validationIssues).not.toContainEqual(
expect.objectContaining({
code: "coverage-id-missing-primary-inventory",
ref: "containers.first-run-onboarding",
}),
);
expect(report.profiles.find((profile) => profile.id === "release")?.scenarioRefs).toContain(
onboarding?.sourcePath,
);
});
it("derives channel defaults from catalog metadata and lane constraints", () => {
const liveTelegram = resolveLiveTransportQaScenarioIds({
channelId: "telegram",
@@ -7,7 +7,6 @@ scenario:
coverage:
primary:
- containers.docker-backed-agent-sandbox-support
secondary:
- containers.first-run-onboarding
objective: Verify a package-installed Docker runner can complete non-interactive onboarding, configure a channel, start Gateway-backed agent behavior, and complete a mocked model turn.
successCriteria:
+1
View File
@@ -182,6 +182,7 @@ export function buildCronMocks(baseTime: number) {
}));
const status: CronStatus = {
enabled: true,
triggersEnabled: true,
jobs: jobs.length,
nextWakeAtMs: overdueJob.state?.nextRunAtMs,
};
+2 -2
View File
@@ -507,7 +507,7 @@ function publishTerminalLaneFacts(params: {
facts: unknown;
lane: Lane;
roots: Roots;
status: "fail" | "pass";
status: "blocked" | "fail" | "pass";
sutAttestation?: SutAttestation;
}): void {
const privatePublished = path.join(params.roots.sessionRoot, "published", params.lane);
@@ -1309,7 +1309,7 @@ async function finalize(
facts,
lane: state.lane,
roots,
status: status === "complete" ? "pass" : "fail",
status: status === "complete" ? "pass" : status === "blocked" ? "blocked" : "fail",
sutAttestation: state.sut.sutAttestation,
});
fs.rmSync(activeFile(roots.sessionRoot, state.lane), { force: true });
@@ -1,10 +1,4 @@
// Packed Plugin Sdk Type Smoke script supports OpenClaw repository automation.
import type {
MemoryReadResult,
MemorySearchManager,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
type PublicPluginSdkModules = [
typeof import("openclaw/plugin-sdk/core"),
typeof import("openclaw/plugin-sdk/channel-entry-contract"),
@@ -14,31 +8,5 @@ type PublicPluginSdkModules = [
];
const resolvedModules = null as unknown as PublicPluginSdkModules;
declare const canonicalManagerRest: Omit<MemorySearchManager, "readFile">;
declare const canonicalReadResult: MemoryReadResult;
const legacyManager = {
...canonicalManagerRest,
async readFile({ relPath }: { relPath: string }) {
return { text: "", path: relPath };
},
};
const legacyRuntime = {
async getMemorySearchManager() {
return { manager: legacyManager };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
} satisfies MemoryPluginRuntime;
type BareLegacyReadResult = { text: ""; path: string };
const canonicalRejectsBareLegacy: BareLegacyReadResult extends MemoryReadResult ? false : true =
true;
void resolvedModules;
void legacyRuntime;
void canonicalReadResult.from;
void canonicalReadResult.lines;
void canonicalReadResult.truncated;
void canonicalReadResult.nextFrom;
void canonicalRejectsBareLegacy;
+26
View File
@@ -45,6 +45,21 @@ const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12;
// integration tests past the global timeout.
const SERIAL_CHANGED_TARGET_RE = /^extensions\/memory-core\//u;
const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
const DOCKER_SEED_LANE_ORDER = [
"mcp-channels",
"cron-mcp-cleanup",
"mcp-code-mode-gateway",
] as const;
type DockerSeedLane = (typeof DOCKER_SEED_LANE_ORDER)[number];
const DOCKER_SEED_LANES_BY_PATH: Readonly<Record<string, readonly DockerSeedLane[]>> = {
".github/workflows/ci.yml": DOCKER_SEED_LANE_ORDER,
"scripts/e2e/cron-mcp-cleanup-seed.ts": ["cron-mcp-cleanup"],
"scripts/e2e/docker-openai-seed.ts": DOCKER_SEED_LANE_ORDER,
"scripts/e2e/lib/mcp-code-mode-probe-server.ts": ["mcp-code-mode-gateway"],
"scripts/e2e/mcp-channels-seed.ts": ["mcp-channels"],
"scripts/e2e/mcp-code-mode-gateway-seed.ts": ["mcp-code-mode-gateway"],
"scripts/lib/ci-changed-node-test-plan.mts": DOCKER_SEED_LANE_ORDER,
};
const publicPluginSdkEntrySources = Object.values(
buildPluginSdkEntrySources(publicPluginSdkEntrypoints),
);
@@ -61,6 +76,17 @@ const splitNodeTestConfigs = new Set(
fullNodeTestShards.filter((shard) => shard.includePatterns).flatMap((shard) => shard.configs),
);
export function resolveChangedDockerSeedLanes(changedPaths: string[]) {
const selected = new Set<DockerSeedLane>();
for (const changedPath of changedPaths) {
const normalizedPath = changedPath.replaceAll("\\", "/");
for (const lane of DOCKER_SEED_LANES_BY_PATH[normalizedPath] ?? []) {
selected.add(lane);
}
}
return DOCKER_SEED_LANE_ORDER.filter((lane) => selected.has(lane));
}
function isTestOnlyPath(changedPath: string) {
return (
isTestFileTarget(changedPath) ||
+3 -2
View File
@@ -5,8 +5,9 @@ import { resolveNpmJsonEntries } from "./npm-json-output.mts";
// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime
// dependencies, including crypto wasm, so packaged installs do not miss Docker
// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat
// level while allowing that mirrored runtime surface.
const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 202 * 1024 * 1024;
// level while allowing that mirrored runtime surface and the installed agent's
// bundled user documentation.
const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 204 * 1024 * 1024;
function formatMiB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
type CliArgs = Record<string, string>;
type LaneName = "baseline" | "candidate";
type LaneStatus = "blocked" | "fail" | "pass";
type SessionSummary = {
artifacts?: Partial<
Record<
@@ -44,6 +45,7 @@ type TelegramDesktopProofManifest = {
comparison: {
baseline: { expected: string; status: string; ref?: string; sha?: string };
candidate: { expected: string; status: string; fixed: boolean; ref?: string; sha?: string };
outcome: LaneStatus;
pass: boolean;
};
artifacts: EvidenceArtifact[];
@@ -195,8 +197,8 @@ function copyLaneArtifacts({
});
}
function laneStatus(lane: LoadedLane) {
return lane.status === "pass" ? "pass" : "fail";
function laneStatus(lane: LoadedLane): LaneStatus {
return lane.status === "pass" || lane.status === "blocked" ? lane.status : "fail";
}
function requireLaneAttestation(lane: LoadedLane, expectedLane: LaneName, expectedSha: string) {
@@ -216,7 +218,7 @@ function requireLaneAttestation(lane: LoadedLane, expectedLane: LaneName, expect
throw new Error(`SUT attestation mismatch for ${expectedLane}.`);
}
function laneArtifactEntries(statuses: Record<LaneName, "pass" | "fail">): EvidenceArtifact[] {
function laneArtifactEntries(statuses: Record<LaneName, LaneStatus>): EvidenceArtifact[] {
return LANES.flatMap(({ altPrefix, label, lane }) => [
{
alt: `${altPrefix} native Telegram Desktop proof GIF`,
@@ -287,7 +289,12 @@ function buildTelegramDesktopProofManifest({
}): TelegramDesktopProofManifest {
const baselineStatus = laneStatus(baseline);
const candidateStatus = laneStatus(candidate);
const pass = baselineStatus === "pass" && candidateStatus === "pass";
const outcome =
baselineStatus === "fail" || candidateStatus === "fail"
? "fail"
: baselineStatus === "blocked" || candidateStatus === "blocked"
? "blocked"
: "pass";
return {
schemaVersion: 1,
id: "telegram-desktop-proof",
@@ -309,7 +316,8 @@ function buildTelegramDesktopProofManifest({
status: candidateStatus,
fixed: candidateStatus === "pass",
},
pass,
outcome,
pass: outcome === "pass",
},
artifacts: laneArtifactEntries({ baseline: baselineStatus, candidate: candidateStatus }),
};
+8 -1
View File
@@ -27,7 +27,7 @@ import { readBoundedResponseText } from "../lib/bounded-response.mjs";
/**
* @typedef {{
* artifacts: EvidenceArtifact[],
* comparison: { baseline?: EvidenceLane, candidate: EvidenceLane, pass?: boolean },
* comparison: { baseline?: EvidenceLane, candidate: EvidenceLane, outcome?: "blocked" | "fail" | "pass", pass?: boolean },
* id: string,
* manifestDir: string,
* scenario: string,
@@ -385,6 +385,10 @@ function publicSummary(manifest) {
return manifest.summary ?? "Mantis captured QA evidence for this scenario.";
}
function overallStatus(manifest) {
const outcome = manifest.comparison?.outcome;
if (outcome === "blocked" || outcome === "fail" || outcome === "pass") {
return outcome;
}
const pass = manifest.comparison?.pass;
return typeof pass === "boolean" ? String(pass) : "";
}
@@ -396,6 +400,9 @@ export function shouldPublishPrComment(manifest, { requestSource } = {}) {
if (!isTelegramDesktopProof(manifest) || hasVisibleProofArtifacts(manifest)) {
return true;
}
if (manifest.comparison?.outcome === "blocked") {
return true;
}
if (requestSource === "pull_request_target") {
return false;
}
+2 -12
View File
@@ -2113,10 +2113,7 @@ const EXACT_TOOLING_TARGETS = new Map<string, string[]>([
[".github/actions/setup-pnpm-store-cache/action.yml", [packageAcceptance, workflowGuards]],
[".github/actions/setup-pnpm-store-cache/ensure-node.sh", ["setup-pnpm-store-cache-ensure-node"]],
["test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", [dockerE2e, pluginPrerelease]],
[
"scripts/e2e/lib/mcp-code-mode-probe-server.ts",
["docker-e2e-seeds", "mcp-code-mode-gateway-client"],
],
["scripts/e2e/lib/mcp-code-mode-probe-server.ts", ["mcp-code-mode-gateway-client"]],
["scripts/e2e/cron-cli-docker.sh", [dockerBuild, "docker-e2e-observability"]],
["scripts/ios-release-upload.sh", ["ios-release-wrapper-args", "ios-release-fastlane-gates"]],
["scripts/release-verify-beta.ts", ["release-wrapper-scripts"]],
@@ -2782,10 +2779,6 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
),
["mcp-code-mode-gateway-client", "session-log-mentions"],
],
[
/^(?:scripts\/e2e\/(?:mcp-channels|mcp-code-mode-gateway|cron-mcp-cleanup)-seed\.ts)$/u,
["docker-e2e-seeds"],
],
[
/^scripts\/e2e\/(?:mcp-channels|cron-cli|cron-mcp-cleanup)-docker\.sh$/u,
["docker-e2e-observability"],
@@ -2800,10 +2793,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
"src/cron/active-jobs-manual-run.test.ts",
],
],
[
/^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u,
["cron-mcp-cleanup-docker-client", "docker-e2e-seeds"],
],
[/^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u, ["cron-mcp-cleanup-docker-client"]],
[
/^test\/e2e\/qa-lab\/runtime\/mcp-channels\.fixture\.ts$/u,
["test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", "cron-mcp-cleanup-docker-client"],
@@ -330,14 +330,16 @@ describe("accepted context-engine turn finalization", () => {
),
).toMatchObject({ state: "blocked", failure: "stale" });
const nextAdmission = {
...admission,
logicalTurnId: "logical-turn-next",
};
await drainPendingContextEngineTurnsBeforeRun({
admission,
admission: nextAdmission,
lease,
warn,
});
expect(lease.degradeBeforeStart).toHaveBeenCalledWith(
"pending durable turn advancement could not be completed before the next turn",
);
expect(lease.degradeBeforeStart).not.toHaveBeenCalled();
const sibling = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "sibling" },
@@ -137,6 +137,50 @@ describe("context-engine turn outbox", () => {
).toBeUndefined();
});
it("keeps a row pending when its persisted payload has no state", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-state-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
agentId: "main",
env: { OPENCLAW_STATE_DIR: stateDir },
});
const payload = createPayload({
advancementKey: "session-a:missing-state",
databasePath: database.path,
sequence: 1,
sessionId: "session-a",
});
enqueueContextEngineTurnCommit({ database, engineId: "test", payload });
database.db
.prepare(
"UPDATE context_engine_turn_outbox SET payload_json = '{}' WHERE advancement_key = ?",
)
.run(payload.boundary.admission.logicalTurnId);
const commitTurn = vi.fn(async () => ({ status: "committed" as const }));
const engine = {
info: { id: "test", name: "Test" },
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
} satisfies ContextEngine;
const result = await drainContextEngineTurnOutbox({
database,
engine,
engineId: "test",
warn: vi.fn(),
});
expect(result.pending).toBe(true);
expect(commitTurn).not.toHaveBeenCalled();
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(payload.boundary.admission.logicalTurnId),
).toBeDefined();
});
it("drains prior work before fresh-turn assembly and records dispatch admission", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-recovery-"));
tempDirs.push(stateDir);
@@ -372,7 +416,7 @@ describe("context-engine turn outbox", () => {
});
});
it("retains unrecoverable accepted recovery as a blocking marker", async () => {
it("retains unrecoverable accepted recovery as a terminal marker without blocking later turns", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-blocked-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
@@ -417,6 +461,17 @@ describe("context-engine turn outbox", () => {
expect.stringContaining("blocked unrecoverable turn advancement"),
);
enqueueContextEngineTurnCommit({
database,
engineId: "test",
payload: createPayload({
advancementKey: "session-a:later-ready",
databasePath: database.path,
sequence: 3,
sessionId: "session-a",
}),
});
const engine = {
info: {
id: "test",
@@ -445,22 +500,43 @@ describe("context-engine turn outbox", () => {
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => undefined),
} satisfies ContextEngineLogicalTurnLease;
const currentAdmission = {
...payload.boundary.admission,
logicalTurnId: "session-a:current",
};
await drainPendingContextEngineTurnsBeforeRun({
admission: payload.boundary.admission,
admission: currentAdmission,
lease,
warn,
});
expect(engine.commitTurn).not.toHaveBeenCalled();
expect(degradeBeforeStart).toHaveBeenCalledWith(
"pending durable turn advancement could not be completed before the next turn",
expect(engine.commitTurn).toHaveBeenCalledOnce();
expect(engine.commitTurn).toHaveBeenCalledWith(
expect.objectContaining({ advancementKey: "session-a:later-ready" }),
);
expect(degradeBeforeStart).not.toHaveBeenCalled();
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(payload.boundary.admission.logicalTurnId),
).toBeDefined();
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get("session-a:later-ready"),
).toBeUndefined();
expect(
JSON.parse(
(
database.db
.prepare(
"SELECT payload_json FROM context_engine_turn_outbox WHERE advancement_key = ?",
)
.get(currentAdmission.logicalTurnId) as { payload_json: string }
).payload_json,
),
).toMatchObject({ state: "admitted" });
});
it("does not let later same-session turns overtake a failed commit", async () => {
@@ -75,6 +75,12 @@ function oldestOutboxEnqueueSequence() {
return /* kysely-allow-raw: Aggregate the closed implicit-rowid expression used for enqueue order. */ sql<number>`MIN(context_engine_turn_outbox.rowid)`;
}
function outboxPayloadRequiresAdvancement() {
// Blocked rows are terminal audit evidence, not retryable work. Keep them
// inspectable without letting them hold later same-session turns behind them.
return /* kysely-allow-raw: Payload state is owned by the closed outbox union above. */ sql<boolean>`json_extract(context_engine_turn_outbox.payload_json, '$.state') IS NOT 'blocked'`;
}
export function isRetryableContextEngineTurnReadFailure(
kind: ContextEngineTurnReadFailureKind,
): kind is "projection-unavailable" {
@@ -356,7 +362,8 @@ export async function drainContextEngineTurnOutbox(params: {
// Use it instead of wall-clock timestamps, which can collide.
.select(oldestOutboxEnqueueSequence().as("oldest_enqueue_sequence"))
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null);
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
.where(outboxPayloadRequiresAdvancement());
if (params.sessionId) {
pendingSessionsQuery = pendingSessionsQuery.where("session_id", "=", params.sessionId);
}
@@ -382,6 +389,7 @@ export async function drainContextEngineTurnOutbox(params: {
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
.where("session_id", "=", sessionId)
.where(outboxPayloadRequiresAdvancement())
.orderBy(outboxEnqueueSequence(), "asc")
.limit(1),
);
@@ -409,7 +417,8 @@ function hasPendingContextEngineTurn(
.selectFrom("context_engine_turn_outbox")
.select("advancement_key")
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null);
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
.where(outboxPayloadRequiresAdvancement());
if (params.sessionId) {
query = query.where("session_id", "=", params.sessionId);
}
+82 -3
View File
@@ -18,7 +18,9 @@ import {
getCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "../plugins/current-plugin-metadata-snapshot.js";
import * as pluginControlPlaneContext from "../plugins/plugin-control-plane-context.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import * as pluginMetadataSnapshot from "../plugins/plugin-metadata-snapshot.js";
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
import {
buildShouldSuppressBuiltInModelCore,
@@ -34,6 +36,7 @@ describe("model suppression", () => {
});
afterEach(() => {
vi.restoreAllMocks();
setCurrentPluginMetadataSnapshot(undefined);
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -131,7 +134,7 @@ describe("model suppression", () => {
expect(secondResolver).toHaveBeenCalledOnce();
});
it("keeps concurrent scoped suppression resolvers isolated from process-current metadata", async () => {
it("reuses each concurrent generation's suppression resolver across A/B/A interleaving", async () => {
const config = {} satisfies OpenClawConfig;
const snapshotA = createPluginMetadataSnapshot({
config,
@@ -165,7 +168,14 @@ describe("model suppression", () => {
});
markAReady();
await holdA;
return result;
return [
result,
shouldSuppressBuiltInModelCore({
provider: "openai",
id: "generation-model",
config,
}),
];
},
);
await aReady;
@@ -181,8 +191,77 @@ describe("model suppression", () => {
);
releaseA();
await expect(resultA).resolves.toBe(true);
await expect(resultA).resolves.toEqual([true, true]);
expect(resultB).toBe(false);
expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2);
});
it("keys generation resolvers by config identity and workspace", () => {
const configA = {} satisfies OpenClawConfig;
const configB = {} satisfies OpenClawConfig;
const snapshot = createPluginMetadataSnapshot({
config: configA,
manifestRegistry: { plugins: [], diagnostics: [] },
});
mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined);
const check = (config: OpenClawConfig, workspaceDir: string) =>
withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () =>
shouldSuppressBuiltInModelCore({
provider: "openai",
id: "generation-model",
config,
workspaceDir,
}),
);
expect(check(configA, "/workspace/a")).toBe(false);
expect(check(configB, "/workspace/a")).toBe(false);
expect(check(configA, "/workspace/b")).toBe(false);
expect(check(configA, "/workspace/a")).toBe(false);
expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(3);
});
it("does not recompute content fingerprints on a stable generation cache hit", () => {
const config = {} satisfies OpenClawConfig;
const snapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
});
mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined);
const controlPlaneFingerprint = vi.spyOn(
pluginControlPlaneContext,
"resolvePluginControlPlaneFingerprint",
);
const envFingerprint = vi.spyOn(pluginMetadataSnapshot, "resolvePluginMetadataEnvFingerprint");
withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => {
shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config });
controlPlaneFingerprint.mockClear();
envFingerprint.mockClear();
shouldSuppressBuiltInModelCore({ provider: "anthropic", id: "claude-4", config });
expect(controlPlaneFingerprint).not.toHaveBeenCalled();
expect(envFingerprint).not.toHaveBeenCalled();
});
});
it("rebuilds a generation resolver after plugin metadata lifecycle caches clear", () => {
const config = {} satisfies OpenClawConfig;
const snapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
});
mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined);
withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => {
shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config });
clearPluginMetadataLifecycleCaches();
shouldSuppressBuiltInModelCore({ provider: "anthropic", id: "claude-4", config });
});
expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2);
});
it("refreshes manifest suppression resolver when process env plugin metadata inputs change", () => {
+45 -8
View File
@@ -6,29 +6,41 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import {
getCurrentPluginMetadataSnapshot,
isCurrentPluginMetadataSnapshotRuntimeGeneration,
} from "../plugins/current-plugin-metadata-snapshot.js";
import { buildManifestBuiltInModelSuppressionResolver } from "../plugins/manifest-model-suppression.js";
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
import { resolvePluginMetadataEnvFingerprint } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
type ManifestSuppressionResolver = ReturnType<typeof buildManifestBuiltInModelSuppressionResolver>;
type CachedManifestSuppressionResolver = {
type CachedStandaloneManifestSuppressionResolver = {
config: OpenClawConfig | undefined;
controlPlaneFingerprint: string;
cwd: string;
envFingerprint: string;
metadataSnapshot: unknown;
metadataSnapshot: PluginMetadataSnapshot | undefined;
resolver: ManifestSuppressionResolver;
workspaceDir: string | undefined;
};
let cachedManifestSuppressionResolver: CachedManifestSuppressionResolver | undefined;
const configlessRuntimeGeneration = {};
let runtimeGenerationResolvers = new WeakMap<
PluginMetadataSnapshot,
WeakMap<object, Map<string | undefined, ManifestSuppressionResolver>>
>();
let cachedStandaloneManifestSuppressionResolver:
| CachedStandaloneManifestSuppressionResolver
| undefined;
/** Clear cached manifest suppression resolver state for tests and metadata lifecycle resets. */
function clearModelSuppressionResolverCache(): void {
cachedManifestSuppressionResolver = undefined;
runtimeGenerationResolvers = new WeakMap();
cachedStandaloneManifestSuppressionResolver = undefined;
}
registerPluginMetadataProcessMemoLifecycleClear(clearModelSuppressionResolverCache);
@@ -38,7 +50,33 @@ function resolveCachedManifestSuppressionResolver(params: {
env: NodeJS.ProcessEnv;
workspaceDir?: string;
}): ManifestSuppressionResolver {
const cached = cachedManifestSuppressionResolver;
const metadataSnapshot = getCurrentPluginMetadataSnapshot(params);
if (metadataSnapshot && isCurrentPluginMetadataSnapshotRuntimeGeneration(metadataSnapshot)) {
let byConfig = runtimeGenerationResolvers.get(metadataSnapshot);
if (!byConfig) {
byConfig = new WeakMap();
runtimeGenerationResolvers.set(metadataSnapshot, byConfig);
}
const configKey = params.config ?? configlessRuntimeGeneration;
let byWorkspace = byConfig.get(configKey);
if (!byWorkspace) {
byWorkspace = new Map();
byConfig.set(configKey, byWorkspace);
}
const cached = byWorkspace.get(params.workspaceDir);
if (cached) {
return cached;
}
const resolver = buildManifestBuiltInModelSuppressionResolver({
env: params.env,
...(params.config ? { config: params.config } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
byWorkspace.set(params.workspaceDir, resolver);
return resolver;
}
const cached = cachedStandaloneManifestSuppressionResolver;
const controlPlaneFingerprint = resolvePluginControlPlaneFingerprint({
...(params.config ? { config: params.config } : {}),
env: params.env,
@@ -46,7 +84,6 @@ function resolveCachedManifestSuppressionResolver(params: {
});
const cwd = process.cwd();
const envFingerprint = resolvePluginMetadataEnvFingerprint(params.env);
const metadataSnapshot = getCurrentPluginMetadataSnapshot(params);
if (
cached !== undefined &&
cached.config === params.config &&
@@ -63,7 +100,7 @@ function resolveCachedManifestSuppressionResolver(params: {
...(params.config ? { config: params.config } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
cachedManifestSuppressionResolver = {
cachedStandaloneManifestSuppressionResolver = {
config: params.config,
controlPlaneFingerprint,
cwd,
+17
View File
@@ -771,6 +771,23 @@ describe("cron cli", () => {
});
});
it.each(["", "not-a-url"])("rejects invalid cron add --webhook %j", async (value) => {
await expectCronCommandExit([
"cron",
"add",
"Webhook reminder",
"--at",
"20m",
"--system-event",
"Summarize the latest status",
"--webhook",
value,
]);
expectRuntimeErrorContaining("--webhook must be a valid http(s) URL");
expect(callGatewayFromCli).not.toHaveBeenCalled();
});
it("accepts Hermes-style positional cron schedule and message on cron create", async () => {
const params = await runCronAddAndGetParams([
"0 2 * * *",
+7 -2
View File
@@ -8,6 +8,7 @@ import type { Command } from "commander";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
@@ -203,8 +204,12 @@ export function registerCronAddCommand(cron: Command) {
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
const hasNoDeliver = opts.deliver === false;
const webhookUrl = normalizeOptionalString(opts.webhook);
const hasWebhook = typeof opts.webhook === "string";
const webhookUrl =
typeof opts.webhook === "string" ? normalizeHttpWebhookUrl(opts.webhook) : null;
if (typeof opts.webhook === "string" && !webhookUrl) {
throw new Error("--webhook must be a valid http(s) URL");
}
const hasWebhook = Boolean(webhookUrl);
const deliveryFlagCount = [hasAnnounce, hasNoDeliver, hasWebhook].filter(
Boolean,
).length;
@@ -24,6 +24,7 @@ const assignIf = (
export async function resolveCronEditPayloadDeliveryPatch(
opts: Record<string, unknown>,
loadExistingJob: () => Promise<CronJob>,
webhookUrl: string | undefined,
): Promise<Record<string, unknown>> {
const patch: Record<string, unknown> = {};
const hasSystemEventPatch = typeof opts.systemEvent === "string";
@@ -33,12 +34,15 @@ export async function resolveCronEditPayloadDeliveryPatch(
if (commandShell && commandArgv) {
throw new Error("Pass command payload either with --command or --command-argv, not both.");
}
// Raw flag presence owns the set/clear mutex even when normalization omits a blank value.
const hasModel = typeof opts.model === "string";
const model = normalizeOptionalString(opts.model);
if (model && opts.clearModel) {
if (hasModel && opts.clearModel) {
throw new Error("Use --model or --clear-model, not both");
}
const hasThinking = typeof opts.thinking === "string";
const thinking = normalizeOptionalString(opts.thinking);
if (thinking && opts.clearThinking) {
if (hasThinking && opts.clearThinking) {
throw new Error("Use --thinking or --clear-thinking, not both");
}
const fallbacks = parseCronFallbacks(opts.fallbacks);
@@ -86,7 +90,7 @@ export async function resolveCronEditPayloadDeliveryPatch(
throw new Error("Invalid --script-tool-budget (must be a positive integer).");
}
const hasWebhookDelivery = typeof opts.webhook === "string";
const hasWebhookDelivery = Boolean(webhookUrl);
const hasDeliveryModeFlag =
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
const threadId = parseCronThreadIdOption(opts.threadId);
@@ -272,8 +276,7 @@ export async function resolveCronEditPayloadDeliveryPatch(
delivery.channel = channel ? channel : undefined;
}
if (hasWebhookDelivery) {
const webhook = normalizeOptionalString(opts.webhook) ?? "";
delivery.to = webhook ? webhook : undefined;
delivery.to = webhookUrl;
} else if (opts.clearTo) {
delivery.to = null;
} else if (typeof opts.to === "string") {
@@ -698,6 +698,18 @@ describe("cron edit command", () => {
);
});
it.each([
["--model", "", "--clear-model"],
["--model", " ", "--clear-model"],
["--thinking", "", "--clear-thinking"],
["--thinking", " ", "--clear-thinking"],
])("rejects blank %s %j combined with %s", async (flag, value, clearFlag) => {
await expectCronEditRejection(
[flag, value, clearFlag],
`Use ${flag} or ${clearFlag}, not both`,
);
});
it("stores an explicit wildcard with --clear-tools", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "cron.get") {
@@ -1040,6 +1052,10 @@ describe("cron edit command", () => {
exitSpy.mockRestore();
});
it.each(["", "not-a-url"])("rejects invalid --webhook %j before gateway RPC", async (value) => {
await expectCronEditRejection(["--webhook", value], "--webhook must be a valid http(s) URL");
});
it("documents the delivery clear flags alongside the sibling --clear-model", () => {
const editCommand = createCronProgram().commands.find((command) => command.name() === "edit");
const help = editCommand?.helpInformation() ?? "";
+10 -2
View File
@@ -7,6 +7,7 @@ import {
import type { Command } from "commander";
import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { danger } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
@@ -221,7 +222,14 @@ export function registerCronEditCommand(cron: Command) {
"--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery.",
);
}
const hasWebhookDelivery = typeof opts.webhook === "string";
const webhookUrl =
typeof opts.webhook === "string"
? (normalizeHttpWebhookUrl(opts.webhook) ?? undefined)
: undefined;
if (typeof opts.webhook === "string" && !webhookUrl) {
throw new Error("--webhook must be a valid http(s) URL");
}
const hasWebhookDelivery = Boolean(webhookUrl);
const deliveryModeFlagCount = [
Boolean(opts.announce),
typeof opts.deliver === "boolean",
@@ -413,7 +421,7 @@ export function registerCronEditCommand(cron: Command) {
Object.assign(
patch,
await resolveCronEditPayloadDeliveryPatch(opts, readExistingCronJob),
await resolveCronEditPayloadDeliveryPatch(opts, readExistingCronJob, webhookUrl),
);
const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string";
+13
View File
@@ -811,6 +811,19 @@ describe("gatherDaemonStatus", () => {
}
}, 1_000);
it.each(["bogus", "0", "-1", "1.5"])(
"rejects invalid status timeout %s before reading service state",
async (timeout) => {
await expect(gatherStatus({ rpc: { timeout } })).rejects.toThrow(
`Invalid --timeout. Use a positive millisecond value, e.g. --timeout 30000. Received: "${timeout}".`,
);
expect(serviceReadCommand).not.toHaveBeenCalled();
expect(serviceIsLoaded).not.toHaveBeenCalled();
expect(serviceReadRuntime).not.toHaveBeenCalled();
},
);
it("keeps gateway status read-only when service management is unsupported", async () => {
serviceReadCommand.mockResolvedValueOnce(null);
serviceIsLoaded.mockResolvedValueOnce(false);
+4 -2
View File
@@ -1,6 +1,5 @@
// Collects daemon status from service files, config snapshots, ports, probes, and plugin drift.
import fs from "node:fs/promises";
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import JSON5 from "json5";
@@ -66,6 +65,7 @@ import {
} from "../../plugins/plugin-version-drift.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { VERSION } from "../../version.js";
import { parseTimeoutMsWithFallback } from "../parse-timeout.js";
import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js";
import type { GatewayRpcOpts } from "./types.js";
@@ -597,7 +597,9 @@ export async function gatherDaemonStatus(
allowExecSecretRefs?: boolean;
} & FindExtraGatewayServicesOptions,
): Promise<DaemonStatus> {
const timeoutMs = parseStrictPositiveInteger(opts.rpc.timeout ?? undefined) ?? 10_000;
const timeoutMs = parseTimeoutMsWithFallback(opts.rpc.timeout, 10_000, {
invalidType: "error",
});
const service = resolveGatewayService();
const serviceState = await readGatewayServiceState(service, {
env: process.env,
+53 -4
View File
@@ -876,11 +876,16 @@ describe("exec approvals CLI", () => {
});
});
it("defaults allowlist add to wildcard agent", async () => {
it.each([
{ label: "by default", agentArgs: [] as string[], agentKey: "*" },
{ label: "for the explicit wildcard", agentArgs: ["--agent", "*"], agentKey: "*" },
{ label: "for a configured agent", agentArgs: ["--agent", "main"], agentKey: "main" },
])("adds an allowlist entry $label", async ({ agentArgs, agentKey }) => {
readBestEffortConfig.mockResolvedValue({ agents: { list: [{ id: "main" }] } });
const updateExecApprovals = vi.mocked(execApprovals.updateExecApprovals);
updateExecApprovals.mockClear();
await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname"]);
await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname", ...agentArgs]);
expect(callGatewayFromCli.mock.calls.some((call) => call[0] === "exec.approvals.set")).toBe(
false,
@@ -889,12 +894,56 @@ describe("exec approvals CLI", () => {
expect(updateExecApprovals).toHaveBeenCalledWith(
expect.objectContaining({ baseHash: "hash-local" }),
);
if (requireRecord(saved.agents, "saved agents")["*"] === undefined) {
throw new Error("Expected wildcard exec approval agent entry");
if (requireRecord(saved.agents, "saved agents")[agentKey] === undefined) {
throw new Error(`Expected ${agentKey} exec approval agent entry`);
}
expect(readBestEffortConfig).toHaveBeenCalledTimes(agentKey === "main" ? 1 : 0);
expect(loggedOutput()).toContain("Writing local approvals.");
});
it.each(["add", "remove"])(
"rejects an unknown agent before allowlist %s persistence",
async (operation) => {
readBestEffortConfig.mockResolvedValue({ agents: { list: [{ id: "main" }] } });
const updateExecApprovals = vi.mocked(execApprovals.updateExecApprovals);
updateExecApprovals.mockClear();
await expect(
runApprovalsCommand([
"approvals",
"allowlist",
operation,
"/usr/bin/uname",
"--agent",
"nope-agent",
]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors).toStrictEqual([
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
]);
expect(updateExecApprovals).not.toHaveBeenCalled();
expect(localSnapshot.file.agents).toEqual({});
expect(loggedOutput()).not.toContain("Writing local approvals.");
},
);
it.each(["add", "remove"])(
"rejects a blank agent before allowlist %s persistence",
async (operation) => {
const updateExecApprovals = vi.mocked(execApprovals.updateExecApprovals);
updateExecApprovals.mockClear();
await expect(
runApprovalsCommand(["approvals", "allowlist", operation, "/usr/bin/uname", "--agent", ""]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors).toStrictEqual(["--agent must not be blank"]);
expect(updateExecApprovals).not.toHaveBeenCalled();
expect(localSnapshot.file.agents).toEqual({});
},
);
it.each([
{
label: "an already-allowlisted add",
+11 -3
View File
@@ -22,6 +22,7 @@ import {
renderTerminalSafeTable,
} from "../../packages/terminal-core/src/table.js";
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import { readBestEffortConfig, type OpenClawConfig } from "../config/config.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
@@ -1011,8 +1012,7 @@ async function saveSnapshot(
}
function resolveAgentKey(value?: string | null): string {
const trimmed = normalizeOptionalString(value) ?? "";
return trimmed ? trimmed : "*";
return value == null ? "*" : requireTrimmedNonEmpty(value, "--agent must not be blank");
}
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
@@ -1048,6 +1048,15 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{
agent: ExecApprovalsAgent;
allowlistEntries: NonNullable<ExecApprovalsAgent["allowlist"]>;
}> {
const agentKey = resolveAgentKey(opts.agent);
if (agentKey !== "*") {
const source = !opts.gateway && !opts.node ? "local" : opts.gateway ? "gateway" : "node";
const { config } = await loadConfigForApprovalsTarget({ opts, source });
if (!config) {
exitWithError("Config unavailable; cannot validate --agent.");
}
resolveConfiguredAgentId(config, agentKey);
}
const { snapshot, nodeId, source, targetLabel, baseHash, kind } =
await loadWritableSnapshotTarget(opts);
if (kind === "native" || !isFileApprovalsSnapshot(snapshot)) {
@@ -1058,7 +1067,6 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{
const file = snapshot.file;
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
+1 -1
View File
@@ -41,7 +41,7 @@ export class ExpectedCliError extends Error {
}
}
function isGatewayCredentialsCliError(
export function isGatewayCredentialsCliError(
error: unknown,
): error is Error & { method: string; configPath: string } {
// Keep the root failure renderer lean; importing gateway/call would pull the
+22
View File
@@ -424,6 +424,28 @@ describe("hooks CLI metadata config keys", () => {
expect(explicitFleet).toEqual(initialConfig);
});
it("rejects a blank hook agent before resolving a workspace", async () => {
configureExplicitFleet();
await expect(
createHooksProgram().parseAsync(["hooks", "list", "--agent", "", "--json"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(capture.runtimeErrors.at(-1)).toContain("--agent must not be blank");
expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled();
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("rejects a blank parent hook agent before dispatching a subcommand", async () => {
await expect(
createHooksProgram().parseAsync(["hooks", "--agent", "", "list"], { from: "user" }),
).rejects.toThrow("--agent must not be blank");
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("keeps the explicit owner in the offline hooks fallback", async () => {
const explicitFleet = configureExplicitFleet();
+6
View File
@@ -78,6 +78,9 @@ type HooksReportTarget = {
function resolveHooksReportTarget(config: OpenClawConfig, rawAgentId?: string): HooksReportTarget {
const requested = rawAgentId?.trim();
if (rawAgentId !== undefined && !requested) {
throw new Error("--agent must not be blank");
}
const requestedAgentId = requested ? normalizeAgentId(requested) : undefined;
if (requestedAgentId) {
resolveConfiguredAgentId(config, requestedAgentId);
@@ -617,6 +620,9 @@ export function registerHooksCli(program: Command): void {
Boolean(opts?.json || hooks.opts<{ json?: boolean }>().json);
hooks.hook("preAction", (_thisCommand, actionCommand) => {
const parentAgent = hooks.opts<{ agent?: string }>().agent;
if (parentAgent !== undefined && !parentAgent.trim()) {
throw new Error("--agent must not be blank");
}
if (
parentAgent &&
actionCommand !== hooks &&
+1 -1
View File
@@ -321,7 +321,7 @@ describe("skills verify CLI", () => {
).rejects.toThrow("__exit__:1");
const message =
'Skill "nonexistent-skill-xyz" not found. Run `openclaw skills list` to see available skills.';
'Skill "nonexistent-skill-xyz" not found on ClawHub. Run `openclaw skills search nonexistent-skill-xyz` to find the right skill reference.';
if (human) {
expect(mocks.runtimeStdout).toStrictEqual([]);
expect(mocks.runtimeErrors).toStrictEqual([message]);
+9
View File
@@ -361,6 +361,15 @@ describe("agentCliCommand", () => {
expect(zeroTimeoutGatewayRequestMs).toBe(2_147_000_000);
});
it("rejects a blank agent before selecting a local or Gateway target", async () => {
await expect(agentCliCommand({ message: "hi", agent: "" }, runtime)).rejects.toThrow(
"--agent must not be blank",
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).not.toHaveBeenCalled();
});
it("clamps oversized gateway timeout seconds at the command boundary", async () => {
await withTempStore(async () => {
mockGatewaySuccessReply();
+3
View File
@@ -1235,6 +1235,9 @@ export async function agentCliCommand(
runtime: RuntimeEnv,
deps?: AgentCliDeps,
) {
if (opts.agent !== undefined && !opts.agent.trim()) {
throw new Error("--agent must not be blank");
}
protectJsonStdout(opts);
const messageOpts = await resolveAgentMessageOpts(opts);
// `/compact` cannot run as a plain CLI agent turn: the slash-command handler
+148
View File
@@ -0,0 +1,148 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestRuntime } from "./test-runtime-config-helpers.js";
const mocks = vi.hoisted(() => ({
createGitBackup: vi.fn(),
getRuntimeConfig: vi.fn(),
listRegisteredAgentDatabases: vi.fn(),
recordBackupRunOutcome: vi.fn(),
restoreGitBackupRef: vi.fn(),
verifyGitBackupRef: vi.fn(),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return { ...actual, getRuntimeConfig: mocks.getRuntimeConfig };
});
vi.mock("../snapshot/git-backup.js", () => ({
createGitBackup: mocks.createGitBackup,
initializeGitBackupRepository: vi.fn(),
readGitBackupLog: vi.fn(),
restoreGitBackupRef: mocks.restoreGitBackupRef,
verifyGitBackupRef: mocks.verifyGitBackupRef,
}));
vi.mock("../state/backup-run-records.js", () => ({
recordBackupRunOutcome: mocks.recordBackupRunOutcome,
}));
vi.mock("../state/openclaw-agent-db.js", () => ({
listOpenClawRegisteredAgentDatabases: mocks.listRegisteredAgentDatabases,
}));
import {
backupGitCreateCommand,
backupGitRestoreCommand,
backupGitVerifyCommand,
} from "./backup-git.js";
describe("Git backup command agent selection", () => {
beforeEach(() => {
mocks.createGitBackup.mockReset().mockResolvedValue({
commit: "backup-commit",
noChanges: false,
pushed: false,
repositoryPath: "/tmp/repository",
});
mocks.getRuntimeConfig.mockReset().mockReturnValue({
agents: { list: [{ id: "main" }, { id: "ops-team" }] },
});
mocks.listRegisteredAgentDatabases.mockReset().mockReturnValue([]);
mocks.recordBackupRunOutcome.mockReset();
mocks.restoreGitBackupRef.mockReset().mockResolvedValue({
commit: "backup-commit",
excludedTables: [],
targetPath: "/tmp/restored.sqlite",
});
mocks.verifyGitBackupRef.mockReset().mockResolvedValue({
commit: "backup-commit",
tables: [],
});
vi.spyOn(fs, "realpath").mockImplementation(async (value) => path.resolve(String(value)));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates a backup for a configured normalized agent", async () => {
await backupGitCreateCommand(createTestRuntime(), {
repository: "/tmp/repository",
agents: ["Ops Team"],
});
expect(mocks.createGitBackup).toHaveBeenCalledWith(
expect.objectContaining({
databases: [expect.objectContaining({ identity: { role: "agent", agentId: "ops-team" } })],
}),
);
});
it.each([
[
"unknown",
"nope-agent",
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
],
["empty", "", "--agent must not be blank"],
["whitespace-only", " ", "--agent must not be blank"],
])("rejects an %s Git create agent", async (_label, agent, message) => {
await expect(
backupGitCreateCommand(createTestRuntime(), {
repository: "/tmp/repository",
agents: [agent],
}),
).rejects.toThrow(message);
expect(mocks.createGitBackup).not.toHaveBeenCalled();
});
it.each([
{ label: "all", scope: { all: true } },
{ label: "global", scope: { global: true } },
])("keeps the $label Git create scope independent of configured agents", async ({ scope }) => {
await backupGitCreateCommand(createTestRuntime(), {
repository: "/tmp/repository",
...scope,
});
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
expect(mocks.createGitBackup).toHaveBeenCalledOnce();
});
it("keeps the --all plus explicit-scope conflict ahead of agent validation", async () => {
await expect(
backupGitCreateCommand(createTestRuntime(), {
repository: "/tmp/repository",
all: true,
agents: ["nope-agent"],
}),
).rejects.toThrow("Use --all by itself, or select --global and --agent scopes explicitly.");
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
expect(mocks.createGitBackup).not.toHaveBeenCalled();
});
it("keeps artifact verify and restore available for an unconfigured agent", async () => {
await backupGitVerifyCommand(createTestRuntime(), {
repository: "/tmp/repository",
agent: "retired-agent",
});
await backupGitRestoreCommand(createTestRuntime(), {
repository: "/tmp/repository",
agent: "retired-agent",
target: "/tmp/restored.sqlite",
});
expect(mocks.verifyGitBackupRef).toHaveBeenCalledWith(
expect.objectContaining({ identity: { role: "agent", agentId: "retired-agent" } }),
);
expect(mocks.restoreGitBackupRef).toHaveBeenCalledWith(
expect.objectContaining({ identity: { role: "agent", agentId: "retired-agent" } }),
);
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
});
});
+19 -2
View File
@@ -1,5 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -45,14 +47,29 @@ function resolveRequiredPath(value: string | undefined, label: string): string {
}
async function resolveCreateDatabases(runtime: RuntimeEnv, options: BackupGitCreateOptions) {
const agents = [...new Set((options.agents ?? []).map((agent) => normalizeAgentId(agent)))];
const explicit = options.global === true || agents.length > 0;
const normalizedAgents = [
...new Set(
(options.agents ?? []).map((agent) => {
const trimmed = agent.trim();
if (!trimmed) {
throw new Error("--agent must not be blank");
}
return normalizeAgentId(trimmed);
}),
),
];
const explicit = options.global === true || normalizedAgents.length > 0;
if (options.all && explicit) {
throw new Error("Use --all by itself, or select --global and --agent scopes explicitly.");
}
if (!options.all && !explicit) {
throw new Error("Choose at least one Git backup scope: --all, --global, or --agent <id>.");
}
let agents: string[] = [];
if (normalizedAgents.length > 0) {
const config = getRuntimeConfig({ skipPluginValidation: true });
agents = normalizedAgents.map((agent) => resolveConfiguredAgentId(config, agent));
}
const databases: Array<{
path: string;
identity: GitBackupIdentity;
+46
View File
@@ -9,6 +9,9 @@ const gatewayRpc = vi.hoisted(() => ({
call: vi.fn(),
isImplicitLocalTarget: vi.fn(async () => true),
}));
const configMocks = vi.hoisted(() => ({
getRuntimeConfig: vi.fn(),
}));
vi.mock("../cli/gateway-rpc.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../cli/gateway-rpc.js")>();
@@ -19,6 +22,11 @@ vi.mock("../cli/gateway-rpc.js", async (importOriginal) => {
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return { ...actual, getRuntimeConfig: configMocks.getRuntimeConfig };
});
import { GIT_BACKUP_PUSH_CREDENTIAL_WARNING } from "./backup-git.js";
import { backupDisableCommand, backupEnableCommand } from "./backup-schedule.js";
@@ -41,6 +49,9 @@ describe("scheduled backups", () => {
beforeEach(() => {
gatewayRpc.call.mockReset();
gatewayRpc.isImplicitLocalTarget.mockReset().mockResolvedValue(true);
configMocks.getRuntimeConfig.mockReset().mockReturnValue({
agents: { list: [{ id: "main" }, { id: "ops-team" }] },
});
});
afterEach(async () => {
@@ -94,6 +105,41 @@ describe("scheduled backups", () => {
expect(runtime.error).not.toHaveBeenCalled();
});
it("schedules a configured agent using its normalized id", async () => {
gatewayRpc.call.mockResolvedValue({ created: true, job: { id: "backup-job" } });
const runtime = createTestRuntime();
await backupEnableCommand(runtime, {
repository: "/tmp/openclaw-backups",
agent: "Ops Team",
});
const spec = gatewayRpc.call.mock.calls[0]?.[2] as { payload: { argv: string[] } };
expect(spec.payload.argv).toContain("ops-team");
expect(spec.payload.argv).not.toContain("--all");
});
it.each([
[
"unknown",
"nope-agent",
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
],
["empty", "", "--agent must not be blank"],
["whitespace-only", " ", "--agent must not be blank"],
])("rejects an %s scheduled backup agent", async (_label, agent, message) => {
const runtime = createTestRuntime();
await expect(
backupEnableCommand(runtime, {
repository: "/tmp/openclaw-backups",
agent,
}),
).rejects.toThrow(message);
expect(gatewayRpc.call).not.toHaveBeenCalled();
});
it("atomically converges an existing declaration and removes it idempotently", async () => {
gatewayRpc.call.mockResolvedValueOnce({
created: false,
+12 -5
View File
@@ -1,10 +1,12 @@
import path from "node:path";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import {
callGatewayFromCli,
isImplicitLocalGatewayTargetFromCli,
type GatewayRpcOpts,
} from "../cli/gateway-rpc.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { getRuntimeConfig } from "../config/config.js";
import type { CronJob } from "../cron/types.js";
import { executeGitCommand } from "../infra/git-exec.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -56,9 +58,18 @@ function buildScheduledArgv(
redactSecrets: boolean,
): string[] {
const agent = options.agent?.trim();
if (options.agent !== undefined && !agent) {
throw new Error("--agent must not be blank");
}
if (options.globalOnly && agent) {
throw new Error("Use either --global-only or --agent <id>, not both.");
}
const agentId = agent
? resolveConfiguredAgentId(
getRuntimeConfig({ skipPluginValidation: true }),
normalizeAgentId(agent),
)
: undefined;
return [
"openclaw",
"backup",
@@ -66,11 +77,7 @@ function buildScheduledArgv(
"create",
"--repository",
repositoryPath,
...(options.globalOnly
? ["--global"]
: agent
? ["--agent", normalizeAgentId(agent)]
: ["--all"]),
...(options.globalOnly ? ["--global"] : agentId ? ["--agent", agentId] : ["--all"]),
...(options.push ? ["--push"] : []),
...(redactSecrets ? ["--exclude-secrets"] : []),
];
+31 -1
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -18,11 +18,23 @@ import {
backupSqliteVerifyCommand,
} from "./backup-sqlite.js";
const configMocks = vi.hoisted(() => ({
getRuntimeConfig: vi.fn(),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return { ...actual, getRuntimeConfig: configMocks.getRuntimeConfig };
});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
let previousStateDir: string | undefined;
beforeEach(() => {
previousStateDir = process.env.OPENCLAW_STATE_DIR;
configMocks.getRuntimeConfig.mockReset().mockReturnValue({
agents: { list: [{ id: "main" }, { id: "ops-team" }] },
});
});
afterEach(() => {
@@ -257,6 +269,24 @@ describe("SQLite backup commands", () => {
).rejects.toThrow("Choose exactly one SQLite snapshot source");
});
it.each([
[
"unknown",
"nope-agent",
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
],
["empty", "", "--agent must not be blank"],
["whitespace-only", " ", "--agent must not be blank"],
])("rejects an %s SQLite snapshot agent", async (_label, agent, message) => {
process.env.OPENCLAW_STATE_DIR = tempDirs.make("openclaw-backup-sqlite-agent-rejection-");
await expect(
backupSqliteCreateCommand(createRuntimeCapture(), {
agent,
repository: "/tmp/snapshots",
}),
).rejects.toThrow(message);
});
it("does not claim completion when a corrupt database also rejects outcome recording", async () => {
const tempDir = tempDirs.make("openclaw-backup-sqlite-corrupt-");
const stateDir = path.join(tempDir, "state");
+9 -1
View File
@@ -1,5 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import { getRuntimeConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
@@ -168,6 +170,9 @@ async function resolveSnapshotDatabase(
options: BackupSqliteCreateOptions,
): Promise<ResolvedSnapshotDatabase> {
const rawAgentId = options.agent?.trim();
if (options.agent !== undefined && !rawAgentId) {
throw new Error("--agent must not be blank");
}
if (options.global === true && rawAgentId) {
throw new Error("Choose exactly one SQLite snapshot source: --global or --agent <id>.");
}
@@ -180,7 +185,10 @@ async function resolveSnapshotDatabase(
identity: { role: "global" },
};
}
const agentId = normalizeAgentId(rawAgentId);
const agentId = resolveConfiguredAgentId(
getRuntimeConfig({ skipPluginValidation: true }),
normalizeAgentId(rawAgentId),
);
return {
path: await fs.realpath(resolveOpenClawAgentSqlitePath({ agentId })),
identity: { role: "agent", agentId },
@@ -203,6 +203,16 @@ function requireReadOnlyPluginListCall(): unknown[] {
}
describe("config-only channels status output", () => {
it("guides operators when no channels are configured", async () => {
activeChannelPlugins.splice(0);
const output = await formatLocalStatusSummary({ channels: {} });
expect(output).toContain(
"- no configured chat channels (run `openclaw channels list --all` to see installable channels)",
);
});
it("sanitizes channel and account display names in terminal output", async () => {
const control = "\u001B]0;channels-status-injection\u0007";
registerSingleTestPlugin(
+28
View File
@@ -90,6 +90,10 @@ describe("channelsResolveCommand", () => {
});
it("uses installed channel plugins for explicit target resolution without installing", async () => {
mocks.loadConfig.mockReturnValue({
agents: { list: [{ id: "main" }, { id: "ops" }] },
channels: {},
});
const resolveTargets = vi.fn().mockResolvedValue([
{
input: "friends",
@@ -141,6 +145,30 @@ describe("channelsResolveCommand", () => {
expect(runtime.log).toHaveBeenCalledWith("friends -> 120363000000@g.us (Friends)");
});
it.each([
[
"unknown",
"nope-agent",
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
],
["empty", "", "--agent must not be blank"],
["whitespace-only", " ", "--agent must not be blank"],
])("rejects an %s explicit agent before channel resolution", async (_label, agent, message) => {
mocks.loadConfig.mockReturnValue({
agents: { list: [{ id: "main" }] },
channels: {},
});
await expect(
channelsResolveCommand({ agent, channel: "telegram", entries: ["friends"] }, runtime),
).rejects.toThrow(message);
expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled();
expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
expect(mocks.resolveInstallableChannelPlugin).not.toHaveBeenCalled();
expect(mocks.resolveMessageChannelSelection).not.toHaveBeenCalled();
});
it("tells users to add an explicit catalog channel before resolving", async () => {
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: { channels: {} },
@@ -1,6 +1,7 @@
// Channels status command-flow tests cover gateway calls, config fallback, and timeout validation.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { GatewayTransportError } from "../gateway/transport-error.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { channelsStatusCommand } from "./channels/status.js";
import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js";
@@ -214,6 +215,18 @@ function createTokenOnlyPlugin() {
};
}
function createGatewayTransportError(message = "Gateway not reachable (ECONNREFUSED).") {
return new GatewayTransportError({
kind: "closed",
message,
connectionDetails: {
url: "ws://127.0.0.1:18997",
urlSource: "local loopback",
message: "Gateway target: ws://127.0.0.1:18997",
},
});
}
describe("channelsStatusCommand SecretRef fallback flow", () => {
beforeEach(() => {
mocks.callGateway.mockReset();
@@ -270,7 +283,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
});
it("keeps read-only fallback output when SecretRefs are unresolved", async () => {
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
mocks.callGateway.mockRejectedValue(createGatewayTransportError());
mocks.requireValidConfig.mockResolvedValue({ secretResolved: false, channels: {} });
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: { secretResolved: false, channels: {} },
@@ -284,6 +297,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
await channelsStatusCommand({ probe: false }, runtime as never);
expect(errors.join("\n")).toContain("Gateway not reachable");
expect(errors.join("\n")).not.toContain("Gateway auth unavailable");
expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledOnce();
const configResolutionRequest = mocks.resolveCommandConfigWithSecrets.mock.calls[0]?.[0];
expect(configResolutionRequest?.commandName).toBe("channels status");
@@ -294,6 +308,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
),
).toBe(true);
const joined = logs.join("\n");
expect(joined).toContain("Gateway not reachable; showing config-only status.");
expect(joined).not.toContain("Gateway auth unavailable; showing config-only status.");
expect(joined).toContain("configured, secret unavailable in this command path");
expect(joined).toContain("token:config (unavailable)");
});
@@ -319,6 +335,10 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
expect(joined).toContain("Gateway auth unavailable; showing config-only status.");
expect(joined).not.toContain("Gateway not reachable; showing config-only status.");
expect(joined).toContain("configured, secret unavailable in this command path");
const { runtime: jsonRuntime, logs: jsonLogs } = createCapturingTestRuntime();
await channelsStatusCommand({ json: true, probe: false }, jsonRuntime as never);
expect(JSON.parse(jsonLogs.at(-1) ?? "{}").gatewayAuthUnavailable).toBe(true);
});
it("renders missing gateway credentials canonically before config-only status", async () => {
@@ -434,7 +454,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
it("keeps JSON fallback structured without rendering config-only text", async () => {
mocks.callGateway.mockRejectedValue(
new Error(
createGatewayTransportError(
[
"gateway timeout after 3000ms",
"Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible",
@@ -37,6 +37,14 @@ describe("channels command", () => {
setActivePluginRegistry(createTestRegistry([]));
});
it("guides operators when no channels are configured", () => {
const lines = formatGatewayChannelsStatusLines({ channelAccounts: {} });
expect(lines).toContain(
"- no configured chat channels (run `openclaw channels list --all` to see installable channels)",
);
});
it("surfaces Signal runtime errors in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelLabels: {
+6 -6
View File
@@ -19,7 +19,11 @@ import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-sna
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { listManifestInstalledChannelIds } from "../channel-setup/discovery.js";
import { listTrustedChannelPluginCatalogEntries } from "../channel-setup/trusted-catalog.js";
import { formatChannelAccountLabel, requireValidChannelConfig } from "./shared.js";
import {
formatChannelAccountLabel,
NO_CONFIGURED_CHAT_CHANNELS_LINE,
requireValidChannelConfig,
} from "./shared.js";
export type ChannelsListOptions = {
json?: boolean;
@@ -341,11 +345,7 @@ export async function channelsListCommand(
}
if (accountLines.length === 0 && catalogOnlyLines.length === 0) {
lines.push(
theme.muted(
showAll
? "- no chat channels found"
: "- no configured chat channels (run `openclaw channels list --all` to see installable channels)",
),
theme.muted(showAll ? "- no chat channels found" : NO_CONFIGURED_CHAT_CHANNELS_LINE),
);
} else {
for (const line of accountLines) {
+20 -13
View File
@@ -4,6 +4,7 @@ import {
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { resolveConfiguredAgentId } from "../../agents/agent-scope-config.js";
import type {
ChannelResolveKind,
ChannelResolveResult,
@@ -118,17 +119,6 @@ function formatResolveResult(result: ResolveResult): string {
/** Resolve user/group/channel labels into plugin-specific stable target ids. */
export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) {
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
const loadedRaw = getRuntimeConfig();
let { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({
config: loadedRaw,
commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(),
agentId: opts.agent,
mode: "read_only_operational",
runtime,
autoEnable: true,
});
const entries = normalizeStringEntries(opts.entries);
if (entries.length === 0) {
throw new Error(
@@ -136,12 +126,29 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
);
}
const loadedRaw = getRuntimeConfig();
const requestedAgent = opts.agent?.trim();
if (opts.agent !== undefined && !requestedAgent) {
throw new Error("--agent must not be blank");
}
const agentId = requestedAgent ? resolveConfiguredAgentId(loadedRaw, requestedAgent) : undefined;
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
let { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({
config: loadedRaw,
commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(),
agentId,
mode: "read_only_operational",
runtime,
autoEnable: true,
});
const explicitChannel = opts.channel?.trim();
const resolvedExplicit = explicitChannel
? await resolveInstallableChannelPlugin({
cfg,
runtime,
agentId: opts.agent,
agentId,
rawChannel: explicitChannel,
allowInstall: false,
supports: (plugin) => Boolean(plugin.resolver?.resolveTargets),
@@ -168,7 +175,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
: await resolveMessageChannelSelection({
cfg,
channel: opts.channel ?? null,
agentId: opts.agent,
agentId,
});
const plugin = selection.plugin;
if (!plugin?.resolver?.resolveTargets) {
+3
View File
@@ -12,6 +12,9 @@ import { requireValidConfig, requireValidConfigFileSnapshot } from "../config-va
export type ChatChannel = ChannelId;
export const NO_CONFIGURED_CHAT_CHANNELS_LINE =
"- no configured chat channels (run `openclaw channels list --all` to see installable channels)";
export { requireValidConfigFileSnapshot };
/** Load valid channel command config with read-only secret resolution applied. */
@@ -23,6 +23,7 @@ import {
appendTokenSourceBits,
buildChannelAccountLine,
type ChatChannel,
NO_CONFIGURED_CHAT_CHANNELS_LINE,
} from "./shared.js";
type ChannelStatusPluginLabel = {
@@ -74,6 +75,7 @@ export async function formatConfigChannelsStatusLines(
includeSetupFallbackPlugins: true,
}).filter((plugin) => !requestedChannel || plugin.id === requestedChannel);
const visibleChannelIds = new Set<string>();
const statusLinesStart = lines.length;
for (const plugin of plugins) {
visibleChannelIds.add(plugin.id);
const accountIds = plugin.config.listAccountIds(cfg);
@@ -127,6 +129,9 @@ export async function formatConfigChannelsStatusLines(
lines.push(`- ${hint.label}: ${hint.repairHint}`);
}
}
if (lines.length === statusLinesStart) {
lines.push(theme.muted(NO_CONFIGURED_CHAT_CHANNELS_LINE));
}
lines.push("");
lines.push(
+5
View File
@@ -21,6 +21,7 @@ import {
appendTokenSourceBits,
buildChannelAccountLine,
type ChatChannel,
NO_CONFIGURED_CHAT_CHANNELS_LINE,
} from "./shared.js";
import { formatConfigChannelsStatusLines } from "./status-config-format.js";
import type { ChannelsStatusOptions } from "./status.js";
@@ -193,12 +194,16 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
accountPayloads[channelId] = raw as Array<Record<string, unknown>>;
}
}
const accountLinesStart = lines.length;
for (const channelId of Object.keys(accountPayloads).toSorted()) {
const accounts = accountPayloads[channelId];
if (accounts && accounts.length > 0) {
lines.push(...accountLines(channelId, accounts));
}
}
if (lines.length === accountLinesStart) {
lines.push(theme.muted(NO_CONFIGURED_CHAT_CHANNELS_LINE));
}
lines.push("");
const issues = collectChannelStatusIssues(payload);
+7 -2
View File
@@ -1,7 +1,11 @@
// Implements `openclaw channels status` with gateway status and config-only fallback.
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { formatCliFailureLines, isExpectedCliError } from "../../cli/failure-output.js";
import {
formatCliFailureLines,
isExpectedCliError,
isGatewayCredentialsCliError,
} from "../../cli/failure-output.js";
import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js";
import { withProgress } from "../../cli/progress.js";
import { callGateway } from "../../gateway/call.js";
@@ -77,7 +81,8 @@ export async function channelsStatusCommand(
} catch (err) {
const safeError = formatChannelsStatusError(err);
const expectedError = isExpectedCliError(err);
const gatewayAuthUnavailable = expectedError || isGatewaySecretRefUnavailableError(err);
const gatewayAuthUnavailable =
isGatewayCredentialsCliError(err) || isGatewaySecretRefUnavailableError(err);
const expectedErrorOutput = expectedError
? formatCliFailureLines({ title: "", error: err }).join("\n")
: undefined;
+27
View File
@@ -1858,6 +1858,33 @@ describe("doctor config flow", () => {
expect(result.cfg.agents).not.toHaveProperty("list");
});
it("stamps explicit ownership when Doctor migrates a markerless multi-agent list", async () => {
const rawConfig = {
agents: {
list: [{ id: "ops" }, { id: "research", model: "openai/research" }],
},
};
const result = await runDoctorConfigWithInput({
config: migratePersistedImplicitMainRoster(rawConfig).config as OpenClawConfig,
parsedConfig: rawConfig,
repair: true,
run: loadAndMaybeMigrateDoctorConfig,
});
expect(result.shouldWriteConfig).toBe(true);
expect(result.explicitSetPaths).toEqual([
["agents", "entries"],
["agents", "ownership"],
]);
expect(result.cfg.agents).toEqual({
ownership: "explicit",
entries: {
ops: {},
research: { model: "openai/research" },
},
});
});
it("materializes ambient roles for a multi-agent configured default", async () => {
const rawConfig = {
agents: {
+1 -2
View File
@@ -266,8 +266,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const migratedRoster = readAgentRosterProperty(migrated);
const migratedEntries = migratedRoster?.kind === "entries" ? migratedRoster.value : undefined;
const { list: _legacyList, ...candidateAgents } = migrated.agents ?? {};
const stampsExplicitOwnership =
legacyDefaultAgentId !== undefined && Object.keys(migratedEntries ?? {}).length > 1;
const stampsExplicitOwnership = Object.keys(migratedEntries ?? {}).length > 1;
const rosterRepair = {
config: {
...migrated,
+5 -8
View File
@@ -18,9 +18,9 @@ import type {
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { redactSecretDegradationReason } from "../secrets/runtime-degraded-state.js";
import type { StatusSummary } from "../status/types.js";
import { VERSION } from "../version.js";
import { projectDoctorSecretRuntimeDegradations } from "./doctor-secret-runtime-degradation.js";
import {
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
@@ -94,14 +94,11 @@ export async function checkGatewayHealth(params: {
});
healthOk = true;
noteCliGatewayVersionSkew(status);
if (status.degradedSecretOwners && status.degradedSecretOwners.length > 0) {
const secretDegradations = projectDoctorSecretRuntimeDegradations(status);
if (secretDegradations.length > 0) {
note(
status.degradedSecretOwners
.map(
(owner) =>
`- ${owner.degradationState ?? "cold"} ${owner.ownerKind}:${owner.ownerId} (${owner.paths.join(", ")}): ${redactSecretDegradationReason(owner.reason)}` +
"\n Retry: openclaw secrets reload",
)
secretDegradations
.map((owner) => `- ${owner.message}\n Retry: ${owner.retryHint}`)
.join("\n"),
"Secret runtime degradation",
);
+111 -10
View File
@@ -6,6 +6,8 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import * as bundledHealthChecks from "../flows/bundled-health-checks.js";
import { CORE_HEALTH_CHECKS } from "../flows/doctor-core-checks.js";
import { clearHealthChecksForTest, registerHealthCheck } from "../flows/health-check-registry.js";
import { clearLoadInstalledPluginIndexInstallRecordsCache } from "../plugins/installed-plugin-index-record-cache.js";
import { writePersistedInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
@@ -17,6 +19,8 @@ const mocks = vi.hoisted(() => ({
actualOpenNodeSqliteDatabase: vi.fn(),
actualPrepareSqliteReadOnlyLocationSync: vi.fn(),
actualReadConfigFileSnapshot: vi.fn(),
buildGatewayProbeConnectionDetails: vi.fn(),
callGateway: vi.fn(),
openNodeSqliteDatabase: vi.fn(),
prepareSqliteReadOnlyLocationSync: vi.fn(),
readConfigFileSnapshot: vi.fn(),
@@ -66,7 +70,14 @@ vi.mock("../flows/doctor-health-contributions.js", async (importOriginal) => {
mocks.resolveDoctorContributionHealthChecks(...args),
};
});
vi.mock("../gateway/call.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/call.js")>();
return {
...actual,
buildGatewayProbeConnectionDetails: mocks.buildGatewayProbeConnectionDetails,
callGateway: mocks.callGateway,
};
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
@@ -79,6 +90,10 @@ describe("runDoctorLintCli", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readConfigFileSnapshot.mockReset();
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
url: "ws://127.0.0.1:18789",
});
mocks.callGateway.mockReset().mockResolvedValue({ degradedSecretOwners: [] });
mocks.openNodeSqliteDatabase.mockImplementation((...args: unknown[]) =>
mocks.actualOpenNodeSqliteDatabase(...args),
);
@@ -113,29 +128,115 @@ describe("runDoctorLintCli", () => {
}
});
it("reports the visible finding count in human output", async () => {
it.each([
{ label: "--only JSON", selection: "only", json: true },
{ label: "--only human text", selection: "only", json: false },
{ label: "--all JSON", selection: "all", json: true },
{ label: "--all human text", selection: "all", json: false },
{ label: "default JSON", selection: "default", json: true },
{ label: "default human text", selection: "default", json: false },
] as const)("keeps Gateway-owned secret degradation observable through $label", async (entry) => {
const gatewayCheck = CORE_HEALTH_CHECKS.find(
(check) => check.id === "core/doctor/gateway-health",
);
expect(gatewayCheck).toBeDefined();
const previousResolveChecks =
mocks.resolveDoctorContributionHealthChecks.getMockImplementation();
mocks.resolveDoctorContributionHealthChecks.mockResolvedValue([gatewayCheck]);
const registerChecks = vi
.spyOn(bundledHealthChecks, "registerBundledHealthChecks")
.mockImplementation(() => {});
const resolveStateMode = vi
.spyOn(bundledHealthChecks, "resolveBundledHealthCheckPluginStateMode")
.mockReturnValue("direct");
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {},
config: {
gateway: {
mode: "local",
auth: { mode: "token", token: "SYNTHETIC_GATEWAY_SECRET" },
},
},
path: "/tmp/openclaw.json",
});
mocks.callGateway.mockResolvedValue({
degradedSecretOwners: [
{
ownerKind: "account",
ownerId: "discord:ops",
state: "unavailable",
paths: ["channels.discord.accounts.ops.token"],
reason:
"secret reference was not found (env:default:PRIVATE_REF_ID=SYNTHETIC_OWNER_SECRET)",
},
],
});
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const originalIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: !entry.json });
try {
const exitCode = await runDoctorLintCli(runtime, {
severityMin: "error",
onlyIds: ["core/doctor/final-config-validation"],
...(entry.json ? { json: true } : {}),
...(entry.selection === "only"
? { onlyIds: ["core/doctor/gateway-health"] }
: entry.selection === "all"
? { includeAllChecks: true }
: {}),
});
const output = stdout.mock.calls.map(([line]) => String(line)).join("");
expect(exitCode).toBe(0);
expect(String(stdout.mock.calls[0]?.[0])).toContain("0 finding(s)");
expect(String(stdout.mock.calls[1]?.[0])).toBe(" no findings\n");
if (entry.selection === "default") {
expect(exitCode).toBe(0);
if (entry.json) {
expect(JSON.parse(output)).toMatchObject({
checksRun: 0,
checksSkipped: 1,
findings: [],
});
} else {
expect(output).toContain("0 finding(s)");
expect(output).toContain(" no findings\n");
}
expect(mocks.buildGatewayProbeConnectionDetails).not.toHaveBeenCalled();
expect(mocks.callGateway).not.toHaveBeenCalled();
return;
}
expect(exitCode).toBe(1);
expect(output).toContain("core/doctor/gateway-health");
expect(output).toContain("cold account:discord:ops");
expect(output).toContain("channels.discord.accounts.ops.token");
expect(output).toContain("openclaw secrets reload");
expect(output).not.toContain("SYNTHETIC_GATEWAY_SECRET");
expect(output).not.toContain("SYNTHETIC_OWNER_SECRET");
expect(output).not.toContain("PRIVATE_REF_ID");
expect(mocks.callGateway).toHaveBeenCalledOnce();
if (entry.json) {
expect(JSON.parse(output)).toMatchObject({
ok: false,
checksRun: 1,
findings: [
{
checkId: "core/doctor/gateway-health",
severity: "warning",
path: "channels.discord.accounts.ops.token",
target: "account:discord:ops",
},
],
});
} else {
expect(output).toContain("[warning] core/doctor/gateway-health");
}
} finally {
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsTTY });
stdout.mockRestore();
registerChecks.mockRestore();
resolveStateMode.mockRestore();
if (previousResolveChecks) {
mocks.resolveDoctorContributionHealthChecks.mockImplementation(previousResolveChecks);
}
}
});
@@ -0,0 +1,41 @@
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import {
redactSecretDegradationReason,
SECRET_DEGRADATION_RETRY_HINT,
} from "../secrets/runtime-degraded-state.js";
import type { StatusSummary } from "../status/types.js";
const DOCTOR_SECRET_OWNER_ID_MAX_CHARS = 96;
const DOCTOR_SECRET_OWNER_PATH_MAX_CHARS = 120;
const DOCTOR_SECRET_OWNER_VISIBLE_PATHS = 3;
function safeDoctorSecretOwnerText(value: string, maxChars: number): string {
const safe = sanitizeTerminalText(redactSensitiveUrlLikeString(value));
return safe.length <= maxChars ? safe : `${truncateUtf16Safe(safe, maxChars - 1)}`;
}
/** Projects Gateway-owned secret degradation into the shared bounded Doctor display shape. */
export function projectDoctorSecretRuntimeDegradations(
status: Pick<StatusSummary, "degradedSecretOwners">,
) {
return (status.degradedSecretOwners ?? []).map((owner) => {
const ownerId = safeDoctorSecretOwnerText(owner.ownerId, DOCTOR_SECRET_OWNER_ID_MAX_CHARS);
const target = `${owner.ownerKind}:${ownerId}`;
const visiblePaths = owner.paths
.slice(0, DOCTOR_SECRET_OWNER_VISIBLE_PATHS)
.map((configPath) =>
safeDoctorSecretOwnerText(configPath, DOCTOR_SECRET_OWNER_PATH_MAX_CHARS),
);
const omittedPaths = owner.paths.length - visiblePaths.length;
const paths =
visiblePaths.join(", ") + (omittedPaths > 0 ? ` (+${omittedPaths} paths omitted)` : "");
return {
message: `${owner.degradationState ?? "cold"} ${target} (${paths || "no affected paths reported"}): ${redactSecretDegradationReason(owner.reason)}`,
path: visiblePaths[0] ?? "gateway",
target,
retryHint: SECRET_DEGRADATION_RETRY_HINT,
};
});
}
@@ -61,6 +61,34 @@ describe("doctor database schema preflight", () => {
expect(autoMigrateLegacyStateDir).not.toHaveBeenCalled();
expect(readConfigFileSnapshot).not.toHaveBeenCalled();
});
it.each([
["plain doctor", { nonInteractive: true }],
["doctor --fix", { nonInteractive: true, repair: true }],
])("diagnoses an unreadable shared state database for %s", async (_label, options) => {
const statePath = resolveOpenClawStateSqlitePath(process.env);
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, "not a sqlite database");
mockDoctorConfigSnapshot();
const failure = await doctorCommand(createDoctorRuntime(), options).then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(Error);
expect((failure as Error).message).toContain(statePath);
expect((failure as Error).message).toMatch(/file is not a database/iu);
expect((failure as Error).message).toContain("left unchanged");
expect((failure as Error).message).toContain("restore this file from a verified backup");
expect((failure as Error).message).toContain("openclaw doctor --fix");
expect((failure as Error).message).toContain(
"https://docs.openclaw.ai/reference/database-schemas",
);
expect(fs.readFileSync(statePath, "utf8")).toBe("not a sqlite database");
expect(autoMigrateLegacyStateDir).not.toHaveBeenCalled();
expect(readConfigFileSnapshot).not.toHaveBeenCalled();
});
});
function mockInteractiveGitUpdate(status: "ok" | "skipped"): void {
+5
View File
@@ -29,6 +29,7 @@ import {
formatUnresolvedCommandPromptAdvisory,
formatUnresolvedShellPromptAdvisory,
} from "./repair-plan.js";
import { rethrowSqliteSchemaVersionError } from "./schema-safety.js";
import { normalizeStoredCronJobs } from "./store-migration.js";
import { noteCronDeliveryTargetAdvisory, noteCronModelOverrides } from "./warnings.js";
@@ -158,6 +159,7 @@ export async function collectLegacyCronStoreHealthFindings(params: {
try {
state = await loadLegacyCronRepairState({ cfg: params.cfg, readOnly: true });
} catch (err) {
rethrowSqliteSchemaVersionError(err);
const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg));
return [
legacyCronStoreFinding({
@@ -202,6 +204,7 @@ export async function collectLegacyCronStoreHealthFindings(params: {
);
}
} catch (err) {
rethrowSqliteSchemaVersionError(err);
findings.push(
legacyCronStoreFinding({
message: `Unable to read quarantined cron rows in SQLite at ${shortenHomePath(sqliteStorePath)}.`,
@@ -356,6 +359,7 @@ export async function maybeRepairLegacyCronStore(params: {
try {
state = await loadLegacyCronRepairState({ cfg: params.cfg });
} catch (err) {
rethrowSqliteSchemaVersionError(err);
const reason = err instanceof Error ? err.message : String(err);
const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg));
note(
@@ -395,6 +399,7 @@ export async function maybeRepairLegacyCronStore(params: {
);
}
} catch (err) {
rethrowSqliteSchemaVersionError(err);
const reason = err instanceof Error ? err.message : String(err);
note(
[
+12
View File
@@ -49,6 +49,10 @@ import {
needsSqliteProjectionBackfill,
} from "./repair-plan.js";
import { planCronCodexRefRewriteAgainstPersistedConfig } from "./runtime-policy-migration.js";
import {
assertCronStateSchemaSupported,
rethrowSqliteSchemaVersionError,
} from "./schema-safety.js";
import {
collectStoredCronCodexRuntimePolicyTargets,
cronCodexRuntimePolicyTargetKey,
@@ -123,6 +127,7 @@ export async function loadLegacyCronRepairState(params: {
const legacyStoreDetected = await legacyCronStoreFilesExist(storePath);
const legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath);
const legacyQuarantine = await loadLegacyCronQuarantineForMigration(storePath);
assertCronStateSchemaSupported(params.env);
if (
params.onlyIfLegacyDetected &&
!legacyStoreDetected &&
@@ -220,6 +225,7 @@ export async function applyLegacyCronStoreRepair(params: {
migrateCodexModelRefs?: boolean;
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
}): Promise<LegacyCronRepairResult> {
assertCronStateSchemaSupported();
const { state } = params;
const changes: string[] = [];
const warnings: string[] = [];
@@ -309,6 +315,7 @@ export async function applyLegacyCronStoreRepair(params: {
saveCronQuarantinedJobs({ storePath: state.storePath, ...quarantine });
}
} catch (err) {
rethrowSqliteSchemaVersionError(err);
return {
changes,
warnings: [
@@ -337,6 +344,7 @@ export async function applyLegacyCronStoreRepair(params: {
try {
importedRunLogs = (await migrateLegacyCronRunLogsToSqlite(state.storePath)).importedFiles;
} catch (err) {
rethrowSqliteSchemaVersionError(err);
warnings.push(
`Failed importing legacy cron run logs at ${shortenHomePath(state.storePath)}: ${errorMessage(err)}`,
);
@@ -353,6 +361,7 @@ export async function applyLegacyCronStoreRepair(params: {
try {
markLegacyCronMigrationSourceRemoved(state.legacyMigrationSource);
} catch (err) {
rethrowSqliteSchemaVersionError(err);
warnings.push(
`Cron store was archived, but its migration receipt could not be finalized: ${errorMessage(err)}`,
);
@@ -411,6 +420,7 @@ export async function repairLegacyCronStoreWithoutPrompt(params: {
onlyIfLegacyDetected: true,
});
} catch (err) {
rethrowSqliteSchemaVersionError(err);
return {
changes: [],
warnings: [
@@ -438,6 +448,7 @@ export async function collectCronCodexRuntimePolicyTargetsReadOnly(params: {
warnings: [],
};
} catch (err) {
rethrowSqliteSchemaVersionError(err);
return {
targets: [],
warnings: [
@@ -466,6 +477,7 @@ export async function repairCronCodexModelRefsAfterConfigWrite(params: {
})
: { changes: [], warnings: [] };
} catch (err) {
rethrowSqliteSchemaVersionError(err);
return {
changes: [],
warnings: [
@@ -0,0 +1,197 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { isSqliteSchemaVersionError } from "../../../infra/sqlite-user-version.js";
import {
closeOpenClawStateDatabaseForTest,
OPENCLAW_STATE_SCHEMA_VERSION,
} from "../../../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../../../state/openclaw-state-db.paths.js";
import { collectLegacyCronStoreHealthFindings, maybeRepairLegacyCronStore } from "./index.js";
import {
applyLegacyCronStoreRepair,
collectCronCodexRuntimePolicyTargetsReadOnly,
loadLegacyCronRepairState,
repairCronCodexModelRefsAfterConfigWrite,
repairLegacyCronStoreWithoutPrompt,
} from "./legacy-repair.js";
type FutureSchemaFixture = {
cfg: OpenClawConfig;
databasePath: string;
storePath: string;
};
let tempRoot: string | undefined;
let fixtureDatabase: DatabaseSync | undefined;
afterEach(async () => {
fixtureDatabase?.close();
fixtureDatabase = undefined;
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
tempRoot = undefined;
}
});
async function writeFutureSchema(databasePath: string): Promise<void> {
await fs.mkdir(path.dirname(databasePath), { recursive: true });
const database = new DatabaseSync(databasePath);
try {
database.exec(`
CREATE TABLE preserved_sentinel (value TEXT NOT NULL) STRICT;
INSERT INTO preserved_sentinel (value) VALUES ('keep-me');
PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};
`);
} finally {
database.close();
}
}
async function createFixture(options: { futureSchema: boolean }): Promise<FutureSchemaFixture> {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-cron-schema-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempRoot);
const storePath = path.join(tempRoot, "cron", "jobs.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs: [] }), "utf8");
const databasePath = resolveOpenClawStateSqlitePath();
if (options.futureSchema) {
await writeFutureSchema(databasePath);
}
return {
cfg: { cron: { store: storePath } } as OpenClawConfig,
databasePath,
storePath,
};
}
async function snapshotFixture(databasePath: string) {
const database = new DatabaseSync(databasePath, { readOnly: true });
try {
const versionRow = database.prepare("PRAGMA user_version").get() as {
user_version: number;
};
const sentinelRow = database.prepare("SELECT value FROM preserved_sentinel").get() as {
value: string;
};
const bytes = await fs.readFile(databasePath);
const artifactNames = (await fs.readdir(path.dirname(databasePath))).toSorted();
return {
sha256: createHash("sha256").update(bytes).digest("hex"),
schemaVersion: versionRow.user_version,
sentinel: sentinelRow.value,
artifacts: Object.fromEntries(
await Promise.all(
artifactNames.map(async (name) => [
name,
createHash("sha256")
.update(await fs.readFile(path.join(path.dirname(databasePath), name)))
.digest("hex"),
]),
),
),
};
} finally {
database.close();
}
}
async function expectSchemaRefusalWithoutMutation(
fixture: FutureSchemaFixture,
operation: () => Promise<unknown>,
): Promise<void> {
const before = await snapshotFixture(fixture.databasePath);
let error: unknown;
try {
await operation();
} catch (caught) {
error = caught;
}
expect(isSqliteSchemaVersionError(error)).toBe(true);
await expect(snapshotFixture(fixture.databasePath)).resolves.toEqual(before);
}
describe("future shared-state schema safety", () => {
const entrypoints: Array<[string, (fixture: FutureSchemaFixture) => Promise<unknown>]> = [
[
"legacy cron health collection",
async ({ cfg }) => await collectLegacyCronStoreHealthFindings({ cfg }),
],
[
"interactive legacy cron repair",
async ({ cfg }) =>
await maybeRepairLegacyCronStore({
cfg,
options: {},
prompter: { confirm: vi.fn() },
}),
],
[
"non-interactive legacy cron repair",
async ({ cfg }) => await repairLegacyCronStoreWithoutPrompt({ cfg }),
],
[
"Codex cron migration planning",
async ({ cfg }) => await collectCronCodexRuntimePolicyTargetsReadOnly({ cfg }),
],
[
"Codex cron migration commit",
async ({ cfg }) => await repairCronCodexModelRefsAfterConfigWrite({ cfg }),
],
];
it.each(entrypoints)("fails closed without mutation during %s", async (_name, operation) => {
const fixture = await createFixture({ futureSchema: true });
await expectSchemaRefusalWithoutMutation(fixture, () => operation(fixture));
});
it("fails closed in non-interactive repair when no legacy files remain", async () => {
const fixture = await createFixture({ futureSchema: true });
await fs.rm(fixture.storePath);
await expectSchemaRefusalWithoutMutation(fixture, async () => {
await repairLegacyCronStoreWithoutPrompt({ cfg: fixture.cfg });
});
});
it("preserves active WAL artifacts while rejecting the future schema", async () => {
const fixture = await createFixture({ futureSchema: false });
await fs.mkdir(path.dirname(fixture.databasePath), { recursive: true });
fixtureDatabase = new DatabaseSync(fixture.databasePath);
fixtureDatabase.exec(`
PRAGMA journal_mode = WAL;
PRAGMA wal_autocheckpoint = 0;
CREATE TABLE preserved_sentinel (value TEXT NOT NULL) STRICT;
INSERT INTO preserved_sentinel (value) VALUES ('keep-me');
PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};
`);
await expectSchemaRefusalWithoutMutation(fixture, async () => {
await maybeRepairLegacyCronStore({
cfg: fixture.cfg,
options: {},
prompter: { confirm: vi.fn() },
});
});
});
it("fails closed if the schema advances between inspection and repair", async () => {
const fixture = await createFixture({ futureSchema: false });
const state = await loadLegacyCronRepairState({ cfg: fixture.cfg });
expect(state).not.toBeNull();
closeOpenClawStateDatabaseForTest();
await writeFutureSchema(fixture.databasePath);
await expectSchemaRefusalWithoutMutation(fixture, async () => {
await applyLegacyCronStoreRepair({ cfg: fixture.cfg, state: state! });
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { isSqliteSchemaVersionError } from "../../../infra/sqlite-user-version.js";
import { withExistingOpenClawStateDatabaseArtifactPreservingReadOnly } from "../../../state/openclaw-state-db-readonly.js";
export function assertCronStateSchemaSupported(env?: NodeJS.ProcessEnv): void {
withExistingOpenClawStateDatabaseArtifactPreservingReadOnly(() => undefined, { env });
}
export function rethrowSqliteSchemaVersionError(error: unknown): void {
if (isSqliteSchemaVersionError(error)) {
throw error;
}
}
+44
View File
@@ -146,6 +146,50 @@ describe("exportTrajectoryCommand", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it.each([
[
"unknown",
"nope-agent",
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
],
["empty", "", "--agent must not be blank"],
["whitespace-only", " ", "--agent must not be blank"],
])("rejects an %s explicit agent before reading a session", async (_label, agent, message) => {
const runtime = createRuntime();
mocks.getRuntimeConfig.mockReturnValue({ agents: { list: [{ id: "main" }] } });
await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123", agent }, runtime);
expect(runtime.error).toHaveBeenCalledWith(message);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
});
it("keeps a configured explicit agent as the session store owner", async () => {
const runtime = createRuntime();
mocks.getRuntimeConfig.mockReturnValue({
agents: { list: [{ id: "main" }, { id: "work" }] },
session: { store: "/tmp/openclaw/agents/{agentId}/sessions/sessions.json" },
});
mocks.resolveStorePath.mockReturnValue("/tmp/openclaw/agents/work/sessions/sessions.json");
await exportTrajectoryCommand(
{ sessionKey: "agent:main:telegram:direct:123", agent: "work" },
runtime,
);
expect(mocks.resolveStorePath).toHaveBeenCalledWith(
"/tmp/openclaw/agents/{agentId}/sessions/sessions.json",
{ agentId: "work" },
);
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "work",
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/openclaw/agents/work/sessions/sessions.json",
});
});
it.each([
["home-prefixed", "~/x/sessions.json", "/home/demo/x/sessions.json"],
[
+17 -1
View File
@@ -1,6 +1,7 @@
/** CLI command for exporting a session transcript as a trajectory artifact. */
import path from "node:path";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import { formatCliCommand } from "../cli/command-format.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
@@ -114,7 +115,22 @@ export async function exportTrajectoryCommand(
runtime.exit(1);
return;
}
const targetAgentId = resolvedOpts.agent ?? resolveAgentIdFromSessionKey(sessionKey);
const requestedAgent = resolvedOpts.agent?.trim();
if (resolvedOpts.agent !== undefined && !requestedAgent) {
runtime.error("--agent must not be blank");
runtime.exit(1);
return;
}
let targetAgentId = resolveAgentIdFromSessionKey(sessionKey);
if (requestedAgent) {
try {
targetAgentId = resolveConfiguredAgentId(getRuntimeConfig(), requestedAgent);
} catch (error) {
runtime.error(formatErrorMessage(error));
runtime.exit(1);
return;
}
}
let storePath = resolvedOpts.store
? resolveSessionStorePathCore(resolvedOpts.store, { agentId: targetAgentId })
: resolveSessionStorePathCore(getRuntimeConfig().session?.store, { agentId: targetAgentId });
+4
View File
@@ -36,4 +36,8 @@ describe("migration context helpers", () => {
it("keeps the configured default when no migration target is supplied", () => {
expect(resolveMigrationTargetAgentId({}, undefined)).toBeUndefined();
});
it("rejects an explicitly blank migration target", () => {
expect(() => resolveMigrationTargetAgentId({}, "")).toThrow("--agent must not be blank");
});
});
+3
View File
@@ -40,6 +40,9 @@ export function resolveMigrationTargetAgentId(
rawAgentId: string | undefined,
): string | undefined {
const raw = rawAgentId?.trim();
if (rawAgentId !== undefined && !raw) {
throw new Error("--agent must not be blank");
}
if (!raw) {
return undefined;
}
+25
View File
@@ -23,6 +23,30 @@ vi.mock("../config/config.js", async () => {
});
describe("sandbox explain command", () => {
it.each([
[
"unknown",
"nope-agent",
'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.',
],
["blank", "", "--agent must not be blank"],
])("rejects an explicit %s agent", async (_label, agent, message) => {
mockCfg = {
agents: {
defaults: { sandbox: { mode: "off" } },
list: [{ id: "main" }],
},
};
await expect(
sandboxExplainCommand({ json: true, agent }, {
log: () => {},
error: () => {},
exit: (_code: number) => {},
} as unknown as Parameters<typeof sandboxExplainCommand>[1]),
).rejects.toThrow(message);
});
it("honors an explicit agent in an ownerless multi-agent fleet", async () => {
mockCfg = {
agents: {
@@ -462,6 +486,7 @@ describe("sandbox explain command", () => {
defaults: {
sandbox: { mode: "non-main" },
},
list: [{ id: "main" }, { id: "builder" }],
},
};
+9 -1
View File
@@ -13,6 +13,7 @@ import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { colorize, isRich, theme } from "../../packages/terminal-core/src/theme.js";
import {
resolveAgentConfig,
resolveConfiguredAgentId,
resolveSessionAgentId,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
@@ -144,7 +145,11 @@ export async function sandboxExplainCommand(
const cfg = getRuntimeConfig();
const requestedSession = opts.session?.trim();
const requestedAgentId = opts.agent?.trim() ? normalizeAgentId(opts.agent) : undefined;
const requestedAgent = opts.agent?.trim();
if (opts.agent !== undefined && !requestedAgent) {
throw new Error("--agent must not be blank");
}
const requestedAgentId = requestedAgent ? normalizeAgentId(requestedAgent) : undefined;
const sessionAgentId =
requestedSession && requestedSession !== "global" && requestedSession.includes(":")
? normalizeAgentId(resolveAgentIdFromSessionKey(requestedSession))
@@ -154,6 +159,9 @@ export async function sandboxExplainCommand(
`Sandbox explain agent "${requestedAgentId}" does not match session agent "${sessionAgentId}".`,
);
}
if (requestedAgentId) {
resolveConfiguredAgentId(cfg, requestedAgentId);
}
const resolvedAgentId = resolveSessionAgentId({
sessionKey: requestedSession,
config: cfg,
+10
View File
@@ -27,6 +27,16 @@ beforeEach(() => {
});
describe("sessionsCompactCommand", () => {
it("rejects a blank agent before calling the Gateway", async () => {
const runtime = createRuntime();
await expect(
sessionsCompactCommand({ key: "agent:main:main", agent: "" }, runtime),
).rejects.toThrow("--agent must not be blank");
expect(callGatewayCli).not.toHaveBeenCalled();
});
it("prints the token delta and does not exit on a successful compaction", async () => {
callGatewayCli.mockResolvedValue({
ok: true,
+5 -1
View File
@@ -73,6 +73,10 @@ export async function sessionsCompactCommand(
opts: SessionsCompactCliOptions,
runtime: RuntimeEnv,
): Promise<void> {
const agent = opts.agent?.trim();
if (opts.agent !== undefined && !agent) {
throw new Error("--agent must not be blank");
}
const rpcOpts: SessionsCompactRpcOpts = {
url: opts.url,
token: opts.token,
@@ -84,7 +88,7 @@ export async function sessionsCompactCommand(
};
const params = {
key: opts.key,
...(opts.agent ? { agentId: opts.agent } : {}),
...(agent ? { agentId: agent } : {}),
...(opts.maxLines !== undefined ? { maxLines: opts.maxLines } : {}),
};
+57
View File
@@ -4,12 +4,17 @@ import { sessionsArchiveCommand, sessionsDeleteCommand } from "./sessions-lifecy
const mocks = vi.hoisted(() => ({
callGateway: vi.fn(),
confirm: vi.fn(),
getRuntimeConfig: vi.fn(),
}));
vi.mock("../cli/gateway-rpc.js", () => ({
callGatewayFromCliWithTransport: mocks.callGateway,
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: mocks.getRuntimeConfig,
}));
vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: () => ({ confirm: mocks.confirm }),
}));
@@ -35,6 +40,58 @@ describe("sessions lifecycle commands", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.confirm.mockResolvedValue(true);
mocks.getRuntimeConfig.mockReturnValue({
agents: { entries: { main: {}, work: {} } },
});
});
it.each([
["archive", sessionsArchiveCommand, {} as Record<string, unknown>],
["delete", sessionsDeleteCommand, { yes: true } as Record<string, unknown>],
])(
"%s rejects an unconfigured --agent before contacting the gateway",
async (_label, command, extra) => {
const runtime = createRuntime();
await command(
{ keys: ["agent:ghost:main"], agent: "ghost", json: true, ...extra } as never,
runtime as never,
);
expect(mocks.callGateway).not.toHaveBeenCalled();
expect(runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
results: [
expect.objectContaining({
error: expect.stringContaining('Unknown agent id "ghost"'),
}),
],
}),
2,
);
},
);
it.each([
["archive", sessionsArchiveCommand, {} as Record<string, unknown>],
["delete", sessionsDeleteCommand, { yes: true } as Record<string, unknown>],
])("%s rejects a blank --agent", async (_label, command, extra) => {
const runtime = createRuntime();
await command(
{ keys: ["agent:main:main"], agent: " ", json: true, ...extra } as never,
runtime as never,
);
expect(mocks.callGateway).not.toHaveBeenCalled();
expect(runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
results: [
expect.objectContaining({
error: expect.stringContaining("--agent must not be blank"),
}),
],
}),
2,
);
});
it("archives through sessions.patch and emits the stable JSON envelope", async () => {
+19 -5
View File
@@ -4,9 +4,11 @@ import type {
SessionsDeleteResult,
WorktreePreservationReason,
} from "../../packages/gateway-protocol/src/index.js";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import { formatCliCommand } from "../cli/command-format.js";
import { formatCliJsonFailure, rethrowExpectedCliError } from "../cli/failure-output.js";
import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js";
import { getRuntimeConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { SESSION_ARCHIVE_REQUEST_TIMEOUT_MS } from "../shared/session-archive-timeout.js";
@@ -68,6 +70,14 @@ type SessionsLifecycleRpcOptions = Parameters<typeof callGatewayFromCliWithTrans
// be distinguished from a session outside the first Gateway list page.
const SESSION_TARGET_PAGE_SIZE = 200;
function resolveLifecycleAgentId(rawAgent: string | undefined): string | undefined {
const requested = rawAgent?.trim();
if (rawAgent !== undefined && !requested) {
throw new Error("--agent must not be blank");
}
return requested ? resolveConfiguredAgentId(getRuntimeConfig(), requested) : undefined;
}
function listHint(agent?: string): string {
const agentFlag = agent ? ` --agent ${agent}` : "";
return formatCliCommand(`openclaw sessions list${agentFlag} --json`);
@@ -210,8 +220,12 @@ async function runSessionsLifecycleCommand(
json: opts.json,
};
let sessions: Map<string, SessionsListRow>;
let agent: string | undefined;
try {
sessions = await listRequestedSessions(keys.filter(Boolean), opts.agent, rpcOptions);
// The not-found hint points at `sessions list --agent <id>`, which rejects an unconfigured id
// locally. Validating here keeps that suggestion runnable instead of handing back a dead end.
agent = resolveLifecycleAgentId(opts.agent);
sessions = await listRequestedSessions(keys.filter(Boolean), agent, rpcOptions);
} catch (error) {
rethrowExpectedCliError(error);
const message = formatErrorMessage(error);
@@ -226,7 +240,7 @@ async function runSessionsLifecycleCommand(
}
const results = keys.map((key): SessionsLifecycleResult | undefined =>
key && sessions.has(key) ? undefined : notFoundResult(key, opts.agent),
key && sessions.has(key) ? undefined : notFoundResult(key, agent),
);
const listedTargets = keys.flatMap((key, index) => {
const session = sessions.get(key);
@@ -296,7 +310,7 @@ async function runSessionsLifecycleCommand(
rpcOptions,
{
key: session.key,
...(opts.agent ? { agentId: opts.agent } : {}),
...(agent ? { agentId: agent } : {}),
...(session.sessionId ? { expectedSessionId: session.sessionId } : {}),
archived: true,
},
@@ -312,7 +326,7 @@ async function runSessionsLifecycleCommand(
rpcOptions,
{
key: session.key,
...(opts.agent ? { agentId: opts.agent } : {}),
...(agent ? { agentId: agent } : {}),
...(session.sessionId ? { expectedSessionId: session.sessionId } : {}),
deleteTranscript: true,
...(session.archived === true ? { archivedOnly: true } : {}),
@@ -320,7 +334,7 @@ async function runSessionsLifecycleCommand(
{ defaultTimeoutMs: 30_000 },
)) as SessionsDeleteResult;
if (!response.deleted) {
results[index] = notFoundResult(session.key, opts.agent);
results[index] = notFoundResult(session.key, agent);
continue;
}
results[index] = {
+18
View File
@@ -31,6 +31,24 @@ function makeEaccesFs(configPath: string) {
}
describe("config io EACCES handling", () => {
it("logs config load failures without exposing a raw error stack", () => {
const configPath = "/data/.openclaw/openclaw.json";
const errors: unknown[][] = [];
const io = createConfigIO({
configPath,
fs: makeEaccesFs(configPath),
logger: {
error: (...args: unknown[]) => errors.push(args),
warn: () => {},
},
});
expect(() => io.loadConfig()).toThrow(expect.objectContaining({ code: "EACCES" }));
expect(errors).toEqual([
[`Failed to read config at ${configPath}: EACCES: permission denied, open '${configPath}'`],
]);
});
it("returns a helpful error message when config file is not readable (EACCES)", async () => {
const configPath = "/data/.openclaw/openclaw.json";
const errors: string[] = [];
+2 -1
View File
@@ -1,3 +1,4 @@
import { formatErrorMessage } from "../infra/errors.js";
import {
loadShellEnvFallback,
resolveShellEnvFallbackTimeoutMs,
@@ -208,7 +209,7 @@ export function loadConfigFromContext(
if ((error as { code?: string })?.code === "INVALID_CONFIG") {
throw error;
}
deps.logger.error(`Failed to read config at ${configPath}`, error);
deps.logger.error(`Failed to read config at ${configPath}: ${formatErrorMessage(error)}`);
throw error;
}
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveSessionStoreTargets } from "./targets.js";
describe("session store target selection", () => {
it("rejects a blank agent instead of selecting the default store", () => {
expect(() => resolveSessionStoreTargets({}, { agent: "" })).toThrow(
"--agent must not be blank",
);
});
});
+7 -3
View File
@@ -620,7 +620,11 @@ export function resolveSessionStoreTargets(
params: { env?: NodeJS.ProcessEnv; diagnostics?: string[] } = {},
): SessionStoreTarget[] {
const env = params.env ?? process.env;
const hasAgent = Boolean(opts.agent?.trim());
const requestedAgent = opts.agent?.trim();
if (opts.agent !== undefined && !requestedAgent) {
throw new Error("--agent must not be blank");
}
const hasAgent = requestedAgent !== undefined;
const allAgents = opts.allAgents === true;
if (hasAgent && allAgents) {
throw new Error("--agent and --all-agents cannot be used together");
@@ -638,7 +642,7 @@ export function resolveSessionStoreTargets(
if (persistedStoreOwner.kind === "retired") {
throw new Error(`Session store owner is retired: ${persistedStoreOwner.agentId}`);
}
const requestedAgentId = hasAgent ? normalizeAgentId(opts.agent ?? "") : undefined;
const requestedAgentId = requestedAgent ? normalizeAgentId(requestedAgent) : undefined;
if (
requestedAgentId &&
persistedStoreOwner.kind === "configured" &&
@@ -687,7 +691,7 @@ export function resolveSessionStoreTargets(
}
if (hasAgent) {
const requested = normalizeAgentId(opts.agent ?? "");
const requested = normalizeAgentId(requestedAgent);
resolveConfiguredAgentId(cfg, requested);
return [
{
+26 -3
View File
@@ -21,12 +21,14 @@ function streamJob(overrides: Partial<CronJobCreate> = {}): CronJobCreate {
};
}
async function createCron(triggersEnabled: boolean) {
async function createCron(triggersEnabled: boolean | undefined, cronEnabled = true) {
const { storePath } = await makeStorePath();
const cron = new CronService({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: triggersEnabled } },
cronEnabled,
...(triggersEnabled === undefined
? {}
: { cronConfig: { triggers: { enabled: triggersEnabled } } }),
log: logger,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
@@ -37,6 +39,27 @@ async function createCron(triggersEnabled: boolean) {
}
describe("cron stream schedule validation", () => {
it.each([
{ cronEnabled: true, configured: undefined, triggersEnabled: true },
{ cronEnabled: true, configured: true, triggersEnabled: true },
{ cronEnabled: true, configured: false, triggersEnabled: false },
{ cronEnabled: false, configured: true, triggersEnabled: true },
{ cronEnabled: false, configured: false, triggersEnabled: false },
])(
"reports active trigger capability independently of scheduler enablement ($cronEnabled/$configured)",
async ({ cronEnabled, configured, triggersEnabled }) => {
const cron = await createCron(configured, cronEnabled);
try {
await expect(cron.status()).resolves.toMatchObject({
enabled: cronEnabled,
triggersEnabled,
});
} finally {
cron.stop();
}
},
);
it("rejects creation while cron triggers are disabled", async () => {
const cron = await createCron(false);
try {
+1
View File
@@ -42,6 +42,7 @@ export async function status(state: CronServiceState) {
const sqlitePath = resolveOpenClawStateSqlitePath();
return {
enabled: state.deps.cronEnabled,
triggersEnabled: state.deps.cronConfig?.triggers?.enabled !== false,
storePath: sqlitePath,
storage: "sqlite" as const,
sqlitePath,
+1
View File
@@ -372,6 +372,7 @@ export type CronWakeMode = "now" | "next-heartbeat";
/** Lightweight service status returned to gateway/control surfaces. */
export type CronStatusSummary = {
enabled: boolean;
triggersEnabled: boolean;
/** @deprecated Alias for `sqlitePath`. */
storePath: string;
/** Storage backend identifier. */
+1 -1
View File
@@ -231,7 +231,7 @@ describe("cron service timer seam coverage", () => {
expect(task.startedAt).toBe(now);
expect(task.lastEventAt).toBe(now);
expect(task.endedAt).toBe(now);
expect(task.cleanupAfter).toBeUndefined();
expect(task.cleanupAfter).toBe(now + 7 * 24 * 60 * 60_000);
const delays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
+199 -50
View File
@@ -1,7 +1,9 @@
// Doctor runtime check tests cover runtime-backed doctor checks.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { GATEWAY_HEALTH_RATE_LIMITED_MESSAGE } from "../commands/gateway-health-auth-diagnostic.js";
import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { setPluginToolMeta } from "../plugins/tools.js";
const mocks = vi.hoisted(() => ({
@@ -11,7 +13,8 @@ const mocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
normalizeProviderToolSchemasWithPlugin: vi.fn(),
buildGatewayProbeConnectionDetails: vi.fn(),
probeGatewayStatus: vi.fn(),
callGateway: vi.fn(),
isGatewayCredentialsRequiredError: vi.fn(),
readGatewayServiceState: vi.fn(),
resolveGatewayService: vi.fn(() => ({ label: "openclaw-gateway" })),
resolvePluginProvidersCore: vi.fn((): Array<Record<string, unknown>> => []),
@@ -46,10 +49,8 @@ vi.mock("../agents/agent-tools.js", () => ({
vi.mock("../gateway/call.js", () => ({
buildGatewayProbeConnectionDetails: mocks.buildGatewayProbeConnectionDetails,
}));
vi.mock("../cli/daemon-cli/probe.js", () => ({
probeGatewayStatus: mocks.probeGatewayStatus,
callGateway: mocks.callGateway,
isGatewayCredentialsRequiredError: mocks.isGatewayCredentialsRequiredError,
}));
vi.mock("../daemon/service.js", () => ({
@@ -105,13 +106,6 @@ describe("doctor runtime tool schema checks", () => {
mocks.normalizeProviderToolSchemasWithPlugin
.mockReset()
.mockImplementation(({ context }) => context.tools);
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
url: "http://127.0.0.1:5829",
});
mocks.probeGatewayStatus.mockReset().mockResolvedValue({
ok: true,
server: { version: "2026.6.26" },
});
mocks.readGatewayServiceState.mockReset().mockResolvedValue({
installed: true,
loadState: { status: "loaded" },
@@ -567,10 +561,8 @@ describe("doctor gateway runtime checks", () => {
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
url: "http://127.0.0.1:5829",
});
mocks.probeGatewayStatus.mockReset().mockResolvedValue({
ok: true,
server: { version: "2026.6.26" },
});
mocks.callGateway.mockReset().mockResolvedValue({ degradedSecretOwners: [] });
mocks.isGatewayCredentialsRequiredError.mockReset().mockReturnValue(false);
mocks.readGatewayServiceState.mockReset().mockResolvedValue({
installed: true,
loadState: { status: "loaded" },
@@ -582,52 +574,209 @@ describe("doctor gateway runtime checks", () => {
mocks.resolveGatewayService.mockReset().mockReturnValue({ label: "openclaw-gateway" });
});
it("reports unreachable gateway health probes", async () => {
mocks.probeGatewayStatus.mockResolvedValueOnce({
ok: false,
error: "connect ECONNREFUSED 127.0.0.1:5829",
it("projects every degraded SecretRef owner from exactly one authenticated read-only status RPC", async () => {
const cfg = { gateway: { mode: "local" as const } };
const privateToken = "SYNTHETIC_PRIVATE_URL_TOKEN";
mocks.buildGatewayProbeConnectionDetails.mockResolvedValueOnce({
url: "wss://127.0.0.1:5829",
tlsFingerprint: "sha256:test-doctor-fingerprint",
preauthHandshakeTimeoutMs: 1200,
});
mocks.callGateway.mockResolvedValueOnce({
degradedSecretOwners: [
{
ownerKind: "account",
ownerId: "discord:ops",
state: "unavailable",
paths: ["channels.discord.accounts.ops.token"],
reason: "secret reference was not found (env:default:PRIVATE_REF_ID)",
},
{
ownerKind: "capability",
ownerId: "tts",
state: "unavailable",
degradationState: "stale",
paths: ["tts.providers.elevenlabs.apiKey", "tts.providers.elevenlabs.voiceId"],
reason: "secret provider policy denied resolution",
},
{
ownerKind: "provider",
ownerId: `vault\u001b]52;c;attack\u0007:https://user:${privateToken}@secret.test/${"a".repeat(500)}`,
state: "unavailable",
paths: Array.from(
{ length: 12 },
(_, index) =>
`providers.example.${index}.https://secret.test/value?token=${privateToken}\n${"z".repeat(400)}`,
),
reason: `secret provider failed: ${privateToken}\nref PRIVATE_REF_ID`,
},
],
degradedPlugins: [{ pluginId: "not-this-check" }],
});
await expect(
collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }),
).resolves.toContainEqual({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: "Gateway is not reachable: connect ECONNREFUSED 127.0.0.1:5829",
path: "gateway.mode",
target: "http://127.0.0.1:5829",
fixHint:
"Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
const findings = await collectGatewayHealthFindings({
cfg,
configPath: "/tmp/selected-openclaw.json",
});
expect(mocks.callGateway).toHaveBeenCalledExactlyOnceWith({
method: "status",
params: { includeChannelSummary: false },
timeoutMs: 3000,
sharedStateMode: "read-only",
config: cfg,
configPath: "/tmp/selected-openclaw.json",
tlsFingerprint: "sha256:test-doctor-fingerprint",
preauthHandshakeTimeoutMs: 1200,
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: expect.stringContaining("cold account:discord:ops"),
path: "channels.discord.accounts.ops.token",
target: "account:discord:ops",
fixHint: expect.stringContaining("openclaw secrets reload"),
}),
expect.objectContaining({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: expect.stringContaining("stale capability:tts"),
path: "tts.providers.elevenlabs.apiKey",
target: "capability:tts",
fixHint: expect.stringContaining("openclaw secrets reload"),
}),
expect.objectContaining({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: expect.stringContaining("provider:vault"),
path: expect.stringContaining("providers.example.0"),
target: expect.stringContaining("provider:vault"),
}),
]);
expect(findings[1]?.message).toContain("tts.providers.elevenlabs.voiceId");
const finding = findings[2];
const rendered = JSON.stringify(findings);
expect(finding?.message).toContain("omitted");
expect(finding?.message).toContain("secret resolution failed");
expect(finding?.message.length).toBeLessThanOrEqual(700);
expect(finding?.target?.length).toBeLessThanOrEqual(150);
expect(finding?.path?.length).toBeLessThanOrEqual(180);
expect(rendered).not.toContain(privateToken);
expect(rendered).not.toContain("PRIVATE_REF_ID");
expect(rendered).not.toContain("not-this-check");
expect(rendered).not.toContain("\u001b");
expect(rendered).not.toContain("\u0007");
});
it("reports temporary Gateway authentication lockouts with wait-and-retry guidance", async () => {
mocks.probeGatewayStatus.mockResolvedValueOnce({
ok: false,
error: "connect failed",
connectFailure: { kind: "rate-limited", detailCode: "AUTH_RATE_LIMITED" },
});
it.each([
{
label: "missing Gateway authentication",
error: new Error("auth token SYNTHETIC_PRIVATE_TOKEN\nref PRIVATE_REF_ID"),
credentialsRequired: true,
message:
"Gateway status could not be inspected because this CLI has no usable token/password or paired device token for read-scope RPCs.",
},
{
label: "an unavailable Gateway authentication SecretRef",
error: new GatewaySecretRefUnavailableError("gateway.auth.token"),
credentialsRequired: false,
message:
"Gateway status could not be inspected because this CLI has no usable token/password or paired device token for read-scope RPCs.",
},
{
label: "temporary Gateway authentication rate limiting",
error: new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unauthorized: too many failed authentication attempts (retry later)",
details: { code: "AUTH_RATE_LIMITED", authReason: "rate_limited" },
retryable: true,
}),
credentialsRequired: false,
message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE,
},
{
label: "an unreachable Gateway with terminal control characters",
error: new Error("connect ECONNREFUSED 127.0.0.1:5829\u001b]52;c;attack\u0007\u009b"),
credentialsRequired: false,
message: "Gateway status could not be inspected: connect ECONNREFUSED 127.0.0.1:5829",
},
])("reports $label from exactly one sanitized status attempt", async (entry) => {
mocks.callGateway.mockRejectedValueOnce(entry.error);
mocks.isGatewayCredentialsRequiredError.mockReturnValueOnce(entry.credentialsRequired);
const findings = await collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } });
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: entry.message,
path: "gateway.mode",
target: "http://127.0.0.1:5829",
}),
]);
expect(JSON.stringify(findings)).not.toContain("SYNTHETIC_PRIVATE_TOKEN");
expect(JSON.stringify(findings)).not.toContain("PRIVATE_REF_ID");
expect(mocks.callGateway).toHaveBeenCalledOnce();
});
it("reports preparation failures without exposing URL credentials or control characters", async () => {
mocks.buildGatewayProbeConnectionDetails.mockRejectedValueOnce(
new Error(
`invalid wss://user:${"SYNTHETIC_PRIVATE_TOKEN".repeat(20)}@gateway.test/rpc\nmore`,
),
);
const findings = await collectGatewayHealthFindings({ cfg: {} });
expect(findings).toEqual([
expect.objectContaining({
severity: "warning",
message: expect.stringContaining("Gateway health inspection could not be prepared"),
path: "gateway",
}),
]);
expect(JSON.stringify(findings)).not.toContain("SYNTHETIC_PRIVATE_TOKEN");
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("prepares the target but skips the RPC for active exec credentials unless execution is allowed", async () => {
const cfg = {
gateway: {
mode: "local" as const,
auth: {
mode: "token" as const,
token: { source: "exec" as const, provider: "vault", id: "PRIVATE_REF_ID" },
},
},
};
const findings = await collectGatewayHealthFindings({ cfg, env: {} });
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: expect.stringContaining("intentionally skipped"),
fixHint: expect.stringContaining("--allow-exec"),
}),
]);
expect(JSON.stringify(findings)).not.toContain("PRIVATE_REF_ID");
expect(mocks.buildGatewayProbeConnectionDetails).toHaveBeenCalledOnce();
expect(mocks.callGateway).not.toHaveBeenCalled();
await expect(
collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }),
).resolves.toContainEqual({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE,
path: "gateway.mode",
target: "http://127.0.0.1:5829",
fixHint: "Wait for the temporary authentication lockout to expire, then rerun doctor.",
});
collectGatewayHealthFindings({ cfg, env: {}, allowExecSecretRefs: true }),
).resolves.toEqual([]);
expect(mocks.callGateway).toHaveBeenCalledOnce();
});
it("redacts sensitive remote gateway URLs from health finding targets", async () => {
mocks.buildGatewayProbeConnectionDetails.mockResolvedValueOnce({
url: "wss://user:pass@gateway.example.test/rpc?token=secret&safe=value",
});
mocks.probeGatewayStatus.mockResolvedValueOnce({
ok: false,
error: "remote gateway did not answer",
});
mocks.callGateway.mockRejectedValueOnce(new Error("remote gateway did not answer"));
const findings = await collectGatewayHealthFindings({
cfg: { gateway: { mode: "remote", remote: { url: "wss://gateway.example.test/rpc" } } },
@@ -636,7 +785,7 @@ describe("doctor gateway runtime checks", () => {
expect(findings).toContainEqual({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: "Gateway is not reachable: remote gateway did not answer",
message: "Gateway status could not be inspected: remote gateway did not answer",
path: "gateway.remote.url",
target: "wss://***:***@gateway.example.test/rpc?token=***&safe=value",
fixHint: "Verify the remote Gateway URL, network path, TLS settings, and credentials.",
+89 -57
View File
@@ -1,5 +1,6 @@
// Doctor runtime checks inspect tool names, browser residue, and runtime state.
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { TOOL_NAME_SEPARATOR } from "../agents/agent-bundle-mcp-names.js";
import {
type McpToolCatalogDiagnostic,
@@ -29,12 +30,11 @@ import {
type RuntimeToolSchemaDiagnostic,
} from "../agents/tool-schema-projection.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
import { projectDoctorSecretRuntimeDegradations } from "../commands/doctor-secret-runtime-degradation.js";
import { collectUnavailableAgentSkills } from "../commands/doctor-skills-core.js";
import {
GATEWAY_HEALTH_RATE_LIMITED_MESSAGE,
gatewayProbeResultSawGateway,
gatewayProbeResultWasRateLimited,
gatewayConnectErrorWasRateLimited,
} from "../commands/gateway-health-auth-diagnostic.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -42,7 +42,12 @@ import {
type GatewayServiceRuntime,
} from "../daemon/service-runtime.js";
import { resolveGatewayService, readGatewayServiceState } from "../daemon/service.js";
import { buildGatewayProbeConnectionDetails } from "../gateway/call.js";
import {
buildGatewayProbeConnectionDetails,
callGateway,
isGatewayCredentialsRequiredError,
} from "../gateway/call.js";
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
formatLocalAudioSelection,
@@ -54,14 +59,18 @@ import { getPluginToolMeta, setPluginToolMeta } from "../plugins/tools.js";
import type { ProviderCatalogOrder, ProviderPlugin } from "../plugins/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { buildWorkspaceSkillStatus } from "../skills/discovery/status.js";
import type { StatusSummary } from "../status/types.js";
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js";
import type { HealthCheckContext, HealthFinding } from "./health-checks.js";
type BundleMcpToolRuntime = Awaited<ReturnType<typeof createBundleMcpToolRuntime>>;
const PROVIDER_CATALOG_ORDERS = ["simple", "profile", "paired", "late"] as const;
const PROVIDER_CATALOG_ORDER_SET = new Set<ProviderCatalogOrder>(PROVIDER_CATALOG_ORDERS);
function formatGatewayHealthTarget(url: string): string {
return redactSensitiveUrlLikeString(url);
function formatGatewayHealthDiagnostic(value: unknown): string {
const raw = value instanceof Error ? value.message : String(value);
return scrubDoctorErrorMessage(sanitizeTerminalText(redactSensitiveUrlLikeString(raw)));
}
export function detectUnavailableSkills(cfg: OpenClawConfig, workspaceDir: string) {
@@ -105,64 +114,87 @@ export async function collectLocalAudioAccelerationFindings(): Promise<readonly
}
export async function collectGatewayHealthFindings(
ctx: Pick<HealthCheckContext, "cfg" | "configPath">,
ctx: Pick<HealthCheckContext, "cfg" | "configPath" | "env" | "allowExecSecretRefs">,
): Promise<readonly HealthFinding[]> {
let probeDetails: Awaited<ReturnType<typeof buildGatewayProbeConnectionDetails>>;
const mode = ctx.cfg.gateway?.mode === "remote" ? "remote" : "local";
const gatewayPath = mode === "remote" ? "gateway.remote.url" : "gateway.mode";
let probeDetails: Awaited<ReturnType<typeof buildGatewayProbeConnectionDetails>> | undefined;
const warning = (message: string, fixHint: string): HealthFinding => ({
checkId: "core/doctor/gateway-health",
severity: "warning",
message,
path: probeDetails || mode === "remote" ? gatewayPath : "gateway",
...(probeDetails ? { target: formatGatewayHealthDiagnostic(probeDetails.url) } : {}),
fixHint,
});
try {
probeDetails = await buildGatewayProbeConnectionDetails({
config: ctx.cfg,
...(ctx.configPath ? { configPath: ctx.configPath } : {}),
configPath: ctx.configPath,
});
} catch (error) {
return [
{
checkId: "core/doctor/gateway-health",
severity: "warning",
message: `Gateway health probe could not be prepared: ${formatErrorMessage(error)}`,
path: ctx.cfg.gateway?.mode === "remote" ? "gateway.remote.url" : "gateway",
fixHint:
"Fix Gateway connection configuration, then rerun `openclaw doctor --lint --only core/doctor/gateway-health`.",
},
];
}
const probe = await probeGatewayStatus({
url: probeDetails.url,
timeoutMs: 3000,
tlsFingerprint: probeDetails.tlsFingerprint,
preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs,
config: ctx.cfg,
json: true,
});
const mode = ctx.cfg.gateway?.mode === "remote" ? "remote" : "local";
if (gatewayProbeResultWasRateLimited(probe)) {
return [
{
checkId: "core/doctor/gateway-health",
severity: "warning",
message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE,
path: mode === "remote" ? "gateway.remote.url" : "gateway.mode",
target: formatGatewayHealthTarget(probeDetails.url),
fixHint: "Wait for the temporary authentication lockout to expire, then rerun doctor.",
},
];
}
if (gatewayProbeResultSawGateway(probe)) {
return [];
}
return [
{
if (
ctx.allowExecSecretRefs !== true &&
(await hasActiveGatewayExecCredential({
cfg: ctx.cfg,
env: ctx.env,
targetUrl: probeDetails.url,
}))
) {
return [
warning(
"Authenticated Gateway health inspection was intentionally skipped because an active credential uses an exec SecretRef.",
"Rerun `openclaw doctor --lint --only core/doctor/gateway-health --allow-exec` to permit configured secret execution.",
),
];
}
const status = await callGateway<StatusSummary>({
method: "status",
params: { includeChannelSummary: false },
timeoutMs: 3000,
sharedStateMode: "read-only",
config: ctx.cfg,
configPath: ctx.configPath,
tlsFingerprint: probeDetails.tlsFingerprint,
preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs,
});
return projectDoctorSecretRuntimeDegradations(status).map((owner) => ({
checkId: "core/doctor/gateway-health",
severity: "warning",
message: `Gateway is not reachable: ${probe.error ?? "status probe failed"}`,
path: mode === "remote" ? "gateway.remote.url" : "gateway.mode",
target: formatGatewayHealthTarget(probeDetails.url),
fixHint:
mode === "remote"
? "Verify the remote Gateway URL, network path, TLS settings, and credentials."
: "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
},
];
message: `Secret runtime degradation: ${owner.message}`,
path: owner.path,
target: owner.target,
fixHint: `Retry: ${owner.retryHint}`,
}));
} catch (error) {
if (!probeDetails) {
return [
warning(
`Gateway health inspection could not be prepared: ${formatGatewayHealthDiagnostic(error)}`,
"Fix Gateway connection configuration, then rerun `openclaw doctor --lint --only core/doctor/gateway-health`.",
),
];
}
const diagnostic = gatewayConnectErrorWasRateLimited(error)
? {
message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE,
fixHint: "Wait for the temporary authentication lockout to expire, then rerun doctor.",
}
: isGatewayCredentialsRequiredError(error) || isGatewaySecretRefUnavailableError(error)
? {
message:
"Gateway status could not be inspected because this CLI has no usable token/password or paired device token for read-scope RPCs.",
fixHint:
"Configure the Gateway token/password or pair this device, then rerun the selected health check.",
}
: {
message: `Gateway status could not be inspected: ${formatGatewayHealthDiagnostic(error)}`,
fixHint:
mode === "remote"
? "Verify the remote Gateway URL, network path, TLS settings, and credentials."
: "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
};
return [warning(diagnostic.message, diagnostic.fixHint)];
}
}
function gatewayRuntimeStatus(runtime: GatewayServiceRuntime | undefined): string | undefined {
+1 -1
View File
@@ -1063,7 +1063,7 @@ function createGatewayHealthCheck(deps: CoreHealthCheckDeps): SplitHealthCheckDe
return {
id: GATEWAY_HEALTH_CHECK_ID,
kind: "core",
description: "Gateway reachability is represented as structured findings.",
description: "Authenticated Gateway health and degraded secret owners are structured findings.",
source: "doctor",
defaultEnabled: false,
async detect(ctx) {
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SecretInput } from "../config/types.secrets.js";
import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js";
const execRef = { source: "exec", provider: "vault", id: "PRIVATE_EDGE_REF_ID" } as const;
type GatewayExecEdgeCredentialCase = {
label: string;
mode: "local" | "remote";
targetUrl?: string;
edgeAuth: Record<string, SecretInput>;
expected: boolean;
};
describe("hasActiveGatewayExecCredential", () => {
it.each<GatewayExecEdgeCredentialCase>([
{
label: "the configured remote target uses an exec-backed edge header",
mode: "remote" as const,
edgeAuth: { "X-Edge-Auth": execRef },
expected: true,
},
{
label: "a matching target differs only by query parameters",
mode: "remote" as const,
targetUrl: "wss://gateway.example.test/rpc?profile=two",
edgeAuth: { "X-Edge-Auth": execRef },
expected: true,
},
{
label: "the effective target is a different gateway",
mode: "remote" as const,
targetUrl: "wss://other-gateway.example.test/rpc",
edgeAuth: { "X-Edge-Auth": execRef },
expected: false,
},
{
label: "a local gateway cannot use unrelated remote edge headers",
mode: "local" as const,
edgeAuth: { "X-Edge-Auth": execRef },
expected: false,
},
{
label: "matching literal and environment-backed headers do not execute",
mode: "remote" as const,
edgeAuth: {
"X-Literal": "literal-edge-value",
"X-Environment": { source: "env", provider: "default", id: "EDGE_TOKEN" } as const,
},
expected: false,
},
])("detects only effective exec edge credentials when $label", async (entry) => {
const cfg: OpenClawConfig = {
gateway: {
mode: entry.mode,
remote: { url: "wss://gateway.example.test/rpc", edgeAuth: entry.edgeAuth },
},
};
await expect(
hasActiveGatewayExecCredential({ cfg, env: {}, targetUrl: entry.targetUrl }),
).resolves.toBe(entry.expected);
});
});
+21 -1
View File
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
export async function hasActiveGatewayExecCredential(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
targetUrl?: string;
}): Promise<boolean> {
const [{ resolveSecretInputRef }, { gatewaySecretInputPathCanWin }, secretPaths] =
await Promise.all([
@@ -11,7 +12,7 @@ export async function hasActiveGatewayExecCredential(params: {
import("../gateway/secret-input-paths.js"),
]);
const mode = params.cfg.gateway?.mode === "remote" ? "remote" : "local";
return secretPaths.ALL_GATEWAY_SECRET_INPUT_PATHS.some((path) => {
const hasExecCredential = secretPaths.ALL_GATEWAY_SECRET_INPUT_PATHS.some((path) => {
if (
!gatewaySecretInputPathCanWin({
config: params.cfg,
@@ -28,4 +29,23 @@ export async function hasActiveGatewayExecCredential(params: {
}).ref;
return ref?.source === "exec";
});
if (hasExecCredential || !params.cfg.gateway?.remote?.edgeAuth) {
return hasExecCredential;
}
const [{ buildGatewayProbeConnectionDetails }, edgeAuth] = await Promise.all([
import("../gateway/call.js"),
import("../gateway/edge-auth.js"),
]);
const targetUrl =
params.targetUrl ?? (await buildGatewayProbeConnectionDetails({ config: params.cfg })).url;
const { gatewayEdgeAuthValueForTarget, normalizeEdgeAuthHeadersConfig } = edgeAuth;
const headers = normalizeEdgeAuthHeadersConfig(
gatewayEdgeAuthValueForTarget({ config: params.cfg, targetUrl }),
);
return Object.values(headers ?? {}).some(
(value) =>
resolveSecretInputRef({ value, defaults: params.cfg.secrets?.defaults }).ref?.source ===
"exec",
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ vi.mock("../config/paths.js", () => ({
vi.mock("../state/openclaw-database-preflight.js", () => ({
OpenClawDatabaseSchemaPreflightError: class extends Error {},
preflightOpenClawDatabaseSchemas: () => ({ incompatible: [] }),
preflightOpenClawDatabaseSchemas: () => ({ incompatible: [], indeterminate: [] }),
}));
vi.mock("../state/openclaw-agent-db.js", () => ({

Some files were not shown because too many files have changed in this diff Show More