mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 08:12:29 +08:00
Compare commits
8 Commits
main
...
omarshahin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3c3562fcc | ||
|
|
1560b346e7 | ||
|
|
c39247f2e9 | ||
|
|
4337b4b572 | ||
|
|
9d08d4e98c | ||
|
|
8dafb6c3b5 | ||
|
|
40259a1256 | ||
|
|
1a4ebea073 |
@@ -146,7 +146,7 @@ Default guidance:
|
||||
|
||||
Default Completeness bands:
|
||||
|
||||
- `Clawesome` (95-100): complete across expected workflows, variants, and
|
||||
- `Lovable` (95-100): complete across expected workflows, variants, and
|
||||
recovery branches, with only minor polish gaps.
|
||||
- `Stable` (80-95): the expected workflow set is broadly present, with only
|
||||
bounded missing branches.
|
||||
@@ -172,7 +172,7 @@ Default Completeness bands:
|
||||
|
||||
Bands:
|
||||
|
||||
- `Clawesome`: 95-100
|
||||
- `Lovable`: 95-100
|
||||
- `Stable`: 80-95
|
||||
- `Beta`: 70-80
|
||||
- `Alpha`: 50-70
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# OpenClaw Maturity Scorecard Agent
|
||||
|
||||
You are refreshing the OpenClaw maturity score source for a release scorecard.
|
||||
|
||||
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
|
||||
|
||||
Allowed tracked paths:
|
||||
|
||||
- `qa/maturity-scores.yaml`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
|
||||
- Do not render docs. The workflow renders docs after validating the score source.
|
||||
- Keep the score source schema valid for QA Lab maturity score validation.
|
||||
|
||||
Required workflow:
|
||||
|
||||
1. Use the `$claw-score` skill before editing.
|
||||
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
|
||||
3. Refresh scores for every active surface in `taxonomy.yaml`.
|
||||
4. Run the QA Lab maturity score validation used by this repository.
|
||||
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.
|
||||
17
.github/workflows/ci-build-artifacts-testbox.yml
vendored
17
.github/workflows/ci-build-artifacts-testbox.yml
vendored
@@ -198,19 +198,10 @@ jobs:
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
17
.github/workflows/ci-check-arm-testbox.yml
vendored
17
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -116,19 +116,10 @@ jobs:
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
17
.github/workflows/ci-check-testbox.yml
vendored
17
.github/workflows/ci-check-testbox.yml
vendored
@@ -105,19 +105,10 @@ jobs:
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
80
.github/workflows/ci.yml
vendored
80
.github/workflows/ci.yml
vendored
@@ -100,7 +100,6 @@ jobs:
|
||||
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
|
||||
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
|
||||
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
|
||||
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
|
||||
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
||||
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
|
||||
steps:
|
||||
@@ -205,7 +204,6 @@ jobs:
|
||||
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
|
||||
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
|
||||
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
|
||||
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
|
||||
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
|
||||
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
||||
@@ -269,8 +267,6 @@ jobs:
|
||||
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
|
||||
const runMacos =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
|
||||
const runIosBuild =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository;
|
||||
const runAndroid =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
|
||||
const runWindows =
|
||||
@@ -365,7 +361,6 @@ jobs:
|
||||
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
|
||||
),
|
||||
run_macos_swift: runMacos,
|
||||
run_ios_build: runIosBuild,
|
||||
run_android_job: runAndroid,
|
||||
android_matrix: createMatrix(
|
||||
runAndroid
|
||||
@@ -1182,9 +1177,7 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# The canonical main path waits for the admission debounce above, so
|
||||
# modestly widen this large matrix without recreating registration bursts.
|
||||
max-parallel: 16
|
||||
max-parallel: 12
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -2167,76 +2160,6 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios-build:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "ios-build"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_ios_build == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
||||
if [ -d "$xcode_app/Contents/Developer" ]; then
|
||||
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
||||
break
|
||||
fi
|
||||
done
|
||||
xcodebuild -version
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
if [[ "$xcode_version" != 26.* ]]; then
|
||||
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
swift --version
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Install iOS Swift tooling
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Build iOS app
|
||||
run: pnpm ios:build
|
||||
|
||||
android:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -2388,7 +2311,6 @@ jobs:
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- ios-build
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
18
.github/workflows/codeql.yml
vendored
18
.github/workflows/codeql.yml
vendored
@@ -22,6 +22,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -49,32 +55,32 @@ jobs:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
category: core-auth-secrets
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: channel-runtime-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: network-ssrf-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: mcp-process-tool-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: plugin-trust-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
|
||||
- language: actions
|
||||
category: actions
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout_minutes: 10
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
|
||||
38
.github/workflows/crabbox-hydrate.yml
vendored
38
.github/workflows/crabbox-hydrate.yml
vendored
@@ -171,19 +171,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
@@ -499,7 +490,7 @@ jobs:
|
||||
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
|
||||
Write-Error "Invalid crabbox_id"
|
||||
}
|
||||
$actionsRoot = "C:\ProgramData\crabbox\actions"
|
||||
$actionsRoot = Join-Path $HOME ".crabbox\actions"
|
||||
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
|
||||
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
|
||||
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
|
||||
@@ -555,7 +546,7 @@ jobs:
|
||||
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
|
||||
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
|
||||
}
|
||||
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
|
||||
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
|
||||
$deadline = (Get-Date).AddMinutes($minutes)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path $stop) {
|
||||
@@ -593,19 +584,10 @@ jobs:
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
2
.github/workflows/ios-periphery.yml
vendored
2
.github/workflows/ios-periphery.yml
vendored
@@ -220,7 +220,7 @@ jobs:
|
||||
with:
|
||||
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-periphery
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail on dead code
|
||||
|
||||
129
.github/workflows/maturity-scorecard.yml
vendored
129
.github/workflows/maturity-scorecard.yml
vendored
@@ -12,40 +12,6 @@ on:
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
qa_evidence_run_id:
|
||||
description: Optional workflow run id containing qa-evidence.json
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA containing the maturity score source
|
||||
required: true
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
required: true
|
||||
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
|
||||
description: Optional OpenAI API key used by maturity scorecard agent steps
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY:
|
||||
description: Optional GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY_FALLBACK:
|
||||
description: Optional fallback GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -77,25 +43,14 @@ jobs:
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
expected_sha="${EXPECTED_SHA,,}"
|
||||
trusted_reason=""
|
||||
|
||||
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
|
||||
@@ -132,9 +87,9 @@ jobs:
|
||||
if: ${{ inputs.qa_evidence_run_id == '' }}
|
||||
uses: ./.github/workflows/qa-profile-evidence.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: release
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: all
|
||||
fail_on_qa_failure: false
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -238,8 +193,8 @@ jobs:
|
||||
}
|
||||
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (evidence.profile !== "release") {
|
||||
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
|
||||
if (evidence.profile !== "all") {
|
||||
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
|
||||
}
|
||||
|
||||
const artifactDir = path.dirname(evidencePath);
|
||||
@@ -256,75 +211,14 @@ jobs:
|
||||
const manifestPath = path.join(artifactDir, manifestNames[0]);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const manifestProfile = manifest.qaProfile ?? evidence.profile;
|
||||
if (manifestProfile !== "release") {
|
||||
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
|
||||
if (manifestProfile !== "all") {
|
||||
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
|
||||
}
|
||||
if (manifest.targetSha !== targetSha) {
|
||||
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Ensure maturity scorecard agent key exists
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run Codex maturity scorecard agent
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
env:
|
||||
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
|
||||
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
|
||||
MATURITY_TAXONOMY_PATH: taxonomy.yaml
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
|
||||
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
effort: high
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
|
||||
- name: Enforce focused maturity score patch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git restore --staged :/
|
||||
|
||||
allowed='^qa/maturity-scores\.yaml$'
|
||||
bad_tracked="$(
|
||||
git diff --name-only HEAD -- | while IFS= read -r path; do
|
||||
if [[ ! "$path" =~ $allowed ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_tracked" ]]; then
|
||||
echo "Maturity scorecard agent touched forbidden tracked paths:"
|
||||
printf '%s\n' "$bad_tracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bad_untracked="$(
|
||||
git ls-files --others --exclude-standard | while IFS= read -r path; do
|
||||
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_untracked" ]]; then
|
||||
echo "Maturity scorecard agent created forbidden untracked paths:"
|
||||
printf '%s\n' "$bad_untracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f qa/maturity-scores.yaml ]]; then
|
||||
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate maturity score sources
|
||||
run: |
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
@@ -367,7 +261,6 @@ jobs:
|
||||
--strict-inputs
|
||||
|
||||
- name: Create generated docs PR app token
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
@@ -378,7 +271,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Create generated docs PR fallback app token
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
|
||||
if: ${{ steps.app-token.outcome == 'failure' }}
|
||||
id: app-token-fallback
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
@@ -388,7 +281,6 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Open generated docs PR
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
|
||||
@@ -400,7 +292,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "- Pull request: skipped; generated scorecard matches selected ref"
|
||||
@@ -420,6 +312,9 @@ jobs:
|
||||
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
|
||||
git switch -C "$branch"
|
||||
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
|
||||
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
|
||||
git add docs/maturity-scores.yaml
|
||||
fi
|
||||
git commit -m "docs: update maturity scorecard"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
|
||||
52
.github/workflows/openclaw-release-checks.yml
vendored
52
.github/workflows/openclaw-release-checks.yml
vendored
@@ -44,11 +44,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
run_maturity_scorecard:
|
||||
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
rerun_group:
|
||||
description: Release check group to run
|
||||
required: false
|
||||
@@ -111,7 +106,6 @@ jobs:
|
||||
mode: ${{ steps.inputs.outputs.mode }}
|
||||
release_profile: ${{ steps.inputs.outputs.release_profile }}
|
||||
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
|
||||
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
|
||||
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
|
||||
@@ -285,7 +279,6 @@ jobs:
|
||||
RELEASE_MODE_INPUT: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
|
||||
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
|
||||
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -326,12 +319,6 @@ jobs:
|
||||
else
|
||||
run_release_soak=true
|
||||
fi
|
||||
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
|
||||
run_maturity_scorecard=false
|
||||
else
|
||||
run_maturity_scorecard=true
|
||||
fi
|
||||
release_profile="$RELEASE_PROFILE_INPUT"
|
||||
if [[ "$release_profile" == "minimum" ]]; then
|
||||
release_profile=beta
|
||||
@@ -435,7 +422,6 @@ jobs:
|
||||
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
|
||||
printf 'release_profile=%s\n' "$release_profile"
|
||||
printf 'run_release_soak=%s\n' "$run_release_soak"
|
||||
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
|
||||
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
|
||||
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
|
||||
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
|
||||
@@ -458,7 +444,6 @@ jobs:
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -476,7 +461,6 @@ jobs:
|
||||
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
|
||||
echo "- Release profile: \`${RELEASE_PROFILE}\`"
|
||||
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
|
||||
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
|
||||
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
|
||||
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
|
||||
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
|
||||
@@ -783,20 +767,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
|
||||
maturity_scorecard_release_checks:
|
||||
name: Render maturity scorecard release docs
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
uses: ./.github/workflows/maturity-scorecard.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
expected_sha: ${{ needs.resolve_target.outputs.revision }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
qa_lab_parity_lane_release_checks:
|
||||
name: Run QA Lab parity lane (${{ matrix.lane }})
|
||||
needs: [resolve_target]
|
||||
@@ -883,7 +853,7 @@ jobs:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -989,7 +959,7 @@ jobs:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1161,7 +1131,7 @@ jobs:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1271,13 +1241,13 @@ jobs:
|
||||
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
qa_live_matrix_release_checks:
|
||||
name: Run QA Lab live Matrix lane
|
||||
@@ -1357,7 +1327,7 @@ jobs:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1497,7 +1467,7 @@ jobs:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1637,7 +1607,7 @@ jobs:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1780,7 +1750,7 @@ jobs:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1920,7 +1890,7 @@ jobs:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1976,7 +1946,6 @@ jobs:
|
||||
- docker_e2e_release_checks
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- maturity_scorecard_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
@@ -2062,7 +2031,6 @@ jobs:
|
||||
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
|
||||
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
|
||||
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
|
||||
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
|
||||
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
|
||||
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
|
||||
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
|
||||
|
||||
@@ -1466,9 +1466,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload postpublish evidence
|
||||
if: ${{ always() && inputs.publish_openclaw_npm }}
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
if-no-files-found: error
|
||||
if-no-files-found: ignore
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -66,5 +66,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -97,5 +97,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
24
.github/workflows/qa-profile-evidence.yml
vendored
24
.github/workflows/qa-profile-evidence.yml
vendored
@@ -20,6 +20,11 @@ on:
|
||||
required: true
|
||||
default: release
|
||||
type: string
|
||||
fail_on_qa_failure:
|
||||
description: Fail the workflow when the QA profile command exits non-zero
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -35,6 +40,11 @@ on:
|
||||
description: Taxonomy QA profile id to run
|
||||
required: true
|
||||
type: string
|
||||
fail_on_qa_failure:
|
||||
description: Fail the reusable workflow when the QA profile command exits non-zero
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
@@ -89,13 +99,6 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
// Reusable workflow jobs inherit the caller event but run as
|
||||
// github-actions[bot]; selected ref validation still gates secrets.
|
||||
if (context.actor === "github-actions[bot]") {
|
||||
core.info("Skipping manual actor permission check for a reusable workflow call.");
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
@@ -250,9 +253,6 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Ensure Playwright Chromium
|
||||
run: node scripts/ensure-playwright-chromium.mjs
|
||||
|
||||
- name: Run QA profile
|
||||
id: run_profile
|
||||
env:
|
||||
@@ -367,8 +367,8 @@ jobs:
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Fail if QA profile failed
|
||||
if: always()
|
||||
- name: Fail if configured QA gate failed
|
||||
if: always() && inputs.fail_on_qa_failure
|
||||
env:
|
||||
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
|
||||
@@ -37,7 +37,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
|
||||
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
|
||||
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
|
||||
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2026.6.9 - 2026-06-23
|
||||
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
@@ -1,3 +1 @@
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# App Review Notes
|
||||
|
||||
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
|
||||
|
||||
## Demo Account / Setup
|
||||
|
||||
Use the OpenClaw iOS app with the live review Gateway setup code included in
|
||||
the `Notes` field of this App Review submission.
|
||||
|
||||
The setup code is a single generated code string. It already contains the public
|
||||
Gateway host and setup credential.
|
||||
|
||||
## Setup Walkthrough
|
||||
|
||||
1. Open the OpenClaw app.
|
||||
2. Tap `Continue`.
|
||||
3. On `Connect Gateway`, tap `Set Up Manually`.
|
||||
4. In the `Setup Code` section, tap the `Paste setup code` field.
|
||||
5. Paste the setup code string from the App Review submission `Notes` field.
|
||||
6. Tap `Apply Setup Code`.
|
||||
7. If `Trust and connect` appears, tap `Trust and connect`.
|
||||
8. Wait for the `Connected` screen.
|
||||
9. On `Connected`, tap `Open OpenClaw`.
|
||||
10. Confirm the `Control` screen shows `Gateway Online`.
|
||||
11. Tap `Settings`.
|
||||
12. Tap `Approvals`.
|
||||
13. Tap `Open Notifications`.
|
||||
14. Tap `Enable Notifications`.
|
||||
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
|
||||
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
|
||||
17. Confirm `Notifications` shows `Enabled`.
|
||||
|
||||
## Chat
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Start Apple review checklist.
|
||||
```
|
||||
|
||||
Expected result: the assistant replies with the available App Review demos.
|
||||
|
||||
## Approval Demo
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Run the approval demo.
|
||||
```
|
||||
|
||||
Expected result: the iPhone shows `Exec approval required` with the harmless
|
||||
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
|
||||
`Allow Once`. The chat then replies:
|
||||
|
||||
```text
|
||||
The approval demo completed.
|
||||
```
|
||||
|
||||
## Talk
|
||||
|
||||
1. Tap the `Talk` tab.
|
||||
2. Tap `Start Talk`.
|
||||
3. If iOS asks for microphone access, tap `Allow`.
|
||||
4. If iOS asks for Speech Recognition access, tap `Allow`.
|
||||
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
|
||||
6. Say:
|
||||
|
||||
```text
|
||||
Summarize this review setup in one sentence.
|
||||
```
|
||||
|
||||
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
|
||||
|
||||
## Talk + Background Audio
|
||||
|
||||
1. Tap the `Talk` tab.
|
||||
2. Confirm `Speakerphone` is on.
|
||||
3. Confirm `Background listening` is on.
|
||||
4. Tap `Start Talk`.
|
||||
5. If iOS asks for microphone access, tap `Allow`.
|
||||
6. If iOS asks for Speech Recognition access, tap `Allow`.
|
||||
7. Confirm `Stop Talk` is visible.
|
||||
8. Say:
|
||||
|
||||
```text
|
||||
Tell me when you can hear me.
|
||||
```
|
||||
|
||||
9. While Talk is active, send OpenClaw to the background by returning to the
|
||||
Home Screen or locking the iPhone. Do not force quit the app.
|
||||
10. Continue speaking then wait for assistant audio reply.
|
||||
|
||||
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
|
||||
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
|
||||
|
||||
## Gateway Status
|
||||
|
||||
1. Tap `Control`.
|
||||
2. Tap `Instances`.
|
||||
3. Confirm the screen shows `Gateway online`.
|
||||
4. Confirm at least one `agent` row is connected.
|
||||
5. Confirm the iPhone review device appears in the connected instances list.
|
||||
|
||||
## Push Notification
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Start push notification demo.
|
||||
```
|
||||
|
||||
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
|
||||
force quit the app.
|
||||
|
||||
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
|
||||
notification with this body:
|
||||
|
||||
```text
|
||||
OpenClaw App Review push notification demo
|
||||
```
|
||||
|
||||
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
|
||||
`Control`, tap `Chat`. Expected chat reply:
|
||||
|
||||
```text
|
||||
The push notification demo completed.
|
||||
```
|
||||
|
||||
## Push Wake / Status
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Send this exact message:
|
||||
|
||||
```text
|
||||
Start push wake demo.
|
||||
```
|
||||
|
||||
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
|
||||
force quit the app.
|
||||
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
|
||||
about 10 seconds after the message is sent.
|
||||
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
|
||||
`Control`, tap `Chat`.
|
||||
|
||||
Expected result: the app reconnects to the live Gateway and Chat replies:
|
||||
|
||||
```text
|
||||
The push wake and node status demo completed.
|
||||
```
|
||||
|
||||
## Device Permissions
|
||||
|
||||
1. Tap `Settings`.
|
||||
2. Tap `Permissions`.
|
||||
3. Confirm these current app controls are available:
|
||||
- `Camera`
|
||||
- `Location` with `Off`, `While Using`, and `Always`
|
||||
- `Keep Awake`
|
||||
4. Expand `Privacy & Access`.
|
||||
5. Confirm these request controls are available:
|
||||
- `Contacts` / `Request Access`
|
||||
- `Calendar (Add Events)` / `Request Access`
|
||||
- `Calendar (View Events)` / `Request Full Access`
|
||||
- `Reminders` / `Request Access`
|
||||
|
||||
## Share Sheet
|
||||
|
||||
1. Open Safari.
|
||||
2. Navigate to `https://example.com`.
|
||||
3. Tap the Safari toolbar `More` button.
|
||||
4. Tap `Share`.
|
||||
5. Tap `OpenClaw`.
|
||||
6. Confirm the OpenClaw share extension appears and shows
|
||||
`Edit text, then tap Send.` and `Send to OpenClaw`.
|
||||
7. Tap `Send to OpenClaw`.
|
||||
|
||||
Expected result: the OpenClaw share extension sends the shared Safari page to
|
||||
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
|
||||
Chat shows the shared `Example Domain` page.
|
||||
@@ -67,9 +67,9 @@ Release behavior:
|
||||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
@@ -83,8 +83,9 @@ Release behavior:
|
||||
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
@@ -161,19 +162,25 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Upload the build:
|
||||
6. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
6. Expected behavior:
|
||||
7. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
@@ -181,16 +188,15 @@ pnpm ios:release:upload
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
7. Expected outputs after a successful run:
|
||||
8. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -240,13 +246,13 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
|
||||
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
|
||||
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
|
||||
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||
|
||||
## APNs Expectations For Official Builds
|
||||
|
||||
@@ -255,7 +261,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||
|
||||
## Official Build Relay Trust Model
|
||||
|
||||
@@ -152,7 +152,6 @@ extension SettingsProTab {
|
||||
}
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayDiagnosticConnected,
|
||||
@@ -326,7 +325,6 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
|
||||
return false
|
||||
}
|
||||
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
|
||||
self.setupStatusText = "Checking gateway reachability..."
|
||||
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
|
||||
if !ok {
|
||||
@@ -419,7 +417,6 @@ extension SettingsProTab {
|
||||
let status = settings.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.applyNotificationStatus(status)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,7 +437,6 @@ extension SettingsProTab {
|
||||
|
||||
func requestNotificationAuthorizationFromSettings() {
|
||||
guard !self.isRequestingNotificationAuthorization else { return }
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
self.isRequestingNotificationAuthorization = true
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
@@ -452,19 +448,12 @@ extension SettingsProTab {
|
||||
await MainActor.run {
|
||||
self.isRequestingNotificationAuthorization = false
|
||||
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
|
||||
guard granted else { return }
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
guard granted, self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func registerForRemoteNotificationsIfEnrollmentReady() {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
self.notificationStatus = SettingsNotificationStatus(status)
|
||||
|
||||
@@ -127,8 +127,6 @@ final class GatewayConnectionController {
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var localNetworkAccessRequested: Bool
|
||||
private var currentScenePhase: ScenePhase = .inactive
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var pendingTrustConnect: PendingTrustConnect?
|
||||
@@ -139,14 +137,9 @@ final class GatewayConnectionController {
|
||||
let useTLS: Bool
|
||||
}
|
||||
|
||||
init(
|
||||
appModel: NodeAppModel,
|
||||
startDiscovery: Bool = true,
|
||||
deferDiscoveryUntilLocalNetworkRequest: Bool = false)
|
||||
{
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.discoveryEnabled = startDiscovery
|
||||
self.appModel = appModel
|
||||
self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -155,7 +148,7 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
|
||||
if self.discoveryEnabled, self.localNetworkAccessRequested {
|
||||
if self.discoveryEnabled {
|
||||
self.discovery.start()
|
||||
}
|
||||
}
|
||||
@@ -164,29 +157,11 @@ final class GatewayConnectionController {
|
||||
self.discovery.setDebugLoggingEnabled(enabled)
|
||||
}
|
||||
|
||||
func requestLocalNetworkAccess(reason: String) {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
|
||||
self.localNetworkAccessRequested = true
|
||||
GatewayDiagnostics.log("local network access requested reason=\(reason)")
|
||||
|
||||
guard self.currentScenePhase != .background else { return }
|
||||
self.discovery.start()
|
||||
self.updateFromDiscovery()
|
||||
self.attemptAutoReconnectIfNeeded()
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
self.currentScenePhase = phase
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
}
|
||||
guard self.localNetworkAccessRequested else { return }
|
||||
|
||||
switch phase {
|
||||
case .background:
|
||||
@@ -206,10 +181,6 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
guard self.localNetworkAccessRequested else {
|
||||
self.requestLocalNetworkAccess(reason: "restart_discovery")
|
||||
return
|
||||
}
|
||||
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
@@ -226,7 +197,6 @@ final class GatewayConnectionController {
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
forceReconnect: Bool = false) async -> String?
|
||||
{
|
||||
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway")
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if instanceId.isEmpty {
|
||||
@@ -305,7 +275,6 @@ final class GatewayConnectionController {
|
||||
authOverride: ManualAuthOverride? = nil,
|
||||
forceReconnect: Bool = false) async
|
||||
{
|
||||
self.requestLocalNetworkAccess(reason: "connect_manual")
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let token =
|
||||
authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
@@ -371,7 +340,6 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func connectLastKnown() async {
|
||||
self.requestLocalNetworkAccess(reason: "connect_last_known")
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
|
||||
@@ -82,10 +82,18 @@
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawPushMode</key>
|
||||
<string>$(OPENCLAW_PUSH_MODE)</string>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||
<key>OpenClawPushProofPolicy</key>
|
||||
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||
<key>OpenClawPushRelayProfile</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
|
||||
<key>OpenClawPushTransport</key>
|
||||
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -4103,9 +4103,6 @@ extension NodeAppModel {
|
||||
|
||||
private func registerAPNsTokenIfNeeded() async {
|
||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
|
||||
return
|
||||
}
|
||||
guard self.gatewayConnected else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
|
||||
@@ -4166,23 +4163,6 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
|
||||
}
|
||||
return false
|
||||
}
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard Self.isNotificationAuthorizationAllowed(status) else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "gateway.identity.get",
|
||||
@@ -5146,10 +5126,6 @@ extension NodeAppModel {
|
||||
self.setOperatorConnected(connected)
|
||||
}
|
||||
|
||||
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
|
||||
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
|
||||
}
|
||||
|
||||
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
|
||||
self.makeWatchChatItems(from: raw)
|
||||
}
|
||||
|
||||
@@ -73,16 +73,10 @@ struct OnboardingWizardView: View {
|
||||
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
let allowSkip: Bool
|
||||
let onRequestLocalNetworkAccess: (String) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
init(
|
||||
allowSkip: Bool,
|
||||
onRequestLocalNetworkAccess: @escaping (String) -> Void,
|
||||
onClose: @escaping () -> Void)
|
||||
{
|
||||
init(allowSkip: Bool, onClose: @escaping () -> Void) {
|
||||
self.allowSkip = allowSkip
|
||||
self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess
|
||||
self.onClose = onClose
|
||||
_step = State(
|
||||
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
|
||||
@@ -237,7 +231,6 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
|
||||
}
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
@@ -871,20 +864,10 @@ extension OnboardingWizardView {
|
||||
|
||||
private func advanceFromIntro() {
|
||||
OnboardingStateStore.markFirstRunIntroSeen()
|
||||
self.requestLocalNetworkAccess(reason: "onboarding_continue")
|
||||
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
self.step = .welcome
|
||||
}
|
||||
|
||||
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
|
||||
guard self.step != .intro else { return }
|
||||
self.requestLocalNetworkAccess(reason: reason)
|
||||
}
|
||||
|
||||
private func requestLocalNetworkAccess(reason: String) {
|
||||
self.onRequestLocalNetworkAccess(reason)
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
guard let target = self.step.previous else { return }
|
||||
self.connectingGatewayID = nil
|
||||
|
||||
@@ -123,28 +123,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.delegate = self
|
||||
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
|
||||
Task { @MainActor in
|
||||
await self.registerForRemoteNotificationsIfEnrollmentReady(application)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard await Self.isNotificationAuthorizationAllowed() else { return }
|
||||
application.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
private static func isNotificationAuthorizationAllowed() async -> Bool {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .denied, .notDetermined:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
@@ -646,8 +626,7 @@ struct OpenClawApp: App {
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: !Self.screenshotModeEnabled,
|
||||
deferDiscoveryUntilLocalNetworkRequest: true))
|
||||
startDiscovery: !Self.screenshotModeEnabled))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -27,16 +27,7 @@ enum PushProofPolicy: String {
|
||||
case internalSimulator
|
||||
}
|
||||
|
||||
enum PushBuildMode: String {
|
||||
case localSandbox
|
||||
case localProduction
|
||||
case appStore
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let mode: PushBuildMode
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
@@ -63,64 +54,31 @@ struct PushBuildConfig {
|
||||
}
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
|
||||
self.transport = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushTransport",
|
||||
fallback: .direct)
|
||||
self.distribution = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushDistribution",
|
||||
fallback: .local)
|
||||
self.apnsEnvironment = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushAPNsEnvironment",
|
||||
fallback: Self.defaultAPNsEnvironment)
|
||||
self.relayProfile = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushRelayProfile",
|
||||
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
|
||||
self.proofPolicy = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushProofPolicy",
|
||||
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
init(infoDictionary: [String: Any]) {
|
||||
self.init(readValue: { infoDictionary[$0] })
|
||||
}
|
||||
|
||||
private init(readValue: (String) -> Any?) {
|
||||
self.mode = Self.readEnum(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushMode",
|
||||
fallback: .localSandbox)
|
||||
let relayBaseURLOverride = Self.readURL(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushRelayBaseURL")
|
||||
switch self.mode {
|
||||
case .localSandbox:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .localProduction:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .appStore:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .deviceSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .simulatorSandbox
|
||||
self.proofPolicy = .internalSimulator
|
||||
}
|
||||
}
|
||||
|
||||
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
|
||||
guard let raw = readValue(key) as? String else { return nil }
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let components = URLComponents(string: trimmed),
|
||||
@@ -138,12 +96,29 @@ struct PushBuildConfig {
|
||||
}
|
||||
|
||||
private static func readEnum<T: RawRepresentable>(
|
||||
readValue: (String) -> Any?,
|
||||
bundle: Bundle,
|
||||
key: String,
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = readValue(key) as? String else { return fallback }
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
}
|
||||
|
||||
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||
|
||||
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
|
||||
apnsEnvironment == .production ? .production : .deviceSandbox
|
||||
}
|
||||
|
||||
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
.appleStrict
|
||||
case .deviceSandbox:
|
||||
.appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
.internalSimulator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum PushEnrollmentConsent {
|
||||
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
|
||||
|
||||
static var disclosureAccepted: Bool {
|
||||
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
static func markDisclosureAccepted() {
|
||||
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func reset() {
|
||||
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -69,7 +69,7 @@ actor PushRegistrationManager {
|
||||
async throws -> String {
|
||||
guard self.buildConfig.distribution == .official else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires an official push build mode")
|
||||
"Relay transport requires OpenClawPushDistribution=official")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
|
||||
@@ -683,7 +683,6 @@ struct RootTabs: View {
|
||||
self.updateIdleTimer()
|
||||
self.updateHomeCanvasState()
|
||||
guard newValue == .active else { return }
|
||||
self.maybeRequestLocalNetworkAccess(reason: "scene_active")
|
||||
Task {
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await MainActor.run {
|
||||
@@ -730,10 +729,6 @@ struct RootTabs: View {
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.showOnboarding) { _, newValue in
|
||||
guard !newValue else { return }
|
||||
self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed")
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.selectSidebarDestination(.chat)
|
||||
}
|
||||
@@ -772,9 +767,6 @@ struct RootTabs: View {
|
||||
.fullScreenCover(isPresented: self.$showOnboarding) {
|
||||
OnboardingWizardView(
|
||||
allowSkip: self.onboardingAllowSkip,
|
||||
onRequestLocalNetworkAccess: { reason in
|
||||
self.requestLocalNetworkAccess(reason: reason)
|
||||
},
|
||||
onClose: {
|
||||
self.showOnboarding = false
|
||||
})
|
||||
@@ -1053,14 +1045,13 @@ extension RootTabs {
|
||||
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
||||
switch route {
|
||||
case .none:
|
||||
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
|
||||
break
|
||||
case .onboarding:
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,7 +1078,6 @@ extension RootTabs {
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.maybeRequestLocalNetworkAccess(reason: "auto_open_settings")
|
||||
}
|
||||
|
||||
private func maybeOpenSettingsForGatewaySetup() {
|
||||
@@ -1098,19 +1088,6 @@ extension RootTabs {
|
||||
self.presentedSheet = nil
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.requestLocalNetworkAccess(reason: "gateway_setup_deeplink")
|
||||
}
|
||||
|
||||
private func maybeRequestLocalNetworkAccess(reason: String) {
|
||||
guard self.didEvaluateOnboarding else { return }
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
self.requestLocalNetworkAccess(reason: reason)
|
||||
}
|
||||
|
||||
private func requestLocalNetworkAccess(reason: String) {
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
||||
self.gatewayController.requestLocalNetworkAccess(reason: reason)
|
||||
}
|
||||
|
||||
private func applyInitialChatSessionIfNeeded() {
|
||||
|
||||
@@ -76,7 +76,6 @@ Sources/Permissions/PermissionRequestBridge.swift
|
||||
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||
Sources/Push/BackgroundAliveBeacon.swift
|
||||
Sources/Push/PushBuildConfig.swift
|
||||
Sources/Push/PushEnrollmentConsent.swift
|
||||
Sources/Push/PushRegistrationManager.swift
|
||||
Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
|
||||
@@ -1377,24 +1377,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(center.addCalls == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .authorized
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
PushEnrollmentConsent.reset()
|
||||
defer { PushEnrollmentConsent.reset() }
|
||||
|
||||
#expect(await appModel._test_canPublishAPNsRegistration() == false)
|
||||
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
|
||||
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
center.status = .notDetermined
|
||||
#expect(await appModel._test_canPublishAPNsRegistration() == false)
|
||||
|
||||
center.status = .authorized
|
||||
#expect(await appModel._test_canPublishAPNsRegistration())
|
||||
}
|
||||
|
||||
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .notDetermined
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct PushBuildConfigTests {
|
||||
@Test func `app store mode derives production relay contract`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "appStore",
|
||||
"OpenClawPushRelayBaseURL": "https://wrong.example.com",
|
||||
])
|
||||
|
||||
#expect(config.mode == .appStore)
|
||||
#expect(config.transport == .relay)
|
||||
#expect(config.distribution == .official)
|
||||
#expect(config.relayBaseURL?.absoluteString == "https://ios-push-relay.openclaw.ai")
|
||||
#expect(config.apnsEnvironment == .production)
|
||||
#expect(config.relayProfile == .production)
|
||||
#expect(config.proofPolicy == .appleStrict)
|
||||
}
|
||||
|
||||
@Test func `simulator sandbox mode derives internal proof contract`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "simulatorSandbox",
|
||||
"OpenClawPushRelayBaseURL": "https://staging-relay.example.com",
|
||||
])
|
||||
|
||||
#expect(config.mode == .simulatorSandbox)
|
||||
#expect(config.transport == .relay)
|
||||
#expect(config.distribution == .official)
|
||||
#expect(config.relayBaseURL?.absoluteString == "https://staging-relay.example.com")
|
||||
#expect(config.apnsEnvironment == .sandbox)
|
||||
#expect(config.relayProfile == .simulatorSandbox)
|
||||
#expect(config.proofPolicy == .internalSimulator)
|
||||
}
|
||||
|
||||
@Test func `local release mode remains direct production push`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "localProduction",
|
||||
"OpenClawPushRelayBaseURL": "https://ios-push-relay.openclaw.ai",
|
||||
])
|
||||
|
||||
#expect(config.mode == .localProduction)
|
||||
#expect(config.transport == .direct)
|
||||
#expect(config.distribution == .local)
|
||||
#expect(config.relayBaseURL == nil)
|
||||
#expect(config.apnsEnvironment == .production)
|
||||
#expect(config.relayProfile == .production)
|
||||
#expect(config.proofPolicy == .appleStrict)
|
||||
}
|
||||
}
|
||||
@@ -550,20 +550,6 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func `push enrollment stays behind notification disclosure flow`() throws {
|
||||
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
|
||||
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
|
||||
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
|
||||
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
|
||||
#expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted"))
|
||||
#expect(modelSource.contains("notifications_not_authorized"))
|
||||
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
|
||||
}
|
||||
|
||||
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
@@ -594,7 +580,6 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
|
||||
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
#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."))
|
||||
@@ -611,32 +596,6 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
}
|
||||
|
||||
@Test func `local network access is requested from visible gateway flows`() throws {
|
||||
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true"))
|
||||
#expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)"))
|
||||
#expect(controllerSource.contains("guard self.localNetworkAccessRequested else"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")"))
|
||||
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")"))
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")"))
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"onboarding_dismissed\")"))
|
||||
#expect(rootSource.contains("self.requestLocalNetworkAccess(reason: \"gateway_setup_deeplink\")"))
|
||||
#expect(rootSource.contains("guard self.didEvaluateOnboarding else { return }"))
|
||||
#expect(rootSource.contains("onRequestLocalNetworkAccess: { reason in"))
|
||||
|
||||
#expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")"))
|
||||
#expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")"))
|
||||
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
}
|
||||
|
||||
@Test func `gateway settings preview matrix covers primary states`() throws {
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
|
||||
@@ -827,20 +786,6 @@ struct RootTabsSourceGuardTests {
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func onboardingWizardSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
|
||||
}
|
||||
|
||||
private static func openClawAppSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/OpenClawApp.swift")
|
||||
}
|
||||
|
||||
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
var deviceLanguage = ""
|
||||
var locale = ""
|
||||
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
@@ -32,7 +33,6 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
@@ -52,7 +52,6 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
@@ -60,8 +59,6 @@ open class Snapshot: NSObject {
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
@@ -106,17 +103,17 @@ open class Snapshot: NSObject {
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
if locale.isEmpty && !deviceLanguage.isEmpty {
|
||||
locale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
if !locale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +165,7 @@ open class Snapshot: NSObject {
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
#if os(iOS)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
@@ -184,7 +181,7 @@ open class Snapshot: NSObject {
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
@@ -284,7 +281,6 @@ private extension XCUIElementQuery {
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
@@ -310,4 +306,4 @@ private extension CGFloat {
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.30]
|
||||
// SnapshotHelperVersion [1.27]
|
||||
|
||||
@@ -10,24 +10,7 @@ default_platform(:ios)
|
||||
|
||||
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
|
||||
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
|
||||
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
|
||||
{
|
||||
label: "iPhone",
|
||||
patterns: [
|
||||
/\AiPhone .* Pro Max\z/,
|
||||
/\AiPhone .* Plus\z/,
|
||||
/\AiPhone .*\z/
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "13-inch iPad",
|
||||
patterns: [
|
||||
/\AiPad Pro 13-inch/,
|
||||
/\AiPad Air 13-inch/,
|
||||
/\AiPad .*13-inch/
|
||||
]
|
||||
}
|
||||
].freeze
|
||||
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
|
||||
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
|
||||
@@ -94,23 +77,11 @@ end
|
||||
|
||||
def snapshot_devices
|
||||
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
|
||||
return default_snapshot_devices if raw.empty?
|
||||
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
|
||||
|
||||
raw.split(",").map(&:strip).reject(&:empty?)
|
||||
end
|
||||
|
||||
def default_snapshot_devices
|
||||
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
|
||||
|
||||
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
|
||||
match = family.fetch(:patterns).filter_map do |pattern|
|
||||
names.find { |name| name.match?(pattern) }
|
||||
end.first
|
||||
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
|
||||
match
|
||||
end
|
||||
end
|
||||
|
||||
def watch_snapshot_device
|
||||
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
|
||||
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
|
||||
@@ -142,51 +113,6 @@ def resolve_simulator_device(name)
|
||||
fallback
|
||||
end
|
||||
|
||||
def install_ready_for_review_edit_state_lookup!
|
||||
require "spaceship"
|
||||
|
||||
app_class = Spaceship::ConnectAPI::App
|
||||
app_class.class_eval do
|
||||
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
|
||||
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
|
||||
end
|
||||
|
||||
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
|
||||
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
|
||||
end
|
||||
|
||||
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
|
||||
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
|
||||
return version if version
|
||||
|
||||
# First public releases can leave the only version in READY_FOR_REVIEW.
|
||||
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
|
||||
# second version; use the existing review-ready version as the edit target.
|
||||
client ||= Spaceship::ConnectAPI
|
||||
platform ||= Spaceship::ConnectAPI::Platform::IOS
|
||||
filter = {
|
||||
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
|
||||
platform: platform
|
||||
}
|
||||
|
||||
get_app_store_versions(client: client, filter: filter, includes: includes)
|
||||
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
|
||||
.last
|
||||
end
|
||||
|
||||
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
|
||||
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
|
||||
return app_info if app_info
|
||||
|
||||
client ||= Spaceship::ConnectAPI
|
||||
client
|
||||
.get_app_infos(app_id: id, includes: includes)
|
||||
.to_models
|
||||
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def bundle_identifier_for_product(product_path)
|
||||
info_plist_path = File.join(product_path, "Info.plist")
|
||||
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
|
||||
@@ -284,7 +210,6 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
script = <<~SWIFT
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
let timeText = CommandLine.arguments[2]
|
||||
@@ -296,37 +221,36 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let drawWidth = CGFloat(width)
|
||||
let drawHeight = CGFloat(height)
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let bitmapContext = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
|
||||
let width = CGFloat(cgImage.width)
|
||||
let height = CGFloat(cgImage.height)
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(width),
|
||||
pixelsHigh: Int(height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0),
|
||||
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
else {
|
||||
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
|
||||
bitmap.size = NSSize(width: width, height: height)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = graphicsContext
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
|
||||
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
|
||||
operation: .sourceOver,
|
||||
in: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
from: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
|
||||
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .right
|
||||
@@ -336,26 +260,17 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
timeText.draw(
|
||||
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
|
||||
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
|
||||
withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let output = bitmapContext.makeImage(),
|
||||
let destination = CGImageDestinationCreateWithURL(
|
||||
URL(fileURLWithPath: path) as CFURL,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil)
|
||||
guard let png = bitmap.representation(using: .png, properties: [:])
|
||||
else {
|
||||
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(destination, output, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(5)
|
||||
}
|
||||
try png.write(to: URL(fileURLWithPath: path))
|
||||
SWIFT
|
||||
|
||||
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
|
||||
@@ -839,11 +754,6 @@ def prepare_app_store_release!(version:, build_number:)
|
||||
release_xcconfig
|
||||
end
|
||||
|
||||
def validate_app_store_ipa!(ipa_path)
|
||||
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
|
||||
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
|
||||
end
|
||||
|
||||
def build_app_store_release(context)
|
||||
version = context[:version]
|
||||
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
|
||||
@@ -894,7 +804,6 @@ def build_app_store_release(context)
|
||||
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
||||
exported_ipa = exported_ipas.first
|
||||
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
||||
validate_app_store_ipa!(expected_ipa_path)
|
||||
|
||||
{
|
||||
archive_path: archive_path,
|
||||
@@ -1014,12 +923,25 @@ platform :ios do
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Build + upload an App Store distribution build to App Store Connect"
|
||||
lane :app_store do
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
ipa: build[:ipa_path],
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
||||
lane :release_upload do
|
||||
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
|
||||
end
|
||||
|
||||
release_signing_check!
|
||||
preserve_local_signing do
|
||||
screenshots
|
||||
@@ -1046,7 +968,6 @@ platform :ios do
|
||||
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
install_ready_for_review_edit_state_lookup!
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
api_key = app_store_connect_api_key_config
|
||||
|
||||
@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
|
||||
pnpm ios:screenshots
|
||||
```
|
||||
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
|
||||
Upload to App Store Connect:
|
||||
|
||||
@@ -112,9 +112,12 @@ Upload to App Store Connect:
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
Direct Fastlane TestFlight upload is disabled. Use the package script so the
|
||||
release wrapper, App Store push mode, and exported-IPA validation gate all run
|
||||
in the same path.
|
||||
Direct Fastlane entry point:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios release_upload
|
||||
```
|
||||
|
||||
Maintainer recovery path for a fresh clone on the same Mac:
|
||||
|
||||
@@ -141,7 +144,13 @@ fastlane ios auth_check
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Upload:
|
||||
5. Set the official relay URL before release:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
6. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
@@ -150,7 +159,6 @@ pnpm ios:release:upload
|
||||
Quick verification after upload:
|
||||
|
||||
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane validates the exported IPA before upload
|
||||
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
|
||||
|
||||
@@ -167,7 +175,5 @@ Versioning rules:
|
||||
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
|
||||
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
|
||||
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
||||
@@ -2,9 +2,10 @@ project("OpenClaw.xcodeproj")
|
||||
scheme("OpenClawUITests")
|
||||
configuration("Debug")
|
||||
|
||||
# The Fastfile screenshot lane resolves concrete device names from the installed
|
||||
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
|
||||
# this file intentionally does not hardcode simulator model names.
|
||||
devices([
|
||||
"iPhone 16 Pro Max",
|
||||
"iPad Pro 13-inch (M4)",
|
||||
])
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
|
||||
@@ -122,13 +122,21 @@ targets:
|
||||
Debug:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_MODE: localSandbox
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
|
||||
Release:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_MODE: localProduction
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: production
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
@@ -170,8 +178,12 @@ targets:
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for talk mode and voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
OpenClawPushMode: "$(OPENCLAW_PUSH_MODE)"
|
||||
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
|
||||
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
|
||||
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bundled A2UI runtime resource embedded by OpenClawKit.
|
||||
var __defProp$1 = Object.defineProperty;
|
||||
var __exportAll = (all, no_symbols) => {
|
||||
let target = {};
|
||||
@@ -11935,10 +11936,6 @@ var __runInitializers = function(thisArg, initializers, value) {
|
||||
};
|
||||
return _classThis;
|
||||
})();
|
||||
/**
|
||||
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
|
||||
* helpers.
|
||||
*/
|
||||
const modalStyles = i$10`
|
||||
dialog {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
|
||||
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
|
||||
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
|
||||
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -579,7 +579,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Read receipts and typing">
|
||||
When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with:
|
||||
When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -155,7 +155,6 @@ Notes:
|
||||
|
||||
- `onchar` still responds to explicit @mentions.
|
||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
|
||||
|
||||
## Threading and sessions
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
commandLevel: "all",
|
||||
historyLimit: 50,
|
||||
tools: { deny: ["exec", "read", "write"] },
|
||||
},
|
||||
@@ -159,7 +158,6 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
name: "Release room",
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
commandLevel: "safety",
|
||||
historyLimit: 20,
|
||||
prompt: "Keep replies short and operational.",
|
||||
},
|
||||
@@ -174,9 +172,6 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
settings include:
|
||||
|
||||
- `requireMention`: require an @mention before the bot replies. Default: `true`.
|
||||
- `commandLevel`: control which built-in slash commands can run in groups.
|
||||
Default: `all`, which preserves the pre-existing QQBot group behavior when the
|
||||
setting is omitted.
|
||||
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
|
||||
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
|
||||
- `tools`: allow/deny tools for the whole group.
|
||||
@@ -184,17 +179,6 @@ settings include:
|
||||
- `name`: friendly label used in logs and group context.
|
||||
- `prompt`: per-group behavior prompt appended to the agent context.
|
||||
|
||||
`commandLevel` accepts:
|
||||
|
||||
- `all`: keep recognized built-in commands available as before. Some commands may
|
||||
stay hidden from menus, but authorized users can still run them in the group.
|
||||
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
|
||||
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
|
||||
`/bash` in private chat.
|
||||
- `strict`: only allow the group-session controls needed for strict group
|
||||
operation. `/stop` still stays urgent so an authorized sender can interrupt an
|
||||
active run.
|
||||
|
||||
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
|
||||
|
||||
Activation modes are `mention` and `always`. `requireMention: true` maps to
|
||||
|
||||
12
docs/ci.md
12
docs/ci.md
@@ -42,7 +42,6 @@ or an explicit manual dispatch.
|
||||
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
|
||||
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
|
||||
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
|
||||
| `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes |
|
||||
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
|
||||
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
|
||||
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
|
||||
@@ -53,7 +52,7 @@ or an explicit manual dispatch.
|
||||
2. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
|
||||
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
|
||||
4. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
|
||||
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
|
||||
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
|
||||
|
||||
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
|
||||
|
||||
@@ -81,7 +80,7 @@ When the check fails, update the PR body instead of pushing another code commit.
|
||||
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
|
||||
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, iOS, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
|
||||
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
|
||||
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
|
||||
@@ -121,7 +120,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
|
||||
|
||||
## Manual dispatches
|
||||
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
|
||||
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref.
|
||||
|
||||
@@ -141,7 +140,7 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
|
||||
|
||||
@@ -163,7 +162,6 @@ pnpm test:channels
|
||||
pnpm test:contracts:channels
|
||||
pnpm check:docs # docs format + lint + broken links
|
||||
pnpm build # build dist when CI artifact/smoke checks matter
|
||||
pnpm ios:build # generate and build the iOS app project
|
||||
pnpm ci:timings # summarize the latest origin/main push CI run
|
||||
pnpm ci:timings:recent # compare recent successful main CI runs
|
||||
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs
|
||||
@@ -200,7 +198,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
|
||||
|
||||
## Full Release Validation
|
||||
|
||||
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, maturity scorecard rendering from QA profile evidence, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
|
||||
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
|
||||
|
||||
See [Full release validation](/reference/full-release-validation) for the
|
||||
stage matrix, exact workflow job names, profile differences, artifacts, and
|
||||
|
||||
@@ -23,9 +23,9 @@ OpenClaw agent or Gateway.
|
||||
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills verify @owner/<slug>
|
||||
openclaw skills install <slug>
|
||||
openclaw skills update <slug>
|
||||
openclaw skills verify <slug>
|
||||
|
||||
openclaw plugins search "calendar"
|
||||
openclaw plugins install clawhub:<package>
|
||||
|
||||
@@ -24,13 +24,13 @@ where you have publisher access.
|
||||
Skills are published from a skill folder. The public page is:
|
||||
|
||||
```text
|
||||
https://clawhub.ai/<owner>/skills/<slug>
|
||||
https://clawhub.ai/<owner>/<slug>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://clawhub.ai/alice/skills/review-helper
|
||||
https://clawhub.ai/alice/review-helper
|
||||
```
|
||||
|
||||
The publish request includes the selected owner, slug, version, changelog, and
|
||||
|
||||
@@ -25,24 +25,24 @@ Related:
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills search --limit 20 --json
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills install @owner/<slug> --version <version>
|
||||
openclaw skills install <slug>
|
||||
openclaw skills install <slug> --version <version>
|
||||
openclaw skills install git:owner/repo
|
||||
openclaw skills install git:owner/repo@main
|
||||
openclaw skills install ./path/to/skill --as custom-name
|
||||
openclaw skills install @owner/<slug> --force
|
||||
openclaw skills install @owner/<slug> --agent <id>
|
||||
openclaw skills install @owner/<slug> --global
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills update @owner/<slug> --global
|
||||
openclaw skills install <slug> --force
|
||||
openclaw skills install <slug> --agent <id>
|
||||
openclaw skills install <slug> --global
|
||||
openclaw skills update <slug>
|
||||
openclaw skills update <slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --agent <id>
|
||||
openclaw skills update --all --global
|
||||
openclaw skills verify @owner/<slug>
|
||||
openclaw skills verify @owner/<slug> --version <version>
|
||||
openclaw skills verify @owner/<slug> --tag <tag>
|
||||
openclaw skills verify @owner/<slug> --card
|
||||
openclaw skills verify @owner/<slug> --global
|
||||
openclaw skills verify <slug>
|
||||
openclaw skills verify <slug> --version <version>
|
||||
openclaw skills verify <slug> --tag <tag>
|
||||
openclaw skills verify <slug> --card
|
||||
openclaw skills verify <slug> --global
|
||||
openclaw skills list
|
||||
openclaw skills list --eligible
|
||||
openclaw skills list --json
|
||||
@@ -64,8 +64,8 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
|
||||
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
|
||||
```
|
||||
|
||||
`search`, `update`, and `verify` use ClawHub directly. `install @owner/<slug>`
|
||||
installs a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
|
||||
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
|
||||
a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
|
||||
`install ./path` copies a local skill directory. By default, `install`, `update`,
|
||||
and `verify` target the active workspace `skills/` directory; with `--global`,
|
||||
they target the shared managed skills directory. `list`/`info`/`check` still
|
||||
@@ -94,22 +94,19 @@ Notes:
|
||||
`SKILL.md`.
|
||||
- `install --as <slug>` overrides the inferred slug for Git and local directory
|
||||
installs.
|
||||
- `install --version <version>` applies only to ClawHub skill refs.
|
||||
- `install --version <version>` applies only to ClawHub skill slugs.
|
||||
- `install --force` overwrites an existing workspace skill folder for the same
|
||||
slug.
|
||||
- `--global` targets the shared managed skills directory and cannot be combined
|
||||
with `--agent <id>`.
|
||||
- `--agent <id>` targets one configured agent workspace and overrides current
|
||||
working directory inference.
|
||||
- `update @owner/<slug>` updates a single tracked skill. Add `--global` to
|
||||
target the shared managed skills directory instead of the workspace.
|
||||
- `update <slug>` updates a single tracked skill. Add `--global` to target the
|
||||
shared managed skills directory instead of the workspace.
|
||||
- `update --all` updates tracked ClawHub installs in the selected workspace, or
|
||||
in the shared managed skills directory when combined with `--global`.
|
||||
- `verify @owner/<slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON
|
||||
envelope by default. There is no `--json` flag because JSON is already the
|
||||
default. Bare slugs remain accepted for compatibility when the skill is
|
||||
already installed or unambiguous, but owner-qualified refs avoid publisher
|
||||
ambiguity.
|
||||
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
|
||||
default. There is no `--json` flag because JSON is already the default.
|
||||
- When ClawHub returns server-resolved source provenance, verify JSON also
|
||||
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
|
||||
self-declared source URLs stay only in the raw provenance envelope and are not
|
||||
|
||||
@@ -68,14 +68,6 @@
|
||||
"source": "/reference/openclaw-sdk-api-design",
|
||||
"destination": "/gateway/external-apps"
|
||||
},
|
||||
{
|
||||
"source": "/reference/maturity-scorecard",
|
||||
"destination": "/maturity/scorecard"
|
||||
},
|
||||
{
|
||||
"source": "/reference/maturity-taxonomy",
|
||||
"destination": "/maturity/taxonomy"
|
||||
},
|
||||
{
|
||||
"source": "/mcp",
|
||||
"destination": "/cli/mcp"
|
||||
@@ -1860,8 +1852,6 @@
|
||||
{
|
||||
"group": "Release and CI",
|
||||
"pages": [
|
||||
"maturity/scorecard",
|
||||
"maturity/taxonomy",
|
||||
"reference/RELEASING",
|
||||
"reference/full-release-validation",
|
||||
"reference/release-performance-sweep",
|
||||
|
||||
@@ -602,7 +602,7 @@ See [Inferred commitments](/concepts/commitments).
|
||||
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts.
|
||||
- `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port.
|
||||
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
|
||||
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used after relay-backed iOS builds publish registrations to the gateway. Public App Store/TestFlight builds use the hosted OpenClaw relay. Custom relay URLs must match a deliberately separate iOS build/deployment path whose relay URL points at that relay.
|
||||
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build.
|
||||
- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
|
||||
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
|
||||
|
||||
@@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Enable relay-backed push for official iOS builds">
|
||||
Relay-backed push for public App Store/TestFlight builds uses the hosted OpenClaw relay: `https://ios-push-relay.openclaw.ai`.
|
||||
Relay-backed push uses the hosted OpenClaw relay by default: `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. If you are using a custom relay build, set this in gateway config:
|
||||
To use a custom relay, set this in gateway config:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -369,12 +369,12 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
- Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
|
||||
- Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
|
||||
- Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
|
||||
- Must match the relay base URL baked into the iOS build, so registration and send traffic reach the same relay deployment.
|
||||
- Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
|
||||
|
||||
End-to-end flow:
|
||||
|
||||
1. Install an official/TestFlight iOS build.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
3. Pair the iOS app to the gateway and let both node and operator sessions connect.
|
||||
4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
|
||||
5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
|
||||
@@ -387,7 +387,7 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the iOS build. The public App Store release lane rejects custom iOS relay URL overrides.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the official/TestFlight iOS build.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
|
||||
|
||||
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
|
||||
|
||||
@@ -346,10 +346,10 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills search --limit 20
|
||||
openclaw skills install @owner/<skill-slug>
|
||||
openclaw skills install @owner/<skill-slug> --version <version>
|
||||
openclaw skills install @owner/<skill-slug> --force
|
||||
openclaw skills install @owner/<skill-slug> --global
|
||||
openclaw skills install <skill-slug>
|
||||
openclaw skills install <skill-slug> --version <version>
|
||||
openclaw skills install <skill-slug> --force
|
||||
openclaw skills install <skill-slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --global
|
||||
openclaw skills list --eligible
|
||||
@@ -433,11 +433,11 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
Install skills:
|
||||
|
||||
```bash
|
||||
openclaw skills install @owner/<skill-slug>
|
||||
openclaw skills install <skill-slug>
|
||||
openclaw skills update --all
|
||||
```
|
||||
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install @owner/<skill-slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install <slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -110,18 +110,14 @@ systemctl --user daemon-reload
|
||||
### Windows (Scheduled Task)
|
||||
|
||||
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
|
||||
The task script lives under your state dir as `gateway.cmd`; current installs may
|
||||
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
|
||||
of opening `gateway.cmd` directly.
|
||||
The task script lives under your state dir.
|
||||
|
||||
```powershell
|
||||
schtasks /Delete /F /TN "OpenClaw Gateway"
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
|
||||
```
|
||||
|
||||
If you used a profile, delete the matching task name and the `gateway.cmd` /
|
||||
`gateway.vbs` files under `~\.openclaw-<profile>`.
|
||||
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
|
||||
|
||||
## Normal install vs source checkout
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -75,9 +75,9 @@ openclaw gateway call node.list --params "{}"
|
||||
Official distributed iOS builds use the external push relay instead of publishing the raw APNs
|
||||
token to the gateway.
|
||||
|
||||
Official/TestFlight builds from the public App Store release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
By default, official/TestFlight builds and gateways use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. The public App Store release lane does not accept custom relay URL overrides. If you are using a custom relay build, set the matching gateway relay URL:
|
||||
Custom relay deployments can override the gateway relay URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -100,7 +100,7 @@ How the flow works:
|
||||
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
|
||||
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
|
||||
- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the iOS build.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the official/TestFlight iOS build.
|
||||
- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
|
||||
|
||||
What the gateway does **not** need for this path:
|
||||
@@ -111,7 +111,7 @@ What the gateway does **not** need for this path:
|
||||
Expected operator flow:
|
||||
|
||||
1. Install the official/TestFlight iOS build.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
3. Pair the app to the gateway and let it finish connecting.
|
||||
4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds.
|
||||
5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration.
|
||||
@@ -130,7 +130,7 @@ compatible but does not count as a durable last-seen update.
|
||||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
|
||||
- The public App Store release lane rejects `OPENCLAW_PUSH_RELAY_BASE_URL` for iOS builds.
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL` still works as a temporary env override for official/TestFlight iOS builds.
|
||||
|
||||
## Authentication and trust flow
|
||||
|
||||
|
||||
@@ -124,11 +124,8 @@ openclaw gateway status --json
|
||||
```
|
||||
|
||||
Native Windows CLI and Gateway flows are supported and continue to improve.
|
||||
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
|
||||
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
|
||||
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
|
||||
a visible console window. If task creation is denied, OpenClaw falls back to a
|
||||
per-user Startup-folder login item.
|
||||
Managed startup uses Windows Scheduled Tasks when available and falls back to a
|
||||
per-user Startup-folder login item if task creation is denied.
|
||||
|
||||
To install the Gateway service:
|
||||
|
||||
|
||||
@@ -259,10 +259,14 @@ under `describe("runSideQuestion")`.
|
||||
fallback for whatever runtimes do not have a peer surface.
|
||||
- PI session state is not migrated when an agent switches to `copilot`.
|
||||
Selection is per attempt; existing PI sessions remain valid.
|
||||
- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex
|
||||
harness. When the Copilot SDK asks for user input, OpenClaw posts a
|
||||
blocking prompt to the active channel/TUI and the next queued user
|
||||
message resolves the SDK request.
|
||||
- **Interactive `ask_user` is not yet wired.** The SDK's
|
||||
`onUserInputRequest` handler is intentionally not registered, which
|
||||
per the SDK contract hides the `ask_user` tool from the model
|
||||
entirely. Agents running under this harness make best-judgment
|
||||
decisions from the initial prompt rather than asking clarifying
|
||||
questions mid-turn. A follow-up will port the codex pattern at
|
||||
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
|
||||
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
|
||||
|
||||
## Permissions and ask_user
|
||||
|
||||
@@ -324,15 +328,11 @@ the tool bridge. The bridge also forwards the bounded tool-construction
|
||||
controls it can enforce at the SDK boundary: `includeCoreTools`, the
|
||||
runtime tool allowlist, and `toolConstructionPlan`.
|
||||
|
||||
The bridge also uses the shared harness tool-surface helper from
|
||||
`openclaw/plugin-sdk/agent-harness-tool-runtime` for PI parity. When
|
||||
tool-search is enabled, the SDK sees compact control tools plus a hidden
|
||||
catalog executor instead of every OpenClaw tool schema. When code mode is
|
||||
enabled, the helper builds the same code-mode control surface and catalog
|
||||
lifecycle used by other agent harnesses. Local-model lean defaults,
|
||||
runtime-compatible schema filtering, directory hydration, and catalog
|
||||
cleanup all stay in the shared helper so Copilot and Codex-adjacent
|
||||
harnesses do not drift.
|
||||
The remaining PI tool-search/code-mode fields are intentionally **not**
|
||||
forwarded at MVP and tracked as follow-ups: `toolSearchCatalogRef`,
|
||||
`includeToolSearchControls`, and `toolSearchCatalogExecutor`. Those
|
||||
controls drive PI's native tool-search UI and have no direct Copilot SDK
|
||||
analog yet.
|
||||
|
||||
### Session-level GitHub token
|
||||
|
||||
@@ -349,10 +349,7 @@ When the resolved mode is `useLoggedInUser`, the session-level field
|
||||
is omitted so the SDK keeps deriving identity from the logged-in
|
||||
identity.
|
||||
|
||||
`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts
|
||||
choice indexes or labels for fixed-choice requests, accepts free-form
|
||||
answers when the SDK request allows them, and cancels a pending request
|
||||
when the OpenClaw attempt is aborted.
|
||||
`ask_user` is intentionally hidden — see Limitations above.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -191,7 +191,6 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
|
||||
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
|
||||
| `name` | No | `string` | Human-readable plugin name. |
|
||||
| `description` | No | `string` | Short summary shown in plugin surfaces. |
|
||||
| `icon` | No | `string` | HTTPS image URL for marketplace/catalog cards. ClawHub accepts any valid `https://` URL and falls back to the default plugin icon when this is omitted or invalid. |
|
||||
| `version` | No | `string` | Informational plugin version. |
|
||||
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
|
||||
|
||||
|
||||
@@ -196,23 +196,6 @@ finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### User input and tool surfaces
|
||||
|
||||
Native harnesses that expose a runtime-level user-input request should use the
|
||||
user-input helpers from `openclaw/plugin-sdk/agent-harness-runtime` to format
|
||||
the prompt, deliver it through OpenClaw's blocking reply path, and normalize
|
||||
choice/free-form answers back into the runtime's native response shape. The
|
||||
helper keeps channel/TUI presentation consistent while each harness keeps its
|
||||
own protocol parsing and pending-request lifecycle.
|
||||
|
||||
Native harnesses that need PI-like compact tool routing should use
|
||||
`createAgentHarnessToolSurfaceRuntime(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-tool-runtime`. It owns
|
||||
tool-search/code-mode control selection, local-model lean defaults,
|
||||
runtime-compatible schema filtering, hidden catalog execution, directory
|
||||
hydration, and catalog cleanup. Harnesses still own their SDK-specific tool
|
||||
conversion and native execution callback.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -84,8 +84,8 @@ Choose the Token Plan auth choice that matches the regional base URL shown in Xi
|
||||
|
||||
| Model ref | Input | Context | Max output | Reasoning | Notes |
|
||||
| --------------------------------- | ----------- | --------- | ---------- | --------- | ------------- |
|
||||
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 131,072 | Yes | Default model |
|
||||
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 131,072 | Yes | Multimodal |
|
||||
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 32,000 | Yes | Default model |
|
||||
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 32,000 | Yes | Multimodal |
|
||||
|
||||
<Tip>
|
||||
Token Plan onboarding validates the key shape and warns when a `tp-...` key is entered into the pay-as-you-go path, or an `sk-...` key is entered into the Token Plan path.
|
||||
@@ -222,7 +222,7 @@ Token Plan:
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 131072,
|
||||
maxTokens: 32000,
|
||||
},
|
||||
{
|
||||
id: "mimo-v2.5",
|
||||
@@ -230,7 +230,7 @@ Token Plan:
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 131072,
|
||||
maxTokens: 32000,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ Wraps papla.media TTS and sends results as Telegram voice notes (no annoying aut
|
||||
<img src="/assets/showcase/papla-tts.jpg" alt="Telegram voice note output from TTS" />
|
||||
</Card>
|
||||
|
||||
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/skills/codexmonitor">
|
||||
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/codexmonitor">
|
||||
**@odrobnik** • `devtools` `codex` `brew`
|
||||
|
||||
Homebrew-installed helper to list, inspect, and watch local OpenAI Codex sessions (CLI + VS Code).
|
||||
@@ -75,7 +75,7 @@ Homebrew-installed helper to list, inspect, and watch local OpenAI Codex session
|
||||
<img src="/assets/showcase/codexmonitor.png" alt="CodexMonitor on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/skills/bambu-cli">
|
||||
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/bambu-cli">
|
||||
**@tobiasbischoff** • `hardware` `3d-printing` `skill`
|
||||
|
||||
Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibration, and more.
|
||||
@@ -83,7 +83,7 @@ Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibrati
|
||||
<img src="/assets/showcase/bambu-cli.png" alt="Bambu CLI skill on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/skills/wienerlinien">
|
||||
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/wienerlinien">
|
||||
**@hjanuschka** • `travel` `transport` `skill`
|
||||
|
||||
Real-time departures, disruptions, elevator status, and routing for Vienna's public transport.
|
||||
@@ -97,7 +97,7 @@ Real-time departures, disruptions, elevator status, and routing for Vienna's pub
|
||||
Automated UK school meal booking via ParentPay. Uses mouse coordinates for reliable table cell clicking.
|
||||
</Card>
|
||||
|
||||
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/julianengel/skills/r2-upload">
|
||||
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/skills/r2-upload">
|
||||
**@julianengel** • `files` `r2` `presigned-urls`
|
||||
|
||||
Upload to Cloudflare R2/S3 and generate secure presigned download links. Useful for remote OpenClaw instances.
|
||||
@@ -267,7 +267,7 @@ Speech-first entry points, phone bridges, and transcription-heavy workflows.
|
||||
Vapi voice assistant to OpenClaw HTTP bridge. Near real-time phone calls with your agent.
|
||||
</Card>
|
||||
|
||||
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/skills/openrouter-transcribe">
|
||||
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/openrouter-transcribe">
|
||||
**@obviyus** • `transcription` `multilingual` `skill`
|
||||
|
||||
Multi-lingual audio transcription via OpenRouter (Gemini, and more). Available on ClawHub.
|
||||
@@ -289,8 +289,8 @@ Packaging, deployment, and integrations that make OpenClaw easier to run and ext
|
||||
OpenClaw gateway running on Home Assistant OS with SSH tunnel support and persistent state.
|
||||
</Card>
|
||||
|
||||
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/homeofe/skills/openclaw-homeassistant">
|
||||
**@homeofe** • `homeassistant` `skill` `automation`
|
||||
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/skills/homeassistant">
|
||||
**ClawHub** • `homeassistant` `skill` `automation`
|
||||
|
||||
Control and automate Home Assistant devices via natural language.
|
||||
|
||||
@@ -303,8 +303,8 @@ Control and automate Home Assistant devices via natural language.
|
||||
Batteries-included nixified OpenClaw configuration for reproducible deployments.
|
||||
</Card>
|
||||
|
||||
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/asleep123/skills/caldav-calendar">
|
||||
**@asleep123** • `calendar` `caldav` `skill`
|
||||
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/skills/caldav-calendar">
|
||||
**ClawHub** • `calendar` `caldav` `skill`
|
||||
|
||||
Calendar skill using khal and vdirsyncer. Self-hosted calendar integration.
|
||||
|
||||
|
||||
750
docs/style.css
750
docs/style.css
@@ -135,753 +135,3 @@ html.dark .nav-tabs-underline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.maturity-hero {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 10px 0 38px;
|
||||
padding: 4px 0 26px 20px;
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 22%, transparent);
|
||||
border-left: 3px solid rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-hero-compact {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.maturity-hero h2 {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maturity-hero-title {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
font-weight: 750;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maturity-hero > p:not(.maturity-kicker):not(.maturity-jump-links) {
|
||||
max-width: 58rem;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.maturity-kicker {
|
||||
margin: 0;
|
||||
color: rgb(var(--primary));
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-jump-links {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.maturity-jump-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-jump-links a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.maturity-score-stable,
|
||||
.maturity-band-stable {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-score-beta,
|
||||
.maturity-band-beta {
|
||||
color: #849fd2;
|
||||
}
|
||||
|
||||
.maturity-score-alpha,
|
||||
.maturity-band-alpha {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-score-experimental,
|
||||
.maturity-band-experimental {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-score-clawesome,
|
||||
.maturity-band-clawesome {
|
||||
color: #46b59a;
|
||||
}
|
||||
|
||||
.maturity-level-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in oklab, currentColor 32%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, currentColor 10%, transparent);
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-level-code {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.maturity-level-experimental {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-level-alpha {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-level-beta {
|
||||
color: #849fd2;
|
||||
}
|
||||
|
||||
.maturity-level-stable {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-level-clawesome {
|
||||
color: #46b59a;
|
||||
}
|
||||
|
||||
.maturity-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 20px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 18px 20px 18px 0;
|
||||
}
|
||||
|
||||
.maturity-summary-item + .maturity-summary-item {
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.maturity-summary-value {
|
||||
display: inline-block;
|
||||
font-size: 30px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.maturity-summary-heading > span:not(.maturity-summary-value) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-summary-bar {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-bar span {
|
||||
display: block;
|
||||
width: calc(var(--score) * 1%);
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.maturity-summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maturity-summary-meta span:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-summary-meta span:last-child {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-band-list {
|
||||
display: flex;
|
||||
margin: 12px 0 30px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-band {
|
||||
display: grid;
|
||||
flex: 1 1 0;
|
||||
gap: 3px;
|
||||
padding: 10px 12px 11px 0;
|
||||
}
|
||||
|
||||
.maturity-band + .maturity-band {
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-band-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-band-title + span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band > span:last-child {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band .maturity-level-pill {
|
||||
font-size: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-band .maturity-level-pill span {
|
||||
font-size: inherit;
|
||||
opacity: inherit;
|
||||
}
|
||||
|
||||
.maturity-score {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-score-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.maturity-score-label > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-score-label > span:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.maturity-score-label .maturity-level-pill {
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.maturity-score-label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-summary-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-meter {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, currentColor 15%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.maturity-meter > span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.maturity-score-unscored,
|
||||
.maturity-lts-none {
|
||||
color: inherit;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.maturity-surface-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 8px 0 22px;
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.55fr) repeat(3, minmax(110px, 1fr)) minmax(72px, 0.55fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-row-header {
|
||||
padding: 0 0 9px;
|
||||
border-top: 0;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-surface-name {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
border-bottom: 0 !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-surface-name:hover .maturity-surface-title {
|
||||
color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-surface-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.maturity-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-surface-meta > span:not(.maturity-level-pill) {
|
||||
font-size: 11px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-surface-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-surface-metric {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-surface-metric-label {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-surface-support {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.maturity-lts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-lts::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.maturity-lts-partial {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-lts-full {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 24px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-evidence-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px 14px 0;
|
||||
}
|
||||
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-evidence-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-evidence-card span {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-readiness-summary {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.maturity-readiness-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-readiness-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 10%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.maturity-readiness-row-header {
|
||||
padding: 8px 0;
|
||||
border-bottom-color: color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-readiness-area {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-readiness-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-readiness-status {
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-ready {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-partially-reviewed {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-needs-review {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-category-list {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maturity-category-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.55fr) repeat(3, minmax(100px, 1fr)) minmax(140px, 1.2fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.maturity-category-row-header {
|
||||
padding: 8px 0;
|
||||
border-top: 0;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-category-area {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-category-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-category-area > span:last-child {
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-category-docs {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maturity-category-docs a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-category-docs a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.maturity-level-list {
|
||||
display: grid;
|
||||
margin: 12px 0 28px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-level-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.32fr) minmax(0, 1fr);
|
||||
gap: 4px 14px;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
|
||||
}
|
||||
|
||||
.maturity-level-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.maturity-level-title {
|
||||
grid-row: span 2;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-level-title .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-level-row span,
|
||||
.maturity-level-promotion {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-surface-link {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-surface-link:hover {
|
||||
color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-surface-link .maturity-surface-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta > span:not(.maturity-level-pill) {
|
||||
font-size: 11px;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-surface-rollup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 14px;
|
||||
margin: 0 0 14px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-rollup > span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#content table .maturity-score,
|
||||
#content table .maturity-lts {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.maturity-summary-grid,
|
||||
.maturity-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.maturity-summary-item,
|
||||
.maturity-summary-item + .maturity-summary-item,
|
||||
.maturity-evidence-card,
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
padding: 14px 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.maturity-summary-item + .maturity-summary-item,
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(98px, 1fr)) minmax(70px, 0.5fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.maturity-category-row {
|
||||
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(86px, 1fr)) minmax(110px, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.maturity-hero {
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.maturity-surface-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 9px 12px;
|
||||
}
|
||||
|
||||
.maturity-surface-metric {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maturity-surface-metric-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.maturity-surface-support {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.maturity-readiness-row,
|
||||
.maturity-category-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.maturity-readiness-row-header,
|
||||
.maturity-category-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maturity-category-docs {
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.maturity-band-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.maturity-band + .maturity-band {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.maturity-level-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.maturity-level-title {
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ See [Skill Workshop](/tools/skill-workshop) for the full proposal lifecycle.
|
||||
metadata:
|
||||
|
||||
```bash
|
||||
openclaw skills install @openclaw/clawhub-publish
|
||||
openclaw skills install clawhub-publish
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -88,9 +88,8 @@ still returns one synthesized answer with citations rather than an N-result
|
||||
list.
|
||||
|
||||
`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts
|
||||
`pd`, `pw`, `pm`, and `py`. `day`/`pd` adds a recency instruction to the Gemini
|
||||
query instead of a hard 24-hour range. `week`, `month`, `year`, and explicit
|
||||
`date_after`/`date_before` ranges set Gemini Google Search grounding's
|
||||
`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit
|
||||
`date_after`/`date_before` range, into Gemini Google Search grounding's
|
||||
`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported.
|
||||
|
||||
## Model selection
|
||||
|
||||
@@ -145,15 +145,15 @@ publish and sync.
|
||||
|
||||
| Action | Command |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| Install a skill into the workspace | `openclaw skills install @owner/<slug>` |
|
||||
| Install a skill into the workspace | `openclaw skills install <slug>` |
|
||||
| Install from a Git repository | `openclaw skills install git:owner/repo@ref` |
|
||||
| Install a local skill directory | `openclaw skills install ./path/to/skill --as my-tool` |
|
||||
| Install for all local agents | `openclaw skills install @owner/<slug> --global` |
|
||||
| Install for all local agents | `openclaw skills install <slug> --global` |
|
||||
| Update all workspace skills | `openclaw skills update --all` |
|
||||
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
|
||||
| Update a shared managed skill | `openclaw skills update <slug> --global` |
|
||||
| Update all shared managed skills | `openclaw skills update --all --global` |
|
||||
| Verify a skill's trust envelope | `openclaw skills verify @owner/<slug>` |
|
||||
| Print the generated Skill Card | `openclaw skills verify @owner/<slug> --card` |
|
||||
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
|
||||
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
|
||||
| Publish / sync via ClawHub CLI | `clawhub sync --all` |
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -171,17 +171,15 @@ publish and sync.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Verification and security scanning">
|
||||
`openclaw skills verify @owner/<slug>` asks ClawHub for the skill's
|
||||
`openclaw skills verify <slug>` asks ClawHub for the skill's
|
||||
`clawhub.skill.verify.v1` trust envelope. Installed ClawHub skills verify
|
||||
against the version and registry recorded in `.clawhub/origin.json`.
|
||||
Bare slugs remain accepted for existing installed or unambiguous skills, but
|
||||
owner-qualified refs avoid publisher ambiguity.
|
||||
|
||||
ClawHub skill pages expose the latest security scan state before install,
|
||||
with detail pages for VirusTotal, ClawScan, and static analysis. The
|
||||
command exits non-zero when ClawHub marks verification as failed. Publishers
|
||||
recover false positives through the ClawHub dashboard or
|
||||
`clawhub skill rescan @owner/<slug>`.
|
||||
`clawhub skill rescan <slug>`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Private archive installs">
|
||||
|
||||
@@ -389,8 +389,8 @@ show the `x_search` prompt.
|
||||
freshness ranges require both start and end dates.
|
||||
Gemini, Grok, and Kimi return one synthesized answer with citations. They
|
||||
accept `count` for shared-tool compatibility, but it does not change the
|
||||
grounded answer shape. Gemini treats `day` freshness as a recency hint; wider
|
||||
freshness values and explicit dates set Google Search grounding time ranges.
|
||||
grounded answer shape. Gemini supports `freshness`, `date_after`, and
|
||||
`date_before` by converting them to Google Search grounding time ranges.
|
||||
Perplexity behaves the same way when you use the Sonar/OpenRouter
|
||||
compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` /
|
||||
`model` or `OPENROUTER_API_KEY`).
|
||||
|
||||
60
extensions/acpx/npm-shrinkwrap.json
generated
60
extensions/acpx/npm-shrinkwrap.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"acpx": "0.11.2",
|
||||
"acpx": "0.10.0",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
@@ -196,9 +196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
|
||||
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
|
||||
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
@@ -209,12 +209,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
|
||||
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
|
||||
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.3.1",
|
||||
"@clack/core": "1.4.1",
|
||||
"fast-string-width": "^3.0.2",
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
@@ -701,7 +701,6 @@
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz",
|
||||
"integrity": "sha512-eAv7sGBeiYrYkOulF729nrM51szS7WIhBtugRj5wWq6csRKZUhAZfoUZlF8xUWdHPtOIzd/eT6MNG6gMHu6z0w==",
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex-acp": "bin/codex-acp.js"
|
||||
@@ -722,7 +721,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -739,7 +737,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -756,7 +753,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -773,7 +769,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -790,7 +785,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -807,7 +801,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -831,15 +824,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acpx": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
|
||||
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
|
||||
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.28.1",
|
||||
"commander": "^15.0.0",
|
||||
"skillflag": "^0.2.0",
|
||||
"tsx": "^4.22.4",
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"commander": "^14.0.3",
|
||||
"skillflag": "^0.1.4",
|
||||
"tsx": "^4.22.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -849,15 +842,6 @@
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
|
||||
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -1059,12 +1043,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
@@ -2061,9 +2045,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/skillflag": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
|
||||
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
|
||||
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.1",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"acpx": "0.11.2",
|
||||
"acpx": "0.10.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -251,15 +251,6 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(wrapper).not.toMatch(
|
||||
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
|
||||
);
|
||||
// Orphan detection must trigger on any PPID change, not only when the new
|
||||
// PPID is init (1). Systemd user services and container init reparent
|
||||
// orphaned processes to a session manager or container init (PID != 1),
|
||||
// and the older `process.ppid !== 1` guard would silently leak the codex
|
||||
// adapter tree there.
|
||||
expect(wrapper).not.toContain("process.ppid !== 1");
|
||||
expect(wrapper).toMatch(
|
||||
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
|
||||
|
||||
@@ -475,13 +475,7 @@ const parentWatcher =
|
||||
process.platform === "win32"
|
||||
? undefined
|
||||
: setInterval(() => {
|
||||
// Orphan detection: parent PID changed means our original parent died.
|
||||
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
|
||||
// systemd user-session manager, OR a container init, OR a session
|
||||
// leader — depending on environment. Previously this only triggered
|
||||
// on PPID == 1, which missed all systemd-managed deployments and
|
||||
// leaked codex-acp adapter trees on every gateway restart.
|
||||
if (process.ppid === originalParentPid) {
|
||||
if (process.ppid === originalParentPid || process.ppid !== 1) {
|
||||
return;
|
||||
}
|
||||
if (orphanCleanupStarted) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { RequestedModelUnsupportedError } from "acpx/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AcpRuntimeError,
|
||||
@@ -709,100 +708,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries without a model when ACPX reports missing model capability", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
|
||||
list: () => ["opencode"],
|
||||
},
|
||||
});
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(
|
||||
new RequestedModelUnsupportedError(
|
||||
"Cannot apply --model: the ACP agent did not advertise model support",
|
||||
"missing-capability",
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "opencode",
|
||||
});
|
||||
|
||||
await runtime.ensureSession({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
agent: "opencode",
|
||||
mode: "persistent",
|
||||
model: "openrouter/owl-alpha",
|
||||
});
|
||||
|
||||
expect(ensure).toHaveBeenCalledTimes(2);
|
||||
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
|
||||
model: "openrouter/owl-alpha",
|
||||
sessionOptions: { model: "openrouter/owl-alpha" },
|
||||
});
|
||||
const [, secondCall] = ensure.mock.calls;
|
||||
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
|
||||
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
|
||||
list: () => ["opencode"],
|
||||
},
|
||||
});
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(
|
||||
new RequestedModelUnsupportedError(
|
||||
"Cannot apply --model: the ACP agent did not advertise that model",
|
||||
"unadvertised-model",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
agent: "opencode",
|
||||
mode: "persistent",
|
||||
model: "unknown/model",
|
||||
}),
|
||||
).rejects.toThrow("did not advertise that model");
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry an unrelated error with similar wording", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore);
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:main:acp:test",
|
||||
agent: "main",
|
||||
mode: "persistent",
|
||||
model: "openrouter/owl-alpha",
|
||||
}),
|
||||
).rejects.toThrow("did not advertise model support");
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("injects Codex ACP startup config into the scoped registry", () => {
|
||||
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
|
||||
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
isRequestedModelUnsupportedError,
|
||||
type AcpAgentRegistry,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
@@ -587,26 +586,6 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
|
||||
} as AcpxDelegateEnsureInput;
|
||||
}
|
||||
|
||||
function isAcpModelCapabilityMissingError(error: unknown): boolean {
|
||||
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
|
||||
}
|
||||
|
||||
// ACPX owns the distinction between missing model capability and an invalid model id.
|
||||
// Retry only the former so explicit model mistakes remain visible to the caller.
|
||||
async function ensureDelegateSessionWithModelFallback(
|
||||
delegate: BaseAcpxRuntime,
|
||||
input: OpenClawRuntimeEnsureInput,
|
||||
): Promise<AcpRuntimeHandle> {
|
||||
try {
|
||||
return await delegate.ensureSession(withAcpxSessionOptions(input));
|
||||
} catch (error) {
|
||||
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
@@ -1010,7 +989,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.withCodexWrapperDiagnostics({
|
||||
command: stableLaunchCommand,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"id": "alibaba",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"id": "anthropic-vertex",
|
||||
"name": "Anthropic Vertex",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"id": "anthropic",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"id": "brave",
|
||||
"name": "Brave",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"icon": "https://cdn.simpleicons.org/brave",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -43,39 +43,23 @@ afterAll(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function malformedJsonResponse(): Response {
|
||||
return new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyWebSearchResponse(): Response {
|
||||
return jsonResponse({ web: { results: [] } });
|
||||
}
|
||||
|
||||
function installBraveLlmContextFetch() {
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return jsonResponse({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
}),
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
return mockFetch;
|
||||
@@ -270,7 +254,10 @@ describe("brave web search provider", () => {
|
||||
it("uses configured Brave baseUrl for web search requests", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -323,7 +310,12 @@ describe("brave web search provider", () => {
|
||||
it("reports malformed Brave web search JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return malformedJsonResponse();
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -347,7 +339,12 @@ describe("brave web search provider", () => {
|
||||
it("reports malformed Brave llm-context JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return malformedJsonResponse();
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -431,7 +428,10 @@ describe("brave web search provider", () => {
|
||||
it("keeps Brave cache entries isolated by baseUrl", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -573,7 +573,10 @@ describe("brave web search provider", () => {
|
||||
it("sends Brave web auth in the X-Subscription-Token header", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -729,7 +732,10 @@ describe("brave web search provider", () => {
|
||||
it("falls back unsupported country values before calling Brave", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -757,17 +763,21 @@ describe("brave web search provider", () => {
|
||||
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return jsonResponse({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Response;
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
|
||||
@@ -15,12 +15,8 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const rootDir = path.resolve(pluginDir, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const hashFile =
|
||||
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
|
||||
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile =
|
||||
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
|
||||
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
|
||||
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
|
||||
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
|
||||
|
||||
@@ -11,9 +11,7 @@ const repoRoot = path.resolve(here, "../../../../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const uiRoot = path.resolve(repoRoot, "ui");
|
||||
const fromHere = (p) => path.resolve(here, p);
|
||||
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
|
||||
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
|
||||
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
|
||||
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
|
||||
|
||||
const a2uiLitIndex = require.resolve("@a2ui/lit");
|
||||
const a2uiLitUi = require.resolve("@a2ui/lit/ui");
|
||||
|
||||
@@ -15,14 +15,6 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const result = await provider.catalog?.run({
|
||||
@@ -52,9 +44,10 @@ async function withRealChutesDiscovery<T>(
|
||||
delete process.env.VITEST;
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,14 +15,6 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveChutesDiscovery<T>(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
run: () => Promise<T>,
|
||||
@@ -53,11 +45,12 @@ async function withLiveChutesDiscovery<T>(
|
||||
function createAuthEchoFetchMock() {
|
||||
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
const auth = readAuthorizationHeader(init);
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: auth ? `${auth}-model` : "public-model" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,8 +124,9 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("discoverChutesModels correctly maps API response when not in test env", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "zai-org/GLM-4.7-TEE" },
|
||||
{
|
||||
@@ -146,7 +140,7 @@ describe("chutes-models", () => {
|
||||
{ id: "new-provider/simple-model" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("test-token-real-fetch");
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
@@ -164,8 +158,9 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("falls back from malformed live token metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: "provider/bad-window",
|
||||
@@ -179,7 +174,7 @@ describe("chutes-models", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("malformed-token-metadata");
|
||||
@@ -200,10 +195,14 @@ describe("chutes-models", () => {
|
||||
it("discoverChutesModels retries without auth on 401", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
|
||||
return Promise.resolve(new Response("", { status: 401 }));
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: "Qwen/Qwen3-32B",
|
||||
@@ -233,7 +232,7 @@ describe("chutes-models", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("test-token-error");
|
||||
@@ -243,7 +242,10 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("does not cache fallback static catalog for non-OK responses", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const first = await discoverChutesModels("chutes-fallback-token");
|
||||
@@ -258,24 +260,27 @@ describe("chutes-models", () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
const auth = readAuthorizationHeader(init);
|
||||
if (auth === "Bearer chutes-token-a") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "private/model-a" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (auth === "Bearer chutes-token-b") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "private/model-b" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "public/model" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const modelsA = await discoverChutesModels("chutes-token-a");
|
||||
@@ -320,13 +325,17 @@ describe("chutes-models", () => {
|
||||
it("does not cache 401 fallback under the failed token key", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
if (readAuthorizationHeader(init) === "Bearer failed-token") {
|
||||
return Promise.resolve(new Response("", { status: 401 }));
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "public/model" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
await discoverChutesModels("failed-token");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"id": "cloudflare-ai-gateway",
|
||||
"icon": "https://cdn.simpleicons.org/cloudflare",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -78,16 +78,13 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
|
||||
};
|
||||
|
||||
/** Reads mirrored Codex session history for harness hooks. */
|
||||
export async function readMirroredSessionHistoryMessages(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<AgentMessage[] | undefined> {
|
||||
const messages = await readCodexMirroredSessionHistoryMessages(params);
|
||||
export async function readMirroredSessionHistoryMessages(
|
||||
sessionFile: string,
|
||||
): Promise<AgentMessage[] | undefined> {
|
||||
const messages = await readCodexMirroredSessionHistoryMessages(sessionFile);
|
||||
if (!messages) {
|
||||
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
|
||||
sessionFile: params.sessionFile,
|
||||
sessionFile,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
|
||||
@@ -1102,6 +1102,362 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -63,9 +64,11 @@ type CodexDynamicToolHookContext = {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
@@ -100,6 +103,166 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
);
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -114,6 +277,7 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -132,6 +296,8 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -176,6 +342,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -333,10 +500,9 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -358,12 +524,46 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result),
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -803,7 +1003,7 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
if (
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -794,6 +794,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -53,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -412,6 +413,8 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
@@ -1827,14 +1830,7 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
|
||||
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
|
||||
return (
|
||||
(await readCodexMirroredSessionHistoryMessages({
|
||||
agentId: this.params.agentId,
|
||||
sessionFile: this.params.sessionFile,
|
||||
sessionId: this.params.sessionId,
|
||||
sessionKey: this.params.sessionKey,
|
||||
})) ?? []
|
||||
);
|
||||
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
|
||||
}
|
||||
|
||||
private createAssistantMessage(text: string): AssistantMessage {
|
||||
|
||||
@@ -841,24 +841,17 @@ export async function runCodexAppServerAttempt(
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
});
|
||||
const hadSessionFile = await pathExists(activeSessionFile);
|
||||
const activeTranscriptTarget = {
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: activeSessionFile,
|
||||
sessionId: activeSessionId,
|
||||
sessionKey: contextSessionKey,
|
||||
};
|
||||
let historyMessages =
|
||||
!activeContextEngine && initialStartupBindingHadInactiveThreadBootstrap
|
||||
? []
|
||||
: ((await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? []);
|
||||
let historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? [];
|
||||
const hookContextWindowFields = {
|
||||
...(params.contextWindowInfo?.tokens
|
||||
? { contextTokenBudget: params.contextWindowInfo.tokens }
|
||||
@@ -916,7 +909,7 @@ export async function runCodexAppServerAttempt(
|
||||
warn: (message) => embeddedAgentLog.warn(message),
|
||||
});
|
||||
historyMessages =
|
||||
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? historyMessages;
|
||||
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
|
||||
}
|
||||
const memoryToolNames = getCodexWorkspaceMemoryToolNames(toolBridge.availableSpecs);
|
||||
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
|
||||
@@ -3048,7 +3041,7 @@ export async function runCodexAppServerAttempt(
|
||||
const activeContextEnginePluginIdLocal =
|
||||
resolveContextEngineOwnerPluginId(activeContextEngine);
|
||||
const finalMessages =
|
||||
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ??
|
||||
(await readMirroredSessionHistoryMessages(activeSessionFile)) ??
|
||||
historyMessages.concat(result.messagesSnapshot);
|
||||
await finalizeHarnessContextEngineTurn({
|
||||
contextEngine: activeContextEngine,
|
||||
|
||||
@@ -51,14 +51,6 @@ function messageEntry(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function mirroredTarget(sessionFile: string) {
|
||||
return {
|
||||
sessionFile,
|
||||
sessionId: "codex-session",
|
||||
sessionKey: "codex-session",
|
||||
};
|
||||
}
|
||||
|
||||
describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
it("replays only the branch selected by a leaf control", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
@@ -83,9 +75,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toMatchObject([
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "root prompt" },
|
||||
{ role: "assistant", content: "active answer" },
|
||||
]);
|
||||
@@ -103,9 +93,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toEqual([]);
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps visible history when continuation rows use a disjoint append cursor", async () => {
|
||||
@@ -137,9 +125,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toMatchObject([
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
@@ -168,9 +154,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toMatchObject([
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
|
||||
@@ -10,59 +10,40 @@ import {
|
||||
migrateSessionEntries,
|
||||
parseSessionEntries,
|
||||
} from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
resolveSessionTranscriptTarget,
|
||||
type SessionTranscriptTargetParams,
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js";
|
||||
|
||||
export type CodexMirroredSessionHistoryTarget = {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns sanitized session-context messages for a Codex mirrored session file. */
|
||||
export async function readCodexMirroredSessionHistoryMessages(
|
||||
target: CodexMirroredSessionHistoryTarget,
|
||||
sessionFile: string,
|
||||
): Promise<AgentMessage[] | undefined> {
|
||||
try {
|
||||
await resolveSessionTranscriptTarget(resolveCodexHistoryTranscriptTarget(target));
|
||||
const raw = await fs.readFile(target.sessionFile, "utf-8");
|
||||
const raw = await fs.readFile(sessionFile, "utf-8");
|
||||
const entries = parseSessionEntries(raw);
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstEntry = entries[0] as { type?: unknown; id?: unknown } | undefined;
|
||||
if (firstEntry?.type !== "session" || typeof firstEntry.id !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
migrateSessionEntries(entries as SessionEntry[]);
|
||||
const sessionEntries = entries.filter((entry): entry is SessionEntry => {
|
||||
return (
|
||||
entry !== null &&
|
||||
typeof entry === "object" &&
|
||||
!Array.isArray(entry) &&
|
||||
(entry as { type?: unknown }).type !== "session"
|
||||
);
|
||||
});
|
||||
migrateSessionEntries(entries);
|
||||
const sessionEntries = entries.filter(
|
||||
(entry): entry is SessionEntry => entry.type !== "session",
|
||||
);
|
||||
return sanitizeCodexHistoryImagePayloads(
|
||||
buildSessionContext(sessionEntries).messages,
|
||||
"codex mirrored history",
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return [];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexHistoryTranscriptTarget(
|
||||
target: CodexMirroredSessionHistoryTarget,
|
||||
): SessionTranscriptTargetParams {
|
||||
return {
|
||||
...(target.agentId ? { agentId: target.agentId } : {}),
|
||||
sessionFile: target.sessionFile,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,14 +21,13 @@ import {
|
||||
mirrorCodexAppServerTranscript,
|
||||
} from "./transcript-mirror.js";
|
||||
|
||||
const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn());
|
||||
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
publishSessionTranscriptUpdateByIdentity: publishSessionTranscriptUpdateByIdentityMock,
|
||||
emitSessionTranscriptUpdate: emitSessionTranscriptUpdateMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -45,7 +44,7 @@ const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
resetGlobalHookRunner();
|
||||
publishSessionTranscriptUpdateByIdentityMock.mockReset();
|
||||
emitSessionTranscriptUpdateMock.mockReset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -131,7 +130,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage, toolResultMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -166,32 +164,30 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
const firstMirror = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [userMessage],
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
const secondMirror = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [userMessage],
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
|
||||
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
||||
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown>,
|
||||
);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]?.sessionFile).toBe(sessionFile);
|
||||
expect(updates[0]?.sessionKey).toBe("agent:main:main");
|
||||
expect(updates[0]?.update?.messageId).toEqual(expect.any(String));
|
||||
expect(updates[0]?.update?.message).toMatchObject({
|
||||
expect(updates[0]?.messageId).toEqual(expect.any(String));
|
||||
expect(updates[0]?.message).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "show me live" }],
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
||||
});
|
||||
expect(updates[0]?.update?.messageSeq).toBe(1);
|
||||
expect(updates[0]?.messageSeq).toBe(1);
|
||||
expect(firstMirror.userMessagesPresent).toHaveLength(1);
|
||||
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
|
||||
role: "user",
|
||||
@@ -211,7 +207,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [
|
||||
attachCodexMirrorIdentity(
|
||||
@@ -232,16 +227,14 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
|
||||
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
||||
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown>,
|
||||
);
|
||||
expect(updates.map((update) => update.update?.messageSeq)).toEqual([1, 2]);
|
||||
expect(
|
||||
updates.map((update) => {
|
||||
const message = update.update?.message as { role?: string } | undefined;
|
||||
return message?.role;
|
||||
}),
|
||||
).toEqual(["user", "assistant"]);
|
||||
expect(updates.map((update) => update.messageSeq)).toEqual([1, 2]);
|
||||
expect(updates.map((update) => (update.message as { role?: string }).role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the transcript directory on first mirror", async () => {
|
||||
@@ -250,7 +243,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -281,14 +273,12 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "scope-1",
|
||||
});
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -322,7 +312,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -359,14 +348,12 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
const first = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
});
|
||||
const second = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -407,7 +394,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -433,7 +419,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -471,7 +456,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -550,7 +534,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -564,7 +547,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
);
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, reasoningMessage, assistantMessage],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -613,14 +595,12 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn1, assistantTurn1],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
});
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn2, assistantTurn2],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -658,7 +638,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
);
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn1, assistantTurn1],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -682,7 +661,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
// turn 1's entries (with their original identities preserved).
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -713,7 +691,6 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// Codex plugin module implements transcript mirror behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
acquireSessionWriteLock,
|
||||
appendSessionTranscriptMessage,
|
||||
embeddedAgentLog,
|
||||
emitSessionTranscriptUpdate,
|
||||
formatErrorMessage,
|
||||
resolveSessionWriteLockOptions,
|
||||
runAgentHarnessBeforeMessageWriteHook,
|
||||
type AgentMessage,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type EmbeddedRunAttemptResult,
|
||||
type SessionWriteLockAcquireTimeoutConfig,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
publishSessionTranscriptUpdateByIdentity,
|
||||
withSessionTranscriptWriteLock,
|
||||
type SessionTranscriptTargetParams,
|
||||
type SessionTranscriptWriteLockParams,
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
@@ -273,13 +273,13 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
|
||||
|
||||
export async function mirrorCodexAppServerTranscript(params: {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messages: AgentMessage[];
|
||||
idempotencyScope?: string;
|
||||
config?: SessionTranscriptWriteLockParams["config"];
|
||||
config?: SessionWriteLockAcquireTimeoutConfig;
|
||||
}): Promise<CodexAppServerTranscriptMirrorResult> {
|
||||
const messages = params.messages.filter(
|
||||
(message): message is MirroredAgentMessage =>
|
||||
@@ -289,133 +289,120 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
return { userMessagesPresent: [] };
|
||||
}
|
||||
|
||||
const transcriptTarget = resolveCodexMirrorTranscriptTarget(params);
|
||||
const { appendedUpdates, userMessagesPresent } = await withSessionTranscriptWriteLock(
|
||||
{ ...transcriptTarget, config: params.config },
|
||||
async (transcript) => {
|
||||
const nextAppendedUpdates: Array<{
|
||||
messageId: string;
|
||||
message: AgentMessage;
|
||||
messageSeq: number;
|
||||
}> = [];
|
||||
const nextUserMessagesPresent: MirroredUserMessage[] = [];
|
||||
const mirrorState = readTranscriptMirrorState(await transcript.readEvents());
|
||||
let nextMessageSeq = mirrorState.messageCount;
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
|
||||
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
|
||||
if (persistedUserMessage) {
|
||||
nextUserMessagesPresent.push(persistedUserMessage);
|
||||
}
|
||||
continue;
|
||||
const lock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
...resolveSessionWriteLockOptions(params.config),
|
||||
});
|
||||
const appendedUpdates: Array<{ messageId: string; message: AgentMessage; messageSeq: number }> =
|
||||
[];
|
||||
const userMessagesPresent: MirroredUserMessage[] = [];
|
||||
try {
|
||||
const mirrorState = await readTranscriptMirrorState(params.sessionFile);
|
||||
let nextMessageSeq = mirrorState.messageCount;
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
|
||||
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
|
||||
if (persistedUserMessage) {
|
||||
userMessagesPresent.push(persistedUserMessage);
|
||||
}
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
const appended = await transcript.appendMessage({
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
cwd: params.cwd,
|
||||
});
|
||||
if (!appended) {
|
||||
continue;
|
||||
}
|
||||
const { messageId, message: appendedMessage } = appended;
|
||||
if (appendedMessage.role === "user") {
|
||||
nextUserMessagesPresent.push(appendedMessage);
|
||||
if (idempotencyKey) {
|
||||
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
|
||||
}
|
||||
}
|
||||
nextMessageSeq += 1;
|
||||
nextAppendedUpdates.push({
|
||||
messageId,
|
||||
message: appendedMessage,
|
||||
messageSeq: nextMessageSeq,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
|
||||
transcriptPath: params.sessionFile,
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
sessionId: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
config: params.config,
|
||||
});
|
||||
if (appendedMessage.role === "user") {
|
||||
userMessagesPresent.push(appendedMessage);
|
||||
if (idempotencyKey) {
|
||||
mirrorState.idempotencyKeys.add(idempotencyKey);
|
||||
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
|
||||
}
|
||||
}
|
||||
return { appendedUpdates: nextAppendedUpdates, userMessagesPresent: nextUserMessagesPresent };
|
||||
},
|
||||
);
|
||||
nextMessageSeq += 1;
|
||||
appendedUpdates.push({ messageId, message: appendedMessage, messageSeq: nextMessageSeq });
|
||||
if (idempotencyKey) {
|
||||
mirrorState.idempotencyKeys.add(idempotencyKey);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
for (const update of appendedUpdates) {
|
||||
await publishSessionTranscriptUpdateByIdentity({
|
||||
...transcriptTarget,
|
||||
update: {
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
message: update.message,
|
||||
messageId: update.messageId,
|
||||
messageSeq: update.messageSeq,
|
||||
},
|
||||
emitSessionTranscriptUpdate({
|
||||
sessionFile: params.sessionFile,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
message: update.message,
|
||||
messageId: update.messageId,
|
||||
messageSeq: update.messageSeq,
|
||||
});
|
||||
}
|
||||
|
||||
return { userMessagesPresent };
|
||||
}
|
||||
|
||||
function resolveCodexMirrorTranscriptTarget(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): SessionTranscriptTargetParams {
|
||||
return {
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTranscriptMirrorState(events: unknown[]): {
|
||||
async function readTranscriptMirrorState(sessionFile: string): Promise<{
|
||||
idempotencyKeys: Set<string>;
|
||||
messageCount: number;
|
||||
userMessagesByIdempotencyKey: Map<string, MirroredUserMessage>;
|
||||
} {
|
||||
}> {
|
||||
const idempotencyKeys = new Set<string>();
|
||||
const userMessagesByIdempotencyKey = new Map<string, MirroredUserMessage>();
|
||||
let messageCount = 0;
|
||||
for (const event of events) {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(sessionFile, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const parsed = event as {
|
||||
message?: AgentMessage & { idempotencyKey?: unknown };
|
||||
type?: unknown;
|
||||
};
|
||||
if (parsed.type === "message") {
|
||||
messageCount += 1;
|
||||
}
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
idempotencyKeys.add(parsed.message.idempotencyKey);
|
||||
if (parsed.message.role === "user") {
|
||||
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { message?: AgentMessage & { idempotencyKey?: unknown } };
|
||||
if ((parsed as { type?: unknown }).type === "message") {
|
||||
messageCount += 1;
|
||||
}
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
idempotencyKeys.add(parsed.message.idempotencyKey);
|
||||
if (parsed.message.role === "user") {
|
||||
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
* turns replies into app-server answer payloads.
|
||||
*/
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
deliverAgentHarnessUserInputPrompt,
|
||||
embeddedAgentLog,
|
||||
emptyAgentHarnessUserInputAnswers,
|
||||
type AgentHarnessUserInputOption,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { formatCodexDisplayText } from "../command-formatters.js";
|
||||
@@ -24,11 +19,25 @@ type PendingUserInput = {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: AgentHarnessUserInputQuestion[];
|
||||
questions: UserInputQuestion[];
|
||||
resolve: (value: JsonValue) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type UserInputQuestion = {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
isOther: boolean;
|
||||
isSecret: boolean;
|
||||
options: UserInputOption[] | null;
|
||||
};
|
||||
|
||||
type UserInputOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CodexUserInputBridge = {
|
||||
handleRequest: (request: {
|
||||
id: number | string;
|
||||
@@ -133,7 +142,7 @@ function readUserInputParams(value: JsonValue | undefined):
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: AgentHarnessUserInputQuestion[];
|
||||
questions: UserInputQuestion[];
|
||||
}
|
||||
| undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
@@ -148,11 +157,11 @@ function readUserInputParams(value: JsonValue | undefined):
|
||||
}
|
||||
const questions = questionsRaw
|
||||
.map(readQuestion)
|
||||
.filter((question): question is AgentHarnessUserInputQuestion => Boolean(question));
|
||||
.filter((question): question is UserInputQuestion => Boolean(question));
|
||||
return { threadId, turnId, itemId, questions };
|
||||
}
|
||||
|
||||
function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined {
|
||||
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -172,17 +181,17 @@ function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefin
|
||||
};
|
||||
}
|
||||
|
||||
function readOptions(value: JsonValue | undefined): AgentHarnessUserInputOption[] | null {
|
||||
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const options = value
|
||||
.map(readOption)
|
||||
.filter((option): option is AgentHarnessUserInputOption => Boolean(option));
|
||||
.filter((option): option is UserInputOption => Boolean(option));
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
|
||||
function readOption(value: JsonValue): UserInputOption | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -193,25 +202,116 @@ function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
|
||||
|
||||
async function deliverUserInputPrompt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
questions: AgentHarnessUserInputQuestion[],
|
||||
questions: UserInputQuestion[],
|
||||
): Promise<void> {
|
||||
await deliverAgentHarnessUserInputPrompt(params, questions, {
|
||||
formatText: formatCodexDisplayText,
|
||||
intro: "Codex needs input:",
|
||||
});
|
||||
const text = formatUserInputPrompt(questions);
|
||||
if (params.onBlockReply) {
|
||||
await params.onBlockReply({ text });
|
||||
return;
|
||||
}
|
||||
await params.onPartialReply?.({ text });
|
||||
}
|
||||
|
||||
function buildUserInputResponse(
|
||||
questions: AgentHarnessUserInputQuestion[],
|
||||
inputText: string,
|
||||
): JsonObject {
|
||||
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
|
||||
const lines = ["Codex needs input:"];
|
||||
questions.forEach((question, index) => {
|
||||
if (questions.length > 1) {
|
||||
lines.push(
|
||||
"",
|
||||
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
|
||||
formatCodexDisplayText(question.question),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"",
|
||||
formatCodexDisplayText(question.header),
|
||||
formatCodexDisplayText(question.question),
|
||||
);
|
||||
}
|
||||
if (question.isSecret) {
|
||||
lines.push("This channel may show your reply to other participants.");
|
||||
}
|
||||
question.options?.forEach((option, optionIndex) => {
|
||||
lines.push(
|
||||
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
|
||||
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
|
||||
}`,
|
||||
);
|
||||
});
|
||||
if (question.isOther) {
|
||||
lines.push("Other: reply with your own answer.");
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
|
||||
// Multi-question replies may use "header: answer" or numbered lines. Keep the
|
||||
// parser permissive so chat-channel replies remain ergonomic.
|
||||
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
|
||||
const answers: JsonObject = {};
|
||||
if (questions.length === 1) {
|
||||
const question = questions[0];
|
||||
if (question) {
|
||||
const answer = normalizeAnswer(inputText, question);
|
||||
answers[question.id] = { answers: answer ? [answer] : [] };
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
const keyed = parseKeyedAnswers(inputText);
|
||||
const fallbackLines = inputText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
questions.forEach((question, index) => {
|
||||
const key =
|
||||
keyed.get(question.id.toLowerCase()) ??
|
||||
keyed.get(question.header.toLowerCase()) ??
|
||||
keyed.get(question.question.toLowerCase()) ??
|
||||
keyed.get(String(index + 1));
|
||||
const answer = key ?? fallbackLines[index] ?? "";
|
||||
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
|
||||
answers[question.id] = { answers: normalized ? [normalized] : [] };
|
||||
});
|
||||
return { answers };
|
||||
}
|
||||
|
||||
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
|
||||
const trimmed = answer.trim();
|
||||
const options = question.options ?? [];
|
||||
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
|
||||
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
|
||||
if (indexed) {
|
||||
return indexed.label;
|
||||
}
|
||||
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
|
||||
if (exact) {
|
||||
return exact.label;
|
||||
}
|
||||
if (options.length > 0 && !question.isOther) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseKeyedAnswers(inputText: string): Map<string, string> {
|
||||
const answers = new Map<string, string>();
|
||||
for (const line of inputText.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const key = match[1]?.trim().toLowerCase();
|
||||
const value = match[2]?.trim();
|
||||
if (key && value) {
|
||||
answers.set(key, value);
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
function emptyUserInputResponse(): JsonObject {
|
||||
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
|
||||
return { answers: {} };
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1609,12 +1609,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
summaryContent: "compacted summary",
|
||||
contextWindow: {
|
||||
tokenLimit: 1000,
|
||||
currentTokens: 777,
|
||||
messagesLength: 12,
|
||||
},
|
||||
}));
|
||||
const disconnect = vi.fn(async () => {
|
||||
throw new Error("disconnect failed");
|
||||
@@ -1655,7 +1649,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
model: "gpt-4.1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
currentTokenCount: 900,
|
||||
workspaceDir: "/this\u0000is/illegal",
|
||||
customInstructions: "Keep decisions.",
|
||||
});
|
||||
@@ -1691,25 +1684,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "copilot-sdk-history-compacted",
|
||||
result: {
|
||||
summary: "compacted summary",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: 900,
|
||||
tokensAfter: 777,
|
||||
details: {
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
summaryContent: "compacted summary",
|
||||
contextWindow: {
|
||||
tokenLimit: 1000,
|
||||
currentTokens: 777,
|
||||
messagesLength: 12,
|
||||
},
|
||||
},
|
||||
sessionId: "oc-sess-compact-1",
|
||||
sessionFile: "/session.json",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -62,14 +62,6 @@ interface CopilotHistoryCompactResult {
|
||||
tokensRemoved: number;
|
||||
messagesRemoved: number;
|
||||
summaryContent?: string;
|
||||
contextWindow?: {
|
||||
tokenLimit: number;
|
||||
currentTokens: number;
|
||||
messagesLength: number;
|
||||
systemTokens?: number;
|
||||
conversationTokens?: number;
|
||||
toolDefinitionsTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CopilotHistoryCompactSession {
|
||||
@@ -880,21 +872,6 @@ export function createCopilotAgentHarness(
|
||||
ok: true,
|
||||
compacted,
|
||||
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
|
||||
...(compacted
|
||||
? {
|
||||
result: {
|
||||
summary: compactResult.summaryContent ?? "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore:
|
||||
params.currentTokenCount ??
|
||||
(compactResult.contextWindow?.currentTokens ?? 0) + compactResult.tokensRemoved,
|
||||
tokensAfter: compactResult.contextWindow?.currentTokens,
|
||||
details: compactResult,
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"id": "copilot",
|
||||
"name": "GitHub Copilot agent runtime",
|
||||
"description": "Registers the GitHub Copilot agent runtime.",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot",
|
||||
"version": "2026.6.2",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
|
||||
@@ -3,11 +3,9 @@ import fsp from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
queueAgentHarnessMessage,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
import type {
|
||||
AgentHarnessAttemptParams,
|
||||
AgentHarnessAttemptResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
@@ -1173,36 +1171,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.externalAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("active-run abort path marks the attempt as externally aborted", async () => {
|
||||
const sendDeferred = createDeferred<SessionEventShape | undefined>();
|
||||
const sessionCreated = createDeferred<FakeSession>();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockReturnValue(sendDeferred.promise);
|
||||
session.abort.mockImplementationOnce(async () => {
|
||||
sendDeferred.resolve(undefined);
|
||||
});
|
||||
sessionCreated.resolve(session);
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
const runPromise = runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool,
|
||||
});
|
||||
const session = await sessionCreated.promise;
|
||||
await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(abortAgentHarnessRun("session-1")).toBe(true);
|
||||
const result = await runPromise;
|
||||
|
||||
expect(session.abort).toHaveBeenCalledTimes(1);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.externalAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("abort path (signal already aborted)", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@@ -1479,42 +1447,18 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.feedback).toContain("no permission policy installed");
|
||||
});
|
||||
|
||||
it("registers ask_user and resolves it from the active OpenClaw queue", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session, cfg) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
const handler = cfg.onUserInputRequest;
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("expected onUserInputRequest handler");
|
||||
}
|
||||
const response = await handler(
|
||||
{
|
||||
question: "Pick a mode",
|
||||
choices: ["Fast", "Deep"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: session.sessionId },
|
||||
);
|
||||
return makeAssistantMessageEvent(`selected ${response.answer}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
it("does not register onUserInputRequest (ask_user hidden from the model in MVP)", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool });
|
||||
|
||||
await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(queueAgentHarnessMessage("session-1", "2")).toBe(true);
|
||||
const result = await attempt;
|
||||
await runCopilotAttempt(makeParams(), { pool });
|
||||
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0];
|
||||
expect(typeof cfg.onUserInputRequest).toBe("function");
|
||||
expect(onBlockReply.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining("Pick a mode") }),
|
||||
);
|
||||
expect(result.assistantTexts).toEqual(["selected Deep"]);
|
||||
expect(queueAgentHarnessMessage("session-1", "late")).toBe(false);
|
||||
// Per the SDK contract (types.d.ts: `When provided, enables the
|
||||
// ask_user tool allowing the agent to ask questions`), omitting the
|
||||
// handler hides ask_user from the model entirely. The MVP keeps it
|
||||
// hidden until a real channel/TUI prompt bridge exists.
|
||||
expect("onUserInputRequest" in cfg).toBe(false);
|
||||
});
|
||||
|
||||
it("enableSessionTelemetry is omitted from createSession when undefined (SDK default)", async () => {
|
||||
@@ -1910,7 +1854,6 @@ describe("runCopilotAttempt", () => {
|
||||
it("retains a timed-out session until later compaction reaches session.idle", async () => {
|
||||
const afterCompaction = vi.fn();
|
||||
const onDeferredCompaction = vi.fn();
|
||||
const cleanupToolBridge = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
|
||||
);
|
||||
@@ -1923,14 +1866,8 @@ describe("runCopilotAttempt", () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
const createToolBridge = vi.fn(async () => ({
|
||||
cleanup: cleanupToolBridge,
|
||||
sdkTools: [],
|
||||
sourceTools: [],
|
||||
}));
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
onDeferredCompaction,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
@@ -1940,7 +1877,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(onDeferredCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sdkSessionId: "sess-1" }),
|
||||
);
|
||||
expect(cleanupToolBridge).not.toHaveBeenCalled();
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
activeSession?.emit("session.compaction_start", {});
|
||||
@@ -1954,7 +1890,6 @@ describe("runCopilotAttempt", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(cleanupToolBridge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
|
||||
@@ -2461,13 +2396,11 @@ describe("runCopilotAttempt", () => {
|
||||
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
|
||||
const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
messages: Array<{ role: string }>;
|
||||
idempotencyScope?: string;
|
||||
};
|
||||
expect(args.sessionFile).toBe("session.json");
|
||||
expect(args.sessionId).toBe("session-1");
|
||||
expect(args.idempotencyScope).toBe("copilot:sess-1");
|
||||
expect(args.idempotencyScope).toMatch(/^copilot:/u);
|
||||
expect(args.messages.length).toBeGreaterThan(0);
|
||||
const roles = args.messages.map((m) => m.role);
|
||||
expect(roles).toContain("user");
|
||||
@@ -2514,9 +2447,10 @@ describe("runCopilotAttempt", () => {
|
||||
}
|
||||
const identity = message["__openclaw"]?.mirrorIdentity ?? "";
|
||||
// The terminal assistant carries the turn-stable
|
||||
// `${runId}:assistant:final` identity attached by attempt.ts.
|
||||
// Caller-passed history without an identity falls through to
|
||||
// the positional `${scope}:role:idx`.
|
||||
// `${runId}:assistant:final` identity attached by attempt.ts
|
||||
// (rubber-duck-validated identity scheme — survives SDK session
|
||||
// reuse across turns). Caller-passed history without an
|
||||
// identity falls through to the positional `${scope}:role:idx`
|
||||
// fingerprint that the existing tagging map applies.
|
||||
if (message.role === "assistant" && index === args.messages.length - 1) {
|
||||
expect(identity).toMatch(/:assistant:final$/u);
|
||||
@@ -3132,8 +3066,7 @@ describe("runCopilotAttempt", () => {
|
||||
// permission policy and pollute the catalog under the default reject
|
||||
// policy. `createSessionConfig` derives `availableTools` from the
|
||||
// post-filter `sdkTools` so create- and resume-session always carry
|
||||
// exactly the names of the tools the bridge actually exposed plus the
|
||||
// built-in `ask_user` tool owned by the registered user-input handler.
|
||||
// exactly the names of the tools the bridge actually exposed.
|
||||
describe("availableTools surface restriction (PR #86155 [P1] round-8)", () => {
|
||||
function makeFakeSdkTool(name: string): SdkTool {
|
||||
return {
|
||||
@@ -3157,11 +3090,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
|
||||
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([
|
||||
"read",
|
||||
"edit",
|
||||
"builtin:ask_user",
|
||||
]);
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read", "edit"]);
|
||||
});
|
||||
|
||||
it("forwards `[]` to the SDK when the bridge returns no tools (disable / raw / fully filtered)", async () => {
|
||||
@@ -3171,13 +3100,12 @@ describe("runCopilotAttempt", () => {
|
||||
// (`modelRun: true` or `promptMode: "none"`), an empty
|
||||
// `toolsAllow: []`, and an unsupported provider to `sdkTools: []`.
|
||||
// Whatever the upstream reason, `availableTools` must be the same
|
||||
// ask_user-only list so the SDK cannot fall back to its native
|
||||
// catalog while the registered user-input handler remains usable.
|
||||
// empty list so the SDK cannot fall back to its native catalog.
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
|
||||
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["builtin:ask_user"]);
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([]);
|
||||
});
|
||||
|
||||
it("forwards the full bridged set when the run is unrestricted (no toolsAllow)", async () => {
|
||||
@@ -3203,7 +3131,6 @@ describe("runCopilotAttempt", () => {
|
||||
"edit",
|
||||
"exec",
|
||||
"message",
|
||||
"builtin:ask_user",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3229,7 +3156,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
|
||||
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
|
||||
expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]);
|
||||
expect(resumeCfg?.availableTools).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("forwards `[]` to resumeSession when the bridge returns no tools", async () => {
|
||||
@@ -3248,7 +3175,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
|
||||
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
|
||||
expect(resumeCfg?.availableTools).toEqual(["builtin:ask_user"]);
|
||||
expect(resumeCfg?.availableTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user