mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Merge origin/main and address review findings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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__/**",
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
+217
-217
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()
|
||||
|
||||
+31
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}"#
|
||||
|
||||
|
||||
+10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2295,
|
||||
"channel": 3716,
|
||||
"plugin": 4040
|
||||
"core": 2300,
|
||||
"channel": 3582,
|
||||
"plugin": 3997
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user