mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 00:11:53 +08:00
Compare commits
112 Commits
v2026.6.9-
...
codex-prot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42140a2d70 | ||
|
|
3b180d5d99 | ||
|
|
b9095bf70d | ||
|
|
049c3c4877 | ||
|
|
1265da2a5c | ||
|
|
cfdabfbaab | ||
|
|
e3fe6715af | ||
|
|
0948bd648a | ||
|
|
d07cd4c968 | ||
|
|
db5b883a9c | ||
|
|
fd7e181500 | ||
|
|
3407402b2c | ||
|
|
05a0dfdd08 | ||
|
|
7f9f687d82 | ||
|
|
7f1d82ab25 | ||
|
|
9408380ae7 | ||
|
|
0a3aa5f278 | ||
|
|
a3d5e5bc72 | ||
|
|
7cb2571a99 | ||
|
|
7b7e8f6e88 | ||
|
|
60459d4061 | ||
|
|
54288a1e7c | ||
|
|
de4b8d8ebf | ||
|
|
52bc2a12bc | ||
|
|
ca4e4d93d2 | ||
|
|
92418fc9da | ||
|
|
c7b4c6bfc5 | ||
|
|
c39dea917a | ||
|
|
40aad24e8a | ||
|
|
69a73b6278 | ||
|
|
54c400a975 | ||
|
|
e9671ed603 | ||
|
|
b9280d5863 | ||
|
|
b4cdd92119 | ||
|
|
5a0b95269d | ||
|
|
69b95c3447 | ||
|
|
bf89552e67 | ||
|
|
a8d33f23a0 | ||
|
|
6c045c5ca3 | ||
|
|
bfccbc3fee | ||
|
|
9a1f2022b1 | ||
|
|
5967ae61bd | ||
|
|
48ec58a584 | ||
|
|
c0a4a7890d | ||
|
|
0a6a10193d | ||
|
|
c350c35fad | ||
|
|
56dc53f6d2 | ||
|
|
ec0f311f7f | ||
|
|
f0d8048aa3 | ||
|
|
54415d322f | ||
|
|
3a9ea1d85b | ||
|
|
a90eb93452 | ||
|
|
468db12c21 | ||
|
|
4ca6ac326e | ||
|
|
9833f3ea9b | ||
|
|
e9bd90d209 | ||
|
|
314de694c4 | ||
|
|
4648701fc1 | ||
|
|
3b7631e50d | ||
|
|
21104cd52e | ||
|
|
6ab084e89d | ||
|
|
e16193bad5 | ||
|
|
31d49c59d7 | ||
|
|
ef2ca9e50e | ||
|
|
09854d9de7 | ||
|
|
d31d26ef42 | ||
|
|
440284f879 | ||
|
|
784e86433c | ||
|
|
a82abc771a | ||
|
|
a93bc61a84 | ||
|
|
b502a92bf1 | ||
|
|
21410d1c32 | ||
|
|
a4e02cd1dd | ||
|
|
b6a3f2988c | ||
|
|
bf95883812 | ||
|
|
d2ddc26e89 | ||
|
|
96a49caffa | ||
|
|
2649064548 | ||
|
|
370cef2e3b | ||
|
|
a2dd821908 | ||
|
|
443115c632 | ||
|
|
5b9cb3bd3a | ||
|
|
8b84e951e5 | ||
|
|
52154eda0d | ||
|
|
3853eb15af | ||
|
|
696c1ecd20 | ||
|
|
2da7dc9f2c | ||
|
|
439b0582e2 | ||
|
|
f13b6ea151 | ||
|
|
56d201fa67 | ||
|
|
9bb68b55dd | ||
|
|
d48778994f | ||
|
|
479e2aaae3 | ||
|
|
1893a0727a | ||
|
|
61e93a800d | ||
|
|
da7f9c51df | ||
|
|
55156a1241 | ||
|
|
cc856cde1b | ||
|
|
4106f446bd | ||
|
|
8c3ba33463 | ||
|
|
86bab9699d | ||
|
|
d2a6529f04 | ||
|
|
2dcfd9f218 | ||
|
|
d86069ded0 | ||
|
|
7269b26926 | ||
|
|
224ea76d29 | ||
|
|
cc0a18da4f | ||
|
|
48b3cb69b7 | ||
|
|
cfe0bac99a | ||
|
|
7f69fe009a | ||
|
|
7cdec28706 | ||
|
|
9f413acc18 |
8
.github/workflows/install-smoke.yml
vendored
8
.github/workflows/install-smoke.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
if: steps.existing.outputs.exists != 'true'
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -429,7 +429,7 @@ jobs:
|
||||
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -126,7 +126,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -1497,7 +1497,7 @@ jobs:
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.needs_build == '1'
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -1593,8 +1593,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repository="${GITHUB_REPOSITORY,,}"
|
||||
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
|
||||
live_image_extensions="matrix,acpx"
|
||||
live_image_tag_suffix="${live_image_extensions//,/-}"
|
||||
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}"
|
||||
echo "live_image=${live_image}" >> "$GITHUB_OUTPUT"
|
||||
echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT"
|
||||
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Log in to GHCR
|
||||
@@ -1617,7 +1620,7 @@ jobs:
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.exists != '1'
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -1629,7 +1632,7 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
target: build
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.live_image }}
|
||||
sbom: true
|
||||
|
||||
32
.github/workflows/qa-live-transports-convex.yml
vendored
32
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -159,7 +159,7 @@ jobs:
|
||||
run_mock_parity:
|
||||
name: Run QA Lab mock parity lane
|
||||
needs: [validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
name: Run live runtime token-efficiency lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
environment: qa-live-shared
|
||||
env:
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run live runtime parity lane
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
name: Run Matrix live QA lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
name: Run Matrix live QA lane (${{ matrix.profile }})
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
strategy:
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane shard
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
run_live_telegram:
|
||||
name: Run Telegram live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -520,7 +520,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
@@ -575,7 +575,7 @@ jobs:
|
||||
run_live_discord:
|
||||
name: Run Discord live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
run_live_whatsapp:
|
||||
name: Run WhatsApp live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
@@ -712,7 +712,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
@@ -766,7 +766,7 @@ jobs:
|
||||
run_live_slack:
|
||||
name: Run Slack live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -806,7 +806,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
|
||||
95
.github/workflows/stale.yml
vendored
95
.github/workflows/stale.yml
vendored
@@ -509,60 +509,62 @@ jobs:
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
let cursor = null;
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
const result = await github.graphql(
|
||||
`query ClosedIssuesForLocking(
|
||||
$owner: String!
|
||||
$repo: String!
|
||||
$cursor: String
|
||||
$perPage: Int!
|
||||
) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(
|
||||
first: $perPage
|
||||
after: $cursor
|
||||
states: CLOSED
|
||||
orderBy: { field: CREATED_AT, direction: ASC }
|
||||
) {
|
||||
nodes {
|
||||
number
|
||||
locked
|
||||
closedAt
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
cursor,
|
||||
perPage,
|
||||
},
|
||||
);
|
||||
const issues = result.repository.issues;
|
||||
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
if (issue.locked) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.closed_at) {
|
||||
for (const issue of issues.nodes) {
|
||||
if (issue.locked || !issue.closedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs)) {
|
||||
continue;
|
||||
}
|
||||
if (closedAtMs > cutoffMs) {
|
||||
const closedAtMs = Date.parse(issue.closedAt);
|
||||
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const lastComment = issue.comments.nodes[0];
|
||||
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
|
||||
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
|
||||
if (lastActivityMs > cutoffMs) {
|
||||
continue;
|
||||
@@ -578,7 +580,10 @@ jobs:
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = issues.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
|
||||
69
.github/workflows/workflow-sanity.yml
vendored
69
.github/workflows/workflow-sanity.yml
vendored
@@ -34,10 +34,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
@@ -78,10 +93,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
@@ -190,10 +220,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
|
||||
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
|
||||
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
|
||||
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
|
||||
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
|
||||
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
|
||||
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
|
||||
|
||||
@@ -48,6 +48,7 @@ These patterns are usually not vulnerabilities by themselves:
|
||||
|
||||
- Prompt injection without a policy, auth, approval, sandbox, or tool-boundary bypass.
|
||||
- A trusted operator using an intentional local feature, such as local shell access or browser/script execution.
|
||||
- A report whose only primitive is changing the process or child-process environment before running OpenClaw or an executable OpenClaw invokes.
|
||||
- A malicious plugin after a trusted operator installs or enables it.
|
||||
- Multiple adversarial users sharing one Gateway host/config and expecting per-user isolation.
|
||||
- Scanner-only, dependency-only, or stale-path reports without a working repro and demonstrated OpenClaw impact.
|
||||
@@ -103,6 +104,7 @@ These are frequently reported but are typically closed with no code change:
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on attacker-controlled environment variables changing executable behavior, including variables that redirect lookup paths, preload code, select wrappers/interpreters, alter package-manager or runtime hooks, or make one executable call another executable. Control of the process or child-process environment is trusted host/operator control in OpenClaw's model; these reports need a separate OpenClaw boundary bypass that lets untrusted input set or mutate that environment.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface.
|
||||
@@ -161,6 +163,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports whose only claim is environment-variable-driven executable behavior change, including path lookup changes, preload hooks, wrapper/interpreter selection, package-manager/runtime hooks, or variables that make an executable invoke another executable, unless a separate OpenClaw boundary bypass lets untrusted input set or mutate that environment.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass.
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
@@ -181,6 +184,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
OpenClaw security guidance assumes:
|
||||
|
||||
- The host where OpenClaw runs is within a trusted OS/admin boundary.
|
||||
- Anyone who can set or mutate the OpenClaw process environment, launcher environment, or child-process environment is inside that trusted host/operator boundary.
|
||||
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
|
||||
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
|
||||
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
|
||||
|
||||
127
appcast.xml
127
appcast.xml
@@ -2,6 +2,86 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.5</title>
|
||||
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606000590</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.5</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw <code><thinking></code> content from leaking into channel replies. (#89913, #90132) Thanks @openperf.</li>
|
||||
<li>MCP tool results now coerce <code>resource_link</code>, <code>resource</code>, <code>audio</code>, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
|
||||
<li>Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for <code>message_start</code>, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.</li>
|
||||
<li>Parallel is now a bundled <code>web_search</code> provider with <code>PARALLEL_API_KEY</code> discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.</li>
|
||||
<li>Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.</li>
|
||||
<li>Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)</li>
|
||||
<li>Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)</li>
|
||||
<li>Agent, tool, and provider loops are stricter around MCP lease timestamps, prompt-cache tool names, local tool catalogs, unreadable dynamic tools, owner-only HTTP tools, and provider catalog metadata, reducing hidden retries and unsafe exposure. (#91124, #91233, #90022, #90261)</li>
|
||||
<li>macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.</li>
|
||||
<li>Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded <code>api.parallel.ai/v1/search</code> support. (#85158) Thanks @NormallyGaussian.</li>
|
||||
<li>Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)</li>
|
||||
<li>Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Skills/ClawHub: avoid one filesystem watcher per skill file during refresh, keeping large skill trees from exhausting watcher limits.</li>
|
||||
<li>Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.</li>
|
||||
<li>Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, Android adds theme mode selection, and iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, fallback copy, and unavailable Talk controls reachable. (#90752, #91201)</li>
|
||||
<li>Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)</li>
|
||||
<li>Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward <code>web_fetch</code>, clarify legacy <code>openai-codex</code> auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.</li>
|
||||
<li>Release/process: switch release trains to <code>YYYY.M.PATCH</code> monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at <code>2026.6.5</code>.</li>
|
||||
<li>Release/process: defer the session-metadata SQLite migration from the <code>2026.6.5</code> beta train so this release keeps the existing JSON-backed session metadata path while the migration risk is worked on <code>main</code>.</li>
|
||||
<li>Release metadata: align OpenClaw, publishable plugin manifests, generated shrinkwraps, app version metadata, iOS release notes, Matrix plugin changelog, and generated release baselines with the <code>2026.6.5</code> release train.</li>
|
||||
<li>Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.</li>
|
||||
<li>Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
|
||||
<li>Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until <code>message_start</code>, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.</li>
|
||||
<li>Agents/Codex/tools: MCP lease release no longer refreshes <code>lastUsedAt</code>, prompt-cache tool names are guarded, lean local tool catalogs stay compact, unreadable dynamic tools are quarantined, orphan tool errors still surface, native subagent completion results survive app-server monitoring, and background-session name derivation avoids regex backtracking risk. (#91124, #90612, #90022, #91235, #91233)</li>
|
||||
<li>Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.</li>
|
||||
<li>Provider/model resolution: live provider model catalogs keep helper coverage, Ollama catalog metadata is preserved, Google provider prefixes are stripped from Gemini paths, Foundry Responses reasoning replay ids survive, MiniMax M3 thinking stays enabled, Vertex multi-region calls use the right regional host, and OpenRouter streamed generation cost is reconciled. (#91125)</li>
|
||||
<li>Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.</li>
|
||||
<li>Gateway/security/config: owner-only HTTP tools are gated, sandbox skills remain readable in writable sandboxes, legacy agent registry and Codex model metadata migrate safely, and stalled MCP response bodies time out instead of tying up Gateway workers. (#90261)</li>
|
||||
<li>Gateway/config: <code>config.patch</code> now preserves explicit array replacement semantics for arrays without merge keys, so replacement patches do not accidentally merge stale entries. (#91551)</li>
|
||||
<li>SDK: event pump failures now surface to clients instead of being swallowed behind a quiet iterator shutdown.</li>
|
||||
<li>Agents/transcripts: inline image payload redaction now catches data URLs and repaired transcript images before they can leak raw image bytes into stored or exported transcripts. (#91529)</li>
|
||||
<li>Plugins/Gateway: legacy flat Control UI descriptors from shipped JavaScript plugins now normalize <code>name</code> and missing surface fields into session descriptors, restoring Kitchen Sink RPC descriptor proof for package-backed plugin validation.</li>
|
||||
<li>TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.</li>
|
||||
<li>Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, isolated agent turn payload messages preserve timeout context, service env planning skips unresolved placeholders that would mask state-dir <code>.env</code> values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #91230, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.</li>
|
||||
<li>State/storage: Matrix sync and crypto sidecars, memory-wiki import/source-sync state, sandbox registry state, ACPX process state, device-pair notify state, Zalo hosted media, and plugin SDK dedupe state now use SQLite-owned storage instead of ad hoc runtime files. (#91100, #91108, #91056)</li>
|
||||
<li>Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)</li>
|
||||
<li>Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on <code>globalThis</code> and default replies stay inside existing Mattermost threads instead of starting new ones; Feishu streaming cards preserve full merged content; iMessage private-API failures and send timeouts explain themselves while split-send coalescing honors balloon metadata; voice-call tracks Twilio streams after connect; ClickClack reply tools respect <code>toolsAllow</code>; Discord runtime adapters stay resolvable; and outbound delivery retries survive budget deferrals. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500, #91041, #90858, #91119, #91241) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, @sahibzada-allahyar, and @jacobtomlinson.</li>
|
||||
<li>Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.</li>
|
||||
<li>WhatsApp: captured replies after restart now route through the successor controller instead of the stale pre-restart controller. (#85823)</li>
|
||||
<li>Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.</li>
|
||||
<li>Release/CI/E2E: installed-package root dist verification now allows the current package's JavaScript file count while keeping dependency, per-file-size, and scan-bound checks active.</li>
|
||||
<li>Release/CI/E2E: Chutes OAuth model-discovery proof now accepts standard <code>Headers</code> requests, and QR package install smoke caps Docker CPU requests to the hosted runner capacity so beta validation fails on real package regressions.</li>
|
||||
<li>Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.</li>
|
||||
<li>Release/CI/E2E: Docker E2E CPU limits now cap to the runner capacity, keeping package Telegram acceptance on hosted 8-vCPU runners focused on package regressions instead of impossible Docker resource requests.</li>
|
||||
<li>Release/CI/E2E: task maintenance release checks now reset pinned config around isolated temp state dirs, keeping normal CI focused on the active session-store fixture instead of stale process snapshots.</li>
|
||||
<li>Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.</li>
|
||||
<li>Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.</li>
|
||||
<li>Release/CI/E2E: browser snapshot, release-scenario, release-user-journey, Telegram desktop/RTT/package, web-search, Parallels update, plugin update, doctor switch, and upgrade-survivor diagnostics now stream or bound log/artifact reads so failed proof stays inspectable without unbounded output.</li>
|
||||
<li>Release/CI/E2E: Parallels smoke validation now runs without requiring <code>pnpm</code> on the host, supports already-started Windows/Linux guests without snapshots, reports empty snapshot metadata clearly, and finds portable user-local Node on Windows.</li>
|
||||
<li>Release/CI/E2E: ClawHub publish jobs prepare dependencies after checking out the target ref, and Docker store seed package discovery now targets the intended production packages. (#91547)</li>
|
||||
<li>Release/CI/E2E: QA Lab capability-flip release validation now marks intentional <code>tools.deny</code> restores as array replacements, so beta validation fails only on real capability regressions.</li>
|
||||
<li>Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.</li>
|
||||
<li>Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.</li>
|
||||
<li>Tests/state isolation: QA Lab runtime parity now treats matching controlled tool errors as equivalent and falls back to transcript tool results when mock debug rows miss async image-generation starts.</li>
|
||||
<li>Tests/state isolation: QA suites now fail closed on skipped summaries, missing runtime tool proof, planned-only rows, loose release limits, missing live/provider artifacts, failed agent reply markers, and package Telegram summary failures.</li>
|
||||
<li>Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)</li>
|
||||
<li>Sessions: the beta SQLite downgrade rescue now skips extra pre-reads for active non-empty JSON session stores, preserving cache race detection while still restoring missing or empty beta session files.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
@@ -193,52 +273,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.27</title>
|
||||
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
|
||||
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
|
||||
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
|
||||
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
|
||||
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
|
||||
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
|
||||
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
|
||||
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
|
||||
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
|
||||
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
|
||||
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
|
||||
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
|
||||
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
|
||||
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
|
||||
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
|
||||
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
|
||||
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
|
||||
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
struct AgentProDreamingDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let overviewLoading: Bool
|
||||
@@ -20,6 +21,7 @@ struct AgentProDreamingDestination: View {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.detailSummaryCard(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
@@ -57,6 +59,23 @@ struct AgentProDreamingDestination: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Dreaming",
|
||||
subtitle: self.dreamingDetail,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DreamAction: String, CaseIterable, Identifiable {
|
||||
case backfill
|
||||
case repair
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AgentProNodesDestination: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let agentCount: Int
|
||||
@@ -16,6 +17,7 @@ struct AgentProNodesDestination: View {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.summaryCard
|
||||
self.totalsCard
|
||||
self.nodesList
|
||||
@@ -27,16 +29,33 @@ struct AgentProNodesDestination: View {
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Nodes")
|
||||
.navigationTitle("Instances")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Instances",
|
||||
subtitle: self.instancesDetail,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: self.instancesColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Nodes")
|
||||
Text("Instances")
|
||||
.font(.headline)
|
||||
Text(self.instancesDetail)
|
||||
.font(.caption)
|
||||
@@ -70,16 +89,16 @@ struct AgentProNodesDestination: View {
|
||||
|
||||
private var nodesList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Connected Nodes")
|
||||
ProSectionHeader(title: "Connected Instances")
|
||||
ProCard(padding: 0) {
|
||||
let nodes = self.sortedPresenceEntries
|
||||
if nodes.isEmpty {
|
||||
self.emptyRow(
|
||||
icon: "display",
|
||||
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
|
||||
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not report any system presence entries."
|
||||
: "Connect a gateway to inspect connected nodes.")
|
||||
: "Connect a gateway to inspect connected instances.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
@@ -114,7 +133,7 @@ struct AgentProNodesDestination: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(Self.presenceDetail(entry))
|
||||
@@ -153,7 +172,7 @@ struct AgentProNodesDestination: View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
.font(.headline)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
@@ -192,7 +211,7 @@ struct AgentProNodesDestination: View {
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ extension AgentProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: AgentRoute) -> some View {
|
||||
switch route {
|
||||
case .agents:
|
||||
self.agentsDestination
|
||||
case .skills:
|
||||
self.skillsDestination
|
||||
case .nodes:
|
||||
self.nodesDestination
|
||||
case .instances:
|
||||
self.instancesDestination
|
||||
case .cron:
|
||||
self.cronDestination
|
||||
case .usage:
|
||||
@@ -19,6 +21,26 @@ extension AgentProTab {
|
||||
}
|
||||
}
|
||||
|
||||
var agentsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.rosterHeader
|
||||
self.agentFilters
|
||||
self.agentsSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Agents")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
@@ -46,8 +68,9 @@ extension AgentProTab {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var nodesDestination: some View {
|
||||
var instancesDestination: some View {
|
||||
AgentProNodesDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
agentCount: self.appModel.gatewayAgents.count,
|
||||
@@ -64,6 +87,10 @@ extension AgentProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .cron,
|
||||
title: "Cron Jobs",
|
||||
subtitle: self.cronDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron Jobs",
|
||||
@@ -89,6 +116,10 @@ extension AgentProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .usage,
|
||||
title: "Usage",
|
||||
subtitle: self.usageDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
@@ -111,6 +142,7 @@ extension AgentProTab {
|
||||
|
||||
var dreamingDestination: some View {
|
||||
AgentProDreamingDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
overviewLoading: self.overviewLoading,
|
||||
@@ -122,6 +154,27 @@ extension AgentProTab {
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
|
||||
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
|
||||
self.directRoute == route ? self.headerLeadingAction : nil
|
||||
}
|
||||
|
||||
func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
|
||||
@@ -5,18 +5,19 @@ import SwiftUI
|
||||
extension AgentProTab {
|
||||
var rosterHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Agents")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
Text("\(self.sortedAgents.count) total")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: "\(self.sortedAgents.count) total",
|
||||
titleFont: .system(size: 28, weight: .bold),
|
||||
subtitleFont: .subheadline,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
} accessory: {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayPillButton
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
@@ -56,6 +57,19 @@ extension AgentProTab {
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -140,7 +154,7 @@ extension AgentProTab {
|
||||
value: self.instancesValue,
|
||||
detail: self.instancesDetail,
|
||||
color: self.instancesColor,
|
||||
route: .nodes)
|
||||
route: .instances)
|
||||
self.metricTile(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron",
|
||||
|
||||
@@ -6,6 +6,12 @@ struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let initialRoute: AgentRoute?
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
let openSettings: (() -> Void)?
|
||||
@State var navigationPath: [AgentRoute] = []
|
||||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@@ -31,8 +37,9 @@ struct AgentProTab: View {
|
||||
@State var cronActionStatusText: String?
|
||||
|
||||
enum AgentRoute: Hashable {
|
||||
case agents
|
||||
case skills
|
||||
case nodes
|
||||
case instances
|
||||
case cron
|
||||
case usage
|
||||
case dreaming
|
||||
@@ -119,8 +126,42 @@ struct AgentProTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
initialRoute: AgentRoute? = nil,
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let directRoute {
|
||||
self.directDestination(for: directRoute)
|
||||
} else {
|
||||
self.overviewNavigation
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewNavigation: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
@@ -143,15 +184,22 @@ struct AgentProTab: View {
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
self.destination(for: route)
|
||||
.toolbar(
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,25 @@ struct ChatProTab: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
let showsAgentBadge: Bool
|
||||
let openSettings: (() -> Void)?
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String? = nil,
|
||||
headerSubtitle: String? = nil,
|
||||
showsAgentBadge: Bool = true,
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.headerSubtitle = headerSubtitle
|
||||
self.showsAgentBadge = showsAgentBadge
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -67,7 +86,30 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 11) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerDisplayTitle,
|
||||
subtitle: self.headerDisplaySubtitle,
|
||||
titleFont: .headline.weight(.semibold),
|
||||
subtitleFont: .caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
HStack(spacing: 11) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
self.headerIdentityBadge
|
||||
}
|
||||
} accessory: {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerIdentityBadge: some View {
|
||||
if self.showsAgentBadge {
|
||||
Text(self.agentBadge)
|
||||
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
@@ -86,24 +128,9 @@ struct ChatProTab: View {
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(self.agentDisplayName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text("AI Assistant")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.connectionPill
|
||||
} else {
|
||||
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func syncChatViewModel() {
|
||||
@@ -162,37 +189,93 @@ struct ChatProTab: View {
|
||||
?? "main"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectionPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
self.connectionPill
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
self.connectionPill
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
Text(self.gatewayConnected ? "Connected" : "Connecting")
|
||||
ProStatusDot(color: self.gatewayPillColor)
|
||||
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
.foregroundStyle(self.gatewayPillColor)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
|
||||
.fill(self.gatewayPillColor.opacity(0.11))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
|
||||
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var gatewayPillColor: Color {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
isGatewayUsable ? "Connected" : "Unavailable"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var messagePlaceholder: String {
|
||||
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
|
||||
}
|
||||
|
||||
private var headerDisplayTitle: String {
|
||||
self.normalized(self.headerTitle)
|
||||
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
|
||||
}
|
||||
|
||||
private var headerDisplaySubtitle: String {
|
||||
self.normalized(self.headerSubtitle) ?? "AI Assistant"
|
||||
}
|
||||
|
||||
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
|
||||
showsAgentBadge ? agentDisplayName : "Chat"
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct CommandPanel<Content: View>: View {
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent,
|
||||
padding: self.padding,
|
||||
radius: 12)
|
||||
radius: OpenClawProMetric.cardRadius)
|
||||
{
|
||||
self.content
|
||||
}
|
||||
@@ -34,40 +34,15 @@ struct CommandControlBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.34),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
.frame(height: 260)
|
||||
Color.white.opacity(0.20)
|
||||
.frame(height: 140)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private var darkColors: [Color] {
|
||||
[
|
||||
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
|
||||
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
|
||||
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
|
||||
]
|
||||
}
|
||||
|
||||
private var lightColors: [Color] {
|
||||
[
|
||||
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
|
||||
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
|
||||
.white,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionRow: View {
|
||||
@@ -114,12 +89,12 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
@@ -136,11 +111,11 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,21 +129,21 @@ struct CommandViewMoreRow: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,13 +174,13 @@ struct CommandEmptyStateRow: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.06))
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(Color(uiColor: .systemBackground))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
fileprivate static let recentSessionsFetchLimit = 200
|
||||
static let recentSessionsFetchLimit = 200
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
|
||||
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var headerTitle: String = "OpenClaw"
|
||||
var headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var showsHeaderMark: Bool = true
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
@@ -31,20 +35,37 @@ struct CommandCenterTab: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
self.defaultChatSessionSection
|
||||
self.recentSessions
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
if Self.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: self.horizontalSizeClass,
|
||||
containerWidth: geometry.size.width)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.defaultChatSessionSection
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
self.recentSessions
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
self.defaultChatSessionSection
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.recentSessions
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
@@ -53,12 +74,47 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
static func usesSplitSectionsLayout(
|
||||
horizontalSizeClass: UserInterfaceSizeClass?,
|
||||
containerWidth: CGFloat) -> Bool
|
||||
{
|
||||
guard horizontalSizeClass == .regular else { return false }
|
||||
return containerWidth >= 1000
|
||||
}
|
||||
|
||||
static func shouldShowHeaderMark(
|
||||
hasLeadingAction: Bool,
|
||||
showsHeaderMark: Bool) -> Bool
|
||||
{
|
||||
!hasLeadingAction && showsHeaderMark
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
Text("OpenClaw")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: self.gatewaySubtitle,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} else if Self.shouldShowHeaderMark(
|
||||
hasLeadingAction: headerLeadingAction != nil,
|
||||
showsHeaderMark: self.showsHeaderMark)
|
||||
{
|
||||
OpenClawProMark(size: 28, shadowRadius: 5)
|
||||
}
|
||||
} accessory: {
|
||||
Button(action: self.openSettings) {
|
||||
ProCapsule(
|
||||
title: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
@@ -86,7 +142,7 @@ struct CommandCenterTab: View {
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
@@ -160,7 +216,6 @@ struct CommandCenterTab: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var recentSessions: some View {
|
||||
@@ -200,7 +255,6 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
@@ -213,7 +267,8 @@ struct CommandCenterTab: View {
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if let badgeValue {
|
||||
Text(badgeValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
@@ -403,7 +458,7 @@ struct CommandCenterTab: View {
|
||||
return result
|
||||
}
|
||||
|
||||
fileprivate static func sessionWorkItem(
|
||||
static func sessionWorkItem(
|
||||
for session: OpenClawChatSessionEntry,
|
||||
currentSessionKey: String) -> WorkItem
|
||||
{
|
||||
@@ -558,14 +613,20 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommandSessionsScreen: View {
|
||||
struct CommandSessionsScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let openChat: () -> Void
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openChat = openChat
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
@@ -587,12 +648,18 @@ private struct CommandSessionsScreen: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
244
apps/ios/Sources/Design/IPadActivityScreen.swift
Normal file
244
apps/ios/Sources/Design/IPadActivityScreen.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct IPadActivityScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let openChat: () -> Void
|
||||
let openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
openChat: @escaping () -> Void,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openChat = openChat
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
IPadSidebarScreenChrome(
|
||||
title: "Activity",
|
||||
subtitle: "Live device and gateway activity.",
|
||||
headerLeadingAction: self.headerLeadingAction,
|
||||
gatewayAction: self.openSettings)
|
||||
{
|
||||
ProMetricGrid(metrics: self.metrics)
|
||||
self.activityFeed
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var metrics: [ProMetric] {
|
||||
[
|
||||
ProMetric(
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary),
|
||||
ProMetric(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline",
|
||||
color: OpenClawBrand.accent),
|
||||
ProMetric(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Sessions",
|
||||
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
|
||||
color: OpenClawBrand.accentHot),
|
||||
]
|
||||
}
|
||||
|
||||
private var activityFeed: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: self.isLoading ? "Loading" : nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {
|
||||
Task { await self.refreshSessions() }
|
||||
})
|
||||
|
||||
if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt {
|
||||
ProStatusRow(
|
||||
icon: "hand.raised.fill",
|
||||
title: "Approval needed",
|
||||
detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText,
|
||||
value: "pending",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
|
||||
ProStatusRow(
|
||||
icon: self.gatewayConnected ? "network" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayDetailText,
|
||||
value: self.gatewayStateText.lowercased(),
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
actionTitle: self.gatewayConnected ? nil : "Settings",
|
||||
action: self.gatewayConnected ? nil : self.openSettings)
|
||||
|
||||
Divider().padding(.leading, 58)
|
||||
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: self.appModel.lastShareEventText,
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
|
||||
if self.isLoading, self.sessions.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if let loadErrorText {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: loadErrorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if self.sessionRows.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline",
|
||||
detail: self.sessionsAvailable
|
||||
? "Start a chat and it will appear here."
|
||||
: "Connect to the gateway to load recent chat activity.",
|
||||
value: self.sessionsAvailable ? "empty" : "offline",
|
||||
color: .secondary,
|
||||
actionTitle: self.sessionsAvailable ? "Chat" : nil,
|
||||
action: self.sessionsAvailable ? self.openChat : nil)
|
||||
} else {
|
||||
ForEach(self.sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {
|
||||
self.open(row)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.sessionsMode,
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Online" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? "Offline" : status
|
||||
}
|
||||
|
||||
private var gatewayDetailText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "No gateway connection"
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
self.sessions
|
||||
.filter { CommandCenterTab.isRecentChatSession(
|
||||
$0.key,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
.prefix(8)
|
||||
.map {
|
||||
CommandCenterTab.sessionWorkItem(
|
||||
for: $0,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.sessionsAvailable else {
|
||||
self.sessions = []
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
self.sessions = []
|
||||
self.loadErrorText = "Try again after the gateway reconnects."
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
672
apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift
Normal file
672
apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift
Normal file
@@ -0,0 +1,672 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Activity states") {
|
||||
IPadActivityStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard states") {
|
||||
IPadWorkboardStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop states") {
|
||||
IPadSkillWorkshopStatesPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop iPad kanban lanes",
|
||||
traits: .fixedLayout(width: 1180, height: 820))
|
||||
{
|
||||
IPadSkillWorkshopKanbanPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard phone queue rows") {
|
||||
IPadWorkboardCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop phone queue rows") {
|
||||
IPadSkillWorkshopCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Workboard phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadWorkboardScreen(openChat: {}, openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadSkillWorkshopScreen(openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardCompactRowsPreview: View {
|
||||
private let statuses = ["todo", "ready", "running", "review", "blocked", "done"]
|
||||
private let cards = IPadWorkboardPreviewFixtures.cards
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.cards.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadWorkboardQueueRow(
|
||||
card: card,
|
||||
statuses: self.statuses,
|
||||
isBusy: card.id == "preview-running",
|
||||
inspect: {},
|
||||
openSession: {},
|
||||
move: { _ in },
|
||||
archive: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No cards",
|
||||
detail: "Create a card or change the filter.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone queue")
|
||||
.font(.headline)
|
||||
Text("Tap for detail, swipe or long-press for card actions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopCompactRowsPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == "preview-pending",
|
||||
isBusy: proposal.id == "preview-held")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone proposals")
|
||||
.font(.headline)
|
||||
Text("Tap for detail, swipe or long-press for proposal actions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSidebarTaskScreenPreviewHost<Content: View>: View {
|
||||
@State private var appModel = NodeAppModel()
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
.environment(self.appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadActivityStatesPreview: View {
|
||||
private let connectedSessions = [
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-main",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Main",
|
||||
detail: "Updated just now",
|
||||
state: "active",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat("main")),
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-ipad-audit",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "iPad audit",
|
||||
detail: "Updated 8m ago",
|
||||
state: "recent",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.accent,
|
||||
progress: nil,
|
||||
route: .chat("ipad-audit")),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: self.connectedSessions,
|
||||
tailRows: [])
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "Fetching recent activity from the gateway.",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent),
|
||||
])
|
||||
|
||||
self.previewHeader("Empty")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "No recent sessions",
|
||||
detail: "Start a chat and it will appear here.",
|
||||
value: "empty",
|
||||
color: .secondary),
|
||||
])
|
||||
|
||||
self.previewHeader("Error")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "No gateway connection",
|
||||
gatewayValue: "offline",
|
||||
gatewayColor: .secondary,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn),
|
||||
])
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func activityCard(
|
||||
gatewayTitle: String,
|
||||
gatewayDetail: String,
|
||||
gatewayValue: String,
|
||||
gatewayColor: Color,
|
||||
sessionRows: [CommandCenterTab.WorkItem],
|
||||
tailRows: [ActivityPreviewRow]) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: gatewayValue == "online" ? "network" : "wifi.slash",
|
||||
title: gatewayTitle,
|
||||
detail: gatewayDetail,
|
||||
value: gatewayValue,
|
||||
color: gatewayColor,
|
||||
actionTitle: gatewayValue == "online" ? nil : "Settings",
|
||||
action: {})
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: "No share events yet.",
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {})
|
||||
}
|
||||
ForEach(tailRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.value,
|
||||
color: row.color,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityPreviewRow: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardStatesPreview: View {
|
||||
private let statuses = ["todo", "running", "review"]
|
||||
private let connectedCards = IPadWorkboardPreviewFixtures.cards
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.connectedBoard
|
||||
|
||||
self.previewHeader("Empty")
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: "todo",
|
||||
cards: [],
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "arrow.clockwise",
|
||||
title: "Loading cards",
|
||||
detail: "Refreshing the workboard from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Error")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Cards unavailable",
|
||||
detail: "Check the gateway connection, then refresh.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: "Retry",
|
||||
action: {})
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectedBoard: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.statuses, id: \.self) { status in
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: status,
|
||||
cards: self.connectedCards.filter { $0.status == status },
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadWorkboardPreviewFixtures {
|
||||
static let cards = [
|
||||
IPadWorkboardCard(
|
||||
id: "preview-todo",
|
||||
title: "Prep iPad sidebar audit",
|
||||
notes: "Confirm portrait drawer behavior before device install.",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: ["iPad", "UI"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 0,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-running",
|
||||
title: "Verify phone workboard queue",
|
||||
notes: "Single-list compact flow with detail sheet actions.",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
labels: ["phone"],
|
||||
agentId: "main",
|
||||
sessionKey: "session-preview",
|
||||
position: 1,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-review",
|
||||
title: "Review adaptive shell",
|
||||
notes: "Make sure shared destinations stay device-specific.",
|
||||
status: "review",
|
||||
priority: "normal",
|
||||
labels: ["shell"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 2,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
]
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopStatesPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending")
|
||||
|
||||
self.previewHeader("Empty")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Offline / Error")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Workshop offline",
|
||||
detail: "Connect to the gateway to load Skill Workshop proposals.",
|
||||
value: "offline",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Proposal unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func queueCard(
|
||||
_ proposals: [IPadSkillProposal],
|
||||
selectedID: String?,
|
||||
busyID: String?) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == selectedID,
|
||||
isBusy: proposal.id == busyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopKanbanPreview: View {
|
||||
private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.lanes, id: \.self) { status in
|
||||
IPadSkillProposalKanbanColumn(
|
||||
status: status,
|
||||
proposals: self.proposals.filter { $0.status == status },
|
||||
selectedProposalID: "preview-pending",
|
||||
inspectingProposalID: "preview-needs-review",
|
||||
canApplyProposalMutations: true,
|
||||
busyAction: nil,
|
||||
select: { _ in },
|
||||
inspect: { _ in },
|
||||
apply: { _ in },
|
||||
reject: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 22)
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("iPad kanban")
|
||||
.font(.headline)
|
||||
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadSkillWorkshopPreviewFixtures {
|
||||
static let kanbanStatuses = [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
"manual_QA",
|
||||
]
|
||||
|
||||
static let proposals = [
|
||||
Self.proposal(
|
||||
id: "preview-pending",
|
||||
status: "pending",
|
||||
title: "Add Tailscale gateway helper",
|
||||
description: "Drafts a helper skill for checking local Tailscale reachability before pairing.",
|
||||
minutesAgo: 9),
|
||||
Self.proposal(
|
||||
id: "preview-applied",
|
||||
status: "applied",
|
||||
title: "Summarize channel health",
|
||||
description: "Adds a lightweight status summary for channel clients and recent routing failures.",
|
||||
minutesAgo: 47),
|
||||
Self.proposal(
|
||||
id: "preview-held",
|
||||
status: "quarantined",
|
||||
title: "Desktop automation bridge",
|
||||
description: "Held for review because it requests broader file access than mobile should expose.",
|
||||
minutesAgo: 128),
|
||||
Self.proposal(
|
||||
id: "preview-needs-review",
|
||||
status: "needs-review",
|
||||
title: "Review pairing diagnostics",
|
||||
description: "Adds a diagnostic checklist before trusting a new gateway certificate.",
|
||||
minutesAgo: 32),
|
||||
Self.proposal(
|
||||
id: "preview-manual-qa",
|
||||
status: "manual_QA",
|
||||
title: "Manual QA runbook",
|
||||
description: "Generates a device checklist for iPhone portrait and iPad split layouts.",
|
||||
minutesAgo: 15),
|
||||
]
|
||||
|
||||
private static func proposal(
|
||||
id: String,
|
||||
status: String,
|
||||
title: String,
|
||||
description: String,
|
||||
minutesAgo: Int) -> IPadSkillProposal
|
||||
{
|
||||
let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60)))
|
||||
return IPadSkillProposal(
|
||||
entry: IPadSkillProposalManifestEntry(
|
||||
id: id,
|
||||
kind: "skill",
|
||||
status: status,
|
||||
title: title,
|
||||
description: description,
|
||||
skillName: title,
|
||||
skillKey: id,
|
||||
createdAt: updatedAt,
|
||||
updatedAt: updatedAt,
|
||||
scanState: "complete"),
|
||||
previous: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
17
apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift
Normal file
17
apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct EmptyParams: Encodable {}
|
||||
|
||||
enum IPadSidebarGatewayError: Error {
|
||||
case offline
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .offline:
|
||||
"Gateway offline."
|
||||
case .invalidPayload:
|
||||
"Could not encode request."
|
||||
}
|
||||
}
|
||||
}
|
||||
71
apps/ios/Sources/Design/IPadSidebarScreenChrome.swift
Normal file
71
apps/ios/Sources/Design/IPadSidebarScreenChrome.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IPadSidebarScreenChrome<Content: View>: View {
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
gatewayAction: (() -> Void)? = nil,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title,
|
||||
subtitle: self.subtitle,
|
||||
titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold),
|
||||
subtitleLineLimit: self.isCompactHeight ? 1 : 2)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.content
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset
|
||||
}
|
||||
}
|
||||
1218
apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift
Normal file
1218
apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift
Normal file
File diff suppressed because it is too large
Load Diff
1395
apps/ios/Sources/Design/IPadWorkboardScreen.swift
Normal file
1395
apps/ios/Sources/Design/IPadWorkboardScreen.swift
Normal file
File diff suppressed because it is too large
Load Diff
129
apps/ios/Sources/Design/OpenClawDocsScreen.swift
Normal file
129
apps/ios/Sources/Design/OpenClawDocsScreen.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpenClawDocsScreen: View {
|
||||
private let docsURL = URL(string: "https://docs.openclaw.ai")!
|
||||
private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")!
|
||||
private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")!
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.headerCard
|
||||
self.linkCard
|
||||
self.versionCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Docs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Docs",
|
||||
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
|
||||
titleFont: .headline,
|
||||
subtitleFont: .caption)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
ProIconBadge(systemName: "book", color: OpenClawBrand.accent)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var linkCard: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.docsLinkRow(
|
||||
title: "Docs Home",
|
||||
detail: "Browse the current OpenClaw reference.",
|
||||
icon: "book",
|
||||
url: self.docsURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Gateway",
|
||||
detail: "Connection, auth, and diagnostics.",
|
||||
icon: "network",
|
||||
url: self.gatewayURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Pairing",
|
||||
detail: "Mobile setup codes, QR, and node approval.",
|
||||
icon: "qrcode",
|
||||
url: self.pairingURL)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var versionCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Version")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View {
|
||||
Link(destination: url) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 20
|
||||
static let cardRadius: CGFloat = 14
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let pagePadding: CGFloat = 18
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 22
|
||||
static let heroRadius: CGFloat = 12
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent.opacity(0.05),
|
||||
OpenClawBrand.accent.opacity(0.02),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading)
|
||||
.frame(height: 620)
|
||||
Color.white.opacity(0.22)
|
||||
.frame(height: 140)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
@@ -66,7 +56,7 @@ struct ProSectionHeader: View {
|
||||
struct ProCard<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent: Bool = false
|
||||
var padding: CGFloat = 14
|
||||
var padding: CGFloat = 12
|
||||
var radius: CGFloat = OpenClawProMetric.cardRadius
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@@ -91,78 +81,39 @@ private struct ProPanelBackground: View {
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
ProPanelTexture()
|
||||
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
|
||||
.clipShape(shape)
|
||||
}
|
||||
.overlay {
|
||||
shape.strokeBorder(self.borderStyle, lineWidth: 1)
|
||||
}
|
||||
.overlay {
|
||||
shape
|
||||
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
|
||||
.padding(1)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
shape
|
||||
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
|
||||
.mask(alignment: .top) {
|
||||
Rectangle().frame(height: 28)
|
||||
}
|
||||
if self.isProminent {
|
||||
shape.strokeBorder(
|
||||
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
|
||||
lineWidth: 1)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
let base = self.isProminent
|
||||
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
|
||||
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
|
||||
return AnyShapeStyle(base)
|
||||
let base = self.isProminent
|
||||
? Color(uiColor: .systemBackground)
|
||||
: Color(uiColor: .secondarySystemGroupedBackground)
|
||||
if let tint {
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
base,
|
||||
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
base,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.98),
|
||||
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
|
||||
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
return AnyShapeStyle(base)
|
||||
}
|
||||
|
||||
private var borderStyle: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.72),
|
||||
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
|
||||
Color.black.opacity(0.08),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelTexture: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
|
||||
for y in stride(from: 2.0, through: size.height, by: 6.5) {
|
||||
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
|
||||
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
|
||||
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
|
||||
context.fill(Path(ellipseIn: dot), with: .color(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,9 +202,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
|
||||
}
|
||||
.modifier(ProLightGlassModifier(radius: self.radius))
|
||||
.shadow(
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
|
||||
radius: self.isProminent ? 20 : 12,
|
||||
y: self.isProminent ? 10 : 6)
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
|
||||
radius: self.isProminent ? 9 : 4,
|
||||
y: self.isProminent ? 4 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,16 +214,160 @@ struct ProIconBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 34, height: 34)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderAction {
|
||||
let systemName: String
|
||||
let accessibilityLabel: String
|
||||
let accessibilityIdentifier: String?
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
systemName: String,
|
||||
accessibilityLabel: String,
|
||||
accessibilityIdentifier: String? = nil,
|
||||
action: @escaping () -> Void)
|
||||
{
|
||||
self.systemName = systemName
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarRevealButton: View {
|
||||
let headerAction: OpenClawSidebarHeaderAction
|
||||
|
||||
init(action: OpenClawSidebarHeaderAction) {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.headerAction = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: action)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: 38, height: 38)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel(self.headerAction.accessibilityLabel)
|
||||
|
||||
if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier {
|
||||
button.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderLeadingSlot: View {
|
||||
let action: OpenClawSidebarHeaderAction
|
||||
|
||||
var body: some View {
|
||||
OpenClawSidebarRevealButton(action: self.action)
|
||||
.frame(width: 44, height: 44, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var titleFont: Font = .title3.weight(.semibold)
|
||||
var subtitleFont: Font = .subheadline
|
||||
var subtitleLineLimit: Int? = 2
|
||||
@ViewBuilder let leading: Leading
|
||||
@ViewBuilder let accessory: Accessory
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
titleFont: Font = .title3.weight(.semibold),
|
||||
subtitleFont: Font = .subheadline,
|
||||
subtitleLineLimit: Int? = 2,
|
||||
@ViewBuilder leading: () -> Leading,
|
||||
@ViewBuilder accessory: () -> Accessory)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.titleFont = titleFont
|
||||
self.subtitleFont = subtitleFont
|
||||
self.subtitleLineLimit = subtitleLineLimit
|
||||
self.leading = leading()
|
||||
self.accessory = accessory()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
self.horizontalLayout
|
||||
self.stackedLayout
|
||||
}
|
||||
}
|
||||
|
||||
private var horizontalLayout: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(self.titleFont)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.86)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(self.subtitle)
|
||||
.font(self.subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(self.subtitleLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusDot: View {
|
||||
var color: Color
|
||||
|
||||
@@ -280,7 +375,6 @@ struct ProStatusDot: View {
|
||||
Circle()
|
||||
.fill(self.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: self.color.opacity(0.35), radius: 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +406,7 @@ struct OpenClawProMark: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
}
|
||||
}
|
||||
@@ -390,7 +484,10 @@ struct ProCapsule: View {
|
||||
}
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.foregroundStyle(self.color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
@@ -405,6 +502,57 @@ struct ProCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawGatewayCompactPill: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
ProCapsule(
|
||||
title: self.title,
|
||||
color: self.color,
|
||||
icon: self.icon)
|
||||
.accessibilityLabel("Gateway \(self.title)")
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"checkmark.circle.fill"
|
||||
case .connecting:
|
||||
"arrow.triangle.2.circlepath"
|
||||
case .error:
|
||||
"exclamationmark.triangle.fill"
|
||||
case .disconnected:
|
||||
"wifi.slash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@@ -531,28 +679,120 @@ struct ProMetricTile: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetric: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct ProMetricGrid: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
let metrics: [ProMetric]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible()), count: self.columnCount),
|
||||
spacing: 10)
|
||||
{
|
||||
ForEach(self.metrics) { metric in
|
||||
ProMetricTile(
|
||||
title: metric.title,
|
||||
value: metric.value,
|
||||
icon: metric.icon,
|
||||
color: metric.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var columnCount: Int {
|
||||
guard self.horizontalSizeClass != .compact else { return 1 }
|
||||
return min(max(self.metrics.count, 1), 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProPanelHeader: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var actionTitle: String?
|
||||
var actionIcon: String?
|
||||
var actionAccessibilityLabel: String?
|
||||
var isActionDisabled = false
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.actionControl
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionControl: some View {
|
||||
if let action {
|
||||
if let actionIcon {
|
||||
Button(action: action) {
|
||||
Image(systemName: actionIcon)
|
||||
}
|
||||
.accessibilityLabel(self.actionAccessibilityLabel ?? self.actionTitle ?? self.title)
|
||||
.disabled(self.isActionDisabled)
|
||||
} else if let actionTitle {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.disabled(self.isActionDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let value: String?
|
||||
let color: Color
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.value, color: self.color)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let value {
|
||||
ProValuePill(value: value, color: self.color)
|
||||
}
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
417
apps/ios/Sources/Design/RootTabsPhoneControlHub.swift
Normal file
417
apps/ios/Sources/Design/RootTabsPhoneControlHub.swift
Normal file
@@ -0,0 +1,417 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct RootTabsPhoneControlHub: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@State private var navigationPath: [RootTabs.SidebarDestination] = []
|
||||
@State private var didApplyInitialDestination = false
|
||||
|
||||
let groups: [RootTabs.SidebarGroup]
|
||||
let initialDestination: RootTabs.SidebarDestination?
|
||||
let openRootDestination: (RootTabs.SidebarDestination) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
self.headerCard
|
||||
ForEach(self.groups) { group in
|
||||
self.groupSection(group)
|
||||
}
|
||||
self.versionFooter
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 16)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Control")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
|
||||
self.detail(for: destination)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialDestinationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerCard: some View {
|
||||
if self.isCompactHeight {
|
||||
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 24, shadowRadius: 3)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 32, shadowRadius: 4)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
|
||||
self.gatewayActionRow
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionRow: some View {
|
||||
Button {
|
||||
self.openRootDestination(.gateway)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ProStatusDot(color: self.gatewayStateColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.gatewayStateText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.gatewayActionTitle)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
|
||||
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
|
||||
ProSectionHeader(title: group.title.capitalized)
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
self.destinationRow(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
if self.opensRootTab(destination) {
|
||||
Button {
|
||||
self.openRootDestination(destination)
|
||||
} label: {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
self.navigationPath.append(destination)
|
||||
} label: {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(destination.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(destination.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 8 : 10)
|
||||
.padding(.horizontal, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents, .gateway:
|
||||
EmptyView()
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
headerTitle: "Overview",
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
gatewayAction: { self.openRootDestination(.gateway) })
|
||||
case .settings:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneDetailBackAction: OpenClawSidebarHeaderAction {
|
||||
OpenClawSidebarHeaderAction(
|
||||
systemName: "chevron.left",
|
||||
accessibilityLabel: "Back to Control",
|
||||
accessibilityIdentifier: "OpenClawPhoneDetailBackButton",
|
||||
action: { self.popPhoneDetail() })
|
||||
}
|
||||
|
||||
private func popPhoneDetail() {
|
||||
guard !self.navigationPath.isEmpty else { return }
|
||||
self.navigationPath.removeLast()
|
||||
}
|
||||
|
||||
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
|
||||
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
|
||||
}
|
||||
|
||||
private func applyInitialDestinationIfNeeded() {
|
||||
guard !self.didApplyInitialDestination else { return }
|
||||
self.didApplyInitialDestination = true
|
||||
guard let initialDestination, initialDestination != .overview else { return }
|
||||
if self.opensRootTab(initialDestination) {
|
||||
self.openRootDestination(initialDestination)
|
||||
} else {
|
||||
self.navigationPath = [initialDestination]
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarActiveAgentTitle: String {
|
||||
let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID()
|
||||
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) {
|
||||
return self.agentTitle(for: agent)
|
||||
}
|
||||
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
|
||||
}
|
||||
|
||||
private var gatewayDisplayLabel: String {
|
||||
self.normalized(self.appModel.gatewayServerName)
|
||||
?? self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected: "Online"
|
||||
case .connecting: "Connecting"
|
||||
case .error: "Attention"
|
||||
case .disconnected: "Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStateColor: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionTitle: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Manage"
|
||||
case .connecting:
|
||||
"Details"
|
||||
case .error:
|
||||
"Fix"
|
||||
case .disconnected:
|
||||
"Connect"
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass)
|
||||
}
|
||||
|
||||
static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat {
|
||||
verticalSizeClass == .compact ? 72 : 112
|
||||
}
|
||||
|
||||
private func color(for destination: RootTabs.SidebarDestination) -> Color {
|
||||
switch destination {
|
||||
case .chat, .talk, .overview, .gateway:
|
||||
OpenClawBrand.accent
|
||||
case .instances:
|
||||
Color.secondary
|
||||
case .activity, .usage, .docs:
|
||||
OpenClawBrand.accentHot
|
||||
case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings:
|
||||
OpenClawBrand.ok
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func agentTitle(for agent: AgentSummary) -> String {
|
||||
let name = self.normalized(agent.name) ?? agent.id
|
||||
return name == agent.id ? name : "\(name) (\(agent.id))"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Phone control hub offline") {
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connected") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connecting") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub gateway error") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Phone control hub landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
extension RootTabsPhoneControlHub {
|
||||
fileprivate static func preview(appModel: NodeAppModel) -> some View {
|
||||
RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
726
apps/ios/Sources/Design/SettingsChannelsDestination.swift
Normal file
726
apps/ios/Sources/Design/SettingsChannelsDestination.swift
Normal file
@@ -0,0 +1,726 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsChannelsDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let showsSummaryCard: Bool
|
||||
@State private var snapshot: ChannelsStatusResult?
|
||||
@State private var isLoading = false
|
||||
@State private var errorText: String?
|
||||
@State private var busyOperation: SettingsChannelOperation?
|
||||
|
||||
init(showsSummaryCard: Bool = true) {
|
||||
self.showsSummaryCard = showsSummaryCard
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.showsSummaryCard {
|
||||
self.summaryCard
|
||||
}
|
||||
self.channelsCard
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.loadChannels(force: false)
|
||||
}
|
||||
.refreshable {
|
||||
await self.loadChannels(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.headline)
|
||||
Text(self.summaryDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.summaryValue, color: self.summaryColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var channelsCard: some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: self.headerValue,
|
||||
actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: self.isLoading,
|
||||
action: {
|
||||
Task { await self.loadChannels(force: true) }
|
||||
})
|
||||
|
||||
if let errorText {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: errorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
} else if !self.canRead {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
} else if self.isLoading, self.snapshot == nil {
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channels",
|
||||
detail: "Fetching installed channels, accounts, and routing status from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
} else if self.channelEntries.isEmpty {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
} else {
|
||||
ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
SettingsChannelRow(
|
||||
entry: entry,
|
||||
canAdmin: self.canAdmin,
|
||||
busyOperation: self.busyOperation,
|
||||
start: { accountID in
|
||||
Task { await self.run(.start, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
stop: { accountID in
|
||||
Task { await self.run(.stop, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
logout: { accountID in
|
||||
Task { await self.run(.logout, channelID: entry.id, accountID: accountID) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.canRead ? "connected" : "offline",
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var canRead: Bool {
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var canAdmin: Bool {
|
||||
self.appModel.hasOperatorAdminScope
|
||||
}
|
||||
|
||||
static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool {
|
||||
canRead && hasOperatorAdminScope
|
||||
}
|
||||
|
||||
private var headerValue: String? {
|
||||
if self.isLoading { return "Loading" }
|
||||
guard self.canRead else { return "Offline" }
|
||||
return "\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryDetail: String {
|
||||
guard self.canRead else {
|
||||
return "Connect to load channel integrations."
|
||||
}
|
||||
if let errorText {
|
||||
return errorText
|
||||
}
|
||||
return "Installed channel clients, account state, and message-routing readiness."
|
||||
}
|
||||
|
||||
private var summaryValue: String {
|
||||
guard self.canRead else { return "offline" }
|
||||
if self.isLoading { return "loading" }
|
||||
if self.errorText != nil { return "error" }
|
||||
let configured = self.channelEntries.count(where: { $0.configured })
|
||||
return "\(configured)/\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryColor: Color {
|
||||
guard self.canRead else { return .secondary }
|
||||
if self.errorText != nil { return OpenClawBrand.warn }
|
||||
return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand
|
||||
.accent
|
||||
}
|
||||
|
||||
private var channelEntries: [SettingsChannelEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder
|
||||
return ids.map { self.entry(channelID: $0, snapshot: snapshot) }
|
||||
}
|
||||
|
||||
private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry {
|
||||
let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:]
|
||||
let accounts = self.accounts(channelID: channelID, snapshot: snapshot)
|
||||
let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true
|
||||
let running = accounts.contains(where: \.running)
|
||||
let connected = accounts.contains(where: \.connected)
|
||||
let linked = accounts.contains(where: \.linked)
|
||||
let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID)
|
||||
let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID)
|
||||
let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID)
|
||||
let lastActivity = accounts.compactMap(\.lastActivityMs).max()
|
||||
let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue
|
||||
return SettingsChannelEntry(
|
||||
id: channelID,
|
||||
label: label,
|
||||
detail: detail,
|
||||
systemImage: systemImage,
|
||||
configured: configured,
|
||||
running: running,
|
||||
connected: connected,
|
||||
linked: linked,
|
||||
lastActivityText: lastActivity.map(Self.relativeTime),
|
||||
lastError: lastError,
|
||||
unavailableReason: configured ? nil : "Configure this channel on the gateway.",
|
||||
accounts: accounts)
|
||||
}
|
||||
|
||||
private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] {
|
||||
let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? []
|
||||
return rawAccounts.compactMap { raw in
|
||||
guard let dict = raw.dictionaryValue else { return nil }
|
||||
let accountID = dict["accountId"]?.stringValue ?? "default"
|
||||
let name = dict["name"]?.stringValue
|
||||
let lastActivity = [
|
||||
dict["lastInboundAt"]?.intValue,
|
||||
dict["lastOutboundAt"]?.intValue,
|
||||
dict["lastTransportActivityAt"]?.intValue,
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.max()
|
||||
return SettingsChannelAccount(
|
||||
id: accountID,
|
||||
name: name,
|
||||
configured: dict["configured"]?.boolValue == true,
|
||||
enabled: dict["enabled"]?.boolValue != false,
|
||||
running: dict["running"]?.boolValue == true,
|
||||
connected: dict["connected"]?.boolValue == true,
|
||||
linked: dict["linked"]?.boolValue == true,
|
||||
healthState: dict["healthState"]?.stringValue,
|
||||
lastError: dict["lastError"]?.stringValue,
|
||||
lastActivityMs: lastActivity)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChannels(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.canRead else {
|
||||
self.snapshot = nil
|
||||
self.errorText = nil
|
||||
return
|
||||
}
|
||||
if self.isLoading { return }
|
||||
|
||||
self.isLoading = true
|
||||
self.errorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil)
|
||||
let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12)
|
||||
self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data)
|
||||
} catch {
|
||||
if force || self.snapshot == nil {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async {
|
||||
guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin),
|
||||
self.busyOperation == nil
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID)
|
||||
self.errorText = nil
|
||||
defer { self.busyOperation = nil }
|
||||
|
||||
do {
|
||||
switch kind {
|
||||
case .start:
|
||||
let params = ChannelsStartParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20)
|
||||
case .stop:
|
||||
let params = ChannelsStopParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20)
|
||||
case .logout:
|
||||
let params = ChannelsLogoutParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
await self.loadChannels(force: true)
|
||||
} catch {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data {
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SettingsChannelError.invalidPayload
|
||||
}
|
||||
return try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
static func fallbackLabel(_ id: String) -> String {
|
||||
if let metadata = self.fallbackMetadata[id.lowercased()] {
|
||||
return metadata.label
|
||||
}
|
||||
return id.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.split(separator: " ")
|
||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func fallbackDetail(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
|
||||
}
|
||||
|
||||
static func fallbackSystemImage(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right"
|
||||
}
|
||||
|
||||
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
|
||||
"clickclack": SettingsChannelFallbackMetadata(
|
||||
label: "ClickClack",
|
||||
detail: "Self-hosted chat bot routing.",
|
||||
systemImage: "bubble.left.and.bubble.right"),
|
||||
]
|
||||
|
||||
private static func relativeTime(_ milliseconds: Int) -> String {
|
||||
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
|
||||
let minutes = age / 60000
|
||||
if minutes < 1 { return "now" }
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
return "\(hours / 24)d ago"
|
||||
}
|
||||
|
||||
private static func message(for error: Error) -> String {
|
||||
if let channelError = error as? SettingsChannelError {
|
||||
return channelError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsChannelsScreen: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
SettingsChannelsDestination(showsSummaryCard: false)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Message routing and external channel clients.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
let busyOperation: SettingsChannelOperation?
|
||||
let start: (String?) -> Void
|
||||
let stop: (String?) -> Void
|
||||
let logout: (String?) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.entry.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.entry.detailText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let lastError = self.entry.lastError {
|
||||
Text(lastError)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.entry.statusValue, color: self.entry.color)
|
||||
}
|
||||
|
||||
if !self.entry.accounts.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 38)
|
||||
}
|
||||
self.accountRow(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func accountRow(_ account: SettingsChannelAccount) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(account.color)
|
||||
.frame(width: 28, height: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(account.displayName)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(account.detailText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Menu {
|
||||
if account.running {
|
||||
Button("Stop") {
|
||||
self.stop(account.id)
|
||||
}
|
||||
} else {
|
||||
Button("Start") {
|
||||
self.start(account.id)
|
||||
}
|
||||
.disabled(!account.configured || !account.enabled)
|
||||
}
|
||||
if account.linked {
|
||||
Button("Logout", role: .destructive) {
|
||||
self.logout(account.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: self.actionMenuIcon(account))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(!self.canAdmin || self.isBusy(account))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func actionMenuIcon(_ account: SettingsChannelAccount) -> String {
|
||||
if self.isBusy(account) {
|
||||
return "hourglass"
|
||||
}
|
||||
if !self.canAdmin {
|
||||
return "lock.shield"
|
||||
}
|
||||
return "ellipsis.circle"
|
||||
}
|
||||
|
||||
private func isBusy(_ account: SettingsChannelAccount) -> Bool {
|
||||
self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelEntry: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let configured: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let lastActivityText: String?
|
||||
let lastError: String?
|
||||
let unavailableReason: String?
|
||||
let accounts: [SettingsChannelAccount]
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var statusValue: String {
|
||||
if self.connected { return "connected" }
|
||||
if self.running { return "running" }
|
||||
if self.linked { return "linked" }
|
||||
if self.configured { return "configured" }
|
||||
return "not set"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
if let lastActivityText {
|
||||
return "\(self.detail) • active \(lastActivityText)"
|
||||
}
|
||||
if let unavailableReason {
|
||||
return unavailableReason
|
||||
}
|
||||
return self.detail
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelFallbackMetadata {
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
}
|
||||
|
||||
private struct SettingsChannelAccount: Identifiable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let configured: Bool
|
||||
let enabled: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let healthState: String?
|
||||
let lastError: String?
|
||||
let lastActivityMs: Int?
|
||||
|
||||
var displayName: String {
|
||||
let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
let state = if self.connected {
|
||||
"connected"
|
||||
} else if self.running {
|
||||
"running"
|
||||
} else if self.linked {
|
||||
"linked"
|
||||
} else if self.configured {
|
||||
"configured"
|
||||
} else {
|
||||
"not configured"
|
||||
}
|
||||
let enabledText = self.enabled ? "enabled" : "disabled"
|
||||
if let healthState, !healthState.isEmpty {
|
||||
return "\(state), \(enabledText), \(healthState)"
|
||||
}
|
||||
if let lastError, !lastError.isEmpty {
|
||||
return "\(state), \(enabledText), error"
|
||||
}
|
||||
return "\(state), \(enabledText)"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelOperation: Equatable {
|
||||
enum Kind {
|
||||
case start
|
||||
case stop
|
||||
case logout
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let channelID: String
|
||||
let accountID: String?
|
||||
}
|
||||
|
||||
private enum SettingsChannelError: Error {
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .invalidPayload:
|
||||
"Could not encode channel request."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Channels states") {
|
||||
SettingsChannelsStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsChannelsStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
SettingsChannelRow(
|
||||
entry: Self.telegramEntry,
|
||||
canAdmin: true,
|
||||
busyOperation: nil,
|
||||
start: { _ in },
|
||||
stop: { _ in },
|
||||
logout: { _ in })
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "Loading",
|
||||
actionIcon: "hourglass",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: true,
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channel status",
|
||||
detail: "Checking installed channel clients and account state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "0",
|
||||
actionIcon: "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: "Gateway returned an unexpected channel status response.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
|
||||
self.stateSection("Offline") {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let telegramEntry = SettingsChannelEntry(
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
detail: "Message routing client",
|
||||
systemImage: "paperplane",
|
||||
configured: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
lastActivityText: "4m ago",
|
||||
lastError: nil,
|
||||
unavailableReason: nil,
|
||||
accounts: [
|
||||
SettingsChannelAccount(
|
||||
id: "main",
|
||||
name: "OpenClaw Ops",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
healthState: "healthy",
|
||||
lastError: nil,
|
||||
lastActivityMs: nil),
|
||||
])
|
||||
}
|
||||
#endif
|
||||
@@ -58,9 +58,38 @@ struct SettingsProTab: View {
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
@State private var navigationPath: [SettingsRoute] = []
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
|
||||
init(
|
||||
initialRoute: SettingsRoute? = nil,
|
||||
directRoute: SettingsRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
self.settingsModalPresentation(
|
||||
self.settingsLifecycle(
|
||||
self.settingsContent))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var settingsContent: some View {
|
||||
if let directRoute {
|
||||
self.destination(for: directRoute)
|
||||
} else {
|
||||
self.settingsNavigationStack
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsNavigationStack: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
@@ -78,11 +107,17 @@ struct SettingsProTab: View {
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.task {
|
||||
self.previousLocationModeRaw = self.locationModeRaw
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
@@ -119,66 +154,76 @@ struct SettingsProTab: View {
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
|
||||
private func settingsModalPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,7 @@ extension SettingsProTab {
|
||||
case .gateway: "Gateway"
|
||||
case .approvals: "Approvals"
|
||||
case .permissions: "Permissions"
|
||||
case .channels: "Channels"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
case .privacy: "Privacy"
|
||||
@@ -503,6 +504,20 @@ extension SettingsProTab {
|
||||
}
|
||||
}
|
||||
|
||||
func subtitle(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Pairing, diagnostics, and Tailscale checks."
|
||||
case .approvals: "Review pending agent actions."
|
||||
case .permissions: "Control device capabilities."
|
||||
case .channels: "Message routing and external clients."
|
||||
case .voice: "Talk mode and wake phrase settings."
|
||||
case .diagnostics: "Run local health checks."
|
||||
case .privacy: "Data and device privacy controls."
|
||||
case .notifications: "Alert permissions and delivery."
|
||||
case .about: "Version and support details."
|
||||
}
|
||||
}
|
||||
|
||||
var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
|
||||
@@ -3,10 +3,20 @@ import SwiftUI
|
||||
|
||||
extension SettingsProTab {
|
||||
var settingsHeader: some View {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Settings",
|
||||
subtitle: "Gateway, permissions, voice, and device controls.",
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var appearanceSection: some View {
|
||||
@@ -131,6 +141,11 @@ extension SettingsProTab {
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
title: "Channels / Integrations",
|
||||
detail: "Message routing and external channel clients.",
|
||||
route: .channels)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
@@ -199,6 +214,9 @@ extension SettingsProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.headerLeadingAction != nil {
|
||||
self.routeHeader(for: route)
|
||||
}
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
@@ -206,6 +224,8 @@ extension SettingsProTab {
|
||||
self.approvalsDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .channels:
|
||||
SettingsChannelsDestination()
|
||||
case .voice:
|
||||
self.voiceDestination
|
||||
case .diagnostics:
|
||||
@@ -224,6 +244,24 @@ extension SettingsProTab {
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
func routeHeader(for route: SettingsRoute) -> some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title(for: route),
|
||||
subtitle: self.subtitle(for: route),
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var gatewayDestination: some View {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case approvals
|
||||
case permissions
|
||||
case channels
|
||||
case voice
|
||||
case diagnostics
|
||||
case privacy
|
||||
@@ -150,3 +152,176 @@ extension SettingsProTab {
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Gateway settings states") {
|
||||
SettingsGatewayStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsGatewayStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Gateway online",
|
||||
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
|
||||
value: "online",
|
||||
color: OpenClawBrand.ok)
|
||||
self.gatewayFactsCard(
|
||||
address: "100.88.41.20:18789",
|
||||
server: "openclaw-gateway",
|
||||
discovered: "3",
|
||||
agent: "Aiden")
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Checking gateway",
|
||||
detail: "Refreshing connection, discovery, and device trust state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
self.gatewayActionsCard(isBusy: true)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
self.gatewayStatusCard(
|
||||
title: "No gateway configured",
|
||||
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
|
||||
value: "setup",
|
||||
color: .secondary)
|
||||
self.setupActionsCard
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
GatewayProblemBanner(
|
||||
problem: Self.pairingProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {},
|
||||
onShowDetails: {})
|
||||
self.gatewayStatusCard(
|
||||
title: "Tailscale warning",
|
||||
detail: "Tailscale is off on this device. Turn it on, then try again.",
|
||||
value: "network",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayStatusCard(
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
|
||||
title: title,
|
||||
detail: detail,
|
||||
value: value,
|
||||
color: color,
|
||||
actionTitle: value == "setup" ? "Scan QR" : nil,
|
||||
action: value == "setup" ? {} : nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayFactsCard(
|
||||
address: String,
|
||||
server: String,
|
||||
discovered: String,
|
||||
agent: String) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.factRow("Address", value: address)
|
||||
Divider()
|
||||
self.factRow("Server", value: server)
|
||||
Divider()
|
||||
self.factRow("Discovered", value: discovered)
|
||||
Divider()
|
||||
self.factRow("Default Agent", value: agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func factRow(_ label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.frame(height: SettingsLayout.rowHeight)
|
||||
}
|
||||
|
||||
private func gatewayActionsCard(isBusy: Bool) -> some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
|
||||
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var setupActionsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
|
||||
self.previewButton("Connect", systemImage: "link", isBusy: false)
|
||||
}
|
||||
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewButton(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
isBusy: Bool) -> some View
|
||||
{
|
||||
Button {} label: {
|
||||
Label(title, systemImage: systemImage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
|
||||
private static let pairingProblem = GatewayConnectionProblem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: "Pairing required",
|
||||
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
|
||||
actionCommand: "/pair approve req-ipad-preview",
|
||||
requestId: "req-ipad-preview",
|
||||
retryable: false,
|
||||
pauseReconnect: true)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,8 +9,17 @@ struct TalkProTab: View {
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
@State private var showTalkIssueDetails = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
@@ -85,6 +94,9 @@ struct TalkProTab: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
|
||||
@@ -116,6 +116,8 @@ final class NodeAppModel {
|
||||
self.operatorConnected
|
||||
}
|
||||
|
||||
private(set) var hasOperatorAdminScope: Bool = false
|
||||
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
@@ -297,6 +299,7 @@ final class NodeAppModel {
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
self.talkMode.attachGateway(self.operatorGateway)
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
self.refreshLastShareEventFromRelay()
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
self.setTalkEnabled(talkEnabled)
|
||||
@@ -2757,6 +2760,15 @@ extension NodeAppModel {
|
||||
private func setOperatorConnected(_ connected: Bool) {
|
||||
self.operatorConnected = connected
|
||||
self.operatorStatusText = connected ? "Connected" : "Offline"
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
}
|
||||
|
||||
private func refreshOperatorAdminScopeFromStore() {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
self.hasOperatorAdminScope = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
.scopes
|
||||
.contains("operator.admin") == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4636,6 +4648,10 @@ extension NodeAppModel {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
func _test_refreshOperatorAdminScopeFromStore() {
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
}
|
||||
|
||||
func _test_applyPendingForegroundNodeActions(
|
||||
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ struct RootTabs: View {
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@@ -21,10 +23,14 @@ struct RootTabs: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@State private var selectedTab: AppTab = Self.initialTab
|
||||
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
|
||||
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
|
||||
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
|
||||
@State private var isSidebarDrawerLayout: Bool = false
|
||||
@State private var didResolveSidebarLayout: Bool = false
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@@ -34,14 +40,6 @@ struct RootTabs: View {
|
||||
@State private var didApplyInitialChatSession: Bool = false
|
||||
@State private var handledGatewaySetupRequestID: Int = 0
|
||||
|
||||
private enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
private static var initialTab: AppTab {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
|
||||
@@ -66,6 +64,28 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static var initialSidebarDestination: SidebarDestination {
|
||||
if let requested = requestedInitialSidebarDestination {
|
||||
return requested
|
||||
}
|
||||
return Self.defaultSidebarDestination(for: initialTab)
|
||||
}
|
||||
|
||||
private static var requestedInitialSidebarDestination: SidebarDestination? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
|
||||
}
|
||||
|
||||
private static var initialSidebarVisibility: Bool? {
|
||||
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
private static var initialChatSessionKey: String? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
|
||||
@@ -87,45 +107,11 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
static func shouldUseSidebarTabs(
|
||||
idiom: UIUserInterfaceIdiom,
|
||||
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
idiom == .pad
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -136,20 +122,30 @@ struct RootTabs: View {
|
||||
.tint(OpenClawBrand.accent))))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tabContent: some View {
|
||||
if self.usesSidebarTabs {
|
||||
self.sidebarSplitContent
|
||||
} else {
|
||||
self.phoneTabContent
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneTabContent: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
CommandCenterTab(
|
||||
openChat: { self.selectedTab = .chat },
|
||||
openSettings: { self.selectedTab = .settings })
|
||||
.tabItem { Label("Command", systemImage: "target") }
|
||||
RootTabsPhoneControlHub(
|
||||
groups: Self.phoneControlGroups,
|
||||
initialDestination: Self.requestedInitialSidebarDestination,
|
||||
openRootDestination: { self.selectSidebarDestination($0) })
|
||||
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
|
||||
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
|
||||
.tag(AppTab.control)
|
||||
|
||||
ChatProTab()
|
||||
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
|
||||
.tag(AppTab.chat)
|
||||
|
||||
TalkProTab(openSettings: { self.selectedTab = .settings })
|
||||
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.tabItem {
|
||||
Label(
|
||||
"Talk",
|
||||
@@ -157,16 +153,394 @@ struct RootTabs: View {
|
||||
}
|
||||
.tag(AppTab.talk)
|
||||
|
||||
AgentProTab()
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
NavigationStack {
|
||||
AgentProTab(
|
||||
directRoute: .agents,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
}
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
SettingsProTab()
|
||||
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
|
||||
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(AppTab.settings)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarSplitContent: some View {
|
||||
GeometryReader { proxy in
|
||||
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size)
|
||||
let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout)
|
||||
Group {
|
||||
if isDrawerLayout {
|
||||
self.sidebarDrawerContent(sidebarWidth: sidebarWidth)
|
||||
} else {
|
||||
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible)
|
||||
.onAppear {
|
||||
self.updateSidebarLayout(containerSize: proxy.size, force: false)
|
||||
}
|
||||
.onChange(of: proxy.size) { _, size in
|
||||
self.updateSidebarLayout(containerSize: size, force: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
if self.isSidebarVisible {
|
||||
self.sidebarColumn
|
||||
.frame(width: sidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
self.sidebarVerticalSeparator
|
||||
}
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(OpenClawProBackground())
|
||||
}
|
||||
|
||||
private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if self.isSidebarVisible {
|
||||
Color.black.opacity(0.28)
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.hideSidebar()
|
||||
}
|
||||
.transition(.opacity)
|
||||
|
||||
self.sidebarColumn
|
||||
.frame(width: sidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
self.sidebarVerticalSeparator
|
||||
}
|
||||
.shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDetailShell: some View {
|
||||
self.sidebarDetail
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
}
|
||||
|
||||
private var sidebarColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
self.sidebarIdentityHeader
|
||||
self.sidebarList
|
||||
self.sidebarFooter
|
||||
}
|
||||
.safeAreaPadding(.top, 8)
|
||||
.safeAreaPadding(.bottom, 8)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private var sidebarIdentityHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
OpenClawProMark(size: 30, shadowRadius: 3)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(self.sidebarGatewayStatusColor)
|
||||
Text(self.sidebarGatewayStatusTitle)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if self.isSidebarDrawerLayout {
|
||||
self.sidebarHideButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
.overlay(alignment: .bottom) {
|
||||
self.sidebarHorizontalSeparator
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
|
||||
}
|
||||
|
||||
private var sidebarGatewayStatusTitle: String {
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Needs attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarList: some View {
|
||||
List {
|
||||
ForEach(Self.sidebarGroups) { group in
|
||||
Section(group.title.capitalized) {
|
||||
ForEach(group.destinations) { destination in
|
||||
self.sidebarDestinationButton(destination)
|
||||
}
|
||||
}
|
||||
.listSectionSeparator(.hidden, edges: .all)
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.tint(OpenClawBrand.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private var sidebarFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
self.sidebarHorizontalSeparator
|
||||
HStack(spacing: 10) {
|
||||
Text("VERSION")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
ProStatusDot(color: self.sidebarGatewayStatusColor)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarHorizontalSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(height: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
private var sidebarVerticalSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(width: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
private var sidebarGatewayStatusColor: Color {
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarDestinationButton(
|
||||
_ destination: SidebarDestination,
|
||||
title: String? = nil) -> some View
|
||||
{
|
||||
Button {
|
||||
self.selectSidebarDestination(destination)
|
||||
} label: {
|
||||
Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary)
|
||||
.listRowBackground(
|
||||
destination == self.selectedSidebarDestination
|
||||
? OpenClawBrand.accent.opacity(0.12)
|
||||
: Color.clear)
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sidebarDetail: some View {
|
||||
switch self.selectedSidebarDestination {
|
||||
case .chat:
|
||||
ChatProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .talk:
|
||||
TalkProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
headerTitle: "Overview",
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .agents:
|
||||
AgentProTab(
|
||||
directRoute: .agents,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Agents",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
||||
case .settings:
|
||||
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
case .gateway:
|
||||
SettingsProTab(
|
||||
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDetailNavigationShell: some View {
|
||||
NavigationStack {
|
||||
self.sidebarDetailShell
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private var usesSidebarTabs: Bool {
|
||||
Self.shouldUseSidebarTabs(
|
||||
idiom: self.userInterfaceIdiom,
|
||||
horizontalSizeClass: self.horizontalSizeClass)
|
||||
}
|
||||
|
||||
private var userInterfaceIdiom: UIUserInterfaceIdiom {
|
||||
if let userInterfaceIdiomOverride {
|
||||
return userInterfaceIdiomOverride
|
||||
}
|
||||
return UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
|
||||
private var shouldCollapseSidebarAfterSelection: Bool {
|
||||
Self.shouldCollapseSidebarAfterSelection(
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
}
|
||||
|
||||
private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? {
|
||||
guard Self.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: self.isSidebarVisible,
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if self.isSidebarVisible {
|
||||
return OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Hide Sidebar",
|
||||
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
|
||||
action: { self.hideSidebar() })
|
||||
}
|
||||
return OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
|
||||
action: { self.showSidebar() })
|
||||
}
|
||||
|
||||
private var sidebarHideButton: some View {
|
||||
Button {
|
||||
self.hideSidebar()
|
||||
} label: {
|
||||
Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel("Hide Sidebar")
|
||||
.accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier)
|
||||
}
|
||||
|
||||
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
|
||||
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
|
||||
}
|
||||
|
||||
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
||||
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
|
||||
}
|
||||
|
||||
private func rootOverlays(_ content: some View) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
@@ -195,6 +569,7 @@ struct RootTabs: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
if self.appModel.cameraFlashNonce != 0 {
|
||||
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
@@ -325,7 +700,7 @@ struct RootTabs: View {
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.selectedTab = .chat
|
||||
self.selectSidebarDestination(.chat)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.maybeOpenSettingsForGatewaySetup()
|
||||
@@ -334,10 +709,6 @@ struct RootTabs: View {
|
||||
|
||||
private func rootPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = .settings })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
@@ -504,6 +875,50 @@ struct RootTabs: View {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func selectSidebarDestination(_ destination: SidebarDestination) {
|
||||
self.selectedSidebarDestination = destination
|
||||
self.selectedTab = destination.appTab
|
||||
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
|
||||
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
|
||||
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
|
||||
let didResolvePreviousLayout = self.didResolveSidebarLayout
|
||||
let layoutModeDidChange = layoutMode != previousLayoutMode
|
||||
self.didResolveSidebarLayout = true
|
||||
self.isSidebarDrawerLayout = layoutMode == .drawer
|
||||
if layoutModeDidChange && didResolvePreviousLayout {
|
||||
self.sidebarVisibilityUserOverridden = false
|
||||
}
|
||||
guard force || !self.sidebarVisibilityUserOverridden else { return }
|
||||
|
||||
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
|
||||
guard self.isSidebarVisible != preferredVisibility else { return }
|
||||
self.setSidebarVisible(preferredVisibility)
|
||||
}
|
||||
|
||||
private func setSidebarVisible(_ isVisible: Bool) {
|
||||
self.isSidebarVisible = isVisible
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
@@ -538,7 +953,7 @@ struct RootTabs: View {
|
||||
} else if problem.retryable {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
} else {
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +980,7 @@ struct RootTabs: View {
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,7 +1006,7 @@ struct RootTabs: View {
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
|
||||
private func maybeOpenSettingsForGatewaySetup() {
|
||||
@@ -601,7 +1016,7 @@ struct RootTabs: View {
|
||||
self.showOnboarding = false
|
||||
self.presentedSheet = nil
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
|
||||
private func applyInitialChatSessionIfNeeded() {
|
||||
@@ -681,3 +1096,120 @@ private struct RootCameraFlashOverlay: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom?
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview(
|
||||
"Shell iPhone portrait",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone connected",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone, gatewayState: .connected)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone gateway error",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone, gatewayState: .error)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad portrait drawer",
|
||||
traits: .fixedLayout(width: 1024, height: 1366),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad landscape split",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .connected)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad connecting",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad gateway error",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .error)
|
||||
}
|
||||
|
||||
private struct RootTabsPreviewHost: View {
|
||||
@State private var appModel: NodeAppModel
|
||||
@State private var gatewayController: GatewayConnectionController
|
||||
private let idiom: UIUserInterfaceIdiom
|
||||
|
||||
init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) {
|
||||
let appModel = NodeAppModel()
|
||||
gatewayState.apply(to: appModel)
|
||||
self.idiom = idiom
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
RootTabs()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
.environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RootTabsPreviewGatewayState {
|
||||
case offline
|
||||
case connecting
|
||||
case connected
|
||||
case error
|
||||
|
||||
@MainActor
|
||||
func apply(to appModel: NodeAppModel) {
|
||||
switch self {
|
||||
case .offline:
|
||||
break
|
||||
case .connecting:
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
case .connected:
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
case .error:
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
308
apps/ios/Sources/RootTabsNavigation.swift
Normal file
308
apps/ios/Sources/RootTabsNavigation.swift
Normal file
@@ -0,0 +1,308 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension RootTabs {
|
||||
private static var sidebarPersistentWidthThreshold: CGFloat {
|
||||
980
|
||||
}
|
||||
|
||||
static let sidebarSplitMinimumWidth: CGFloat = 292
|
||||
static let sidebarSplitIdealWidth: CGFloat = 316
|
||||
static let sidebarSplitMaximumWidth: CGFloat = 340
|
||||
static let sidebarDrawerMaximumWidth: CGFloat = 340
|
||||
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
|
||||
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
|
||||
|
||||
enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
|
||||
case chat
|
||||
case talk
|
||||
case overview
|
||||
case activity
|
||||
case agents
|
||||
case workboard
|
||||
case skillWorkshop
|
||||
case instances
|
||||
case sessions
|
||||
case dreaming
|
||||
case usage
|
||||
case cron
|
||||
case docs
|
||||
case settings
|
||||
case gateway
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chat: "Chat"
|
||||
case .talk: "Talk"
|
||||
case .overview: "Overview"
|
||||
case .activity: "Activity"
|
||||
case .agents: "Agents"
|
||||
case .workboard: "Workboard"
|
||||
case .skillWorkshop: "Skill Workshop"
|
||||
case .instances: "Instances"
|
||||
case .sessions: "Sessions"
|
||||
case .dreaming: "Dreaming"
|
||||
case .usage: "Usage"
|
||||
case .cron: "Cron Jobs"
|
||||
case .docs: "Docs"
|
||||
case .settings: "Settings"
|
||||
case .gateway: "Settings / Gateway"
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarTitle: String {
|
||||
switch self {
|
||||
case .gateway: "Connection"
|
||||
default: self.title
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .chat: "Agent chat and recent work."
|
||||
case .talk: "Realtime voice and fallback controls."
|
||||
case .overview: "Status, entry points, health."
|
||||
case .activity: "Gateway, session, and device activity."
|
||||
case .agents: "Agent roster and readiness."
|
||||
case .workboard: "Agent work queue and session handoff."
|
||||
case .skillWorkshop: "Review and apply proposed skills."
|
||||
case .instances: "Latest presence from OpenClaw nodes."
|
||||
case .sessions: "Active sessions and defaults."
|
||||
case .dreaming: "Memory signals and background synthesis."
|
||||
case .usage: "API usage and costs."
|
||||
case .cron: "Wakeups and recurring runs."
|
||||
case .docs: "Reference docs and setup guides."
|
||||
case .settings: "Connection, permissions, channels, and app options."
|
||||
case .gateway: "Pairing, diagnostics, permissions, and device controls."
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .chat: "bubble.left"
|
||||
case .talk: "waveform.circle"
|
||||
case .overview: "chart.bar"
|
||||
case .activity: "waveform.path.ecg"
|
||||
case .agents: "person.2"
|
||||
case .workboard: "folder"
|
||||
case .skillWorkshop: "hammer"
|
||||
case .instances: "dot.radiowaves.left.and.right"
|
||||
case .sessions: "doc.text"
|
||||
case .dreaming: "moon.stars"
|
||||
case .usage: "chart.bar.xaxis"
|
||||
case .cron: "timer"
|
||||
case .docs: "book"
|
||||
case .settings: "gearshape"
|
||||
case .gateway: "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var appTab: AppTab {
|
||||
switch self {
|
||||
case .chat:
|
||||
.chat
|
||||
case .talk:
|
||||
.talk
|
||||
case .agents:
|
||||
.agent
|
||||
case .settings, .gateway:
|
||||
.settings
|
||||
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
|
||||
.usage,
|
||||
.cron, .docs:
|
||||
.control
|
||||
}
|
||||
}
|
||||
|
||||
var settingsRoute: SettingsRoute? {
|
||||
switch self {
|
||||
case .gateway:
|
||||
.gateway
|
||||
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
|
||||
.dreaming,
|
||||
.usage, .cron, .settings, .docs:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarLayoutMode: Equatable {
|
||||
case drawer
|
||||
case split
|
||||
}
|
||||
|
||||
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
|
||||
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
|
||||
? .drawer
|
||||
: .split
|
||||
}
|
||||
|
||||
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
|
||||
layoutMode == .split
|
||||
}
|
||||
|
||||
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
|
||||
layoutMode == .drawer
|
||||
}
|
||||
|
||||
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
||||
if isDrawerLayout {
|
||||
return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86))
|
||||
}
|
||||
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
|
||||
}
|
||||
|
||||
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
|
||||
!isSidebarVisible
|
||||
}
|
||||
|
||||
static func shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: Bool,
|
||||
layoutMode: SidebarLayoutMode) -> Bool
|
||||
{
|
||||
switch layoutMode {
|
||||
case .split:
|
||||
true
|
||||
case .drawer:
|
||||
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
|
||||
}
|
||||
}
|
||||
|
||||
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
|
||||
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "visible", "show", "shown", "open", "true", "1":
|
||||
return true
|
||||
case "hidden", "hide", "closed", "false", "0":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents, .gateway, .settings:
|
||||
true
|
||||
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
|
||||
.usage,
|
||||
.cron, .docs:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
|
||||
switch tab {
|
||||
case .control:
|
||||
.overview
|
||||
case .chat:
|
||||
.chat
|
||||
case .talk:
|
||||
.talk
|
||||
case .agent:
|
||||
.agents
|
||||
case .settings:
|
||||
.settings
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
}
|
||||
|
||||
struct SidebarGroup: Identifiable {
|
||||
let title: String
|
||||
let destinations: [SidebarDestination]
|
||||
|
||||
var id: String {
|
||||
self.title
|
||||
}
|
||||
}
|
||||
|
||||
static let sidebarGroups: [SidebarGroup] = [
|
||||
SidebarGroup(title: "CHAT", destinations: [.chat, .talk]),
|
||||
SidebarGroup(
|
||||
title: "CONTROL",
|
||||
destinations: [
|
||||
.overview,
|
||||
.activity,
|
||||
.agents,
|
||||
.workboard,
|
||||
.skillWorkshop,
|
||||
.instances,
|
||||
.sessions,
|
||||
.dreaming,
|
||||
.usage,
|
||||
.cron,
|
||||
]),
|
||||
SidebarGroup(
|
||||
title: "SETTINGS",
|
||||
destinations: [.settings]),
|
||||
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
|
||||
]
|
||||
|
||||
static var phoneControlGroups: [SidebarGroup] {
|
||||
self.sidebarGroups
|
||||
.map { group in
|
||||
SidebarGroup(
|
||||
title: group.title,
|
||||
destinations: group.destinations.filter { $0 != .agents })
|
||||
}
|
||||
.filter { !$0.destinations.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func gatewayActionsDialog(
|
||||
isPresented: Binding<Bool>,
|
||||
onDisconnect: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void) -> some View
|
||||
{
|
||||
self.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
onDisconnect()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
onOpenSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,15 @@ Sources/Design/AgentProTab+Usage.swift
|
||||
Sources/Design/AgentProTab+DetailComponents.swift
|
||||
Sources/Design/AgentProTab+GatewayData.swift
|
||||
Sources/Design/AgentProModels.swift
|
||||
Sources/Design/IPadActivityScreen.swift
|
||||
Sources/Design/IPadSidebarFeaturePreviews.swift
|
||||
Sources/Design/IPadSidebarFeatureScreens.swift
|
||||
Sources/Design/IPadSkillWorkshopScreen.swift
|
||||
Sources/Design/IPadSidebarScreenChrome.swift
|
||||
Sources/Design/IPadWorkboardScreen.swift
|
||||
Sources/Design/OpenClawDocsScreen.swift
|
||||
Sources/Design/RootTabsPhoneControlHub.swift
|
||||
Sources/Design/SettingsChannelsDestination.swift
|
||||
Sources/EventKit/EventKitAuthorization.swift
|
||||
Sources/Gateway/DeepLinkAgentPromptAlert.swift
|
||||
Sources/Gateway/ExecApprovalPromptDialog.swift
|
||||
@@ -73,6 +82,7 @@ Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/RootTabsNavigation.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
@@ -86,7 +96,6 @@ Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
Sources/Status/GatewayActionsDialog.swift
|
||||
Sources/Status/GatewayStatusBuilder.swift
|
||||
Sources/Status/VoiceWakeToast.swift
|
||||
Sources/Voice/TalkGatewayPermissionState.swift
|
||||
|
||||
27
apps/ios/Tests/CommandCenterTabLayoutTests.swift
Normal file
27
apps/ios/Tests/CommandCenterTabLayoutTests.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@Suite struct CommandCenterTabLayoutTests {
|
||||
@Test func splitLayoutDisabledForCompactWidth() {
|
||||
#expect(
|
||||
!CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .compact,
|
||||
containerWidth: 1_200))
|
||||
}
|
||||
|
||||
@Test func splitLayoutDisabledBelowWidthThreshold() {
|
||||
#expect(
|
||||
!CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .regular,
|
||||
containerWidth: 900))
|
||||
}
|
||||
|
||||
@Test func splitLayoutEnabledForRegularWideLayout() {
|
||||
#expect(
|
||||
CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .regular,
|
||||
containerWidth: 1_024))
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,12 @@ import Testing
|
||||
|
||||
#expect(state == .connecting)
|
||||
}
|
||||
|
||||
@Test func chatGatewayPillLabelsMatchDisplayState() {
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,6 +1025,38 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel.openChatRequestID == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
#expect(appModel.hasOperatorAdminScope == false)
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "operator-token",
|
||||
scopes: ["operator.read", "operator.admin"])
|
||||
appModel._test_refreshOperatorAdminScopeFromStore()
|
||||
#expect(appModel.hasOperatorAdminScope == true)
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator")
|
||||
appModel._test_refreshOperatorAdminScopeFromStore()
|
||||
#expect(appModel.hasOperatorAdminScope == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@@ -38,4 +40,432 @@ import Testing
|
||||
|
||||
#expect(!shouldPresent)
|
||||
}
|
||||
|
||||
@Test func sidebarTabsEnabledForIPadRegularWidth() {
|
||||
#expect(
|
||||
RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .pad,
|
||||
horizontalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func sidebarTabsEnabledForIPadCompactWidth() {
|
||||
#expect(
|
||||
RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .pad,
|
||||
horizontalSizeClass: .compact))
|
||||
}
|
||||
|
||||
@Test func sidebarTabsDisabledForIPhone() {
|
||||
#expect(
|
||||
!RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .phone,
|
||||
horizontalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func sidebarGroupsMatchAdaptiveNavigationModel() {
|
||||
let groups = RootTabs.sidebarGroups
|
||||
let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue)
|
||||
|
||||
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
|
||||
#expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"])
|
||||
#expect(groups[1].destinations == [
|
||||
.overview,
|
||||
.activity,
|
||||
.agents,
|
||||
.workboard,
|
||||
.skillWorkshop,
|
||||
.instances,
|
||||
.sessions,
|
||||
.dreaming,
|
||||
.usage,
|
||||
.cron,
|
||||
])
|
||||
#expect(groups[2].destinations == [.settings])
|
||||
#expect(groups[3].destinations == [.docs])
|
||||
#expect(destinationIDs == [
|
||||
"chat",
|
||||
"talk",
|
||||
"overview",
|
||||
"activity",
|
||||
"agents",
|
||||
"workboard",
|
||||
"skillWorkshop",
|
||||
"instances",
|
||||
"sessions",
|
||||
"dreaming",
|
||||
"usage",
|
||||
"cron",
|
||||
"docs",
|
||||
"settings",
|
||||
"gateway",
|
||||
])
|
||||
#expect(!destinationIDs.contains("agent"))
|
||||
#expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway))
|
||||
}
|
||||
|
||||
@Test func phoneControlGroupsAvoidDuplicatingTheAgentTab() {
|
||||
let groups = RootTabs.phoneControlGroups
|
||||
let destinations = groups.flatMap(\.destinations)
|
||||
|
||||
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
|
||||
#expect(!destinations.contains(.agents))
|
||||
#expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents))
|
||||
#expect(destinations.contains(.dreaming))
|
||||
#expect(destinations.contains(.instances))
|
||||
}
|
||||
|
||||
@Test func sidebarUsesCompactLabelsForLongRoutes() {
|
||||
#expect(RootTabs.SidebarDestination.settings.title == "Settings")
|
||||
#expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway")
|
||||
#expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection")
|
||||
}
|
||||
|
||||
@Test func phoneHubUsesRootTabsOnlyForNativeChatAgentAndGateway() {
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings))
|
||||
|
||||
for destination in RootTabs.SidebarDestination.allCases
|
||||
where destination != .chat && destination != .talk && destination != .agents && destination != .gateway && destination != .settings
|
||||
{
|
||||
#expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func legacyInitialTabsMapToMatchingSidebarDestinations() {
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .control) == .overview)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings)
|
||||
}
|
||||
|
||||
@Test func skillWorkshopMutationsRequireAdminScope() {
|
||||
#expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true))
|
||||
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false))
|
||||
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopHeldFilterIncludesQuarantinedAndStale() {
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held"))
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held"))
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held"))
|
||||
#expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held"))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopBoardLanesMatchStatusFilter() {
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "pending",
|
||||
proposalStatuses: ["pending", "applied"]) == ["pending"])
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "held",
|
||||
proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"])
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "all",
|
||||
proposalStatuses: ["pending", "needs-review"]) == [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
])
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA")
|
||||
}
|
||||
|
||||
@Test func skillWorkshopSelectionStaysInsideActiveFilter() {
|
||||
let proposals = [
|
||||
(id: "applied-1", status: "applied"),
|
||||
(id: "pending-1", status: "pending"),
|
||||
(id: "held-1", status: "quarantined"),
|
||||
]
|
||||
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "applied-1",
|
||||
proposals: proposals,
|
||||
filter: "pending") == "pending-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "held-1",
|
||||
proposals: proposals,
|
||||
filter: "held") == "held-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "pending-1",
|
||||
visibleProposalIDs: ["held-1"]) == "held-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "pending-1",
|
||||
visibleProposalIDs: []) == nil)
|
||||
}
|
||||
|
||||
@Test func workboardBoardScopeLabelsStayCompact() {
|
||||
#expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning")
|
||||
#expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards")
|
||||
#expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning")
|
||||
#expect(IPadWorkboardScreen.boardScopeOptions(
|
||||
knownBoardIDs: ["default", " empty-board ", ""],
|
||||
cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"])
|
||||
#expect(IPadWorkboardScreen
|
||||
.workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active")
|
||||
#expect(IPadWorkboardScreen
|
||||
.workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running")
|
||||
}
|
||||
|
||||
@Test func workboardCompactUnavailableCopyExplainsRealCapabilityState() {
|
||||
#expect(IPadWorkboardScreen
|
||||
.compactWriteUnavailableMessage(canRead: false) ==
|
||||
"Connect from Settings to create, move, and dispatch cards.")
|
||||
#expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.")
|
||||
}
|
||||
|
||||
@Test func skillWorkshopAgentScopeNormalizesGatewayIds() {
|
||||
#expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden")
|
||||
#expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "")
|
||||
}
|
||||
|
||||
@Test func channelLifecycleControlsRequireAdminScope() {
|
||||
#expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true))
|
||||
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false))
|
||||
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true))
|
||||
}
|
||||
|
||||
@Test func clickClackStaysInChannelsIntegrationMetadata() {
|
||||
#expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack")
|
||||
#expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.")
|
||||
#expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right")
|
||||
}
|
||||
|
||||
@Test func iPadOverviewCanSuppressStandaloneHeaderBranding() {
|
||||
#expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true))
|
||||
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true))
|
||||
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false))
|
||||
}
|
||||
|
||||
@Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() {
|
||||
let standalone = ChatProTab()
|
||||
let routed = ChatProTab(
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
openSettings: {})
|
||||
|
||||
#expect(standalone.showsAgentBadge)
|
||||
#expect(standalone.headerTitle == nil)
|
||||
#expect(standalone.openSettings == nil)
|
||||
#expect(routed.headerTitle == "Chat")
|
||||
#expect(routed.headerSubtitle == "Agent conversation")
|
||||
#expect(!routed.showsAgentBadge)
|
||||
#expect(routed.openSettings != nil)
|
||||
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw")
|
||||
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat")
|
||||
}
|
||||
|
||||
@Test func agentRoutesCanOpenGatewaySettingsFromHeaderPill() {
|
||||
let standalone = AgentProTab()
|
||||
let routed = AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerTitle: "Instances",
|
||||
openSettings: {})
|
||||
|
||||
#expect(standalone.headerTitle == "Agents")
|
||||
#expect(standalone.directRoute == nil)
|
||||
#expect(standalone.openSettings == nil)
|
||||
#expect(AgentProTab(directRoute: .agents).directRoute == .agents)
|
||||
#expect(routed.directRoute == .instances)
|
||||
#expect(routed.headerTitle == "Instances")
|
||||
#expect(routed.openSettings != nil)
|
||||
}
|
||||
|
||||
@Test func workboardDispatchSummaryReportsStartedAndFailures() throws {
|
||||
let payload = Data(
|
||||
"""
|
||||
{
|
||||
"count": 2,
|
||||
"started": [{}],
|
||||
"startFailures": [{}],
|
||||
"promoted": [],
|
||||
"reclaimed": [],
|
||||
"blocked": [],
|
||||
"orchestrated": []
|
||||
}
|
||||
""".utf8)
|
||||
let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload)
|
||||
|
||||
#expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.")
|
||||
}
|
||||
|
||||
@Test func talkSidebarDestinationCanReceiveRevealAction() {
|
||||
let action = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: {})
|
||||
let routed = TalkProTab(headerLeadingAction: action, openSettings: {})
|
||||
|
||||
#expect(routed.headerLeadingAction?.systemName == "sidebar.left")
|
||||
#expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar")
|
||||
}
|
||||
|
||||
@Test func iPadPortraitUsesHiddenDrawerSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
|
||||
|
||||
#expect(mode == .drawer)
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func iPadWideLandscapeUsesVisibleSplitSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024))
|
||||
|
||||
#expect(mode == .split)
|
||||
#expect(RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func iPadSplitSidebarWidthStaysUsable() {
|
||||
let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false)
|
||||
|
||||
#expect(width >= RootTabs.sidebarSplitIdealWidth)
|
||||
#expect(width <= RootTabs.sidebarSplitMaximumWidth)
|
||||
}
|
||||
|
||||
@Test func iPadCollapsedSplitSidebarUsesHeaderRevealWithoutReservedRail() {
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .drawer))
|
||||
}
|
||||
|
||||
@Test func initialSidebarVisibilityParsesLaunchArgument() {
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"hidden",
|
||||
]) == false)
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"visible",
|
||||
]) == true)
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"unknown",
|
||||
]) == nil)
|
||||
}
|
||||
|
||||
@Test func sidebarControlsHaveStableAccessibilityIdentifiers() {
|
||||
#expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show")
|
||||
#expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide")
|
||||
}
|
||||
|
||||
@Test func iPadDrawerSidebarWidthStaysInsideScreen() {
|
||||
let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true)
|
||||
|
||||
#expect(width >= 280)
|
||||
#expect(width <= RootTabs.sidebarDrawerMaximumWidth)
|
||||
}
|
||||
|
||||
@Test func narrowLandscapeKeepsDrawerSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600))
|
||||
|
||||
#expect(mode == .drawer)
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func drawerSelectionCollapsesSidebarButSplitSelectionDoesNot() {
|
||||
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
|
||||
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
|
||||
}
|
||||
|
||||
@Test func hiddenSidebarShowsRevealControl() {
|
||||
#expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false))
|
||||
}
|
||||
|
||||
@Test func sidebarRevealControlsHideWhenSidebarIsVisible() {
|
||||
#expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true))
|
||||
}
|
||||
|
||||
@Test func iPadSplitPrefersIntegratedVisibleSidebar() {
|
||||
#expect(RootTabs.preferredSidebarVisibility(layoutMode: .split))
|
||||
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer))
|
||||
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
|
||||
}
|
||||
|
||||
@Test func destinationHeadersOwnHiddenSidebarRevealControl() {
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .split))
|
||||
}
|
||||
|
||||
@Test func workboardAndSkillWorkshopUseCompactTaskFlowOnPhoneSizes() {
|
||||
#expect(
|
||||
IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact))
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact))
|
||||
}
|
||||
|
||||
@Test func workboardAndSkillWorkshopKeepRegularTaskFlowOnWideIPadSizes() {
|
||||
#expect(
|
||||
!IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
!IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func phoneHubLeavesRoomForFloatingTabBar() {
|
||||
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .regular) == 112)
|
||||
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .compact) == 72)
|
||||
}
|
||||
}
|
||||
|
||||
65
apps/ios/Tests/RootTabsSidebarRegressionTests.swift
Normal file
65
apps/ios/Tests/RootTabsSidebarRegressionTests.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSidebarRegressionTests {
|
||||
@Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
|
||||
|
||||
#expect(splitContent.contains("HStack(spacing: 0)"))
|
||||
#expect(splitContent.contains("self.sidebarColumn"))
|
||||
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
|
||||
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
|
||||
#expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(!source.contains("NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!splitContent.contains("NavigationSplitView"))
|
||||
#expect(!splitContent.contains("self.collapsedSidebarRail"))
|
||||
#expect(!source.contains("private var collapsedSidebarRail: some View"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
|
||||
#expect(!navigationSource.contains("static let sidebarCollapsedRailWidth"))
|
||||
#expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)"))
|
||||
#expect(!navigationSource
|
||||
.contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)"))
|
||||
}
|
||||
|
||||
@Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let layoutUpdate = try Self.extract(
|
||||
source,
|
||||
from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)",
|
||||
to: "private func setSidebarVisible(_ isVisible: Bool)")
|
||||
|
||||
#expect(source.contains("@State private var didResolveSidebarLayout: Bool = false"))
|
||||
#expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout"))
|
||||
#expect(layoutUpdate.contains("self.didResolveSidebarLayout = true"))
|
||||
#expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout"))
|
||||
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func rootTabsNavigationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabsNavigation.swift")
|
||||
}
|
||||
|
||||
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
|
||||
let startRange = try #require(source.range(of: start))
|
||||
let tail = source[startRange.lowerBound...]
|
||||
let endRange = try #require(tail.range(of: end))
|
||||
return String(tail[..<endRange.lowerBound])
|
||||
}
|
||||
}
|
||||
812
apps/ios/Tests/RootTabsSourceGuardTests.swift
Normal file
812
apps/ios/Tests/RootTabsSourceGuardTests.swift
Normal file
@@ -0,0 +1,812 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSourceGuardTests {
|
||||
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("sidebarHeaderLeadingAction"))
|
||||
#expect(source.contains("Hide Sidebar"))
|
||||
#expect(source.contains("Show Sidebar"))
|
||||
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
|
||||
#expect(source.contains("layoutMode: self.isSidebarDrawerLayout ? .drawer : .split"))
|
||||
#expect(componentSource.contains("OpenClawSidebarHeaderLeadingSlot"))
|
||||
#expect(componentSource.contains(".frame(width: 44, height: 44, alignment: .center)"))
|
||||
#expect(source.contains(".safeAreaPadding(.top, 8)"))
|
||||
#expect(source.contains("Self.sidebarShowButtonAccessibilityIdentifier"))
|
||||
#expect(source.contains("Self.sidebarHideButtonAccessibilityIdentifier"))
|
||||
#expect(source.contains("accessibilityLabel: \"Hide Sidebar\""))
|
||||
#expect(source.contains("accessibilityLabel: \"Show Sidebar\""))
|
||||
#expect(source.contains("action: { self.hideSidebar() }"))
|
||||
#expect(source.contains("action: { self.showSidebar() }"))
|
||||
#expect(!source.contains("private var collapsedSidebarRail: some View"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(source.contains("requestedInitialSidebarVisibility"))
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("NavigationSplitView(columnVisibility: self.$splitColumnVisibility)"))
|
||||
#expect(source.contains("HStack(spacing: 0)"))
|
||||
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(!source.contains("shouldReserveSidebarRevealInset"))
|
||||
#expect(!source.contains("safeAreaInset(edge: .top"))
|
||||
#expect(!source.contains("thinMaterial, in: Circle"))
|
||||
#expect(!source.contains("sidebarRevealInset"))
|
||||
#expect(source.contains("Color.black.opacity(0.28)"))
|
||||
#expect(source.contains(".background(Color(uiColor: .systemBackground))"))
|
||||
#expect(!source.contains("sidebarRevealCornerButton"))
|
||||
#expect(!source.contains("shouldShowSidebarRevealOverlay"))
|
||||
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
|
||||
}
|
||||
|
||||
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
|
||||
let drawerContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private var sidebarDetailShell: some View")
|
||||
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("Self.sidebarSplitColumnVisibility(isSidebarVisible:"))
|
||||
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(splitContent.contains("HStack(spacing: 0)"))
|
||||
#expect(splitContent.contains("self.sidebarColumn"))
|
||||
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
|
||||
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
|
||||
#expect(splitContent.contains("self.sidebarVerticalSeparator"))
|
||||
#expect(splitContent.contains("self.sidebarDetailNavigationShell"))
|
||||
#expect(!splitContent.contains("NavigationSplitView"))
|
||||
#expect(!splitContent.contains("self.collapsedSidebarRail"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(drawerContent.contains("ZStack(alignment: .topLeading)"))
|
||||
#expect(drawerContent.contains("Color.black.opacity(0.28)"))
|
||||
#expect(drawerContent.contains(".transition(.move(edge: .leading).combined(with: .opacity))"))
|
||||
#expect(!drawerContent.contains("NavigationSplitView"))
|
||||
}
|
||||
|
||||
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let sidebarColumn = try Self.extract(
|
||||
source,
|
||||
from: "private var sidebarColumn: some View",
|
||||
to: "private var sidebarList: some View")
|
||||
|
||||
#expect(source.contains("ForEach(Self.sidebarGroups)"))
|
||||
#expect(!source.contains("Section(\"Context\")"))
|
||||
#expect(!source.contains("sidebarAgentMenu"))
|
||||
#expect(!source.contains("sidebarDeviceMenu"))
|
||||
#expect(sidebarColumn.contains("self.sidebarIdentityHeader"))
|
||||
#expect(source.contains("private var sidebarIdentityHeader: some View"))
|
||||
#expect(source.contains("OpenClawProMark(size: 30"))
|
||||
#expect(source.contains("Text(\"OpenClaw\")"))
|
||||
#expect(source.contains("private var sidebarGatewayStatusTitle: String"))
|
||||
#expect(source.contains("private var sidebarGatewayStatusColor: Color"))
|
||||
#expect(!sidebarColumn.contains("activeAgent"))
|
||||
#expect(!source.contains("shouldShowSidebarColumnHeader"))
|
||||
#expect(!source.contains("private var sidebarColumnHeader: some View"))
|
||||
#expect(sidebarColumn.contains(".safeAreaPadding(.top, 8)"))
|
||||
#expect(source.contains(".scrollContentBackground(.hidden)"))
|
||||
#expect(source.contains(".listStyle(.sidebar)"))
|
||||
#expect(source.contains("private var sidebarHorizontalSeparator: some View"))
|
||||
#expect(source.contains("private var sidebarVerticalSeparator: some View"))
|
||||
#expect(source.contains("1 / UIScreen.main.scale"))
|
||||
#expect(!source.contains("geometry.size.height >= Self.sidebarListNonScrollingMinimumHeight"))
|
||||
#expect(!source.contains("private var sidebarListContent: some View"))
|
||||
#expect(source.contains(".listRowSeparator(.hidden, edges: .all)"))
|
||||
#expect(source.contains(".listSectionSeparator(.hidden, edges: .all)"))
|
||||
#expect(source.contains("if self.isSidebarDrawerLayout {"))
|
||||
#expect(source.contains("private var sidebarFooter: some View"))
|
||||
#expect(!source.contains("LabeledContent(\"Version\""))
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"CHAT\", destinations: [.chat, .talk])"))
|
||||
#expect(!navigationSource.contains("title: \"AGENT\""))
|
||||
#expect(navigationSource.contains("case settings"))
|
||||
#expect(!navigationSource.contains("case settingsChannels"))
|
||||
#expect(!navigationSource.contains("case settingsApprovals"))
|
||||
#expect(!navigationSource.contains("case settingsPrivacy"))
|
||||
#expect(navigationSource.contains("SidebarGroup(\n title: \"SETTINGS\""))
|
||||
#expect(navigationSource.contains("destinations: [.settings]"))
|
||||
#expect(!navigationSource.contains("destinations: [.gateway"))
|
||||
#expect(!navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.settings"))
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
|
||||
}
|
||||
|
||||
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let sidebarDetail = try Self.extract(
|
||||
rootSource,
|
||||
from: "private var sidebarDetail: some View",
|
||||
to: "private var sidebarDetailNavigationShell: some View")
|
||||
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Chat\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Overview\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Agents\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Instances\""))
|
||||
#expect(!sidebarDetail.contains("headerTitle: \"Nodes\""))
|
||||
#expect(sidebarDetail.contains("directRoute: .agents"))
|
||||
#expect(sidebarDetail.contains("directRoute: .instances"))
|
||||
#expect(sidebarDetail.contains("directRoute: .dreaming"))
|
||||
#expect(sidebarDetail.contains("directRoute: .usage"))
|
||||
#expect(sidebarDetail.contains("directRoute: .cron"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .nodes"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .usage"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .cron"))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Dreaming\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Usage\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\""))
|
||||
#expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\""))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("title: self.headerTitle"))
|
||||
#expect(!agentOverviewSource.contains("Text(\"OpenClaw\")"))
|
||||
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(docsSource.contains("title: \"Docs\""))
|
||||
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
|
||||
}
|
||||
|
||||
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
|
||||
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
|
||||
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
|
||||
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
|
||||
let directDestination = try Self.extract(
|
||||
source,
|
||||
from: "private func directDestination(for route: AgentRoute) -> some View",
|
||||
to: "private func applyInitialRouteIfNeeded()")
|
||||
|
||||
#expect(!directDestination.contains("ToolbarItem"))
|
||||
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .cron"))
|
||||
#expect(destinationsSource.contains("self.directRoute == route ? self.headerLeadingAction : nil"))
|
||||
#expect(nodesSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
}
|
||||
|
||||
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(componentsSource.contains("struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View"))
|
||||
#expect(componentsSource.contains("ViewThatFits(in: .horizontal)"))
|
||||
#expect(componentsSource.contains("private var stackedLayout: some View"))
|
||||
#expect(componentsSource.contains(".layoutPriority(1)"))
|
||||
#expect(componentsSource.contains(".fixedSize(horizontal: true, vertical: false)"))
|
||||
#expect(featureChromeSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(overviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(chatSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("case .docs:"))
|
||||
#expect(source.contains("OpenClawDocsScreen("))
|
||||
#expect(source.contains("headerLeadingAction: self.phoneDetailBackAction"))
|
||||
#expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }"))
|
||||
#expect(!source.contains("Label(\"Docs\", systemImage: \"book\")"))
|
||||
#expect(!source.contains("https://docs.openclaw.ai"))
|
||||
}
|
||||
|
||||
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone landscape\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone connected\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone gateway error\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad portrait drawer\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad landscape split\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad connecting\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
|
||||
}
|
||||
|
||||
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
|
||||
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Chat connected\")"))
|
||||
#expect(source.contains("#Preview(\"Chat empty\")"))
|
||||
#expect(source.contains("#Preview(\"Chat loading\")"))
|
||||
#expect(source.contains("#Preview(\"Chat gateway error\")"))
|
||||
#expect(source.contains("enum Scenario"))
|
||||
#expect(source.contains("case connected"))
|
||||
#expect(source.contains("case empty"))
|
||||
#expect(source.contains("case loading"))
|
||||
#expect(source.contains("case error"))
|
||||
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
|
||||
#expect(!source.contains(".padding(.bottom, self.bottomScrollInset)"))
|
||||
#expect(!source.contains("bottomViewportInset"))
|
||||
#expect(!source.contains("bottomTabBarClearance"))
|
||||
}
|
||||
|
||||
@Test func phoneHubHeaderStaysTaskFirst() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("private var gatewayActionRow: some View"))
|
||||
#expect(source.contains("self.openRootDestination(.gateway)"))
|
||||
#expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction"))
|
||||
#expect(source.contains("accessibilityLabel: \"Back to Control\""))
|
||||
#expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\""))
|
||||
#expect(source.contains(".navigationBarBackButtonHidden(true)"))
|
||||
#expect(source.contains(".toolbar(.hidden, for: .navigationBar)"))
|
||||
#expect(source.matches(of: /headerLeadingAction: self\.phoneDetailBackAction/).count == 10)
|
||||
#expect(!source.contains("directRoute: .agents"))
|
||||
#expect(!source.contains("ToolbarItem(placement: .topBarTrailing)"))
|
||||
#expect(!source.contains("Image(systemName: \"gearshape\")"))
|
||||
#expect(!source.contains("self.metric(label:"))
|
||||
#expect(!source.contains("private func metric(label:"))
|
||||
}
|
||||
|
||||
@Test func workboardUsesRealGatewayMethods() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("workboard.cards.list"))
|
||||
#expect(source.contains("workboard.cards.create"))
|
||||
#expect(source.contains("workboard.cards.move"))
|
||||
#expect(source.contains("workboard.cards.archive"))
|
||||
#expect(source.contains("workboard.cards.dispatch"))
|
||||
#expect(source.contains(".padding(.bottom, 12)"))
|
||||
#expect(!source.contains("Workboard gateway contract unavailable"))
|
||||
#expect(!source.contains("supportsGatewayContract"))
|
||||
#expect(!source.contains("Compact mobile queue control"))
|
||||
#expect(!source.contains("Multi-column queue control"))
|
||||
}
|
||||
|
||||
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
let createFunction = try Self.extract(
|
||||
source,
|
||||
from: "private func createCard() async -> Bool",
|
||||
to: "private func move(_ card: IPadWorkboardCard, to status: String) async")
|
||||
|
||||
#expect(source.contains("private var createUnavailableMessage: String?"))
|
||||
#expect(source.contains("Enter a title to create a card."))
|
||||
#expect(source.contains("Card creation is already in progress."))
|
||||
#expect(source.contains("private func newCardButton(expands: Bool) -> some View"))
|
||||
#expect(source.contains("private func beginCreateCard()"))
|
||||
#expect(source.contains("self.newCardButton(expands: false)"))
|
||||
#expect(source.contains("self.newCardButton(expands: true)"))
|
||||
#expect(source.contains("Label(\"New Card\", systemImage: \"plus\")"))
|
||||
#expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")"))
|
||||
#expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")"))
|
||||
#expect(source.contains("if await self.createCard()"))
|
||||
#expect(source.contains(".disabled(self.isCreatingCard)"))
|
||||
#expect(!source.contains("Button(\"Create\")"))
|
||||
#expect(!source.contains("TextField(\"New card\""))
|
||||
#expect(!source.contains(".disabled(!self.canWrite || self.draftTitle"))
|
||||
#expect(createFunction.contains("self.errorText = createUnavailableMessage"))
|
||||
#expect(createFunction.contains("return false"))
|
||||
#expect(createFunction.contains("return true"))
|
||||
}
|
||||
|
||||
@Test func taskScopeControlsSendRealGatewayParams() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
|
||||
#expect(source.contains("private var boardScopeMenu: some View"))
|
||||
#expect(source.contains("method: \"workboard.boards.list\""))
|
||||
#expect(source.contains("IPadWorkboardListParams(boardId: selectedBoardParam)"))
|
||||
#expect(source.contains("boardId: selectedBoardParam"))
|
||||
#expect(source
|
||||
.matches(
|
||||
of: /method: "workboard\.cards\.dispatch"[\s\S]*?IPadWorkboardListParams\(boardId: selectedBoardParam\)/)
|
||||
.count == 1)
|
||||
#expect(source.contains("private var agentScopeMenu: some View"))
|
||||
#expect(source.contains("IPadSkillProposalListParams(agentId: selectedAgentParam)"))
|
||||
#expect(source.contains("agentId: selectedAgentParam"))
|
||||
#expect(!source
|
||||
.contains(
|
||||
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let compactControls = try Self.extract(
|
||||
source,
|
||||
from: "private var compactQueueControls: some View",
|
||||
to: "private var compactRefreshButton: some View")
|
||||
|
||||
#expect(source.contains("struct IPadWorkboardQueueRow"))
|
||||
#expect(source.contains("private var actionMenuItems: some View"))
|
||||
#expect(source.components(separatedBy: ".contextMenu {").count - 1 >= 2)
|
||||
#expect(source.components(separatedBy: ".swipeActions(edge: .leading").count - 1 >= 2)
|
||||
#expect(source.components(separatedBy: ".swipeActions(edge: .trailing").count - 1 >= 2)
|
||||
#expect(source.contains("@State private var presentedProposalRoute: IPadSkillProposalSheetRoute?"))
|
||||
#expect(source.contains(".sheet(item: self.$presentedProposalRoute)"))
|
||||
#expect(source.contains("private func selectProposal("))
|
||||
#expect(!source.contains("proposalSheetPresented"))
|
||||
#expect(source.contains("self.presentedSheet = .card(card)"))
|
||||
#expect(!source.contains("Label(\"Gateway\", systemImage: \"network\")"))
|
||||
#expect(!source.contains("Button(\"Gateway\")"))
|
||||
#expect(!source.contains("actionTitle: self.canRead ? nil : \"Gateway\""))
|
||||
#expect(!source.contains("Workboard offline"))
|
||||
#expect(!source.contains("Workshop offline"))
|
||||
#expect(!source.contains("Connect gateway to"))
|
||||
#expect(source.contains("private var compactRefreshButton: some View"))
|
||||
#expect(source.contains("private var compactBoardScopeMenu: some View"))
|
||||
#expect(source.contains("Color(uiColor: .secondarySystemGroupedBackground)"))
|
||||
#expect(source.contains(".allowsHitTesting(false)"))
|
||||
#expect(compactControls.contains("self.compactRefreshButton"))
|
||||
#expect(compactControls.contains("self.compactBoardScopeMenu"))
|
||||
#expect(!compactControls.contains("Self.workboardSubtitle("))
|
||||
#expect(!compactControls.contains("Label(\"Refresh\""))
|
||||
#expect(compactControls.contains("Label(\"Dispatch\""))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
|
||||
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
|
||||
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
let content = try Self.extract(
|
||||
source,
|
||||
from: "private var proposalContent: some View",
|
||||
to: "private var proposalBoard: some View")
|
||||
let board = try Self.extract(
|
||||
source,
|
||||
from: "private var proposalBoard: some View",
|
||||
to: "private var proposalList: some View")
|
||||
|
||||
#expect(content.contains("if self.isCompactWidth"))
|
||||
#expect(content.contains("self.proposalList"))
|
||||
#expect(content.contains("self.proposalBoard"))
|
||||
#expect(!content.contains("self.proposalDetail"))
|
||||
#expect(board.contains("ScrollView(.horizontal)"))
|
||||
#expect(board.contains("IPadSkillProposalKanbanColumn("))
|
||||
#expect(source.contains("private struct IPadSkillProposalKanbanCard"))
|
||||
#expect(source.contains("static let defaultProposalStatusBoardLanes"))
|
||||
#expect(source.contains("private func proposals(forLaneStatus status: String)"))
|
||||
#expect(previewSource.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
|
||||
#expect(previewSource.contains("private struct IPadSkillWorkshopKanbanPreview"))
|
||||
#expect(previewSource.contains("IPadSkillProposalKanbanColumn("))
|
||||
#expect(previewSource.contains("status: \"needs-review\""))
|
||||
#expect(previewSource.contains("status: \"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
|
||||
#expect(source.contains("#Preview(\"Skill Workshop phone queue rows\")"))
|
||||
#expect(source.contains("private struct IPadWorkboardCompactRowsPreview"))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopCompactRowsPreview"))
|
||||
#expect(source.contains("IPadWorkboardPreviewFixtures.cards"))
|
||||
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
|
||||
}
|
||||
|
||||
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard states\")"))
|
||||
#expect(source.contains("private struct IPadWorkboardStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Connected\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Empty\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Loading\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Error\")"))
|
||||
#expect(source.contains("title: \"Loading cards\""))
|
||||
#expect(source.contains("title: \"Cards unavailable\""))
|
||||
#expect(source.contains("IPadWorkboardKanbanColumn("))
|
||||
|
||||
#expect(source.contains("#Preview(\"Skill Workshop states\")"))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Offline / Error\")"))
|
||||
#expect(source.contains("title: \"No proposals\""))
|
||||
#expect(source.contains("title: \"Workshop offline\""))
|
||||
#expect(source.contains("title: \"Proposal unavailable\""))
|
||||
#expect(source.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopKanbanPreview"))
|
||||
#expect(source.contains("\"needs-review\""))
|
||||
#expect(source.contains("\"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func activityPreviewMatrixCoversConnectionStates() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Activity states\")"))
|
||||
#expect(source.contains("private struct IPadActivityStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Connected\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Loading\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Empty\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Error\")"))
|
||||
#expect(source.contains("title: \"Sessions unavailable\""))
|
||||
#expect(source.contains("title: \"No recent sessions\""))
|
||||
#expect(source.contains("title: \"Loading sessions\""))
|
||||
}
|
||||
|
||||
@Test func routedFeatureScreensReuseSharedProComponents() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("ProMetricGrid(metrics: self.metrics)"))
|
||||
#expect(source.contains("ProPanelHeader("))
|
||||
#expect(source.contains("ProStatusRow("))
|
||||
#expect(!source.contains("private struct ProMetricGrid"))
|
||||
#expect(!source.contains("private struct ProMetric"))
|
||||
#expect(!source.contains("private struct ProPanelHeader"))
|
||||
#expect(!source.contains("private struct ProStatusRow"))
|
||||
#expect(!channelsSource.contains("private struct SettingsChannelPanelHeader"))
|
||||
#expect(!channelsSource.contains("private struct SettingsChannelInfoRow"))
|
||||
#expect(componentsSource.contains("struct ProMetricGrid"))
|
||||
#expect(componentsSource.contains("struct ProPanelHeader"))
|
||||
#expect(componentsSource.contains("struct ProStatusRow"))
|
||||
}
|
||||
|
||||
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(activitySource.contains("struct IPadActivityScreen: View"))
|
||||
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(activitySource.contains("IPadSidebarScreenChrome("))
|
||||
#expect(!taskSource.contains("struct IPadActivityScreen"))
|
||||
#expect(!taskSource.contains("import OpenClawChatUI"))
|
||||
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chromeSource.contains("struct IPadSidebarScreenChrome<Content: View>: View"))
|
||||
#expect(chromeSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
#expect(chromeSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(!taskSource.contains("struct IPadSidebarScreenChrome"))
|
||||
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let featureSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chromeSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(chromeSource.contains("private var gatewayPill: some View"))
|
||||
#expect(chromeSource.contains("Button(action: gatewayAction)"))
|
||||
#expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2)
|
||||
#expect(rootSource.contains("IPadActivityScreen("))
|
||||
#expect(rootSource
|
||||
.matches(of: /IPadActivityScreen\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
|
||||
.count == 1)
|
||||
}
|
||||
|
||||
@Test func routedGatewayPillsOpenGatewaySettings() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
|
||||
#expect(!rootSource.contains("showGatewayActions"))
|
||||
#expect(!rootSource.contains("gatewayActionsDialog"))
|
||||
#expect(overviewSource.contains("Button(action: self.openSettings)"))
|
||||
#expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(agentSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(agentOverviewSource.contains("Button(action: openSettings)"))
|
||||
#expect(rootSource
|
||||
.matches(of: /AgentProTab\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
|
||||
.count >= 3)
|
||||
#expect(chatSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(chatSource.contains("private var connectionPillButton: some View"))
|
||||
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
|
||||
#expect(rootSource.contains("case .settings:"))
|
||||
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
|
||||
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
|
||||
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(sectionsSource.contains("var gatewayDestination: some View"))
|
||||
#expect(sectionsSource.contains("self.gatewayActions"))
|
||||
#expect(sectionsSource.contains("self.manualGatewayCard"))
|
||||
#expect(sectionsSource.contains("self.gatewaySetupCard"))
|
||||
#expect(sectionsSource.contains("self.discoveredGatewaysCard"))
|
||||
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
|
||||
#expect(sectionsSource.contains("title: \"Reconnect\""))
|
||||
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
|
||||
#expect(sectionsSource.contains("title: \"Diagnose\""))
|
||||
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
|
||||
#expect(sectionsSource.contains("title: \"Scan QR\""))
|
||||
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
|
||||
#expect(sectionsSource.contains("title: \"Connect\""))
|
||||
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
|
||||
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
|
||||
#expect(sectionsSource.contains("tailnetWarningText"))
|
||||
#expect(sectionsSource.contains("GatewayProblemBanner("))
|
||||
#expect(sectionsSource.contains("Task { await self.handleGatewayProblemPrimaryAction(problem) }"))
|
||||
|
||||
#expect(actionsSource.contains("await self.gatewayController.connectLastKnown()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
|
||||
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
|
||||
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
|
||||
#expect(actionsSource.contains("Check Tailscale or LAN."))
|
||||
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
|
||||
#expect(actionsSource.contains("self.resetOnboarding()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
|
||||
#expect(actionsSource.contains("await self.retryGatewayConnectionFromProblem()"))
|
||||
|
||||
#expect(settingsSource.contains("GatewayProblemDetailsSheet("))
|
||||
#expect(settingsSource.contains("QRScannerView("))
|
||||
#expect(trustSource.contains("Trust this gateway?"))
|
||||
#expect(trustSource.contains("Trust and connect"))
|
||||
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
|
||||
#expect(supportSource.contains("private struct SettingsGatewayStatesPreview"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Connected\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Loading\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Empty\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Error\")"))
|
||||
#expect(supportSource.contains("GatewayProblemBanner("))
|
||||
#expect(supportSource.contains("kind: .pairingRequired"))
|
||||
#expect(supportSource.contains("Run /pair approve in your OpenClaw chat"))
|
||||
#expect(supportSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
#expect(supportSource.contains("self.previewButton(\"Scan QR\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Connect\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Reconnect\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
|
||||
}
|
||||
|
||||
@Test func nativeChatUsesGatewayTransport() throws {
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(channelsSource.contains("Message routing and external channel clients."))
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
#expect(channelsSource.contains("label: \"ClickClack\""))
|
||||
#expect(channelsSource.contains("Self-hosted chat bot routing."))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func phoneHubSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift")
|
||||
}
|
||||
|
||||
private static func proComponentsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawProComponents.swift")
|
||||
}
|
||||
|
||||
private static func commandCenterSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabOverviewSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab+Overview.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabDestinationsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab+Destinations.swift")
|
||||
}
|
||||
|
||||
private static func agentProNodesDestinationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProNodesDestination.swift")
|
||||
}
|
||||
|
||||
private static func agentProDreamingDestinationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProDreamingDestination.swift")
|
||||
}
|
||||
|
||||
private static func rootTabsNavigationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabsNavigation.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarFeatureScreensSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarFeatureScreens.swift")
|
||||
}
|
||||
|
||||
private static func iPadTaskFeatureScreensSource() throws -> String {
|
||||
try [
|
||||
self.iPadWorkboardScreenSourceURL(),
|
||||
self.iPadSkillWorkshopScreenSourceURL(),
|
||||
self.iPadSidebarFeatureScreensSourceURL(),
|
||||
]
|
||||
.map { try String(contentsOf: $0, encoding: .utf8) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func iPadWorkboardScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadWorkboardScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSkillWorkshopScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSkillWorkshopScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarFeaturePreviewsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarFeaturePreviews.swift")
|
||||
}
|
||||
|
||||
private static func iPadActivityScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadActivityScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarScreenChromeSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarScreenChrome.swift")
|
||||
}
|
||||
|
||||
private static func chatProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/ChatProTab.swift")
|
||||
}
|
||||
|
||||
private static func docsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawDocsScreen.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSectionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabSections.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabActionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabActions.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSupportSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabSupport.swift")
|
||||
}
|
||||
|
||||
private static func gatewayTrustPromptAlertSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/GatewayTrustPromptAlert.swift")
|
||||
}
|
||||
|
||||
private static func gatewayConnectionControllerSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
|
||||
}
|
||||
|
||||
private static func channelsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsChannelsDestination.swift")
|
||||
}
|
||||
|
||||
private static func sharedChatPreviewSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift")
|
||||
}
|
||||
|
||||
private static func xcodeProjectSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("OpenClaw.xcodeproj/project.pbxproj")
|
||||
}
|
||||
|
||||
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
|
||||
let startRange = try #require(source.range(of: start))
|
||||
let tail = source[startRange.lowerBound...]
|
||||
let endRange = try #require(tail.range(of: end))
|
||||
return String(tail[..<endRange.lowerBound])
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct SwiftUIRenderSmokeTests {
|
||||
@MainActor private static func host(_ view: some View) -> UIWindow {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
|
||||
let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size)
|
||||
let window = UIWindow(frame: frame)
|
||||
window.rootViewController = UIHostingController(rootView: view)
|
||||
window.makeKeyAndVisible()
|
||||
window.rootViewController?.view.setNeedsLayout()
|
||||
@@ -41,18 +42,102 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
@Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() {
|
||||
for scenario in Self.rootTabsShellScenarios() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom)
|
||||
.environment(\.horizontalSizeClass, scenario.horizontalSizeClass)
|
||||
.environment(\.verticalSizeClass, scenario.verticalSizeClass)
|
||||
|
||||
_ = Self.host(root, size: scenario.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildGatewayStateViewHierarchies() {
|
||||
for appModel in Self.rootTabsGatewayStateModels() {
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func phoneControlHubBuildsGatewayStateViewHierarchies() {
|
||||
for appModel in Self.rootTabsGatewayStateModels() {
|
||||
let root = RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func phoneControlHubBuildsLandscapeCompactState() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func routedSidebarScreensBuildOfflineStates() {
|
||||
let appModel = NodeAppModel()
|
||||
let screens: [AnyView] = [
|
||||
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(OpenClawDocsScreen()),
|
||||
AnyView(SettingsChannelsScreen()),
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
AnyView(AgentProTab(directRoute: .agents)),
|
||||
AnyView(AgentProTab(directRoute: .instances)),
|
||||
AnyView(CommandSessionsScreen(openChat: {})),
|
||||
AnyView(AgentProTab(directRoute: .dreaming)),
|
||||
AnyView(AgentProTab(directRoute: .usage)),
|
||||
AnyView(AgentProTab(directRoute: .cron)),
|
||||
]
|
||||
|
||||
for screen in screens {
|
||||
let root = NavigationStack { screen }
|
||||
.environment(appModel)
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func taskScreensBuildPhoneLandscapeCompactStates() {
|
||||
let appModel = NodeAppModel()
|
||||
let screens: [AnyView] = [
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
]
|
||||
|
||||
for screen in screens {
|
||||
let root = NavigationStack { screen }
|
||||
.environment(appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||
@@ -64,4 +149,51 @@ import UIKit
|
||||
let root = VoiceWakeToast(command: "openclaw: do something")
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] {
|
||||
let offlineModel = NodeAppModel()
|
||||
|
||||
let connectingModel = NodeAppModel()
|
||||
connectingModel.gatewayStatusText = "Connecting..."
|
||||
|
||||
let connectedModel = NodeAppModel()
|
||||
connectedModel.enterAppleReviewDemoMode()
|
||||
|
||||
let errorModel = NodeAppModel()
|
||||
errorModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
|
||||
return [offlineModel, connectingModel, connectedModel, errorModel]
|
||||
}
|
||||
|
||||
private static func rootTabsShellScenarios() -> [RootTabsShellScenario] {
|
||||
[
|
||||
RootTabsShellScenario(
|
||||
idiom: .phone,
|
||||
size: CGSize(width: 393, height: 852),
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular),
|
||||
RootTabsShellScenario(
|
||||
idiom: .phone,
|
||||
size: CGSize(width: 852, height: 393),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact),
|
||||
RootTabsShellScenario(
|
||||
idiom: .pad,
|
||||
size: CGSize(width: 1024, height: 1366),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular),
|
||||
RootTabsShellScenario(
|
||||
idiom: .pad,
|
||||
size: CGSize(width: 1366, height: 1024),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular),
|
||||
]
|
||||
}
|
||||
|
||||
private struct RootTabsShellScenario {
|
||||
let idiom: UIUserInterfaceIdiom
|
||||
let size: CGSize
|
||||
let horizontalSizeClass: UserInterfaceSizeClass
|
||||
let verticalSizeClass: UserInterfaceSizeClass
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ targets:
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
@@ -183,7 +183,7 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
@@ -220,7 +220,7 @@ targets:
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
|
||||
@@ -24,6 +24,15 @@ enum HostEnvSanitizer {
|
||||
"NO_COLOR",
|
||||
"FORCE_COLOR",
|
||||
]
|
||||
private static let gitAllowProtocolKey = "GIT_ALLOW_PROTOCOL"
|
||||
private static let gitProtocolFromUserKey = "GIT_PROTOCOL_FROM_USER"
|
||||
private static let gitProtocolFromUserDisabledValue = "0"
|
||||
private static let gitDefaultAlwaysAllowedProtocols: Set<String> = [
|
||||
"git",
|
||||
"http",
|
||||
"https",
|
||||
"ssh",
|
||||
]
|
||||
|
||||
private static func isBlocked(_ upperKey: String) -> Bool {
|
||||
if self.blockedKeys.contains(upperKey) { return true }
|
||||
@@ -82,6 +91,25 @@ enum HostEnvSanitizer {
|
||||
Array(Set(values)).sorted()
|
||||
}
|
||||
|
||||
private static func isPermissiveGitProtocolFromUserValue(_ value: String) -> Bool {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized == "true" || normalized == "yes" || normalized == "on" {
|
||||
return true
|
||||
}
|
||||
let isInteger = normalized.range(of: #"^[+-]?[0-9]+$"#, options: .regularExpression) != nil
|
||||
let isZero = normalized.range(of: #"^[+-]?0+$"#, options: .regularExpression) != nil
|
||||
return isInteger && !isZero
|
||||
}
|
||||
|
||||
private static func sanitizeInheritedGitAllowProtocolValue(_ value: String) -> String {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if normalized.isEmpty { return "" }
|
||||
let safeProtocols = normalized
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
.filter { self.gitDefaultAlwaysAllowedProtocols.contains(String($0)) }
|
||||
return safeProtocols.joined(separator: ":")
|
||||
}
|
||||
|
||||
static func inspectOverrides(
|
||||
overrides: [String: String]?,
|
||||
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
|
||||
@@ -120,6 +148,22 @@ enum HostEnvSanitizer {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
// Preserve inherited Git allowlists without widening malformed or unsafe entries by
|
||||
// deletion. Protocols outside Git's safe default set are removed instead.
|
||||
if upper == self.gitAllowProtocolKey {
|
||||
merged[key] = self.sanitizeInheritedGitAllowProtocolValue(value)
|
||||
continue
|
||||
}
|
||||
// Preserve non-permissive Git boolean values. Permissive values must become explicit
|
||||
// `0` because Git's unset default still permits protocols with policy `user`.
|
||||
if upper == self.gitProtocolFromUserKey {
|
||||
if !self.isPermissiveGitProtocolFromUserValue(value) {
|
||||
merged[key] = value
|
||||
} else {
|
||||
merged[key] = self.gitProtocolFromUserDisabledValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
if self.isBlockedInherited(upper) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ enum HostEnvSecurityPolicy {
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AZURE_CLIENT_ID",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"BASHOPTS",
|
||||
"BASH_ENV",
|
||||
"BROWSER",
|
||||
"BUNDLE_GEMFILE",
|
||||
@@ -70,12 +71,14 @@ enum HostEnvSecurityPolicy {
|
||||
"ERL_ZFLAGS",
|
||||
"EXINIT",
|
||||
"FCEDIT",
|
||||
"FPATH",
|
||||
"GCONV_PATH",
|
||||
"GEM_HOME",
|
||||
"GEM_PATH",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITLAB_TOKEN",
|
||||
"GIT_ALLOW_PROTOCOL",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_ASKPASS",
|
||||
"GIT_COMMON_DIR",
|
||||
@@ -87,6 +90,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_PROTOCOL_FROM_USER",
|
||||
"GIT_PROXY_COMMAND",
|
||||
"GIT_SEQUENCE_EDITOR",
|
||||
"GIT_SSH",
|
||||
@@ -116,6 +120,7 @@ enum HostEnvSecurityPolicy {
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"JULIA_EDITOR",
|
||||
"KSH_ENV",
|
||||
"LDFLAGS",
|
||||
"LESSCLOSE",
|
||||
"LESSOPEN",
|
||||
@@ -183,6 +188,7 @@ enum HostEnvSecurityPolicy {
|
||||
"SUDO_EDITOR",
|
||||
"SVN_EDITOR",
|
||||
"SVN_SSH",
|
||||
"TCLLIBPATH",
|
||||
"TF_CLI_CONFIG_FILE",
|
||||
"TF_PLUGIN_CACHE_DIR",
|
||||
"UV_DEFAULT_INDEX",
|
||||
@@ -207,6 +213,7 @@ enum HostEnvSecurityPolicy {
|
||||
|
||||
static let blockedKeys: Set<String> = [
|
||||
"ANT_OPTS",
|
||||
"BASHOPTS",
|
||||
"BASH_ENV",
|
||||
"BROWSER",
|
||||
"BZR_EDITOR",
|
||||
@@ -232,7 +239,9 @@ enum HostEnvSecurityPolicy {
|
||||
"ERL_FLAGS",
|
||||
"ERL_ZFLAGS",
|
||||
"EXINIT",
|
||||
"FPATH",
|
||||
"GCONV_PATH",
|
||||
"GIT_ALLOW_PROTOCOL",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_DIR",
|
||||
@@ -243,6 +252,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_PROTOCOL_FROM_USER",
|
||||
"GIT_SEQUENCE_EDITOR",
|
||||
"GIT_SSL_CAINFO",
|
||||
"GIT_SSL_CAPATH",
|
||||
@@ -260,6 +270,7 @@ enum HostEnvSecurityPolicy {
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"JULIA_EDITOR",
|
||||
"KSH_ENV",
|
||||
"LUA_INIT",
|
||||
"LUA_INIT_5_1",
|
||||
"LUA_INIT_5_2",
|
||||
@@ -297,6 +308,7 @@ enum HostEnvSecurityPolicy {
|
||||
"SUDO_ASKPASS",
|
||||
"SVN_EDITOR",
|
||||
"SVN_SSH",
|
||||
"TCLLIBPATH",
|
||||
"VAGRANT_VAGRANTFILE",
|
||||
"VIMINIT",
|
||||
"_JAVA_OPTIONS"
|
||||
@@ -425,6 +437,11 @@ enum HostEnvSecurityPolicy {
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTFLAGS",
|
||||
"RUSTUP_DIST_ROOT",
|
||||
"RUSTUP_DIST_SERVER",
|
||||
"RUSTUP_HOME",
|
||||
"RUSTUP_TOOLCHAIN",
|
||||
"RUSTUP_UPDATE_ROOT",
|
||||
"R_LIBS_USER",
|
||||
"SSH_ASKPASS",
|
||||
"SSH_AUTH_SOCK",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
enum Scenario {
|
||||
case connected
|
||||
case empty
|
||||
case loading
|
||||
case error
|
||||
}
|
||||
|
||||
let scenario: Scenario
|
||||
|
||||
init(scenario: Scenario = .connected) {
|
||||
self.scenario = scenario
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
switch self.scenario {
|
||||
case .connected:
|
||||
break
|
||||
case .empty:
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-empty-session",
|
||||
messages: [],
|
||||
thinkingLevel: "medium")
|
||||
case .loading:
|
||||
try await Task.sleep(nanoseconds: 60_000_000_000)
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-loading-session",
|
||||
messages: [],
|
||||
thinkingLevel: "medium")
|
||||
case .error:
|
||||
throw NSError(
|
||||
domain: "OpenClawChatPreviewTransport",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
|
||||
}
|
||||
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-session",
|
||||
messages: [
|
||||
Self.message(
|
||||
role: "user",
|
||||
text: "Can you check the gateway status and summarize anything risky?",
|
||||
timestamp: 1),
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: "Gateway is reachable. The only notable item is that push relay is still using local distribution, so device tests should stay on the local lane.",
|
||||
timestamp: 2),
|
||||
Self.toolCall(
|
||||
id: "tool-preview-1",
|
||||
name: "gateway.status",
|
||||
arguments: ["deep": AnyCodable(true)],
|
||||
timestamp: 3),
|
||||
Self.toolResult(
|
||||
toolCallId: "tool-preview-1",
|
||||
name: "gateway.status",
|
||||
text: "status=ok, channels=ios,macos, lastHeartbeat=12s",
|
||||
timestamp: 4),
|
||||
],
|
||||
thinkingLevel: "medium")
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
contextWindow: 400_000),
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200_000),
|
||||
]
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
|
||||
}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
OpenClawChatSessionsListResponse(
|
||||
ts: 0,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 400_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "off", label: "off"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "high"),
|
||||
],
|
||||
thinkingDefault: "medium",
|
||||
mainSessionKey: "main"),
|
||||
sessions: [
|
||||
Self.session(key: "main", displayName: "Main", updatedAt: 2),
|
||||
Self.session(key: "ios-preview", displayName: "iOS preview", updatedAt: 1),
|
||||
])
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
switch self.scenario {
|
||||
case .connected, .empty, .loading:
|
||||
true
|
||||
case .error:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> AnyCodable {
|
||||
AnyCodable([
|
||||
"role": role,
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolCall(
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: [String: AnyCodable],
|
||||
timestamp: Double) -> AnyCodable
|
||||
{
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
[
|
||||
"type": "toolCall",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"arguments": AnyCodable(arguments),
|
||||
],
|
||||
],
|
||||
"timestamp": timestamp,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolResult(
|
||||
toolCallId: String,
|
||||
name: String,
|
||||
text: String,
|
||||
timestamp: Double) -> AnyCodable
|
||||
{
|
||||
AnyCodable([
|
||||
"role": "tool",
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
"toolCallId": toolCallId,
|
||||
"toolName": name,
|
||||
])
|
||||
}
|
||||
|
||||
private static func session(
|
||||
key: String,
|
||||
displayName: String,
|
||||
updatedAt: Double) -> OpenClawChatSessionEntry
|
||||
{
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: displayName,
|
||||
surface: "ios",
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: updatedAt,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: "medium",
|
||||
verboseLevel: nil,
|
||||
inputTokens: 2500,
|
||||
outputTokens: 900,
|
||||
totalTokens: 3400,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 400_000)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Chat") {
|
||||
OpenClawChatPreview(scenario: .connected)
|
||||
}
|
||||
|
||||
#Preview("Chat connected") {
|
||||
OpenClawChatPreview(scenario: .connected)
|
||||
}
|
||||
|
||||
#Preview("Chat empty") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .empty,
|
||||
sessionKey: "empty-preview")
|
||||
}
|
||||
|
||||
#Preview("Chat loading") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .loading,
|
||||
sessionKey: "loading-preview")
|
||||
}
|
||||
|
||||
#Preview("Chat gateway error") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .error,
|
||||
sessionKey: "error-preview")
|
||||
}
|
||||
|
||||
#Preview("Onboarding chat") {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
sessionKey: "ios-preview",
|
||||
transport: OpenClawChatPreviewTransport()),
|
||||
showsSessionSwitcher: false,
|
||||
style: .onboarding,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue)
|
||||
}
|
||||
|
||||
private struct OpenClawChatPreview: View {
|
||||
let scenario: OpenClawChatPreviewTransport.Scenario
|
||||
var sessionKey: String = "main"
|
||||
|
||||
var body: some View {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
sessionKey: self.sessionKey,
|
||||
transport: OpenClawChatPreviewTransport(scenario: self.scenario)),
|
||||
showsSessionSwitcher: true,
|
||||
style: .standard,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue,
|
||||
showsAssistantTrace: true)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
1cd5bcc75461c64d39a00918a50d033e66ae7ec199d8029f7cccaaa2eeb16f22 plugin-sdk-api-baseline.json
|
||||
a5d3b43c3710c4238958b1b3163e652ac34bdc7b82215c6294ce61b72188d75e plugin-sdk-api-baseline.jsonl
|
||||
4768607253fdc720cb2bc280ac285ccfa7f7057a01659691f5be5b1f58422789 plugin-sdk-api-baseline.json
|
||||
7901bc511cf6f9628df4cd619035265f48c40939e4e8e51c5c10dc73a263f183 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -42,7 +42,7 @@ Requires OpenClaw 2026.5.29 or above. Run `openclaw --version` to check. Upgrade
|
||||
Configure `dmPolicy` to control who can DM the bot:
|
||||
|
||||
- `"pairing"` - unknown users receive a pairing code; approve via CLI
|
||||
- `"allowlist"` - only users listed in `allowFrom` can chat (default: bot owner only)
|
||||
- `"allowlist"` - only users listed in `allowFrom` can chat
|
||||
- `"open"` - allow public DMs only when `allowFrom` includes `"*"`; with restrictive entries, only matching users can chat
|
||||
- `"disabled"` - disable all DMs
|
||||
|
||||
@@ -567,8 +567,8 @@ Full configuration: [Gateway configuration](/gateway/configuration)
|
||||
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
|
||||
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
|
||||
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
|
||||
| `channels.feishu.requireMention` | Require @mention in groups | `true` |
|
||||
|
||||
@@ -763,6 +763,31 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Messages send but inbound iMessages do not arrive">
|
||||
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
|
||||
|
||||
```bash
|
||||
imsg chats --limit 10 --json
|
||||
imsg watch --chat-id <chat-id> --json
|
||||
sqlite3 ~/Library/Messages/chat.db \
|
||||
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
|
||||
```
|
||||
|
||||
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k system/com.apple.apsd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
|
||||
imsg launch
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gateway is not running on macOS">
|
||||
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ For most users, the upgrade is in place:
|
||||
- runtime state stays under `~/.openclaw/matrix/`
|
||||
|
||||
You do not need to rename config keys or reinstall the plugin under a new name.
|
||||
The root `openclaw` package no longer bundles Matrix runtime code or Matrix SDK
|
||||
dependencies. If `openclaw channels status` shows Matrix is configured but the
|
||||
plugin is missing after an update, run `openclaw doctor --fix` or
|
||||
`openclaw plugins install @openclaw/matrix`; do not install Matrix SDK packages
|
||||
into the root OpenClaw package.
|
||||
|
||||
## What the migration does automatically
|
||||
|
||||
|
||||
@@ -673,7 +673,7 @@ Launches a local child process and communicates over stdin/stdout.
|
||||
<Warning>
|
||||
**Stdio env safety filter**
|
||||
|
||||
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
|
||||
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
|
||||
|
||||
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
|
||||
</Warning>
|
||||
|
||||
@@ -417,7 +417,7 @@ openclaw plugins inspect <id> --runtime
|
||||
openclaw plugins inspect <id> --json
|
||||
```
|
||||
|
||||
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
|
||||
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. JSON output includes the plugin manifest contracts, such as `contracts.agentToolResultMiddleware` and `contracts.trustedToolPolicies`, so operators can audit trusted-surface declarations before enabling or restarting a plugin. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
|
||||
|
||||
Plugin-owned CLI commands are usually installed as root `openclaw` command groups, but plugins may also register nested commands under a core parent such as `openclaw nodes`. After `inspect --runtime` shows a command under `cliCommands`, run it at the listed path; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`.
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ Notes:
|
||||
- `--local` cannot be combined with `--url`, `--token`, or `--password`.
|
||||
- `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers).
|
||||
- When launched from inside a configured agent workspace directory, TUI auto-selects that agent for the session key default (unless `--session` is explicitly `agent:<id>:...`).
|
||||
- To show the Gateway hostname in the footer for non-local URL-backed connections, run `openclaw config set tui.footer.showRemoteHost true`. The host label is off by default and never appears for loopback or embedded local connections.
|
||||
- Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable.
|
||||
- Local mode adds `/auth [provider]` inside the TUI command surface.
|
||||
- Plugin approval gates still apply in local mode. Tools that require approval prompt for a decision in the terminal; nothing is silently auto-approved because the Gateway is not involved.
|
||||
|
||||
@@ -309,7 +309,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-ultra-550b-a55b` |
|
||||
| NovitaAI | `novita` | `NOVITA_API_KEY` | `novita/deepseek/deepseek-v3-0324` |
|
||||
| [Ollama Cloud](/providers/ollama-cloud) | `ollama-cloud` | `OLLAMA_API_KEY` | `ollama-cloud/kimi-k2.6` |
|
||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
| OpenRouter | `openrouter` | OpenRouter OAuth or `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
| Qianfan | `qianfan` | `QIANFAN_API_KEY` | `qianfan/deepseek-v3.2` |
|
||||
| Qwen Cloud | `qwen` | `QWEN_API_KEY` / `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | `qwen/qwen3.5-plus` |
|
||||
| [Qwen OAuth](/providers/qwen-oauth) | `qwen-oauth` | `QWEN_API_KEY` | `qwen-oauth/qwen3.5-plus` |
|
||||
|
||||
@@ -1374,6 +1374,7 @@
|
||||
"pages": [
|
||||
"clawhub/cli",
|
||||
"clawhub/publishing",
|
||||
"clawhub/plugin-validation-fixes",
|
||||
"clawhub/skill-format",
|
||||
"clawhub/soul-format",
|
||||
"clawhub/auth",
|
||||
|
||||
@@ -615,6 +615,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
|
||||
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
|
||||
mediaMaxMb: 16,
|
||||
service: "auto",
|
||||
sendTransport: "auto",
|
||||
region: "US",
|
||||
actions: {
|
||||
reactions: true,
|
||||
@@ -637,6 +638,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
|
||||
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
|
||||
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
|
||||
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
|
||||
- `channels.imessage.sendTransport`: preferred `imsg` RPC send transport for normal outbound replies. `auto` (default) uses the IMCore bridge for existing chats when it is running, then falls back to AppleScript; `bridge` requires private-API delivery; `applescript` forces the public Messages automation path.
|
||||
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
|
||||
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
|
||||
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile.
|
||||
|
||||
@@ -161,6 +161,13 @@ When any subkey is enabled, model and tool spans get bounded, redacted
|
||||
`captureContent: true` only for broad diagnostics captures where OTLP log
|
||||
message bodies are also approved for export.
|
||||
|
||||
`toolInputs`/`toolOutputs` content is captured for the built-in agent runtime's
|
||||
tool executions (`openclaw.content.tool_input` on completed/error spans,
|
||||
`openclaw.content.tool_output` on completed spans). External harness tool calls
|
||||
(Codex, Claude CLI) emit `tool.execution.*` spans without content payloads.
|
||||
Captured content travels on a trusted, listener-only channel and is never placed
|
||||
on the public diagnostic event bus.
|
||||
|
||||
## Sampling and flushing
|
||||
|
||||
- **Traces:** `diagnostics.otel.sampleRate` (root-span only, `0.0` drops all,
|
||||
|
||||
@@ -780,16 +780,19 @@ rather than the pre-handshake defaults.
|
||||
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility.
|
||||
- successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth.
|
||||
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade).
|
||||
- direct-loopback `gateway-client` backend RPCs authenticated with the shared
|
||||
gateway token/password.
|
||||
- Omitting device identity has scope consequences. When a Control UI connection
|
||||
lacks device identity, `shouldClearUnboundScopesForMissingDeviceIdentity`
|
||||
clears self-declared scopes to an empty set for token, password, and
|
||||
trusted-proxy auth. The connection is allowed on explicit trust paths, but
|
||||
scope-gated methods fail. The exception is local Control UI token/password
|
||||
sessions with `allowInsecureAuth`, which preserve scopes. For other cases,
|
||||
set `gateway.controlUi.dangerouslyDisableDeviceAuth=true` only as a
|
||||
break-glass scope-preservation path.
|
||||
- direct-loopback `gateway-client` backend RPCs on the reserved internal
|
||||
helper path.
|
||||
- Omitting device identity has scope consequences. When a device-less operator
|
||||
connection is allowed through an explicit trust path, OpenClaw still clears
|
||||
self-declared scopes to an empty set unless that path has a named
|
||||
scope-preservation exception. Scope-gated methods then fail with
|
||||
`missing scope`.
|
||||
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` is a Control UI
|
||||
break-glass scope-preservation path. It does not grant scopes to arbitrary
|
||||
custom backend or CLI-shaped WebSocket clients.
|
||||
- The reserved direct-loopback `gateway-client` backend helper path preserves
|
||||
scopes only for internal local control-plane RPCs; custom backend IDs do not
|
||||
receive this exception.
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
### Device auth migration diagnostics
|
||||
|
||||
@@ -53,24 +53,23 @@ Use `trusted-proxy` auth mode when:
|
||||
|
||||
When `gateway.auth.mode = "trusted-proxy"` is active and the request passes trusted-proxy checks, Control UI WebSocket sessions can connect without device pairing identity.
|
||||
|
||||
Scope implications:
|
||||
|
||||
- Device-less Control UI WebSocket sessions connect but receive no operator scopes by default. OpenClaw clears the requested scope list to `[]` so a session that is not bound to an approved paired device/token cannot self-declare permissions.
|
||||
- If methods fail with `missing scope` after a successful WebSocket connect, use HTTPS so the browser can generate device identity and complete pairing. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
|
||||
- Break-glass only: `gateway.controlUi.dangerouslyDisableDeviceAuth=true` preserves requested scopes even without device identity. This is a severe security downgrade; revert quickly. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
|
||||
|
||||
Reverse-proxy scope capping:
|
||||
|
||||
- If your proxy sends `x-openclaw-scopes` on the Control UI WebSocket upgrade request, OpenClaw caps the session scopes to the intersection of the requested scopes and the declared scopes. This header does not grant scopes; it only narrows what the session can hold.
|
||||
|
||||
Implications:
|
||||
|
||||
- Pairing is no longer the primary gate for Control UI access in this mode.
|
||||
- Your reverse proxy auth policy and `allowUsers` become the effective access control.
|
||||
- Keep gateway ingress locked to trusted proxy IPs only (`gateway.trustedProxies` + firewall).
|
||||
|
||||
**Scope clearing without device identity:** Because the browser over plain HTTP
|
||||
cannot create the device identity that OpenClaw uses to bind operator scopes,
|
||||
trusted-proxy WebSocket connections that lack device identity have their
|
||||
self-declared scopes cleared to an empty set. The connection is allowed, but
|
||||
scope-gated methods (`operator.read`, `operator.write`, etc.) fail with
|
||||
`missing scope`.
|
||||
|
||||
To preserve operator scopes on trusted-proxy WebSocket connections without
|
||||
device identity, set `gateway.controlUi.dangerouslyDisableDeviceAuth: true`.
|
||||
This is a break-glass flag (`openclaw security audit` reports it as critical).
|
||||
Use it only when the reverse proxy is the sole path to the Gateway and device
|
||||
identity cannot be established.
|
||||
Custom WebSocket clients are not Control UI sessions. `gateway.controlUi.dangerouslyDisableDeviceAuth` does not grant scopes to arbitrary `client.mode: "backend"` or CLI-shaped clients. Custom automation should use device identity/pairing, the reserved direct-local `client.id: "gateway-client"` backend helper path, or the [admin HTTP RPC plugin](/plugins/admin-http-rpc) when an HTTP request/response surface is a better fit.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -322,12 +321,9 @@ Loopback trusted-proxy identity headers still fail closed: same-host callers are
|
||||
|
||||
## Operator scopes header
|
||||
|
||||
Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may optionally declare operator scopes with `x-openclaw-scopes`.
|
||||
Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may optionally declare operator scopes with `x-openclaw-scopes` on HTTP API requests.
|
||||
|
||||
Note: `x-openclaw-scopes` applies to HTTP endpoints only. WebSocket scopes are
|
||||
determined by the Gateway protocol handshake and device identity binding. For
|
||||
WebSocket scope behavior with trusted-proxy, see
|
||||
[Control UI pairing behavior](#control-ui-pairing-behavior).
|
||||
Note: WebSocket scopes are determined by the Gateway protocol handshake and device identity binding. On Control UI WebSocket upgrade requests, `x-openclaw-scopes` is only a cap on the negotiated session scopes, not a grant. For WebSocket scope behavior with trusted-proxy, see [Control UI pairing behavior](#control-ui-pairing-behavior).
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -342,6 +338,7 @@ Behavior:
|
||||
- When the header is absent, normal identity-bearing HTTP APIs fall back to the standard operator default scope set.
|
||||
- Gateway-auth **plugin HTTP routes** are narrower by default: when `x-openclaw-scopes` is absent, their runtime scope falls back to `operator.write`.
|
||||
- Browser-origin HTTP requests still have to pass `gateway.controlUi.allowedOrigins` (or deliberate Host-header fallback mode) even after trusted-proxy auth succeeds.
|
||||
- For Control UI WebSocket sessions, `x-openclaw-scopes` is a scope cap when present on the upgrade request. An empty value yields no scopes.
|
||||
|
||||
Practical rule: send `x-openclaw-scopes` explicitly when you want a trusted-proxy request to be narrower than the defaults, or when a gateway-auth plugin route needs something stronger than write scope.
|
||||
|
||||
@@ -427,17 +424,20 @@ The audit checks for:
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Connection succeeds but methods report missing scope">
|
||||
The WebSocket connects, but `chat.history` or `sessions.list` fails with
|
||||
`missing scope: operator.read`.
|
||||
The WebSocket connects, but `chat.history`, `sessions.list`, or
|
||||
`models.list` fails with `missing scope: operator.read`.
|
||||
|
||||
This is expected for trusted-proxy WebSocket connections without device
|
||||
identity. Connections lacking device identity have their scopes cleared. The
|
||||
browser cannot generate device identity over plain HTTP.
|
||||
Common causes:
|
||||
|
||||
- Device-less Control UI session: trusted-proxy auth can admit the WebSocket connection without device identity, but OpenClaw clears scopes on device-less sessions by design.
|
||||
- Custom backend client: `gateway.controlUi.dangerouslyDisableDeviceAuth` is Control UI scoped and does not grant scopes to arbitrary backend or CLI-shaped WebSocket clients.
|
||||
- Overly narrow `x-openclaw-scopes`: if your proxy injects this header on the Control UI WebSocket upgrade request, the session scopes are capped to that set. An empty header value yields no scopes.
|
||||
|
||||
Fix:
|
||||
|
||||
- Set `gateway.controlUi.dangerouslyDisableDeviceAuth: true` to preserve operator scopes on trusted-proxy WebSocket connections, or
|
||||
- Use device identity pairing so scopes are bound to the device token.
|
||||
- For Control UI, use HTTPS so the browser can generate device identity and complete pairing.
|
||||
- For custom automation, use device identity/pairing, the reserved direct-local `gateway-client` backend helper path, or [admin HTTP RPC](/plugins/admin-http-rpc).
|
||||
- Use `gateway.controlUi.dangerouslyDisableDeviceAuth: true` only as a temporary Control UI break-glass path.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="WebSocket still failing">
|
||||
|
||||
@@ -381,7 +381,7 @@ Notes:
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
|
||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
|
||||
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
|
||||
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||
- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`).
|
||||
|
||||
@@ -107,7 +107,7 @@ Notes:
|
||||
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
|
||||
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
|
||||
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app's environment.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
|
||||
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
|
||||
|
||||
@@ -106,6 +106,14 @@ local proof.
|
||||
eagerly loading every plugin runtime. Set `activation.onStartup`
|
||||
intentionally. This example starts on Gateway startup.
|
||||
|
||||
Host-trusted plugin surfaces are also manifest-gated and require explicit
|
||||
enablement for installed plugins. If an installed plugin registers
|
||||
`api.registerAgentToolResultMiddleware(...)`, declare each target runtime in
|
||||
`contracts.agentToolResultMiddleware`. If it registers
|
||||
`api.registerTrustedToolPolicy(...)`, declare each policy id in
|
||||
`contracts.trustedToolPolicies`. These declarations keep install-time
|
||||
inspection and runtime registration aligned.
|
||||
|
||||
For every manifest field, see [Plugin manifest](/plugins/manifest).
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -373,7 +373,7 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
|
||||
- GPT-5.4 mini
|
||||
- GPT-5.2
|
||||
|
||||
The current bundled harness is `@openai/codex` `0.137.0`. A `model/list` probe
|
||||
The current bundled harness is `@openai/codex` `0.139.0`. A `model/list` probe
|
||||
against that bundled app-server returned:
|
||||
|
||||
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
|
||||
|
||||
@@ -227,12 +227,17 @@ See [Plugin permission requests](/plugins/plugin-permission-requests) for
|
||||
approval routing, decision behavior, and when to use `requireApproval` instead
|
||||
of optional tools or exec approvals.
|
||||
|
||||
Bundled plugins that need host-level policy can register trusted tool policies
|
||||
with `api.registerTrustedToolPolicy(...)`. These run before ordinary
|
||||
`before_tool_call` hooks and before external plugin decisions. Use them only
|
||||
Plugins that need host-level policy can register trusted tool policies with
|
||||
`api.registerTrustedToolPolicy(...)`. These run before ordinary
|
||||
`before_tool_call` hooks and before normal hook decisions. Bundled trusted
|
||||
policies run first; installed-plugin trusted policies run next in plugin-load
|
||||
order; ordinary `before_tool_call` hooks run after them. Bundled plugins keep
|
||||
the existing trusted-policy path. Installed plugins must be explicitly enabled
|
||||
and declare every policy id in `contracts.trustedToolPolicies`; undeclared ids
|
||||
are rejected before registration. Policy ids are scoped to the registering
|
||||
plugin, so different plugins may reuse the same local id. Use this tier only
|
||||
for host-trusted gates such as workspace policy, budget enforcement, or
|
||||
reserved workflow safety. External plugins should use normal `before_tool_call`
|
||||
hooks.
|
||||
reserved workflow safety.
|
||||
|
||||
### Exec environment hook
|
||||
|
||||
|
||||
@@ -631,6 +631,7 @@ read without importing the plugin runtime.
|
||||
{
|
||||
"contracts": {
|
||||
"agentToolResultMiddleware": ["openclaw", "codex"],
|
||||
"trustedToolPolicies": ["workflow-budget"],
|
||||
"externalAuthProviders": ["acme-ai"],
|
||||
"embeddingProviders": ["openai-compatible"],
|
||||
"speechProviders": ["openai"],
|
||||
@@ -651,32 +652,41 @@ read without importing the plugin runtime.
|
||||
|
||||
Each list is optional:
|
||||
|
||||
| Field | Type | What it means |
|
||||
| -------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `embeddedExtensionFactories` | `string[]` | Codex app-server extension factory ids, currently `codex-app-server`. |
|
||||
| `agentToolResultMiddleware` | `string[]` | Runtime ids a bundled plugin may register tool-result middleware for. |
|
||||
| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. |
|
||||
| `embeddingProviders` | `string[]` | General embedding provider ids this plugin owns for reusable vector embedding use, including memory. |
|
||||
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
|
||||
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
|
||||
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
|
||||
| `memoryEmbeddingProviders` | `string[]` | Deprecated memory-specific embedding provider ids this plugin owns. |
|
||||
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
|
||||
| `transcriptSourceProviders` | `string[]` | Transcript source provider ids this plugin owns. |
|
||||
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
|
||||
| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. |
|
||||
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
|
||||
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
|
||||
| `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. |
|
||||
| `gatewayMethodDispatch` | `string[]` | Reserved entitlement for authenticated plugin HTTP routes that dispatch Gateway methods in-process. |
|
||||
| `tools` | `string[]` | Agent tool names this plugin owns. |
|
||||
| Field | Type | What it means |
|
||||
| -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `embeddedExtensionFactories` | `string[]` | Codex app-server extension factory ids, currently `codex-app-server`. |
|
||||
| `agentToolResultMiddleware` | `string[]` | Runtime ids this plugin may register tool-result middleware for. |
|
||||
| `trustedToolPolicies` | `string[]` | Plugin-local trusted pre-tool policy ids an installed plugin may register. Bundled plugins may register policies without this field. |
|
||||
| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. |
|
||||
| `embeddingProviders` | `string[]` | General embedding provider ids this plugin owns for reusable vector embedding use, including memory. |
|
||||
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
|
||||
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
|
||||
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
|
||||
| `memoryEmbeddingProviders` | `string[]` | Deprecated memory-specific embedding provider ids this plugin owns. |
|
||||
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
|
||||
| `transcriptSourceProviders` | `string[]` | Transcript source provider ids this plugin owns. |
|
||||
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
|
||||
| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. |
|
||||
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
|
||||
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
|
||||
| `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. |
|
||||
| `gatewayMethodDispatch` | `string[]` | Reserved entitlement for authenticated plugin HTTP routes that dispatch Gateway methods in-process. |
|
||||
| `tools` | `string[]` | Agent tool names this plugin owns. |
|
||||
|
||||
`contracts.embeddedExtensionFactories` is retained for bundled Codex
|
||||
app-server-only extension factories. Bundled tool-result transforms should
|
||||
declare `contracts.agentToolResultMiddleware` and register with
|
||||
`api.registerAgentToolResultMiddleware(...)` instead. External plugins cannot
|
||||
register tool-result middleware because the seam can rewrite high-trust tool
|
||||
output before the model sees it.
|
||||
`api.registerAgentToolResultMiddleware(...)` instead. Installed plugins may use
|
||||
the same middleware seam only when explicitly enabled and only for runtimes they
|
||||
declare in `contracts.agentToolResultMiddleware`.
|
||||
|
||||
Installed plugins that need the host-trusted pre-tool policy tier must declare
|
||||
each registered local id in `contracts.trustedToolPolicies` and be explicitly
|
||||
enabled. Bundled plugins keep the existing trusted-policy path, but installed
|
||||
plugins with undeclared policy ids are rejected before registration. Policy ids
|
||||
are scoped to the registering plugin, so two plugins may both declare and
|
||||
register `workflow-budget`; a single plugin may not register the same local id
|
||||
twice.
|
||||
|
||||
Runtime `api.registerTool(...)` registrations must match `contracts.tools`.
|
||||
Tool discovery uses this list to load only the plugin runtimes that can own the
|
||||
|
||||
@@ -160,11 +160,12 @@ Codex `0.124.0`, while pinning OpenClaw to the newer tested stable line.
|
||||
|
||||
### Tool-result middleware
|
||||
|
||||
Bundled plugins can attach runtime-neutral tool-result middleware through
|
||||
Bundled plugins and explicitly enabled installed plugins with matching manifest
|
||||
contracts can attach runtime-neutral tool-result middleware through
|
||||
`api.registerAgentToolResultMiddleware(...)` when their manifest declares the
|
||||
targeted runtime ids in `contracts.agentToolResultMiddleware`. This trusted
|
||||
seam is for async tool-result transforms that must run before OpenClaw or Codex feeds
|
||||
tool output back into the model.
|
||||
seam is for async tool-result transforms that must run before OpenClaw or Codex
|
||||
feeds tool output back into the model.
|
||||
|
||||
Legacy bundled plugins can still use
|
||||
`api.registerCodexAppServerExtensionFactory(...)` for Codex app-server-only
|
||||
|
||||
@@ -92,7 +92,7 @@ Runtime send helpers also live on `channel-outbound`:
|
||||
- `sendDurableMessageBatch(...)`
|
||||
- `withDurableMessageSendContext(...)`
|
||||
- `deliverInboundReplyWithMessageSendContext(...)`
|
||||
- draft streaming/progress helpers such as `resolveChannelStreamingPreviewChunk(...)`
|
||||
- draft streaming/progress helpers such as `resolveChannelDraftStreamingChunking(...)`
|
||||
|
||||
`sendDurableMessageBatch(...)` returns one explicit outcome:
|
||||
|
||||
|
||||
@@ -318,8 +318,10 @@ releases.
|
||||
}
|
||||
```
|
||||
|
||||
External plugins cannot register tool-result middleware because it can
|
||||
rewrite high-trust tool output before the model sees it.
|
||||
Installed plugins can also register tool-result middleware when they are
|
||||
explicitly enabled and declare every targeted runtime in
|
||||
`contracts.agentToolResultMiddleware`. Undeclared installed middleware
|
||||
registrations are rejected.
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ plugins.
|
||||
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
|
||||
| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
|
||||
| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params |
|
||||
| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params |
|
||||
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
|
||||
| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true`; Discord native commands support `descriptionLocalizations` |
|
||||
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
|
||||
@@ -235,7 +235,10 @@ The contracts intentionally split authority:
|
||||
- External plugins can own session extensions, UI descriptors, commands, tool
|
||||
metadata, next-turn injections, and normal hooks.
|
||||
- Trusted tool policies run before ordinary `before_tool_call` hooks and are
|
||||
bundled-only because they participate in host safety policy.
|
||||
host-trusted. Bundled policies run first; installed-plugin policies require
|
||||
explicit enablement plus their local ids in
|
||||
`contracts.trustedToolPolicies`, and run next in plugin-load order. Policy ids
|
||||
are scoped to the registering plugin.
|
||||
- Reserved command ownership is bundled-only. External plugins should use their
|
||||
own command names or aliases.
|
||||
- `allowPromptInjection=false` disables prompt-mutating hooks including
|
||||
@@ -260,16 +263,18 @@ Examples of non-Plan consumers:
|
||||
</Note>
|
||||
|
||||
<Accordion title="When to use tool-result middleware">
|
||||
Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when
|
||||
Bundled plugins and explicitly enabled installed plugins with matching
|
||||
manifest contracts can use `api.registerAgentToolResultMiddleware(...)` when
|
||||
they need to rewrite a tool result after execution and before the runtime
|
||||
feeds that result back into the model. This is the trusted runtime-neutral
|
||||
seam for async output reducers such as tokenjuice.
|
||||
|
||||
Bundled plugins must declare `contracts.agentToolResultMiddleware` for each
|
||||
targeted runtime, for example `["openclaw", "codex"]`. External plugins
|
||||
cannot register this middleware; keep normal OpenClaw plugin hooks for work
|
||||
that does not need pre-model tool-result timing. The old embedded-runner-only
|
||||
extension factory registration path has been removed.
|
||||
Plugins must declare `contracts.agentToolResultMiddleware` for each targeted
|
||||
runtime, for example `["openclaw", "codex"]`. Installed plugins without that
|
||||
contract, or without explicit enablement, cannot register this middleware; keep
|
||||
normal OpenClaw plugin hooks for work that does not need pre-model tool-result
|
||||
timing. The old
|
||||
embedded-runner-only extension factory registration path has been removed.
|
||||
</Accordion>
|
||||
|
||||
### Gateway discovery registration
|
||||
|
||||
@@ -246,7 +246,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), target discovery, legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
|
||||
|
||||
@@ -14,24 +14,52 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc
|
||||
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step title="Get your API key">
|
||||
Create an API key at [openrouter.ai/keys](https://openrouter.ai/keys).
|
||||
</Step>
|
||||
<Step title="Run onboarding">
|
||||
```bash
|
||||
openclaw onboard --auth-choice openrouter-api-key
|
||||
```
|
||||
</Step>
|
||||
<Step title="(Optional) Switch to a specific model">
|
||||
Onboarding defaults to `openrouter/auto`. Pick a concrete model later:
|
||||
<Tabs>
|
||||
<Tab title="OAuth">
|
||||
<Steps>
|
||||
<Step title="Run OAuth onboarding">
|
||||
```bash
|
||||
openclaw onboard --auth-choice openrouter-oauth
|
||||
```
|
||||
|
||||
```bash
|
||||
openclaw models set openrouter/<provider>/<model>
|
||||
```
|
||||
OpenClaw opens OpenRouter's browser sign-in flow, exchanges the PKCE
|
||||
code for an OpenRouter API key, and stores that key in the default
|
||||
OpenRouter auth profile. On remote/headless hosts, OpenClaw prints the
|
||||
sign-in URL and asks you to paste the redirect URL after signing in.
|
||||
</Step>
|
||||
<Step title="(Optional) Switch to a specific model">
|
||||
Onboarding defaults to `openrouter/auto`. Pick a concrete model later:
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
```bash
|
||||
openclaw models set openrouter/<provider>/<model>
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
</Tab>
|
||||
<Tab title="API key">
|
||||
<Steps>
|
||||
<Step title="Get your API key">
|
||||
Create an API key at [openrouter.ai/keys](https://openrouter.ai/keys).
|
||||
</Step>
|
||||
<Step title="Run API-key onboarding">
|
||||
```bash
|
||||
openclaw onboard --auth-choice openrouter-api-key
|
||||
```
|
||||
</Step>
|
||||
<Step title="(Optional) Switch to a specific model">
|
||||
Onboarding defaults to `openrouter/auto`. Pick a concrete model later:
|
||||
|
||||
```bash
|
||||
openclaw models set openrouter/<provider>/<model>
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Config example
|
||||
|
||||
@@ -187,7 +215,20 @@ OpenClaw sends OpenRouter STT requests as JSON with base64 audio under
|
||||
|
||||
## Authentication and headers
|
||||
|
||||
OpenRouter uses a Bearer token with your API key under the hood.
|
||||
OpenRouter uses a Bearer token with your API key under the hood. OpenRouter
|
||||
OAuth is a PKCE login flow that issues an OpenRouter API key, so OpenClaw stores
|
||||
the result as the same `openrouter:default` API-key auth profile used by the
|
||||
manual API-key setup path.
|
||||
|
||||
For an existing install, sign in or rotate the stored OpenRouter key without
|
||||
rerunning full onboarding:
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider openrouter --method oauth
|
||||
```
|
||||
|
||||
Use `openclaw models auth login --provider openrouter --method api-key` when
|
||||
you want to paste a key you created manually at OpenRouter.
|
||||
|
||||
On real OpenRouter requests (`https://openrouter.ai/api/v1`), OpenClaw also adds
|
||||
OpenRouter's documented app-attribution headers:
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
# Tweakcn Custom Theme Import Design
|
||||
|
||||
Status: approved in terminal on 2026-04-22
|
||||
|
||||
## Summary
|
||||
|
||||
Add exactly one browser-local custom Control UI theme slot that can be imported from a tweakcn share link. The existing built-in theme families remain `claw`, `knot`, and `dash`. The new `custom` family behaves like a normal OpenClaw theme family and supports `light`, `dark`, and `system` mode when the imported tweakcn payload includes both light and dark token sets.
|
||||
|
||||
The imported theme is stored only in the current browser profile with the rest of the Control UI settings. It is not written to gateway config and does not sync across devices or browsers.
|
||||
|
||||
## Problem
|
||||
|
||||
The Control UI theme system is currently closed over three hard-coded theme families:
|
||||
|
||||
- `ui/src/ui/theme.ts`
|
||||
- `ui/src/ui/views/config.ts`
|
||||
- `ui/src/styles/base.css`
|
||||
|
||||
Users can switch among built-in families and mode variants, but they cannot bring in a theme from tweakcn without editing repo CSS. The requested outcome is smaller than a general theming system: keep the three built-ins and add one user-controlled imported slot that can be replaced from a tweakcn link.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the existing built-in theme families unchanged.
|
||||
- Add exactly one imported custom slot, not a theme library.
|
||||
- Accept a tweakcn share link or a direct `https://tweakcn.com/r/themes/{id}` URL.
|
||||
- Persist the imported theme in browser local storage only.
|
||||
- Make the imported slot work with existing `light`, `dark`, and `system` mode controls.
|
||||
- Keep failure behavior safe: a bad import never breaks the active UI theme.
|
||||
|
||||
## Non goals
|
||||
|
||||
- No multi-theme library or browser-local list of imports.
|
||||
- No gateway-side persistence or cross-device sync.
|
||||
- No arbitrary CSS editor or raw theme JSON editor.
|
||||
- No automatic loading of remote font assets from tweakcn.
|
||||
- No attempt to support tweakcn payloads that only expose one mode.
|
||||
- No repo-wide theming refactor beyond the seams required for the Control UI.
|
||||
|
||||
## User decisions already made
|
||||
|
||||
- Keep the three built-in themes.
|
||||
- Add one tweakcn-powered import slot.
|
||||
- Store the imported theme in the browser, not gateway config.
|
||||
- Support `light`, `dark`, and `system` for the imported slot.
|
||||
- Overwriting the custom slot with the next import is the intended behavior.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
Add a fourth theme family id, `custom`, to the Control UI theme model. The `custom` family becomes selectable only when a valid tweakcn import is present. The imported payload is normalized into an OpenClaw-specific custom theme record and stored in browser local storage with the rest of the UI settings.
|
||||
|
||||
At runtime, OpenClaw renders a managed `<style>` tag that defines the resolved custom CSS variable blocks:
|
||||
|
||||
```css
|
||||
:root[data-theme="custom"] { ... }
|
||||
:root[data-theme="custom-light"] { ... }
|
||||
```
|
||||
|
||||
This keeps custom theme variables scoped to the `custom` family and avoids leaking inline CSS variables into the built-in families.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Theme model
|
||||
|
||||
Update `ui/src/ui/theme.ts`:
|
||||
|
||||
- Extend `ThemeName` to include `custom`.
|
||||
- Extend `ResolvedTheme` to include `custom` and `custom-light`.
|
||||
- Extend `VALID_THEME_NAMES`.
|
||||
- Update `resolveTheme()` so `custom` mirrors the existing family behavior:
|
||||
- `custom + dark` -> `custom`
|
||||
- `custom + light` -> `custom-light`
|
||||
- `custom + system` -> `custom` or `custom-light` based on OS preference
|
||||
|
||||
No legacy aliases are added for `custom`.
|
||||
|
||||
### Persistence model
|
||||
|
||||
Extend `UiSettings` persistence in `ui/src/ui/storage.ts` with one optional custom-theme payload:
|
||||
|
||||
- `customTheme?: ImportedCustomTheme`
|
||||
|
||||
Recommended stored shape:
|
||||
|
||||
```ts
|
||||
type ImportedCustomTheme = {
|
||||
sourceUrl: string;
|
||||
themeId: string;
|
||||
label: string;
|
||||
importedAt: string;
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `sourceUrl` stores the original user input after normalization.
|
||||
- `themeId` is the tweakcn theme id extracted from the URL.
|
||||
- `label` is the tweakcn `name` field when present, else `Custom`.
|
||||
- `light` and `dark` are already normalized OpenClaw token maps, not raw tweakcn payloads.
|
||||
- The imported payload lives beside other browser-local settings and is serialized in the same local-storage document.
|
||||
- If stored custom-theme data is missing or invalid on load, ignore the payload and fall back to `theme: "claw"` when the persisted family was `custom`.
|
||||
|
||||
### Runtime application
|
||||
|
||||
Add a narrow custom-theme stylesheet manager in the Control UI runtime, owned near `ui/src/ui/app-settings.ts` and `ui/src/ui/theme.ts`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Create or update one stable `<style id="openclaw-custom-theme">` tag in `document.head`.
|
||||
- Emit CSS only when a valid custom theme payload exists.
|
||||
- Remove the style tag content when the payload is cleared.
|
||||
- Keep built-in family CSS in `ui/src/styles/base.css`; do not splice imported tokens into the checked-in stylesheet.
|
||||
|
||||
This manager runs whenever settings are loaded, saved, imported, or cleared.
|
||||
|
||||
### Light-mode selectors
|
||||
|
||||
Implementation should prefer `data-theme-mode="light"` for cross-family light styling rather than special-casing `custom-light`. If an existing selector is pinned to `data-theme="light"` and needs to apply to every light family, broaden it as part of this work.
|
||||
|
||||
## Import UX
|
||||
|
||||
Update `ui/src/ui/views/config.ts` in the `Appearance` section:
|
||||
|
||||
- Add a `Custom` theme card beside `Claw`, `Knot`, and `Dash`.
|
||||
- Show the card as disabled when no imported custom theme exists.
|
||||
- Add an import panel under the theme grid with:
|
||||
- one text input for a tweakcn share link or `/r/themes/{id}` URL
|
||||
- one `Import` button
|
||||
- one `Replace` path when a custom payload already exists
|
||||
- one `Clear` action when a custom payload already exists
|
||||
- Show the imported theme label and source host when a payload exists.
|
||||
- If the active theme is `custom`, importing a replacement applies immediately.
|
||||
- If the active theme is not `custom`, importing only stores the new payload until the user selects the `Custom` card.
|
||||
|
||||
The quick settings theme picker in `ui/src/ui/views/config-quick.ts` should also show `Custom` only when a payload exists.
|
||||
|
||||
## URL parsing and remote fetch
|
||||
|
||||
The browser import path accepts:
|
||||
|
||||
- `https://tweakcn.com/themes/{id}`
|
||||
- `https://tweakcn.com/r/themes/{id}`
|
||||
|
||||
Implementation should normalize both forms to:
|
||||
|
||||
- `https://tweakcn.com/r/themes/{id}`
|
||||
|
||||
The browser then fetches the normalized `/r/themes/{id}` endpoint directly.
|
||||
|
||||
Use a narrow schema validator for the external payload. A zod schema is preferred because this is an untrusted external boundary.
|
||||
|
||||
Required remote fields:
|
||||
|
||||
- top-level `name` as optional string
|
||||
- `cssVars.theme` as optional object
|
||||
- `cssVars.light` as object
|
||||
- `cssVars.dark` as object
|
||||
|
||||
If either `cssVars.light` or `cssVars.dark` is missing, reject the import. This is deliberate: the approved product behavior is full mode support, not best-effort synthesis of a missing side.
|
||||
|
||||
## Token mapping
|
||||
|
||||
Do not mirror tweakcn variables blindly. Normalize a bounded subset into OpenClaw tokens and derive the rest in a helper.
|
||||
|
||||
### Tokens imported directly
|
||||
|
||||
From each tweakcn mode block:
|
||||
|
||||
- `background`
|
||||
- `foreground`
|
||||
- `card`
|
||||
- `card-foreground`
|
||||
- `popover`
|
||||
- `popover-foreground`
|
||||
- `primary`
|
||||
- `primary-foreground`
|
||||
- `secondary`
|
||||
- `secondary-foreground`
|
||||
- `muted`
|
||||
- `muted-foreground`
|
||||
- `accent`
|
||||
- `accent-foreground`
|
||||
- `destructive`
|
||||
- `destructive-foreground`
|
||||
- `border`
|
||||
- `input`
|
||||
- `ring`
|
||||
- `radius`
|
||||
|
||||
From shared `cssVars.theme` when present:
|
||||
|
||||
- `font-sans`
|
||||
- `font-mono`
|
||||
|
||||
If a mode block overrides `font-sans`, `font-mono`, or `radius`, the mode-local value wins.
|
||||
|
||||
### Tokens derived for OpenClaw
|
||||
|
||||
The importer derives OpenClaw-only variables from the imported base colors:
|
||||
|
||||
- `--bg-accent`
|
||||
- `--bg-elevated`
|
||||
- `--bg-hover`
|
||||
- `--panel`
|
||||
- `--panel-strong`
|
||||
- `--panel-hover`
|
||||
- `--chrome`
|
||||
- `--chrome-strong`
|
||||
- `--text`
|
||||
- `--text-strong`
|
||||
- `--chat-text`
|
||||
- `--muted`
|
||||
- `--muted-strong`
|
||||
- `--accent-hover`
|
||||
- `--accent-muted`
|
||||
- `--accent-subtle`
|
||||
- `--accent-glow`
|
||||
- `--focus`
|
||||
- `--focus-ring`
|
||||
- `--focus-glow`
|
||||
- `--secondary`
|
||||
- `--secondary-foreground`
|
||||
- `--danger`
|
||||
- `--danger-muted`
|
||||
- `--danger-subtle`
|
||||
|
||||
Derivation rules live in a pure helper so they can be tested independently. Exact color-mixing formulas are an implementation detail, but the helper must satisfy two constraints:
|
||||
|
||||
- preserve readable contrast close to the imported theme intent
|
||||
- produce stable output for the same imported payload
|
||||
|
||||
### Tokens ignored in v1
|
||||
|
||||
These tweakcn tokens are intentionally ignored in the first version:
|
||||
|
||||
- `chart-*`
|
||||
- `sidebar-*`
|
||||
- `font-serif`
|
||||
- `shadow-*`
|
||||
- `tracking-*`
|
||||
- `letter-spacing`
|
||||
- `spacing`
|
||||
|
||||
This keeps the scope on the tokens the current Control UI actually needs.
|
||||
|
||||
### Fonts
|
||||
|
||||
Font stack strings are imported if present, but OpenClaw does not load remote font assets in v1. If the imported stack references fonts that are unavailable in the browser, normal fallback behavior applies.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Bad imports must fail closed.
|
||||
|
||||
- Invalid URL format: show inline validation error, do not fetch.
|
||||
- Unsupported host or path shape: show inline validation error, do not fetch.
|
||||
- Network failure, non-OK response, or malformed JSON: show inline error, keep current stored payload untouched.
|
||||
- Schema failure or missing light/dark blocks: show inline error, keep current stored payload untouched.
|
||||
- Clear action:
|
||||
- removes the stored custom payload
|
||||
- removes the managed custom style tag content
|
||||
- if `custom` is active, switches theme family back to `claw`
|
||||
- Invalid stored custom payload on first load:
|
||||
- ignore the stored payload
|
||||
- do not emit custom CSS
|
||||
- if persisted theme family was `custom`, fall back to `claw`
|
||||
|
||||
At no point should a failed import leave the active document with partial custom CSS variables applied.
|
||||
|
||||
## Files expected to change in implementation
|
||||
|
||||
Primary files:
|
||||
|
||||
- `ui/src/ui/theme.ts`
|
||||
- `ui/src/ui/storage.ts`
|
||||
- `ui/src/ui/app-settings.ts`
|
||||
- `ui/src/ui/views/config.ts`
|
||||
- `ui/src/ui/views/config-quick.ts`
|
||||
- `ui/src/styles/base.css`
|
||||
|
||||
Likely new helpers:
|
||||
|
||||
- `ui/src/ui/custom-theme.ts`
|
||||
|
||||
Tests:
|
||||
|
||||
- `ui/src/ui/app-settings.test.ts`
|
||||
- `ui/src/ui/storage.node.test.ts`
|
||||
- `ui/src/ui/views/config.browser.test.ts`
|
||||
- new focused tests for URL parsing and payload normalization
|
||||
|
||||
## Testing
|
||||
|
||||
Minimum implementation coverage:
|
||||
|
||||
- parse share-link URL into tweakcn theme id
|
||||
- normalize `/themes/{id}` and `/r/themes/{id}` into the fetch URL
|
||||
- reject unsupported hosts and malformed ids
|
||||
- validate tweakcn payload shape
|
||||
- map a valid tweakcn payload into normalized OpenClaw light and dark token maps
|
||||
- load and save the custom payload in browser-local settings
|
||||
- resolve `custom` for `light`, `dark`, and `system`
|
||||
- disable `Custom` selection when no payload exists
|
||||
- apply imported theme immediately when `custom` is already active
|
||||
- fall back to `claw` when the active custom theme is cleared
|
||||
|
||||
Manual verification target:
|
||||
|
||||
- import a known tweakcn theme from Settings
|
||||
- switch among `light`, `dark`, and `system`
|
||||
- switch between `custom` and the built-in families
|
||||
- reload the page and confirm the imported custom theme persists locally
|
||||
|
||||
## Rollout notes
|
||||
|
||||
This feature is intentionally small. If users later ask for multiple imported themes, rename, export, or cross-device sync, treat that as a follow-on design. Do not pre-build a theme library abstraction in this implementation.
|
||||
@@ -54,7 +54,7 @@ Notes:
|
||||
- Header: connection URL, current agent, current session.
|
||||
- Chat log: user messages, assistant replies, system notices, tool cards.
|
||||
- Status line: connection/run state (connecting, running, streaming, idle, error).
|
||||
- Footer: connection state + agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver.
|
||||
- Footer: agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver. When `tui.footer.showRemoteHost` is enabled, remote Gateway connections also show the connection host.
|
||||
- Input: text editor with autocomplete.
|
||||
|
||||
## Mental model: agents + sessions
|
||||
@@ -68,6 +68,14 @@ Notes:
|
||||
- `per-sender` (default): each agent has many sessions.
|
||||
- `global`: the TUI always uses the `global` session (the picker may be empty).
|
||||
- The current agent + session are always visible in the footer.
|
||||
- To show the Gateway host for non-local URL-backed connections, opt in with:
|
||||
|
||||
```bash
|
||||
openclaw config set tui.footer.showRemoteHost true
|
||||
```
|
||||
|
||||
Loopback and embedded local connections never show a host label.
|
||||
|
||||
- If the session has a [goal](/tools/goal), the footer shows its compact state
|
||||
such as `Pursuing goal`, `Goal paused (/goal resume)`, or
|
||||
`Goal achieved`.
|
||||
|
||||
6
extensions/acpx/npm-shrinkwrap.json
generated
6
extensions/acpx/npm-shrinkwrap.json
generated
@@ -1548,9 +1548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.21",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
|
||||
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
||||
@@ -101,8 +101,10 @@ describe("chutes implicit provider auth mode", () => {
|
||||
|
||||
const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai"));
|
||||
expect(chutesCalls.length).toBeGreaterThan(0);
|
||||
const request = chutesCalls[0]?.[1] as { headers?: Record<string, string> } | undefined;
|
||||
expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token");
|
||||
const request = chutesCalls[0]?.[1] as { headers?: HeadersInit } | undefined;
|
||||
expect(new Headers(request?.headers).get("authorization")).toBe(
|
||||
"Bearer my-chutes-access-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
import type {
|
||||
AgentHarness,
|
||||
AgentHarnessCompactParams,
|
||||
AgentHarnessCompactResult,
|
||||
ContextEngineHostCapability,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type {
|
||||
@@ -25,6 +27,12 @@ const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
|
||||
/** Public model-listing types exposed for Codex app-server catalog callers. */
|
||||
export type { CodexAppServerListModelsOptions, CodexAppServerModel, CodexAppServerModelListResult };
|
||||
|
||||
type CodexAppServerAgentHarness = AgentHarness & {
|
||||
compactAfterContextEngine?(
|
||||
params: AgentHarnessCompactParams,
|
||||
): Promise<AgentHarnessCompactResult | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the Codex app-server harness used for attempts, side questions,
|
||||
* compaction, reset, and disposal.
|
||||
@@ -41,7 +49,7 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
id.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
return {
|
||||
const harness: CodexAppServerAgentHarness = {
|
||||
id: options?.id ?? "codex",
|
||||
label: options?.label ?? "Codex agent harness",
|
||||
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
|
||||
@@ -80,6 +88,13 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
||||
});
|
||||
},
|
||||
compactAfterContextEngine: async (params) => {
|
||||
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
|
||||
return maybeCompactCodexAppServerSession(params, {
|
||||
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
||||
allowNonManualNativeRequest: true,
|
||||
});
|
||||
},
|
||||
reset: async (params) => {
|
||||
if (params.sessionFile) {
|
||||
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
|
||||
@@ -92,4 +107,5 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
await clearSharedCodexAppServerClientAndWait();
|
||||
},
|
||||
};
|
||||
return harness;
|
||||
}
|
||||
|
||||
56
extensions/codex/npm-shrinkwrap.json
generated
56
extensions/codex/npm-shrinkwrap.json
generated
@@ -8,16 +8,16 @@
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.2",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.137.0",
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex": {
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0.tgz",
|
||||
"integrity": "sha512-1jUsCnzDBwv7Z4VFZajIlsz41fC18qg6d5qK4PEZhiUk0zJHS90/uGBA70aQPUJLTUZShvyKVAANjw6J/D9eYQ==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0.tgz",
|
||||
"integrity": "sha512-wr2fRE+fzW0CjEbfFsLh1ftarVEcw0CMLWS7QyA0nyOz5qacQPVq3cq2+/U7oEbwm1TOqoi0Fm1nxniB5FkpmA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
@@ -26,19 +26,19 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.137.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.137.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.137.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.137.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.137.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.137.0-win32-x64"
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.139.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.139.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.139.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.139.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.139.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.139.0-win32-x64"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.137.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-YjKmre7DlKslQVhSfocHscgxntZKaZc1LQySKh7q+hNL8jdK+c8nSWSePi583yKFNIxZ8Z/zCkewtjFNvOpQiQ==",
|
||||
"version": "0.139.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-o+0ZKWwgDFMMLO7rwinzO0PQsgK+Vme1pMN2GeAxsX29ZgGZcyPICfpJbeGSUO1mb2a36Skjx6nfdRnxMY0r7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -53,9 +53,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.137.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-zjzrFV80LZby9et44dan82e3cwUd46U7u1LSVXTIz5AUcY4y1KZpAeN6cSLVKMZuOHXTDpi15MUQdRwzdeqIOg==",
|
||||
"version": "0.139.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-9gkBWzu6DB2rqU4DbpxD3DE5bofGpsK46Lp0h0I+bKWc2IIcxvSi8K2utKmBLoJCbKrn4JQu7dFNGRqEfENung==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -70,9 +70,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-linux-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.137.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-R3ZZymQQA1qpp6OpowN49XJ4scHwSckq7CjVvgmLv3bIs3X+F0XXK3xPFkC9vs2mX3wPekPi3ONpxx+yPAsJ6Q==",
|
||||
"version": "0.139.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-tBQE5lZciRHeWZGuURgjP9S717MvTIpQMc593+DNxY2LQxozkngOkzFSQd1+/UmQKGrCqdFLu5irIwPXpSZyEw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -87,9 +87,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-linux-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.137.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-x64.tgz",
|
||||
"integrity": "sha512-n+26MUj8rekbEDUeYTGoD6HXuGS0MmLHn2LOn0i5qTNYIJvXV82B7cCLSTzVKF/RJxRMRl22se9Q0Z035JIVng==",
|
||||
"version": "0.139.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-linux-x64.tgz",
|
||||
"integrity": "sha512-14UgzDS+X4crkvdt6S02A/ZZOrS8ZyWiuTRpguCtnhNamb7unSuDxy86BWgpAl3sqiTaN2CP8VLyp2ohQ8Nbzw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -104,9 +104,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-win32-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.137.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-Cofktt213TycdQ/v+nAUuwXUBzjMWfA/ZkXyqefyXxDgw0TMtaiM3cgDna3I8YdXnR0PM9AMbx4t7VloJ3ZZYQ==",
|
||||
"version": "0.139.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-nlwRjsYotH1Rtqu/Q0VwQbIeO2UX1mkHK84Ov9qn/hl29QqqoBtno0tRyqIPbkXFIVQuWiAYXlV3ugLwH5fTrQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -121,9 +121,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-win32-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.137.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-x64.tgz",
|
||||
"integrity": "sha512-g9qZ9ERrm5OWXMWJOgojYv1kOc5jajTKq37PBMSe56aJfAr9Jk/qBvIOy7LKq3rABdXuz8k+W65PIt2E1hXilw==",
|
||||
"version": "0.139.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-win32-x64.tgz",
|
||||
"integrity": "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.137.0",
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
@@ -362,6 +362,101 @@ describe("codex provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches usage from native Codex app-server rate limits for synthetic auth", async () => {
|
||||
const readRateLimits = vi.fn(async () => ({
|
||||
rateLimitsByLimitId: {
|
||||
codex: {
|
||||
limitId: "codex",
|
||||
primary: {
|
||||
usedPercent: 9,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: 1_700_003_600,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
const provider = buildCodexProvider({ readRateLimits });
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
provider: "openai",
|
||||
token: "codex-app-server",
|
||||
timeoutMs: 3500,
|
||||
config: {},
|
||||
env: {},
|
||||
fetchFn: fetch,
|
||||
} as never),
|
||||
).resolves.toEqual({
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 9, resetAt: 1_700_003_600_000 }],
|
||||
plan: undefined,
|
||||
});
|
||||
expect(readRateLimits).toHaveBeenCalledWith({
|
||||
timeoutMs: 3500,
|
||||
agentDir: undefined,
|
||||
config: {},
|
||||
startOptions: expect.objectContaining({
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps synthetic usage rate-limit reads on the configured Codex auth bridge", async () => {
|
||||
const requestCodexAppServerJson = vi.fn(async (_params: unknown) => ({
|
||||
rateLimitsByLimitId: {},
|
||||
}));
|
||||
vi.doMock("./src/app-server/request.js", () => ({
|
||||
requestCodexAppServerJson,
|
||||
}));
|
||||
try {
|
||||
const provider = buildCodexProvider();
|
||||
|
||||
await provider.fetchUsageSnapshot?.({
|
||||
provider: "openai",
|
||||
token: "codex-app-server",
|
||||
authProfileId: "openai:work",
|
||||
timeoutMs: 3500,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: TEST_CODEX_APP_SERVER_CONFIG,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
fetchFn: fetch,
|
||||
} as never);
|
||||
|
||||
expect(requestCodexAppServerJson).toHaveBeenCalledWith({
|
||||
method: "account/rateLimits/read",
|
||||
timeoutMs: 3500,
|
||||
agentDir: undefined,
|
||||
authProfileId: "openai:work",
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: TEST_CODEX_APP_SERVER_CONFIG,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
startOptions: expect.objectContaining({
|
||||
command: "/tmp/openclaw-test-codex",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
}),
|
||||
isolated: true,
|
||||
});
|
||||
} finally {
|
||||
vi.doUnmock("./src/app-server/request.js");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a setup auth choice for installing Codex as an external provider", async () => {
|
||||
const provider = buildCodexProvider();
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
CodexAppServerModel,
|
||||
CodexAppServerModelListResult,
|
||||
} from "./src/app-server/models.js";
|
||||
import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js";
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
|
||||
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
|
||||
@@ -43,9 +44,18 @@ type CodexModelLister = (options: {
|
||||
sharedClient?: boolean;
|
||||
}) => Promise<CodexAppServerModelListResult>;
|
||||
|
||||
type CodexRateLimitReader = (options: {
|
||||
timeoutMs: number;
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
config?: Parameters<typeof requestCodexAppServerRateLimitsLazy>[0]["config"];
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
type BuildCodexProviderOptions = {
|
||||
pluginConfig?: unknown;
|
||||
listModels?: CodexModelLister;
|
||||
readRateLimits?: CodexRateLimitReader;
|
||||
};
|
||||
|
||||
type BuildCatalogOptions = {
|
||||
@@ -107,6 +117,22 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
|
||||
source: "codex-app-server",
|
||||
mode: "token",
|
||||
}),
|
||||
fetchUsageSnapshot: async (ctx) => {
|
||||
if (ctx.token !== CODEX_APP_SERVER_AUTH_MARKER) {
|
||||
return null;
|
||||
}
|
||||
const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);
|
||||
const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const rateLimits = await (options.readRateLimits ?? requestCodexAppServerRateLimitsLazy)({
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
agentDir: ctx.agentDir,
|
||||
...(ctx.authProfileId ? { authProfileId: ctx.authProfileId } : {}),
|
||||
config: ctx.config,
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
return buildCodexAppServerUsageSnapshot(rateLimits);
|
||||
},
|
||||
resolveThinkingProfile: ({ modelId }) => ({
|
||||
levels: [
|
||||
{ id: "off" },
|
||||
@@ -210,6 +236,27 @@ async function listCodexAppServerModelsLazy(options: {
|
||||
return listCodexAppServerModels(options);
|
||||
}
|
||||
|
||||
async function requestCodexAppServerRateLimitsLazy(options: {
|
||||
timeoutMs: number;
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
config?: Parameters<
|
||||
typeof import("./src/app-server/request.js").requestCodexAppServerJson
|
||||
>[0]["config"];
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
}): Promise<unknown> {
|
||||
const { requestCodexAppServerJson } = await import("./src/app-server/request.js");
|
||||
return await requestCodexAppServerJson({
|
||||
method: "account/rateLimits/read",
|
||||
timeoutMs: options.timeoutMs,
|
||||
agentDir: options.agentDir,
|
||||
...(options.authProfileId ? { authProfileId: options.authProfileId } : {}),
|
||||
config: options.config,
|
||||
startOptions: options.startOptions,
|
||||
isolated: true,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Codex tests cover app server policy plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
|
||||
import {
|
||||
resolveCodexAppServerForModelProvider,
|
||||
resolveCodexAppServerForOpenClawToolPolicy,
|
||||
} from "./app-server-policy.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
|
||||
describe("Codex app-server policy", () => {
|
||||
@@ -66,4 +69,143 @@ describe("Codex app-server policy", () => {
|
||||
expect(explicitEnv.approvalPolicy).toBe("never");
|
||||
expect(explicitRequirements.approvalPolicy).toBe("never");
|
||||
});
|
||||
|
||||
it("keeps model-backed reviewers for explicit OpenAI model providers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "codex",
|
||||
model: "openai/gpt-5.5",
|
||||
}).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({ appServer, provider: "openai" }).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
});
|
||||
|
||||
it("uses human approval for OpenAI-compatible custom endpoints", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(appServer.approvalsReviewer).toBe("user");
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("uses human approval instead of Codex Guardian for custom model providers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
const resolved = resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "lmstudio",
|
||||
});
|
||||
const vendorPrefixedModel = resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.5",
|
||||
});
|
||||
|
||||
expect(appServer.approvalsReviewer).toBe("auto_review");
|
||||
expect(resolved.approvalPolicy).toBe("on-request");
|
||||
expect(resolved.sandbox).toBe("workspace-write");
|
||||
expect(resolved.approvalsReviewer).toBe("user");
|
||||
expect(vendorPrefixedModel.approvalsReviewer).toBe("user");
|
||||
});
|
||||
|
||||
it("infers custom providers from provider-qualified model refs", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
model: "lmstudio/local-model",
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("uses provider-qualified model refs to override broad native provider wrappers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "codex",
|
||||
model: "lmstudio/local-model",
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("downgrades legacy guardian_subagent for custom model providers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({ appServer, provider: "local" }).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
|
||||
* approvals.
|
||||
*/
|
||||
import type {
|
||||
CodexAppServerRuntimeOptions,
|
||||
CodexPluginConfig,
|
||||
OpenClawExecPolicyForCodexAppServer,
|
||||
import {
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
type CodexPluginConfig,
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./config.js";
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,35 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerForModelProvider(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
config?: Parameters<typeof canUseCodexModelBackedApprovalsReviewerForModel>[0]["config"];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
}): CodexAppServerRuntimeOptions {
|
||||
const explicitProvider = normalizeModelBackedReviewerProvider(params.provider);
|
||||
if (
|
||||
!isCodexModelBackedApprovalsReviewer(params.appServer.approvalsReviewer) ||
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: explicitProvider,
|
||||
model: params.model,
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
agentDir: params.agentDir,
|
||||
codexConfigToml: params.codexConfigToml,
|
||||
})
|
||||
) {
|
||||
return params.appServer;
|
||||
}
|
||||
return {
|
||||
...params.appServer,
|
||||
approvalsReviewer: "user",
|
||||
};
|
||||
}
|
||||
|
||||
function isCodexAppServerPolicyMode(value: unknown): boolean {
|
||||
return value === "guardian" || value === "yolo";
|
||||
}
|
||||
@@ -53,3 +83,12 @@ function isCodexAppServerApprovalPolicy(value: unknown): boolean {
|
||||
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexModelBackedApprovalsReviewer(value: string): boolean {
|
||||
return value === "auto_review" || value === "guardian_subagent";
|
||||
}
|
||||
|
||||
function normalizeModelBackedReviewerProvider(provider: string | undefined): string | undefined {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Codex tests cover approval bridge plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { reviewExecRequestWithConfiguredModel } from "openclaw/plugin-sdk/agent-harness-exec-review-runtime";
|
||||
import {
|
||||
callGatewayTool,
|
||||
hasNativeHookRelayInvocation,
|
||||
@@ -22,12 +26,20 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => (
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-exec-review-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("openclaw/plugin-sdk/agent-harness-exec-review-runtime")
|
||||
>()),
|
||||
reviewExecRequestWithConfiguredModel: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
||||
const mockHasNativeHookRelayInvocation = vi.mocked(hasNativeHookRelayInvocation);
|
||||
const mockInvokeNativeHookRelay = vi.mocked(invokeNativeHookRelay);
|
||||
const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
|
||||
resolveNativeHookRelayDeferredToolApproval,
|
||||
);
|
||||
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
|
||||
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
@@ -111,6 +123,12 @@ describe("Codex app-server approval bridge", () => {
|
||||
mockInvokeNativeHookRelay.mockReset();
|
||||
mockResolveNativeHookRelayDeferredToolApproval.mockReset();
|
||||
mockResolveNativeHookRelayDeferredToolApproval.mockResolvedValue(undefined);
|
||||
mockReviewExecRequestWithConfiguredModel.mockReset();
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValue({
|
||||
decision: "ask",
|
||||
rationale: "test reviewer asks for approval",
|
||||
risk: "unknown",
|
||||
});
|
||||
mockRunBeforeToolCallHook.mockReset();
|
||||
mockRunBeforeToolCallHook.mockImplementation(async ({ params }) => ({
|
||||
blocked: false,
|
||||
@@ -232,6 +250,920 @@ describe("Codex app-server approval bridge", () => {
|
||||
findApprovalEvent(params, { status: "approved", approvalId: "plugin:approval-1" });
|
||||
});
|
||||
|
||||
it("uses the configured OpenClaw exec auto-review model before plugin approvals", async () => {
|
||||
const params = createParams();
|
||||
params.workspaceDir = "/workspace";
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "read-only version check",
|
||||
risk: "low",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
execReviewerAgentId: "main",
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledWith({
|
||||
cfg: params.config,
|
||||
agentId: "main",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
input: {
|
||||
command: "node --version",
|
||||
argv: ["node", "--version"],
|
||||
cwd: "/workspace",
|
||||
envKeys: undefined,
|
||||
host: "codex-app-server",
|
||||
reason: "approval-required",
|
||||
analysis: {
|
||||
parsed: true,
|
||||
allowlistMatched: false,
|
||||
inlineEval: false,
|
||||
},
|
||||
agent: {
|
||||
id: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
findApprovalEvent(params, {
|
||||
status: "approved",
|
||||
message:
|
||||
"Codex app-server command approval granted by OpenClaw exec auto-reviewer: read-only version check",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to plugin approval when no exec auto-review model is configured", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-no-reviewer", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-no-reviewer", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-missing",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to plugin approval for managed-network command approvals", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "network request looks fine",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-network", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-network", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-network",
|
||||
command: "curl https://example.test",
|
||||
networkApprovalContext: {
|
||||
host: "example.test",
|
||||
port: 443,
|
||||
protocol: "https",
|
||||
},
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["lmstudio/local-model", "local-model"])(
|
||||
"falls back to plugin approval for unsafe exec auto-review model %s",
|
||||
async (model) => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "unsafe self review",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-local-reviewer", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-local-reviewer", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-local",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "provider base URL",
|
||||
reviewerModel: "openai/gpt-5.5-mini",
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provider key casing with custom base URL",
|
||||
reviewerModel: "openai/gpt-5.5-mini",
|
||||
models: {
|
||||
providers: {
|
||||
OpenAI: {
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provider local service",
|
||||
reviewerModel: "openai/gpt-5.5-mini",
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
localService: { command: "local-openai-compatible" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "model base URL",
|
||||
reviewerModel: "openai/gpt-5.5-mini@work",
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5-mini",
|
||||
name: "Local GPT-compatible reviewer",
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])(
|
||||
"falls back to plugin approval for OpenAI reviewer with custom $name",
|
||||
async ({ models, reviewerModel }) => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: reviewerModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
models,
|
||||
} as unknown as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "custom endpoint reviewer",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-custom-openai", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-custom-openai", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-custom-openai",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back to plugin approval when an OpenAI-looking reviewer is a configured model alias", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"lmstudio/local-reviewer": {
|
||||
alias: "OpenAI/Reviewer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/reviewer@work",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "aliased local reviewer",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-aliased-openai", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-aliased-openai", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-aliased-openai",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps exec auto-review when only an agent-specific alias matches the OpenAI reviewer", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "sidecar",
|
||||
models: {
|
||||
"lmstudio/local-reviewer": {
|
||||
alias: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini@work",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "real OpenAI reviewer",
|
||||
risk: "low",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-agent-alias",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
execReviewerAgentId: "main",
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: params.config,
|
||||
agentId: "main",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini@work",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to plugin approval when OpenAI reviewer uses a custom environment base URL", async () => {
|
||||
const params = createParams();
|
||||
vi.stubEnv("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "custom env endpoint reviewer",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-env-openai", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-env-openai", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-env-openai",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to plugin approval when Codex native OpenAI config uses a local base URL", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-approval-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(tempDir, "codex-home"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "codex-home", "config.toml"),
|
||||
'openai_base_url = "http://127.0.0.1:11434/v1"\n',
|
||||
);
|
||||
const params = createParams();
|
||||
params.agentDir = tempDir;
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "custom native endpoint reviewer",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-native-openai", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-native-openai", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-native-openai",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps permission amendment command approvals on the plugin approval route", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "safe command",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-amendment", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-amendment", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-amendment",
|
||||
command: "node --version",
|
||||
additionalPermissions: {
|
||||
network: {
|
||||
allowHosts: ["example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
expect(gatewayRequestPayload().description).toContain("Additional permissions: network");
|
||||
});
|
||||
|
||||
it("keeps object-shaped execpolicy amendment command approvals on the plugin approval route", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "safe command",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-execpolicy-object", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-execpolicy-object", decision: "allow-always" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-execpolicy-object",
|
||||
command: "node --version",
|
||||
proposedExecpolicyAmendment: {
|
||||
permissions: [{ permission: "allow", command: ["node"] }],
|
||||
},
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "acceptForSession" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unbound shell command approvals on the plugin approval route", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "safe command",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-unbound", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-unbound", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-unbound",
|
||||
command: "node --version && echo ok",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/approve abc123 allow-once",
|
||||
"bash -lc '/approve abc123 allow-once'",
|
||||
"openclaw channels login --channel whatsapp",
|
||||
"sudo -EH bash -lc 'openclaw channels login --channel whatsapp'",
|
||||
])("keeps unsafe control command approvals on the plugin approval route: %s", async (command) => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "unsafe control command",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-control-command", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-control-command", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-control-command",
|
||||
command,
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps security audit suppression edits on the plugin approval route", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "safe command",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-security-suppression", status: "accepted" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-security-suppression",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-security-suppression",
|
||||
command: "openclaw config set security.audit.suppressions '[]'",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps amendment-only decision command approvals on the plugin approval route", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "allow-once",
|
||||
rationale: "safe command",
|
||||
risk: "low",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-amendment-only", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-amendment-only", decision: "allow-always" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-amendment-only",
|
||||
command: "node --version",
|
||||
availableDecisions: [
|
||||
{
|
||||
acceptWithExecpolicyAmendment: {
|
||||
patterns: ["node"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
decision: {
|
||||
acceptWithExecpolicyAmendment: {
|
||||
patterns: ["node"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to plugin approval when the exec auto-review model asks", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: { primary: "openai/gpt-5.5-mini" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
||||
decision: "ask",
|
||||
rationale: "needs human review",
|
||||
risk: "medium",
|
||||
});
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-reviewer-ask", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-reviewer-ask", decision: "allow-once" });
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-ask",
|
||||
command: "git status",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels command approvals when the run aborts during exec auto-review", async () => {
|
||||
const params = createParams();
|
||||
params.config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
const abortController = new AbortController();
|
||||
mockReviewExecRequestWithConfiguredModel.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
decision: "allow-once",
|
||||
rationale: "late allow",
|
||||
risk: "low",
|
||||
}),
|
||||
50,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const resultPromise = handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-auto-review-abort",
|
||||
command: "node --version",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
execPolicy: { mode: "auto" },
|
||||
internalExecAutoReview: true,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
abortController.abort(new Error("run stopped"));
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ decision: "cancel" });
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
findApprovalEvent(params, {
|
||||
status: "failed",
|
||||
message: "Codex app-server approval cancelled because the run stopped.",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
|
||||
const params = createParams();
|
||||
params.messageChannel = "telegram";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
buildExecAutoReviewInputForShellCommand,
|
||||
reviewExecRequestWithConfiguredModel,
|
||||
} from "openclaw/plugin-sdk/agent-harness-exec-review-runtime";
|
||||
/**
|
||||
* Bridges Codex app-server approval requests into OpenClaw policy hooks and
|
||||
* plugin approval UX.
|
||||
@@ -14,8 +18,13 @@ import {
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
runBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { formatCodexDisplayText } from "../command-formatters.js";
|
||||
import {
|
||||
isTrustedCodexModelBackedOpenAIProvider,
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./config.js";
|
||||
import {
|
||||
approvalRequestExplicitlyUnavailable,
|
||||
mapExecDecisionToOutcome,
|
||||
@@ -72,6 +81,9 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
NativeHookRelayRegistrationHandle,
|
||||
"allowedEvents" | "generation" | "relayId"
|
||||
>;
|
||||
execPolicy?: Pick<OpenClawExecPolicyForCodexAppServer, "mode">;
|
||||
execReviewerAgentId?: string;
|
||||
internalExecAutoReview?: boolean;
|
||||
autoApprove?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<JsonValue | undefined> {
|
||||
@@ -137,8 +149,29 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
});
|
||||
return buildApprovalResponse(params.method, context.requestParams, "approved-session");
|
||||
}
|
||||
// Native hook policy did not decide; fall back to the OpenClaw approval
|
||||
// route so user-facing runs still get an approval prompt.
|
||||
const autoReviewOutcome = await runInternalExecAutoReviewForApprovalRequest({
|
||||
enabled: params.internalExecAutoReview === true && params.execPolicy?.mode === "auto",
|
||||
method: params.method,
|
||||
requestParams,
|
||||
paramsForRun: params.paramsForRun,
|
||||
context,
|
||||
agentId: params.execReviewerAgentId,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (autoReviewOutcome?.outcome === "approved-once") {
|
||||
emitApprovalEvent(params.paramsForRun, {
|
||||
phase: "resolved",
|
||||
kind: context.kind,
|
||||
status: "approved",
|
||||
title: context.title,
|
||||
...context.eventDetails,
|
||||
...approvalEventScope(params.method, autoReviewOutcome.outcome),
|
||||
message: autoReviewOutcome.reason,
|
||||
});
|
||||
return buildApprovalResponse(params.method, context.requestParams, autoReviewOutcome.outcome);
|
||||
}
|
||||
// Native hook/model policy did not decide; fall back to the OpenClaw
|
||||
// approval route so user-facing runs still get an approval prompt.
|
||||
const requestResult = await requestPluginApproval({
|
||||
paramsForRun: params.paramsForRun,
|
||||
title: context.title,
|
||||
@@ -344,6 +377,244 @@ type ApprovalPolicyOutcome =
|
||||
| { outcome: "approved-once" | "approved-session" }
|
||||
| { outcome: "no-decision" };
|
||||
|
||||
async function runInternalExecAutoReviewForApprovalRequest(params: {
|
||||
enabled: boolean;
|
||||
method: string;
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
context: ApprovalContext;
|
||||
agentId?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ outcome: "approved-once"; reason: string } | undefined> {
|
||||
if (!params.enabled || params.method !== "item/commandExecution/requestApproval") {
|
||||
return undefined;
|
||||
}
|
||||
if (hasCommandApprovalCapabilityAmendments(params.requestParams)) {
|
||||
return undefined;
|
||||
}
|
||||
const input = await buildAppServerExecAutoReviewInput({
|
||||
requestParams: params.requestParams,
|
||||
paramsForRun: params.paramsForRun,
|
||||
});
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const reviewerConfig = resolveExecReviewerConfig(params.paramsForRun, params.agentId);
|
||||
if (
|
||||
!canUseInternalExecAutoReviewReviewer(
|
||||
reviewerConfig,
|
||||
params.paramsForRun.config,
|
||||
process.env,
|
||||
params.paramsForRun.agentDir,
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const decision = await waitForInternalExecAutoReviewDecision({
|
||||
signal: params.signal,
|
||||
promise: reviewExecRequestWithConfiguredModel({
|
||||
cfg: params.paramsForRun.config,
|
||||
agentId: params.agentId ?? params.paramsForRun.agentId,
|
||||
reviewer: reviewerConfig,
|
||||
input,
|
||||
}),
|
||||
});
|
||||
if (decision.decision !== "allow-once") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
outcome: "approved-once",
|
||||
reason: `Codex app-server command approval granted by OpenClaw exec auto-reviewer: ${formatCodexDisplayText(
|
||||
decision.rationale,
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForInternalExecAutoReviewDecision(params: {
|
||||
signal?: AbortSignal;
|
||||
promise: Promise<Awaited<ReturnType<typeof reviewExecRequestWithConfiguredModel>>>;
|
||||
}): Promise<Awaited<ReturnType<typeof reviewExecRequestWithConfiguredModel>>> {
|
||||
if (!params.signal) {
|
||||
return params.promise;
|
||||
}
|
||||
if (params.signal.aborted) {
|
||||
throw toCodexAppServerApprovalCancellationError(params.signal.reason);
|
||||
}
|
||||
let onAbort: (() => void) | undefined;
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
onAbort = () => reject(toCodexAppServerApprovalCancellationError(params.signal?.reason));
|
||||
params.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([params.promise, abortPromise]);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
params.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toCodexAppServerApprovalCancellationError(reason: unknown): Error {
|
||||
if (reason instanceof Error) {
|
||||
return reason;
|
||||
}
|
||||
return new Error(
|
||||
typeof reason === "string" && reason.trim() ? reason : "Codex app-server approval cancelled.",
|
||||
);
|
||||
}
|
||||
|
||||
async function buildAppServerExecAutoReviewInput(params: {
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
}) {
|
||||
const command = readString(params.requestParams, "command");
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
return buildExecAutoReviewInputForShellCommand({
|
||||
command,
|
||||
cwd: readString(params.requestParams, "cwd") ?? params.paramsForRun.workspaceDir ?? null,
|
||||
host: "codex-app-server",
|
||||
agent: {
|
||||
id: params.paramsForRun.agentId ?? null,
|
||||
sessionKey: params.paramsForRun.sessionKey ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function hasCommandApprovalCapabilityAmendments(requestParams: JsonObject | undefined): boolean {
|
||||
return (
|
||||
hasNonEmptyJsonObject(requestParams?.additionalPermissions) ||
|
||||
hasNonEmptyJsonObject(requestParams?.networkApprovalContext) ||
|
||||
hasNonEmptyJsonObject(requestParams?.proposedExecpolicyAmendment) ||
|
||||
hasNonEmptyArray(requestParams?.proposedExecpolicyAmendment) ||
|
||||
hasNonEmptyArray(requestParams?.proposedNetworkPolicyAmendments) ||
|
||||
findAvailableCommandAmendmentDecision(requestParams) !== undefined ||
|
||||
commandAcceptDecisionUnavailable(requestParams)
|
||||
);
|
||||
}
|
||||
|
||||
function commandAcceptDecisionUnavailable(requestParams: JsonObject | undefined): boolean {
|
||||
const available = requestParams?.availableDecisions;
|
||||
return Array.isArray(available) && !available.includes("accept");
|
||||
}
|
||||
|
||||
function hasNonEmptyJsonObject(value: unknown): boolean {
|
||||
return isJsonObject(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function hasNonEmptyArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
function resolveExecReviewerConfig(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
agentId?: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const configRoot = readUnknownRecord(params.config);
|
||||
const globalExec = readUnknownRecord(readUnknownRecord(configRoot?.tools)?.exec);
|
||||
const agentExec = resolveAgentExecConfig(configRoot, agentId ?? params.agentId);
|
||||
return readUnknownRecord(agentExec?.reviewer) ?? readUnknownRecord(globalExec?.reviewer);
|
||||
}
|
||||
|
||||
function canUseInternalExecAutoReviewReviewer(
|
||||
reviewerConfig: Record<string, unknown> | undefined,
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined,
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
agentDir: string | undefined,
|
||||
): boolean {
|
||||
const model = readExecReviewerModelRef(reviewerConfig);
|
||||
const slashIndex = model?.indexOf("/") ?? -1;
|
||||
if (!model || slashIndex <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (configuredAgentModelAliasMatches(config, model)) {
|
||||
return false;
|
||||
}
|
||||
const provider = model.slice(0, slashIndex).trim().toLowerCase();
|
||||
if (provider !== "openai") {
|
||||
return false;
|
||||
}
|
||||
return isTrustedCodexModelBackedOpenAIProvider({
|
||||
config,
|
||||
env,
|
||||
agentDir,
|
||||
model: model.slice(slashIndex + 1).trim(),
|
||||
});
|
||||
}
|
||||
|
||||
function readExecReviewerModelRef(
|
||||
reviewerConfig: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const model = reviewerConfig?.model;
|
||||
if (typeof model === "string") {
|
||||
return model.trim() || undefined;
|
||||
}
|
||||
const primary = readUnknownRecord(model)?.primary;
|
||||
return typeof primary === "string" && primary.trim() ? primary.trim() : undefined;
|
||||
}
|
||||
|
||||
function configuredAgentModelAliasMatches(
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined,
|
||||
modelRef: string,
|
||||
): boolean {
|
||||
const normalizedModelRef = normalizeExecReviewerAliasRef(modelRef);
|
||||
const agents = readUnknownRecord(readUnknownRecord(config)?.agents);
|
||||
return agentModelAliasMatches(readUnknownRecord(agents?.defaults), normalizedModelRef);
|
||||
}
|
||||
|
||||
function agentModelAliasMatches(
|
||||
agentConfig: Record<string, unknown> | undefined,
|
||||
normalizedModelRef: string,
|
||||
): boolean {
|
||||
const models = readUnknownRecord(agentConfig?.models);
|
||||
if (!models) {
|
||||
return false;
|
||||
}
|
||||
for (const entry of Object.values(models)) {
|
||||
const alias = readUnknownRecord(entry)?.alias;
|
||||
if (typeof alias === "string" && normalizeExecReviewerAliasRef(alias) === normalizedModelRef) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeExecReviewerAliasRef(modelRef: string): string {
|
||||
const trimmed = modelRef.trim().toLowerCase();
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
const authProfileIndex = trimmed.indexOf("@", slashIndex + 1);
|
||||
return authProfileIndex > 0 ? trimmed.slice(0, authProfileIndex) : trimmed;
|
||||
}
|
||||
|
||||
function resolveAgentExecConfig(
|
||||
configRoot: Record<string, unknown> | undefined,
|
||||
agentId: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const normalizedAgentId = agentId ? normalizeAgentId(agentId) : undefined;
|
||||
if (!normalizedAgentId) {
|
||||
return undefined;
|
||||
}
|
||||
const agentList = readUnknownRecord(configRoot?.agents)?.list;
|
||||
if (!Array.isArray(agentList)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const entry of agentList) {
|
||||
const record = readUnknownRecord(entry);
|
||||
if (typeof record?.id !== "string" || normalizeAgentId(record.id) !== normalizedAgentId) {
|
||||
continue;
|
||||
}
|
||||
return readUnknownRecord(readUnknownRecord(record.tools)?.exec);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readUnknownRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonObject | undefined;
|
||||
|
||||
@@ -11,7 +11,11 @@ import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
clearCodexAppServerBindingForThread,
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
@@ -95,6 +99,26 @@ function compactDetails(result: CompactResult): Record<string, unknown> {
|
||||
return (result.result?.details ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function flushAsyncTasks(iterations = 3): Promise<void> {
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectExternalMutationBlockedDuringNativeRequest(params: {
|
||||
releaseExternalMutation: () => void;
|
||||
isExternalMutationStarted: () => boolean;
|
||||
isExternalMutationFinished: () => boolean;
|
||||
}): Promise<Record<string, never>> {
|
||||
params.releaseExternalMutation();
|
||||
await flushAsyncTasks();
|
||||
expect(params.isExternalMutationStarted()).toBe(true);
|
||||
expect(params.isExternalMutationFinished()).toBe(false);
|
||||
return {};
|
||||
}
|
||||
|
||||
describe("maybeCompactCodexAppServerSession", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-compact-"));
|
||||
@@ -156,6 +180,359 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts native app-server compaction for post-context-engine budget requests", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
},
|
||||
{ allowNonManualNativeRequest: true },
|
||||
),
|
||||
);
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"thread/compact/start",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(result.result?.tokensBefore).toBe(456);
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
threadId: "thread-1",
|
||||
signal: "thread/compact/start",
|
||||
pending: true,
|
||||
request: "after_context_engine",
|
||||
trigger: "budget",
|
||||
});
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(await readCodexAppServerBinding(sessionFile))?.contextEngine?.projection,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves projection when aborted before guarded native compaction", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const abortController = new AbortController();
|
||||
abortController.abort("cancelled");
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
abortSignal: abortController.signal,
|
||||
},
|
||||
{ allowNonManualNativeRequest: true },
|
||||
),
|
||||
);
|
||||
|
||||
expect(fake.request).not.toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toBe("codex app-server compaction aborted before native compaction");
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "aborted_before_native_compaction",
|
||||
request: "after_context_engine",
|
||||
trigger: "budget",
|
||||
expectedThreadId: "thread-1",
|
||||
currentThreadId: "thread-1",
|
||||
});
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
contextEngine: {
|
||||
projection: {
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skips post-context-engine native compaction when the binding changes before projection clear", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const originalContextEngine = {
|
||||
schemaVersion: 1 as const,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection: {
|
||||
schemaVersion: 1 as const,
|
||||
mode: "thread_bootstrap" as const,
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
};
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: originalContextEngine,
|
||||
});
|
||||
const actualReadFile = fs.readFile.bind(fs);
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
let bindingReads = 0;
|
||||
readFileSpy.mockImplementation(async (...args: Parameters<typeof fs.readFile>) => {
|
||||
const result = await actualReadFile(...args);
|
||||
const readPath =
|
||||
typeof args[0] === "string"
|
||||
? args[0]
|
||||
: args[0] instanceof URL
|
||||
? args[0].pathname
|
||||
: Buffer.isBuffer(args[0])
|
||||
? args[0].toString("utf8")
|
||||
: "";
|
||||
if (readPath.endsWith(".codex-app-server.json") && bindingReads++ === 0) {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-2",
|
||||
cwd: tempDir,
|
||||
contextEngine: {
|
||||
...originalContextEngine,
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-2",
|
||||
fingerprint: "fingerprint-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
},
|
||||
{ allowNonManualNativeRequest: true },
|
||||
),
|
||||
);
|
||||
|
||||
expect(fake.request).not.toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toBe("codex app-server binding changed before native compaction");
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "binding_changed_before_native_compaction",
|
||||
request: "after_context_engine",
|
||||
trigger: "budget",
|
||||
expectedThreadId: "thread-1",
|
||||
currentThreadId: "thread-2",
|
||||
});
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
|
||||
threadId: "thread-2",
|
||||
contextEngine: {
|
||||
projection: {
|
||||
epoch: "epoch-2",
|
||||
fingerprint: "fingerprint-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
readFileSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks same-process binding writes until guarded native compaction starts", async () => {
|
||||
let releaseExternalWrite!: () => void;
|
||||
const externalWriteGate = new Promise<void>((resolve) => {
|
||||
releaseExternalWrite = resolve;
|
||||
});
|
||||
let externalWriteStarted = false;
|
||||
let externalWriteFinished = false;
|
||||
const fake = createFakeCodexClient();
|
||||
fake.request.mockImplementation(() =>
|
||||
expectExternalMutationBlockedDuringNativeRequest({
|
||||
releaseExternalMutation: releaseExternalWrite,
|
||||
isExternalMutationStarted: () => externalWriteStarted,
|
||||
isExternalMutationFinished: () => externalWriteFinished,
|
||||
}),
|
||||
);
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const externalWrite = (async () => {
|
||||
await externalWriteGate;
|
||||
externalWriteStarted = true;
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-2",
|
||||
cwd: tempDir,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-2",
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
externalWriteFinished = true;
|
||||
})();
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
},
|
||||
{ allowNonManualNativeRequest: true },
|
||||
),
|
||||
);
|
||||
|
||||
await externalWrite;
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"thread/compact/start",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
|
||||
threadId: "thread-2",
|
||||
contextEngine: {
|
||||
policyFingerprint: "policy-2",
|
||||
projection: {
|
||||
epoch: "epoch-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks same-process binding clears until guarded native compaction starts", async () => {
|
||||
let releaseExternalClear!: () => void;
|
||||
const externalClearGate = new Promise<void>((resolve) => {
|
||||
releaseExternalClear = resolve;
|
||||
});
|
||||
let externalClearStarted = false;
|
||||
let externalClearFinished = false;
|
||||
const fake = createFakeCodexClient();
|
||||
fake.request.mockImplementation(() =>
|
||||
expectExternalMutationBlockedDuringNativeRequest({
|
||||
releaseExternalMutation: releaseExternalClear,
|
||||
isExternalMutationStarted: () => externalClearStarted,
|
||||
isExternalMutationFinished: () => externalClearFinished,
|
||||
}),
|
||||
);
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const externalClear = (async () => {
|
||||
await externalClearGate;
|
||||
externalClearStarted = true;
|
||||
const cleared = await clearCodexAppServerBindingForThread(sessionFile, "thread-1");
|
||||
externalClearFinished = true;
|
||||
expect(cleared).toBe(true);
|
||||
})();
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
},
|
||||
{ allowNonManualNativeRequest: true },
|
||||
),
|
||||
);
|
||||
|
||||
await externalClear;
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"thread/compact/start",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips native app-server compaction when trigger is omitted", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
@@ -683,12 +1060,12 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
|
||||
function createFakeCodexClient(): {
|
||||
client: CodexAppServerClient;
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
request: ReturnType<typeof vi.fn<CodexAppServerClient["request"]>>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
emit: (notification: CodexServerNotification) => void;
|
||||
} {
|
||||
const handlers = new Set<(notification: CodexServerNotification) => void>();
|
||||
const request = vi.fn(async () => ({}));
|
||||
const request = vi.fn<CodexAppServerClient["request"]>(async () => ({}));
|
||||
const close = vi.fn();
|
||||
const addNotificationHandler = vi.fn(
|
||||
(handler: (notification: CodexServerNotification) => void) => {
|
||||
|
||||
@@ -13,10 +13,21 @@ import {
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
readCodexAppServerBinding,
|
||||
withCodexAppServerBindingLock,
|
||||
writeCodexAppServerBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
type CodexAppServerCompactOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
allowNonManualNativeRequest?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts native Codex compaction for a manually requested bound session, or
|
||||
@@ -24,7 +35,7 @@ const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
*/
|
||||
export async function maybeCompactCodexAppServerSession(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
|
||||
options: CodexAppServerCompactOptions = {},
|
||||
): Promise<EmbeddedAgentCompactResult | undefined> {
|
||||
warnIfIgnoringOpenClawCompactionOverrides(params);
|
||||
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
|
||||
@@ -134,9 +145,9 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
async function compactCodexNativeThread(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
|
||||
options: CodexAppServerCompactOptions = {},
|
||||
): Promise<EmbeddedAgentCompactResult | undefined> {
|
||||
if (params.trigger !== "manual") {
|
||||
if (params.trigger !== "manual" && !options.allowNonManualNativeRequest) {
|
||||
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -169,13 +180,16 @@ async function compactCodexNativeThread(
|
||||
return { ok: false, compacted: false, reason: nativeExecutionBlock };
|
||||
}
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
|
||||
if (!binding?.threadId) {
|
||||
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
|
||||
config: params.config,
|
||||
});
|
||||
if (!initialBinding?.threadId) {
|
||||
return failedCodexThreadBindingCompactionResult(params, {
|
||||
reason: "no codex app-server thread binding",
|
||||
recovery: "missing_thread_binding",
|
||||
});
|
||||
}
|
||||
let binding = initialBinding;
|
||||
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
|
||||
if (
|
||||
requestedAuthProfileId &&
|
||||
@@ -186,7 +200,6 @@ async function compactCodexNativeThread(
|
||||
// with another profile risks operating on a different Codex account.
|
||||
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
|
||||
}
|
||||
|
||||
const shouldReleaseDefaultLease = !options.clientFactory;
|
||||
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const client = await clientFactory(
|
||||
@@ -196,9 +209,71 @@ async function compactCodexNativeThread(
|
||||
params.config,
|
||||
);
|
||||
try {
|
||||
await client.request("thread/compact/start", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
if (options.allowNonManualNativeRequest) {
|
||||
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
|
||||
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
|
||||
config: params.config,
|
||||
});
|
||||
if (params.abortSignal?.aborted) {
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server compaction aborted before native compaction",
|
||||
code: "aborted_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
|
||||
embeddedAgentLog.warn(
|
||||
"skipping codex app-server compaction because the thread binding changed",
|
||||
{
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
},
|
||||
);
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server binding changed before native compaction",
|
||||
code: "binding_changed_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
binding = currentBinding;
|
||||
await clearContextEngineProjectionBeforeNativeCompaction({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
binding,
|
||||
config: params.config,
|
||||
});
|
||||
await client.request(
|
||||
"thread/compact/start",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
{
|
||||
timeoutMs: Math.min(
|
||||
appServer.requestTimeoutMs,
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
),
|
||||
},
|
||||
);
|
||||
return { started: true as const };
|
||||
});
|
||||
if (!guardedResult.started) {
|
||||
return guardedResult.result;
|
||||
}
|
||||
} else {
|
||||
await client.request("thread/compact/start", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
}
|
||||
embeddedAgentLog.info("started codex app-server compaction", {
|
||||
sessionId: params.sessionId,
|
||||
threadId: binding.threadId,
|
||||
@@ -232,6 +307,12 @@ async function compactCodexNativeThread(
|
||||
threadId: binding.threadId,
|
||||
signal: "thread/compact/start",
|
||||
pending: true,
|
||||
...(options.allowNonManualNativeRequest
|
||||
? {
|
||||
request: "after_context_engine",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
@@ -245,6 +326,36 @@ async function compactCodexNativeThread(
|
||||
};
|
||||
}
|
||||
|
||||
function skippedCodexNativeCompactionResult(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
skipped: {
|
||||
reason: string;
|
||||
code: string;
|
||||
expectedThreadId?: string;
|
||||
currentThreadId?: string;
|
||||
},
|
||||
): EmbeddedAgentCompactResult {
|
||||
return {
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: skipped.reason,
|
||||
result: {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: {
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: skipped.code,
|
||||
request: "after_context_engine",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
...(skipped.expectedThreadId ? { expectedThreadId: skipped.expectedThreadId } : {}),
|
||||
...(skipped.currentThreadId ? { currentThreadId: skipped.currentThreadId } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function failedCodexThreadBindingCompactionResult(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
recovery: {
|
||||
@@ -271,6 +382,54 @@ function failedCodexThreadBindingCompactionResult(
|
||||
};
|
||||
}
|
||||
|
||||
async function clearContextEngineProjectionBeforeNativeCompaction(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
binding: CodexAppServerThreadBinding;
|
||||
config: CompactEmbeddedAgentSessionParams["config"];
|
||||
}): Promise<void> {
|
||||
const contextEngineBinding = params.binding.contextEngine;
|
||||
if (!contextEngineBinding?.projection) {
|
||||
return;
|
||||
}
|
||||
// Native Codex compaction mutates the thread history outside the projection
|
||||
// guard. Clear only the projection marker so the next turn reprojects context.
|
||||
await writeCodexAppServerBinding(
|
||||
params.sessionFile,
|
||||
{
|
||||
...params.binding,
|
||||
contextEngine: {
|
||||
...contextEngineBinding,
|
||||
projection: undefined,
|
||||
},
|
||||
createdAt: params.binding.createdAt,
|
||||
},
|
||||
{ config: params.config },
|
||||
);
|
||||
embeddedAgentLog.info("cleared codex context-engine projection before native compaction", {
|
||||
sessionId: params.sessionId,
|
||||
threadId: params.binding.threadId,
|
||||
previousEpoch: contextEngineBinding.projection.epoch,
|
||||
previousFingerprint: contextEngineBinding.projection.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
function isSameNativeCompactionBinding(
|
||||
current: CodexAppServerThreadBinding,
|
||||
expected: CodexAppServerThreadBinding,
|
||||
): boolean {
|
||||
return (
|
||||
current.threadId === expected.threadId &&
|
||||
current.authProfileId === expected.authProfileId &&
|
||||
current.contextEngine?.engineId === expected.contextEngine?.engineId &&
|
||||
current.contextEngine?.policyFingerprint === expected.contextEngine?.policyFingerprint &&
|
||||
current.contextEngine?.projection?.mode === expected.contextEngine?.projection?.mode &&
|
||||
current.contextEngine?.projection?.epoch === expected.contextEngine?.projection?.epoch &&
|
||||
current.contextEngine?.projection?.fingerprint ===
|
||||
expected.contextEngine?.projection?.fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexThreadNotFoundError(error: unknown): boolean {
|
||||
return formatCompactionError(error).toLowerCase().includes("thread not found");
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
CODEX_COMPUTER_USE_CONFIG_KEYS,
|
||||
CODEX_PLUGIN_ENTRY_CONFIG_KEYS,
|
||||
CODEX_PLUGINS_CONFIG_KEYS,
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
codexAppServerStartOptionsKey,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
resolveCodexComputerUseConfig,
|
||||
resolveCodexModelBackedReviewerPolicyContext,
|
||||
resolveOpenClawExecModeForCodexAppServer,
|
||||
resolveOpenClawExecModeFromConfig,
|
||||
resolveOpenClawExecPolicyForCodexAppServer,
|
||||
@@ -104,6 +106,7 @@ describe("Codex app-server config", () => {
|
||||
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
|
||||
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "read-only",
|
||||
},
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expectFields(runtime, "runtime", {
|
||||
@@ -197,6 +200,7 @@ describe("Codex app-server config", () => {
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expectFields(runtime, "runtime", {
|
||||
@@ -267,12 +271,270 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults native Codex approvals to guardian when requirements disallow full access", () => {
|
||||
it("treats only explicit OpenAI model context as safe for Codex-backed auto-review", () => {
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "codex",
|
||||
model: "openai/gpt-5.5",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel({})).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "codex",
|
||||
model: "gpt-5.5",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openrouter",
|
||||
model: "openai/gpt-5.5",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "lmstudio/local-model",
|
||||
}),
|
||||
).toBe(false);
|
||||
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
|
||||
model: "lmstudio/local-model",
|
||||
bindingModel: "gpt-5.5",
|
||||
nativeAuthProfile: true,
|
||||
});
|
||||
expect(switchedLocalModel).toEqual({
|
||||
modelProvider: "lmstudio",
|
||||
model: "lmstudio/local-model",
|
||||
});
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(switchedLocalModel)).toBe(false);
|
||||
const switchedOpenAIModel = resolveCodexModelBackedReviewerPolicyContext({
|
||||
provider: "codex",
|
||||
model: "openai/gpt-5.5",
|
||||
bindingModel: "local-model",
|
||||
bindingModelProvider: "lmstudio",
|
||||
});
|
||||
expect(switchedOpenAIModel).toEqual({
|
||||
modelProvider: "openai",
|
||||
model: "openai/gpt-5.5",
|
||||
});
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(switchedOpenAIModel)).toBe(true);
|
||||
const legacyBindingOpenAIModel = resolveCodexModelBackedReviewerPolicyContext({
|
||||
provider: "codex",
|
||||
model: "openai/gpt-5.5",
|
||||
bindingModelProvider: "lmstudio",
|
||||
});
|
||||
expect(legacyBindingOpenAIModel).toEqual({
|
||||
modelProvider: "openai",
|
||||
model: "openai/gpt-5.5",
|
||||
});
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(legacyBindingOpenAIModel)).toBe(true);
|
||||
const boundLocalOpenAIName = resolveCodexModelBackedReviewerPolicyContext({
|
||||
provider: "codex",
|
||||
model: "openai/gpt-oss-20b",
|
||||
bindingModel: "openai/gpt-oss-20b",
|
||||
bindingModelProvider: "lmstudio",
|
||||
});
|
||||
expect(boundLocalOpenAIName).toEqual({
|
||||
modelProvider: "lmstudio",
|
||||
model: "openai/gpt-oss-20b",
|
||||
});
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(boundLocalOpenAIName)).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: 'openai_base_url = "https://api.openai.com/v1"\n',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: 'openai_base_url = "http://localhost:8080/v1"\n',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: '[model_providers.openai]\nbase_url = "http://localhost:8080/v1"\n',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: 'model_providers.openai.base_url = "http://localhost:8080/v1"\n',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml:
|
||||
'model_providers = { openai = { base_url = "http://localhost:8080/v1" } }\n',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: 'chatgpt_base_url = "https://chatgpt.com/backend-api/"\n',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: 'chatgpt_base_url = "http://localhost:8080/backend-api"\n',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
for (const openAIProvider of [
|
||||
{
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
request: { proxy: { mode: "explicit-proxy" as const, url: "http://localhost:8080" } },
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
headers: { "x-openclaw-reviewer-proxy": "local" },
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
authHeader: false,
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT with custom headers",
|
||||
reasoning: true,
|
||||
input: ["text" as const],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
headers: { "x-openclaw-reviewer-proxy": "local" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: openAIProvider,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
env: {
|
||||
OPENAI_BASE_URL: "http://localhost:8080/v1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
env: {
|
||||
OPENAI_BASE_URL: "",
|
||||
OPENAI_API_BASE: "http://localhost:8080/v1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses user approvals when Codex native OpenAI config is local", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
codexConfigToml: 'openai_base_url = "http://localhost:8080/v1"\n',
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("forces prompting when explicit no-prompt config cannot use model-backed review", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
modelProvider: "lmstudio",
|
||||
model: "local-model",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
expect(shouldAutoApproveCodexAppServerApprovals(runtime)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses user approvals when requirements force prompting but model provider is unknown", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults native OpenAI Codex approvals to guardian when requirements disallow full access", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
@@ -283,6 +545,7 @@ describe("Codex app-server config", () => {
|
||||
it("uses read-only sandbox for guardian defaults when requirements only allow read-only", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only"]\n',
|
||||
});
|
||||
|
||||
@@ -296,6 +559,7 @@ describe("Codex app-server config", () => {
|
||||
it("defaults native Codex approvals to guardian when requirements disallow never approval", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
requirementsToml: 'allowed_approval_policies = ["on-request"]\n',
|
||||
});
|
||||
|
||||
@@ -309,6 +573,7 @@ describe("Codex app-server config", () => {
|
||||
it("selects an allowed guardian approval policy when on-request is unavailable", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
requirementsToml: 'allowed_approval_policies = ["on-failure"]\n',
|
||||
});
|
||||
|
||||
@@ -335,6 +600,7 @@ describe("Codex app-server config", () => {
|
||||
it("defaults native Codex approvals to guardian when requirements disallow user reviewer", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
|
||||
});
|
||||
|
||||
@@ -362,6 +628,7 @@ describe("Codex app-server config", () => {
|
||||
it("ignores quoted sandbox modes inside requirements comments", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
requirementsToml: `allowed_sandbox_modes = [
|
||||
"read-only",
|
||||
# "danger-full-access",
|
||||
@@ -380,6 +647,7 @@ describe("Codex app-server config", () => {
|
||||
it("applies the first matching remote sandbox requirements before resolving local stdio defaults", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
hostName: "BUILD-01.EXAMPLE.COM.",
|
||||
requirementsToml: `[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-*.example.com"]
|
||||
@@ -420,6 +688,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {},
|
||||
env: {},
|
||||
modelProvider: "openai",
|
||||
requirementsPath: "/custom/codex/requirements.toml",
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
@@ -440,6 +709,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {},
|
||||
env: { ProgramData: "D:\\ManagedData" },
|
||||
modelProvider: "openai",
|
||||
platform: "win32",
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
@@ -779,6 +1049,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
modelProvider: "openai",
|
||||
env: {},
|
||||
});
|
||||
|
||||
@@ -789,9 +1060,27 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
});
|
||||
|
||||
it("uses user approvals for explicit guardian mode when model provider is unknown", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows environment mode fallback to opt in to guardian-reviewed local execution", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
modelProvider: "openai",
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "guardian" },
|
||||
});
|
||||
|
||||
@@ -806,6 +1095,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
@@ -829,6 +1119,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "danger-full-access",
|
||||
},
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
@@ -849,11 +1140,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
},
|
||||
},
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
env: {},
|
||||
});
|
||||
const envRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
env: {
|
||||
OPENCLAW_CODEX_APP_SERVER_MODE: "yolo",
|
||||
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
|
||||
@@ -1015,6 +1308,17 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
).toThrow("tools.exec.mode=ask requires Codex app-server user approvals");
|
||||
});
|
||||
|
||||
it("fails closed when Guardian local-model fallback needs user approvals but requirements disallow them", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { mode: "guardian" } },
|
||||
modelProvider: "lmstudio",
|
||||
model: "local-model",
|
||||
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=ask requires Codex app-server user approvals");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ execMode: "auto", policies: ["never"] },
|
||||
{ execMode: "auto", policies: ["on-failure"] },
|
||||
@@ -1072,16 +1376,45 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
).toThrow("tools.exec.mode=auto requires Codex app-server prompting approvals");
|
||||
});
|
||||
|
||||
it("fails closed when normalized OpenClaw auto mode cannot use an auto reviewer", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
it("uses user approvals when normalized OpenClaw auto mode cannot use Codex auto-review", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
requirementsToml:
|
||||
'allowed_approval_policies = ["on-request"]\nallowed_approvals_reviewers = ["user"]\n',
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ modelProvider: undefined, model: undefined },
|
||||
{ modelProvider: "lmstudio", model: "local-model" },
|
||||
{ modelProvider: "codex", model: "gpt-5.5" },
|
||||
{ modelProvider: "codex", model: "lmstudio/local-model" },
|
||||
])(
|
||||
"uses user approvals for local-model auto exec before requirements validation",
|
||||
({ modelProvider, model }) => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
modelProvider,
|
||||
model,
|
||||
requirementsToml:
|
||||
'allowed_approval_policies = ["on-request"]\nallowed_approvals_reviewers = ["user"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=auto requires Codex app-server auto approvals");
|
||||
});
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps normalized OpenClaw auto mode when legacy app-server yolo was schema-defaulted", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
@@ -1097,6 +1430,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
codexDynamicToolsExclude: [],
|
||||
},
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
@@ -1125,6 +1459,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
},
|
||||
},
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
@@ -1600,12 +1935,14 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { approvalsReviewer: "auto_review" } },
|
||||
modelProvider: "openai",
|
||||
env: {},
|
||||
}).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
expect(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { approvalsReviewer: "guardian_subagent" } },
|
||||
modelProvider: "openai",
|
||||
env: {},
|
||||
}).approvalsReviewer,
|
||||
).toBe("guardian_subagent");
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { hostname as readHostName } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveProviderIdForAuth,
|
||||
type ProviderAuthAliasLookupParams,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
resolveExecApprovalsFromFile,
|
||||
type ExecApprovalsFile,
|
||||
@@ -17,6 +22,8 @@ const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStart
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
const UNIX_CODEX_REQUIREMENTS_PATH = "/etc/codex/requirements.toml";
|
||||
const WINDOWS_CODEX_REQUIREMENTS_SUFFIX = "\\OpenAI\\Codex\\requirements.toml";
|
||||
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
|
||||
const CODEX_CONFIG_TOML_FILENAME = "config.toml";
|
||||
const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/;
|
||||
|
||||
type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
@@ -35,6 +42,7 @@ export type OpenClawExecPolicyForCodexAppServer = {
|
||||
touched: boolean;
|
||||
};
|
||||
type OpenClawExecPolicy = OpenClawExecPolicyForCodexAppServer;
|
||||
type ProviderAuthAliasConfig = NonNullable<ProviderAuthAliasLookupParams>["config"];
|
||||
type CodexAppServerDefaultPolicy = {
|
||||
mode: CodexAppServerPolicyMode;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
@@ -142,6 +150,15 @@ export type CodexAppServerRuntimeOptions = {
|
||||
serviceTier?: CodexServiceTier;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
config?: ProviderAuthAliasConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
};
|
||||
|
||||
export type CodexPluginConfig = {
|
||||
codexDynamicToolsLoading?: CodexDynamicToolsLoading;
|
||||
codexDynamicToolsExclude?: string[];
|
||||
@@ -393,7 +410,12 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
pluginConfig?: unknown;
|
||||
execMode?: OpenClawExecMode;
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
config?: ProviderAuthAliasConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
@@ -434,8 +456,29 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
const normalizedPolicyMode = resolveCodexPolicyModeForOpenClawExecMode(execMode);
|
||||
const ignoreLegacyYoloPolicyMode =
|
||||
normalizedPolicyMode === "guardian" && explicitPolicyMode === "yolo";
|
||||
const forceUserReviewer = execMode !== undefined && execMode !== "auto" && execMode !== "full";
|
||||
const forceGuardianReviewer = execMode === "auto";
|
||||
const canUseModelBackedReviewer = canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: params.modelProvider,
|
||||
model: params.model,
|
||||
config: params.config,
|
||||
env,
|
||||
agentDir: params.agentDir,
|
||||
codexConfigToml: params.codexConfigToml,
|
||||
});
|
||||
const explicitModelBackedReviewer =
|
||||
explicitApprovalsReviewer === "auto_review" ||
|
||||
explicitApprovalsReviewer === "guardian_subagent";
|
||||
const forceUserReviewerForUnknownModel =
|
||||
!canUseModelBackedReviewer &&
|
||||
(explicitModelBackedReviewer ||
|
||||
(explicitPolicyMode === "guardian" && explicitApprovalsReviewer !== "user"));
|
||||
const forceUserReviewerForExecMode =
|
||||
execMode !== undefined &&
|
||||
execMode !== "full" &&
|
||||
(execMode !== "auto" || !canUseModelBackedReviewer);
|
||||
const forceUserReviewer = forceUserReviewerForUnknownModel || forceUserReviewerForExecMode;
|
||||
const forceGuardianReviewer = execMode === "auto" && canUseModelBackedReviewer;
|
||||
const execModeRequiringPromptingApprovals: Extract<OpenClawExecMode, "auto" | "ask"> | undefined =
|
||||
execMode === "auto" || execMode === "ask" ? execMode : forceUserReviewer ? "ask" : undefined;
|
||||
const forceDangerFullAccessSandbox =
|
||||
params.execPolicy?.touched === true &&
|
||||
params.execPolicy.security === "full" &&
|
||||
@@ -449,14 +492,14 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
transport,
|
||||
env,
|
||||
forceGuardian: normalizedPolicyMode === "guardian",
|
||||
forceUserReviewer,
|
||||
execModeRequiringPromptingApprovals:
|
||||
execMode === "auto" || execMode === "ask" ? execMode : undefined,
|
||||
forceUserReviewer: forceUserReviewer || !canUseModelBackedReviewer,
|
||||
execModeRequiringPromptingApprovals,
|
||||
requirementsToml: params.requirementsToml,
|
||||
requirementsPath: params.requirementsPath,
|
||||
readRequirementsFile: params.readRequirementsFile,
|
||||
platform: params.platform,
|
||||
hostName: params.hostName,
|
||||
execModeRequiringUserReviewer: forceUserReviewer ? execMode : undefined,
|
||||
});
|
||||
const preserveExplicitAutoSandbox = forceGuardianReviewer && configuredSandbox === "read-only";
|
||||
const forcedPolicy = forceRuntimePolicy
|
||||
@@ -562,6 +605,93 @@ export function isCodexAppServerApprovalPolicyAllowedByRequirements(
|
||||
return allowedApprovalPolicies === undefined || allowedApprovalPolicies.has(policy);
|
||||
}
|
||||
|
||||
export function canUseCodexModelBackedApprovalsReviewerForModel(
|
||||
params: CodexModelBackedReviewerContext,
|
||||
): boolean {
|
||||
const explicitProvider = params.modelProvider?.trim().toLowerCase();
|
||||
const inferredProvider = inferProviderFromModelRef(params.model);
|
||||
if (explicitProvider && explicitProvider !== "codex") {
|
||||
return (
|
||||
isTrustedCodexModelBackedApprovalsReviewerProvider(explicitProvider, params) &&
|
||||
(inferredProvider === undefined ||
|
||||
isTrustedCodexModelBackedApprovalsReviewerProvider(inferredProvider, params))
|
||||
);
|
||||
}
|
||||
if (inferredProvider !== undefined) {
|
||||
return isTrustedCodexModelBackedApprovalsReviewerProvider(inferredProvider, params);
|
||||
}
|
||||
return isTrustedCodexModelBackedApprovalsReviewerProvider(explicitProvider, params);
|
||||
}
|
||||
|
||||
export function isTrustedCodexModelBackedOpenAIProvider(params: {
|
||||
config?: ProviderAuthAliasConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
model?: string;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
}): boolean {
|
||||
if (!openAIBaseUrlEnvOverridesAreTrustedForModelBackedReview(params.env)) {
|
||||
return false;
|
||||
}
|
||||
const codexBaseUrlOverrides = readCodexBaseUrlOverridesForModelBackedReview(params);
|
||||
if (
|
||||
codexBaseUrlOverrides === false ||
|
||||
!codexBaseUrlOverrides.openAI.every(isNativeOpenAIBaseUrl) ||
|
||||
!codexBaseUrlOverrides.chatGPT.every(isNativeChatGPTBaseUrl)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const openAIProviders = readConfiguredOpenAIProvidersForModelBackedReview(params.config);
|
||||
if (openAIProviders.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return openAIProviders.every((openAIProvider) =>
|
||||
configuredOpenAIProviderIsTrustedForModelBackedReview(openAIProvider, params.model),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
bindingModelProvider?: string;
|
||||
bindingModel?: string;
|
||||
nativeAuthProfile?: boolean;
|
||||
}): CodexModelBackedReviewerContext {
|
||||
const provider = params.provider?.trim();
|
||||
if (provider && provider.toLowerCase() !== "codex") {
|
||||
return {
|
||||
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(provider),
|
||||
model: params.model,
|
||||
};
|
||||
}
|
||||
const bindingModelProvider = params.bindingModelProvider?.trim();
|
||||
const currentModel = params.model?.trim();
|
||||
const bindingModel = params.bindingModel?.trim();
|
||||
if (bindingModelProvider && currentModel && bindingModel && currentModel === bindingModel) {
|
||||
return {
|
||||
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(bindingModelProvider),
|
||||
model: params.model ?? params.bindingModel,
|
||||
};
|
||||
}
|
||||
const currentModelProvider = inferProviderFromModelRef(params.model);
|
||||
if (currentModelProvider) {
|
||||
return {
|
||||
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(currentModelProvider),
|
||||
model: params.model,
|
||||
};
|
||||
}
|
||||
if (bindingModelProvider) {
|
||||
return {
|
||||
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(bindingModelProvider),
|
||||
model: params.model ?? params.bindingModel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
|
||||
model: params.model ?? params.bindingModel,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCodexComputerUseConfig(
|
||||
params: {
|
||||
pluginConfig?: unknown;
|
||||
@@ -709,6 +839,7 @@ function resolveDefaultCodexAppServerPolicy(params: {
|
||||
forceGuardian?: boolean;
|
||||
forceUserReviewer?: boolean;
|
||||
execModeRequiringPromptingApprovals?: Extract<OpenClawExecMode, "auto" | "ask">;
|
||||
execModeRequiringUserReviewer?: OpenClawExecMode;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
@@ -732,7 +863,7 @@ function resolveDefaultCodexAppServerPolicy(params: {
|
||||
params.execModeRequiringPromptingApprovals,
|
||||
),
|
||||
approvalsReviewer: params.forceUserReviewer
|
||||
? selectUserApprovalsReviewer(undefined)
|
||||
? selectUserApprovalsReviewer(undefined, params.execModeRequiringUserReviewer)
|
||||
: selectGuardianApprovalsReviewer(
|
||||
undefined,
|
||||
params.execModeRequiringPromptingApprovals === "auto" ? "auto" : undefined,
|
||||
@@ -763,7 +894,7 @@ function resolveDefaultCodexAppServerPolicy(params: {
|
||||
params.execModeRequiringPromptingApprovals,
|
||||
),
|
||||
approvalsReviewer: params.forceUserReviewer
|
||||
? selectUserApprovalsReviewer(allowedApprovalsReviewers)
|
||||
? selectUserApprovalsReviewer(allowedApprovalsReviewers, params.execModeRequiringUserReviewer)
|
||||
: selectGuardianApprovalsReviewer(
|
||||
allowedApprovalsReviewers,
|
||||
params.execModeRequiringPromptingApprovals === "auto" ? "auto" : undefined,
|
||||
@@ -782,14 +913,14 @@ function readCodexRequirementsToml(params: {
|
||||
if (params.requirementsToml !== undefined) {
|
||||
return params.requirementsToml ?? undefined;
|
||||
}
|
||||
const path =
|
||||
const requirementsPath =
|
||||
readNonEmptyString(params.requirementsPath) ??
|
||||
resolveCodexRequirementsPath(params.env ?? process.env, params.platform ?? process.platform);
|
||||
try {
|
||||
if (params.readRequirementsFile) {
|
||||
return params.readRequirementsFile(path);
|
||||
return params.readRequirementsFile(requirementsPath);
|
||||
}
|
||||
return readFileSync(path, "utf8");
|
||||
return readFileSync(requirementsPath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -881,6 +1012,34 @@ function parseTopLevelRequirementsStringArray(content: string, key: string): str
|
||||
return parseRequirementsStringArray(topLevelContent, key);
|
||||
}
|
||||
|
||||
function parseTomlStringValue(content: string, key: string): string | undefined {
|
||||
const match = parseTomlStringAssignment(content, tomlDottedKeyPattern(key));
|
||||
return match ? (match[1] ?? match[2] ?? "") : undefined;
|
||||
}
|
||||
|
||||
function parseInlineOpenAIModelProviderBaseUrl(content: string): string | undefined {
|
||||
const match = parseTomlStringAssignment(
|
||||
content,
|
||||
`${tomlKeyPattern("model_providers")}\\s*=\\s*\\{[\\s\\S]*?${tomlKeyPattern("openai")}\\s*=\\s*\\{[\\s\\S]*?${tomlKeyPattern("base_url")}`,
|
||||
);
|
||||
return match ? (match[1] ?? match[2] ?? "") : undefined;
|
||||
}
|
||||
|
||||
function parseTomlStringAssignment(content: string, keyPattern: string): RegExpMatchArray | null {
|
||||
return content.match(
|
||||
new RegExp(`(?:^|\\n)\\s*${keyPattern}\\s*=\\s*(?:"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|'([^']*)')`),
|
||||
);
|
||||
}
|
||||
|
||||
function tomlDottedKeyPattern(key: string): string {
|
||||
return key.split(".").map(tomlKeyPattern).join("\\s*\\.\\s*");
|
||||
}
|
||||
|
||||
function tomlKeyPattern(key: string): string {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return `(?:"${escaped}"|'${escaped}'|${escaped})`;
|
||||
}
|
||||
|
||||
function parseRequirementsStringArray(content: string, key: string): string[] | undefined {
|
||||
const match = content.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`));
|
||||
if (!match) {
|
||||
@@ -894,6 +1053,20 @@ function parseRequirementsStringArray(content: string, key: string): string[] |
|
||||
return stringMatches.map((entry) => entry[1] ?? entry[2] ?? "");
|
||||
}
|
||||
|
||||
function parseTomlTableSection(content: string, table: string): string | undefined {
|
||||
const strippedContent = stripTomlLineComments(content);
|
||||
const tablePattern = tomlDottedKeyPattern(table);
|
||||
const headerPattern = new RegExp(`^\\s*\\[\\s*${tablePattern}\\s*\\]\\s*$`, "m");
|
||||
const match = headerPattern.exec(strippedContent);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const sectionStart = match.index + match[0].length;
|
||||
const rest = strippedContent.slice(sectionStart);
|
||||
const nextTableOffset = rest.search(/^\s*\[/m);
|
||||
return nextTableOffset === -1 ? rest : rest.slice(0, nextTableOffset);
|
||||
}
|
||||
|
||||
function parseTomlArrayTableSections(content: string, table: string): string[] {
|
||||
const strippedContent = stripTomlLineComments(content);
|
||||
const escapedTable = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -1059,11 +1232,214 @@ function selectGuardianApprovalsReviewer(
|
||||
|
||||
function selectUserApprovalsReviewer(
|
||||
allowedApprovalsReviewers: Set<CodexAppServerApprovalsReviewer> | undefined,
|
||||
execModeRequiringUserReviewer?: OpenClawExecMode,
|
||||
): CodexAppServerApprovalsReviewer {
|
||||
if (allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("user")) {
|
||||
return "user";
|
||||
}
|
||||
throw new Error("tools.exec.mode=ask requires Codex app-server user approvals");
|
||||
throw new Error(
|
||||
`tools.exec.mode=${execModeRequiringUserReviewer ?? "ask"} requires Codex app-server user approvals`,
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexModelBackedApprovalsReviewerProvider(provider: string | undefined): boolean {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
return normalized === "openai";
|
||||
}
|
||||
|
||||
function isTrustedCodexModelBackedApprovalsReviewerProvider(
|
||||
provider: string | undefined,
|
||||
params: CodexModelBackedReviewerContext,
|
||||
): boolean {
|
||||
return (
|
||||
isCodexModelBackedApprovalsReviewerProvider(provider) &&
|
||||
isTrustedCodexModelBackedOpenAIProvider({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
model: params.model,
|
||||
agentDir: params.agentDir,
|
||||
codexConfigToml: params.codexConfigToml,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function readCodexBaseUrlOverridesForModelBackedReview(
|
||||
params: Pick<CodexModelBackedReviewerContext, "agentDir" | "codexConfigToml">,
|
||||
): { openAI: string[]; chatGPT: string[] } | false {
|
||||
const configToml = readCodexAppServerConfigToml(params);
|
||||
if (configToml === false) {
|
||||
return false;
|
||||
}
|
||||
if (configToml === undefined) {
|
||||
return { openAI: [], chatGPT: [] };
|
||||
}
|
||||
const topLevelContent = stripTomlLineComments(configToml).slice(
|
||||
0,
|
||||
firstTomlTableOffset(configToml),
|
||||
);
|
||||
const modelProviderOpenAISection = parseTomlTableSection(configToml, "model_providers.openai");
|
||||
return {
|
||||
openAI: [
|
||||
parseTomlStringValue(topLevelContent, "openai_base_url"),
|
||||
parseTomlStringValue(topLevelContent, "model_providers.openai.base_url"),
|
||||
parseInlineOpenAIModelProviderBaseUrl(topLevelContent),
|
||||
modelProviderOpenAISection
|
||||
? parseTomlStringValue(modelProviderOpenAISection, "base_url")
|
||||
: undefined,
|
||||
].filter((entry): entry is string => entry !== undefined),
|
||||
chatGPT: [parseTomlStringValue(topLevelContent, "chatgpt_base_url")].filter(
|
||||
(entry): entry is string => entry !== undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexAppServerConfigToml(
|
||||
params: Pick<CodexModelBackedReviewerContext, "agentDir" | "codexConfigToml">,
|
||||
): string | undefined | false {
|
||||
if (params.codexConfigToml !== undefined) {
|
||||
return params.codexConfigToml ?? undefined;
|
||||
}
|
||||
const configPath = resolveCodexAppServerConfigPath(params);
|
||||
if (!configPath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return readFileSync(configPath, "utf8");
|
||||
} catch (error) {
|
||||
return readErrorCode(error) === "ENOENT" ? undefined : false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexAppServerConfigPath(
|
||||
params: Pick<CodexModelBackedReviewerContext, "agentDir">,
|
||||
): string | undefined {
|
||||
const agentDir = readNonEmptyString(params.agentDir);
|
||||
const codexHome = agentDir
|
||||
? path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME)
|
||||
: undefined;
|
||||
return codexHome ? path.join(codexHome, CODEX_CONFIG_TOML_FILENAME) : undefined;
|
||||
}
|
||||
|
||||
function readErrorCode(error: unknown): string | undefined {
|
||||
return error && typeof error === "object" && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readConfiguredOpenAIProvidersForModelBackedReview(
|
||||
config: ProviderAuthAliasConfig | undefined,
|
||||
): Array<Record<string, unknown>> {
|
||||
const providerRecords = readRecord(readRecord(readRecord(config)?.models)?.providers);
|
||||
if (!providerRecords) {
|
||||
return [];
|
||||
}
|
||||
const openAIProviders: Array<Record<string, unknown>> = [];
|
||||
for (const [providerId, providerConfig] of Object.entries(providerRecords)) {
|
||||
if (resolveProviderIdForAuth(providerId, { config }) !== "openai") {
|
||||
continue;
|
||||
}
|
||||
const record = readRecord(providerConfig);
|
||||
if (record) {
|
||||
openAIProviders.push(record);
|
||||
}
|
||||
}
|
||||
return openAIProviders;
|
||||
}
|
||||
|
||||
function configuredOpenAIProviderIsTrustedForModelBackedReview(
|
||||
openAIProvider: Record<string, unknown>,
|
||||
modelInput: string | undefined,
|
||||
): boolean {
|
||||
if (
|
||||
readRecord(openAIProvider.localService) ||
|
||||
hasNonEmptyRecord(openAIProvider.headers) ||
|
||||
hasNonEmptyRecord(openAIProvider.request) ||
|
||||
typeof openAIProvider.authHeader === "boolean" ||
|
||||
!isNativeOpenAIBaseUrl(openAIProvider.baseUrl)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const models = openAIProvider.models;
|
||||
if (!Array.isArray(models)) {
|
||||
return true;
|
||||
}
|
||||
const modelId = normalizeOpenAIModelBackedReviewerModelId(modelInput);
|
||||
if (!modelId) {
|
||||
return false;
|
||||
}
|
||||
for (const entry of models) {
|
||||
const model = readRecord(entry);
|
||||
if (typeof model?.id !== "string" || !matchesConfiguredOpenAIModelId(modelId, model.id)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
hasNonEmptyRecord(model.headers) ||
|
||||
hasNonEmptyRecord(model.request) ||
|
||||
!isNativeOpenAIBaseUrl(model.baseUrl)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeOpenAIModelBackedReviewerModelId(modelInput: string | undefined): string {
|
||||
const normalized = modelInput?.trim() ?? "";
|
||||
const authProfileIndex = normalized.indexOf("@");
|
||||
const withoutAuthProfile =
|
||||
authProfileIndex > 0 ? normalized.slice(0, authProfileIndex) : normalized;
|
||||
const slashIndex = withoutAuthProfile.indexOf("/");
|
||||
return slashIndex > 0 ? withoutAuthProfile.slice(slashIndex + 1).trim() : withoutAuthProfile;
|
||||
}
|
||||
|
||||
function matchesConfiguredOpenAIModelId(modelId: string, configuredModelId: string): boolean {
|
||||
const configured = normalizeOpenAIModelBackedReviewerModelId(configuredModelId);
|
||||
return Boolean(configured) && (modelId === configured || modelId.startsWith(`${configured}@`));
|
||||
}
|
||||
|
||||
function hasNonEmptyRecord(value: unknown): boolean {
|
||||
const record = readRecord(value);
|
||||
return record !== undefined && Object.keys(record).length > 0;
|
||||
}
|
||||
|
||||
function isNativeOpenAIBaseUrl(value: unknown): boolean {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && url.hostname.toLowerCase() === "api.openai.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAIBaseUrlEnvOverridesAreTrustedForModelBackedReview(
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
): boolean {
|
||||
return [env?.OPENAI_BASE_URL, env?.OPENAI_API_BASE].every(isNativeOpenAIBaseUrl);
|
||||
}
|
||||
|
||||
function isNativeChatGPTBaseUrl(value: unknown): boolean {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && url.hostname.toLowerCase() === "chatgpt.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCodexModelBackedReviewerPolicyProvider(provider: string): string {
|
||||
return provider.toLowerCase() === "openai" ? "openai" : provider;
|
||||
}
|
||||
|
||||
function inferProviderFromModelRef(model: string | undefined): string | undefined {
|
||||
const normalized = model?.trim().toLowerCase();
|
||||
const slashIndex = normalized?.indexOf("/") ?? -1;
|
||||
return slashIndex > 0 ? normalized?.slice(0, slashIndex) : undefined;
|
||||
}
|
||||
|
||||
function selectForcedPromptingSandbox(params: {
|
||||
|
||||
@@ -185,15 +185,8 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffortOption": {
|
||||
|
||||
@@ -718,15 +718,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SandboxPolicy": {
|
||||
|
||||
@@ -718,15 +718,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SandboxPolicy": {
|
||||
|
||||
@@ -606,15 +606,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user