Merge origin/main and address review findings

This commit is contained in:
Jesse Merhi
2026-08-13 02:10:37 +10:00
1669 changed files with 94644 additions and 54285 deletions
+23 -7
View File
@@ -139,12 +139,28 @@ runs:
# disk here, this gate binds cooperating code, not hostile code: the
# enforced trust boundary is the fork/dispatch runner gate in ci.yml,
# and same-repo PR authors already hold repository write access.
# Explicit true (not on-change) because the allocated-byte heuristic
# can miss a fingerprint refresh whose reinstall keeps disk usage
# stable, permanently stranding consumers on a stale marker. The action
# skips commit after failed/cancelled steps, so a broken install cannot
# seed this key.
commit: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}
# Warm validated snapshots stay read-only so asynchronous publication
# does not perpetually chase no-op commits. The canonical writer records
# the action's allocation baseline below; after any real capture and
# store pruning, preflight forces a verified delta before this action's
# post phase. The action also skips commit after failed/cancelled steps.
commit: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'on-change' || 'false' }}
- name: Record sticky disk allocation baseline
if: inputs.sticky-disk == 'true' && inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request'
shell: bash
run: |
set -euo pipefail
sticky_root=/var/tmp/openclaw-node-deps
initial_usage_bytes="$(df -B1 --output=used "$sticky_root" | tail -n1 | tr -d '[:space:]')"
if [[ ! "$initial_usage_bytes" =~ ^[0-9]+$ ]] || [[ "$initial_usage_bytes" -le 0 ]]; then
echo "::error::Could not record sticky disk allocation baseline"
exit 1
fi
rebuild_signal="${RUNNER_TEMP:?}/openclaw-sticky-deps-rebuilt"
rm -f "$rebuild_signal"
echo "OPENCLAW_STICKY_INITIAL_USAGE_BYTES=$initial_usage_bytes" >> "$GITHUB_ENV"
echo "OPENCLAW_STICKY_REBUILD_SIGNAL=$rebuild_signal" >> "$GITHUB_ENV"
- name: Restore and save Vitest transform cache
if: inputs.vitest-fs-cache == 'true' && inputs.save-vitest-fs-cache == 'true' && runner.os != 'Windows'
@@ -462,7 +478,7 @@ runs:
# publishes the fingerprint; read-only clones are discarded at job
# end, so capturing there would only burn shard wall clock.
if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ]; then
bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT"
bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT" "${OPENCLAW_STICKY_REBUILD_SIGNAL:?}"
fi
fi
@@ -3,11 +3,12 @@ set -euo pipefail
mode="${1:?mode is required}"
sticky_root="${2:?sticky root is required}"
workspace="${3:?workspace is required}"
workspace="${3:-}"
archive="$sticky_root/importer-node-modules.tar"
archive_checksum="$sticky_root/.openclaw-importer-archive.sha256"
importer_manifest="$sticky_root/importer-node-modules.manifest"
marker="$sticky_root/.openclaw-deps-fingerprint"
force_commit_sentinel="$sticky_root/.openclaw-force-commit"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
archive_sha256() {
@@ -32,7 +33,9 @@ verify_importers() {
case "$mode" in
capture)
workspace="${workspace:?workspace is required}"
fingerprint="${4:?fingerprint is required}"
rebuild_signal="${5:?rebuild signal is required}"
mkdir -p "$sticky_root"
list_file="$(mktemp)"
temp_archive="$archive.tmp.$$"
@@ -67,8 +70,10 @@ case "$mode" in
# registry-backed importer resolution before trusting this snapshot.
printf '%s\n' "$fingerprint" >"$temp_marker"
mv "$temp_marker" "$marker"
: >"$rebuild_signal"
;;
restore)
workspace="${workspace:?workspace is required}"
if [[ ! -f "$archive" || ! -f "$archive_checksum" || ! -f "$importer_manifest" ]]; then
echo "sticky importer archive, manifest, or checksum is missing under $sticky_root" >&2
exit 1
@@ -97,6 +102,61 @@ case "$mode" in
exit 1
fi
;;
ensure-change)
initial_usage_bytes="${3:?initial usage bytes are required}"
if [[ ! "$initial_usage_bytes" =~ ^[0-9]+$ ]] || [[ "$initial_usage_bytes" -le 0 ]]; then
echo "invalid initial sticky disk usage: $initial_usage_bytes" >&2
exit 2
fi
current_usage_bytes() {
df -B1 --output=used "$sticky_root" | tail -n1 | tr -d '[:space:]'
}
allocation_delta() {
local current="$1"
if [[ "$current" -ge "$initial_usage_bytes" ]]; then
echo $((current - initial_usage_bytes))
else
echo $((initial_usage_bytes - current))
fi
}
# The pinned StickyDisk action commits only when the absolute whole-disk
# allocation delta exceeds 4096 bytes. Measure against the same baseline
# after store pruning, then leave a 64 KiB margin for its post phase.
target_delta_bytes=65536
max_sentinel_bytes=1048576
current="$(current_usage_bytes)"
if [[ ! "$current" =~ ^[0-9]+$ ]] || [[ "$current" -le 0 ]]; then
echo "could not read current sticky disk usage" >&2
exit 1
fi
delta="$(allocation_delta "$current")"
if [[ "$delta" -le "$target_delta_bytes" ]] &&
[[ -f "$force_commit_sentinel" ]] &&
[[ "$(stat -c %s "$force_commit_sentinel")" -ge "$max_sentinel_bytes" ]]; then
: >"$force_commit_sentinel"
sync
current="$(current_usage_bytes)"
delta="$(allocation_delta "$current")"
fi
for _ in 1 2 3; do
if [[ "$delta" -gt "$target_delta_bytes" ]]; then
echo "Sticky dependency rebuild changed allocation by ${delta} bytes"
exit 0
fi
bytes_needed=$((initial_usage_bytes + target_delta_bytes + 4096 - current))
blocks_needed=$(((bytes_needed + 4095) / 4096))
if [[ "$blocks_needed" -lt 1 ]]; then
blocks_needed=1
fi
dd if=/dev/zero bs=4096 count="$blocks_needed" status=none >>"$force_commit_sentinel"
sync
current="$(current_usage_bytes)"
delta="$(allocation_delta "$current")"
done
echo "could not force a detectable sticky disk allocation change (delta: ${delta} bytes)" >&2
exit 1
;;
*)
echo "unsupported sticky importer mode: $mode" >&2
exit 2
@@ -140,6 +140,7 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
pass `--link-preview false` to `start`. The runner injects that setting into
the isolated SUT config before Gateway startup. Do not edit the generated
config or restart the Gateway to apply it.
To prove fixed pacing between streamed blocks, pass `--human-delay-fixed-ms <milliseconds>` to `start`.
When the proof must show an in-place streamed edit, also pass
`--mock-response-chunk-delay-ms 1200` and use a mock response long enough
for the first chunk to clear the preview debounce. Capture both the initial
+13
View File
@@ -898,6 +898,17 @@ jobs:
echo "::warning::pnpm store remains above its 8 GiB maintenance ceiling after prune"
fi
# StickyDisk's pinned on-change mode compares whole-filesystem
# allocation to its mount-time baseline. Only a successful real
# dependency capture creates this runner-local signal. Force and
# verify the delta after pruning so a same-size rebuild commits while
# validated warm restores remain read-only.
if [ -f "${OPENCLAW_STICKY_REBUILD_SIGNAL:?}" ]; then
bash "$GITHUB_WORKSPACE/.github/actions/setup-node-env/sticky-importers.sh" \
ensure-change /var/tmp/openclaw-node-deps \
"${OPENCLAW_STICKY_INITIAL_USAGE_BYTES:?}"
fi
# Run dependency-free security checks on a hosted runner in parallel with
# scope detection. No downstream job waits for Python/pre-commit setup.
security-fast:
@@ -1282,6 +1293,8 @@ jobs:
run: node openclaw.mjs status --json --timeout 1
- name: Verify built Doctor plugin index persistence
env:
OPENCLAW_E2E_USE_PREBUILT_DIST: "1"
run: |
if [[ -f test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts ]]; then
# Cold hosted runners can spend over five minutes in E2E setup before
@@ -977,7 +977,7 @@ jobs:
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
timeout-minutes: 90
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
@@ -1004,6 +1004,9 @@ jobs:
- name: Install Playwright Chromium
run: pnpm --dir ui exec playwright install --with-deps chromium
- name: Build sandbox image
run: scripts/sandbox-setup.sh
- name: Run repo E2E suite
run: pnpm test:e2e
@@ -284,250 +284,15 @@ jobs:
- name: Validate candidate release provenance
id: provenance
env:
CANDIDATE_GIT_DIR: .candidate
CANDIDATE_ROOT: .candidate
GH_TOKEN: ${{ github.token }}
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
TARGET_REF: ${{ inputs.target_ref }}
TARGET_SHA: ${{ inputs.target_sha }}
shell: bash
run: |
set -euo pipefail
gh_with_retry() {
local stdout stderr_file stderr_output output status attempt
for attempt in 1 2 3 4 5; do
stderr_file="$(mktemp)"
set +e
stdout="$(gh "$@" 2>"$stderr_file")"
status=$?
set -e
if [[ "$status" -eq 0 ]]; then
if [[ -s "$stderr_file" ]]; then
cat "$stderr_file" >&2
fi
rm -f "$stderr_file"
printf '%s\n' "$stdout"
return 0
fi
stderr_output="$(cat "$stderr_file")"
rm -f "$stderr_file"
output="$stdout"
if [[ -n "$stderr_output" ]]; then
output+="${output:+$'\n'}${stderr_output}"
fi
if [[ "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then
echo "::warning::Transient GitHub response from gh $* on attempt ${attempt}; retrying." >&2
sleep $((attempt * 3))
continue
fi
printf '%s\n' "$output" >&2
return "$status"
done
printf '%s\n' "$output" >&2
return "$status"
}
candidate_sha="$(git -C .candidate rev-parse HEAD)"
[[ "$candidate_sha" == "$TARGET_SHA" ]]
normalized_context_ref="${TARGET_CONTEXT_REF:-}"
normalized_context_ref="${normalized_context_ref#refs/heads/}"
normalized_context_ref="${normalized_context_ref#refs/tags/}"
context_release_branch=""
context_release_tag=""
frozen_release_branch_pattern=""
if [[ "$normalized_context_ref" =~ ^release/([0-9]{4}\.[0-9]+\.[0-9]+)$ ]]; then
release_version="${BASH_REMATCH[1]}"
release_version_pattern="${release_version//./\\.}"
candidate_version="$(jq -er '.version' .candidate/package.json)"
if [[ "$candidate_version" == "$release_version" ]]; then
context_release_branch="$normalized_context_ref"
elif [[ "$candidate_version" =~ ^${release_version_pattern}-beta\.[0-9]+$ ]]; then
candidate_version_pattern="${candidate_version//./\\.}"
frozen_release_branch_pattern="^release/${candidate_version_pattern}-code-frozen(-r[1-9][0-9]*)?$"
else
echo "Telegram candidate version ${candidate_version} does not belong to release ${release_version}." >&2
exit 1
fi
elif [[ "$normalized_context_ref" =~ ^extended-stable/([0-9]{4}\.[0-9]+\.33)$ ]]; then
context_version="${BASH_REMATCH[1]}"
candidate_version="$(jq -er '.version' .candidate/package.json)"
if [[ "$candidate_version" != "$context_version" ]]; then
echo "Telegram candidate version ${candidate_version} does not match context ${normalized_context_ref}." >&2
exit 1
fi
context_release_branch="$normalized_context_ref"
elif [[ "$normalized_context_ref" =~ ^v([0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?)$ ]]; then
context_version="${BASH_REMATCH[1]}"
candidate_version="$(jq -er '.version' .candidate/package.json)"
if [[ "$candidate_version" != "$context_version" ]]; then
echo "Telegram candidate version ${candidate_version} does not match context ${normalized_context_ref}." >&2
exit 1
fi
context_release_tag="$normalized_context_ref"
fi
repository_owner="${GITHUB_REPOSITORY%%/*}"
repository_name="${GITHUB_REPOSITORY#*/}"
candidate_metadata_json="$(
gh_with_retry api graphql \
-f query='query($owner:String!,$name:String!,$oid:GitObjectID!){repository(owner:$owner,name:$name){object(oid:$oid){... on Commit{oid signature{isValid state signer{login}} associatedPullRequests(first:100){nodes{state headRefOid headRepository{nameWithOwner} baseRefName baseRepository{nameWithOwner} mergeCommit{oid} mergedBy{login}}}}}}}' \
-f owner="$repository_owner" \
-f name="$repository_name" \
-f oid="$candidate_sha"
)"
pr_head_count="$(
jq -er \
--arg repo "$GITHUB_REPOSITORY" \
--arg sha "$candidate_sha" \
'[.data.repository.object.associatedPullRequests.nodes[] |
select(.state == "OPEN" and .headRepository.nameWithOwner == $repo and
.headRefOid == $sha)] | length' \
<<<"$candidate_metadata_json"
)"
if [[ "$pr_head_count" != "0" ]]; then
echo "Telegram candidate ${candidate_sha} is an open same-repository PR head." >&2
exit 1
fi
compare_status="$(
gh_with_retry api \
"repos/${GITHUB_REPOSITORY}/compare/${candidate_sha}...main" \
--jq '.status'
)"
trusted_reason=""
trusted_release_branch=""
if [[ -n "$context_release_branch" ]]; then
branch_sha="$(
git -C .candidate ls-remote --exit-code --refs origin \
"refs/heads/${context_release_branch}" |
awk 'NR == 1 { print $1 } END { if (NR != 1) exit 1 }'
)"
[[ "$branch_sha" == "$candidate_sha" ]]
trusted_reason="release-branch-head"
trusted_release_branch="$context_release_branch"
elif [[ -n "$context_release_tag" ]]; then
tag_refs="$(
git -C .candidate ls-remote --exit-code origin \
"refs/tags/${context_release_tag}" "refs/tags/${context_release_tag}^{}"
)"
awk -v sha="$candidate_sha" '$1 == sha { found = 1 } END { exit(found ? 0 : 1) }' \
<<<"$tag_refs"
trusted_reason="release-tag"
elif [[ -z "$frozen_release_branch_pattern" &&
( "$compare_status" == "ahead" || "$compare_status" == "identical" ) ]]; then
trusted_reason="main-ancestor"
else
normalized_ref="${TARGET_REF#refs/heads/}"
if [[ "$normalized_ref" =~ ^(release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*|extended-stable/[0-9]{4}\.[1-9][0-9]*\.33)$ ]] ||
[[ -n "$frozen_release_branch_pattern" && "$normalized_ref" =~ $frozen_release_branch_pattern ]]; then
branch_sha="$(
git -C .candidate ls-remote --exit-code --refs origin \
"refs/heads/${normalized_ref}" |
awk 'NR == 1 { print $1 } END { if (NR != 1) exit 1 }'
)"
[[ "$branch_sha" == "$candidate_sha" ]]
if [[ -n "$frozen_release_branch_pattern" && "$normalized_ref" =~ $frozen_release_branch_pattern ]]; then
trusted_reason="frozen-release-branch-head"
else
trusted_reason="release-branch-head"
fi
trusted_release_branch="$normalized_ref"
elif [[ "$TARGET_REF" =~ ^refs/tags/v ]] || [[ "$TARGET_REF" =~ ^v ]]; then
normalized_tag="${TARGET_REF#refs/tags/}"
tag_refs="$(
git -C .candidate ls-remote --exit-code origin \
"refs/tags/${normalized_tag}" "refs/tags/${normalized_tag}^{}"
)"
awk -v sha="$candidate_sha" '$1 == sha { found = 1 } END { exit(found ? 0 : 1) }' \
<<<"$tag_refs"
trusted_reason="release-tag"
elif [[ "$TARGET_REF" =~ ^[a-f0-9]{40}$ && "$TARGET_REF" == "$candidate_sha" ]]; then
matching_release_branches="$(
gh_with_retry api --paginate \
"repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}/branches-where-head" \
--jq '.[].name' |
awk -v frozen="$frozen_release_branch_pattern" \
'(frozen != "" && $0 ~ frozen) ||
(frozen == "" &&
($0 ~ /^release\/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$/ ||
$0 ~ /^extended-stable\/[0-9]{4}\.[1-9][0-9]*\.33$/)) { print }'
)"
if [[ "$(wc -l <<<"$matching_release_branches" | tr -d ' ')" == "1" && -n "$matching_release_branches" ]]; then
if [[ -n "$frozen_release_branch_pattern" && "$matching_release_branches" =~ $frozen_release_branch_pattern ]]; then
trusted_reason="frozen-release-branch-head"
else
trusted_reason="release-branch-head"
fi
trusted_release_branch="$matching_release_branches"
elif [[ -z "$frozen_release_branch_pattern" ]]; then
matching_release_tags="$(
git -C .candidate ls-remote origin 'refs/tags/v*' |
awk -v sha="$candidate_sha" '$1 == sha { sub(/\^\{\}$/, "", $2); print $2 }' |
sort -u
)"
if [[ -n "$matching_release_tags" ]]; then
trusted_reason="release-tag"
fi
fi
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Telegram candidate ${candidate_sha} is not trusted release provenance." >&2
exit 1
fi
if [[ "$trusted_reason" != "main-ancestor" ]]; then
signature_json="$candidate_metadata_json"
signature_status="$(
jq -er \
--arg sha "$candidate_sha" \
'.data.repository.object |
select(.oid == $sha) |
if .signature == null then "missing"
elif .signature.isValid == true and .signature.state == "VALID" and
(.signature.signer.login // "") != "" then "valid"
else "invalid"
end' \
<<<"$signature_json"
)"
if [[ "$signature_status" == "invalid" ]]; then
echo "Release candidate ${candidate_sha} has an invalid commit signature." >&2
exit 1
fi
signer="$(jq -r '.data.repository.object.signature.signer.login // ""' <<<"$signature_json")"
if [[ "$trusted_reason" == "frozen-release-branch-head" &&
( "$signature_status" != "valid" || "$signer" == "web-flow" ) ]]; then
echo "Frozen release candidate ${candidate_sha} requires a valid maintainer signature." >&2
exit 1
fi
permission_actor="$signer"
if [[ "$signature_status" == "missing" || "$signer" == "web-flow" ]]; then
if [[ "$trusted_reason" != "release-branch-head" || -z "$trusted_release_branch" ]]; then
echo "Unsigned or GitHub web-flow candidates require an exact release branch head." >&2
exit 1
fi
permission_actor="$(
jq -er \
--arg base "$trusted_release_branch" \
--arg repo "$GITHUB_REPOSITORY" \
--arg sha "$candidate_sha" \
'[.data.repository.object.associatedPullRequests.nodes[] |
select(.state == "MERGED" and .baseRefName == $base and
.baseRepository.nameWithOwner == $repo and .mergeCommit.oid == $sha) |
.mergedBy.login] | unique | select(length == 1) | .[0]' \
<<<"$signature_json"
)"
fi
permission_json="$(
gh_with_retry api \
"repos/${GITHUB_REPOSITORY}/collaborators/${permission_actor}/permission"
)"
permission="$(jq -r '.permission // ""' <<<"$permission_json")"
role_name="$(jq -r '.role_name // ""' <<<"$permission_json")"
if [[ "$permission" != "admin" && "$role_name" != "maintain" ]]; then
echo "Release candidate actor ${permission_actor} lacks maintain/admin access." >&2
exit 1
fi
fi
echo "Telegram candidate trust reason: ${trusted_reason}"
bash "${GITHUB_WORKSPACE}/scripts/release-telegram-provenance.sh"
- name: Install candidate dependencies without runner credentials
id: install_candidate
@@ -1106,231 +871,7 @@ jobs:
TARGET_SHA: ${{ inputs.target_sha }}
shell: bash
run: |
set -euo pipefail
gh_with_retry() {
local stdout stderr_file stderr_output output status attempt
for attempt in 1 2 3 4 5; do
stderr_file="$(mktemp)"
set +e
stdout="$(gh "$@" 2>"$stderr_file")"
status=$?
set -e
if [[ "$status" -eq 0 ]]; then
if [[ -s "$stderr_file" ]]; then
cat "$stderr_file" >&2
fi
rm -f "$stderr_file"
printf '%s\n' "$stdout"
return 0
fi
stderr_output="$(cat "$stderr_file")"
rm -f "$stderr_file"
output="$stdout"
if [[ -n "$stderr_output" ]]; then
output+="${output:+$'\n'}${stderr_output}"
fi
if [[ "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then
echo "::warning::Transient GitHub response from gh $* on attempt ${attempt}; retrying." >&2
sleep $((attempt * 3))
continue
fi
printf '%s\n' "$output" >&2
return "$status"
done
printf '%s\n' "$output" >&2
return "$status"
}
candidate_sha="$TARGET_SHA"
normalized_context_ref="${TARGET_CONTEXT_REF:-}"
normalized_context_ref="${normalized_context_ref#refs/heads/}"
normalized_context_ref="${normalized_context_ref#refs/tags/}"
context_release_branch=""
context_release_tag=""
frozen_release_branch_pattern=""
if [[ "$normalized_context_ref" =~ ^release/([0-9]{4}\.[0-9]+\.[0-9]+)$ ]]; then
release_version="${BASH_REMATCH[1]}"
release_version_pattern="${release_version//./\\.}"
candidate_version="$(jq -er '.version' "${CANDIDATE_ROOT}/package.json")"
if [[ "$candidate_version" == "$release_version" ]]; then
context_release_branch="$normalized_context_ref"
elif [[ "$candidate_version" =~ ^${release_version_pattern}-beta\.[0-9]+$ ]]; then
candidate_version_pattern="${candidate_version//./\\.}"
frozen_release_branch_pattern="^release/${candidate_version_pattern}-code-frozen(-r[1-9][0-9]*)?$"
else
echo "Telegram candidate version ${candidate_version} does not belong to release ${release_version}." >&2
exit 1
fi
elif [[ "$normalized_context_ref" =~ ^extended-stable/([0-9]{4}\.[0-9]+\.33)$ ]]; then
context_version="${BASH_REMATCH[1]}"
candidate_version="$(jq -er '.version' "${CANDIDATE_ROOT}/package.json")"
if [[ "$candidate_version" != "$context_version" ]]; then
echo "Telegram candidate version ${candidate_version} does not match context ${normalized_context_ref}." >&2
exit 1
fi
context_release_branch="$normalized_context_ref"
elif [[ "$normalized_context_ref" =~ ^v([0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?)$ ]]; then
context_version="${BASH_REMATCH[1]}"
candidate_version="$(jq -er '.version' "${CANDIDATE_ROOT}/package.json")"
if [[ "$candidate_version" != "$context_version" ]]; then
echo "Telegram candidate version ${candidate_version} does not match context ${normalized_context_ref}." >&2
exit 1
fi
context_release_tag="$normalized_context_ref"
fi
repository_owner="${GITHUB_REPOSITORY%%/*}"
repository_name="${GITHUB_REPOSITORY#*/}"
candidate_metadata_json="$(
gh_with_retry api graphql \
-f query='query($owner:String!,$name:String!,$oid:GitObjectID!){repository(owner:$owner,name:$name){object(oid:$oid){... on Commit{oid signature{isValid state signer{login}} associatedPullRequests(first:100){nodes{state headRefOid headRepository{nameWithOwner} baseRefName baseRepository{nameWithOwner} mergeCommit{oid} mergedBy{login}}}}}}}' \
-f owner="$repository_owner" \
-f name="$repository_name" \
-f oid="$candidate_sha"
)"
pr_head_count="$(
jq -er \
--arg repo "$GITHUB_REPOSITORY" \
--arg sha "$candidate_sha" \
'[.data.repository.object.associatedPullRequests.nodes[] |
select(.state == "OPEN" and .headRepository.nameWithOwner == $repo and
.headRefOid == $sha)] | length' \
<<<"$candidate_metadata_json"
)"
[[ "$pr_head_count" == "0" ]]
compare_status="$(
gh_with_retry api \
"repos/${GITHUB_REPOSITORY}/compare/${candidate_sha}...main" \
--jq '.status'
)"
trusted_reason=""
trusted_release_branch=""
if [[ -n "$context_release_branch" ]]; then
branch_sha="$(
git ls-remote --exit-code --refs origin "refs/heads/${context_release_branch}" |
awk 'NR == 1 { print $1 } END { if (NR != 1) exit 1 }'
)"
[[ "$branch_sha" == "$candidate_sha" ]]
trusted_reason="release-branch-head"
trusted_release_branch="$context_release_branch"
elif [[ -n "$context_release_tag" ]]; then
tag_refs="$(
git ls-remote --exit-code origin \
"refs/tags/${context_release_tag}" "refs/tags/${context_release_tag}^{}"
)"
awk -v sha="$candidate_sha" '$1 == sha { found = 1 } END { exit(found ? 0 : 1) }' \
<<<"$tag_refs"
trusted_reason="release-tag"
elif [[ -z "$frozen_release_branch_pattern" &&
( "$compare_status" == "ahead" || "$compare_status" == "identical" ) ]]; then
trusted_reason="main-ancestor"
else
normalized_ref="${TARGET_REF#refs/heads/}"
if [[ "$normalized_ref" =~ ^(release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*|extended-stable/[0-9]{4}\.[1-9][0-9]*\.33)$ ]] ||
[[ -n "$frozen_release_branch_pattern" && "$normalized_ref" =~ $frozen_release_branch_pattern ]]; then
branch_sha="$(
git ls-remote --exit-code --refs origin "refs/heads/${normalized_ref}" |
awk 'NR == 1 { print $1 } END { if (NR != 1) exit 1 }'
)"
[[ "$branch_sha" == "$candidate_sha" ]]
if [[ -n "$frozen_release_branch_pattern" && "$normalized_ref" =~ $frozen_release_branch_pattern ]]; then
trusted_reason="frozen-release-branch-head"
else
trusted_reason="release-branch-head"
fi
trusted_release_branch="$normalized_ref"
elif [[ "$TARGET_REF" =~ ^refs/tags/v ]] || [[ "$TARGET_REF" =~ ^v ]]; then
normalized_tag="${TARGET_REF#refs/tags/}"
tag_refs="$(
git ls-remote --exit-code origin \
"refs/tags/${normalized_tag}" "refs/tags/${normalized_tag}^{}"
)"
awk -v sha="$candidate_sha" '$1 == sha { found = 1 } END { exit(found ? 0 : 1) }' \
<<<"$tag_refs"
trusted_reason="release-tag"
elif [[ "$TARGET_REF" =~ ^[a-f0-9]{40}$ && "$TARGET_REF" == "$candidate_sha" ]]; then
matching_release_branches="$(
gh_with_retry api --paginate \
"repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}/branches-where-head" \
--jq '.[].name' |
awk -v frozen="$frozen_release_branch_pattern" \
'(frozen != "" && $0 ~ frozen) ||
(frozen == "" &&
($0 ~ /^release\/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$/ ||
$0 ~ /^extended-stable\/[0-9]{4}\.[1-9][0-9]*\.33$/)) { print }'
)"
if [[ "$(wc -l <<<"$matching_release_branches" | tr -d ' ')" == "1" && -n "$matching_release_branches" ]]; then
if [[ -n "$frozen_release_branch_pattern" && "$matching_release_branches" =~ $frozen_release_branch_pattern ]]; then
trusted_reason="frozen-release-branch-head"
else
trusted_reason="release-branch-head"
fi
trusted_release_branch="$matching_release_branches"
elif [[ -z "$frozen_release_branch_pattern" ]]; then
matching_release_tags="$(
git ls-remote origin 'refs/tags/v*' |
awk -v sha="$candidate_sha" '$1 == sha { sub(/\^\{\}$/, "", $2); print $2 }' |
sort -u
)"
if [[ -n "$matching_release_tags" ]]; then
trusted_reason="release-tag"
fi
fi
fi
fi
[[ -n "$trusted_reason" ]]
if [[ "$trusted_reason" != "main-ancestor" ]]; then
signature_json="$candidate_metadata_json"
signature_status="$(
jq -er \
--arg sha "$candidate_sha" \
'.data.repository.object |
select(.oid == $sha) |
if .signature == null then "missing"
elif .signature.isValid == true and .signature.state == "VALID" and
(.signature.signer.login // "") != "" then "valid"
else "invalid"
end' \
<<<"$signature_json"
)"
if [[ "$signature_status" == "invalid" ]]; then
echo "Release candidate ${candidate_sha} has an invalid commit signature." >&2
exit 1
fi
signer="$(jq -r '.data.repository.object.signature.signer.login // ""' <<<"$signature_json")"
if [[ "$trusted_reason" == "frozen-release-branch-head" &&
( "$signature_status" != "valid" || "$signer" == "web-flow" ) ]]; then
echo "Frozen release candidate ${candidate_sha} requires a valid maintainer signature." >&2
exit 1
fi
permission_actor="$signer"
if [[ "$signature_status" == "missing" || "$signer" == "web-flow" ]]; then
if [[ "$trusted_reason" != "release-branch-head" || -z "$trusted_release_branch" ]]; then
echo "Unsigned or GitHub web-flow candidates require an exact release branch head." >&2
exit 1
fi
permission_actor="$(
jq -er \
--arg base "$trusted_release_branch" \
--arg repo "$GITHUB_REPOSITORY" \
--arg sha "$candidate_sha" \
'[.data.repository.object.associatedPullRequests.nodes[] |
select(.state == "MERGED" and .baseRefName == $base and
.baseRepository.nameWithOwner == $repo and .mergeCommit.oid == $sha) |
.mergedBy.login] | unique | select(length == 1) | .[0]' \
<<<"$signature_json"
)"
fi
permission_json="$(
gh_with_retry api \
"repos/${GITHUB_REPOSITORY}/collaborators/${permission_actor}/permission"
)"
permission="$(jq -r '.permission // ""' <<<"$permission_json")"
role_name="$(jq -r '.role_name // ""' <<<"$permission_json")"
[[ "$permission" == "admin" || "$role_name" == "maintain" ]]
fi
bash "${GITHUB_WORKSPACE}/scripts/release-telegram-provenance.sh"
- name: Create isolated Telegram SUT identity and launcher
id: create_sut
+9 -16
View File
@@ -262,8 +262,8 @@
},
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.{test,suite}.ts",
"**/*.{test,suite}.tsx",
"**/*.e2e.test.ts",
"**/*.live.test.ts",
"**/*test-harness.ts",
@@ -282,8 +282,7 @@
"extensions/**/*.{js,ts,mts,cts}"
],
"excludeFiles": [
"**/*.test.*",
"**/*.spec.*",
"**/*.{test,spec,suite}.*",
"**/__generated__/**",
"**/generated/**",
"**/protocol-gen/**",
@@ -304,8 +303,7 @@
"extensions/**/*.{jsx,tsx}"
],
"excludeFiles": [
"**/*.test.*",
"**/*.spec.*",
"**/*.{test,spec,suite}.*",
"**/__generated__/**",
"**/generated/**",
"**/protocol-gen/**",
@@ -326,8 +324,7 @@
"extensions/**/*.{mjs,cjs}"
],
"excludeFiles": [
"**/*.test.*",
"**/*.spec.*",
"**/*.{test,spec,suite}.*",
"**/__generated__/**",
"**/generated/**",
"**/protocol-gen/**",
@@ -342,14 +339,10 @@
},
{
"files": [
"src/**/*.test.*",
"src/**/*.spec.*",
"ui/src/**/*.test.*",
"ui/src/**/*.spec.*",
"packages/**/*.test.*",
"packages/**/*.spec.*",
"extensions/**/*.test.*",
"extensions/**/*.spec.*"
"src/**/*.{test,spec,suite}.*",
"ui/src/**/*.{test,spec,suite}.*",
"packages/**/*.{test,spec,suite}.*",
"extensions/**/*.{test,spec,suite}.*"
],
"excludeFiles": [
"**/__generated__/**",
+3 -1
View File
@@ -170,9 +170,11 @@ Skills own workflows; root owns hard policy and routing. Product direction and m
## Execution Identity Audit
- Execution identity is opt-in diagnostic provenance, never authorization or enforcement. Unknown facts stay unknown; record ingress or invoker facts only at their authoritative producer. Never infer identity from session keys, `runId`, or routing metadata.
- Frozen ingress identity facts are diagnostic audit input, not session-ownership state. Session provenance must use the current canonical authenticated profile ID and never retain a profile display label; only explicitly enabled execution-identity audit storage may retain its bounded, redacted form.
- Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence.
- Each outer admitted turn owns one immutable `executionId` and `contextId`; `runId` is non-unique correlation. Retries, fallbacks, and recovery reuse the original admission identity. Only byte-identical canonical replay is idempotent.
- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution.
- Decision receipts adapt owner-native durable decisions directly; `execution_decision_facts` is only for boundaries without an owner-native record and must never duplicate approvals. The generic fact store stays dormant until an explicit product-boundary producer exists, and any producer requires an explicit operator retention opt-in; the 30-day retention bound does not authorize default collection. Receipt coverage `enforced` is diagnostic, not authority: emit it only when the owner changed the outcome and the exact context/execution/run tuple validates; otherwise keep it `unknown`.
- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. Admission validates only a recursively owned, enumerable, accessor-free data snapshot constructed from descriptors before schema checks or ordinary property reads; inherited properties are absent and accessors never run. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution.
- Raw identity references are transient worker-message data. Never persist, export, inspect, or log them. Public Plugin SDK ingress must strip private recovery/admission authority, including JavaScript extra and inherited properties.
- Default or disabled collection creates and propagates no identity token and does not create optional storage. Existing-storage maintenance may continue. Reads enforce expiry before projection; missing or expired evidence never proves no run occurred.
- `audit.run.inspect` intentionally uses `operator.read` within one trusted Gateway domain. Reader isolation requires separate domains. Ask before changing this scope, default-off behavior, retained fields, 30-day cutoff, maintenance/row bounds, or schema/protocol contract.
+3 -2
View File
@@ -377,8 +377,9 @@ USER node
# - Override --bind to "lan" (0.0.0.0) and set auth credentials
#
# Built-in probe endpoints for container health checks:
# - GET /healthz (liveness) and GET /readyz (readiness)
# - aliases: /health and /ready
# - GET /healthz (liveness), GET /startupz (startup/traffic admission),
# and GET /readyz (channel-aware readiness)
# - aliases: /health, /startup, and /ready
# For external access from host/ingress, override bind to "lan" and set auth.
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
CMD ["node", "dist/docker-healthcheck.js"]
File diff suppressed because it is too large Load Diff
@@ -842,6 +842,7 @@ class MainViewModel private constructor(
host = config.host,
port = config.port,
tlsEnabled = config.tls,
contextPath = config.contextPath,
)
val targetAlreadyPaired =
prefs.gatewayRegistry.entries.value
@@ -876,6 +877,7 @@ class MainViewModel private constructor(
host = config.host,
port = config.port,
tls = config.tls,
contextPath = config.contextPath,
),
)
@@ -8530,6 +8530,7 @@ internal fun manualGatewayEndpoint(entry: GatewayRegistryEntry): GatewayEndpoint
host = normalizedHost,
port = normalizedPort,
tlsEnabled = entry.tls,
contextPath = entry.contextPath,
)
}
@@ -8545,6 +8546,7 @@ internal fun gatewayRegistryEntry(
host = endpoint.host,
port = endpoint.port,
tls = endpoint.tlsEnabled,
contextPath = endpoint.contextPath,
lastConnectedAtMs = existing?.lastConnectedAtMs ?: 0L,
)
} else {
@@ -12,6 +12,7 @@ data class GatewayEndpoint(
val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
val contextPath: String = "",
) {
companion object {
/** Builds a stable manual endpoint key that survives display-name changes. */
@@ -19,14 +20,65 @@ data class GatewayEndpoint(
host: String,
port: Int,
tlsEnabled: Boolean = false,
): GatewayEndpoint =
GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port",
contextPath: String = "",
): GatewayEndpoint {
val normalizedContextPath = normalizeGatewayContextPath(contextPath)
val stableIdPath = if (normalizedContextPath.isEmpty()) "" else "|$normalizedContextPath"
return GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port$stableIdPath",
name = "$host:$port",
host = host,
port = port,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = null,
contextPath = normalizedContextPath,
)
}
}
}
internal fun normalizeGatewayContextPath(value: String?): String {
val path = value.orEmpty()
if (path.isEmpty() || path == "/") return ""
val prefixed = if (path.startsWith('/')) path else "/$path"
val encoded = StringBuilder(prefixed.length)
var index = 0
while (index < prefixed.length) {
if (
prefixed[index] == '%' &&
index + 2 < prefixed.length &&
prefixed[index + 1].isAsciiHexDigit() &&
prefixed[index + 2].isAsciiHexDigit()
) {
encoded.append(prefixed, index, index + 3)
index += 3
continue
}
val codePoint = prefixed.codePointAt(index)
if (isGatewayPathCodePoint(codePoint)) {
encoded.appendCodePoint(codePoint)
} else {
for (byte in String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)) {
val value = byte.toInt() and 0xff
encoded.append('%')
encoded.append(HEX_DIGITS[value ushr 4])
encoded.append(HEX_DIGITS[value and 0x0f])
}
}
index += Character.charCount(codePoint)
}
return encoded.toString()
}
private const val HEX_DIGITS = "0123456789ABCDEF"
private fun Char.isAsciiHexDigit(): Boolean = this in '0'..'9' || this in 'A'..'F' || this in 'a'..'f'
private fun isGatewayPathCodePoint(value: Int): Boolean =
value == '/'.code ||
value == ':'.code ||
value == '@'.code ||
value in 'A'.code..'Z'.code ||
value in 'a'.code..'z'.code ||
value in '0'.code..'9'.code ||
(value <= 0x7f && value.toChar() in "-._~!$&'()*+,;=")
@@ -166,6 +166,13 @@ data class WorkerDesktopLaunchResult(
val status: String = "ready",
)
@Serializable
data class ProjectsListResult(
val projects: List<ProjectsListResultProjectsItem>,
val recents: List<JsonElement>? = null,
val observedProjects: List<ProjectsListResultObservedProjectsItem>? = null,
)
@Serializable
data class GatewayEventFrameStateVersion(
val presence: Long,
@@ -178,6 +185,30 @@ data class GatewayNodeInvokeResultParamsError(
val message: String? = null,
)
@Serializable
data class ProjectsListResultProjectsItem(
val id: String,
val displayName: String,
val repoRoot: String? = null,
val originUrl: String? = null,
val source: String,
val agentId: String? = null,
)
@Serializable
data class ProjectsListResultObservedProjectsItem(
val name: String,
val originUrl: String? = null,
val checkouts: List<ProjectsListResultObservedProjectsItemCheckoutsItem>,
val lastUsedAt: Double,
)
@Serializable
data class ProjectsListResultObservedProjectsItemCheckoutsItem(
val runnerId: String,
val path: String,
)
enum class GatewayMethod(
val rawValue: String,
) {
@@ -541,6 +572,8 @@ enum class GatewayMethod(
UsersPrefsSet("users.prefs.set"),
ProjectsAdd("projects.add"),
ProjectsSearchRemote("projects.searchRemote"),
DesktopObserve("desktop.observe"),
DesktopLaunch("desktop.launch"),
}
enum class GatewayEvent(
@@ -28,6 +28,7 @@ data class GatewayRegistryEntry(
val port: Int? = null,
val tls: Boolean = true,
val lastConnectedAtMs: Long = 0L,
val contextPath: String = "",
)
@Serializable
@@ -83,6 +84,7 @@ class GatewayRegistryStore(
stableId = stableId,
name = entry.name.trim().ifEmpty { stableId },
host = entry.host?.trim()?.takeIf { it.isNotEmpty() },
contextPath = normalizeGatewayContextPath(entry.contextPath),
lastConnectedAtMs =
if (entry.lastConnectedAtMs == 0L) {
existing?.lastConnectedAtMs ?: 0L
@@ -2175,9 +2175,11 @@ internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,
useTls: Boolean,
contextPath: String = "",
): String {
val scheme = if (useTls) "wss" else "ws"
return "$scheme://${formatGatewayAuthority(host, port)}"
val path = normalizeGatewayContextPath(contextPath)
return "$scheme://${formatGatewayAuthority(host, port)}$path"
}
/** Builds one gateway upgrade request without exposing proxy credentials to cleartext routes. */
@@ -2186,7 +2188,15 @@ internal fun buildGatewayWebSocketUpgradeRequest(
tls: GatewayTlsParams?,
customHeadersProvider: ((stableId: String) -> Map<String, String>)?,
): Request {
val request = Request.Builder().url(buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null))
val request =
Request.Builder().url(
buildGatewayWebSocketUrl(
endpoint.host,
endpoint.port,
tls != null,
endpoint.contextPath,
),
)
if (tls == null) return request.build()
// Read at connect time so edits apply on the next reconnect. Headers may contain service tokens
@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import ai.openclaw.app.gateway.normalizeGatewayContextPath
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
@@ -20,6 +21,7 @@ internal data class GatewayEndpointConfig(
val port: Int,
val tls: Boolean,
val displayUrl: String,
val contextPath: String = "",
)
/** Effective transport shown by manual gateway forms before they connect. */
@@ -45,6 +47,7 @@ internal data class GatewayConnectConfig(
val bootstrapToken: String,
val token: String,
val password: String,
val contextPath: String = "",
)
/** How a connection attempt may update credentials already owned by the runtime. */
@@ -136,6 +139,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
contextPath = parsed.contextPath,
bootstrapToken = setupBootstrapToken,
token = sharedToken,
password = sharedPassword,
@@ -151,6 +155,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
contextPath = parsed.contextPath,
bootstrapToken = bootstrapToken,
token = token,
password = password,
@@ -206,7 +211,11 @@ internal fun resolveGatewayConnectPlan(
return GatewayConnectPlan(config, action)
}
private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = host.equals(config.host, ignoreCase = true) && port == config.port && tls == config.tls
private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean =
host.equals(config.host, ignoreCase = true) &&
port == config.port &&
tls == config.tls &&
contextPath == config.contextPath
/** Parses an endpoint string and returns only the valid connection config. */
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
@@ -221,6 +230,9 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
runCatching { URI(normalized) }
.getOrNull()
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
}
val host =
uri.host
?.trim()
@@ -247,22 +259,31 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
val defaultPort = if (tls) 443 else 18789
val displayPort = if (tls) 443 else 80
val port = gatewayPort(uri.port, defaultPort) ?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val contextPath = normalizeGatewayContextPath(uri.rawPath)
val displayPath = contextPath
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =
if (port == displayPort && defaultPort == displayPort) {
"${if (tls) "https" else "http"}://$displayHost"
"${if (tls) "https" else "http"}://$displayHost$displayPath"
} else {
"${if (tls) "https" else "http"}://$displayHost:$port"
"${if (tls) "https" else "http"}://$displayHost:$port$displayPath"
}
return GatewayEndpointParseResult(
config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl),
config =
GatewayEndpointConfig(
host = host,
port = port,
tls = tls,
displayUrl = displayUrl,
contextPath = contextPath,
),
)
}
/** Decodes base64url setup-code payloads produced by gateway onboarding. */
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val trimmed = rawInput.trim()
val trimmed = stripPairingSetupUrlPrefix(rawInput.trim())
if (trimmed.isEmpty()) return null
val padded =
@@ -512,3 +533,12 @@ private fun jsonField(
val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
return value.ifEmpty { null }
}
private const val PAIRING_SETUP_URL_PREFIX = "oc-pair://"
private fun stripPairingSetupUrlPrefix(raw: String): String =
if (raw.startsWith(PAIRING_SETUP_URL_PREFIX, ignoreCase = true)) {
raw.substring(PAIRING_SETUP_URL_PREFIX.length)
} else {
raw
}
@@ -46,6 +46,14 @@ class GatewayProtocolGeneratedTest {
assertEquals(5_000L, decoded.timeoutMs)
}
@Test
fun projectsListResultDecodesALegacyProjectsOnlyPayload() {
val decoded = json.decodeFromString(ProjectsListResult.serializer(), """{"projects":[]}""")
assertTrue(decoded.projects.isEmpty())
assertNull(decoded.observedProjects)
}
@Test
fun generatedGatewayCatalogsAreCompleteAndUnique() {
val methods = GatewayMethod.entries.map { it.rawValue }
@@ -72,6 +72,38 @@ class GatewayRegistryStoreTest {
assertEquals(first, second)
}
@Test
fun roundTripPreservesManualGatewayContextPath() {
val (prefs, securePrefs) = freshPrefs()
val endpoint =
GatewayEndpoint.manual(
host = "gateway.example",
port = 443,
tlsEnabled = true,
contextPath = "/openclaw-gw",
)
prefs.gatewayRegistry.upsert(
GatewayRegistryEntry(
stableId = endpoint.stableId,
kind = GatewayRegistryEntryKind.MANUAL,
name = endpoint.name,
host = endpoint.host,
port = endpoint.port,
tls = endpoint.tlsEnabled,
contextPath = endpoint.contextPath,
),
)
val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
assertEquals(
"/openclaw-gw",
restored.entries.value
.single()
.contextPath,
)
}
@Test
fun failedRemovalCommitDoesNotPublishCandidateState() {
val (_, securePrefs) = freshPrefs()
@@ -21,6 +21,37 @@ class GatewaySessionInvokeTimeoutTest {
assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("[::1]", 443, useTls = true))
}
@Test
fun buildGatewayWebSocketUrl_preservesAndEncodesContextPath() {
assertEquals(
"wss://gateway.example:443/openclaw%20gateway",
buildGatewayWebSocketUrl(
host = "gateway.example",
port = 443,
useTls = true,
contextPath = "/openclaw%20gateway",
),
)
assertEquals(
"wss://gateway.example:443/openclaw%2Fgateway",
buildGatewayWebSocketUrl(
host = "gateway.example",
port = 443,
useTls = true,
contextPath = "/openclaw%2Fgateway",
),
)
assertEquals(
"wss://gateway.example:443//openclaw",
buildGatewayWebSocketUrl(
host = "gateway.example",
port = 443,
useTls = true,
contextPath = "//openclaw",
),
)
}
@Test
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))
@@ -109,6 +109,30 @@ class GatewayConfigResolverTest {
)
}
@Test
fun parseGatewayEndpointPreservesDecodedContextPath() {
val parsed = parseGatewayEndpoint("wss://gateway.example/openclaw%20gateway")
assertEquals("/openclaw%20gateway", parsed?.contextPath)
assertEquals("https://gateway.example/openclaw%20gateway", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointPreservesEscapedPathDelimiter() {
val parsed = parseGatewayEndpoint("wss://gateway.example/openclaw%2Fgateway")
assertEquals("/openclaw%2Fgateway", parsed?.contextPath)
assertEquals("https://gateway.example/openclaw%2Fgateway", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointPreservesRepeatedLeadingPathSlashes() {
val parsed = parseGatewayEndpoint("wss://gateway.example//openclaw")
assertEquals("//openclaw", parsed?.contextPath)
assertEquals("https://gateway.example//openclaw", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() {
assertEndpointRejected("ws://gateway.example")
@@ -375,6 +399,22 @@ class GatewayConfigResolverTest {
assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultRejectsCredentialsQueriesAndFragments() {
val urls =
listOf(
"wss://user@gateway.example/openclaw-gw",
"wss://gateway.example/openclaw-gw?mode=setup",
"wss://gateway.example/openclaw-gw#fragment",
)
for (url in urls) {
val parsed = parseGatewayEndpointResult(url)
assertNull(url, parsed.config)
assertEquals(url, GatewayEndpointValidationError.INVALID_URL, parsed.error)
}
}
@Test
fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
@@ -420,6 +460,17 @@ class GatewayConfigResolverTest {
assertNull(decoded?.password)
}
@Test
fun decodeGatewaySetupCodeAcceptsPairingUrlWrapper() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"Bootstrap-AbC123"}""")
val decoded = decodeGatewaySetupCode("oc-pair://$setupCode")
assertEquals("wss://gateway.example:18789", decoded?.url)
assertEquals("Bootstrap-AbC123", decoded?.bootstrapToken)
}
@Test
fun manualTokenDetectsSetupCodePayloads() {
val setupCode =
@@ -450,6 +501,20 @@ class GatewayConfigResolverTest {
assertEquals("", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigPreservesSetupContextPath() {
val resolved =
resolveConnectConfigFixture(
useSetupCode = true,
setupCode = setupCode("wss://gateway.example/openclaw-gw"),
)
assertEquals("gateway.example", resolved?.host)
assertEquals(443, resolved?.port)
assertEquals(true, resolved?.tls)
assertEquals("/openclaw-gw", resolved?.contextPath)
}
@Test
fun resolveGatewayConnectConfigAcceptsQrJsonSetupCodePayload() {
val setupCode = setupCode("wss://gateway.example:18789")
@@ -630,9 +695,9 @@ class GatewayConfigResolverTest {
val cases =
listOf(
"ws://gateway.local:18790" to true,
"http://192.168.1.20:18790/gateway?mode=manual" to true,
"http://192.168.1.20:18790/gateway" to true,
"wss://gateway.example:8443" to false,
"https://gateway.example/gateway?mode=manual" to false,
"https://gateway.example/gateway" to false,
"HTTPS://gateway.example:443" to false,
"WS://GATEWAY.LOCAL.:18790" to true,
"ws://[::1]:18790" to true,
@@ -763,6 +828,9 @@ class GatewayConfigResolverTest {
"gateway.local:18789#evil.example",
"[::1]:18789?redirect=evil.example",
"[::1]:18789#evil.example",
"wss://user@gateway.example/openclaw-gw",
"wss://gateway.example/openclaw-gw?mode=manual",
"wss://gateway.example/openclaw-gw#fragment",
)
for (hostInput in hosts) {
@@ -18,7 +18,8 @@ struct SessionDashboardScreen: View {
authScript: AuthenticatedControlUI.authUserScript(
config: config,
pageURL: url,
storedOperatorToken: storedOperatorToken))
storedOperatorToken: storedOperatorToken),
tls: config?.tls)
.id(AuthenticatedControlUI.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken))
@@ -56,6 +56,7 @@ struct SettingsProTab: View {
@State var gatewayPassword = ""
@State var gatewayCredentialFieldStableID: String?
@State var manualGatewayPortText = ""
@State var manualGatewayContextPath: String?
@State var setupStatusText: String?
@State var setupAttemptID: UUID?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@@ -214,6 +214,15 @@ extension SettingsProTab {
func syncSettingsState() {
self.refreshGatewayRegistry()
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
let activeManual = GatewaySettingsStore.activeGatewayEntry()
if activeManual?.kind == .manual,
activeManual?.host?.caseInsensitiveCompare(self.manualGatewayHost) == .orderedSame,
activeManual?.port == self.manualGatewayPort
{
self.manualGatewayContextPath = activeManual?.contextPath
} else {
self.manualGatewayContextPath = nil
}
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
self.refreshLocationPermissionSummary()
@@ -371,6 +380,7 @@ extension SettingsProTab {
self.manualGatewayPort = link.port
self.manualGatewayPortText = String(link.port)
self.manualGatewayTLS = link.tls
self.manualGatewayContextPath = link.contextPath
let instanceId = GatewaySettingsStore.currentInstanceID()
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
@@ -543,6 +553,7 @@ extension SettingsProTab {
host: host,
port: port,
useTLS: self.manualGatewayTLS,
contextPath: self.manualGatewayContextPath,
authOverride: authOverride)
// The controller now owns this attempt's immutable override. A later retry must reload
// durable state so a spent bootstrap token cannot be resurrected from the live view.
@@ -830,7 +841,8 @@ extension SettingsProTab {
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
port: port,
contextPath: self.manualGatewayContextPath)
}
var gatewayCredentialTargetStableID: String? {
@@ -879,6 +891,7 @@ extension SettingsProTab {
get: { self.manualGatewayHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualGatewayContextPath = nil
self.manualGatewayHost = value
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
@@ -968,6 +981,7 @@ extension SettingsProTab {
get: { self.manualGatewayPortText },
set: { newValue in
let previousStableID = self.currentManualGatewayStableID
self.manualGatewayContextPath = nil
let filtered = newValue.filter(\.isNumber)
self.manualGatewayPortText = filtered
self.manualGatewayPort = Int(filtered) ?? 0
@@ -1334,6 +1334,7 @@ extension SettingsProTab {
get: { self.manualGatewayTransport.effectiveTLS },
set: { enabled in
guard !self.manualGatewayTransport.requiresTLS else { return }
self.manualGatewayContextPath = nil
self.manualGatewayTLS = enabled
})
}
@@ -16,13 +16,17 @@ struct GatewayManualTransportPresentation: Equatable {
}
extension GatewayConnectionController {
func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = port
return components.url
func buildGatewayURL(
host: String,
port: Int,
useTLS: Bool,
contextPath: String? = nil) -> URL?
{
GatewayConnectEndpoint(
host: host,
port: port,
tls: useTLS,
contextPath: contextPath).websocketURL
}
func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
@@ -51,8 +55,8 @@ extension GatewayConnectionController {
helperText: helperText)
}
func manualStableID(host: String, port: Int) -> String {
ManualAuthOverride.manualStableID(host: host, port: port)
func manualStableID(host: String, port: Int, contextPath: String? = nil) -> String {
ManualAuthOverride.manualStableID(host: host, port: port, contextPath: contextPath)
}
func makeConnectOptions(
@@ -189,8 +189,14 @@ extension GatewayConnectionController {
suppressStoredDeviceAuth: pendingOverride.suppressStoredDeviceAuth)
}
static func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
static func manualStableID(host: String, port: Int, contextPath: String? = nil) -> String {
let endpoint = GatewayConnectEndpoint(
host: host,
port: port,
tls: true,
contextPath: contextPath)
let pathSuffix = endpoint.contextPath.map { "|\($0)" } ?? ""
return "manual|\(host.lowercased())|\(port)\(pathSuffix)"
}
static func setupAuth(from link: GatewayConnectDeepLink) -> SetupAuth {
@@ -198,7 +204,10 @@ extension GatewayConnectionController {
token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
bootstrapToken: link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
password: link.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
targetStableID: self.manualStableID(host: link.host, port: link.port))
targetStableID: self.manualStableID(
host: link.host,
port: link.port,
contextPath: link.contextPath))
}
}
}
@@ -390,6 +390,7 @@ final class GatewayConnectionController {
host: String,
port: Int,
useTLS: Bool,
contextPath: String? = nil,
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
{
@@ -399,7 +400,10 @@ final class GatewayConnectionController {
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = Self.resolvedManualPort(host: host, port: port)
else { return }
let stableID = self.manualStableID(host: host, port: resolvedPort)
let stableID = self.manualStableID(
host: host,
port: resolvedPort,
contextPath: contextPath)
self.pendingConnectionStableID = stableID
await self.waitForPendingForgetCleanup(stableID: stableID)
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
@@ -422,7 +426,12 @@ final class GatewayConnectionController {
: nil)
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if resolvedUseTLS, stored == nil {
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
guard let url = self.buildGatewayURL(
host: host,
port: resolvedPort,
useTLS: true,
contextPath: contextPath)
else { return }
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
guard let probeResult = await self.probeTLSFingerprint(
host: host,
@@ -465,7 +474,8 @@ final class GatewayConnectionController {
guard let url = self.buildGatewayURL(
host: host,
port: resolvedPort,
useTLS: tlsParams?.required == true)
useTLS: tlsParams?.required == true,
contextPath: contextPath)
else { return }
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
@@ -474,6 +484,7 @@ final class GatewayConnectionController {
host: host,
port: resolvedPort,
useTLS: resolvedUseTLS && tlsParams != nil,
contextPath: contextPath,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else { return }
self.didAutoConnect = true
@@ -496,7 +507,12 @@ final class GatewayConnectionController {
switch active.kind {
case .manual:
guard let host = active.host, let port = active.port else { return }
await self.connectManual(host: host, port: port, useTLS: active.useTLS, forceReconnect: true)
await self.connectManual(
host: host,
port: port,
useTLS: active.useTLS,
contextPath: active.contextPath,
forceReconnect: true)
case .discovered:
if let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, active.stableID)
@@ -506,7 +522,12 @@ final class GatewayConnectionController {
}
guard let fallback = self.mostRecentlyConnectedManualGateway() else { return }
guard let host = fallback.host, let port = fallback.port else { return }
await self.connectManual(host: host, port: port, useTLS: fallback.useTLS, forceReconnect: true)
await self.connectManual(
host: host,
port: port,
useTLS: fallback.useTLS,
contextPath: fallback.contextPath,
forceReconnect: true)
}
}
@@ -533,6 +554,7 @@ final class GatewayConnectionController {
host: host,
port: port,
useTLS: entry.useTLS,
contextPath: entry.contextPath,
forceReconnect: true)
return nil
case .discovered:
@@ -815,6 +837,9 @@ final class GatewayConnectionController {
host: pending.isManual ? prompt.host : nil,
port: pending.isManual ? prompt.port : nil,
useTLS: true,
contextPath: pending.isManual
? URLComponents(url: pending.url, resolvingAgainstBaseURL: false)?.percentEncodedPath
: nil,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else {
_ = GatewayTLSStore.clearFingerprint(stableID: pending.stableID)
@@ -1056,7 +1081,8 @@ extension GatewayConnectionController {
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
useTLS: tlsParams?.required == true,
contextPath: active.contextPath)
else { return false }
let credentials = GatewaySettingsStore.loadGatewayCredentials(
@@ -1261,7 +1287,8 @@ extension GatewayConnectionController {
let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tls?.required == true)
useTLS: tls?.required == true,
contextPath: entry.contextPath)
else { return nil }
route = (url, tls)
case .discovered:
@@ -70,8 +70,29 @@ enum GatewaySettingsStore {
var host: String?
var port: Int?
var useTLS: Bool
var contextPath: String?
var lastConnectedAtMs: Int?
init(
stableID: String,
kind: Kind,
name: String,
host: String?,
port: Int?,
useTLS: Bool,
contextPath: String? = nil,
lastConnectedAtMs: Int?)
{
self.stableID = stableID
self.kind = kind
self.name = name
self.host = host
self.port = port
self.useTLS = useTLS
self.contextPath = contextPath
self.lastConnectedAtMs = lastConnectedAtMs
}
var id: GatewayStableIdentifier.Key {
GatewayStableIdentifier.Key(self.stableID)
}
@@ -83,6 +104,7 @@ enum GatewaySettingsStore {
lhs.host == rhs.host &&
lhs.port == rhs.port &&
lhs.useTLS == rhs.useTLS &&
lhs.contextPath == rhs.contextPath &&
lhs.lastConnectedAtMs == rhs.lastConnectedAtMs
}
}
@@ -628,6 +650,11 @@ enum GatewaySettingsStore {
if entry.kind == .manual {
let host = entry.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty, let port = entry.port, (1...65535).contains(port) else { return nil }
let contextPath = GatewayConnectEndpoint(
host: host,
port: port,
tls: entry.useTLS,
contextPath: entry.contextPath).contextPath
return GatewayRegistryEntry(
stableID: stableID,
kind: .manual,
@@ -635,6 +662,7 @@ enum GatewaySettingsStore {
host: host,
port: port,
useTLS: entry.useTLS,
contextPath: contextPath,
lastConnectedAtMs: entry.lastConnectedAtMs)
}
return GatewayRegistryEntry(
@@ -27,6 +27,7 @@ struct OnboardingWizardView: View {
@State private var manualPort: Int = 18789
@State private var manualPortText: String = "18789"
@State private var manualTLS: Bool = true
@State private var manualContextPath: String?
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var gatewayCredentialFieldStableID: String?
@@ -743,6 +744,7 @@ extension OnboardingWizardView {
get: { self.manualTransport.effectiveTLS },
set: { enabled in
guard !self.manualTransport.requiresTLS else { return }
self.manualContextPath = nil
self.manualTLS = enabled
})
}
@@ -979,6 +981,7 @@ extension OnboardingWizardView {
self.manualPort = link.port
self.manualPortText = String(link.port)
self.manualTLS = link.tls
self.manualContextPath = link.contextPath
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
if setupAuth.hasBootstrapToken {
@@ -1221,6 +1224,7 @@ extension OnboardingWizardView {
self.manualHost = host
self.manualPort = port
self.manualTLS = active.useTLS
self.manualContextPath = active.contextPath
} else {
self.manualHost = "openclaw.local"
self.manualPort = 18789
@@ -1280,7 +1284,8 @@ extension OnboardingWizardView {
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
port: port,
contextPath: self.manualContextPath)
}
private var gatewayCredentialTargetStableID: String? {
@@ -1313,6 +1318,7 @@ extension OnboardingWizardView {
get: { self.manualHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualContextPath = nil
self.manualHost = value
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
@@ -1327,6 +1333,7 @@ extension OnboardingWizardView {
get: { self.manualPortText },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualContextPath = nil
let digits = value.filter(\.isNumber)
self.manualPortText = digits
self.manualPort = min(Int(digits) ?? 0, 65535)
@@ -1420,6 +1427,7 @@ extension OnboardingWizardView {
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let previousStableID = self.currentManualGatewayStableID
self.manualContextPath = nil
defer {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
@@ -1502,6 +1510,7 @@ extension OnboardingWizardView {
host: host,
port: port,
useTLS: self.manualTLS,
contextPath: self.manualContextPath,
authOverride: authOverride,
forceReconnect: forceReconnect)
// The controller now owns this attempt's immutable override. A later retry must reload
@@ -30,7 +30,8 @@ struct TerminalHubScreen: View {
url: url,
authScript: Self.terminalAuthUserScript(
config: config,
storedOperatorToken: storedOperatorToken))
storedOperatorToken: storedOperatorToken),
tls: config?.tls)
// Recreate the web view only when the connection inputs
// change; SwiftUI update passes must not restart live shells.
.id(Self.webContentIdentity(
@@ -93,6 +93,10 @@ enum AuthenticatedControlUI {
static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int {
var hasher = Hasher()
hasher.combine(config?.url)
hasher.combine(config?.tls?.required)
hasher.combine(config?.tls?.expectedFingerprint)
hasher.combine(config?.tls?.allowTOFU)
hasher.combine(config?.tls?.storeKey)
hasher.combine(config?.token)
hasher.combine(config?.password)
hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines))
@@ -104,13 +108,7 @@ enum AuthenticatedControlUI {
}
private static func originString(for url: URL) -> String {
guard let scheme = url.scheme, let host = url.host else { return "" }
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
var origin = "\(scheme)://\(hostPart)"
if let port = url.port {
origin += ":\(port)"
}
return origin
GatewayTLSAuthority(url: url)?.serialized ?? ""
}
private static func jsStringLiteral(_ value: String) -> String {
@@ -134,12 +132,89 @@ enum AuthenticatedControlUI {
}
}
@MainActor
final class AuthenticatedControlUIWebViewCoordinator: NSObject, WKNavigationDelegate {
private let expectedOrigin: GatewayTLSAuthority?
private let tls: GatewayTLSParams?
init(url: URL, tls: GatewayTLSParams?) {
self.expectedOrigin = GatewayTLSAuthority(url: url)
self.tls = tls
}
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
decisionHandler(self.allowsNavigation(
to: navigationAction.request.url,
isMainFrame: navigationAction.targetFrame?.isMainFrame) ? .allow : .cancel)
}
func webView(
_: WKWebView,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping @MainActor @Sendable (
URLSession.AuthChallengeDisposition,
URLCredential?) -> Void)
{
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let tls
else {
completionHandler(.performDefaultHandling, nil)
return
}
guard self.matchesExpectedAuthority(
host: challenge.protectionSpace.host,
port: challenge.protectionSpace.port)
else {
// Cross-origin main-frame loads are already cancelled by navigation policy.
// Other authorities may belong to embedded content and do not inherit the Gateway pin.
completionHandler(.performDefaultHandling, nil)
return
}
guard let trust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
switch GatewayTLSServerTrust.evaluate(
trust: trust,
host: challenge.protectionSpace.host,
port: challenge.protectionSpace.port,
params: tls)
{
case .accept:
completionHandler(.useCredential, URLCredential(trust: trust))
case .reject:
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
func allowsNavigation(to candidateURL: URL?, isMainFrame: Bool?) -> Bool {
if isMainFrame == false {
return true
}
guard isMainFrame == true, let candidateURL else { return false }
return GatewayTLSAuthority(url: candidateURL) == self.expectedOrigin
}
func matchesExpectedAuthority(host: String, port: Int) -> Bool {
self.expectedOrigin?.matches(host: host, port: port) == true
}
}
/// Ephemeral, script-hardened WKWebView for a self-contained Control UI page.
struct AuthenticatedControlUIWebView: UIViewRepresentable {
let url: URL
let authScript: String?
let tls: GatewayTLSParams?
func makeUIView(context _: Context) -> WKWebView {
func makeCoordinator() -> AuthenticatedControlUIWebViewCoordinator {
AuthenticatedControlUIWebViewCoordinator(url: self.url, tls: self.tls)
}
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
@@ -152,6 +227,7 @@ struct AuthenticatedControlUIWebView: UIViewRepresentable {
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.isOpaque = true
webView.backgroundColor = .black
webView.allowsLinkPreview = false
@@ -173,7 +249,11 @@ struct AuthenticatedControlUIWebView: UIViewRepresentable {
// Connection changes recreate the view via `.id`; unrelated SwiftUI passes must not reload it.
}
static func dismantleUIView(_ webView: WKWebView, coordinator _: Void) {
static func dismantleUIView(
_ webView: WKWebView,
coordinator _: AuthenticatedControlUIWebViewCoordinator)
{
webView.stopLoading()
webView.navigationDelegate = nil
}
}
@@ -7,6 +7,10 @@ import UIKit
@testable import OpenClaw
@testable import OpenClawKit
private func percentEncodedPath(of url: URL?) -> String? {
url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.percentEncodedPath }
}
@discardableResult
private func saveActiveManualGateway(
host: String,
@@ -924,6 +928,46 @@ private func waitUntil(
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == setupAuth.targetStableID)
}
@Test @MainActor func `setup context path survives registry reconnect`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let instanceID = "ios-context-path-\(UUID().uuidString)"
let temporaryState = try TemporaryOpenClawState(instanceID: instanceID)
defer { temporaryState.restore() }
let link = GatewayConnectDeepLink(
host: "192.168.1.41",
port: 18789,
tls: false,
contextPath: "/openclaw%2Fgateway",
bootstrapToken: nil,
token: nil,
password: nil)
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
await controller.connectManual(
host: link.host,
port: link.port,
useTLS: link.tls,
contextPath: link.contextPath,
authOverride: setupAuth.manualAuthOverride)
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == "/openclaw%2Fgateway")
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == setupAuth.targetStableID)
let stored = try #require(GatewaySettingsStore.activeGatewayEntry())
#expect(stored.contextPath == "/openclaw%2Fgateway")
appModel.disconnectGateway()
await controller.connectActiveGateway()
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == "/openclaw%2Fgateway")
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stored.stableID)
}
@Test @MainActor func `legacy auth preserves proven relay credentials and otherwise requires full re-pair`() throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -2178,6 +2222,35 @@ private func waitUntil(
#expect(!GatewaySettingsStore.loadGatewayRegistry().entries.contains { $0.stableID == stableID })
}
@Test @MainActor func `manual trust handoff persists its context path`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "context-path-trust.example.com"
let contextPath = "/openclaw-gateway"
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: 443,
contextPath: contextPath)
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = makeTLSProbeController(appModel: appModel, fingerprint: "context-path-fingerprint")
await controller.connectManual(
host: host,
port: 443,
useTLS: true,
contextPath: contextPath)
#expect(controller.pendingTrustPrompt?.stableID == stableID)
await controller.acceptPendingTrustPrompt()
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == contextPath)
let stored = try #require(GatewaySettingsStore.activeGatewayEntry())
#expect(stored.contextPath == contextPath)
}
@Test @MainActor func `forget gateway preserves another gateway pending trust handoff`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -695,6 +695,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
host: "z.example.com",
port: 443,
useTLS: true,
contextPath: "/openclaw-gateway",
lastConnectedAtMs: nil)
let gatewayA = GatewaySettingsStore.GatewayRegistryEntry(
stableID: "bonjour|alpha",
@@ -716,6 +717,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
#expect(registry.connectedStableIDs == [gatewayB.stableID])
#expect(GatewaySettingsStore.connectedGatewayEntries().map(\.stableID) == [gatewayB.stableID])
#expect(registry.entries.last?.lastConnectedAtMs == 1234)
#expect(registry.entries.last?.contextPath == "/openclaw-gateway")
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayA))
#expect(KeychainStore.loadString(service: gatewayService, account: "gateway-registry") == firstJSON)
+90 -1
View File
@@ -9,13 +9,14 @@ struct TerminalHubScreenTests {
url: URL,
token: String? = nil,
password: String? = nil,
tls: GatewayTLSParams? = nil,
allowStoredDeviceAuth: Bool = true,
deviceAuthGatewayID: String? = nil) -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
stableID: "manual|gateway.example.com|443",
tls: nil,
tls: tls,
token: token,
bootstrapToken: nil,
password: password,
@@ -69,6 +70,17 @@ struct TerminalHubScreenTests {
#expect(script?.contains("\"gatewayUrl\":\"wss:\\/\\/gateway.example.com:8443\"") == true)
}
@Test func `auth user script canonicalizes an explicit default port`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:443")),
token: "secret-token")
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
#expect(script?.contains("\"https:\\/\\/gateway.example.com\"") == true)
#expect(script?.contains("\"https:\\/\\/gateway.example.com:443\"") == false)
}
@Test func `auth user script falls back to stored operator token`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443")),
@@ -139,6 +151,83 @@ struct TerminalHubScreenTests {
TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-b"))
}
@Test func `web content identity changes with the accepted TLS pin`() throws {
let url = try #require(URL(string: "wss://gateway.example.com"))
let first = Self.makeConfig(
url: url,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "first",
allowTOFU: false,
storeKey: "gateway"))
let second = Self.makeConfig(
url: url,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "second",
allowTOFU: false,
storeKey: "gateway"))
#expect(
TerminalHubScreen.webContentIdentity(config: first, storedOperatorToken: nil) !=
TerminalHubScreen.webContentIdentity(config: second, storedOperatorToken: nil))
}
@Test func `authenticated Control UI origin rejects authority changes`() throws {
let controlURL = try #require(URL(string: "https://gateway.example.com/control"))
let defaultPortURL = try #require(URL(string: "https://GATEWAY.example.com:443/chat"))
let alternatePortURL = try #require(URL(string: "https://gateway.example.com:8443/chat"))
let alternateHostURL = try #require(URL(string: "https://replacement.example.com/chat"))
let insecureURL = try #require(URL(string: "http://gateway.example.com/chat"))
let expected = try #require(GatewayTLSAuthority(url: controlURL))
#expect(expected == GatewayTLSAuthority(url: defaultPortURL))
#expect(expected != GatewayTLSAuthority(url: alternatePortURL))
#expect(expected != GatewayTLSAuthority(url: alternateHostURL))
#expect(expected != GatewayTLSAuthority(url: insecureURL))
}
@Test func `authenticated Control UI canonicalizes IPv6 authorities`() throws {
let controlURL = try #require(URL(string: "https://[2001:db8::1]:8443/control"))
let expected = try #require(GatewayTLSAuthority(url: controlURL))
#expect(expected.serialized == "https://[2001:db8::1]:8443")
#expect(expected.matches(host: "2001:DB8::1", port: 8443))
#expect(expected.matches(host: "[2001:db8::1]", port: 8443))
#expect(!expected.matches(host: "2001:db8::2", port: 8443))
#expect(!expected.matches(host: "2001:db8::1", port: 443))
}
@Test func `authenticated Control UI navigation keeps the main frame on its origin`() throws {
let controlURL = try #require(URL(string: "https://gateway.example.com/control"))
let sameOriginURL = try #require(URL(string: "https://gateway.example.com/chat?session=main"))
let alternateHostURL = try #require(URL(string: "https://replacement.example.com/chat"))
let alternatePortURL = try #require(URL(string: "https://gateway.example.com:8443/chat"))
let embeddedURL = try #require(URL(string: "https://discussion.example.com/embed/thread/a/b"))
let unknownFrameURL = try #require(URL(string: "https://gateway.example.com/chat"))
let coordinator = try AuthenticatedControlUIWebViewCoordinator(
url: controlURL,
tls: nil)
#expect(coordinator.allowsNavigation(to: sameOriginURL, isMainFrame: true))
#expect(!coordinator.allowsNavigation(to: alternateHostURL, isMainFrame: true))
#expect(!coordinator.allowsNavigation(to: alternatePortURL, isMainFrame: true))
#expect(coordinator.allowsNavigation(to: embeddedURL, isMainFrame: false))
#expect(!coordinator.allowsNavigation(to: unknownFrameURL, isMainFrame: nil))
}
@Test func `authenticated Control UI TLS authority uses the normalized page authority`() throws {
let controlURL = try #require(URL(string: "https://Gateway.Example.com/control"))
let coordinator = try AuthenticatedControlUIWebViewCoordinator(
url: controlURL,
tls: nil)
#expect(coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 0))
#expect(coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 443))
#expect(!coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 8443))
#expect(!coordinator.matchesExpectedAuthority(host: "replacement.example.com", port: 443))
}
@Test func `auth user script is omitted without credentials`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")), token: " ")
@@ -69,10 +69,7 @@ extension DashboardWindowController {
}
static func isExpectedTLSAuthority(host: String, port: Int, dashboardURL: URL) -> Bool {
let expectedHost = dashboardURL.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let challengedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let expectedPort = dashboardURL.port ?? (dashboardURL.scheme?.lowercased() == "https" ? 443 : 80)
return expectedHost?.isEmpty == false && challengedHost == expectedHost && port == expectedPort
GatewayTLSAuthority(url: dashboardURL)?.matches(host: host, port: port) == true
}
static func gatewaysRequest(from body: Any) -> DashboardGatewaysRequest? {
@@ -1,5 +1,17 @@
import AppKit
import Foundation
import Observation
import OpenClawProtocol
import OSLog
private let gatewayConnectivityLogger = Logger(
subsystem: "ai.openclaw",
category: "gateway.connectivity")
private struct GatewaySleepPrepareResponse: Decodable {
let status: String
let suspensionId: String?
}
@MainActor
@Observable
@@ -7,8 +19,10 @@ final class GatewayConnectivityCoordinator {
static let shared = GatewayConnectivityCoordinator()
private var endpointTask: Task<Void, Never>?
private var workspaceObservers: [NSObjectProtocol] = []
private var lastResolvedURL: URL?
private var lastRouteRevision: UInt64?
@ObservationIgnored private var sleepCycleController: GatewaySleepCycleController?
private(set) var endpointState: GatewayEndpointState?
private(set) var resolvedURL: URL?
@@ -16,11 +30,42 @@ final class GatewayConnectivityCoordinator {
private(set) var resolvedHostLabel: String?
private init() {
self.sleepCycleController = GatewaySleepCycleController(
requestID: "macos-sleep-\(UUID().uuidString.lowercased())",
// Route tokens and the shared RPC connection both follow the endpoint
// store; a switch between the two reads fails conservatively the wake
// path drops a mismatched lease and lets it self-expire.
currentRoute: { [weak self] in
guard case let .ready(_, url, _, _, _) = self?.endpointState else { return nil }
return url.absoluteString
},
prepare: { requestID in
let data = try await GatewayConnection.shared.request(
method: "gateway.suspend.prepare",
params: ["requestId": AnyCodable(requestID)],
timeoutMs: 3000,
retryTransportFailures: false)
let response = try JSONDecoder().decode(GatewaySleepPrepareResponse.self, from: data)
guard response.status == "ready", let suspensionID = response.suspensionId else {
return .busy
}
return .ready(suspensionID: suspensionID)
},
resume: { suspensionID in
_ = try await GatewayConnection.shared.request(
method: "gateway.suspend.resume",
params: ["suspensionId": AnyCodable(suspensionID)],
timeoutMs: 3000,
retryTransportFailures: false)
},
refresh: { await GatewayEndpointStore.shared.refresh() },
log: { message in gatewayConnectivityLogger.error("\(message, privacy: .public)") })
self.start()
}
func start() {
guard self.endpointTask == nil else { return }
self.registerSleepWakeObservers()
self.endpointTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayEndpointStore.shared.subscribe()
@@ -30,8 +75,32 @@ final class GatewayConnectivityCoordinator {
}
}
private func registerSleepWakeObservers() {
let center = NSWorkspace.shared.notificationCenter
self.workspaceObservers.append(center.addObserver(
forName: NSWorkspace.willSleepNotification,
object: nil,
queue: .main)
{ [weak self] _ in
Task { @MainActor [weak self] in
guard let self, let sleepCycleController = self.sleepCycleController else { return }
await sleepCycleController.willSleep(mode: self.resolvedMode)
}
})
self.workspaceObservers.append(center.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main)
{ [weak self] _ in
Task { @MainActor [weak self] in
guard let self, let sleepCycleController = self.sleepCycleController else { return }
await sleepCycleController.didWake(mode: self.resolvedMode)
}
})
}
var localEndpointHostLabel: String? {
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
guard self.resolvedMode == .local, let url = resolvedURL else { return nil }
return Self.hostLabel(for: url)
}
@@ -58,7 +127,9 @@ final class GatewayConnectivityCoordinator {
private static func hostLabel(for url: URL) -> String {
let host = url.host ?? url.absoluteString
if let port = url.port { return "\(host):\(port)" }
if let port = url.port {
return "\(host):\(port)"
}
return host
}
}
@@ -94,24 +94,3 @@ struct GatewayDiscoveryInlineList: View {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
struct GatewayDiscoveryMenu: View {
var discovery: GatewayDiscoveryModel
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
var body: some View {
Menu {
if self.discovery.gateways.isEmpty {
Button(self.discovery.statusText) {}
.disabled(true)
} else {
ForEach(self.discovery.gateways) { gateway in
Button(gateway.displayName) { self.onSelect(gateway) }
}
}
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
}
.help("Discover OpenClaw gateways on your LAN")
}
}
@@ -0,0 +1,110 @@
import Foundation
enum GatewaySleepPrepareResult: Equatable {
case ready(suspensionID: String)
case busy
}
@MainActor
final class GatewaySleepCycleController {
typealias Prepare = (String) async throws -> GatewaySleepPrepareResult
typealias Resume = (String) async throws -> Void
typealias Refresh = () async -> Void
typealias CurrentRoute = () -> String?
typealias RetryDelay = (Duration) async -> Void
private static let resumeAttempts = 3
private static let resumeRetryDelay: Duration = .seconds(2)
private let requestID: String
private let currentRoute: CurrentRoute
private let prepare: Prepare
private let resume: Resume
private let refresh: Refresh
private let retryDelay: RetryDelay
private let log: (String) -> Void
private var suspension: (id: String, route: String?)?
private var cycleGeneration: UInt64 = 0
init(
requestID: String,
currentRoute: @escaping CurrentRoute,
prepare: @escaping Prepare,
resume: @escaping Resume,
refresh: @escaping Refresh,
retryDelay: @escaping RetryDelay = { try? await Task.sleep(for: $0) },
log: @escaping (String) -> Void)
{
self.requestID = requestID
self.currentRoute = currentRoute
self.prepare = prepare
self.resume = resume
self.refresh = refresh
self.retryDelay = retryDelay
self.log = log
}
func willSleep(mode: AppState.ConnectionMode?) async {
guard mode == .local else { return }
self.cycleGeneration &+= 1
let generation = self.cycleGeneration
do {
switch try await self.prepare(self.requestID) {
case let .ready(suspensionID):
guard generation == self.cycleGeneration else {
// The wake already happened; release the late lease right away
// instead of fencing the gateway until its two-minute expiry.
try await self.resume(suspensionID)
return
}
self.suspension = (id: suspensionID, route: self.currentRoute())
case .busy:
self.log("gateway sleep preparation skipped because the gateway is busy")
}
} catch {
self.log("gateway sleep preparation failed: \(error.localizedDescription)")
}
}
func didWake(mode: AppState.ConnectionMode?) async {
let suspension = self.suspension
self.suspension = nil
// Invalidate a prepare response that arrives after the wake notification;
// its short-lived lease must expire instead of surviving into a later cycle.
self.cycleGeneration &+= 1
guard mode == .local else {
if suspension != nil {
self.log("dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire")
}
return
}
let generation = self.cycleGeneration
// Refresh first: after real sleep the transport is usually dead, and the
// resume RPC needs the re-established connection to succeed at all.
await self.refresh()
if let suspension {
if let route = suspension.route, self.currentRoute() == route {
await self.resumeWithRetries(suspension.id, generation: generation)
} else {
self.log("dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire")
}
}
}
private func resumeWithRetries(_ suspensionID: String, generation: UInt64) async {
for attempt in 1...Self.resumeAttempts {
// A new sleep cycle owns the connection; abandoned leases self-expire.
guard generation == self.cycleGeneration else { return }
do {
try await self.resume(suspensionID)
return
} catch {
self.log("gateway wake resume attempt \(attempt) failed: \(error.localizedDescription)")
if attempt < Self.resumeAttempts {
await self.retryDelay(Self.resumeRetryDelay)
}
}
}
self.log("giving up on gateway wake resume; lease will self-expire")
}
}
@@ -500,14 +500,9 @@ extension MacNodeRuntime {
(Self.locationPreciseEnabled() ? .precise : .balanced)
let services = await mainActorServices()
let status = await services.locationAuthorizationStatus()
let hasPermission = switch mode {
case .always:
status == .authorizedAlways
case .whileUsing:
status == .authorizedAlways
case .off:
false
}
let hasPermission = PermissionManager.isLocationAuthorized(
status: status,
requireAlways: mode == .always)
if !hasPermission {
return BridgeInvokeResponse(
id: req.id,
@@ -1,5 +1,5 @@
import Foundation
import OpenClawProtocol
import SwiftUI
import Testing
@testable import OpenClaw
@@ -41,122 +41,6 @@ private func makeChannelsStore(
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@Test func `channels settings builds body with snapshot`() {
let store = makeChannelsStore(
channels: [
"whatsapp": SnapshotAnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
"self": ["e164": "+15551234567"],
"running": true,
"connected": false,
"lastConnectedAt": 1_700_000_000_000,
"lastDisconnect": [
"at": 1_700_000_050_000,
"status": 401,
"error": "logged out",
"loggedOut": true,
],
"reconnectAttempts": 2,
"lastMessageAt": 1_700_000_060_000,
"lastEventAt": 1_700_000_060_000,
"lastError": "needs login",
]),
"telegram": SnapshotAnyCodable([
"configured": true,
"tokenSource": "env",
"running": true,
"mode": "polling",
"lastStartAt": 1_700_000_000_000,
"probe": [
"ok": true,
"status": 200,
"elapsedMs": 120,
"bot": ["id": 123, "username": "openclawbot"],
"webhook": ["url": "https://example.com/hook", "hasCustomCert": false],
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
"running": true,
"lastStartAt": 1_700_000_000_000,
"probe": [
"ok": true,
"status": 200,
"elapsedMs": 140,
"version": "0.12.4",
],
"lastProbeAt": 1_700_000_050_000,
]),
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_050_000,
]),
])
store.whatsappLoginMessage = "Scan QR"
store.whatsappLoginQrDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
let view = ChannelsSettings(store: store)
_ = view.body
}
@Test func `channels settings builds body without snapshot`() {
let store = makeChannelsStore(
channels: [
"whatsapp": SnapshotAnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
]),
"telegram": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "bot missing",
"probe": [
"ok": false,
"status": 403,
"error": "unauthorized",
"elapsedMs": 120,
],
"lastProbeAt": 1_700_000_100_000,
]),
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
"running": false,
"lastError": "not configured",
"probe": [
"ok": false,
"status": 404,
"error": "unreachable",
"elapsedMs": 200,
],
"lastProbeAt": 1_700_000_200_000,
]),
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
"cliPath": "imsg",
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_200_000,
]),
])
let view = ChannelsSettings(store: store)
_ = view.body
}
@Test func `whatsapp login wait result keeps latest qr until connected`() {
let store = makeChannelsStore(channels: [:])
store.whatsappLoginQrDataUrl = "data:image/png;base64,initial"
@@ -1,10 +0,0 @@
import Testing
@testable import OpenClaw
@MainActor
struct ClawHubSkillsBrowserSmokeTests {
@Test func `ClawHub browser builds guarded review flow`() {
let view = ClawHubSkillsBrowser(installedSkills: [], onInstalled: { _ in })
_ = view.body
}
}
@@ -15,17 +15,7 @@ struct CronJobEditorSmokeTests {
onSave: { _ in })
}
@Test func `status pill builds body`() {
_ = StatusPill(text: "ok", tint: .green).body
_ = StatusPill(text: "disabled", tint: .secondary).body
}
@Test func `cron job editor builds body for new job`() {
let view = self.makeEditor()
_ = view.body
}
@Test func `cron job editor builds body for existing job`() {
@Test func `cron job editor preserves advanced delivery routes`() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob(
id: "job-1",
@@ -72,8 +62,6 @@ struct CronJobEditorSmokeTests {
lastDurationMs: 1000))
let view = self.makeEditor(job: job, channelsStore: channelsStore)
_ = view.body
let delivery = view.buildDelivery()
#expect(delivery["threadId"] as? Int == 42)
#expect((delivery["completionDestination"] as? [String: Any])?["to"] as? String ==
@@ -140,10 +140,18 @@ struct DashboardGatewaysBridgeTests {
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
#expect(controller._testTLSParams == params)
#expect(DashboardWindowController.isExpectedTLSAuthority(
host: "gateway.example",
port: 0,
dashboardURL: url))
#expect(DashboardWindowController.isExpectedTLSAuthority(
host: "gateway.example",
port: 443,
dashboardURL: url))
#expect(!DashboardWindowController.isExpectedTLSAuthority(
host: "gateway.example",
port: 8443,
dashboardURL: url))
#expect(!DashboardWindowController.isExpectedTLSAuthority(
host: "other.example",
port: 443,
@@ -0,0 +1,230 @@
import Testing
@testable import OpenClaw
private struct PrepareFailure: Error {}
@Suite(.serialized)
@MainActor
struct GatewaySleepCycleControllerTests {
@Test func `ready preparation resumes its suspension once and refreshes`() async {
var preparedRequestIDs: [String] = []
var resumedIDs: [String] = []
var refreshCount = 0
let route = "ws://127.0.0.1:18789"
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { route },
prepare: { requestID in
preparedRequestIDs.append(requestID)
return .ready(suspensionID: "suspension-1")
},
resume: { resumedIDs.append($0) },
refresh: { refreshCount += 1 },
log: { _ in })
await controller.willSleep(mode: .local)
await controller.didWake(mode: .local)
await controller.didWake(mode: .local)
#expect(preparedRequestIDs == ["macos-sleep-test-run"])
#expect(resumedIDs == ["suspension-1"])
#expect(refreshCount == 2)
}
@Test func `prepare response arriving after wake resumes the late lease immediately`() async {
var resumedIDs: [String] = []
var releasePrepare: CheckedContinuation<Void, Never>?
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in
await withCheckedContinuation { releasePrepare = $0 }
return .ready(suspensionID: "late-suspension")
},
resume: { resumedIDs.append($0) },
refresh: {},
log: { _ in })
let sleepTask = Task { await controller.willSleep(mode: .local) }
// Let willSleep reach the suspended prepare before waking.
while releasePrepare == nil {
await Task.yield()
}
await controller.didWake(mode: .local)
releasePrepare?.resume()
await sleepTask.value
await controller.didWake(mode: .local)
#expect(resumedIDs == ["late-suspension"])
}
@Test func `resume retries after a transport failure and succeeds`() async {
var resumeAttempts = 0
var delays = 0
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in .ready(suspensionID: "suspension-retry") },
resume: { _ in
resumeAttempts += 1
if resumeAttempts == 1 { throw PrepareFailure() }
},
refresh: {},
retryDelay: { _ in delays += 1 },
log: { _ in })
await controller.willSleep(mode: .local)
await controller.didWake(mode: .local)
#expect(resumeAttempts == 2)
#expect(delays == 1)
}
@Test func `resume gives up after exhausting retries`() async {
var resumeAttempts = 0
var logs: [String] = []
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in .ready(suspensionID: "suspension-exhaust") },
resume: { _ in
resumeAttempts += 1
throw PrepareFailure()
},
refresh: {},
retryDelay: { _ in },
log: { logs.append($0) })
await controller.willSleep(mode: .local)
await controller.didWake(mode: .local)
#expect(resumeAttempts == 3)
#expect(logs.contains { $0.contains("giving up") })
}
@Test func `a new sleep cycle aborts in-flight resume retries`() async {
var resumeAttempts = 0
var beginNextSleep: (() async -> Void)?
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in .ready(suspensionID: "suspension-abort") },
resume: { _ in
resumeAttempts += 1
throw PrepareFailure()
},
refresh: {},
retryDelay: { _ in await beginNextSleep?() },
log: { _ in })
await controller.willSleep(mode: .local)
beginNextSleep = { await controller.willSleep(mode: .local) }
await controller.didWake(mode: .local)
#expect(resumeAttempts == 1)
}
@Test func `busy preparation does not resume but still refreshes`() async {
var resumeCount = 0
var refreshCount = 0
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in .busy },
resume: { _ in resumeCount += 1 },
refresh: { refreshCount += 1 },
log: { _ in })
await controller.willSleep(mode: .local)
await controller.didWake(mode: .local)
#expect(resumeCount == 0)
#expect(refreshCount == 1)
}
@Test func `failed preparation does not resume but still refreshes`() async {
var resumeCount = 0
var refreshCount = 0
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in throw PrepareFailure() },
resume: { _ in resumeCount += 1 },
refresh: { refreshCount += 1 },
log: { _ in })
await controller.willSleep(mode: .local)
await controller.didWake(mode: .local)
#expect(resumeCount == 0)
#expect(refreshCount == 1)
}
@Test func `remote mode performs no sleep or wake work`() async {
var prepareCount = 0
var resumeCount = 0
var refreshCount = 0
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in
prepareCount += 1
return .ready(suspensionID: "unused")
},
resume: { _ in resumeCount += 1 },
refresh: { refreshCount += 1 },
log: { _ in })
await controller.willSleep(mode: .remote)
await controller.didWake(mode: .remote)
#expect(prepareCount == 0)
#expect(resumeCount == 0)
#expect(refreshCount == 0)
}
@Test func `changed route drops the suspension and still refreshes`() async {
var route = "ws://127.0.0.1:18789"
var resumedIDs: [String] = []
var refreshCount = 0
var logs: [String] = []
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { route },
prepare: { _ in .ready(suspensionID: "suspension-1") },
resume: { resumedIDs.append($0) },
refresh: { refreshCount += 1 },
log: { logs.append($0) })
await controller.willSleep(mode: .local)
route = "ws://127.0.0.1:19001"
await controller.didWake(mode: .local)
route = "ws://127.0.0.1:18789"
await controller.didWake(mode: .local)
#expect(resumedIDs.isEmpty)
#expect(refreshCount == 2)
#expect(logs == ["dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire"])
}
@Test func `remote wake clears a held local suspension`() async {
var resumedIDs: [String] = []
var refreshCount = 0
var logs: [String] = []
let controller = GatewaySleepCycleController(
requestID: "macos-sleep-test-run",
currentRoute: { "ws://127.0.0.1:18789" },
prepare: { _ in .ready(suspensionID: "suspension-1") },
resume: { resumedIDs.append($0) },
refresh: { refreshCount += 1 },
log: { logs.append($0) })
await controller.willSleep(mode: .local)
await controller.didWake(mode: .remote)
await controller.didWake(mode: .local)
#expect(resumedIDs.isEmpty)
#expect(refreshCount == 1)
#expect(logs == ["dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire"])
}
}
@@ -1,55 +0,0 @@
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct InstancesSettingsSmokeTests {
@Test func `instances settings builds body with multiple instances`() {
let store = InstancesStore(isPreview: true)
store.statusMessage = "Loaded"
store.instances = [
InstanceInfo(
id: "macbook",
host: "macbook-pro",
ip: "10.0.0.2",
version: "1.2.3",
platform: "macOS 15.1",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro18,1",
lastInputSeconds: 15,
mode: "local",
reason: "heartbeat",
text: "MacBook Pro local",
ts: 1_700_000_000_000),
InstanceInfo(
id: "android",
host: "pixel",
ip: "10.0.0.3",
version: "2.0.0",
platform: "Android 14",
deviceFamily: "Android",
modelIdentifier: nil,
lastInputSeconds: 120,
mode: "node",
reason: "presence",
text: "Android node",
ts: 1_700_000_100_000),
InstanceInfo(
id: "gateway",
host: "gateway",
ip: "10.0.0.4",
version: "3.0.0",
platform: "iOS 18",
deviceFamily: nil,
modelIdentifier: nil,
lastInputSeconds: nil,
mode: "gateway",
reason: "gateway",
text: "Gateway",
ts: 1_700_000_200_000),
]
let view = InstancesSettings(store: store)
_ = view.body
}
}
@@ -130,6 +130,7 @@ struct MacNodeRuntimeTests {
var actError: Error?
var performCallCount = 0
var releaseCallCount = 0
var locationStatus: CLAuthorizationStatus
var receivedLifecycleGenerations: [UInt64] = []
var receivedReleaseGenerations: [UInt64] = []
private let snapshotInspection: SnapshotInspection?
@@ -147,6 +148,7 @@ struct MacNodeRuntimeTests {
snapshotError: Error? = nil,
snapshotInspection: SnapshotInspection? = nil,
actError: Error? = nil,
locationAuthorizationStatus: CLAuthorizationStatus = .authorizedAlways,
performEnteredGate: AsyncTestGate? = nil,
allowPerformGate: AsyncTestGate? = nil)
{
@@ -154,6 +156,7 @@ struct MacNodeRuntimeTests {
self.snapshotError = snapshotError
self.snapshotInspection = snapshotInspection
self.actError = actError
self.locationStatus = locationAuthorizationStatus
self.performEnteredGate = performEnteredGate
self.allowPerformGate = allowPerformGate
}
@@ -192,7 +195,7 @@ struct MacNodeRuntimeTests {
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
.authorizedAlways
self.locationStatus
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
@@ -453,6 +456,30 @@ struct MacNodeRuntimeTests {
}
}
@Test func `handle location invoke applies authorization required by mode`() async throws {
let authorizedWhenInUse = try #require(CLAuthorizationStatus(rawValue: 4))
let cases: [(mode: OpenClawLocationMode, status: CLAuthorizationStatus, accepted: Bool)] = [
(.whileUsing, authorizedWhenInUse, true),
(.always, authorizedWhenInUse, false),
(.whileUsing, .authorizedAlways, true),
(.always, .authorizedAlways, true),
]
for testCase in cases {
await TestIsolation.withUserDefaultsValues([locationModeKey: testCase.mode.rawValue]) {
let services = await MainActor.run {
MainActorServicesProbe(locationAuthorizationStatus: testCase.status)
}
let runtime = MacNodeRuntime(makeMainActorServices: { services })
let response = await self.invoke(
runtime, "req-location", OpenClawLocationCommand.get.rawValue)
#expect(response.ok == testCase.accepted)
}
}
}
@Test func `handle invoke screen record uses injected services`() async throws {
let services = await MainActor.run { MainActorServicesProbe() }
let runtime = MacNodeRuntime(makeMainActorServices: { services })
@@ -1,78 +0,0 @@
import OpenClawDiscovery
import SwiftUI
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct MasterDiscoveryMenuSmokeTests {
@Test func `inline list builds body when empty`() {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Searching…"
discovery.gateways = []
let view = GatewayDiscoveryInlineList(
discovery: discovery,
currentTarget: nil,
currentUrl: nil,
transport: .ssh,
onSelect: { _ in })
_ = view.body
}
@Test func `inline list builds body with master and selection`() {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Found 1"
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Office Mac",
lanHost: "office.local",
tailnetDns: "office.tailnet-123.ts.net",
sshPort: 2222,
gatewayPort: nil,
cliPath: nil,
stableID: "office",
debugID: "office",
isLocal: false),
]
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
let view = GatewayDiscoveryInlineList(
discovery: discovery,
currentTarget: currentTarget,
currentUrl: nil,
transport: .ssh,
onSelect: { _ in })
_ = view.body
}
@Test func `menu builds body with masters`() {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Found 2"
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "A",
lanHost: "a.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: nil,
cliPath: nil,
stableID: "a",
debugID: "a",
isLocal: false),
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "B",
lanHost: nil,
tailnetDns: "b.ts.net",
sshPort: 22,
gatewayPort: nil,
cliPath: nil,
stableID: "b",
debugID: "b",
isLocal: false),
]
let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in })
_ = view.body
}
}
@@ -1,5 +1,4 @@
import AppKit
import SwiftUI
import Testing
@testable import OpenClaw
@@ -10,40 +9,6 @@ struct MenuContentSmokeTests {
#expect(AppTerminationTiming.cleanupDeadlineSeconds < AppTerminationTiming.signalExitFailsafeSeconds)
}
@Test func `menu content builds body local mode`() {
let state = AppState(preview: true)
state.connectionMode = .local
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func `menu content builds body remote mode`() {
let state = AppState(preview: true)
state.connectionMode = .remote
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func `menu content builds body unconfigured mode`() {
let state = AppState(preview: true)
state.connectionMode = .unconfigured
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func `menu content builds body with debug and canvas`() {
let state = AppState(preview: true)
state.connectionMode = .local
state.debugPaneEnabled = true
state.canvasEnabled = true
state.canvasPanelVisible = true
state.swabbleEnabled = true
state.voicePushToTalkEnabled = true
state.heartbeatsEnabled = true
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func `dock menu exposes primary shortcuts`() throws {
let delegate = AppDelegate()
let menu = try #require(delegate.applicationDockMenu(NSApplication.shared))
@@ -3,7 +3,6 @@ import Foundation
import OpenClawDiscovery
import OpenClawIPC
import OpenClawKit
import SwiftUI
import Testing
@testable import OpenClaw
@@ -41,14 +40,6 @@ struct OnboardingViewSmokeTests {
"2 gateways found on your network — click to choose one.")
}
@Test func `onboarding view builds body`() {
let state = AppState(preview: true)
let view = OnboardingView(
state: state,
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
_ = view.body
}
@Test func `foreign local listener is not advertised as attachable`() {
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"])
let foreign = OnboardingView.LocalGatewayProbe(
@@ -1,49 +0,0 @@
import OpenClawProtocol
import SwiftUI
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct QuickChatViewSmokeTests {
@Test func `quick chat view builds body`() {
let model = QuickChatModel(
sessionKeyProvider: { "main" },
agentsProvider: {
AgentsListResult(
defaultid: "main",
mainkey: "main",
scope: AnyCodable("per-agent"),
agents: [AgentSummary(id: "main", name: "Agent")])
},
agentIdentityProvider: { _ in QuickChatAgentDisplay(id: "main", name: "Agent", emoji: nil) },
sendProvider: { _, _, _, _, _, _ in "ok" },
permissionStatusProvider: { capabilities in
Dictionary(uniqueKeysWithValues: capabilities.map { ($0, true) })
},
permissionGrantProvider: { capabilities in
Dictionary(uniqueKeysWithValues: capabilities.map { ($0, true) })
},
connectionGateProvider: { .available },
modelControlsProvider: { _ in .testFixture },
modelPatchProvider: { _, _ in nil })
let view = QuickChatView(
model: model,
replyBinding: QuickChatReplyBinding(),
onDismiss: {},
onSendAccepted: { _ in },
onShowAgentPicker: {},
onShowModelMenu: {},
onShowRecentSessions: {},
onToggleDictation: {},
onStopDictation: {},
onCaptureTextContext: {},
onShowCaptureMenu: {},
onGrantPermissions: {},
onPasteReply: {},
onContentHeightChange: { _ in },
onTextViewReady: { _ in })
_ = view.body
}
}
@@ -60,22 +60,6 @@ struct SettingsViewSmokeTests {
_ = hosting.fittingSize
}
@Test func `config settings builds body`() {
let view = ConfigSettings()
_ = view.body
}
@Test func `debug settings builds body`() {
let view = DebugSettings()
_ = view.body
}
@Test func `connection settings builds body`() {
let state = AppState(preview: true)
let view = GeneralSettings(state: state, page: .connection)
_ = view.body
}
@Test func `general settings renders the keyboard shortcut recorder`() {
let state = AppState(preview: true)
let hosting = NSHostingView(rootView: GeneralSettings(state: state))
@@ -84,41 +68,10 @@ struct SettingsViewSmokeTests {
_ = hosting.fittingSize
}
@Test func `sessions settings builds body`() {
let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true)
_ = view.body
}
@Test func `permissions settings builds body`() {
let state = AppState(preview: true)
let view = PermissionsSettings(
state: state,
status: [
.notifications: .granted,
.screenRecording: .notGranted,
],
refresh: {},
showOnboarding: {})
_ = view.body
}
@Test func `settings root view builds body`() {
let state = AppState(preview: true)
let view = SettingsRootView(state: state, updater: nil, initialTab: .general)
_ = view.body
}
@Test func `Gateway settings is visible and builds body`() throws {
@Test func `Gateway settings is visible`() {
let tabs = SettingsTabGroup.defaultGroups(showDebug: false, showSystemAgent: false)
.flatMap(\.tabs)
#expect(tabs.contains(.gateways))
let profile = try MacGatewayProfile(
id: "studio",
name: "Studio",
url: #require(URL(string: "wss://studio.example")))
let view = GatewaySettings(profiles: [profile], isPreview: true)
_ = view.body
}
@Test func `OpenClaw settings require configured inference`() {
@@ -197,25 +150,4 @@ struct SettingsViewSmokeTests {
previousGatewayID: directA,
currentGatewayID: directB) == .init(clearsPrevious: true, resetsSystemAgent: true))
}
@Test func `about settings builds body`() {
let view = AboutSettings(updater: nil)
_ = view.body
}
@Test func `voice wake settings builds body`() {
let state = AppState(preview: true)
let view = VoiceWakeSettings(state: state, isActive: false)
_ = view.body
}
@Test func `skills settings builds body`() {
let view = SkillsSettings(state: .preview)
_ = view.body
}
@Test func `exec approvals settings builds body`() {
let view = ExecApprovalsSettings()
_ = view.body
}
}
@@ -1,28 +0,0 @@
import SwiftUI
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct VoiceWakeOverlayViewSmokeTests {
@Test func `overlay view builds body in display mode`() {
let controller = VoiceWakeOverlayController(enableUI: false)
_ = controller.startSession(source: .wakeWord, transcript: "hello", forwardEnabled: true)
let view = VoiceWakeOverlayView(controller: controller)
_ = view.body
}
@Test func `overlay view builds body in editing mode`() {
let controller = VoiceWakeOverlayController(enableUI: false)
let token = controller.startSession(source: .pushToTalk, transcript: "edit me", forwardEnabled: true)
controller.userBeganEditing()
controller.updateLevel(token: token, 0.6)
let view = VoiceWakeOverlayView(controller: controller)
_ = view.body
}
@Test func `close button overlay builds body`() {
let view = CloseButtonOverlay(isVisible: true, onHover: { _ in }, onClose: {})
_ = view.body
}
}
@@ -740,11 +740,9 @@ private final class ChatInlineWidgetNavigationDelegate: NSObject, WKNavigationDe
}
private func matchesExpectedProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Bool {
guard let expectedHost = self.resource.url.host,
protectionSpace.host.caseInsensitiveCompare(expectedHost) == .orderedSame
else { return false }
let expectedPort = self.resource.url.port ?? (self.resource.url.scheme?.lowercased() == "https" ? 443 : 80)
return protectionSpace.port == expectedPort
GatewayTLSAuthority(url: self.resource.url)?.matches(
host: protectionSpace.host,
port: protectionSpace.port) == true
}
}
@@ -4,6 +4,55 @@ private func defaultGatewayPort(tls: Bool) -> Int {
tls ? 443 : 18789
}
private func normalizeGatewayContextPath(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
let path = value.hasPrefix("/") ? value : "/\(value)"
guard path != "/" else { return nil }
// Keep valid escapes such as %2F and %FF intact because decoding them can
// change segment boundaries or reject valid non-UTF-8 path octets.
let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "%?#"))
var encoded = ""
var index = path.startIndex
while index < path.endIndex {
if path[index] == "%" {
let first = path.index(after: index)
if first < path.endIndex {
let second = path.index(after: first)
if second < path.endIndex,
path[first].isHexDigit,
path[second].isHexDigit
{
let end = path.index(after: second)
encoded.append(contentsOf: path[index..<end])
index = end
continue
}
}
encoded.append("%25")
index = path.index(after: index)
continue
}
let nextPercent = path[index...].firstIndex(of: "%") ?? path.endIndex
guard let segment = String(path[index..<nextPercent])
.addingPercentEncoding(withAllowedCharacters: allowed)
else { return nil }
encoded.append(segment)
index = nextPercent
}
return encoded
}
extension Character {
fileprivate var isHexDigit: Bool {
guard self.unicodeScalars.count == 1, let value = self.unicodeScalars.first?.value else {
return false
}
return (48...57).contains(value) ||
(65...70).contains(value) ||
(97...102).contains(value)
}
}
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
case gateway(GatewayConnectDeepLink)
@@ -12,11 +61,13 @@ public enum DeepLinkRoute: Sendable, Equatable {
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
private static let maximumSetupEndpoints = 8
private static let pairingSetupURLPrefix = "oc-pair://"
private enum CodingKeys: String, CodingKey {
case host
case port
case tls
case contextPath
case bootstrapToken
case token
case password
@@ -37,6 +88,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public let contextPath: String?
public let bootstrapToken: String?
public let token: String?
public let password: String?
@@ -46,6 +98,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: String,
port: Int,
tls: Bool,
contextPath: String? = nil,
bootstrapToken: String?,
token: String?,
password: String?,
@@ -54,6 +107,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
self.host = host
self.port = port
self.tls = tls
self.contextPath = normalizeGatewayContextPath(contextPath)
self.bootstrapToken = bootstrapToken
self.token = token
self.password = password
@@ -65,6 +119,8 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
self.host = try container.decode(String.self, forKey: .host)
self.port = try container.decode(Int.self, forKey: .port)
self.tls = try container.decode(Bool.self, forKey: .tls)
self.contextPath = try normalizeGatewayContextPath(
container.decodeIfPresent(String.self, forKey: .contextPath))
self.bootstrapToken = try container.decodeIfPresent(String.self, forKey: .bootstrapToken)
self.token = try container.decodeIfPresent(String.self, forKey: .token)
self.password = try container.decodeIfPresent(String.self, forKey: .password)
@@ -74,12 +130,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public var websocketURL: URL? {
guard (1...65535).contains(self.port) else { return nil }
var components = URLComponents()
components.scheme = self.tls ? "wss" : "ws"
components.host = self.host
components.port = self.port
return components.url
self.connectionEndpoints.first?.websocketURL
}
public var isValidEndpoint: Bool {
@@ -88,7 +139,8 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public var connectionEndpoints: [GatewayConnectEndpoint] {
[.init(host: self.host, port: self.port, tls: self.tls)] + self.fallbackEndpoints
[.init(host: self.host, port: self.port, tls: self.tls, contextPath: self.contextPath)] +
self.fallbackEndpoints
}
public func selectingEndpoint(_ endpoint: GatewayConnectEndpoint) -> GatewayConnectDeepLink {
@@ -96,6 +148,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: endpoint.host,
port: endpoint.port,
tls: endpoint.tls,
contextPath: endpoint.contextPath,
bootstrapToken: self.bootstrapToken,
token: self.token,
password: self.password)
@@ -144,8 +197,14 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
/// and `tls`. In both cases, the optional `bootstrapToken`, `token`, and `password` fields
/// are also supported.
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
var trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.range(
of: self.pairingSetupURLPrefix,
options: [.anchored, .caseInsensitive]) != nil
{
trimmed = String(trimmed.dropFirst(self.pairingSetupURLPrefix.count))
}
if let link = decodeSetupPayload(from: Data(trimmed.utf8)) {
return link
}
@@ -185,12 +244,17 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
if let primary = links.first {
let fallbacks = links.dropFirst().map {
GatewayConnectEndpoint(host: $0.host, port: $0.port, tls: $0.tls)
GatewayConnectEndpoint(
host: $0.host,
port: $0.port,
tls: $0.tls,
contextPath: $0.contextPath)
}
return GatewayConnectDeepLink(
host: primary.host,
port: primary.port,
tls: primary.tls,
contextPath: primary.contextPath,
bootstrapToken: primary.bootstrapToken,
token: primary.token,
password: primary.password,
@@ -221,7 +285,11 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
password: String?) -> GatewayConnectDeepLink?
{
guard let parsed = URLComponents(string: urlString),
let hostname = parsed.host, !hostname.isEmpty
let hostname = parsed.host, !hostname.isEmpty,
parsed.user == nil,
parsed.password == nil,
parsed.query == nil,
parsed.fragment == nil
else { return nil }
let scheme = (parsed.scheme ?? "ws").lowercased()
@@ -236,6 +304,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: hostname,
port: parsed.port ?? defaultGatewayPort(tls: tls),
tls: tls,
contextPath: parsed.percentEncodedPath,
bootstrapToken: bootstrapToken,
token: token,
password: password)
@@ -245,6 +314,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: String,
port: Int,
tls: Bool,
contextPath: String? = nil,
bootstrapToken: String?,
token: String?,
password: String?) -> GatewayConnectDeepLink?
@@ -253,6 +323,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: host,
port: port,
tls: tls,
contextPath: contextPath,
bootstrapToken: bootstrapToken,
token: token,
password: password)
@@ -285,14 +356,42 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public struct GatewayConnectEndpoint: Codable, Sendable, Equatable {
private enum CodingKeys: String, CodingKey {
case host
case port
case tls
case contextPath
}
public let host: String
public let port: Int
public let tls: Bool
public let contextPath: String?
public init(host: String, port: Int, tls: Bool) {
public init(host: String, port: Int, tls: Bool, contextPath: String? = nil) {
self.host = host
self.port = port
self.tls = tls
self.contextPath = normalizeGatewayContextPath(contextPath)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.host = try container.decode(String.self, forKey: .host)
self.port = try container.decode(Int.self, forKey: .port)
self.tls = try container.decode(Bool.self, forKey: .tls)
self.contextPath = try normalizeGatewayContextPath(
container.decodeIfPresent(String.self, forKey: .contextPath))
}
public var websocketURL: URL? {
guard (1...65535).contains(self.port) else { return nil }
var components = URLComponents()
components.scheme = self.tls ? "wss" : "ws"
components.host = self.host
components.port = self.port
components.percentEncodedPath = self.contextPath ?? ""
return components.url
}
}
@@ -683,25 +683,49 @@ public protocol GatewayTLSRouteMetadataProviding: AnyObject {
var effectiveTLSFingerprintSHA256: String? { get }
}
struct GatewayTLSAuthority: Equatable, Sendable {
let host: String
let port: Int
public struct GatewayTLSAuthority: Equatable, Sendable {
public let scheme: String
public let host: String
public let port: Int
private let defaultPort: Int
init?(url: URL) {
guard let host = Self.normalizedHost(url.host) else { return nil }
public init?(url: URL) {
guard let scheme = url.scheme?.lowercased(),
let defaultPort = Self.defaultPort(for: scheme),
let host = Self.normalizedHost(url.host)
else { return nil }
self.scheme = scheme
self.host = host
self.port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80)
self.port = url.port ?? defaultPort
self.defaultPort = defaultPort
}
init?(host: String, port: Int) {
guard let host = Self.normalizedHost(host) else { return nil }
self.host = host
self.port = port
public func matches(host: String, port: Int) -> Bool {
// URLProtectionSpace uses 0 for the protocol's default port. Normalize it here so
// every pinned Apple transport reaches the same authority decision.
let challengePort = port == 0 ? self.defaultPort : port
return Self.normalizedHost(host) == self.host && challengePort == self.port
}
public var serialized: String {
let hostPart = self.host.contains(":") ? "[\(self.host)]" : self.host
return "\(self.scheme)://\(hostPart)" + (self.port == self.defaultPort ? "" : ":\(self.port)")
}
private static func defaultPort(for scheme: String) -> Int? {
switch scheme {
case "http", "ws": 80
case "https", "wss": 443
default: nil
}
}
private static func normalizedHost(_ host: String?) -> String? {
let value = host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
return value.isEmpty ? nil : value
guard !value.isEmpty else { return nil }
return value.hasPrefix("[") && value.hasSuffix("]")
? String(value.dropFirst().dropLast())
: value
}
}
@@ -871,9 +895,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
let host = challenge.protectionSpace.host
let port = challenge.protectionSpace.port
let expected = self.currentEnforcedFingerprint()
let challengedAuthority = GatewayTLSAuthority(host: host, port: port)
guard let expectedAuthority = self.currentExpectedAuthority(),
challengedAuthority == expectedAuthority
expectedAuthority.matches(host: host, port: port)
else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: .authorityMismatch,
@@ -182,6 +182,12 @@ public enum SessionDiffFileStatus: String, Codable, Sendable {
case renamed = "renamed"
}
public enum SessionDiffScope: String, Codable, Sendable {
case all = "all"
case uncommitted = "uncommitted"
case commit = "commit"
}
public enum TaskSuggestionResolution: String, Codable, Sendable {
case dismissed = "dismissed"
case accepted = "accepted"
@@ -1898,7 +1904,11 @@ public struct EnvironmentSummary: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let desktop: Bool?
public let worker: WorkerEnvironmentMetadata?
public init(
@@ -1906,14 +1916,22 @@ public struct EnvironmentSummary: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
desktop: Bool? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.desktop = desktop
self.worker = worker
}
@@ -1922,7 +1940,11 @@ public struct EnvironmentSummary: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case desktop
case worker
}
}
@@ -1950,7 +1972,11 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let desktop: Bool?
public let worker: WorkerEnvironmentMetadata?
public init(
@@ -1958,14 +1984,22 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
desktop: Bool? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.desktop = desktop
self.worker = worker
}
@@ -1974,7 +2008,11 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case desktop
case worker
}
}
@@ -2002,7 +2040,11 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let desktop: Bool?
public let worker: WorkerEnvironmentMetadata?
public init(
@@ -2010,14 +2052,22 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
desktop: Bool? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.desktop = desktop
self.worker = worker
}
@@ -2026,7 +2076,11 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case desktop
case worker
}
}
@@ -2070,7 +2124,11 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let desktop: Bool?
public let worker: WorkerEnvironmentMetadata?
public init(
@@ -2078,14 +2136,22 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
desktop: Bool? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.desktop = desktop
self.worker = worker
}
@@ -2094,7 +2160,11 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case desktop
case worker
}
}
@@ -2183,6 +2253,102 @@ public struct WorkerDesktopLaunchResult: Codable, Sendable {
}
}
public struct ProjectCheckout: Codable, Sendable {
public let runnerid: String
public let path: String
public init(
runnerid: String,
path: String)
{
self.runnerid = runnerid
self.path = path
}
private enum CodingKeys: String, CodingKey {
case runnerid = "runnerId"
case path
}
}
public struct ProjectSummary: Codable, Sendable {
public let name: String
public let originurl: String?
public let checkouts: [ProjectCheckout]
public let lastusedat: Double
public init(
name: String,
originurl: String? = nil,
checkouts: [ProjectCheckout],
lastusedat: Double)
{
self.name = name
self.originurl = originurl
self.checkouts = checkouts
self.lastusedat = lastusedat
}
private enum CodingKeys: String, CodingKey {
case name
case originurl = "originUrl"
case checkouts
case lastusedat = "lastUsedAt"
}
}
public struct DesktopObserveResult: Codable, Sendable {
public let transport: String
public let wspath: String
public let expiresatms: Int
public let control: Bool
public let vncpassword: String?
public let auth: String?
public init(
transport: String,
wspath: String,
expiresatms: Int,
control: Bool,
vncpassword: String? = nil,
auth: String? = nil)
{
self.transport = transport
self.wspath = wspath
self.expiresatms = expiresatms
self.control = control
self.vncpassword = vncpassword
self.auth = auth
}
private enum CodingKeys: String, CodingKey {
case transport
case wspath = "wsPath"
case expiresatms = "expiresAtMs"
case control
case vncpassword = "vncPassword"
case auth
}
}
public struct DesktopLaunchParams: Codable, Sendable {
public let source: [String: AnyCodable]
public let app: WorkerDesktopAppId
public init(
source: [String: AnyCodable],
app: WorkerDesktopAppId)
{
self.source = source
self.app = app
}
private enum CodingKeys: String, CodingKey {
case source
case app
}
}
public struct SystemInfoParams: Codable, Sendable {}
public struct SystemInfoResult: Codable, Sendable {
@@ -3165,23 +3331,39 @@ public struct ProjectRecentProject: Codable, Sendable {
}
}
public struct ProjectsListParams: Codable, Sendable {}
public struct ProjectsListParams: Codable, Sendable {
public let includeobserved: Bool?
public init(
includeobserved: Bool? = nil)
{
self.includeobserved = includeobserved
}
private enum CodingKeys: String, CodingKey {
case includeobserved = "includeObserved"
}
}
public struct ProjectsListResult: Codable, Sendable {
public let projects: [ProjectsAddResult]
public let recents: [ProjectRecent]?
public let observedprojects: [ProjectSummary]?
public init(
projects: [ProjectsAddResult],
recents: [ProjectRecent]? = nil)
recents: [ProjectRecent]? = nil,
observedprojects: [ProjectSummary]? = nil)
{
self.projects = projects
self.recents = recents
self.observedprojects = observedprojects
}
private enum CodingKeys: String, CodingKey {
case projects
case recents
case observedprojects = "observedProjects"
}
}
@@ -7945,21 +8127,47 @@ public struct SessionDiffFile: Codable, Sendable {
}
}
public struct SessionDiffCommit: Codable, Sendable {
public let sha: String
public let subject: String
public init(
sha: String,
subject: String)
{
self.sha = sha
self.subject = subject
}
private enum CodingKeys: String, CodingKey {
case sha
case subject
}
}
public struct SessionsDiffParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let scope: SessionDiffScope?
public let commit: String?
public init(
sessionkey: String,
agentid: String? = nil)
agentid: String? = nil,
scope: SessionDiffScope? = nil,
commit: String? = nil)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.scope = scope
self.commit = commit
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case scope
case commit
}
}
@@ -7968,6 +8176,9 @@ public struct SessionsDiffResult: Codable, Sendable {
public let root: String?
public let branch: String?
public let baseref: String?
public let aheadcount: Int?
public let commits: [SessionDiffCommit]?
public let mergebase: SessionDiffCommit?
public let files: [SessionDiffFile]
public let additions: Int
public let deletions: Int
@@ -7979,6 +8190,9 @@ public struct SessionsDiffResult: Codable, Sendable {
root: String? = nil,
branch: String? = nil,
baseref: String? = nil,
aheadcount: Int? = nil,
commits: [SessionDiffCommit]? = nil,
mergebase: SessionDiffCommit? = nil,
files: [SessionDiffFile],
additions: Int,
deletions: Int,
@@ -7989,6 +8203,9 @@ public struct SessionsDiffResult: Codable, Sendable {
self.root = root
self.branch = branch
self.baseref = baseref
self.aheadcount = aheadcount
self.commits = commits
self.mergebase = mergebase
self.files = files
self.additions = additions
self.deletions = deletions
@@ -8001,6 +8218,9 @@ public struct SessionsDiffResult: Codable, Sendable {
case root
case branch
case baseref = "baseRef"
case aheadcount = "aheadCount"
case commits
case mergebase = "mergeBase"
case files
case additions
case deletions
@@ -17756,17 +17976,20 @@ public struct DevicePairSetupCodeParams: Codable, Sendable {
public let preferremoteurl: Bool?
public let includeqr: Bool?
public let bootstrapprofile: String?
public let joinurl: Bool?
public init(
publicurl: String? = nil,
preferremoteurl: Bool? = nil,
includeqr: Bool? = nil,
bootstrapprofile: String? = nil)
bootstrapprofile: String? = nil,
joinurl: Bool? = nil)
{
self.publicurl = publicurl
self.preferremoteurl = preferremoteurl
self.includeqr = includeqr
self.bootstrapprofile = bootstrapprofile
self.joinurl = joinurl
}
private enum CodingKeys: String, CodingKey {
@@ -17774,11 +17997,13 @@ public struct DevicePairSetupCodeParams: Codable, Sendable {
case preferremoteurl = "preferRemoteUrl"
case includeqr = "includeQr"
case bootstrapprofile = "bootstrapProfile"
case joinurl = "joinUrl"
}
}
public struct DevicePairSetupCodeResult: Codable, Sendable {
public let setupcode: String
public let joinurl: String?
public let qrdataurl: String?
public let gatewayurl: String
public let gatewayurls: [String]?
@@ -17786,18 +18011,22 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
public let urlsource: String
public let access: AnyCodable?
public let accessdowngraded: Bool?
public let expiresatms: Int?
public init(
setupcode: String,
joinurl: String? = nil,
qrdataurl: String? = nil,
gatewayurl: String,
gatewayurls: [String]? = nil,
auth: AnyCodable,
urlsource: String,
access: AnyCodable? = nil,
accessdowngraded: Bool? = nil)
accessdowngraded: Bool? = nil,
expiresatms: Int? = nil)
{
self.setupcode = setupcode
self.joinurl = joinurl
self.qrdataurl = qrdataurl
self.gatewayurl = gatewayurl
self.gatewayurls = gatewayurls
@@ -17805,10 +18034,12 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
self.urlsource = urlsource
self.access = access
self.accessdowngraded = accessdowngraded
self.expiresatms = expiresatms
}
private enum CodingKeys: String, CodingKey {
case setupcode = "setupCode"
case joinurl = "joinUrl"
case qrdataurl = "qrDataUrl"
case gatewayurl = "gatewayUrl"
case gatewayurls = "gatewayUrls"
@@ -17816,6 +18047,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
case urlsource = "urlSource"
case access
case accessdowngraded = "accessDowngraded"
case expiresatms = "expiresAtMs"
}
}
@@ -106,6 +106,47 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
password: nil))
}
@Test func setupCodeAcceptsPairingURLWrapperWithoutLowercasingPayload() {
let payload = #"{"url":"wss://gateway.example:8443","bootstrapToken":"Bootstrap-AbC123"}"#
let code = setupCode(from: payload)
#expect(
GatewayConnectDeepLink.fromSetupCode("oc-pair://\(code)") ==
GatewayConnectDeepLink.fromSetupCode(code))
}
@Test func setupCodePreservesPrimaryGatewayContextPath() {
let payload = #"{"url":"wss://gateway.example/openclaw-gw","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw-gw")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw-gw")
}
@Test func setupCodeDecodesGatewayContextPathExactlyOnce() {
let payload = #"{"url":"wss://gateway.example/openclaw%20gateway","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw%20gateway")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%20gateway")
}
@Test func setupCodePreservesEscapedGatewayPathDelimiter() {
let payload = #"{"url":"wss://gateway.example/openclaw%2Fgateway","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw%2Fgateway")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%2Fgateway")
}
@Test func setupCodePreservesNonUTF8GatewayPathOctet() {
let payload = #"{"url":"wss://gateway.example/openclaw%FFgateway","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw%FFgateway")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%FFgateway")
}
@Test func setupCodeAllowsPrivateLanWs() {
let payload = #"{"url":"ws://192.168.1.20:18789","bootstrapToken":"tok"}"#
#expect(
@@ -131,17 +172,18 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
}
@Test func setupCodeParsesOrderedGatewayFallbacks() throws {
let payload = #"{"url":"ws://192.168.1.20:18789","urls":["ws://192.168.1.20:18789","wss://gateway.tailnet.ts.net:8443"],"bootstrapToken":"tok"}"#
let payload = #"{"url":"ws://192.168.1.20:18789/lan-gw","urls":["ws://192.168.1.20:18789/lan-gw","wss://gateway.tailnet.ts.net:8443/tailnet-gw"],"bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.connectionEndpoints == [
.init(host: "192.168.1.20", port: 18789, tls: false),
.init(host: "gateway.tailnet.ts.net", port: 8443, tls: true),
.init(host: "192.168.1.20", port: 18789, tls: false, contextPath: "/lan-gw"),
.init(host: "gateway.tailnet.ts.net", port: 8443, tls: true, contextPath: "/tailnet-gw"),
])
#expect(try link?.selectingEndpoint(#require(link?.connectionEndpoints[1])) == .init(
host: "gateway.tailnet.ts.net",
port: 8443,
tls: true,
contextPath: "/tailnet-gw",
bootstrapToken: "tok",
token: nil,
password: nil))
@@ -154,9 +196,35 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
GatewayConnectDeepLink.self,
from: Data(payload.utf8))
#expect(link.contextPath == nil)
#expect(link.fallbackEndpoints.isEmpty)
}
@Test func legacyEncodedFallbackEndpointDecodesWithoutContextPath() throws {
let payload = #"{"host":"gateway.example","port":443,"tls":true,"fallbackEndpoints":[{"host":"fallback.example","port":443,"tls":true}]}"#
let link = try JSONDecoder().decode(
GatewayConnectDeepLink.self,
from: Data(payload.utf8))
#expect(link.fallbackEndpoints == [
.init(host: "fallback.example", port: 443, tls: true),
])
}
@Test func setupCodeRejectsGatewayURLMetadata() {
let urls = [
"wss://user@gateway.example/openclaw-gw",
"wss://gateway.example/openclaw-gw?mode=setup",
"wss://gateway.example/openclaw-gw#fragment",
]
for url in urls {
let payload = #"{"url":"\#(url)","bootstrapToken":"tok"}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
}
}
@Test func setupCodeDropsInsecureGatewayFallbacks() {
let payload = #"{"url":"ws://attacker.example:18789","urls":["ws://attacker.example:18789","wss://gateway.tailnet.ts.net"],"bootstrapToken":"tok"}"#
@@ -68,4 +68,14 @@ struct GatewayProtocolGeneratedModelsTests {
#expect(additive.scopes == ["operator.read"])
#expect(additive.locale == "en-US")
}
@Test
func `projects list result decodes a legacy projects-only payload`() throws {
let result = try JSONDecoder().decode(
ProjectsListResult.self,
from: Data(#"{"projects":[]}"#.utf8))
#expect(result.projects.isEmpty)
#expect(result.observedprojects == nil)
}
}
@@ -153,10 +153,17 @@ struct GatewayTLSPinningTests {
@Test func `TLS authority includes normalized host and effective port`() throws {
let url = try #require(URL(string: "wss://Gateway.Example.com/path"))
let route = try #require(GatewayTLSAuthority(url: url))
let explicitPortURL = try #require(URL(string: "wss://gateway.example.com:8443/path"))
let explicitPort = try #require(GatewayTLSAuthority(url: explicitPortURL))
#expect(route == GatewayTLSAuthority(host: "gateway.example.com", port: 443))
#expect(route != GatewayTLSAuthority(host: "redirect.example.com", port: 443))
#expect(route != GatewayTLSAuthority(host: "gateway.example.com", port: 8443))
#expect(route.host == "gateway.example.com")
#expect(route.port == 443)
#expect(route.matches(host: "gateway.example.com", port: 0))
#expect(route.matches(host: "gateway.example.com", port: 443))
#expect(!route.matches(host: "redirect.example.com", port: 443))
#expect(!route.matches(host: "gateway.example.com", port: 8443))
#expect(!explicitPort.matches(host: "gateway.example.com", port: 0))
#expect(explicitPort.matches(host: "gateway.example.com", port: 8443))
}
@Test func `matching explicit pin overrides system trust`() {
@@ -200,6 +207,23 @@ struct GatewayTLSPinningTests {
params: mismatch) == .reject)
}
@Test func `server trust evaluator rejects a different system-trusted certificate after pinning`() throws {
let trust = try gatewayTLSTestTrust(systemTrusted: true)
let pinnedFingerprint = SHA256.hash(data: Data("previous certificate".utf8))
.map { String(format: "%02x", $0) }.joined()
let params = GatewayTLSParams(
required: true,
expectedFingerprint: pinnedFingerprint,
allowTOFU: false,
storeKey: "profile:pinned")
#expect(GatewayTLSServerTrust.evaluate(
trust: trust,
host: "gateway.example",
port: 443,
params: params) == .reject)
}
@Test func `server trust evaluator claims trusted first use`() throws {
try self.withFakeKeychain { _ in
let trust = try gatewayTLSTestTrust(systemTrusted: true)
-13
View File
@@ -93,9 +93,6 @@ extensions/discord/src/monitor/provider.test.ts
extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts
extensions/discord/src/outbound-adapter.test.ts
extensions/discord/src/send.sends-basic-channel-messages.test.ts
extensions/discord/src/voice/manager.e2e.test.ts
extensions/discord/src/voice/manager.ts
extensions/discord/src/voice/realtime.ts
extensions/fal/image-generation-provider.ts
extensions/feishu/src/bot.test.ts
extensions/feishu/src/bot.ts
@@ -153,7 +150,6 @@ extensions/memory-core/src/memory/index.test.ts
extensions/memory-core/src/memory/manager-embedding-ops.ts
extensions/memory-core/src/memory/manager-search.test.ts
extensions/memory-core/src/memory/manager-search.ts
extensions/memory-core/src/memory/manager.ts
extensions/memory-core/src/rem-evidence.ts
extensions/memory-core/src/short-term-promotion.test.ts
extensions/memory-core/src/tools.test.ts
@@ -182,8 +178,6 @@ extensions/openai/image-generation-provider.test.ts
extensions/openai/image-generation-provider.ts
extensions/openai/openai-provider.test.ts
extensions/openai/openai-provider.ts
extensions/openai/realtime-voice-provider.test.ts
extensions/openai/realtime-voice-provider.ts
extensions/openrouter/index.test.ts
extensions/openshell/src/backend.ts
extensions/openshell/src/openshell-core.test.ts
@@ -247,8 +241,6 @@ extensions/slack/src/send.ts
extensions/telegram/src/action-runtime.test.ts
extensions/telegram/src/action-runtime.ts
extensions/telegram/src/bot-message-context.session.ts
extensions/telegram/src/bot-native-commands.session-meta.test.ts
extensions/telegram/src/bot-native-commands.ts
extensions/telegram/src/bot.create-telegram-bot.test.ts
extensions/telegram/src/bot.test.ts
extensions/telegram/src/bot/delivery.replies.ts
@@ -343,7 +335,6 @@ src/agents/btw.ts
src/agents/cli-auth-epoch.test.ts
src/agents/cli-runner.reliability.test.ts
src/agents/cli-runner.spawn.test.ts
src/agents/cli-runner.ts
src/agents/cli-runner/execute.supervisor-capture.test.ts
src/agents/cli-runner/prepare.test.ts
src/agents/cli-runner/prepare.ts
@@ -397,7 +388,6 @@ src/agents/model-selection.test.ts
src/agents/models.profiles.live.test.ts
src/agents/openai-transport-stream.base.test.ts
src/agents/openai-transport-stream.replay-and-tools.test.ts
src/agents/openai-transport-stream.streaming.test.ts
src/agents/openclaw-tools.media-factory-plan.test.ts
src/agents/openclaw-tools.session-status.test.ts
src/agents/openclaw-tools.sessions.test.ts
@@ -694,7 +684,6 @@ src/gateway/server-restart-sentinel.test.ts
src/gateway/server-startup-config.secrets.test.ts
src/gateway/server-startup-post-attach.test.ts
src/gateway/server-startup-post-attach.ts
src/gateway/server.auth.control-ui.suite.ts
src/gateway/server.chat.gateway-server-chat-b.test.ts
src/gateway/server.chat.gateway-server-chat.test.ts
src/gateway/server.config-patch.test.ts
@@ -912,7 +901,6 @@ ui/src/pages/chat/chat-view.test.ts
ui/src/pages/chat/components/chat-message.test.ts
ui/src/pages/chat/components/chat-session-workspace.ts
ui/src/pages/chat/components/chat-sidebar.ts
ui/src/pages/chat/components/chat-thread.ts
ui/src/pages/chat/components/chat-tool-cards.ts
ui/src/pages/chat/composer-persistence.test.ts
ui/src/pages/chat/composer-persistence.ts
@@ -921,7 +909,6 @@ ui/src/pages/chat/tool-stream.ts
ui/src/pages/config/config-page.ts
ui/src/pages/config/view.browser.test.ts
ui/src/pages/cron/view.ts
ui/src/pages/new-session/new-session-page.ts
ui/src/pages/plugins/plugins-page.ts
ui/src/pages/plugins/view.ts
ui/src/pages/sessions/sessions-page.ts
+3 -3
View File
@@ -1,5 +1,5 @@
{
"core": 2295,
"channel": 3716,
"plugin": 4040
"core": 2300,
"channel": 3582,
"plugin": 3997
}
+4 -4
View File
@@ -1,4 +1,4 @@
b0973756164132b2f14542be9af4d48a927abbcf482ad8746742d7391b9f9d36 config-baseline.json
8c3ffcba19ab9f88fa331d24c1e928ac817a1c5453518ccf5c78edc9995880e8 config-baseline.core.json
ddcf52b6ca3b83d8a72a74e0808abf4b16ab17b5fc28bfca5856373590913388 config-baseline.channel.json
d93639a3d59b9b7ecaa27ff38b844a9ec90ac074c9e53f02930146ed21665c66 config-baseline.plugin.json
c6ae555c7162c4ed1472fc5e29f897e0a0029efef2e4d03f2ed19af7a972045d config-baseline.json
c8a10d970dc9f2272207ca399cfff6e11321096c92e5f688fa5be4e5475aa767 config-baseline.core.json
552f5ae69ac13628d754e796bace6e800242d09cacbe762593c17ef3693ba754 config-baseline.channel.json
4bcc2364924c80f38139f0508945b6d28b33b70dd2973f0685a8f221d672ac94 config-baseline.plugin.json
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"74ea0a5fceaa6d9219f2d643174784dff0e56abacb7b456bba9237f6890e825b","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"}
{"contentHash":"341f8faa2d27ffc682259647b34b123a75f794190de8e31e317662bbf81bba4b","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"}
@@ -1 +1 @@
{"contentHash":"f23af38bfa07c1a52b003a480b9aff1a6e8ada00c47f7eaf7e474c4aa8d9021b","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"}
{"contentHash":"90366ab23e5ff37d52ddcab17f2aae75a5fb4cbd297c60dc71ad2784ce886f6f","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"}
@@ -1 +1 @@
{"contentHash":"2f1582d31bcc2a1d9134997e042280185188077210811984c8a39e9a754330fe","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"}
{"contentHash":"df2dc27d5a515deba41696812a202d09ae86d06e4c6f09030e747d0e2f3112ec","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"}
@@ -1 +1 @@
{"contentHash":"5e739e6ec9fd1a63b1fad71781d063c47b0e2169d8e39cc8cbf21352ed1333f4","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"32b0a82676f998c3b3e1df8b319c9b930dc75c46933d4130ea738e29230c287a","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"7a620697c8689b8ddd08f9d9ec31e54240455158e43277946178caa8b81c3872","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"f00efd8f5dfd884bed1e0941d9c3ab3251ace31e5ddb9710d1c75e8ad5ce2393","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
@@ -1 +1 @@
{"contentHash":"195a863039b6a651c716bf7ea6e6453903fb45a2c09806dcdc1b6605375403ac","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"}
{"contentHash":"6897cf178237b50feefa44bfeb73cee9a1715585019671f216213e4f40bf4b82","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6667d863dee0c991c58196b0a77fa812fc1800fca9885c866abd04f3df1a03ce","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
{"contentHash":"1d734d04b3acd39d2584c0ce81931fd0d5d00fab706a280d96e0ee01d21fdd4a","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
@@ -1 +1 @@
{"contentHash":"7ebe50be2549b7166286b64e28ef320f1de5ca8305a76addbd8871b04d51af77","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"}
{"contentHash":"973e8db95ce90786af9744d715418bf73ce9a3829d68982ff338f78644c3fd3f","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"}
@@ -1 +1 @@
{"contentHash":"7567ce81ce9192aaf2d546c6570f42d9e08f4a170f46734ea8ca88c2643060c1","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"}
{"contentHash":"7e9f6692a46924d0063b60a7b1c728bdee74a842c4ed2230a76a104c63b128bd","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"}
@@ -1 +1 @@
{"contentHash":"91de2702060fb65adc7ff50e78f7209454581fba4dbe285eb1cea20dba45b17a","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"}
{"contentHash":"bc989599b14494e14bb5a36f5a13596cad1f5e30bdbaff83b99cdfe93d0a3490","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"}
@@ -1 +1 @@
{"contentHash":"4533e6bcac0133c7809df13dc6bee8374441ff0b9b09c4c6dce7752e14871c42","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"}
{"contentHash":"e5e17b876f6a943be32fef5c3b5189ce1b1dc39b8e8853ed9adb73b78ed23b8e","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"}
@@ -1 +1 @@
{"contentHash":"14417cf83a13c7597febb9ba14211231876b9b449d89ef964fbc86ab81a73da6","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"}
{"contentHash":"13e5797a127d9900e804963e7b0ab4966e17e96bcd5a6e772aaa3a6a3a6c223d","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"}
@@ -1 +1 @@
{"contentHash":"932de389fe2df73f703c5b77d79c36845e79cb2915d3b66b7756d479c434f24c","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"}
{"contentHash":"cc1ad6dbd5258e2c2b40f345866ab3c19cec6d1a779e9e36f007bb3639ff52d5","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"86ea00b5f1f272b84c63ae497b5abfadebdfa51d009eb09ace4e8499696fd4ba","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"}
{"contentHash":"1a765a2b51751cdac6f57ad06be857445bce69cbe0f94da09f0d4e54ab7b665e","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"}
@@ -1 +1 @@
{"contentHash":"80835d0bb17661d85c9c3315724e37ba3801d69c7f58fc555c15466f35ef651f","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"}
{"contentHash":"eaaf9e2c1ea291d0111788ebf04f0ec806b25a77e09a367ff93e7a808102607c","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"}
@@ -1 +1 @@
{"contentHash":"be74733e90c57f98afd388799824e68532b3d8a5fc46f51abbc0ae6b1a2acfe6","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"}
{"contentHash":"7682bb32e47cb9a4feda91f218507358799d09f5886c27efdc4319cd133bb057","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"}
@@ -1 +1 @@
{"contentHash":"bc96dace6be0e69bf7bd8ec89efebacbd507be2c9b5a47844183f6bb3374c56f","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"}
{"contentHash":"45770b7d266ec06025bdd370beaced6261a950e68744a29aa7ac6569076b4b49","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"}
@@ -1 +1 @@
{"contentHash":"f74ea9a2fb50bf48fa29955825135ac63ad387099150cc784b0f5e9cb93fda3c","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"}
{"contentHash":"a8010cc04c53d88f4ce795af44c5cc0609029e2a5409ca70cd83237c27ade00a","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"}
@@ -1 +1 @@
{"contentHash":"30b463a4e09c326f52255ad4015c8546c7bca5475d33d784969e2cc4f1feed40","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"}
{"contentHash":"44832134039fb5a9b5c3d001ee0daf500a55ef44eedbf31e19756735e7bf8bff","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c462572277db06da0e31193b91fef1ff87682602665148d89fbbf848929f11a9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"e4158783bc6e43a8ae391a97b762d5e035c2b9ca44d28e9a2c3c0ecda0ce29c9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"f25d352d0cdce67f2455b006129d6661d8d254d31774ffc6923e9d4c92eb9250","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"}
{"contentHash":"709a490a231838b6b65fdce47fe05fd3aad609398f573d6b45bfcceec5852a66","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"}
@@ -1 +1 @@
{"contentHash":"c202b9e8bbbcc1d35d29e9ce92e0d9fd5a08a5a9c2c4c6a4eb6f621a52839da9","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"7ff5e9dba022e6d2ca5f0aa0d52276dc957336b94e842730ced0e8511f1a3379","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"551b4cd1d6940447dd445cc28f07544ad6f5974d65e241a4816f8d03a69b8f82","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"}
{"contentHash":"e60e497ec83a74c69afdad695939551cc65b13527f0b38bfac88f2bf5fbe6ce2","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"}
@@ -1 +1 @@
{"contentHash":"e77cdd92e4f38cdd2a700cfc542402be3aae560c6c0c5c309c39286bdbb61abe","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"}
{"contentHash":"6aa8947155130bdf1eeaf2387d4fe855d78702f3bd82cc518d907244e8f7f089","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"}
@@ -1 +1 @@
{"contentHash":"e9cc066cb5ea878cdd03c53d9114b2ff650a00bb7c74fa31a3fa6be1e9f04668","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"6add8ecd718fa670825ac3420b85e3acbb17fdf86e21d14f308f2f2657f1e5be","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
@@ -1 +1 @@
{"contentHash":"d98f19fb7df1291cfe33dfe01b99485108dbeff47768eedf3b51a3fd4bce76a0","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"}
{"contentHash":"c0541f2781168816d232c6690ed8896f422ce2b7ce950f117328cc2eeda99d0e","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"}
@@ -1 +1 @@
{"contentHash":"22a0413c4e79e1c1cd51e122681bf7ad3e7c867668e61dd2f510ea9e14968891","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"c3046c34991b6f1198e85626a65ba99b051ce5f0164939dd20f3ad5507defed9","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"810a5da4a06925554ab89f9462a17f1fb9fa3196277227eaa383aa82b3b81e58","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"a5ad372d98616d72d4e0e2f9b94748d225220cc7cb48be03504d4a543d325ccf","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"7375a14d1a2ead1dce9d13bc9a6c5c94dc7e809cde37860a7ace9fce480b6751","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
{"contentHash":"83162d60aa40acbfd9752b887848447842690d21e8432abcbe4dfd0b8d346567","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
@@ -1 +1 @@
{"contentHash":"604289da3812346c5a080a9f867a32f4fcc0e06a9837f61395c961d8363d8b9d","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"4f0930ae4e406c8fcf0ec0bcd40221bf0d5abb7065f767354b96a1df84799b9a","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"2dfa0507e128854c5df2f833e57e56c4cabdf6e32c4d79f26fe21b674b240f69","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"}
{"contentHash":"32933d2e3143e7e5db2a0b032187f3eef2353d7f25ad64d079bb572c5ba40aae","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"}
@@ -1 +1 @@
{"contentHash":"1262cc14d9cb4c639ead54a2c2e4c1042cb836b73bdd9d3a39a9664ae04241e9","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"}
{"contentHash":"3b5e25a136fd2cd150a20f35c7c92e4a36f2384e980d8b8f3c5597da0ad76f70","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"}
@@ -1 +1 @@
{"contentHash":"bc8c23c5a5108c1781648509f7b4c6c07ad4e4a064f726d517ece59d8907f19c","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"}
{"contentHash":"e2c744c0afe545e4cd57b5aaba6db9133187bde3bf462c063c000773846d5803","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"}
@@ -1 +1 @@
{"contentHash":"812007b404b41c995529acbc1fadc9fd5661a6a87676bb5dce2f30e52327becf","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"}
{"contentHash":"6d80a38ab05277753106ded06e3299b53e0679c695aaf4178f393cd55a6c5dda","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"}
@@ -1 +1 @@
{"contentHash":"24e36948ee19def3102474d3e3ba0964515db1ef0e06eafdc66ebc733828bb13","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"}
{"contentHash":"b4d94ff4e37aa07c2944bce09a7b644cbd3844ee2aa3b1ddf9b378848d27e250","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"e981991086dc04c69b385d4860acffcb332560368283000ed42b1e4db5a43896","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"}
{"contentHash":"ca58d4cec17d38db2573603e47743ec9ad1cf64b35b6f7453a36f4e2cc16f02e","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"}
@@ -1 +1 @@
{"contentHash":"4fa7e5cf0b6aadbaef2a7ad6dcf0cd732f0284a061e8006e6e1353daec6a4224","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"}
{"contentHash":"ae33abaed302c429a458f4062e99151e5f9e3c86d345278633555eef00fbb6fd","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"}

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