mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -182,6 +182,7 @@ export function buildCronMocks(baseTime: number) {
|
||||
}));
|
||||
const status: CronStatus = {
|
||||
enabled: true,
|
||||
triggersEnabled: true,
|
||||
jobs: jobs.length,
|
||||
nextWakeAtMs: overdueJob.state?.nextRunAtMs,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 * * *",
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] : []),
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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! });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
[
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user