mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-20 13:42:02 +08:00
Compare commits
128 Commits
ci/release
...
qa-fold-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3989ce4a79 | ||
|
|
721c67d5d5 | ||
|
|
1255530ca1 | ||
|
|
658e49f473 | ||
|
|
c26376586a | ||
|
|
d4e7590f78 | ||
|
|
75bc6ab532 | ||
|
|
d09e814ca1 | ||
|
|
e145ca7d21 | ||
|
|
a7e1f6c97d | ||
|
|
84a36057e9 | ||
|
|
b44e39b82c | ||
|
|
e89c255a01 | ||
|
|
a635e97965 | ||
|
|
4f278ef71c | ||
|
|
1df2cc5f02 | ||
|
|
1cda1fc9a0 | ||
|
|
af9b026241 | ||
|
|
6a23a72d74 | ||
|
|
14d362039e | ||
|
|
9391dac56d | ||
|
|
61ee4ffdfc | ||
|
|
78d1b4a9b3 | ||
|
|
0d6e0a2263 | ||
|
|
33a4845555 | ||
|
|
4a75171190 | ||
|
|
c946df0239 | ||
|
|
9ce68d0920 | ||
|
|
2c65b9b407 | ||
|
|
78a2a31a6b | ||
|
|
c719ff3183 | ||
|
|
0479da9bfb | ||
|
|
13e76544e5 | ||
|
|
c81391e270 | ||
|
|
69216f1745 | ||
|
|
a824df2e35 | ||
|
|
f60aec6e9d | ||
|
|
6293e6e3ca | ||
|
|
f4baeab47f | ||
|
|
8f06e65f33 | ||
|
|
3518fa575a | ||
|
|
a5e33b3a6b | ||
|
|
86d1e397f4 | ||
|
|
d6c7e95c7b | ||
|
|
445317a38b | ||
|
|
2844ec2bb0 | ||
|
|
459edec9ba | ||
|
|
e27c9a9a41 | ||
|
|
c80f4c110e | ||
|
|
cfc699d3f6 | ||
|
|
f04c3d6575 | ||
|
|
da03996ab7 | ||
|
|
5fd947c661 | ||
|
|
622955b3fc | ||
|
|
cd69760628 | ||
|
|
3e41587992 | ||
|
|
214a28affd | ||
|
|
9f6d5e4750 | ||
|
|
033455b6f1 | ||
|
|
8b5b150e02 | ||
|
|
4db7d6a90a | ||
|
|
d76c1daa52 | ||
|
|
9491e9187d | ||
|
|
e0ec42e0e0 | ||
|
|
a971641a54 | ||
|
|
50b5238b38 | ||
|
|
0cf941344c | ||
|
|
e6823c3d16 | ||
|
|
4b2b70ec79 | ||
|
|
b6d91d96ef | ||
|
|
dadec4500f | ||
|
|
f76a3a3bbe | ||
|
|
c2e26db61b | ||
|
|
41691a82d5 | ||
|
|
49b0487e5b | ||
|
|
4575734f59 | ||
|
|
7e7dc7505b | ||
|
|
7dca9210c9 | ||
|
|
208bed06e1 | ||
|
|
87358d7a7c | ||
|
|
e02bee6aab | ||
|
|
56c0405018 | ||
|
|
b6d754e3cb | ||
|
|
6e732b3063 | ||
|
|
423b1b3a42 | ||
|
|
faeb731a29 | ||
|
|
d6075c1694 | ||
|
|
a67f809b33 | ||
|
|
1f1c434ede | ||
|
|
3c3f1010aa | ||
|
|
0e980be284 | ||
|
|
27450f6b42 | ||
|
|
d491e9c69b | ||
|
|
6fc0a3a9bd | ||
|
|
0a1ce14dd1 | ||
|
|
f9f94e7dcd | ||
|
|
1e105d5340 | ||
|
|
21c966616f | ||
|
|
be7807f65e | ||
|
|
7ee1dafd4f | ||
|
|
3a7a385baf | ||
|
|
c4d1f37d33 | ||
|
|
ba43be9424 | ||
|
|
aa479ac7d8 | ||
|
|
d6cefe26f4 | ||
|
|
0eed410bd0 | ||
|
|
b073d7cc11 | ||
|
|
d97574aae6 | ||
|
|
a54a56fb98 | ||
|
|
45971784c9 | ||
|
|
6a27300a5b | ||
|
|
023993249f | ||
|
|
cd061a4c7b | ||
|
|
b554c470a2 | ||
|
|
8972bff98d | ||
|
|
6f5fdb1e6b | ||
|
|
0f18e82932 | ||
|
|
9594300f8c | ||
|
|
c2c19a883d | ||
|
|
4a0f497f16 | ||
|
|
3706047d60 | ||
|
|
e35e5f123d | ||
|
|
b5811ea2b3 | ||
|
|
bb1043b14c | ||
|
|
16fba65cb6 | ||
|
|
7e5901752d | ||
|
|
806a37fca8 | ||
|
|
753ff96771 |
@@ -107,16 +107,9 @@ Reject:
|
||||
|
||||
## PR Body Proof
|
||||
|
||||
Use the repo PR template. Include these exact labels:
|
||||
|
||||
```text
|
||||
Behavior addressed:
|
||||
Real environment tested:
|
||||
Exact steps or command run after this patch:
|
||||
Evidence after fix:
|
||||
Observed result after fix:
|
||||
What was not tested:
|
||||
```
|
||||
Use the repo PR template. Include authored `## What Problem This Solves` and
|
||||
`## Evidence` sections. Keep the body focused on intent and the most useful
|
||||
validation evidence; inspect the code, tests, and CI before judging correctness.
|
||||
|
||||
## Existing PR Rules
|
||||
|
||||
|
||||
151
.github/pull_request_template.md
vendored
151
.github/pull_request_template.md
vendored
@@ -1,118 +1,57 @@
|
||||
## Summary
|
||||
<!--
|
||||
Optional linked context:
|
||||
Add a visible `Closes #<issue-number>` or `Related: #<issue-number>` line
|
||||
below this comment.
|
||||
|
||||
What problem does this PR solve?
|
||||
Required PR title:
|
||||
type: user-facing description
|
||||
Use a parenthesized scope only when it adds clarity:
|
||||
fix(auth): login redirect loops when session cookie is expired
|
||||
|
||||
Why does this matter now?
|
||||
Types: feat, fix, improve, refactor, docs, chore.
|
||||
For fixes, describe the user-visible symptom and trigger:
|
||||
fix: task list fails to load when user has no environments
|
||||
Avoid implementation details such as:
|
||||
fix: add null check to task query
|
||||
-->
|
||||
|
||||
What is the intended outcome?
|
||||
## What Problem This Solves
|
||||
|
||||
What is intentionally out of scope?
|
||||
<!--
|
||||
Describe the concrete user, product, or operational problem.
|
||||
For fixes, begin with:
|
||||
"Fixes an issue where users <do X> would <experience Y> when <condition>."
|
||||
or:
|
||||
"Resolves a problem where..."
|
||||
|
||||
What does success look like?
|
||||
Name the affected UI surface or workflow. Do not describe the code-level cause here.
|
||||
-->
|
||||
|
||||
What should reviewers focus on?
|
||||
## Why This Change Was Made
|
||||
|
||||
<details>
|
||||
<summary>Summary guidance</summary>
|
||||
<!--
|
||||
In one or two sentences, explain the complete shipped solution, key design
|
||||
decisions, and relevant boundaries or non-goals. Include implementation detail
|
||||
only when it helps reviewers understand user-visible behavior or risk.
|
||||
Avoid file-by-file narration.
|
||||
-->
|
||||
|
||||
This PR description is the contributor's durable explanation of the change. Write it for human maintainers first; ClawSweeper and Barnacle use the same text to understand intent, proof, risk, and current review state.
|
||||
## User Impact
|
||||
|
||||
Describe the intent and outcome in 2-5 bullets. Avoid restating the diff; reviewers and bots can read the changed files.
|
||||
<!--
|
||||
State what users, operators, or developers can now do or expect. Lead with the
|
||||
concrete benefit and use user-facing language. If there is no user-visible
|
||||
impact, say so plainly.
|
||||
-->
|
||||
|
||||
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
|
||||
## Evidence
|
||||
|
||||
</details>
|
||||
<!--
|
||||
Show the most useful proof that this change works. Screenshots, screencasts,
|
||||
terminal output, focused tests, CI results, live observations, redacted logs,
|
||||
and artifact links are all useful. Include before/after evidence for visual
|
||||
changes when it clarifies the result.
|
||||
|
||||
## Linked context
|
||||
|
||||
Which issue does this close?
|
||||
|
||||
Closes #
|
||||
|
||||
Which issues, PRs, or discussions are related?
|
||||
|
||||
Related #
|
||||
|
||||
Was this requested by a maintainer or owner?
|
||||
|
||||
<details>
|
||||
<summary>Linked context guidance</summary>
|
||||
|
||||
Link the issue, PR, discussion, maintainer request, or owner request that explains why this PR should exist. Maintainer context helps reviewers and automation distinguish intended work from drive-by churn.
|
||||
|
||||
</details>
|
||||
|
||||
## Real behavior proof (required for external PRs)
|
||||
|
||||
- Behavior or issue addressed:
|
||||
- Real environment tested:
|
||||
- Exact steps or command run after this patch:
|
||||
- Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output):
|
||||
- Observed result after fix:
|
||||
- What was not tested:
|
||||
- Proof limitations or environment constraints:
|
||||
- Before evidence (optional but encouraged):
|
||||
|
||||
<details>
|
||||
<summary>Real behavior proof guidance</summary>
|
||||
|
||||
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only.
|
||||
|
||||
Screenshots are encouraged even for CLI, console, text, or log changes. Terminal screenshots, copied live output, redacted runtime logs, recordings, and linked artifacts count.
|
||||
|
||||
If your environment cannot produce the ideal proof, explain that under `Proof limitations or environment constraints` so reviewers and ClawSweeper can direct the next step properly.
|
||||
|
||||
Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
|
||||
|
||||
</details>
|
||||
|
||||
## Tests and validation
|
||||
|
||||
Which commands did you run?
|
||||
|
||||
What regression coverage was added or updated?
|
||||
|
||||
What failed before this fix, if known?
|
||||
|
||||
If no test was added, why not?
|
||||
|
||||
<details>
|
||||
<summary>Testing guidance</summary>
|
||||
|
||||
List focused commands, not every incidental check. CI is useful support, but external PRs still need real behavior proof above when behavior changes.
|
||||
|
||||
</details>
|
||||
|
||||
## Risk checklist
|
||||
|
||||
Did user-visible behavior change? (`Yes/No`)
|
||||
|
||||
Did config, environment, or migration behavior change? (`Yes/No`)
|
||||
|
||||
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
|
||||
|
||||
What is the highest-risk area?
|
||||
|
||||
How is that risk mitigated?
|
||||
|
||||
<details>
|
||||
<summary>Risk guidance</summary>
|
||||
|
||||
Use this for author judgment that is not obvious from the diff. ClawSweeper can see touched files, but it cannot know which behavior you think is risky, why the risk is acceptable, or what mitigation reviewers should verify.
|
||||
|
||||
</details>
|
||||
|
||||
## Current review state
|
||||
|
||||
What is the next action?
|
||||
|
||||
What is still waiting on author, maintainer, CI, or external proof?
|
||||
|
||||
Which bot or reviewer comments were addressed?
|
||||
|
||||
<details>
|
||||
<summary>Review state guidance</summary>
|
||||
|
||||
Keep this as the durable state for review progress. If useful information appears in comments, fold the current next action or blocker back here so maintainers and ClawSweeper do not need to reconstruct state from comment history.
|
||||
|
||||
</details>
|
||||
Reviewers will inspect the code, tests, and CI. Use this section to make the
|
||||
validation easy to understand, not to restate the diff.
|
||||
-->
|
||||
|
||||
@@ -14,6 +14,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
|
||||
4
.github/workflows/ci-check-arm-testbox.yml
vendored
4
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -13,6 +13,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
|
||||
4
.github/workflows/ci-check-testbox.yml
vendored
4
.github/workflows/ci-check-testbox.yml
vendored
@@ -17,6 +17,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- cron: "0 7 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
|
||||
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
|
||||
@@ -136,7 +136,7 @@ on:
|
||||
- cron: "30 6 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
|
||||
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- cron: "0 8 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
|
||||
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -32,7 +32,7 @@ on:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
|
||||
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
|
||||
120
.github/workflows/openclaw-release-checks.yml
vendored
120
.github/workflows/openclaw-release-checks.yml
vendored
@@ -768,122 +768,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
|
||||
qa_profile_release_evidence_release_checks:
|
||||
name: Generate QA profile release evidence
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group)
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
environment: qa-live-shared
|
||||
env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: true
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing required OPENAI_API_KEY." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run release QA profile
|
||||
id: run_profile
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
output_dir=".artifacts/qa-e2e/release-profile-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
pnpm openclaw qa run \
|
||||
--repo-root . \
|
||||
--qa-profile release \
|
||||
--output-dir "${output_dir}"
|
||||
|
||||
- name: Upload release QA profile evidence
|
||||
id: upload_profile_evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-qa-profile-release-${{ needs.resolve_target.outputs.revision }}
|
||||
path: ${{ steps.run_profile.outputs.output_dir || '.artifacts/qa-e2e/' }}
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_profile_release_evidence_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_profile.outcome }} ${{ steps.upload_profile_evidence.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-check-status-qa-profile-release-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_profile_release_evidence_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_lab_parity_lane_release_checks:
|
||||
name: Run QA Lab parity lane (${{ matrix.lane }})
|
||||
needs: [resolve_target]
|
||||
@@ -2063,7 +1947,6 @@ jobs:
|
||||
- docker_e2e_release_checks
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- qa_profile_release_evidence_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
@@ -2133,7 +2016,7 @@ jobs:
|
||||
}
|
||||
advisory_status_override_allowed() {
|
||||
case "$1" in
|
||||
qa_lab_parity_lane_release_checks|qa_profile_release_evidence_release_checks|qa_lab_parity_report_release_checks|qa_lab_runtime_parity_release_checks|qa_live_matrix_release_checks|qa_live_telegram_release_checks|qa_live_discord_release_checks|qa_live_whatsapp_release_checks|qa_live_slack_release_checks)
|
||||
qa_lab_parity_lane_release_checks|qa_lab_parity_report_release_checks|qa_lab_runtime_parity_release_checks|qa_live_matrix_release_checks|qa_live_telegram_release_checks|qa_live_discord_release_checks|qa_live_whatsapp_release_checks|qa_live_slack_release_checks)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
@@ -2149,7 +2032,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 }}" \
|
||||
"qa_profile_release_evidence_release_checks=${{ needs.qa_profile_release_evidence_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 }}" \
|
||||
|
||||
@@ -88,8 +88,27 @@ jobs:
|
||||
if [[ "$release_package_version" =~ ^(.+)-[0-9]+$ ]]; then
|
||||
fallback_package_version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
tag_package_version="$(gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
--jq '.content' | tr -d '\n' | base64 --decode | jq -r '.version // empty')"
|
||||
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
|
||||
tag_package_read=false
|
||||
for attempt in 1 2 3; do
|
||||
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
--jq '.content' > "$tag_package_content"; then
|
||||
tag_package_read=true
|
||||
break
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
sleep $((attempt * 5))
|
||||
fi
|
||||
done
|
||||
if [[ "$tag_package_read" != "true" ]]; then
|
||||
echo "Stable closeout could not read package.json for $tag from GitHub API." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tag_package_json="$(tr -d '\n' < "$tag_package_content" | base64 --decode)"; then
|
||||
echo "Stable closeout package.json content for $tag was not valid base64." >&2
|
||||
exit 1
|
||||
fi
|
||||
tag_package_version="$(jq -r '.version // empty' <<<"$tag_package_json")"
|
||||
fallback_correction=false
|
||||
evidence_source_tag="$tag"
|
||||
if [[ "$release_package_version" != "$fallback_package_version" &&
|
||||
|
||||
@@ -35,7 +35,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
|
||||
- Changelog findings: see Docs / Changelog.
|
||||
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
|
||||
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
|
||||
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
|
||||
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
|
||||
- Do not file findings for repo policy preference when changed code follows the relevant scoped guide and no user-visible, runtime, security, or maintainer-risk impact is shown.
|
||||
|
||||
@@ -165,13 +165,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
|
||||
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
|
||||
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
|
||||
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
|
||||
- PR create: real body required. Use the current template: `What Problem This Solves`, `Why This Change Was Made`, `User Impact`, and `Evidence`; include visible refs, behavior, and validation.
|
||||
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
|
||||
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
|
||||
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
|
||||
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
|
||||
@@ -106,7 +106,8 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
|
||||
## Before You PR
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
|
||||
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
|
||||
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
|
||||
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
|
||||
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
@@ -169,7 +170,7 @@ Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
Please include in your PR:
|
||||
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Include human-run real behavior proof from your own setup. AI-generated tests, mocks, lint, typechecks, and CI output are supplemental only; they do not prove the fix works for users.
|
||||
- [ ] Include a concise **Evidence** section with the most useful validation. Reviewers will inspect the code, tests, and CI rather than relying on the PR body alone.
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review
|
||||
|
||||
@@ -128,10 +128,6 @@ const config = {
|
||||
"**/*.test-utils.ts",
|
||||
"test/helpers/live-image-probe.ts",
|
||||
"src/secrets/credential-matrix.ts",
|
||||
"src/agents/claude-cli-runner.ts",
|
||||
"src/agents/agent-auth-json.ts",
|
||||
"src/agents/tool-policy.conformance.ts",
|
||||
"src/auto-reply/reply/audio-tags.ts",
|
||||
"src/gateway/live-tool-probe-utils.ts",
|
||||
"src/gateway/server.auth.shared.ts",
|
||||
"src/shared/text/assistant-visible-text.ts",
|
||||
|
||||
26
docs/ci.md
26
docs/ci.md
@@ -47,33 +47,21 @@ Use `pnpm ci:timings`, `pnpm ci:timings:recent`, or `node scripts/ci-run-timings
|
||||
|
||||
For pull request runs, the terminal timing-summary job runs the helper from the trusted base revision before passing `GH_TOKEN` to `gh run view`. That keeps the tokened query out of branch-controlled code while still summarizing the pull request's current CI run.
|
||||
|
||||
## Real behavior proof
|
||||
## PR context and evidence
|
||||
|
||||
External contributor PRs run a `Real behavior proof` gate from
|
||||
External contributor PRs run a PR context and evidence gate from
|
||||
`.github/workflows/real-behavior-proof.yml`. The workflow checks out the trusted
|
||||
base commit and evaluates the PR body only; it does not execute code from the
|
||||
contributor branch.
|
||||
|
||||
The gate applies to PR authors who are not repository owners, members,
|
||||
collaborators, or bots. It passes when the PR body contains a
|
||||
`Real behavior proof` section with filled values for:
|
||||
|
||||
- `Behavior or issue addressed`
|
||||
- `Real environment tested`
|
||||
- `Exact steps or command run after this patch`
|
||||
- `Evidence after fix`
|
||||
- `Observed result after fix`
|
||||
- `What was not tested`
|
||||
|
||||
The evidence must show the changed behavior after the patch in a real OpenClaw
|
||||
setup. Screenshots, recordings, terminal captures, console output, copied live
|
||||
output, redacted runtime logs, and linked artifacts all count. Unit tests, mocks,
|
||||
snapshots, lint, typechecks, and CI results are useful supporting verification,
|
||||
but they do not satisfy this gate by themselves.
|
||||
collaborators, or bots. It passes when the PR body contains authored
|
||||
`What Problem This Solves` and `Evidence` sections. Evidence can be a focused
|
||||
test, CI result, screenshot, recording, terminal output, live observation,
|
||||
redacted log, or artifact link. The body provides intent and useful validation;
|
||||
reviewers inspect the code, tests, and CI to assess correctness.
|
||||
|
||||
When the check fails, update the PR body instead of pushing another code commit.
|
||||
Maintainers can apply `proof: override` only when the proof gate should not
|
||||
apply to that PR.
|
||||
|
||||
## Scope and routing
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ openclaw nodes status --last-connected 24h
|
||||
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
|
||||
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
||||
Use `nodes remove --node <id|name|ip>` to delete a stale gateway-owned node pairing record.
|
||||
Use `nodes remove --node <id|name|ip>` to remove a node pairing. For a
|
||||
device-backed node this revokes the device's `node` role in `devices/paired.json`
|
||||
and disconnects its node-role sessions (a mixed-role device keeps its row and
|
||||
only loses the `node` role; a node-only device is deleted); it also clears any
|
||||
matching legacy gateway-owned node pairing record. `operator.pairing` can remove
|
||||
non-operator node rows; a device-token caller revoking its own node role on a
|
||||
mixed-role device additionally needs `operator.admin`.
|
||||
|
||||
Approval note:
|
||||
|
||||
|
||||
@@ -168,11 +168,62 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
## Compact a session
|
||||
|
||||
- Session config: [Configuration reference](/gateway/config-agents#session)
|
||||
Reclaim context budget for a wedged or oversized session. `openclaw sessions compact <key>` is the first-class wrapper around the `sessions.compact` gateway RPC and requires a running gateway.
|
||||
|
||||
```bash
|
||||
openclaw sessions compact "agent:main:main"
|
||||
openclaw sessions compact "agent:main:main" --max-lines 200
|
||||
openclaw sessions compact "agent:work:main" --agent work --json
|
||||
```
|
||||
|
||||
- Without `--max-lines`, the gateway LLM-summarizes the transcript. This can be slow, so the default `--timeout` is `180000` ms.
|
||||
- With `--max-lines <n>`, it truncates to the last `n` transcript lines and archives the prior transcript as a `.bak` sidecar.
|
||||
- `--agent <id>`: agent that owns the session; required for `global` keys.
|
||||
- `--url` / `--token` / `--password`: gateway connection overrides.
|
||||
- `--timeout <ms>`: RPC timeout in milliseconds.
|
||||
- `--json`: print the raw RPC payload.
|
||||
|
||||
The command exits non-zero when the gateway reports a failed compaction or is unreachable, so crons and scripts never mistake a silent no-op for success.
|
||||
|
||||
> Note: `openclaw agent --message '/compact ...'` is **not** a compaction path. Slash commands from the CLI are rejected by the authorized-sender check; that invocation exits non-zero with guidance pointing here instead of silently no-opping.
|
||||
|
||||
### sessions.compact RPC
|
||||
|
||||
`openclaw gateway call sessions.compact --params '<json>'` accepts:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------- | ----------- | -------- | ---------------------------------------------------------- |
|
||||
| `key` | string | yes | Session key to compact (for example `agent:main:main`). |
|
||||
| `agentId` | string | no | Agent id that owns the session (for `global` keys). |
|
||||
| `maxLines` | integer ≥ 1 | no | Truncate to the last N lines instead of LLM summarization. |
|
||||
|
||||
Example LLM-summarize response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"key": "agent:main:main",
|
||||
"compacted": true,
|
||||
"result": { "tokensBefore": 243868, "tokensAfter": 34941 }
|
||||
}
|
||||
```
|
||||
|
||||
Example truncate response (`--max-lines 200`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"key": "agent:main:main",
|
||||
"compacted": true,
|
||||
"archived": "/home/user/.openclaw/agents/main/sessions/transcripts/<id>.jsonl.bak",
|
||||
"kept": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- Session config: [Configuration reference](/gateway/config-agents#session)
|
||||
- [CLI reference](/cli)
|
||||
- [Session management](/concepts/session)
|
||||
|
||||
@@ -58,7 +58,14 @@ Methods:
|
||||
- `node.pair.list` - list pending + paired nodes (`operator.pairing`).
|
||||
- `node.pair.approve` - approve a pending request (issues token).
|
||||
- `node.pair.reject` - reject a pending request.
|
||||
- `node.pair.remove` - remove a stale paired node entry.
|
||||
- `node.pair.remove` - remove a paired node. For device-backed pairings this
|
||||
revokes the device's `node` role: it mutates `devices/paired.json` and
|
||||
invalidates/disconnects that device's node-role sessions. A **mixed-role**
|
||||
device (e.g. it also holds `operator`) keeps its row and only loses the `node`
|
||||
role; a node-only device row is deleted. It also removes any matching legacy
|
||||
gateway-owned node pairing entry. Authz: `operator.pairing` may remove
|
||||
non-operator node rows; a device-token caller revoking its **own** node role on
|
||||
a mixed-role device additionally needs `operator.admin`.
|
||||
- `node.pair.verify` - verify `{ nodeId, token }`.
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -51,8 +51,14 @@ Notes:
|
||||
different role that pairing approval never granted.
|
||||
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate gateway-owned
|
||||
node pairing store; it does **not** gate the WS `connect` handshake.
|
||||
- `openclaw nodes remove --node <id|name|ip>` deletes stale entries from that
|
||||
separate gateway-owned node pairing store.
|
||||
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a
|
||||
device-backed node it revokes the device's `node` role in `devices/paired.json`
|
||||
and disconnects that device's node-role sessions — a mixed-role device keeps
|
||||
its row and only loses the `node` role, while a node-only device row is
|
||||
deleted. It also clears any matching entry from the separate gateway-owned node
|
||||
pairing store. `operator.pairing` may remove non-operator node rows; a
|
||||
device-token caller revoking its own node role on a mixed-role device
|
||||
additionally needs `operator.admin`.
|
||||
- Approval scope follows the pending request's declared commands:
|
||||
- commandless request: `operator.pairing`
|
||||
- non-exec node commands: `operator.pairing` + `operator.write`
|
||||
|
||||
@@ -2129,6 +2129,88 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports confirmed sends as successful when result middleware fails", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
|
||||
const details = requireRecord(event.result.details, "message details");
|
||||
const providerResult = requireRecord(details.result, "provider result");
|
||||
delete providerResult.messageId;
|
||||
throw new Error("redaction failed");
|
||||
});
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "broken-redactor",
|
||||
pluginName: "Broken redactor",
|
||||
rawHandler: handler,
|
||||
handler,
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("raw result must stay private", {
|
||||
ok: true,
|
||||
result: {
|
||||
messageId: "1700000000.000100",
|
||||
channelId: "C123",
|
||||
threadId: "1700000000.000000",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "C123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expectInputText("Message delivered, but result post-processing failed."),
|
||||
);
|
||||
expect(result.sideEffectEvidence).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps deferred internal source replies closed when result middleware fails", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
|
||||
const details = requireRecord(event.result.details, "message details");
|
||||
details.messageId = "forged-by-middleware";
|
||||
throw new Error("redaction failed");
|
||||
});
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "broken-redactor",
|
||||
pluginName: "Broken redactor",
|
||||
rawHandler: handler,
|
||||
handler,
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("queued for internal delivery", {
|
||||
status: "ok",
|
||||
deliveryStatus: "sent",
|
||||
sourceReplySink: "internal-ui",
|
||||
sourceReply: { text: "visible reply" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "C123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
contentItems: [
|
||||
{ type: "inputText", text: "Tool output unavailable due to post-processing error." },
|
||||
],
|
||||
});
|
||||
expect(result.sideEffectEvidence).toBe(true);
|
||||
});
|
||||
|
||||
it("builds terminal presentation from the post-middleware result", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn(async () => ({
|
||||
|
||||
26
extensions/policy/src/doctor/checks.ts
Normal file
26
extensions/policy/src/doctor/checks.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Policy doctor health-check catalog.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { createPolicyChannelProviderChecks, createPolicyIngressChecks } from "./scopes/channels.js";
|
||||
import { createPolicyCoreChecks } from "./scopes/core.js";
|
||||
import { createPolicyDataAuthChecks } from "./scopes/data-auth.js";
|
||||
import { createPolicyExecApprovalChecks } from "./scopes/exec-approvals.js";
|
||||
import { createPolicyGatewayChecks } from "./scopes/gateway.js";
|
||||
import { createPolicyModelNetworkChecks } from "./scopes/model-network.js";
|
||||
import { createPolicySandboxChecks } from "./scopes/sandbox.js";
|
||||
import { createPolicyAgentToolChecks, createPolicyToolMetadataChecks } from "./scopes/tools.js";
|
||||
import type { PolicyDoctorCheckDeps } from "./types.js";
|
||||
|
||||
export function createPolicyDoctorChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
return [
|
||||
...createPolicyCoreChecks(deps),
|
||||
...createPolicyChannelProviderChecks(deps),
|
||||
...createPolicyModelNetworkChecks(deps),
|
||||
...createPolicyIngressChecks(deps),
|
||||
...createPolicyGatewayChecks(deps),
|
||||
...createPolicyAgentToolChecks(deps),
|
||||
...createPolicySandboxChecks(deps),
|
||||
...createPolicyDataAuthChecks(deps),
|
||||
...createPolicyExecApprovalChecks(deps),
|
||||
...createPolicyToolMetadataChecks(deps),
|
||||
];
|
||||
}
|
||||
161
extensions/policy/src/doctor/metadata.test.ts
Normal file
161
extensions/policy/src/doctor/metadata.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// Policy doctor metadata tests cover rule metadata.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js";
|
||||
|
||||
describe("policy doctor metadata", () => {
|
||||
it("describes strictness for agent-scoped policy fields", () => {
|
||||
expect(
|
||||
(POLICY_RULE_METADATA as readonly PolicyRuleMetadata[])
|
||||
.filter(
|
||||
(rule) =>
|
||||
rule.scopeSelectors?.includes("agentIds") ||
|
||||
rule.scopeSelectors?.includes("channelIds"),
|
||||
)
|
||||
.map((rule) => {
|
||||
const description: {
|
||||
path: string;
|
||||
strictness: PolicyRuleMetadata["strictness"];
|
||||
selectors: PolicyRuleMetadata["scopeSelectors"];
|
||||
emptyList?: PolicyRuleMetadata["emptyList"];
|
||||
} = {
|
||||
path: rule.policyPath.join("."),
|
||||
strictness: rule.strictness,
|
||||
selectors: rule.scopeSelectors,
|
||||
};
|
||||
if (rule.emptyList !== undefined) {
|
||||
description.emptyList = rule.emptyList;
|
||||
}
|
||||
return description;
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
path: "agents.workspace.allowedAccess",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "agents.workspace.denyTools",
|
||||
strictness: "denylist-superset",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.profiles.allow",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.fs.requireWorkspaceOnly",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.exec.allowSecurity",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.exec.requireAsk",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.exec.allowHosts",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{ path: "tools.elevated.allow", strictness: "requires-false", selectors: ["agentIds"] },
|
||||
{
|
||||
path: "tools.alsoAllow.expected",
|
||||
strictness: "exact-list",
|
||||
emptyList: "meaningful",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{ path: "tools.denyTools", strictness: "denylist-superset", selectors: ["agentIds"] },
|
||||
{
|
||||
path: "sandbox.requireMode",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.allowBackends",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyHostNetwork",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyContainerNamespaceJoin",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.requireReadOnlyMounts",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyContainerRuntimeSocketMounts",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyUnconfinedProfiles",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.browser.requireCdpSourceRange",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "ingress.channels.allowDmPolicies",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "ingress.channels.denyOpenGroups",
|
||||
strictness: "requires-true",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "ingress.channels.requireMentionInGroups",
|
||||
strictness: "requires-true",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "execApprovals.agents.allowSecurity",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "execApprovals.agents.allowAutoAllowSkills",
|
||||
strictness: "requires-false",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "execApprovals.agents.allowlist.expected",
|
||||
strictness: "exact-list",
|
||||
emptyList: "meaningful",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
543
extensions/policy/src/doctor/metadata.ts
Normal file
543
extensions/policy/src/doctor/metadata.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
// Policy doctor check IDs and rule metadata.
|
||||
export const CHECK_IDS = {
|
||||
policyAttestationMismatch: "policy/attestation-hash-mismatch",
|
||||
policyDeniedChannelProvider: "policy/channels-denied-provider",
|
||||
policyHashMismatch: "policy/policy-hash-mismatch",
|
||||
policyInvalidFile: "policy/policy-jsonc-invalid",
|
||||
policyMissingFile: "policy/policy-jsonc-missing",
|
||||
policyDeniedMcpServer: "policy/mcp-denied-server",
|
||||
policyUnapprovedMcpServer: "policy/mcp-unapproved-server",
|
||||
policyDeniedModelProvider: "policy/models-denied-provider",
|
||||
policyUnapprovedModelProvider: "policy/models-unapproved-provider",
|
||||
policyPrivateNetworkAccess: "policy/network-private-access-enabled",
|
||||
policyIngressDmPolicyUnapproved: "policy/ingress-dm-policy-unapproved",
|
||||
policyIngressDmScopeUnapproved: "policy/ingress-dm-scope-unapproved",
|
||||
policyIngressOpenGroupsDenied: "policy/ingress-open-groups-denied",
|
||||
policyIngressGroupMentionRequired: "policy/ingress-group-mention-required",
|
||||
policyGatewayNonLoopbackBind: "policy/gateway-non-loopback-bind",
|
||||
policyGatewayAuthDisabled: "policy/gateway-auth-disabled",
|
||||
policyGatewayRateLimitMissing: "policy/gateway-rate-limit-missing",
|
||||
policyGatewayControlUiInsecure: "policy/gateway-control-ui-insecure",
|
||||
policyGatewayTailscaleFunnel: "policy/gateway-tailscale-funnel",
|
||||
policyGatewayRemoteEnabled: "policy/gateway-remote-enabled",
|
||||
policyGatewayHttpEndpointEnabled: "policy/gateway-http-endpoint-enabled",
|
||||
policyGatewayHttpUrlFetchUnrestricted: "policy/gateway-http-url-fetch-unrestricted",
|
||||
policyAgentsWorkspaceAccessDenied: "policy/agents-workspace-access-denied",
|
||||
policyAgentsToolNotDenied: "policy/agents-tool-not-denied",
|
||||
policyToolsElevatedEnabled: "policy/tools-elevated-enabled",
|
||||
policyToolsAlsoAllowMissing: "policy/tools-also-allow-missing",
|
||||
policyToolsAlsoAllowUnexpected: "policy/tools-also-allow-unexpected",
|
||||
policyToolsExecAskUnapproved: "policy/tools-exec-ask-unapproved",
|
||||
policyToolsExecHostUnapproved: "policy/tools-exec-host-unapproved",
|
||||
policyToolsExecSecurityUnapproved: "policy/tools-exec-security-unapproved",
|
||||
policyToolsFsWorkspaceOnlyRequired: "policy/tools-fs-workspace-only-required",
|
||||
policyToolsProfileUnapproved: "policy/tools-profile-unapproved",
|
||||
policyToolsRequiredDenyMissing: "policy/tools-required-deny-missing",
|
||||
policySandboxModeUnapproved: "policy/sandbox-mode-unapproved",
|
||||
policySandboxBackendUnapproved: "policy/sandbox-backend-unapproved",
|
||||
policySandboxContainerPostureUnobservable: "policy/sandbox-container-posture-unobservable",
|
||||
policySandboxContainerHostNetworkDenied: "policy/sandbox-container-host-network-denied",
|
||||
policySandboxContainerNamespaceJoinDenied: "policy/sandbox-container-namespace-join-denied",
|
||||
policySandboxContainerMountModeRequired: "policy/sandbox-container-mount-mode-required",
|
||||
policySandboxContainerRuntimeSocketMount: "policy/sandbox-container-runtime-socket-mount",
|
||||
policySandboxContainerUnconfinedProfile: "policy/sandbox-container-unconfined-profile",
|
||||
policySandboxBrowserCdpSourceRangeMissing: "policy/sandbox-browser-cdp-source-range-missing",
|
||||
policyDataHandlingRedactionDisabled: "policy/data-handling-redaction-disabled",
|
||||
policyDataHandlingTelemetryContentCapture: "policy/data-handling-telemetry-content-capture",
|
||||
policyDataHandlingSessionRetentionNotEnforced:
|
||||
"policy/data-handling-session-retention-not-enforced",
|
||||
policyDataHandlingSessionTranscriptMemory:
|
||||
"policy/data-handling-session-transcript-memory-enabled",
|
||||
policySecretsUnmanagedProvider: "policy/secrets-unmanaged-provider",
|
||||
policySecretsDeniedProviderSource: "policy/secrets-denied-provider-source",
|
||||
policySecretsInsecureProvider: "policy/secrets-insecure-provider",
|
||||
policyAuthProfileInvalidMetadata: "policy/auth-profile-invalid-metadata",
|
||||
policyAuthProfileUnapprovedMode: "policy/auth-profile-unapproved-mode",
|
||||
policyExecApprovalsMissing: "policy/exec-approvals-missing",
|
||||
policyExecApprovalsInvalid: "policy/exec-approvals-invalid",
|
||||
policyExecApprovalsDefaultSecurityUnapproved: "policy/exec-approvals-default-security-unapproved",
|
||||
policyExecApprovalsAgentSecurityUnapproved: "policy/exec-approvals-agent-security-unapproved",
|
||||
policyExecApprovalsAutoAllowSkillsEnabled: "policy/exec-approvals-auto-allow-skills-enabled",
|
||||
policyExecApprovalsAllowlistMissing: "policy/exec-approvals-allowlist-missing",
|
||||
policyExecApprovalsAllowlistUnexpected: "policy/exec-approvals-allowlist-unexpected",
|
||||
policyMissingToolOwner: "policy/tools-missing-owner",
|
||||
policyMissingToolRisk: "policy/tools-missing-risk-level",
|
||||
policyMissingToolSensitivity: "policy/tools-missing-sensitivity-token",
|
||||
policyUnknownToolRisk: "policy/tools-unknown-risk-level",
|
||||
policyUnknownToolSensitivity: "policy/tools-unknown-sensitivity-token",
|
||||
} as const;
|
||||
|
||||
export const POLICY_CHECK_IDS = [
|
||||
CHECK_IDS.policyMissingFile,
|
||||
CHECK_IDS.policyInvalidFile,
|
||||
CHECK_IDS.policyHashMismatch,
|
||||
CHECK_IDS.policyAttestationMismatch,
|
||||
CHECK_IDS.policyDeniedChannelProvider,
|
||||
CHECK_IDS.policyDeniedMcpServer,
|
||||
CHECK_IDS.policyUnapprovedMcpServer,
|
||||
CHECK_IDS.policyDeniedModelProvider,
|
||||
CHECK_IDS.policyUnapprovedModelProvider,
|
||||
CHECK_IDS.policyPrivateNetworkAccess,
|
||||
CHECK_IDS.policyIngressDmPolicyUnapproved,
|
||||
CHECK_IDS.policyIngressDmScopeUnapproved,
|
||||
CHECK_IDS.policyIngressOpenGroupsDenied,
|
||||
CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
CHECK_IDS.policyGatewayNonLoopbackBind,
|
||||
CHECK_IDS.policyGatewayAuthDisabled,
|
||||
CHECK_IDS.policyGatewayRateLimitMissing,
|
||||
CHECK_IDS.policyGatewayControlUiInsecure,
|
||||
CHECK_IDS.policyGatewayTailscaleFunnel,
|
||||
CHECK_IDS.policyGatewayRemoteEnabled,
|
||||
CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
|
||||
CHECK_IDS.policyAgentsToolNotDenied,
|
||||
CHECK_IDS.policyToolsProfileUnapproved,
|
||||
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
|
||||
CHECK_IDS.policyToolsExecSecurityUnapproved,
|
||||
CHECK_IDS.policyToolsExecAskUnapproved,
|
||||
CHECK_IDS.policyToolsExecHostUnapproved,
|
||||
CHECK_IDS.policyToolsElevatedEnabled,
|
||||
CHECK_IDS.policyToolsAlsoAllowMissing,
|
||||
CHECK_IDS.policyToolsAlsoAllowUnexpected,
|
||||
CHECK_IDS.policyToolsRequiredDenyMissing,
|
||||
CHECK_IDS.policySandboxModeUnapproved,
|
||||
CHECK_IDS.policySandboxBackendUnapproved,
|
||||
CHECK_IDS.policySandboxContainerPostureUnobservable,
|
||||
CHECK_IDS.policySandboxContainerHostNetworkDenied,
|
||||
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
|
||||
CHECK_IDS.policySandboxContainerMountModeRequired,
|
||||
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
|
||||
CHECK_IDS.policySandboxContainerUnconfinedProfile,
|
||||
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
|
||||
CHECK_IDS.policyDataHandlingRedactionDisabled,
|
||||
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
|
||||
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
|
||||
CHECK_IDS.policySecretsUnmanagedProvider,
|
||||
CHECK_IDS.policySecretsDeniedProviderSource,
|
||||
CHECK_IDS.policySecretsInsecureProvider,
|
||||
CHECK_IDS.policyAuthProfileInvalidMetadata,
|
||||
CHECK_IDS.policyAuthProfileUnapprovedMode,
|
||||
CHECK_IDS.policyExecApprovalsMissing,
|
||||
CHECK_IDS.policyExecApprovalsInvalid,
|
||||
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
|
||||
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
|
||||
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
|
||||
CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
CHECK_IDS.policyMissingToolRisk,
|
||||
CHECK_IDS.policyUnknownToolRisk,
|
||||
CHECK_IDS.policyMissingToolSensitivity,
|
||||
CHECK_IDS.policyMissingToolOwner,
|
||||
CHECK_IDS.policyUnknownToolSensitivity,
|
||||
] as const;
|
||||
|
||||
export type PolicyStrictnessKind =
|
||||
| "allowlist-subset"
|
||||
| "denylist-superset"
|
||||
| "ordered-string"
|
||||
| "requires-true"
|
||||
| "requires-false"
|
||||
| "exact-list";
|
||||
|
||||
export type PolicyEmptyListSemantics = "disabled" | "meaningful";
|
||||
|
||||
export type PolicyScopeSelectorKind = "agentIds" | "channelIds";
|
||||
|
||||
export type PolicyRuleMetadata = {
|
||||
readonly policyPath: readonly string[];
|
||||
readonly strictness: PolicyStrictnessKind;
|
||||
readonly valueType: "boolean" | "channel-provider-deny-rules" | "string" | "string-list";
|
||||
readonly checkIds: readonly (typeof POLICY_CHECK_IDS)[number][];
|
||||
readonly emptyList?: PolicyEmptyListSemantics;
|
||||
readonly allowedValues?: readonly string[];
|
||||
readonly caseSensitive?: boolean;
|
||||
readonly normalizeValues?: "model-provider";
|
||||
readonly orderedValues?: readonly string[];
|
||||
readonly scopeSelectors?: readonly PolicyScopeSelectorKind[];
|
||||
};
|
||||
|
||||
export const SANDBOX_CONTAINER_POLICY_RULES = [
|
||||
{
|
||||
key: "denyHostNetwork",
|
||||
label: "host network posture",
|
||||
checkIds: [CHECK_IDS.policySandboxContainerHostNetworkDenied],
|
||||
},
|
||||
{
|
||||
key: "denyContainerNamespaceJoin",
|
||||
label: "container namespace posture",
|
||||
checkIds: [CHECK_IDS.policySandboxContainerNamespaceJoinDenied],
|
||||
},
|
||||
{
|
||||
key: "requireReadOnlyMounts",
|
||||
label: "container mount mode posture",
|
||||
checkIds: [CHECK_IDS.policySandboxContainerMountModeRequired],
|
||||
},
|
||||
{
|
||||
key: "denyContainerRuntimeSocketMounts",
|
||||
label: "container runtime socket mount posture",
|
||||
checkIds: [CHECK_IDS.policySandboxContainerRuntimeSocketMount],
|
||||
},
|
||||
{
|
||||
key: "denyUnconfinedProfiles",
|
||||
label: "container security profile posture",
|
||||
checkIds: [CHECK_IDS.policySandboxContainerUnconfinedProfile],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SANDBOX_POLICY_RULE_METADATA = [
|
||||
{
|
||||
policyPath: ["sandbox", "requireMode"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policySandboxModeUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["off", "non-main", "all"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["sandbox", "allowBackends"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policySandboxBackendUnapproved],
|
||||
emptyList: "disabled",
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
...SANDBOX_CONTAINER_POLICY_RULES.map((rule) => ({
|
||||
policyPath: ["sandbox", "containers", rule.key] as const,
|
||||
strictness: "requires-true" as const,
|
||||
valueType: "boolean" as const,
|
||||
checkIds: rule.checkIds,
|
||||
scopeSelectors: ["agentIds"] as const,
|
||||
})),
|
||||
{
|
||||
policyPath: ["sandbox", "browser", "requireCdpSourceRange"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
] as const satisfies readonly PolicyRuleMetadata[];
|
||||
|
||||
export const POLICY_RULE_METADATA = [
|
||||
{
|
||||
policyPath: ["channels", "denyRules"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "channel-provider-deny-rules",
|
||||
checkIds: [CHECK_IDS.policyDeniedChannelProvider],
|
||||
emptyList: "meaningful",
|
||||
caseSensitive: true,
|
||||
},
|
||||
{
|
||||
policyPath: ["mcp", "servers", "allow"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyUnapprovedMcpServer],
|
||||
emptyList: "disabled",
|
||||
caseSensitive: true,
|
||||
},
|
||||
{
|
||||
policyPath: ["mcp", "servers", "deny"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyDeniedMcpServer],
|
||||
caseSensitive: true,
|
||||
},
|
||||
{
|
||||
policyPath: ["models", "providers", "allow"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyUnapprovedModelProvider],
|
||||
emptyList: "disabled",
|
||||
normalizeValues: "model-provider",
|
||||
},
|
||||
{
|
||||
policyPath: ["models", "providers", "deny"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyDeniedModelProvider],
|
||||
normalizeValues: "model-provider",
|
||||
},
|
||||
{
|
||||
policyPath: ["network", "privateNetwork", "allow"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyPrivateNetworkAccess],
|
||||
},
|
||||
{
|
||||
policyPath: ["ingress", "session", "requireDmScope"],
|
||||
strictness: "ordered-string",
|
||||
valueType: "string",
|
||||
orderedValues: ["main", "per-peer", "per-channel-peer", "per-account-channel-peer"],
|
||||
checkIds: [CHECK_IDS.policyIngressDmScopeUnapproved],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "exposure", "allowNonLoopbackBind"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayNonLoopbackBind],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "exposure", "allowTailscaleFunnel"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayTailscaleFunnel],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "auth", "requireAuth"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayAuthDisabled],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "auth", "requireExplicitRateLimit"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayRateLimitMissing],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "controlUi", "allowInsecure"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayControlUiInsecure],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "remote", "allow"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayRemoteEnabled],
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "http", "denyEndpoints"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyGatewayHttpEndpointEnabled],
|
||||
allowedValues: ["chatCompletions", "responses"],
|
||||
caseSensitive: true,
|
||||
},
|
||||
{
|
||||
policyPath: ["gateway", "http", "requireUrlAllowlists"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted],
|
||||
},
|
||||
{
|
||||
policyPath: ["agents", "workspace", "allowedAccess"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyAgentsWorkspaceAccessDenied],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["none", "ro", "rw"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["agents", "workspace", "denyTools"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyAgentsToolNotDenied],
|
||||
allowedValues: ["exec", "process", "write", "edit", "apply_patch"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "profiles", "allow"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyToolsProfileUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["minimal", "coding", "messaging", "full"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "fs", "requireWorkspaceOnly"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyToolsFsWorkspaceOnlyRequired],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "exec", "allowSecurity"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyToolsExecSecurityUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["deny", "allowlist", "full"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "exec", "requireAsk"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyToolsExecAskUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["off", "on-miss", "always"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "exec", "allowHosts"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyToolsExecHostUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["auto", "sandbox", "gateway", "node"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "elevated", "allow"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyToolsElevatedEnabled],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "alsoAllow", "expected"],
|
||||
strictness: "exact-list",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyToolsAlsoAllowMissing, CHECK_IDS.policyToolsAlsoAllowUnexpected],
|
||||
emptyList: "meaningful",
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "denyTools"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyToolsRequiredDenyMissing],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["tools", "requireMetadata"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [
|
||||
CHECK_IDS.policyMissingToolRisk,
|
||||
CHECK_IDS.policyMissingToolSensitivity,
|
||||
CHECK_IDS.policyMissingToolOwner,
|
||||
],
|
||||
allowedValues: ["risk", "sensitivity", "owner"],
|
||||
},
|
||||
...SANDBOX_POLICY_RULE_METADATA,
|
||||
{
|
||||
policyPath: ["ingress", "channels", "allowDmPolicies"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyIngressDmPolicyUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["pairing", "allowlist", "open", "disabled"],
|
||||
scopeSelectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["ingress", "channels", "denyOpenGroups"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyIngressOpenGroupsDenied],
|
||||
scopeSelectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["ingress", "channels", "requireMentionInGroups"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyIngressGroupMentionRequired],
|
||||
scopeSelectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["dataHandling", "sensitiveLogging", "requireRedaction"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyDataHandlingRedactionDisabled],
|
||||
},
|
||||
{
|
||||
policyPath: ["dataHandling", "telemetry", "denyContentCapture"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyDataHandlingTelemetryContentCapture],
|
||||
},
|
||||
{
|
||||
policyPath: ["dataHandling", "retention", "requireSessionMaintenance"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced],
|
||||
},
|
||||
{
|
||||
policyPath: ["dataHandling", "memory", "denySessionTranscriptIndexing"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyDataHandlingSessionTranscriptMemory],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["secrets", "requireManagedProviders"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policySecretsUnmanagedProvider],
|
||||
},
|
||||
{
|
||||
policyPath: ["secrets", "denySources"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policySecretsDeniedProviderSource],
|
||||
},
|
||||
{
|
||||
policyPath: ["secrets", "allowInsecureProviders"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policySecretsInsecureProvider],
|
||||
},
|
||||
|
||||
{
|
||||
policyPath: ["execApprovals", "requireFile"],
|
||||
strictness: "requires-true",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyExecApprovalsMissing],
|
||||
},
|
||||
{
|
||||
policyPath: ["execApprovals", "defaults", "allowSecurity"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["deny", "allowlist", "full"],
|
||||
},
|
||||
{
|
||||
policyPath: ["execApprovals", "agents", "allowSecurity"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["deny", "allowlist", "full"],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"],
|
||||
strictness: "requires-false",
|
||||
valueType: "boolean",
|
||||
checkIds: [CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled],
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["execApprovals", "agents", "allowlist", "expected"],
|
||||
strictness: "exact-list",
|
||||
valueType: "string-list",
|
||||
checkIds: [
|
||||
CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
],
|
||||
emptyList: "meaningful",
|
||||
caseSensitive: true,
|
||||
scopeSelectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
policyPath: ["auth", "profiles", "requireMetadata"],
|
||||
strictness: "denylist-superset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyAuthProfileInvalidMetadata],
|
||||
allowedValues: ["provider", "mode"],
|
||||
},
|
||||
{
|
||||
policyPath: ["auth", "profiles", "allowModes"],
|
||||
strictness: "allowlist-subset",
|
||||
valueType: "string-list",
|
||||
checkIds: [CHECK_IDS.policyAuthProfileUnapprovedMode],
|
||||
emptyList: "disabled",
|
||||
allowedValues: ["api_key", "aws-sdk", "oauth", "token"],
|
||||
},
|
||||
] as const satisfies readonly PolicyRuleMetadata[];
|
||||
@@ -19,13 +19,7 @@ import {
|
||||
scanPolicyIngress,
|
||||
scanPolicyMcpServers,
|
||||
} from "../policy-state.js";
|
||||
import {
|
||||
POLICY_RULE_METADATA,
|
||||
isPolicyValueAtLeastAsStrict,
|
||||
registerPolicyDoctorChecks,
|
||||
resetPolicyDoctorChecksForTest,
|
||||
type PolicyRuleMetadata,
|
||||
} from "./register.js";
|
||||
import { registerPolicyDoctorChecks, resetPolicyDoctorChecksForTest } from "./register.js";
|
||||
|
||||
let workspaceDir: string;
|
||||
let originalOpenClawHome: string | undefined;
|
||||
@@ -142,200 +136,6 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
resetPolicyDoctorChecksForTest();
|
||||
});
|
||||
|
||||
it("describes strictness for agent-scoped policy fields", () => {
|
||||
expect(
|
||||
(POLICY_RULE_METADATA as readonly PolicyRuleMetadata[])
|
||||
.filter(
|
||||
(rule) =>
|
||||
rule.scopeSelectors?.includes("agentIds") ||
|
||||
rule.scopeSelectors?.includes("channelIds"),
|
||||
)
|
||||
.map((rule) => {
|
||||
const description: {
|
||||
path: string;
|
||||
strictness: PolicyRuleMetadata["strictness"];
|
||||
selectors: PolicyRuleMetadata["scopeSelectors"];
|
||||
emptyList?: PolicyRuleMetadata["emptyList"];
|
||||
} = {
|
||||
path: rule.policyPath.join("."),
|
||||
strictness: rule.strictness,
|
||||
selectors: rule.scopeSelectors,
|
||||
};
|
||||
if (rule.emptyList !== undefined) {
|
||||
description.emptyList = rule.emptyList;
|
||||
}
|
||||
return description;
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
path: "agents.workspace.allowedAccess",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "agents.workspace.denyTools",
|
||||
strictness: "denylist-superset",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.profiles.allow",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.fs.requireWorkspaceOnly",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.exec.allowSecurity",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.exec.requireAsk",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "tools.exec.allowHosts",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{ path: "tools.elevated.allow", strictness: "requires-false", selectors: ["agentIds"] },
|
||||
{
|
||||
path: "tools.alsoAllow.expected",
|
||||
strictness: "exact-list",
|
||||
emptyList: "meaningful",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{ path: "tools.denyTools", strictness: "denylist-superset", selectors: ["agentIds"] },
|
||||
{
|
||||
path: "sandbox.requireMode",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.allowBackends",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyHostNetwork",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyContainerNamespaceJoin",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.requireReadOnlyMounts",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyContainerRuntimeSocketMounts",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.containers.denyUnconfinedProfiles",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "sandbox.browser.requireCdpSourceRange",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "ingress.channels.allowDmPolicies",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "ingress.channels.denyOpenGroups",
|
||||
strictness: "requires-true",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "ingress.channels.requireMentionInGroups",
|
||||
strictness: "requires-true",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "execApprovals.agents.allowSecurity",
|
||||
strictness: "allowlist-subset",
|
||||
emptyList: "disabled",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "execApprovals.agents.allowAutoAllowSkills",
|
||||
strictness: "requires-false",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
{
|
||||
path: "execApprovals.agents.allowlist.expected",
|
||||
strictness: "exact-list",
|
||||
emptyList: "meaningful",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("compares policy values through strictness metadata", () => {
|
||||
const allowHosts = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.exec.allowHosts",
|
||||
);
|
||||
const denyTools = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.denyTools",
|
||||
);
|
||||
const fsWorkspaceOnly = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly",
|
||||
);
|
||||
const denyHostNetwork = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "sandbox.containers.denyHostNetwork",
|
||||
);
|
||||
const alsoAllow = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected",
|
||||
);
|
||||
|
||||
expect(allowHosts).toBeDefined();
|
||||
expect(denyTools).toBeDefined();
|
||||
expect(fsWorkspaceOnly).toBeDefined();
|
||||
expect(denyHostNetwork).toBeDefined();
|
||||
expect(alsoAllow).toBeDefined();
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, [], ["sandbox"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], [])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec", "write"], ["exec"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["write"], ["exec"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["group:runtime"], ["exec"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec"], ["group:runtime"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, true, true)).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, false, true)).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, [], ["read"])).toBe(false);
|
||||
});
|
||||
|
||||
it("allows scoped overrides that are stricter than top-level policy", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
107
extensions/policy/src/doctor/scopes/channels.ts
Normal file
107
extensions/policy/src/doctor/scopes/channels.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyChannelProviderChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const {
|
||||
channelIdsFromFindings,
|
||||
disableChannels,
|
||||
evaluatePolicy,
|
||||
findingsForCheck,
|
||||
workspaceRepairsDisabledResult,
|
||||
workspaceRepairsEnabled,
|
||||
} = deps;
|
||||
|
||||
const policyChannelsDeniedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDeniedChannelProvider,
|
||||
kind: "plugin",
|
||||
description: "Configured channels satisfy policy deny rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
if (!workspaceRepairsEnabled(ctx)) {
|
||||
return workspaceRepairsDisabledResult("channel config");
|
||||
}
|
||||
const channelIds = channelIdsFromFindings(findings);
|
||||
if (channelIds.length === 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no channel findings matched a configurable channel",
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
const next = disableChannels(ctx.cfg, channelIds);
|
||||
if (next.changed.length === 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "matching channels were already disabled or missing",
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: next.config,
|
||||
changes: next.changed.map(
|
||||
(id) => `Disabled channels.${id}.enabled for policy conformance.`,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [policyChannelsDeniedProviderCheck];
|
||||
}
|
||||
|
||||
export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyIngressDmPolicyUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressDmPolicyUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Channel direct-message access policy matches ingress requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved);
|
||||
},
|
||||
};
|
||||
const policyIngressDmScopeUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressDmScopeUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Direct-message sessions use the policy-required isolation scope.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved);
|
||||
},
|
||||
};
|
||||
const policyIngressOpenGroupsDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressOpenGroupsDenied,
|
||||
kind: "plugin",
|
||||
description: "Channel group access does not use open group policy when denied.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied);
|
||||
},
|
||||
};
|
||||
const policyIngressGroupMentionRequiredCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
kind: "plugin",
|
||||
description: "Channel group access keeps mention gates enabled when required.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyIngressDmPolicyUnapprovedCheck,
|
||||
policyIngressDmScopeUnapprovedCheck,
|
||||
policyIngressOpenGroupsDeniedCheck,
|
||||
policyIngressGroupMentionRequiredCheck,
|
||||
];
|
||||
}
|
||||
52
extensions/policy/src/doctor/scopes/core.ts
Normal file
52
extensions/policy/src/doctor/scopes/core.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyMissingFileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingFile,
|
||||
kind: "plugin",
|
||||
description: "The enabled Policy plugin has a policy file to verify.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile);
|
||||
},
|
||||
};
|
||||
const policyHashMismatchCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyHashMismatch,
|
||||
kind: "plugin",
|
||||
description: "The policy file matches the configured expected hash.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch);
|
||||
},
|
||||
};
|
||||
const policyAttestationMismatchCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAttestationMismatch,
|
||||
kind: "plugin",
|
||||
description: "The current policy check matches the accepted attestation.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch);
|
||||
},
|
||||
};
|
||||
const policyInvalidFileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyInvalidFile,
|
||||
kind: "plugin",
|
||||
description: "The enabled policy file parses before policy checks run.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyMissingFileCheck,
|
||||
policyInvalidFileCheck,
|
||||
policyHashMismatchCheck,
|
||||
policyAttestationMismatchCheck,
|
||||
];
|
||||
}
|
||||
123
extensions/policy/src/doctor/scopes/data-auth.ts
Normal file
123
extensions/policy/src/doctor/scopes/data-auth.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyDataHandlingRedactionDisabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingRedactionDisabled,
|
||||
kind: "plugin",
|
||||
description: "Sensitive logging redaction remains enabled when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingRedactionDisabled,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
kind: "plugin",
|
||||
description: "Telemetry content capture remains disabled when policy denies it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
|
||||
kind: "plugin",
|
||||
description: "Session retention maintenance is enforced when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
|
||||
kind: "plugin",
|
||||
description: "Session transcript memory indexing remains disabled when policy denies it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySecretsUnmanagedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySecretsUnmanagedProvider,
|
||||
kind: "plugin",
|
||||
description:
|
||||
"OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider);
|
||||
},
|
||||
};
|
||||
const policySecretsDeniedProviderSourceCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySecretsDeniedProviderSource,
|
||||
kind: "plugin",
|
||||
description:
|
||||
"OpenClaw config secret providers and SecretRefs do not use sources denied by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySecretsDeniedProviderSource,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySecretsInsecureProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySecretsInsecureProvider,
|
||||
kind: "plugin",
|
||||
description:
|
||||
"Configured secret providers do not opt into insecure posture unless policy allows it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider);
|
||||
},
|
||||
};
|
||||
const policyAuthProfileInvalidMetadataCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAuthProfileInvalidMetadata,
|
||||
kind: "plugin",
|
||||
description: "OpenClaw config auth profiles declare required provider and mode metadata.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyAuthProfileInvalidMetadata,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyAuthProfileUnapprovedModeCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAuthProfileUnapprovedMode,
|
||||
kind: "plugin",
|
||||
description: "OpenClaw config auth profile modes stay within the policy allowlist.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyDataHandlingRedactionDisabledCheck,
|
||||
policyDataHandlingTelemetryContentCaptureCheck,
|
||||
policyDataHandlingSessionRetentionNotEnforcedCheck,
|
||||
policyDataHandlingSessionTranscriptMemoryCheck,
|
||||
policySecretsUnmanagedProviderCheck,
|
||||
policySecretsDeniedProviderSourceCheck,
|
||||
policySecretsInsecureProviderCheck,
|
||||
policyAuthProfileInvalidMetadataCheck,
|
||||
policyAuthProfileUnapprovedModeCheck,
|
||||
];
|
||||
}
|
||||
100
extensions/policy/src/doctor/scopes/exec-approvals.ts
Normal file
100
extensions/policy/src/doctor/scopes/exec-approvals.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyExecApprovalChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyExecApprovalsMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsMissing,
|
||||
kind: "plugin",
|
||||
description: "Required exec approvals artifact is present for policy conformance.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsInvalidCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsInvalid,
|
||||
kind: "plugin",
|
||||
description: "Exec approvals artifact parses before policy checks run.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec approval defaults use a policy-approved security mode.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Per-agent exec approval settings use policy-approved security modes.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
|
||||
kind: "plugin",
|
||||
description:
|
||||
"Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAllowlistMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
kind: "plugin",
|
||||
description: "Exec approval allowlists include every pattern required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
kind: "plugin",
|
||||
description: "Exec approval allowlists do not contain patterns outside policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyExecApprovalsMissingCheck,
|
||||
policyExecApprovalsInvalidCheck,
|
||||
policyExecApprovalsDefaultSecurityUnapprovedCheck,
|
||||
policyExecApprovalsAgentSecurityUnapprovedCheck,
|
||||
policyExecApprovalsAutoAllowSkillsEnabledCheck,
|
||||
policyExecApprovalsAllowlistMissingCheck,
|
||||
policyExecApprovalsAllowlistUnexpectedCheck,
|
||||
];
|
||||
}
|
||||
333
extensions/policy/src/doctor/scopes/gateway.ts
Normal file
333
extensions/policy/src/doctor/scopes/gateway.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
// Policy doctor checks and findings for gateway exposure policy.
|
||||
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
|
||||
import type { PolicyEvidence } from "../../policy-state.js";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
import { readPolicyBoolean, readStringList } from "../utils.js";
|
||||
|
||||
export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyGatewayNonLoopbackBindCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayNonLoopbackBind,
|
||||
kind: "plugin",
|
||||
description: "Gateway bind posture matches policy exposure requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind);
|
||||
},
|
||||
};
|
||||
const policyGatewayAuthDisabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayAuthDisabled,
|
||||
kind: "plugin",
|
||||
description: "Gateway authentication remains enabled when required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled);
|
||||
},
|
||||
};
|
||||
const policyGatewayRateLimitMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayRateLimitMissing,
|
||||
kind: "plugin",
|
||||
description: "Gateway authentication rate-limit posture is explicit when required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing);
|
||||
},
|
||||
};
|
||||
const policyGatewayControlUiInsecureCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayControlUiInsecure,
|
||||
kind: "plugin",
|
||||
description: "Gateway Control UI insecure exposure toggles remain disabled by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure);
|
||||
},
|
||||
};
|
||||
const policyGatewayTailscaleFunnelCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayTailscaleFunnel,
|
||||
kind: "plugin",
|
||||
description: "Gateway Tailscale Funnel exposure matches policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel);
|
||||
},
|
||||
};
|
||||
const policyGatewayRemoteEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayRemoteEnabled,
|
||||
kind: "plugin",
|
||||
description: "Remote gateway mode matches policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled);
|
||||
},
|
||||
};
|
||||
const policyGatewayHttpEndpointEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
kind: "plugin",
|
||||
description: "Gateway HTTP API endpoints match policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
kind: "plugin",
|
||||
description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyGatewayNonLoopbackBindCheck,
|
||||
policyGatewayAuthDisabledCheck,
|
||||
policyGatewayRateLimitMissingCheck,
|
||||
policyGatewayControlUiInsecureCheck,
|
||||
policyGatewayTailscaleFunnelCheck,
|
||||
policyGatewayRemoteEnabledCheck,
|
||||
policyGatewayHttpEndpointEnabledCheck,
|
||||
policyGatewayHttpUrlFetchUnrestrictedCheck,
|
||||
];
|
||||
}
|
||||
|
||||
export function gatewayExposureFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
return [
|
||||
...gatewayNonLoopbackBindFindings(policy, policyDocName, evidence),
|
||||
...gatewayAuthFindings(policy, policyDocName, evidence),
|
||||
...gatewayControlUiFindings(policy, policyDocName, evidence),
|
||||
...gatewayTailscaleFindings(policy, policyDocName, evidence),
|
||||
...gatewayRemoteFindings(policy, policyDocName, evidence),
|
||||
...gatewayHttpEndpointFindings(policy, policyDocName, evidence),
|
||||
...gatewayHttpUrlFetchFindings(policy, policyDocName, evidence),
|
||||
];
|
||||
}
|
||||
|
||||
function gatewayNonLoopbackBindFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
if (readPolicyBoolean(policy, ["gateway", "exposure", "allowNonLoopbackBind"]) !== false) {
|
||||
return [];
|
||||
}
|
||||
return (evidence.gatewayExposure ?? [])
|
||||
.filter((entry) => entry.kind === "bind" && entry.nonLoopback === true)
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayNonLoopbackBind,
|
||||
severity: "error",
|
||||
message:
|
||||
entry.explicit === false
|
||||
? "Gateway bind is omitted while the runtime default can permit non-loopback exposure."
|
||||
: `Gateway bind setting '${entry.id}' permits non-loopback exposure.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/exposure/allowNonLoopbackBind`,
|
||||
fixHint: "Use gateway.bind=loopback or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayAuthFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
const findings: HealthFinding[] = [];
|
||||
if (readPolicyBoolean(policy, ["gateway", "auth", "requireAuth"]) === true) {
|
||||
findings.push(
|
||||
...(evidence.gatewayExposure ?? [])
|
||||
.filter((entry) => entry.kind === "auth" && entry.value === "none")
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayAuthDisabled,
|
||||
severity: "error",
|
||||
message: "Gateway authentication is disabled.",
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/auth/requireAuth`,
|
||||
fixHint: "Set gateway.auth.mode to token, password, or trusted-proxy.",
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (readPolicyBoolean(policy, ["gateway", "auth", "requireExplicitRateLimit"]) === true) {
|
||||
findings.push(
|
||||
...(evidence.gatewayExposure ?? [])
|
||||
.filter((entry) => entry.kind === "authRateLimit" && entry.explicit !== true)
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayRateLimitMissing,
|
||||
severity: "error",
|
||||
message: "Gateway authentication rate-limit posture is not explicit.",
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/auth/requireExplicitRateLimit`,
|
||||
fixHint: "Configure gateway.auth.rateLimit or update policy after review.",
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function gatewayControlUiFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
if (readPolicyBoolean(policy, ["gateway", "controlUi", "allowInsecure"]) !== false) {
|
||||
return [];
|
||||
}
|
||||
return (evidence.gatewayExposure ?? [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.kind === "controlUi" &&
|
||||
entry.value === true &&
|
||||
(entry.id === "gateway-control-ui-insecure-auth" ||
|
||||
entry.id === "gateway-control-ui-device-auth-disabled" ||
|
||||
entry.id === "gateway-control-ui-host-origin-fallback"),
|
||||
)
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayControlUiInsecure,
|
||||
severity: "error",
|
||||
message: `Gateway Control UI insecure toggle '${entry.id}' is enabled.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/controlUi/allowInsecure`,
|
||||
fixHint: "Disable the insecure Control UI toggle or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayTailscaleFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
if (readPolicyBoolean(policy, ["gateway", "exposure", "allowTailscaleFunnel"]) !== false) {
|
||||
return [];
|
||||
}
|
||||
return (evidence.gatewayExposure ?? [])
|
||||
.filter((entry) => entry.kind === "tailscale" && entry.value === "funnel")
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayTailscaleFunnel,
|
||||
severity: "error",
|
||||
message: "Gateway Tailscale Funnel exposure is enabled.",
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/exposure/allowTailscaleFunnel`,
|
||||
fixHint: "Use tailscale serve/off or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayRemoteFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
if (readPolicyBoolean(policy, ["gateway", "remote", "allow"]) !== false) {
|
||||
return [];
|
||||
}
|
||||
return (evidence.gatewayExposure ?? [])
|
||||
.filter((entry) => entry.kind === "remote")
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayRemoteEnabled,
|
||||
severity: "error",
|
||||
message: `Gateway remote posture '${entry.id}' is enabled.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/remote/allow`,
|
||||
fixHint: "Disable remote gateway mode/config or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayHttpEndpointFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
const denied = new Set(
|
||||
readStringList(policy, ["gateway", "http", "denyEndpoints"]).map((endpoint) =>
|
||||
endpoint.toLowerCase(),
|
||||
),
|
||||
);
|
||||
if (denied.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return (evidence.gatewayExposure ?? [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.kind === "httpEndpoint" &&
|
||||
entry.endpoint !== undefined &&
|
||||
denied.has(entry.endpoint.toLowerCase()),
|
||||
)
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
severity: "error",
|
||||
message: `Gateway HTTP endpoint '${entry.endpoint ?? entry.id}' is denied by policy.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/http/denyEndpoints`,
|
||||
fixHint: "Disable the HTTP endpoint or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayHttpUrlFetchFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
if (readPolicyBoolean(policy, ["gateway", "http", "requireUrlAllowlists"]) !== true) {
|
||||
return [];
|
||||
}
|
||||
return (evidence.gatewayExposure ?? [])
|
||||
.filter((entry) => entry.kind === "httpUrlFetch" && entry.hasAllowlist !== true)
|
||||
.map((entry): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
severity: "error",
|
||||
message: `Gateway HTTP URL-fetch input '${entry.id}' has no URL allowlist.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: entry.source,
|
||||
target: entry.source,
|
||||
requirement: `oc://${policyDocName}/gateway/http/requireUrlAllowlists`,
|
||||
fixHint: "Add a urlAllowlist for this URL-fetch input or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
232
extensions/policy/src/doctor/scopes/model-network.ts
Normal file
232
extensions/policy/src/doctor/scopes/model-network.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Policy doctor checks and findings for MCP, model provider, and network policy.
|
||||
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { PolicyEvidence } from "../../policy-state.js";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
import { readPolicyBoolean, readStringList } from "../utils.js";
|
||||
|
||||
export function createPolicyModelNetworkChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyMcpDeniedServerCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDeniedMcpServer,
|
||||
kind: "plugin",
|
||||
description: "Configured MCP servers do not match policy deny rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer);
|
||||
},
|
||||
};
|
||||
const policyMcpUnapprovedServerCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnapprovedMcpServer,
|
||||
kind: "plugin",
|
||||
description: "Configured MCP servers do not match policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer);
|
||||
},
|
||||
};
|
||||
const policyModelsDeniedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDeniedModelProvider,
|
||||
kind: "plugin",
|
||||
description: "Configured model providers do not match policy deny rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider);
|
||||
},
|
||||
};
|
||||
const policyModelsUnapprovedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnapprovedModelProvider,
|
||||
kind: "plugin",
|
||||
description: "Configured model providers do not match policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider);
|
||||
},
|
||||
};
|
||||
const policyNetworkPrivateAccessCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyPrivateNetworkAccess,
|
||||
kind: "plugin",
|
||||
description: "Network SSRF policy settings match private-network requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyMcpDeniedServerCheck,
|
||||
policyMcpUnapprovedServerCheck,
|
||||
policyModelsDeniedProviderCheck,
|
||||
policyModelsUnapprovedProviderCheck,
|
||||
policyNetworkPrivateAccessCheck,
|
||||
];
|
||||
}
|
||||
|
||||
export function mcpServerFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
const denied = new Set(readStringList(policy, ["mcp", "servers", "deny"], { lowercase: false }));
|
||||
const allowed = readStringList(policy, ["mcp", "servers", "allow"], { lowercase: false });
|
||||
const allowedSet = new Set(allowed);
|
||||
const findings: HealthFinding[] = [];
|
||||
|
||||
for (const server of evidence.mcpServers) {
|
||||
if (denied.has(server.id)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.policyDeniedMcpServer,
|
||||
severity: "error",
|
||||
message: `MCP server '${server.id}' is denied by policy.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: server.source,
|
||||
target: server.source,
|
||||
requirement: `oc://${policyDocName}/mcp/servers/deny`,
|
||||
fixHint: "Remove this configured MCP server or update the policy after review.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (allowedSet.size > 0 && !allowedSet.has(server.id)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.policyUnapprovedMcpServer,
|
||||
severity: "error",
|
||||
message: `MCP server '${server.id}' is not in the policy allowlist.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: server.source,
|
||||
target: server.source,
|
||||
requirement: `oc://${policyDocName}/mcp/servers/allow`,
|
||||
fixHint: "Use an approved MCP server or update the policy after review.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function modelProviderFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
const denied = new Set(readModelProviderPolicyList(policy, ["models", "providers", "deny"]));
|
||||
const allowed = readModelProviderPolicyList(policy, ["models", "providers", "allow"]);
|
||||
const allowedSet = new Set(allowed);
|
||||
const findings: HealthFinding[] = [];
|
||||
|
||||
for (const provider of evidence.modelProviders) {
|
||||
findings.push(...modelProviderConformanceFindings(provider, denied, allowedSet, policyDocName));
|
||||
}
|
||||
for (const modelRef of evidence.modelRefs) {
|
||||
findings.push(...modelRefConformanceFindings(modelRef, denied, allowedSet, policyDocName));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function readModelProviderPolicyList(policy: unknown, path: readonly string[]): readonly string[] {
|
||||
return readStringList(policy, path).map((provider) => normalizeProviderId(provider));
|
||||
}
|
||||
|
||||
function modelProviderConformanceFindings(
|
||||
provider: PolicyEvidence["modelProviders"][number],
|
||||
denied: ReadonlySet<string>,
|
||||
allowed: ReadonlySet<string>,
|
||||
policyDocName: string,
|
||||
): readonly HealthFinding[] {
|
||||
const findings: HealthFinding[] = [];
|
||||
if (denied.has(provider.id)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.policyDeniedModelProvider,
|
||||
severity: "error",
|
||||
message: `Model provider '${provider.id}' is denied by policy.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: provider.source,
|
||||
target: provider.source,
|
||||
requirement: `oc://${policyDocName}/models/providers/deny`,
|
||||
fixHint: "Remove this configured provider or update the policy after review.",
|
||||
});
|
||||
}
|
||||
if (!denied.has(provider.id) && allowed.size > 0 && !allowed.has(provider.id)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.policyUnapprovedModelProvider,
|
||||
severity: "error",
|
||||
message: `Model provider '${provider.id}' is not in the policy allowlist.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: provider.source,
|
||||
target: provider.source,
|
||||
requirement: `oc://${policyDocName}/models/providers/allow`,
|
||||
fixHint: "Use an approved model provider or update the policy after review.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function modelRefConformanceFindings(
|
||||
modelRef: PolicyEvidence["modelRefs"][number],
|
||||
denied: ReadonlySet<string>,
|
||||
allowed: ReadonlySet<string>,
|
||||
policyDocName: string,
|
||||
): readonly HealthFinding[] {
|
||||
const findings: HealthFinding[] = [];
|
||||
if (denied.has(modelRef.provider)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.policyDeniedModelProvider,
|
||||
severity: "error",
|
||||
message: `Model ref '${modelRef.ref}' uses denied provider '${modelRef.provider}'.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: modelRef.source,
|
||||
target: modelRef.source,
|
||||
requirement: `oc://${policyDocName}/models/providers/deny`,
|
||||
fixHint: "Select an approved model provider or update the policy after review.",
|
||||
});
|
||||
}
|
||||
if (!denied.has(modelRef.provider) && allowed.size > 0 && !allowed.has(modelRef.provider)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.policyUnapprovedModelProvider,
|
||||
severity: "error",
|
||||
message: `Model ref '${modelRef.ref}' uses unapproved provider '${modelRef.provider}'.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: modelRef.source,
|
||||
target: modelRef.source,
|
||||
requirement: `oc://${policyDocName}/models/providers/allow`,
|
||||
fixHint: "Select an approved model provider or update the policy after review.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function networkFindings(
|
||||
policy: unknown,
|
||||
policyDocName: string,
|
||||
evidence: PolicyEvidence,
|
||||
): readonly HealthFinding[] {
|
||||
const allowPrivateNetwork = readPolicyBoolean(policy, ["network", "privateNetwork", "allow"]);
|
||||
if (allowPrivateNetwork !== false) {
|
||||
return [];
|
||||
}
|
||||
return evidence.network
|
||||
.filter((setting) => setting.value)
|
||||
.map((setting): HealthFinding => {
|
||||
return {
|
||||
checkId: CHECK_IDS.policyPrivateNetworkAccess,
|
||||
severity: "error",
|
||||
message: `Network setting '${setting.id}' allows private-network access.`,
|
||||
source: "policy",
|
||||
path: "openclaw config",
|
||||
ocPath: setting.source,
|
||||
target: setting.source,
|
||||
requirement: `oc://${policyDocName}/network/privateNetwork/allow`,
|
||||
fixHint: "Disable this private-network access setting or update policy after review.",
|
||||
};
|
||||
});
|
||||
}
|
||||
123
extensions/policy/src/doctor/scopes/sandbox.ts
Normal file
123
extensions/policy/src/doctor/scopes/sandbox.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policySandboxModeUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxModeUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Sandbox mode config satisfies policy requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved);
|
||||
},
|
||||
};
|
||||
const policySandboxBackendUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxBackendUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Sandbox backend config satisfies policy requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerPostureUnobservableCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerPostureUnobservable,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container posture policy only targets observable container backends.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerPostureUnobservable,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerHostNetworkDenied,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container config avoids host network mode.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerHostNetworkDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container config avoids joining another container network namespace.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerMountModeRequiredCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerMountModeRequired,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container mounts are read-only when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerMountModeRequired,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerRuntimeSocketMount,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container mounts avoid host container runtime sockets.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerUnconfinedProfileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerUnconfinedProfile,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container profile config avoids unconfined profiles.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerUnconfinedProfile,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
|
||||
kind: "plugin",
|
||||
description: "Sandbox browser CDP config includes a source range when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policySandboxModeUnapprovedCheck,
|
||||
policySandboxBackendUnapprovedCheck,
|
||||
policySandboxContainerPostureUnobservableCheck,
|
||||
policySandboxContainerHostNetworkDeniedCheck,
|
||||
policySandboxContainerNamespaceJoinDeniedCheck,
|
||||
policySandboxContainerMountModeRequiredCheck,
|
||||
policySandboxContainerRuntimeSocketMountCheck,
|
||||
policySandboxContainerUnconfinedProfileCheck,
|
||||
policySandboxBrowserCdpSourceRangeMissingCheck,
|
||||
];
|
||||
}
|
||||
191
extensions/policy/src/doctor/scopes/tools.ts
Normal file
191
extensions/policy/src/doctor/scopes/tools.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { CHECK_IDS } from "../metadata.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAgentsWorkspaceAccessDenied,
|
||||
kind: "plugin",
|
||||
description: "Agent sandbox workspace access matches policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyAgentsToolNotDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAgentsToolNotDenied,
|
||||
kind: "plugin",
|
||||
description: "Agent workspace mutation/runtime tools are denied when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied);
|
||||
},
|
||||
};
|
||||
const policyToolsProfileUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsProfileUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Configured tool profiles match policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved);
|
||||
},
|
||||
};
|
||||
const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
|
||||
kind: "plugin",
|
||||
description: "Filesystem tools use workspace-only posture when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyToolsExecSecurityUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsExecSecurityUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec tool security mode matches policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyToolsExecSecurityUnapproved,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyToolsExecAskUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsExecAskUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec tool ask mode matches policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved);
|
||||
},
|
||||
};
|
||||
const policyToolsExecHostUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsExecHostUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec tool host routing matches policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved);
|
||||
},
|
||||
};
|
||||
const policyToolsElevatedEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsElevatedEnabled,
|
||||
kind: "plugin",
|
||||
description: "Elevated tool mode remains disabled when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled);
|
||||
},
|
||||
};
|
||||
const policyToolsAlsoAllowMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsAlsoAllowMissing,
|
||||
kind: "plugin",
|
||||
description: "Configured tools.alsoAllow entries include policy expected lists.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing);
|
||||
},
|
||||
};
|
||||
const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsAlsoAllowUnexpected,
|
||||
kind: "plugin",
|
||||
description: "Configured tools.alsoAllow entries match policy expected lists.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected);
|
||||
},
|
||||
};
|
||||
const policyToolsRequiredDenyMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsRequiredDenyMissing,
|
||||
kind: "plugin",
|
||||
description: "Configured tool deny lists include tools required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyAgentsWorkspaceAccessDeniedCheck,
|
||||
policyAgentsToolNotDeniedCheck,
|
||||
policyToolsProfileUnapprovedCheck,
|
||||
policyToolsFsWorkspaceOnlyRequiredCheck,
|
||||
policyToolsExecSecurityUnapprovedCheck,
|
||||
policyToolsExecAskUnapprovedCheck,
|
||||
policyToolsExecHostUnapprovedCheck,
|
||||
policyToolsElevatedEnabledCheck,
|
||||
policyToolsAlsoAllowMissingCheck,
|
||||
policyToolsAlsoAllowUnexpectedCheck,
|
||||
policyToolsRequiredDenyMissingCheck,
|
||||
];
|
||||
}
|
||||
|
||||
export function createPolicyToolMetadataChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyToolsMissingRiskCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingToolRisk,
|
||||
kind: "plugin",
|
||||
description: "TOOLS.md policy entries declare explicit risk levels.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk);
|
||||
},
|
||||
};
|
||||
const policyToolsUnknownRiskCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnknownToolRisk,
|
||||
kind: "plugin",
|
||||
description: "TOOLS.md policy entries use known risk levels.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk);
|
||||
},
|
||||
};
|
||||
const policyToolsMissingSensitivityCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingToolSensitivity,
|
||||
kind: "plugin",
|
||||
description: "TOOLS.md policy entries declare default artifact sensitivity.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity);
|
||||
},
|
||||
};
|
||||
const policyToolsUnknownSensitivityCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnknownToolSensitivity,
|
||||
kind: "plugin",
|
||||
description: "TOOLS.md policy entries use known sensitivity levels.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity);
|
||||
},
|
||||
};
|
||||
const policyToolsMissingOwnerCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingToolOwner,
|
||||
kind: "plugin",
|
||||
description: "TOOLS.md policy entries declare an accountable owner.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyToolsMissingRiskCheck,
|
||||
policyToolsUnknownRiskCheck,
|
||||
policyToolsMissingSensitivityCheck,
|
||||
policyToolsMissingOwnerCheck,
|
||||
policyToolsUnknownSensitivityCheck,
|
||||
];
|
||||
}
|
||||
44
extensions/policy/src/doctor/strictness.test.ts
Normal file
44
extensions/policy/src/doctor/strictness.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Policy doctor strictness helper tests.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { POLICY_RULE_METADATA } from "./metadata.js";
|
||||
import { isPolicyValueAtLeastAsStrict } from "./strictness.js";
|
||||
|
||||
describe("policy doctor strictness", () => {
|
||||
it("compares policy values through strictness metadata", () => {
|
||||
const allowHosts = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.exec.allowHosts",
|
||||
);
|
||||
const denyTools = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.denyTools",
|
||||
);
|
||||
const fsWorkspaceOnly = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly",
|
||||
);
|
||||
const denyHostNetwork = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "sandbox.containers.denyHostNetwork",
|
||||
);
|
||||
const alsoAllow = POLICY_RULE_METADATA.find(
|
||||
(rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected",
|
||||
);
|
||||
|
||||
expect(allowHosts).toBeDefined();
|
||||
expect(denyTools).toBeDefined();
|
||||
expect(fsWorkspaceOnly).toBeDefined();
|
||||
expect(denyHostNetwork).toBeDefined();
|
||||
expect(alsoAllow).toBeDefined();
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, [], ["sandbox"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], [])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec", "write"], ["exec"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["write"], ["exec"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["group:runtime"], ["exec"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec"], ["group:runtime"])).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, true, true)).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, false, true)).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false);
|
||||
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true);
|
||||
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, [], ["read"])).toBe(false);
|
||||
});
|
||||
});
|
||||
270
extensions/policy/src/doctor/strictness.ts
Normal file
270
extensions/policy/src/doctor/strictness.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// Policy doctor strictness comparisons for scoped policy overlays.
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js";
|
||||
import type { PolicyRuleMetadata } from "./metadata.js";
|
||||
|
||||
type ExecApprovalAllowlistRequirement = {
|
||||
readonly key: string;
|
||||
readonly pattern: string;
|
||||
readonly argPattern?: string;
|
||||
};
|
||||
|
||||
export function isPolicyValueAtLeastAsStrict(
|
||||
metadata: PolicyRuleMetadata,
|
||||
candidate: unknown,
|
||||
baseline: unknown,
|
||||
): boolean {
|
||||
switch (metadata.strictness) {
|
||||
case "allowlist-subset":
|
||||
return isPolicyAllowlistSubset(metadata, candidate, baseline);
|
||||
case "denylist-superset":
|
||||
return isPolicyDenylistSuperset(metadata, candidate, baseline);
|
||||
case "ordered-string":
|
||||
return isPolicyOrderedStringAtLeastAsStrict(metadata, candidate, baseline);
|
||||
case "requires-true":
|
||||
return baseline !== true || candidate === true;
|
||||
case "requires-false":
|
||||
return baseline !== false || candidate === false;
|
||||
case "exact-list":
|
||||
return samePolicyStringList(candidate, baseline, metadata);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPolicyOrderedStringAtLeastAsStrict(
|
||||
metadata: PolicyRuleMetadata,
|
||||
candidate: unknown,
|
||||
baseline: unknown,
|
||||
): boolean {
|
||||
const candidateValue = policyString(candidate, metadata);
|
||||
const baselineValue = policyString(baseline, metadata);
|
||||
if (
|
||||
candidateValue === undefined ||
|
||||
baselineValue === undefined ||
|
||||
metadata.orderedValues === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const orderedValues = metadata.orderedValues.map((entry) =>
|
||||
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
|
||||
);
|
||||
const candidateIndex = orderedValues.indexOf(candidateValue);
|
||||
const baselineIndex = orderedValues.indexOf(baselineValue);
|
||||
return candidateIndex >= 0 && baselineIndex >= 0 && candidateIndex >= baselineIndex;
|
||||
}
|
||||
|
||||
function isPolicyAllowlistSubset(
|
||||
metadata: PolicyRuleMetadata,
|
||||
candidate: unknown,
|
||||
baseline: unknown,
|
||||
): boolean {
|
||||
const candidateList = policyStringList(candidate, metadata);
|
||||
const baselineList = policyStringList(baseline, metadata);
|
||||
if (candidateList === undefined || baselineList === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (metadata.emptyList === "disabled" && baselineList.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (metadata.emptyList === "disabled" && baselineList.length > 0 && candidateList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const allowed = new Set(baselineList);
|
||||
return candidateList.every((entry) => allowed.has(entry));
|
||||
}
|
||||
|
||||
function isPolicyDenylistSuperset(
|
||||
metadata: PolicyRuleMetadata,
|
||||
candidate: unknown,
|
||||
baseline: unknown,
|
||||
): boolean {
|
||||
const candidateList = policyStringList(candidate, metadata);
|
||||
const baselineList = policyStringList(baseline, metadata);
|
||||
if (candidateList === undefined || baselineList === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (metadata.policyPath.join(".") === "tools.denyTools") {
|
||||
return baselineList
|
||||
.flatMap(expandPolicyToolRequirement)
|
||||
.every((tool) => toolListCoversTool(candidateList, tool));
|
||||
}
|
||||
const denied = new Set(candidateList);
|
||||
return baselineList.every((entry) => denied.has(entry));
|
||||
}
|
||||
|
||||
function samePolicyStringList(
|
||||
candidate: unknown,
|
||||
baseline: unknown,
|
||||
metadata: PolicyRuleMetadata,
|
||||
): boolean {
|
||||
const candidateList = policyStringList(candidate, metadata);
|
||||
const baselineList = policyStringList(baseline, metadata);
|
||||
if (candidateList === undefined || baselineList === undefined) {
|
||||
return false;
|
||||
}
|
||||
const candidateSorted = candidateList.toSorted();
|
||||
const baselineSorted = baselineList.toSorted();
|
||||
return (
|
||||
candidateSorted.length === baselineSorted.length &&
|
||||
candidateSorted.every((entry, index) => entry === baselineSorted[index])
|
||||
);
|
||||
}
|
||||
|
||||
function policyStringList(
|
||||
value: unknown,
|
||||
metadata: PolicyRuleMetadata,
|
||||
): readonly string[] | undefined {
|
||||
if (metadata.valueType === "channel-provider-deny-rules") {
|
||||
return channelProviderDenyRuleList(value, metadata);
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected") {
|
||||
const entries = value.map(execApprovalAllowlistRequirement);
|
||||
if (!entries.every((entry): entry is ExecApprovalAllowlistRequirement => entry !== undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
return entries.map((entry) => entry.key);
|
||||
}
|
||||
if (!value.every((entry) => typeof entry === "string")) {
|
||||
return undefined;
|
||||
}
|
||||
return value
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => normalizePolicyStringListEntry(entry, metadata));
|
||||
}
|
||||
|
||||
function normalizePolicyStringListEntry(entry: string, metadata: PolicyRuleMetadata): string {
|
||||
if (metadata.normalizeValues === "model-provider") {
|
||||
return normalizeProviderId(entry);
|
||||
}
|
||||
return metadata.caseSensitive === true ? entry : entry.toLowerCase();
|
||||
}
|
||||
|
||||
function channelProviderDenyRuleList(
|
||||
value: unknown,
|
||||
metadata: PolicyRuleMetadata,
|
||||
): readonly string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const providers: string[] = [];
|
||||
for (const entry of value) {
|
||||
if (!isChannelDenyRule(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
const provider = entry.when?.provider?.trim();
|
||||
if (provider !== undefined && provider !== "") {
|
||||
providers.push(metadata.caseSensitive === true ? provider : provider.toLowerCase());
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function policyString(value: unknown, metadata: PolicyRuleMetadata): string | undefined {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return metadata.caseSensitive === true ? trimmed : trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function execApprovalAllowlistRequirement(
|
||||
value: unknown,
|
||||
): ExecApprovalAllowlistRequirement | undefined {
|
||||
if (typeof value === "string") {
|
||||
const pattern = value.trim();
|
||||
return pattern === "" ? undefined : execApprovalAllowlistRequirementFromParts(pattern);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.some((key) => key !== "argPattern" && key !== "pattern")) {
|
||||
return undefined;
|
||||
}
|
||||
const pattern = typeof value.pattern === "string" ? value.pattern.trim() : "";
|
||||
if (pattern === "") {
|
||||
return undefined;
|
||||
}
|
||||
const argPattern = typeof value.argPattern === "string" ? value.argPattern.trim() : undefined;
|
||||
if (value.argPattern !== undefined && argPattern === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return execApprovalAllowlistRequirementFromParts(
|
||||
pattern,
|
||||
argPattern === "" ? undefined : argPattern,
|
||||
);
|
||||
}
|
||||
|
||||
function execApprovalAllowlistRequirementFromParts(
|
||||
pattern: string,
|
||||
argPattern?: string,
|
||||
): ExecApprovalAllowlistRequirement {
|
||||
return {
|
||||
key: execApprovalAllowlistRequirementKey(pattern, argPattern),
|
||||
pattern,
|
||||
...(argPattern === undefined ? {} : { argPattern }),
|
||||
};
|
||||
}
|
||||
|
||||
function execApprovalAllowlistRequirementKey(
|
||||
pattern: string,
|
||||
argPattern: string | undefined,
|
||||
): string {
|
||||
return `${pattern}\0${argPattern ?? ""}`;
|
||||
}
|
||||
|
||||
function isChannelDenyRule(value: unknown): value is {
|
||||
readonly id?: string;
|
||||
readonly when?: { readonly provider?: string };
|
||||
readonly reason?: string;
|
||||
} {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
(value.id === undefined || typeof value.id === "string") &&
|
||||
(value.reason === undefined || typeof value.reason === "string") &&
|
||||
isRecord(value.when) &&
|
||||
typeof value.when.provider === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function toolListCoversTool(list: readonly string[], tool: string): boolean {
|
||||
for (const entry of list) {
|
||||
const normalized = normalizePolicyToolName(entry);
|
||||
if (normalized === "*" || normalized === tool) {
|
||||
return true;
|
||||
}
|
||||
if (POLICY_TOOL_GROUPS[normalized]?.includes(tool)) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.includes("*") && policyToolGlobMatches(tool, normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function expandPolicyToolRequirement(value: string): readonly string[] {
|
||||
const normalized = normalizePolicyToolName(value);
|
||||
return POLICY_TOOL_GROUPS[normalized] ?? [normalized];
|
||||
}
|
||||
|
||||
function normalizePolicyToolName(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "bash") {
|
||||
return "exec";
|
||||
}
|
||||
if (normalized === "apply-patch") {
|
||||
return "apply_patch";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function policyToolGlobMatches(tool: string, pattern: string): boolean {
|
||||
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`).test(tool);
|
||||
}
|
||||
35
extensions/policy/src/doctor/types.ts
Normal file
35
extensions/policy/src/doctor/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Policy doctor shared types.
|
||||
import type { HealthCheckContext, HealthFinding } from "openclaw/plugin-sdk/health";
|
||||
import type { PolicyEvidence } from "../policy-state.js";
|
||||
import type { POLICY_CHECK_IDS } from "./metadata.js";
|
||||
|
||||
export type PolicyEvaluation = {
|
||||
readonly policyPath: string;
|
||||
readonly policy?: {
|
||||
readonly value: unknown;
|
||||
readonly hash: string;
|
||||
};
|
||||
readonly evidence: PolicyEvidence;
|
||||
readonly expectedAttestationHash?: string;
|
||||
readonly findings: readonly HealthFinding[];
|
||||
readonly attestedFindings: readonly HealthFinding[];
|
||||
};
|
||||
|
||||
export type PolicyDoctorCheckDeps = {
|
||||
readonly evaluatePolicy: (ctx: HealthCheckContext) => Promise<PolicyEvaluation>;
|
||||
readonly findingsForCheck: (
|
||||
evaluation: PolicyEvaluation,
|
||||
checkId: (typeof POLICY_CHECK_IDS)[number],
|
||||
) => readonly HealthFinding[];
|
||||
readonly workspaceRepairsEnabled: (ctx: HealthCheckContext) => boolean;
|
||||
readonly workspaceRepairsDisabledResult: (fileName: string) => {
|
||||
readonly status: "skipped";
|
||||
readonly reason: string;
|
||||
readonly changes: readonly string[];
|
||||
};
|
||||
readonly channelIdsFromFindings: (findings: readonly HealthFinding[]) => readonly string[];
|
||||
readonly disableChannels: (
|
||||
cfg: HealthCheckContext["cfg"],
|
||||
channelIds: readonly string[],
|
||||
) => { readonly config: HealthCheckContext["cfg"]; readonly changed: readonly string[] };
|
||||
};
|
||||
63
extensions/policy/src/doctor/utils.ts
Normal file
63
extensions/policy/src/doctor/utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Shared policy doctor value readers.
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export function readPolicyStringArray(
|
||||
policy: unknown,
|
||||
path: readonly string[],
|
||||
options: { readonly lowercase?: boolean } = {},
|
||||
): readonly string[] | undefined {
|
||||
let current: unknown = policy;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
if (!Array.isArray(current) || !current.every((entry) => typeof entry === "string")) {
|
||||
return undefined;
|
||||
}
|
||||
const lowercase = options.lowercase ?? true;
|
||||
return current
|
||||
.map((entry) => {
|
||||
const trimmed = entry.trim();
|
||||
return lowercase ? trimmed.toLowerCase() : trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function readStringList(
|
||||
policy: unknown,
|
||||
path: readonly string[],
|
||||
options?: { readonly lowercase?: boolean },
|
||||
): readonly string[] {
|
||||
return readPolicyStringArray(policy, path, options) ?? [];
|
||||
}
|
||||
|
||||
export function readString(policy: unknown, path: readonly string[]): string | undefined {
|
||||
let current: unknown = policy;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return typeof current === "string" ? current.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export function ocPathSegment(value: string): string {
|
||||
if (/^(?:[A-Za-z0-9_-]+|#\d+)$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function readPolicyBoolean(policy: unknown, path: readonly string[]): boolean | undefined {
|
||||
let current: unknown = policy;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return typeof current === "boolean" ? current : undefined;
|
||||
}
|
||||
18
extensions/qa-lab/src/artifact-assertion.ts
Normal file
18
extensions/qa-lab/src/artifact-assertion.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { QaSuiteArtifactError } from "./errors.js";
|
||||
|
||||
export async function assertQaSuiteArtifactWritten(
|
||||
kind: "evidence" | "report" | "summary",
|
||||
filePath: string,
|
||||
) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
throw new QaSuiteArtifactError(
|
||||
`${kind}_missing`,
|
||||
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { toQaErrorObject } from "./errors.js";
|
||||
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
|
||||
import {
|
||||
createQaChannelGatewayConfig,
|
||||
@@ -343,7 +344,7 @@ export async function buildQaDockerHarnessImage(
|
||||
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execFile(command, args, { cwd }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(toLintErrorObject(error, "Non-Error rejection"));
|
||||
reject(toQaErrorObject(error, "Non-Error rejection"));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
@@ -368,17 +369,3 @@ export async function buildQaDockerHarnessImage(
|
||||
|
||||
return { imageName };
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -34,3 +34,17 @@ export class QaSuiteInfraError extends Error {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function toQaErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildQaEvidenceGalleryModel,
|
||||
resolveQaEvidenceArtifactFileByIndex,
|
||||
resolveQaEvidenceArtifactFile,
|
||||
resolveQaEvidenceProducerFile,
|
||||
resolveQaEvidenceFile,
|
||||
} from "./evidence-gallery.js";
|
||||
import {
|
||||
@@ -23,11 +25,28 @@ async function writeJson(filePath: string, value: unknown) {
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function producerRootLeakSegments(repoRoot: string) {
|
||||
if (process.platform !== "win32") {
|
||||
return [`nested${repoRoot}`];
|
||||
}
|
||||
return [
|
||||
"nested",
|
||||
...repoRoot
|
||||
.split(/[\\/]+/u)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.replace(/[^A-Za-z0-9._-]/gu, "_")),
|
||||
];
|
||||
}
|
||||
|
||||
function repoRelativePath(repoRoot: string, filePath: string) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function vitestArtifactEvidence(params: {
|
||||
id: string;
|
||||
title: string;
|
||||
artifact: { kind: string; path: string };
|
||||
}) {
|
||||
}): QaEvidenceSummaryJson {
|
||||
return {
|
||||
kind: "openclaw.qa.evidence-summary",
|
||||
schemaVersion: 2,
|
||||
@@ -126,7 +145,7 @@ describe("evidence gallery", () => {
|
||||
expect.objectContaining({
|
||||
exists: true,
|
||||
kind: "runner-result",
|
||||
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&artifactPath=runner%2Fresult.json",
|
||||
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&entryIndex=0&artifactIndex=0",
|
||||
mediaKind: "json",
|
||||
preview: '{\n "ok": true\n}',
|
||||
}),
|
||||
@@ -144,10 +163,152 @@ describe("evidence gallery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes local roots from gallery failure reasons", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "vitest");
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const evidence: QaEvidenceSummaryJson = vitestArtifactEvidence({
|
||||
id: "qa-lab.failure-path",
|
||||
title: "Failure path evidence",
|
||||
artifact: { kind: "log", path: "missing.log" },
|
||||
});
|
||||
evidence.entries[0] = {
|
||||
...evidence.entries[0],
|
||||
result: {
|
||||
status: "blocked",
|
||||
failure: {
|
||||
class: "blocked",
|
||||
reason: `Command failed at ${repoRoot}/openclaw.mjs and file://${repoRoot}/trace.log`,
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeJson(path.join(outputDir, QA_EVIDENCE_FILENAME), evidence);
|
||||
|
||||
const model = await buildQaEvidenceGalleryModel({
|
||||
evidencePath: outputDir,
|
||||
repoRoot,
|
||||
});
|
||||
|
||||
expect(model.entries[0].failureReason).toBe(
|
||||
"Command failed at <repo-root>/openclaw.mjs and file://<repo-root>/trace.log",
|
||||
);
|
||||
expect(JSON.stringify(model)).not.toContain(repoRoot);
|
||||
});
|
||||
|
||||
it("normalizes absolute source and declared artifact paths for gallery links", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "vitest");
|
||||
const artifactPath = path.join(outputDir, "absolute.log");
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
artifactPath,
|
||||
`absolute artifact ${repoRoot}\nfile://${repoRoot}/trace.log\n`,
|
||||
"utf8",
|
||||
);
|
||||
const relativeLeakArtifactPath = `nested${repoRoot}/relative.log`;
|
||||
const relativeLeakFile = path.resolve(outputDir, relativeLeakArtifactPath);
|
||||
await fs.mkdir(path.dirname(relativeLeakFile), { recursive: true });
|
||||
await fs.writeFile(relativeLeakFile, "relative artifact\n", "utf8");
|
||||
const evidence: QaEvidenceSummaryJson = vitestArtifactEvidence({
|
||||
id: "qa-lab.absolute-artifact-path",
|
||||
title: "Absolute artifact path",
|
||||
artifact: { kind: "log", path: artifactPath },
|
||||
});
|
||||
evidence.profile = `${repoRoot}/qa-profile`;
|
||||
evidence.entries[0] = {
|
||||
...evidence.entries[0],
|
||||
coverage: [{ id: `${repoRoot}/coverage`, role: `${repoRoot}/role` }],
|
||||
execution: {
|
||||
...evidence.entries[0].execution!,
|
||||
artifacts: [
|
||||
{
|
||||
...evidence.entries[0].execution!.artifacts[0],
|
||||
kind: `${repoRoot}/log`,
|
||||
source: `${repoRoot}/vitest`,
|
||||
},
|
||||
{
|
||||
kind: "log",
|
||||
path: relativeLeakArtifactPath,
|
||||
source: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
...evidence.entries[0].test,
|
||||
id: `${repoRoot}/qa-lab.absolute-artifact-path`,
|
||||
kind: `${repoRoot}/vitest-test`,
|
||||
source: { path: path.join(repoRoot, "extensions/qa-lab/src/absolute.test.ts") },
|
||||
title: `Absolute artifact path at ${repoRoot}`,
|
||||
},
|
||||
};
|
||||
await writeJson(path.join(outputDir, QA_EVIDENCE_FILENAME), evidence);
|
||||
|
||||
const model = await buildQaEvidenceGalleryModel({
|
||||
evidencePath: outputDir,
|
||||
repoRoot,
|
||||
});
|
||||
|
||||
const artifact = model.entries[0]?.artifacts[0];
|
||||
expect(artifact).toMatchObject({
|
||||
exists: true,
|
||||
kind: "<repo-root>/log",
|
||||
path: ".artifacts/qa-e2e/vitest/absolute.log",
|
||||
preview: "absolute artifact <repo-root>\nfile://<repo-root>/trace.log\n",
|
||||
source: "<repo-root>/vitest",
|
||||
});
|
||||
expect(artifact?.href).toContain("entryIndex=0&artifactIndex=0");
|
||||
const relativeArtifact = model.entries[0]?.artifacts[1];
|
||||
expect(relativeArtifact).toMatchObject({
|
||||
exists: true,
|
||||
path: expect.stringContaining(".artifacts/qa-e2e/vitest/nested"),
|
||||
preview: "relative artifact\n",
|
||||
});
|
||||
expect(decodeURIComponent(relativeArtifact?.href ?? "")).not.toContain(repoRoot);
|
||||
expect(relativeArtifact?.href).toContain("entryIndex=0&artifactIndex=1");
|
||||
expect(model.entries[0]?.sourcePath).toBe("extensions/qa-lab/src/absolute.test.ts");
|
||||
expect(model.entries[0]).toMatchObject({
|
||||
coverage: [{ id: "<repo-root>/coverage", role: "<repo-root>/role" }],
|
||||
id: "<repo-root>/qa-lab.absolute-artifact-path",
|
||||
kind: "<repo-root>/vitest-test",
|
||||
title: "Absolute artifact path at <repo-root>",
|
||||
});
|
||||
expect(model.profile).toBe("<repo-root>/qa-profile");
|
||||
expect(JSON.stringify(model)).not.toContain(repoRoot);
|
||||
await expect(
|
||||
resolveQaEvidenceArtifactFile({
|
||||
artifactPath: "<repo-root>/.artifacts/qa-e2e/vitest/absolute.log",
|
||||
evidencePath: outputDir,
|
||||
repoRoot,
|
||||
}),
|
||||
).resolves.toBe(await fs.realpath(artifactPath));
|
||||
await expect(
|
||||
resolveQaEvidenceArtifactFileByIndex({
|
||||
artifactIndex: 1,
|
||||
entryIndex: 0,
|
||||
evidencePath: outputDir,
|
||||
repoRoot,
|
||||
}),
|
||||
).resolves.toBe(await fs.realpath(relativeLeakFile));
|
||||
});
|
||||
|
||||
it("detects UX Matrix producer context from suite-level evidence artifacts", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const suiteDir = path.join(repoRoot, ".artifacts", "qa-e2e", "suite");
|
||||
const runDir = path.join(suiteDir, "script", "ux-matrix-evidence-dashboard", "run-1");
|
||||
const runDir = path.join(
|
||||
suiteDir,
|
||||
"script",
|
||||
...producerRootLeakSegments(repoRoot),
|
||||
"ux-matrix-evidence-dashboard",
|
||||
"run-1",
|
||||
);
|
||||
const expectedWebScreenshotNeedle =
|
||||
process.platform === "win32"
|
||||
? ".artifacts/qa-e2e/suite/script/nested"
|
||||
: ".artifacts/qa-e2e/suite/script/nested<repo-root>/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png";
|
||||
const expectedCliLogNeedle =
|
||||
process.platform === "win32"
|
||||
? ".artifacts/qa-e2e/suite/script/nested"
|
||||
: ".artifacts/qa-e2e/suite/script/nested<repo-root>/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt";
|
||||
await fs.mkdir(path.join(runDir, "surfaces", "web-ui", "stages", "first-run"), {
|
||||
recursive: true,
|
||||
});
|
||||
@@ -176,22 +337,24 @@ describe("evidence gallery", () => {
|
||||
"proof-gap": 1,
|
||||
},
|
||||
stages: [
|
||||
{ id: `${repoRoot}/diagnostics`, label: "Diagnostics" },
|
||||
{ id: "first-run", label: "First run" },
|
||||
{ id: "error-state", label: "Error state" },
|
||||
],
|
||||
surfaces: [
|
||||
{ id: `${repoRoot}/native`, label: "Native" },
|
||||
{ id: "web-ui", label: "Web UI" },
|
||||
{ id: "cli", label: "CLI" },
|
||||
],
|
||||
cells: [
|
||||
null,
|
||||
{
|
||||
coverageIds: ["ui.control"],
|
||||
coverageIds: [`${repoRoot}/ui.control`],
|
||||
runner: {
|
||||
availability: "local",
|
||||
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
|
||||
command: `${repoRoot}/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard`,
|
||||
lane: "web-ui-playwright",
|
||||
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
|
||||
workflow: `${repoRoot}/.github/workflows/ux-matrix-qa.yml#ux-matrix-local`,
|
||||
},
|
||||
stage: "first-run",
|
||||
status: "pass",
|
||||
@@ -239,7 +402,7 @@ describe("evidence gallery", () => {
|
||||
test: {
|
||||
kind: "ux-matrix-cell",
|
||||
id: "ux-matrix.web-ui.first-run",
|
||||
title: "UX Matrix: web-ui / first-run",
|
||||
title: `UX Matrix: web-ui / first-run at ${repoRoot}`,
|
||||
source: { path: "scripts/ux-matrix/dashboard.ts" },
|
||||
},
|
||||
coverage: [{ id: "ui.control", role: "primary" }],
|
||||
@@ -260,7 +423,14 @@ describe("evidence gallery", () => {
|
||||
artifacts: [
|
||||
{
|
||||
kind: "screenshot",
|
||||
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
|
||||
path: path.join(
|
||||
runDir,
|
||||
"surfaces",
|
||||
"web-ui",
|
||||
"stages",
|
||||
"first-run",
|
||||
"screenshot.png",
|
||||
),
|
||||
source: "ux-matrix:web-ui:first-run",
|
||||
},
|
||||
],
|
||||
@@ -292,7 +462,10 @@ describe("evidence gallery", () => {
|
||||
artifacts: [
|
||||
{
|
||||
kind: "log",
|
||||
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
|
||||
path: repoRelativePath(
|
||||
repoRoot,
|
||||
path.join(runDir, "surfaces", "cli", "stages", "error-state", "logs.txt"),
|
||||
),
|
||||
source: "ux-matrix:cli:error-state",
|
||||
},
|
||||
],
|
||||
@@ -326,8 +499,8 @@ describe("evidence gallery", () => {
|
||||
blocked: 1,
|
||||
"proof-gap": 1,
|
||||
},
|
||||
stages: ["first-run", "error-state"],
|
||||
surfaces: ["web-ui", "cli"],
|
||||
stages: ["<repo-root>/diagnostics", "first-run", "error-state"],
|
||||
surfaces: ["<repo-root>/native", "web-ui", "cli"],
|
||||
},
|
||||
releaseLedger: {
|
||||
counts: {
|
||||
@@ -340,21 +513,19 @@ describe("evidence gallery", () => {
|
||||
expect(model.producerContext?.matrix?.cells).toEqual([
|
||||
{
|
||||
artifactKinds: ["screenshot"],
|
||||
artifactPaths: [
|
||||
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
|
||||
],
|
||||
coverageIds: ["ui.control"],
|
||||
artifactPaths: [expect.stringContaining(expectedWebScreenshotNeedle)],
|
||||
coverageIds: ["<repo-root>/ui.control"],
|
||||
runner: {
|
||||
availability: "local",
|
||||
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
|
||||
command: "<repo-root>/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard",
|
||||
lane: "web-ui-playwright",
|
||||
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
|
||||
workflow: "<repo-root>/.github/workflows/ux-matrix-qa.yml#ux-matrix-local",
|
||||
},
|
||||
stage: "first-run",
|
||||
status: "pass",
|
||||
surface: "web-ui",
|
||||
testId: "ux-matrix.web-ui.first-run",
|
||||
title: "UX Matrix: web-ui / first-run",
|
||||
title: "UX Matrix: web-ui / first-run at <repo-root>",
|
||||
},
|
||||
{
|
||||
artifactKinds: [],
|
||||
@@ -374,9 +545,7 @@ describe("evidence gallery", () => {
|
||||
},
|
||||
{
|
||||
artifactKinds: ["log"],
|
||||
artifactPaths: [
|
||||
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
|
||||
],
|
||||
artifactPaths: [expect.stringContaining(expectedCliLogNeedle)],
|
||||
coverageIds: [],
|
||||
runner: null,
|
||||
stage: "error-state",
|
||||
@@ -388,9 +557,12 @@ describe("evidence gallery", () => {
|
||||
]);
|
||||
expect(model.producerContext?.scorecard?.preview).toContain("# UX Matrix");
|
||||
expect(model.producerContext?.scorecard?.href).toContain("/api/evidence/artifact?");
|
||||
expect(model.producerContext?.scorecard?.href).not.toContain(repoRoot);
|
||||
expect(decodeURIComponent(model.producerContext?.scorecard?.href ?? "")).not.toContain(
|
||||
repoRoot,
|
||||
);
|
||||
expect(model.producerContext?.commands?.preview).toBe("node ux matrix\n");
|
||||
expect(model.producerContext?.commands?.path).toContain("commands.txt");
|
||||
expect(decodeURIComponent(model.producerContext?.commands?.href ?? "")).not.toContain(repoRoot);
|
||||
expect(model.producerContext?.manifest?.preview).toContain('"runId": "run-1"');
|
||||
expect(model.producerContext?.releaseLedger?.preview).toContain('"proof-gap": 1');
|
||||
expect(model.producerContext?.preflight.memory?.path).toContain("preflight/memory.txt");
|
||||
@@ -400,6 +572,14 @@ describe("evidence gallery", () => {
|
||||
);
|
||||
expect(model.producerContext?.preflight.adbDevices?.preview).toBe("List of devices\n");
|
||||
expect(model.evidencePath).toBe(".artifacts/qa-e2e/suite/qa-evidence.json");
|
||||
expect(JSON.stringify(model)).not.toContain(repoRoot);
|
||||
await expect(
|
||||
resolveQaEvidenceProducerFile({
|
||||
evidencePath: suiteDir,
|
||||
producerFile: "scorecard",
|
||||
repoRoot,
|
||||
}),
|
||||
).resolves.toBe(await fs.realpath(path.join(runDir, "scorecard.md")));
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-evidence-outside-"));
|
||||
const outsideCommands = path.join(outsideDir, "commands.txt");
|
||||
await fs.writeFile(outsideCommands, "outside secret\n", "utf8");
|
||||
@@ -413,8 +593,7 @@ describe("evidence gallery", () => {
|
||||
expect(JSON.stringify(symlinkModel)).not.toContain("outside secret");
|
||||
await expect(
|
||||
resolveQaEvidenceArtifactFile({
|
||||
artifactPath:
|
||||
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/scorecard.md",
|
||||
artifactPath: path.relative(repoRoot, path.join(runDir, "scorecard.md")),
|
||||
evidencePath: suiteDir,
|
||||
repoRoot,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Qa Lab plugin module implements generic QA evidence gallery data.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
QaEvidenceArtifactView,
|
||||
@@ -10,7 +12,7 @@ import type {
|
||||
QaEvidenceProducerContext,
|
||||
QaEvidenceProducerContextFile,
|
||||
} from "../shared/evidence-gallery-types.js";
|
||||
import { toRepoRelativePath } from "./cli-paths.js";
|
||||
import { toRepoPath, toRepoRelativePath } from "./cli-paths.js";
|
||||
import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
validateQaEvidenceSummaryJson,
|
||||
@@ -29,6 +31,7 @@ export type {
|
||||
|
||||
const TEXT_PREVIEW_BYTES = 12 * 1024;
|
||||
const ARTIFACT_VIEW_CONCURRENCY = 8;
|
||||
const REPO_ROOT_ARTIFACT_PATH_PREFIX = "<repo-root>/";
|
||||
|
||||
const UX_MATRIX_PRODUCER_FILES = [
|
||||
{ key: "commands", path: "commands.txt", previewKind: "text" },
|
||||
@@ -40,6 +43,7 @@ const UX_MATRIX_PRODUCER_FILES = [
|
||||
{ key: "adbDevices", path: path.join("preflight", "adb-devices.txt"), previewKind: "text" },
|
||||
] as const;
|
||||
|
||||
type UxMatrixProducerFileKey = (typeof UX_MATRIX_PRODUCER_FILES)[number]["key"];
|
||||
type QaEvidenceArtifact = NonNullable<QaEvidenceSummaryEntry["execution"]>["artifacts"][number];
|
||||
|
||||
export class QaEvidenceGalleryError extends Error {
|
||||
@@ -61,6 +65,70 @@ function isInside(root: string, candidate: string) {
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function sanitizeGalleryText(
|
||||
value: string,
|
||||
params: {
|
||||
extraRoots?: readonly string[];
|
||||
repoRoot: string;
|
||||
},
|
||||
) {
|
||||
const localRoots = [...new Set([params.repoRoot, ...(params.extraRoots ?? [])])];
|
||||
const roots = [
|
||||
...localRoots.flatMap((root) => [
|
||||
{ from: path.resolve(root), to: "<repo-root>" },
|
||||
{ from: pathToFileURL(path.resolve(root)).href, to: "file://<repo-root>" },
|
||||
]),
|
||||
{ from: os.homedir(), to: "<home>" },
|
||||
{ from: pathToFileURL(os.homedir()).href, to: "file://<home>" },
|
||||
].filter((entry) => entry.from && entry.from !== path.parse(entry.from).root);
|
||||
return roots
|
||||
.toSorted((a, b) => b.from.length - a.from.length)
|
||||
.reduce((text, entry) => text.replaceAll(entry.from, entry.to), value);
|
||||
}
|
||||
|
||||
function displayGalleryPath(
|
||||
value: string,
|
||||
params: {
|
||||
extraRoots?: readonly string[];
|
||||
repoRoot: string;
|
||||
},
|
||||
) {
|
||||
if (path.isAbsolute(value)) {
|
||||
const absolute = path.resolve(value);
|
||||
for (const root of [params.repoRoot, ...(params.extraRoots ?? [])]) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
if (isInside(resolvedRoot, absolute)) {
|
||||
return sanitizeGalleryText(toRepoPath(path.relative(resolvedRoot, absolute)), params);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sanitizeGalleryText(value, params);
|
||||
}
|
||||
|
||||
function sanitizeGalleryPreview(
|
||||
value: string | null,
|
||||
params: {
|
||||
extraRoots?: readonly string[];
|
||||
repoRoot: string;
|
||||
},
|
||||
) {
|
||||
return value === null ? null : sanitizeGalleryText(value, params);
|
||||
}
|
||||
|
||||
function sanitizeGalleryStringArray(
|
||||
values: Iterable<unknown>,
|
||||
params: {
|
||||
extraRoots?: readonly string[];
|
||||
repoRoot: string;
|
||||
},
|
||||
) {
|
||||
return readOrderedStringArray(
|
||||
Array.from(values)
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => sanitizeGalleryText(value, params)),
|
||||
);
|
||||
}
|
||||
|
||||
async function realpathIfExists(filePath: string): Promise<string | null> {
|
||||
return fs.realpath(filePath).catch(() => null);
|
||||
}
|
||||
@@ -143,11 +211,86 @@ export async function resolveQaEvidenceArtifactFile(params: {
|
||||
throw evidenceError("Evidence artifact is not declared by this evidence summary.", 403);
|
||||
}
|
||||
|
||||
export async function resolveQaEvidenceArtifactFileByIndex(params: {
|
||||
artifactIndex: number;
|
||||
entryIndex: number;
|
||||
evidencePath: string;
|
||||
repoRoot: string;
|
||||
}): Promise<string> {
|
||||
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
|
||||
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
|
||||
if (
|
||||
!Number.isSafeInteger(params.entryIndex) ||
|
||||
params.entryIndex < 0 ||
|
||||
!Number.isSafeInteger(params.artifactIndex) ||
|
||||
params.artifactIndex < 0
|
||||
) {
|
||||
throw evidenceError("Evidence artifact index is invalid.", 400);
|
||||
}
|
||||
const summary = validateQaEvidenceSummaryJson(
|
||||
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
|
||||
);
|
||||
const artifact = summary.entries[params.entryIndex]?.execution?.artifacts[params.artifactIndex];
|
||||
if (!artifact) {
|
||||
throw evidenceError("Evidence artifact not found.", 404);
|
||||
}
|
||||
const artifactFile = await resolveArtifactFileWithinRoots({
|
||||
artifactPath: artifact.path,
|
||||
evidenceDir: path.dirname(evidencePath),
|
||||
repoRoot,
|
||||
});
|
||||
if (!artifactFile) {
|
||||
throw evidenceError("Evidence artifact not found.", 404);
|
||||
}
|
||||
return artifactFile;
|
||||
}
|
||||
|
||||
export async function resolveQaEvidenceProducerFile(params: {
|
||||
evidencePath: string;
|
||||
producerFile: string;
|
||||
repoRoot: string;
|
||||
}): Promise<string> {
|
||||
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
|
||||
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
|
||||
const producerFile = UX_MATRIX_PRODUCER_FILES.find((file) => file.key === params.producerFile);
|
||||
if (!producerFile) {
|
||||
throw evidenceError("Evidence producer file is unknown.", 400);
|
||||
}
|
||||
const summary = validateQaEvidenceSummaryJson(
|
||||
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
|
||||
);
|
||||
const producerRoot = await findUxMatrixProducerRoot({
|
||||
evidencePath,
|
||||
repoRoot,
|
||||
summaryEntries: summary.entries,
|
||||
});
|
||||
if (!producerRoot) {
|
||||
throw evidenceError("Evidence producer context not found.", 404);
|
||||
}
|
||||
const evidenceDir = path.dirname(evidencePath);
|
||||
const producerPath = path.join(producerRoot, producerFile.path);
|
||||
const realProducerFile = await resolveContainedFileIfExists(producerPath, [
|
||||
repoRoot,
|
||||
evidenceDir,
|
||||
]);
|
||||
if (!realProducerFile) {
|
||||
throw evidenceError("Evidence producer file not found.", 404);
|
||||
}
|
||||
return realProducerFile;
|
||||
}
|
||||
|
||||
function isExplicitRepoRootArtifactPath(raw: string): boolean {
|
||||
const normalized = raw.split(/[\\/]+/u).join("/");
|
||||
return normalized.startsWith(".artifacts/");
|
||||
}
|
||||
|
||||
function repoRootTokenArtifactPath(raw: string): string | null {
|
||||
const normalized = raw.split(/[\\/]+/u).join("/");
|
||||
return normalized.startsWith(REPO_ROOT_ARTIFACT_PATH_PREFIX)
|
||||
? normalized.slice(REPO_ROOT_ARTIFACT_PATH_PREFIX.length)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Resolve an artifact path against pre-resolved roots without re-reading the evidence file.
|
||||
// Returns null when the path is missing or escapes both roots; callers map that to an error.
|
||||
async function resolveArtifactFileWithinRoots(params: {
|
||||
@@ -159,8 +302,13 @@ async function resolveArtifactFileWithinRoots(params: {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const candidates = path.isAbsolute(raw) ? [raw] : [path.resolve(params.evidenceDir, raw)];
|
||||
if (!path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
|
||||
const tokenPath = repoRootTokenArtifactPath(raw);
|
||||
const candidates = tokenPath
|
||||
? [path.resolve(params.repoRoot, tokenPath)]
|
||||
: path.isAbsolute(raw)
|
||||
? [raw]
|
||||
: [path.resolve(params.evidenceDir, raw)];
|
||||
if (!tokenPath && !path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
|
||||
candidates.push(path.resolve(params.repoRoot, raw));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
@@ -290,38 +438,66 @@ async function readJsonIfExists(
|
||||
}
|
||||
}
|
||||
|
||||
function artifactHref(evidencePath: string, artifactPath: string) {
|
||||
const params = new URLSearchParams({
|
||||
evidencePath,
|
||||
artifactPath,
|
||||
});
|
||||
function artifactHref(
|
||||
evidencePath: string,
|
||||
artifact:
|
||||
| {
|
||||
artifactPath: string;
|
||||
}
|
||||
| {
|
||||
artifactIndex: number;
|
||||
entryIndex: number;
|
||||
}
|
||||
| {
|
||||
producerFile: UxMatrixProducerFileKey;
|
||||
},
|
||||
) {
|
||||
const params = new URLSearchParams({ evidencePath });
|
||||
if ("artifactPath" in artifact) {
|
||||
params.set("artifactPath", artifact.artifactPath);
|
||||
} else if ("producerFile" in artifact) {
|
||||
params.set("producerFile", artifact.producerFile);
|
||||
} else {
|
||||
params.set("entryIndex", String(artifact.entryIndex));
|
||||
params.set("artifactIndex", String(artifact.artifactIndex));
|
||||
}
|
||||
return `/api/evidence/artifact?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function buildProducerContextFile(params: {
|
||||
allowedRoots: readonly string[];
|
||||
artifactPath: string;
|
||||
extraRoots: readonly string[];
|
||||
filePath: string;
|
||||
hrefEvidencePath: string;
|
||||
previewKind: "json" | "text";
|
||||
producerFile: UxMatrixProducerFileKey;
|
||||
repoRoot: string;
|
||||
}): Promise<QaEvidenceProducerContextFile | null> {
|
||||
const realFile = await resolveContainedFileIfExists(params.filePath, params.allowedRoots);
|
||||
if (!realFile) {
|
||||
return null;
|
||||
}
|
||||
const repoPath = toRepoRelativePath(params.repoRoot, params.filePath);
|
||||
return {
|
||||
href: artifactHref(params.hrefEvidencePath, params.artifactPath),
|
||||
path: repoPath,
|
||||
preview: await readPreview(realFile, params.previewKind).catch(() => null),
|
||||
href: artifactHref(params.hrefEvidencePath, { producerFile: params.producerFile }),
|
||||
path: displayGalleryPath(params.filePath, params),
|
||||
preview: await readPreview(realFile, params.previewKind)
|
||||
.then((preview) =>
|
||||
sanitizeGalleryPreview(preview, {
|
||||
extraRoots: params.extraRoots,
|
||||
repoRoot: params.repoRoot,
|
||||
}),
|
||||
)
|
||||
.catch(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildArtifactView(params: {
|
||||
allowedArtifactFiles: ReadonlySet<string>;
|
||||
artifactIndex: number;
|
||||
artifact: QaEvidenceArtifact;
|
||||
evidenceDir: string;
|
||||
entryIndex: number;
|
||||
extraRoots: readonly string[];
|
||||
hrefEvidencePath: string;
|
||||
repoRoot: string;
|
||||
}): Promise<QaEvidenceArtifactView> {
|
||||
@@ -331,6 +507,16 @@ async function buildArtifactView(params: {
|
||||
evidenceDir: params.evidenceDir,
|
||||
repoRoot: params.repoRoot,
|
||||
}).catch(() => null);
|
||||
const realFileRepoPath =
|
||||
realFile && isInside(params.repoRoot, realFile)
|
||||
? toRepoRelativePath(params.repoRoot, realFile)
|
||||
: null;
|
||||
const displayPath =
|
||||
(realFileRepoPath ? sanitizeGalleryText(realFileRepoPath, params) : null) ??
|
||||
sanitizeGalleryText(params.artifact.path, {
|
||||
extraRoots: params.extraRoots,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
if (!realFile || !params.allowedArtifactFiles.has(realFile)) {
|
||||
return {
|
||||
exists: false,
|
||||
@@ -338,24 +524,37 @@ async function buildArtifactView(params: {
|
||||
? "Evidence artifact is not declared by this evidence summary."
|
||||
: "Evidence artifact not found.",
|
||||
href: null,
|
||||
kind: params.artifact.kind,
|
||||
kind: sanitizeGalleryText(params.artifact.kind, params),
|
||||
mediaKind,
|
||||
path: params.artifact.path,
|
||||
path: displayPath,
|
||||
preview: null,
|
||||
source: params.artifact.source,
|
||||
source: sanitizeGalleryText(params.artifact.source, params),
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
error: null,
|
||||
href: artifactHref(params.hrefEvidencePath, params.artifact.path),
|
||||
kind: params.artifact.kind,
|
||||
href: artifactHref(params.hrefEvidencePath, {
|
||||
artifactIndex: params.artifactIndex,
|
||||
entryIndex: params.entryIndex,
|
||||
}),
|
||||
kind: sanitizeGalleryText(params.artifact.kind, params),
|
||||
mediaKind,
|
||||
path: params.artifact.path,
|
||||
preview: await readPreview(realFile, mediaKind).catch(
|
||||
(error: unknown) => `Preview unavailable: ${formatErrorMessage(error)}`,
|
||||
),
|
||||
source: params.artifact.source,
|
||||
path: displayPath,
|
||||
preview: await readPreview(realFile, mediaKind)
|
||||
.then((preview) =>
|
||||
sanitizeGalleryPreview(preview, {
|
||||
extraRoots: params.extraRoots,
|
||||
repoRoot: params.repoRoot,
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) =>
|
||||
sanitizeGalleryText(`Preview unavailable: ${formatErrorMessage(error)}`, {
|
||||
extraRoots: params.extraRoots,
|
||||
repoRoot: params.repoRoot,
|
||||
}),
|
||||
),
|
||||
source: sanitizeGalleryText(params.artifact.source, params),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,19 +590,26 @@ function readStringArray(values: Iterable<unknown>) {
|
||||
return readOrderedStringArray(values).toSorted();
|
||||
}
|
||||
|
||||
function readMatrixDimensionIds(value: unknown, fallback: readonly string[]): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return readOrderedStringArray(fallback);
|
||||
function readMatrixDimensionIds(params: {
|
||||
extraRoots: readonly string[];
|
||||
fallback: readonly string[];
|
||||
repoRoot: string;
|
||||
value: unknown;
|
||||
}): string[] {
|
||||
if (!Array.isArray(params.value)) {
|
||||
return sanitizeGalleryStringArray(params.fallback, params);
|
||||
}
|
||||
const ids = readOrderedStringArray(
|
||||
value.map((entry) => {
|
||||
const ids = sanitizeGalleryStringArray(
|
||||
params.value.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return entry;
|
||||
}
|
||||
return readString(readRecord(entry)?.id);
|
||||
}),
|
||||
params,
|
||||
);
|
||||
for (const fallbackId of fallback) {
|
||||
for (const rawFallbackId of params.fallback) {
|
||||
const fallbackId = sanitizeGalleryText(rawFallbackId, params);
|
||||
if (!ids.includes(fallbackId)) {
|
||||
ids.push(fallbackId);
|
||||
}
|
||||
@@ -439,7 +645,9 @@ function buildUxMatrixEvidenceEntryIndex(entries: readonly QaEvidenceSummaryEntr
|
||||
}
|
||||
|
||||
function readMatrixCells(params: {
|
||||
extraRoots: readonly string[];
|
||||
matrix: Record<string, unknown> | null;
|
||||
repoRoot: string;
|
||||
summaryEntries: readonly QaEvidenceSummaryEntry[];
|
||||
}): QaEvidenceMatrixCellView[] {
|
||||
const rawCells = Array.isArray(params.matrix?.cells)
|
||||
@@ -449,34 +657,54 @@ function readMatrixCells(params: {
|
||||
: [];
|
||||
const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries);
|
||||
return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => {
|
||||
const surface = readString(cell.surface);
|
||||
const stage = readString(cell.stage);
|
||||
const status = readString(cell.status) ?? "proof-gap";
|
||||
if (!surface || !stage) {
|
||||
const rawSurface = readString(cell.surface);
|
||||
const rawStage = readString(cell.stage);
|
||||
const rawStatus = readString(cell.status) ?? "proof-gap";
|
||||
if (!rawSurface || !rawStage) {
|
||||
return [];
|
||||
}
|
||||
const entry =
|
||||
status === "proof-gap" ? null : (entriesByCell.get(`${surface}:${stage}`) ?? null);
|
||||
rawStatus === "proof-gap" ? null : (entriesByCell.get(`${rawSurface}:${rawStage}`) ?? null);
|
||||
const artifacts = entry?.execution?.artifacts ?? [];
|
||||
const runner = readRecord(cell.runner);
|
||||
const sanitizeCellString = (value: string) =>
|
||||
sanitizeGalleryText(value, {
|
||||
extraRoots: params.extraRoots,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
const readRunnerString = (value: unknown) => {
|
||||
const text = readString(value);
|
||||
return text ? sanitizeCellString(text) : null;
|
||||
};
|
||||
return [
|
||||
{
|
||||
artifactKinds: readStringArray(artifacts.map((artifact) => artifact.kind)),
|
||||
artifactPaths: artifacts.map((artifact) => artifact.path),
|
||||
coverageIds: readStringArray(Array.isArray(cell.coverageIds) ? cell.coverageIds : []),
|
||||
artifactKinds: readStringArray(
|
||||
artifacts.map((artifact) => sanitizeCellString(artifact.kind)),
|
||||
),
|
||||
artifactPaths: artifacts.map((artifact) =>
|
||||
displayGalleryPath(artifact.path, {
|
||||
extraRoots: params.extraRoots,
|
||||
repoRoot: params.repoRoot,
|
||||
}),
|
||||
),
|
||||
coverageIds: readStringArray(
|
||||
(Array.isArray(cell.coverageIds) ? cell.coverageIds : []).map((coverageId) =>
|
||||
typeof coverageId === "string" ? sanitizeCellString(coverageId) : coverageId,
|
||||
),
|
||||
),
|
||||
runner: runner
|
||||
? {
|
||||
availability: readString(runner.availability),
|
||||
command: readString(runner.command),
|
||||
lane: readString(runner.lane),
|
||||
workflow: readString(runner.workflow),
|
||||
availability: readRunnerString(runner.availability),
|
||||
command: readRunnerString(runner.command),
|
||||
lane: readRunnerString(runner.lane),
|
||||
workflow: readRunnerString(runner.workflow),
|
||||
}
|
||||
: null,
|
||||
stage,
|
||||
status,
|
||||
surface,
|
||||
testId: entry?.test.id ?? null,
|
||||
title: entry?.test.title ?? null,
|
||||
stage: sanitizeCellString(rawStage),
|
||||
status: sanitizeCellString(rawStatus),
|
||||
surface: sanitizeCellString(rawSurface),
|
||||
testId: entry?.test.id ? sanitizeCellString(entry.test.id) : null,
|
||||
title: entry?.test.title ? sanitizeCellString(entry.test.title) : null,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -533,6 +761,7 @@ async function findUxMatrixProducerRoot(params: {
|
||||
|
||||
async function buildProducerContext(params: {
|
||||
evidencePath: string;
|
||||
extraRoots: readonly string[];
|
||||
hrefEvidencePath: string;
|
||||
repoRoot: string;
|
||||
summaryEntries: readonly QaEvidenceSummaryEntry[];
|
||||
@@ -555,16 +784,20 @@ async function buildProducerContext(params: {
|
||||
const manifest = await readJsonIfExists(manifestPath, allowedRoots);
|
||||
const matrix = await readJsonIfExists(matrixPath, allowedRoots);
|
||||
const releaseLedger = await readJsonIfExists(releaseLedgerPath, allowedRoots);
|
||||
const run = readRecord(manifest?.run);
|
||||
const runId = readString(run?.runId);
|
||||
const runStatus = readString(run?.status);
|
||||
const producerFiles = Object.fromEntries(
|
||||
await Promise.all(
|
||||
UX_MATRIX_PRODUCER_FILES.map(async (file) => [
|
||||
file.key,
|
||||
await buildProducerContextFile({
|
||||
allowedRoots,
|
||||
artifactPath: toRepoRelativePath(repoRoot, producerPaths[file.key]),
|
||||
extraRoots: params.extraRoots,
|
||||
filePath: producerPaths[file.key],
|
||||
hrefEvidencePath: params.hrefEvidencePath,
|
||||
previewKind: file.previewKind,
|
||||
producerFile: file.key,
|
||||
repoRoot,
|
||||
}),
|
||||
]),
|
||||
@@ -574,7 +807,9 @@ async function buildProducerContext(params: {
|
||||
QaEvidenceProducerContextFile | null
|
||||
>;
|
||||
const matrixCells = readMatrixCells({
|
||||
extraRoots: params.extraRoots,
|
||||
matrix,
|
||||
repoRoot,
|
||||
summaryEntries: params.summaryEntries,
|
||||
});
|
||||
return {
|
||||
@@ -584,23 +819,27 @@ async function buildProducerContext(params: {
|
||||
manifest && producerFiles.manifest
|
||||
? {
|
||||
...producerFiles.manifest,
|
||||
runId: readString(readRecord(manifest.run)?.runId),
|
||||
runStatus: readString(readRecord(manifest.run)?.status),
|
||||
runId: runId ? sanitizeGalleryText(runId, params) : null,
|
||||
runStatus: runStatus ? sanitizeGalleryText(runStatus, params) : null,
|
||||
}
|
||||
: null,
|
||||
matrix: matrix
|
||||
? {
|
||||
cells: matrixCells,
|
||||
counts: readCountRecord(matrix.counts),
|
||||
path: toRepoRelativePath(repoRoot, matrixPath),
|
||||
stages: readMatrixDimensionIds(
|
||||
matrix.stages,
|
||||
matrixCells.map((cell) => cell.stage),
|
||||
),
|
||||
surfaces: readMatrixDimensionIds(
|
||||
matrix.surfaces,
|
||||
matrixCells.map((cell) => cell.surface),
|
||||
),
|
||||
path: displayGalleryPath(matrixPath, { extraRoots: params.extraRoots, repoRoot }),
|
||||
stages: readMatrixDimensionIds({
|
||||
extraRoots: params.extraRoots,
|
||||
fallback: matrixCells.map((cell) => cell.stage),
|
||||
repoRoot,
|
||||
value: matrix.stages,
|
||||
}),
|
||||
surfaces: readMatrixDimensionIds({
|
||||
extraRoots: params.extraRoots,
|
||||
fallback: matrixCells.map((cell) => cell.surface),
|
||||
repoRoot,
|
||||
value: matrix.surfaces,
|
||||
}),
|
||||
}
|
||||
: null,
|
||||
preflight: {
|
||||
@@ -614,7 +853,7 @@ async function buildProducerContext(params: {
|
||||
counts: readCountRecord(releaseLedger.counts),
|
||||
}
|
||||
: null,
|
||||
rootPath: toRepoRelativePath(repoRoot, rootPath),
|
||||
rootPath: displayGalleryPath(rootPath, { extraRoots: params.extraRoots, repoRoot }),
|
||||
scorecard: producerFiles.scorecard,
|
||||
};
|
||||
}
|
||||
@@ -642,7 +881,8 @@ export async function buildQaEvidenceGalleryModel(params: {
|
||||
evidencePath: string;
|
||||
repoRoot: string;
|
||||
}): Promise<QaEvidenceGalleryModel> {
|
||||
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
|
||||
const requestedRepoRoot = path.resolve(params.repoRoot);
|
||||
const repoRoot = await fs.realpath(requestedRepoRoot);
|
||||
const evidencePath = await resolveQaEvidenceFile({
|
||||
inputPath: params.evidencePath,
|
||||
repoRoot,
|
||||
@@ -667,29 +907,47 @@ export async function buildQaEvidenceGalleryModel(params: {
|
||||
});
|
||||
const limitArtifactView = createConcurrencyLimit(ARTIFACT_VIEW_CONCURRENCY);
|
||||
const entries = await Promise.all(
|
||||
summary.entries.map(async (entry): Promise<QaEvidenceGalleryEntryView> => {
|
||||
summary.entries.map(async (entry, entryIndex): Promise<QaEvidenceGalleryEntryView> => {
|
||||
counts[entry.result.status] += 1;
|
||||
const sanitizeEntryText = (value: string) =>
|
||||
sanitizeGalleryText(value, {
|
||||
extraRoots: [requestedRepoRoot],
|
||||
repoRoot,
|
||||
});
|
||||
return {
|
||||
artifacts: await Promise.all(
|
||||
(entry.execution?.artifacts ?? []).map((artifact) =>
|
||||
(entry.execution?.artifacts ?? []).map((artifact, artifactIndex) =>
|
||||
limitArtifactView(() =>
|
||||
buildArtifactView({
|
||||
allowedArtifactFiles,
|
||||
artifact,
|
||||
artifactIndex,
|
||||
evidenceDir,
|
||||
entryIndex,
|
||||
extraRoots: [requestedRepoRoot],
|
||||
hrefEvidencePath,
|
||||
repoRoot,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
coverage: entry.coverage,
|
||||
failureReason: entry.result.failure?.reason ?? null,
|
||||
id: entry.test.id,
|
||||
kind: entry.test.kind,
|
||||
sourcePath: entry.test.source?.path ?? null,
|
||||
coverage: entry.coverage.map((coverage) => ({
|
||||
id: sanitizeEntryText(coverage.id),
|
||||
role: sanitizeEntryText(coverage.role),
|
||||
})),
|
||||
failureReason: entry.result.failure?.reason
|
||||
? sanitizeEntryText(entry.result.failure.reason)
|
||||
: null,
|
||||
id: sanitizeEntryText(entry.test.id),
|
||||
kind: sanitizeEntryText(entry.test.kind),
|
||||
sourcePath: entry.test.source?.path
|
||||
? displayGalleryPath(entry.test.source.path, {
|
||||
extraRoots: [requestedRepoRoot],
|
||||
repoRoot,
|
||||
})
|
||||
: null,
|
||||
status: entry.result.status,
|
||||
title: entry.test.title,
|
||||
title: sanitizeEntryText(entry.test.title),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -699,9 +957,12 @@ export async function buildQaEvidenceGalleryModel(params: {
|
||||
evidenceMode: summary.evidenceMode,
|
||||
evidencePath: hrefEvidencePath,
|
||||
generatedAt: summary.generatedAt,
|
||||
profile: summary.profile ?? null,
|
||||
profile: summary.profile
|
||||
? sanitizeGalleryText(summary.profile, { extraRoots: [requestedRepoRoot], repoRoot })
|
||||
: null,
|
||||
producerContext: await buildProducerContext({
|
||||
evidencePath,
|
||||
extraRoots: [requestedRepoRoot],
|
||||
hrefEvidencePath,
|
||||
repoRoot,
|
||||
summaryEntries: summary.entries,
|
||||
|
||||
@@ -1087,14 +1087,27 @@ describe("buildQaRuntimeEnv", () => {
|
||||
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=convex-maintainer-secret",
|
||||
"OPENCLAW_LIVE_CODEX_API_KEY=codex-live-secret",
|
||||
"botToken=12345:AbCdEfGhIjKl",
|
||||
"--botToken=12345:flag-secret",
|
||||
'"driverToken":"12345:driver-secr3t"',
|
||||
"sutToken='12345:sut-secr3t'",
|
||||
"leaseToken=lease-12345",
|
||||
'"apiKey":"secret-json-api-key"',
|
||||
"clientSecret=secret-client-secret&secret-tail",
|
||||
"url=http://127.0.0.1:18789/#token=abc123",
|
||||
"callback=https://gateway.example.test/callback?access_token=secret-access-token&ok=1",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
stderrLogPath,
|
||||
[
|
||||
"Authorization: Bearer secret+/token=123456",
|
||||
"Cookie: qa_session=secret-cookie; theme=dark",
|
||||
"Set-Cookie: qa_session=secret-cookie; HttpOnly",
|
||||
"x-api-key: secret-header-api-key",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(stderrLogPath, "Authorization: Bearer secret+/token=123456", "utf8");
|
||||
await mkdir(path.join(tempRoot, "state"), { recursive: true });
|
||||
await writeFile(path.join(tempRoot, "state", "secret.txt"), "do-not-copy", "utf8");
|
||||
|
||||
@@ -1119,14 +1132,23 @@ describe("buildQaRuntimeEnv", () => {
|
||||
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=<redacted>",
|
||||
"OPENCLAW_LIVE_CODEX_API_KEY=<redacted>",
|
||||
"botToken=<redacted>",
|
||||
"--botToken=<redacted>",
|
||||
'"driverToken":"<redacted>"',
|
||||
"sutToken=<redacted>",
|
||||
"leaseToken=<redacted>",
|
||||
'"apiKey":"<redacted>"',
|
||||
"clientSecret=<redacted>",
|
||||
"url=http://127.0.0.1:18789/#token=<redacted>",
|
||||
"callback=https://gateway.example.test/callback?access_token=<redacted>&ok=1",
|
||||
].join("\n"),
|
||||
);
|
||||
await expect(readFile(path.join(artifactDir, "gateway.stderr.log"), "utf8")).resolves.toBe(
|
||||
"Authorization: Bearer <redacted>",
|
||||
[
|
||||
"Authorization: Bearer <redacted>",
|
||||
"Cookie: <redacted>",
|
||||
"Set-Cookie: <redacted>",
|
||||
"x-api-key: <redacted>",
|
||||
].join("\n"),
|
||||
);
|
||||
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain(
|
||||
"was not copied because it may contain credentials or auth tokens",
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resolveQaRuntimeHostVersion,
|
||||
} from "./bundled-plugin-staging.js";
|
||||
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
|
||||
import { QaSuiteInfraError } from "./errors.js";
|
||||
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
|
||||
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
|
||||
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
|
||||
import { splitQaModelRef, type QaProviderMode } from "./model-selection.js";
|
||||
@@ -810,7 +810,7 @@ export async function startQaGatewayChild(params: {
|
||||
}
|
||||
}
|
||||
if (!rpcReady) {
|
||||
throw toLintErrorObject(
|
||||
throw toQaErrorObject(
|
||||
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
|
||||
"Non-Error thrown",
|
||||
);
|
||||
@@ -913,7 +913,7 @@ export async function startQaGatewayChild(params: {
|
||||
}
|
||||
}
|
||||
if (!rpcReady) {
|
||||
throw toLintErrorObject(
|
||||
throw toQaErrorObject(
|
||||
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
|
||||
"Non-Error thrown",
|
||||
);
|
||||
@@ -1067,17 +1067,3 @@ export async function startQaGatewayChild(params: {
|
||||
}
|
||||
}
|
||||
export { testing as __testing };
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,35 @@ const QA_GATEWAY_DEBUG_SECRET_ENV_VARS = Object.freeze([
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
]);
|
||||
const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([
|
||||
"accessToken",
|
||||
"access_token",
|
||||
"apiKey",
|
||||
"api_key",
|
||||
"botToken",
|
||||
"clientSecret",
|
||||
"client_secret",
|
||||
"cookie",
|
||||
"driverToken",
|
||||
"sutToken",
|
||||
"leaseToken",
|
||||
"refreshToken",
|
||||
"refresh_token",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
]);
|
||||
const QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS = Object.freeze([
|
||||
"access_token",
|
||||
"api_key",
|
||||
"apiKey",
|
||||
"auth",
|
||||
"deviceToken",
|
||||
"id_token",
|
||||
"key",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"token",
|
||||
]);
|
||||
const QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS = Object.freeze(["cookie", "set-cookie", "x-api-key"]);
|
||||
|
||||
function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
|
||||
const source = pattern.source.replace(/^\^/u, "").replace(/\$$/u, "");
|
||||
@@ -26,8 +50,30 @@ function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
|
||||
.replace(new RegExp(`"(${source})"\\s*:\\s*"[^"]*"`, "g"), `"$1":"<redacted>"`);
|
||||
}
|
||||
|
||||
function redactSecretValueKey(text: string, key: string) {
|
||||
const escapedKey = escapeRegExp(key);
|
||||
return text
|
||||
.replace(new RegExp(`([?#&]${escapedKey}=)[^&\\s]+`, "gi"), "$1<redacted>")
|
||||
.replace(
|
||||
new RegExp(`(^|\\s)(--${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
|
||||
`$1$2$3<redacted>`,
|
||||
)
|
||||
.replace(
|
||||
new RegExp(`(^|[^\\w?#&-])(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
|
||||
`$1$2$3<redacted>`,
|
||||
)
|
||||
.replace(new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"), `$1"<redacted>"`);
|
||||
}
|
||||
|
||||
export function redactQaGatewayDebugText(text: string) {
|
||||
let redacted = text;
|
||||
for (const key of QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS) {
|
||||
const escapedKey = escapeRegExp(key);
|
||||
redacted = redacted.replace(
|
||||
new RegExp(`^(\\s*${escapedKey}\\s*:\\s*).+$`, "gim"),
|
||||
"$1<redacted>",
|
||||
);
|
||||
}
|
||||
for (const envVar of QA_GATEWAY_DEBUG_SECRET_ENV_VARS) {
|
||||
const escapedEnvVar = escapeRegExp(envVar);
|
||||
redacted = redacted.replace(
|
||||
@@ -43,20 +89,18 @@ export function redactQaGatewayDebugText(text: string) {
|
||||
redacted = redactSecretEnvKeyPattern(redacted, pattern);
|
||||
}
|
||||
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
|
||||
const escapedKey = escapeRegExp(key);
|
||||
redacted = redacted.replace(
|
||||
new RegExp(`\\b(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
|
||||
`$1$2<redacted>`,
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"),
|
||||
`$1"<redacted>"`,
|
||||
);
|
||||
redacted = redactSecretValueKey(redacted, key);
|
||||
}
|
||||
return redacted
|
||||
.replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "<redacted>")
|
||||
.replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer <redacted>")
|
||||
.replaceAll(/([?#&]token=)[^&\s]+/gi, "$1<redacted>");
|
||||
.replaceAll(
|
||||
new RegExp(
|
||||
`([?#&](?:${QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS.map(escapeRegExp).join("|")})=)[^&\\s]+`,
|
||||
"gi",
|
||||
),
|
||||
"$1<redacted>",
|
||||
);
|
||||
}
|
||||
|
||||
export function formatQaGatewayLogsForError(logs: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { compareToolCallShape, stableHash } from "./parity-shared.js";
|
||||
// Qa Lab plugin module implements harness parity behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
RuntimeId,
|
||||
RuntimeParityCell,
|
||||
@@ -98,10 +98,6 @@ export type HarnessParityResult = {
|
||||
firstDriftTurn?: number;
|
||||
};
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function countComparableTranscriptRecords(transcriptBytes: string) {
|
||||
let count = 0;
|
||||
for (const line of transcriptBytes.split(/\r?\n/u)) {
|
||||
@@ -127,25 +123,6 @@ function countComparableTranscriptRecords(transcriptBytes: string) {
|
||||
return count;
|
||||
}
|
||||
|
||||
function normalizeForStableHash(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeForStableHash(entry));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.toSorted((left, right) => left.localeCompare(right))
|
||||
.map((key) => [key, normalizeForStableHash(record[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableHash(value: unknown) {
|
||||
return sha256(JSON.stringify(normalizeForStableHash(value)) ?? "null");
|
||||
}
|
||||
|
||||
function readPositiveNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
}
|
||||
@@ -190,23 +167,6 @@ function normalizeTextForParity(text: string) {
|
||||
return text.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function compareToolCallShape(left: RuntimeParityToolCall[], right: RuntimeParityToolCall[]) {
|
||||
if (left.length !== right.length) {
|
||||
return `tool call count differs (${left.length} vs ${right.length})`;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftCall = left[index];
|
||||
const rightCall = right[index];
|
||||
if (!leftCall || !rightCall) {
|
||||
return `tool call row ${index + 1} missing`;
|
||||
}
|
||||
if (leftCall.tool !== rightCall.tool || leftCall.argsHash !== rightCall.argsHash) {
|
||||
return `tool call ${index + 1} differs (${leftCall.tool}/${leftCall.argsHash} vs ${rightCall.tool}/${rightCall.argsHash})`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compareToolResultShape(left: RuntimeParityToolCall[], right: RuntimeParityToolCall[]) {
|
||||
const total = Math.min(left.length, right.length);
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
|
||||
@@ -384,7 +384,8 @@ describe("qa-lab server", () => {
|
||||
port: 0,
|
||||
outputPath,
|
||||
repoRoot,
|
||||
controlUiUrl: "http://127.0.0.1:18789/?token=qa-token&panel=chat#token=fragment-token",
|
||||
controlUiUrl:
|
||||
"https://gateway.example.test/?token=qa-token&api_key=qa-api-key&id_token=qa-id-token&panel=chat#token=fragment-token",
|
||||
embeddedGateway: "disabled",
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
@@ -403,8 +404,8 @@ describe("qa-lab server", () => {
|
||||
};
|
||||
expect(bootstrap.defaults.conversationId).toBe("qa-operator");
|
||||
expect(bootstrap.defaults.senderId).toBe("qa-operator");
|
||||
expect(bootstrap.controlUiUrl).toBe("http://127.0.0.1:18789/?panel=chat");
|
||||
expect(bootstrap.controlUiEmbeddedUrl).toBe("http://127.0.0.1:18789/?panel=chat");
|
||||
expect(bootstrap.controlUiUrl).toBe("https://gateway.example.test/?panel=chat");
|
||||
expect(bootstrap.controlUiEmbeddedUrl).toBe("https://gateway.example.test/?panel=chat");
|
||||
expect(bootstrap.kickoffTask).toContain("Lobster Invaders");
|
||||
expect(bootstrap.scenarios.length).toBeGreaterThanOrEqual(10);
|
||||
expect(bootstrap.scenarios.map((scenario) => scenario.id)).toContain("dm-chat-baseline");
|
||||
@@ -422,7 +423,20 @@ describe("qa-lab server", () => {
|
||||
).json()) as {
|
||||
status: { gateway: { url: string } };
|
||||
};
|
||||
expect(startupStatus.status.gateway.url).toBe("http://127.0.0.1:18789/?panel=chat");
|
||||
expect(startupStatus.status.gateway.url).toBe("https://gateway.example.test/?panel=chat");
|
||||
|
||||
lab.setControlUi({
|
||||
controlUiUrl:
|
||||
"/control-ui/?token=late-token&api_key=late-api-key&id_token=late-id-token&panel=chat#token=fragment-token",
|
||||
});
|
||||
const relativeBootstrap = (await (
|
||||
await fetchWithRetry(`${lab.baseUrl}/api/bootstrap`)
|
||||
).json()) as {
|
||||
controlUiUrl: string | null;
|
||||
controlUiEmbeddedUrl: string | null;
|
||||
};
|
||||
expect(relativeBootstrap.controlUiUrl).toBe("/control-ui/?panel=chat");
|
||||
expect(relativeBootstrap.controlUiEmbeddedUrl).toBe("/control-ui/?panel=chat");
|
||||
|
||||
const messageResponse = await fetch(`${lab.baseUrl}/api/inbound/message`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -19,7 +19,9 @@ import { createQaBusState, type QaBusState } from "./bus-state.js";
|
||||
import {
|
||||
QaEvidenceGalleryError,
|
||||
buildQaEvidenceGalleryModel,
|
||||
resolveQaEvidenceArtifactFileByIndex,
|
||||
resolveQaEvidenceArtifactFile,
|
||||
resolveQaEvidenceProducerFile,
|
||||
} from "./evidence-gallery.js";
|
||||
import { createQaRunnerRuntime } from "./harness-runtime.js";
|
||||
import {
|
||||
@@ -137,12 +139,33 @@ function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefa
|
||||
|
||||
const CONTROL_UI_CREDENTIAL_QUERY_KEYS = new Set([
|
||||
"access_token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"auth",
|
||||
"devicetoken",
|
||||
"id_token",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"token",
|
||||
]);
|
||||
const CONTROL_UI_CREDENTIAL_QUERY_PATTERN =
|
||||
/([?&])(?:access_token|api_?key|auth|deviceToken|id_token|password|refresh_token|token)=[^&#\s]*&?/gi;
|
||||
|
||||
function stripSensitiveQueryParamsFromText(rawUrl: string): string {
|
||||
let sanitized = rawUrl;
|
||||
for (;;) {
|
||||
const next = sanitized
|
||||
.replace(CONTROL_UI_CREDENTIAL_QUERY_PATTERN, (match: string, separator: string) =>
|
||||
match.endsWith("&") ? separator : "",
|
||||
)
|
||||
.replace(/[?&]$/, "")
|
||||
.replace("?&", "?");
|
||||
if (next === sanitized) {
|
||||
return next;
|
||||
}
|
||||
sanitized = next;
|
||||
}
|
||||
}
|
||||
|
||||
function stripSensitiveQueryParams(rawUrl: string): string {
|
||||
try {
|
||||
@@ -154,13 +177,7 @@ function stripSensitiveQueryParams(rawUrl: string): string {
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return rawUrl
|
||||
.replace(
|
||||
/([?&])(?:access_token|auth|deviceToken|password|refresh_token|token)=[^&#\s]*&?/gi,
|
||||
(match: string, separator: string) => (match.endsWith("&") ? separator : ""),
|
||||
)
|
||||
.replace(/[?&]$/, "")
|
||||
.replace("?&", "?");
|
||||
return stripSensitiveQueryParamsFromText(rawUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,15 +470,34 @@ export async function startQaLabServer(
|
||||
) {
|
||||
const evidencePath = url.searchParams.get("evidencePath")?.trim();
|
||||
const artifactPath = url.searchParams.get("artifactPath")?.trim();
|
||||
if (!evidencePath || !artifactPath) {
|
||||
writeError(res, 400, "Missing evidencePath or artifactPath");
|
||||
const producerFile = url.searchParams.get("producerFile")?.trim();
|
||||
const entryIndexText = url.searchParams.get("entryIndex")?.trim();
|
||||
const artifactIndexText = url.searchParams.get("artifactIndex")?.trim();
|
||||
if (
|
||||
!evidencePath ||
|
||||
(!artifactPath && !producerFile && (!entryIndexText || !artifactIndexText))
|
||||
) {
|
||||
writeError(res, 400, "Missing evidencePath and artifact selector");
|
||||
return;
|
||||
}
|
||||
const artifactFile = await resolveQaEvidenceArtifactFile({
|
||||
artifactPath,
|
||||
evidencePath,
|
||||
repoRoot,
|
||||
});
|
||||
const artifactFile = artifactPath
|
||||
? await resolveQaEvidenceArtifactFile({
|
||||
artifactPath,
|
||||
evidencePath,
|
||||
repoRoot,
|
||||
})
|
||||
: producerFile
|
||||
? await resolveQaEvidenceProducerFile({
|
||||
evidencePath,
|
||||
producerFile,
|
||||
repoRoot,
|
||||
})
|
||||
: await resolveQaEvidenceArtifactFileByIndex({
|
||||
artifactIndex: Number(artifactIndexText),
|
||||
entryIndex: Number(entryIndexText),
|
||||
evidencePath,
|
||||
repoRoot,
|
||||
});
|
||||
const artifactStats = await fs.promises.stat(artifactFile);
|
||||
res.writeHead(200, {
|
||||
"content-type": detectQaEvidenceArtifactContentType(artifactFile),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { chromium } from "playwright-core";
|
||||
import { z } from "zod";
|
||||
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
|
||||
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
|
||||
import {
|
||||
defaultQaModelForMode,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
redactQaLiveLaneIssues,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import { assertLiveScenarioReply as assertDiscordScenarioReply } from "../shared/live-scenario-reply.js";
|
||||
import {
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
selectLiveTransportScenarios,
|
||||
@@ -382,11 +384,6 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof DISCORD_QA_ENV_KEY
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function resolveDiscordQaRuntimeEnv(env: NodeJS.ProcessEnv = process.env): DiscordQaRuntimeEnv {
|
||||
const voiceChannelId = env.OPENCLAW_QA_DISCORD_VOICE_CHANNEL_ID?.trim();
|
||||
const runtimeEnv = {
|
||||
@@ -1482,22 +1479,6 @@ function matchesDiscordScenarioReply(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function assertDiscordScenarioReply(params: {
|
||||
expectedTextIncludes?: string[];
|
||||
message: DiscordObservedMessage;
|
||||
}) {
|
||||
if (!params.message.text.trim()) {
|
||||
throw new Error(`reply message ${params.message.messageId} was empty`);
|
||||
}
|
||||
for (const expected of params.expectedTextIncludes ?? []) {
|
||||
if (!params.message.text.includes(expected)) {
|
||||
throw new Error(
|
||||
`reply message ${params.message.messageId} missing expected text: ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertDiscordApplicationCommandsRegistered(params: {
|
||||
applicationId: string;
|
||||
expectedCommandNames: string[];
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export function formatApprovalResultValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (value == null) {
|
||||
return "<missing>";
|
||||
}
|
||||
return JSON.stringify(value) ?? "<unserializable>";
|
||||
}
|
||||
|
||||
export function readAcceptedApprovalRequest(result: unknown) {
|
||||
const accepted =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { id?: unknown; status?: unknown })
|
||||
: null;
|
||||
if (accepted?.status !== "accepted") {
|
||||
throw new Error(
|
||||
`approval request status was ${formatApprovalResultValue(
|
||||
accepted?.status,
|
||||
)} instead of accepted`,
|
||||
);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
export function readAcceptedApprovalRequestId(result: unknown) {
|
||||
const id = readAcceptedApprovalRequest(result).id;
|
||||
if (typeof id !== "string" || id.trim().length === 0) {
|
||||
throw new Error(`approval request id was ${formatApprovalResultValue(id)}`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function assertApprovalDecisionResult(params: { decision: string; result: unknown }) {
|
||||
const resultDecision =
|
||||
typeof params.result === "object" && params.result !== null
|
||||
? (params.result as { decision?: unknown }).decision
|
||||
: undefined;
|
||||
if (resultDecision !== params.decision) {
|
||||
throw new Error(
|
||||
`approval decision was ${formatApprovalResultValue(resultDecision)} instead of ${params.decision}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type QaInferredCredentialSource = "convex" | "env";
|
||||
|
||||
export function inferQaCredentialSource(
|
||||
value: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): QaInferredCredentialSource {
|
||||
const normalized =
|
||||
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
|
||||
return normalized === "convex" ? "convex" : "env";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
type LiveScenarioReplyMessage = {
|
||||
messageId: string | number;
|
||||
text: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function assertLiveScenarioReply(params: {
|
||||
expectedTextIncludes?: string[];
|
||||
message: LiveScenarioReplyMessage;
|
||||
}) {
|
||||
if (!params.message.text.trim()) {
|
||||
throw new Error(`reply message ${params.message.messageId} was empty`);
|
||||
}
|
||||
for (const expected of params.expectedTextIncludes ?? []) {
|
||||
if (!params.message.text.includes(expected)) {
|
||||
throw new Error(
|
||||
`reply message ${params.message.messageId} missing expected text: ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { z } from "zod";
|
||||
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
|
||||
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
|
||||
import {
|
||||
defaultQaModelForMode,
|
||||
@@ -21,10 +22,16 @@ import {
|
||||
acquireQaCredentialLease,
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
} from "../shared/credential-lease.runtime.js";
|
||||
import {
|
||||
assertApprovalDecisionResult,
|
||||
formatApprovalResultValue,
|
||||
readAcceptedApprovalRequestId,
|
||||
} from "../shared/live-approval-result.js";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { inferQaCredentialSource as inferSlackCredentialSource } from "../shared/live-credential-source.js";
|
||||
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import {
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
@@ -504,20 +511,6 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof SLACK_QA_ENV_KEYS)
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function inferSlackCredentialSource(
|
||||
value: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): "convex" | "env" {
|
||||
const normalized =
|
||||
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
|
||||
return normalized === "convex" ? "convex" : "env";
|
||||
}
|
||||
|
||||
function normalizeSlackId(value: string, label: string) {
|
||||
const normalized = value.trim();
|
||||
if (!/^[A-Z][A-Z0-9]+$/.test(normalized)) {
|
||||
@@ -722,39 +715,6 @@ async function listSlackThreadMessages(params: {
|
||||
return replies.messages ?? [];
|
||||
}
|
||||
|
||||
function formatApprovalResultValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (value == null) {
|
||||
return "<missing>";
|
||||
}
|
||||
return JSON.stringify(value) ?? "<unserializable>";
|
||||
}
|
||||
|
||||
function readAcceptedApprovalRequest(result: unknown) {
|
||||
const accepted =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { id?: unknown; status?: unknown })
|
||||
: null;
|
||||
if (accepted?.status !== "accepted") {
|
||||
throw new Error(
|
||||
`approval request status was ${formatApprovalResultValue(
|
||||
accepted?.status,
|
||||
)} instead of accepted`,
|
||||
);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function readAcceptedApprovalRequestId(result: unknown) {
|
||||
const id = readAcceptedApprovalRequest(result).id;
|
||||
if (typeof id !== "string" || id.trim().length === 0) {
|
||||
throw new Error(`approval request id was ${formatApprovalResultValue(id)}`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function collectSlackBlockStringFields(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
@@ -1386,21 +1346,6 @@ async function resolveApprovalDecision(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function assertApprovalDecisionResult(params: {
|
||||
decision: SlackQaApprovalDecision;
|
||||
result: unknown;
|
||||
}) {
|
||||
const resultDecision =
|
||||
typeof params.result === "object" && params.result !== null
|
||||
? (params.result as { decision?: unknown }).decision
|
||||
: undefined;
|
||||
if (resultDecision !== params.decision) {
|
||||
throw new Error(
|
||||
`approval decision was ${formatApprovalResultValue(resultDecision)} instead of ${params.decision}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSlackApprovalScenario(params: {
|
||||
channelId: string;
|
||||
context: Omit<SlackQaScenarioContext, "sentTs">;
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
type QaEvidenceTiming,
|
||||
} from "../../evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
|
||||
import {
|
||||
parseQaProgressBooleanEnv as parseTelegramQaProgressBooleanEnv,
|
||||
sanitizeQaProgressValue as sanitizeTelegramQaProgressValue,
|
||||
} from "../../progress-format.js";
|
||||
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
|
||||
import {
|
||||
defaultQaModelForMode,
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
redactQaLiveLaneIssues,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import { assertLiveScenarioReply as assertTelegramScenarioReply } from "../shared/live-scenario-reply.js";
|
||||
import type { LiveTransportCheckResult } from "../shared/live-transport-result.js";
|
||||
import {
|
||||
normalizeLiveTransportRttOptions,
|
||||
@@ -565,11 +571,6 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof TELEGRAM_QA_ENV_KE
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function readConfigRecord(root: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const value = root[key];
|
||||
if (!isRecord(value)) {
|
||||
@@ -578,20 +579,6 @@ function readConfigRecord(root: Record<string, unknown>, key: string): Record<st
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTelegramQaProgressBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldLogTelegramQaLiveProgress(env: NodeJS.ProcessEnv = process.env) {
|
||||
const override = parseTelegramQaProgressBooleanEnv(env[QA_SUITE_PROGRESS_ENV]);
|
||||
if (override !== undefined) {
|
||||
@@ -684,20 +671,6 @@ function writeTelegramQaProgress(enabled: boolean, message: string) {
|
||||
process.stderr.write(`${TELEGRAM_QA_PROGRESS_PREFIX} ${message}\n`);
|
||||
}
|
||||
|
||||
function sanitizeTelegramQaProgressValue(value: string): string {
|
||||
let normalized = "";
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
continue;
|
||||
}
|
||||
const isControl = code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
normalized += isControl ? " " : char;
|
||||
}
|
||||
normalized = normalized.replace(/\s+/gu, " ").trim();
|
||||
return normalized.length > 0 ? normalized : "<empty>";
|
||||
}
|
||||
|
||||
function formatTelegramQaProgressDetails(details: string): string {
|
||||
const sanitized = sanitizeTelegramQaProgressValue(details);
|
||||
if (sanitized.length <= TELEGRAM_QA_PROGRESS_DETAIL_LIMIT) {
|
||||
@@ -1470,22 +1443,6 @@ function matchesTelegramScenarioReply(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function assertTelegramScenarioReply(params: {
|
||||
expectedTextIncludes?: string[];
|
||||
message: TelegramObservedMessage;
|
||||
}) {
|
||||
if (!params.message.text.trim()) {
|
||||
throw new Error(`reply message ${params.message.messageId} was empty`);
|
||||
}
|
||||
for (const expected of params.expectedTextIncludes ?? []) {
|
||||
if (!params.message.text.includes(expected)) {
|
||||
throw new Error(
|
||||
`reply message ${params.message.messageId} missing expected text: ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertTelegramCanaryPresenceReply(message: TelegramObservedMessage) {
|
||||
if (!message.senderIsBot) {
|
||||
throw new Error(`canary reply message ${message.messageId} was not sent by a bot`);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { z } from "zod";
|
||||
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
|
||||
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
|
||||
import { fingerprintQaCredentialId } from "../../qa-credentials-fingerprint.runtime.js";
|
||||
import {
|
||||
@@ -29,10 +30,16 @@ import {
|
||||
acquireQaCredentialLease,
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
} from "../shared/credential-lease.runtime.js";
|
||||
import {
|
||||
assertApprovalDecisionResult,
|
||||
formatApprovalResultValue,
|
||||
readAcceptedApprovalRequestId,
|
||||
} from "../shared/live-approval-result.js";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
redactQaLiveLaneDetails,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { inferQaCredentialSource as inferWhatsAppCredentialSource } from "../shared/live-credential-source.js";
|
||||
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import {
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
@@ -1387,11 +1394,6 @@ export const WHATSAPP_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardSce
|
||||
scenarios: WHATSAPP_QA_SCENARIOS,
|
||||
});
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof WHATSAPP_QA_ENV_KEYS)[number]) {
|
||||
const value = env[key]?.trim();
|
||||
if (!value) {
|
||||
@@ -1400,15 +1402,6 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof WHATSAPP_QA_ENV_KE
|
||||
return value;
|
||||
}
|
||||
|
||||
function inferWhatsAppCredentialSource(
|
||||
value: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): "convex" | "env" {
|
||||
const normalized =
|
||||
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
|
||||
return normalized === "convex" ? "convex" : "env";
|
||||
}
|
||||
|
||||
function resolveWhatsAppMetadataRedaction(env: NodeJS.ProcessEnv = process.env) {
|
||||
const raw = env[QA_REDACT_PUBLIC_METADATA_ENV];
|
||||
return raw === undefined ? true : isTruthyOptIn(raw);
|
||||
@@ -2139,39 +2132,6 @@ async function startWhatsAppQaDriverSessionWithRetry(params: { authDir: string }
|
||||
throw new Error("unreachable WhatsApp QA driver retry loop exit");
|
||||
}
|
||||
|
||||
function formatApprovalResultValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (value == null) {
|
||||
return "<missing>";
|
||||
}
|
||||
return JSON.stringify(value) ?? "<unserializable>";
|
||||
}
|
||||
|
||||
function readAcceptedApprovalRequest(result: unknown) {
|
||||
const accepted =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { id?: unknown; status?: unknown })
|
||||
: null;
|
||||
if (accepted?.status !== "accepted") {
|
||||
throw new Error(
|
||||
`approval request status was ${formatApprovalResultValue(
|
||||
accepted?.status,
|
||||
)} instead of accepted`,
|
||||
);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function readAcceptedApprovalRequestId(result: unknown) {
|
||||
const id = readAcceptedApprovalRequest(result).id;
|
||||
if (typeof id !== "string" || id.trim().length === 0) {
|
||||
throw new Error(`approval request id was ${formatApprovalResultValue(id)}`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function requestWhatsAppApproval(params: {
|
||||
approvalId: string;
|
||||
driverPhoneE164: string;
|
||||
@@ -2265,21 +2225,6 @@ async function resolveApprovalDecision(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function assertApprovalDecisionResult(params: {
|
||||
decision: WhatsAppQaApprovalDecision;
|
||||
result: unknown;
|
||||
}) {
|
||||
const resultDecision =
|
||||
typeof params.result === "object" && params.result !== null
|
||||
? (params.result as { decision?: unknown }).decision
|
||||
: undefined;
|
||||
if (resultDecision !== params.decision) {
|
||||
throw new Error(
|
||||
`approval decision was ${formatApprovalResultValue(resultDecision)} instead of ${params.decision}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesWhatsAppApprovalPendingText(params: {
|
||||
approvalId: string;
|
||||
approvalKind: WhatsAppQaApprovalKind;
|
||||
|
||||
10
extensions/qa-lab/src/mantis-options.runtime.ts
Normal file
10
extensions/qa-lab/src/mantis-options.runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Qa Lab plugin module implements shared Mantis option parsing helpers.
|
||||
export function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
52
extensions/qa-lab/src/mantis-phase-timer.runtime.ts
Normal file
52
extensions/qa-lab/src/mantis-phase-timer.runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Qa Lab plugin module implements shared Mantis phase timing behavior.
|
||||
export type MantisPhaseTiming = {
|
||||
durationMs: number;
|
||||
finishedAt: string;
|
||||
name: string;
|
||||
startedAt: string;
|
||||
status: "accepted" | "fail" | "pass";
|
||||
};
|
||||
|
||||
export type MantisPhaseTimings = {
|
||||
phases: MantisPhaseTiming[];
|
||||
totalMs: number;
|
||||
};
|
||||
|
||||
export function createPhaseTimer(startedAt: Date) {
|
||||
const phases: MantisPhaseTiming[] = [];
|
||||
const origin = startedAt.getTime();
|
||||
function recordPhase(name: string, phaseStarted: Date, status: MantisPhaseTiming["status"]) {
|
||||
const phaseFinished = new Date();
|
||||
phases.push({
|
||||
durationMs: phaseFinished.getTime() - phaseStarted.getTime(),
|
||||
finishedAt: phaseFinished.toISOString(),
|
||||
name,
|
||||
startedAt: phaseStarted.toISOString(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
async function timePhase<T>(name: string, run: () => Promise<T>): Promise<T> {
|
||||
const phaseStarted = new Date();
|
||||
try {
|
||||
const result = await run();
|
||||
recordPhase(name, phaseStarted, "pass");
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordPhase(name, phaseStarted, "fail");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function snapshot(now = new Date()): MantisPhaseTimings {
|
||||
return {
|
||||
phases: [...phases],
|
||||
totalMs: now.getTime() - origin,
|
||||
};
|
||||
}
|
||||
function updatePhaseStatus(name: string, status: MantisPhaseTiming["status"]) {
|
||||
const phase = phases.findLast((entry) => entry.name === name);
|
||||
if (phase) {
|
||||
phase.status = status;
|
||||
}
|
||||
}
|
||||
return { recordPhase, snapshot, timePhase, updatePhaseStatus };
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { spawn, type SpawnOptions } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { trimToValue } from "../mantis-options.runtime.js";
|
||||
|
||||
export type CommandResult = {
|
||||
stderr: string;
|
||||
@@ -26,11 +27,6 @@ export type CrabboxInspect = {
|
||||
state?: string;
|
||||
};
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export async function defaultCommandRunner(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
|
||||
import {
|
||||
type CommandRunner,
|
||||
type CrabboxInspect,
|
||||
@@ -89,16 +90,6 @@ const BROWSER_PROFILE_ARCHIVE_ENV = "OPENCLAW_MANTIS_BROWSER_PROFILE_TGZ_B64";
|
||||
const BROWSER_PROFILE_DIR_ENV = "OPENCLAW_MANTIS_BROWSER_PROFILE_DIR";
|
||||
const DEFAULT_VIDEO_DURATION_SECONDS = 10;
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function defaultOutputDir(repoRoot: string, startedAt: Date) {
|
||||
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
|
||||
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `desktop-browser-${stamp}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
|
||||
|
||||
export type MantisDiscordSmokeOptions = {
|
||||
channelId?: string;
|
||||
@@ -100,16 +101,6 @@ const DEFAULT_GUILD_ID_ENV = "OPENCLAW_QA_DISCORD_GUILD_ID";
|
||||
const DEFAULT_CHANNEL_ID_ENV = "OPENCLAW_QA_DISCORD_CHANNEL_ID";
|
||||
const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function assertDiscordSnowflake(value: string, label: string) {
|
||||
if (!/^\d{17,20}$/u.test(value)) {
|
||||
throw new Error(`${label} must be a Discord snowflake.`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
import { QA_EVIDENCE_FILENAME, validateQaEvidenceSummaryJson } from "../evidence-summary.js";
|
||||
import { trimToValue } from "../mantis-options.runtime.js";
|
||||
|
||||
export type MantisBeforeAfterOptions = {
|
||||
allowFailures?: boolean;
|
||||
@@ -134,11 +135,6 @@ const MANTIS_SCENARIO_CONFIGS: Record<string, MantisScenarioConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeRequiredLiteral<T extends string>(
|
||||
value: string | undefined,
|
||||
defaultValue: T,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
acquireQaCredentialLease,
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
} from "../live-transports/shared/credential-lease.runtime.js";
|
||||
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
|
||||
import { createPhaseTimer, type MantisPhaseTimings } from "../mantis-phase-timer.runtime.js";
|
||||
import {
|
||||
type CommandRunner,
|
||||
type CrabboxInspect,
|
||||
@@ -103,19 +105,6 @@ type MantisSlackDesktopSmokeSummary = {
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
type MantisPhaseTiming = {
|
||||
durationMs: number;
|
||||
finishedAt: string;
|
||||
name: string;
|
||||
startedAt: string;
|
||||
status: "accepted" | "fail" | "pass";
|
||||
};
|
||||
|
||||
type MantisPhaseTimings = {
|
||||
phases: MantisPhaseTiming[];
|
||||
totalMs: number;
|
||||
};
|
||||
|
||||
type SlackDesktopRemoteMetadata = {
|
||||
gatewayAlive?: boolean;
|
||||
gatewayPid?: string;
|
||||
@@ -165,16 +154,6 @@ const HYDRATE_MODE_ENV = "OPENCLAW_MANTIS_HYDRATE_MODE";
|
||||
const SLACK_URL_ENV = "OPENCLAW_MANTIS_SLACK_URL";
|
||||
const SLACK_CHANNEL_ID_ENV = "OPENCLAW_MANTIS_SLACK_CHANNEL_ID";
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function normalizeHydrateMode(
|
||||
value: string | undefined,
|
||||
): MantisSlackDesktopHydrateMode | undefined {
|
||||
@@ -188,45 +167,6 @@ function normalizeHydrateMode(
|
||||
throw new Error(`Unsupported Mantis Slack desktop hydrate mode: ${value}`);
|
||||
}
|
||||
|
||||
function createPhaseTimer(startedAt: Date) {
|
||||
const phases: MantisPhaseTiming[] = [];
|
||||
const origin = startedAt.getTime();
|
||||
function recordPhase(name: string, phaseStarted: Date, status: MantisPhaseTiming["status"]) {
|
||||
const phaseFinished = new Date();
|
||||
phases.push({
|
||||
durationMs: phaseFinished.getTime() - phaseStarted.getTime(),
|
||||
finishedAt: phaseFinished.toISOString(),
|
||||
name,
|
||||
startedAt: phaseStarted.toISOString(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
async function timePhase<T>(name: string, run: () => Promise<T>): Promise<T> {
|
||||
const phaseStarted = new Date();
|
||||
try {
|
||||
const result = await run();
|
||||
recordPhase(name, phaseStarted, "pass");
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordPhase(name, phaseStarted, "fail");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function snapshot(now = new Date()): MantisPhaseTimings {
|
||||
return {
|
||||
phases: [...phases],
|
||||
totalMs: now.getTime() - origin,
|
||||
};
|
||||
}
|
||||
function updatePhaseStatus(name: string, status: MantisPhaseTiming["status"]) {
|
||||
const phase = phases.findLast((entry) => entry.name === name);
|
||||
if (phase) {
|
||||
phase.status = status;
|
||||
}
|
||||
}
|
||||
return { recordPhase, snapshot, timePhase, updatePhaseStatus };
|
||||
}
|
||||
|
||||
function defaultOutputDir(repoRoot: string, startedAt: Date) {
|
||||
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
|
||||
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `slack-desktop-${stamp}`);
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
acquireQaCredentialLease,
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
} from "../live-transports/shared/credential-lease.runtime.js";
|
||||
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
|
||||
import { createPhaseTimer, type MantisPhaseTimings } from "../mantis-phase-timer.runtime.js";
|
||||
import {
|
||||
type CommandRunner,
|
||||
type CrabboxInspect,
|
||||
@@ -95,19 +97,6 @@ type MantisTelegramDesktopBuilderSummary = {
|
||||
timings: MantisPhaseTimings;
|
||||
};
|
||||
|
||||
type MantisPhaseTiming = {
|
||||
durationMs: number;
|
||||
finishedAt: string;
|
||||
name: string;
|
||||
startedAt: string;
|
||||
status: "accepted" | "fail" | "pass";
|
||||
};
|
||||
|
||||
type MantisPhaseTimings = {
|
||||
phases: MantisPhaseTiming[];
|
||||
totalMs: number;
|
||||
};
|
||||
|
||||
type TelegramDesktopRemoteMetadata = {
|
||||
gatewayAlive?: boolean;
|
||||
gatewayPid?: string;
|
||||
@@ -138,16 +127,6 @@ const TELEGRAM_PROFILE_ARCHIVE_ENV_NAME_ENV =
|
||||
"OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_ARCHIVE_ENV";
|
||||
const TELEGRAM_PROFILE_DIR_ENV = "OPENCLAW_MANTIS_TELEGRAM_DESKTOP_PROFILE_DIR";
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function normalizeHydrateMode(
|
||||
value: string | undefined,
|
||||
): MantisTelegramDesktopHydrateMode | undefined {
|
||||
@@ -161,45 +140,6 @@ function normalizeHydrateMode(
|
||||
throw new Error(`Unsupported Mantis Telegram desktop hydrate mode: ${value}`);
|
||||
}
|
||||
|
||||
function createPhaseTimer(startedAt: Date) {
|
||||
const phases: MantisPhaseTiming[] = [];
|
||||
const origin = startedAt.getTime();
|
||||
function recordPhase(name: string, phaseStarted: Date, status: MantisPhaseTiming["status"]) {
|
||||
const phaseFinished = new Date();
|
||||
phases.push({
|
||||
durationMs: phaseFinished.getTime() - phaseStarted.getTime(),
|
||||
finishedAt: phaseFinished.toISOString(),
|
||||
name,
|
||||
startedAt: phaseStarted.toISOString(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
async function timePhase<T>(name: string, run: () => Promise<T>): Promise<T> {
|
||||
const phaseStarted = new Date();
|
||||
try {
|
||||
const result = await run();
|
||||
recordPhase(name, phaseStarted, "pass");
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordPhase(name, phaseStarted, "fail");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function snapshot(now = new Date()): MantisPhaseTimings {
|
||||
return {
|
||||
phases: [...phases],
|
||||
totalMs: now.getTime() - origin,
|
||||
};
|
||||
}
|
||||
function updatePhaseStatus(name: string, status: MantisPhaseTiming["status"]) {
|
||||
const phase = phases.findLast((entry) => entry.name === name);
|
||||
if (phase) {
|
||||
phase.status = status;
|
||||
}
|
||||
}
|
||||
return { recordPhase, snapshot, timePhase, updatePhaseStatus };
|
||||
}
|
||||
|
||||
function defaultOutputDir(repoRoot: string, startedAt: Date) {
|
||||
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
|
||||
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `telegram-desktop-${stamp}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { pathExists, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
|
||||
import {
|
||||
type CommandRunner,
|
||||
type CrabboxInspect,
|
||||
@@ -141,16 +142,6 @@ const CRABBOX_KEEP_ENV = "OPENCLAW_MANTIS_KEEP_VM";
|
||||
const CRABBOX_IDLE_TIMEOUT_ENV = "OPENCLAW_MANTIS_CRABBOX_IDLE_TIMEOUT";
|
||||
const CRABBOX_TTL_ENV = "OPENCLAW_MANTIS_CRABBOX_TTL";
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function defaultOutputDir(repoRoot: string, startedAt: Date) {
|
||||
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
|
||||
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `visual-task-${stamp}`);
|
||||
|
||||
50
extensions/qa-lab/src/parity-shared.ts
Normal file
50
extensions/qa-lab/src/parity-shared.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Qa Lab plugin module implements shared parity comparison helpers.
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type ParityToolCallShape = {
|
||||
argsHash: string;
|
||||
tool: string;
|
||||
};
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeForStableHash(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeForStableHash(entry));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.toSorted((left, right) => left.localeCompare(right))
|
||||
.map((key) => [key, normalizeForStableHash(record[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function stableHash(value: unknown) {
|
||||
return sha256(JSON.stringify(normalizeForStableHash(value)) ?? "null");
|
||||
}
|
||||
|
||||
export function compareToolCallShape(
|
||||
left: readonly ParityToolCallShape[],
|
||||
right: readonly ParityToolCallShape[],
|
||||
): string | undefined {
|
||||
if (left.length !== right.length) {
|
||||
return `tool call count differs (${left.length} vs ${right.length})`;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftCall = left[index];
|
||||
const rightCall = right[index];
|
||||
if (!leftCall || !rightCall) {
|
||||
return `tool call row ${index + 1} missing`;
|
||||
}
|
||||
if (leftCall.tool !== rightCall.tool || leftCall.argsHash !== rightCall.argsHash) {
|
||||
return `tool call ${index + 1} differs (${leftCall.tool}/${leftCall.argsHash} vs ${rightCall.tool}/${rightCall.argsHash})`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
27
extensions/qa-lab/src/progress-format.ts
Normal file
27
extensions/qa-lab/src/progress-format.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function parseQaProgressBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sanitizeQaProgressValue(value: string): string {
|
||||
let normalized = "";
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
continue;
|
||||
}
|
||||
const isControl = code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
normalized += isControl ? " " : char;
|
||||
}
|
||||
normalized = normalized.replace(/\s+/gu, " ").trim();
|
||||
return normalized.length > 0 ? normalized : "<empty>";
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type JournalEntry,
|
||||
type Mountable,
|
||||
} from "@copilotkit/aimock";
|
||||
import { writeJson } from "../shared/http-json.js";
|
||||
|
||||
type AimockRequestSnapshot = {
|
||||
raw: string;
|
||||
@@ -22,16 +23,6 @@ type AimockRequestSnapshot = {
|
||||
toolOutputStructuredError?: true;
|
||||
};
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: unknown) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function stringifyContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { closeQaHttpServer } from "../../bus-server.js";
|
||||
import { writeJson } from "../shared/http-json.js";
|
||||
|
||||
type ResponsesInputItem = Record<string, unknown>;
|
||||
|
||||
@@ -234,16 +235,6 @@ function transcriptionTextForAudioRequest(rawBody: string) {
|
||||
return QA_AUDIO_TRANSCRIPTION_TEXT;
|
||||
}
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: unknown) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function writeSse(res: ServerResponse, events: StreamEvent[]) {
|
||||
const body = `${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
res.writeHead(200, {
|
||||
|
||||
11
extensions/qa-lab/src/providers/shared/http-json.ts
Normal file
11
extensions/qa-lab/src/providers/shared/http-json.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
export function writeJson(res: ServerResponse, status: number, body: unknown) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
35
extensions/qa-lab/src/repo-path.ts
Normal file
35
extensions/qa-lab/src/repo-path.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type QaRepoPathKind = "file" | "directory";
|
||||
|
||||
function walkUpDirectories(start: string): string[] {
|
||||
const roots: string[] = [];
|
||||
let current = path.resolve(start);
|
||||
while (true) {
|
||||
roots.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return roots;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveQaRepoPath(
|
||||
startDir: string,
|
||||
relativePath: string,
|
||||
kind: QaRepoPathKind = "file",
|
||||
): string | null {
|
||||
for (const dir of walkUpDirectories(startDir)) {
|
||||
const candidate = path.join(dir, relativePath);
|
||||
if (!fs.existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(candidate);
|
||||
if ((kind === "file" && stat.isFile()) || (kind === "directory" && stat.isDirectory())) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Qa Lab plugin module implements runtime parity behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
scanGatewayLogSentinels,
|
||||
type GatewayLogSentinelFinding,
|
||||
} from "./gateway-log-sentinel.js";
|
||||
import { compareToolCallShape, stableHash } from "./parity-shared.js";
|
||||
|
||||
export type RuntimeId = "openclaw" | "codex";
|
||||
|
||||
@@ -144,29 +144,6 @@ function normalizeTextForParity(text: string) {
|
||||
return text.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeForStableHash(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeForStableHash(entry));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.toSorted((left, right) => left.localeCompare(right))
|
||||
.map((key) => [key, normalizeForStableHash(record[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableHash(value: unknown) {
|
||||
return sha256(JSON.stringify(normalizeForStableHash(value)) ?? "null");
|
||||
}
|
||||
|
||||
function readUsageTotals(raw: unknown): RuntimeParityUsage {
|
||||
const usage = isMessageRecord(raw) ? raw : {};
|
||||
const inputTokens =
|
||||
@@ -713,26 +690,6 @@ function aggregateUsage(records: RuntimeParityTranscriptRecord[]): RuntimeParity
|
||||
return totals;
|
||||
}
|
||||
|
||||
function compareToolCallShape(
|
||||
left: RuntimeParityToolCall[],
|
||||
right: RuntimeParityToolCall[],
|
||||
): string | undefined {
|
||||
if (left.length !== right.length) {
|
||||
return `tool call count differs (${left.length} vs ${right.length})`;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftCall = left[index];
|
||||
const rightCall = right[index];
|
||||
if (!leftCall || !rightCall) {
|
||||
return `tool call row ${index + 1} missing`;
|
||||
}
|
||||
if (leftCall.tool !== rightCall.tool || leftCall.argsHash !== rightCall.argsHash) {
|
||||
return `tool call ${index + 1} differs (${leftCall.tool}/${leftCall.argsHash} vs ${rightCall.tool}/${rightCall.argsHash})`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compareToolResultShape(
|
||||
left: RuntimeParityToolCall[],
|
||||
right: RuntimeParityToolCall[],
|
||||
|
||||
@@ -50,22 +50,19 @@ describe("qa scenario catalog", () => {
|
||||
expect(
|
||||
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
|
||||
).toEqual(requiredScenarioIds);
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution?.kind !== "flow")
|
||||
.map((scenario) => scenario.id)
|
||||
.toSorted(),
|
||||
).toStrictEqual(
|
||||
[
|
||||
"channel-message-flows",
|
||||
"control-ui-chat-flow-playwright",
|
||||
"gateway-smoke",
|
||||
"package-openclaw-for-docker",
|
||||
"plugin-lifecycle-probe",
|
||||
"qa-otel-smoke",
|
||||
"ux-matrix-evidence-dashboard",
|
||||
].toSorted(),
|
||||
const nativeExecutionScenarios = pack.scenarios.filter(
|
||||
(scenario) => scenario.execution.kind !== "flow",
|
||||
);
|
||||
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
|
||||
for (const scenario of nativeExecutionScenarios) {
|
||||
const execution = scenario.execution;
|
||||
if (execution.kind === "flow") {
|
||||
throw new Error(`expected native execution scenario: ${scenario.id}`);
|
||||
}
|
||||
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
|
||||
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
|
||||
expect(execution.flow).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution.kind === "flow")
|
||||
@@ -176,6 +173,21 @@ describe("qa scenario catalog", () => {
|
||||
expect(uxMatrix.coverage?.primary).toContain("qa.artifact-safety");
|
||||
});
|
||||
|
||||
it("loads folded HTTP API script scenarios with primary taxonomy coverage", () => {
|
||||
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.primary).toStrictEqual([
|
||||
"gateway.openai-compatible-apis",
|
||||
]);
|
||||
expect(readQaScenarioById("openai-web-search-minimal").coverage?.primary).toStrictEqual([
|
||||
"runtime.reasoning-and-cache-controls",
|
||||
]);
|
||||
expect(
|
||||
readQaScenarioById("openai-web-search-native-assertions").coverage?.primary,
|
||||
).toStrictEqual(["web-search.openai-native-web-search", "plugins.web-search-and-fetch"]);
|
||||
expect(readQaScenarioById("openwebui-openai-compatible").coverage?.primary).toStrictEqual([
|
||||
"gateway.openai-compatible-apis",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads runtime parity tier metadata for first-hour and soak lanes", () => {
|
||||
const firstHour = readQaScenarioById("runtime-first-hour-20-turn");
|
||||
const soak = readQaScenarioById("runtime-soak-100-turn");
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
import { isRepoRootRelativeRef } from "./cli-paths.js";
|
||||
import { resolveQaRepoPath, type QaRepoPathKind } from "./repo-path.js";
|
||||
|
||||
export const DEFAULT_QA_AGENT_IDENTITY_MARKDOWN = `# Dev C-3PO
|
||||
|
||||
@@ -292,37 +293,14 @@ const repoPathCache = new Map<string, string | null>();
|
||||
let qaScenarioYamlPathsCache: string[] | null = null;
|
||||
let qaScenarioPackCache: QaScenarioPack | null = null;
|
||||
|
||||
function walkUpDirectories(start: string): string[] {
|
||||
const roots: string[] = [];
|
||||
let current = path.resolve(start);
|
||||
while (true) {
|
||||
roots.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return roots;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepoPath(relativePath: string, kind: "file" | "directory" = "file"): string | null {
|
||||
function resolveRepoPath(relativePath: string, kind: QaRepoPathKind = "file"): string | null {
|
||||
const cacheKey = `${kind}:${relativePath}`;
|
||||
if (repoPathCache.has(cacheKey)) {
|
||||
return repoPathCache.get(cacheKey) ?? null;
|
||||
}
|
||||
for (const dir of walkUpDirectories(import.meta.dirname)) {
|
||||
const candidate = path.join(dir, relativePath);
|
||||
if (!fs.existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(candidate);
|
||||
if ((kind === "file" && stat.isFile()) || (kind === "directory" && stat.isDirectory())) {
|
||||
repoPathCache.set(cacheKey, candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
repoPathCache.set(cacheKey, null);
|
||||
return null;
|
||||
const resolved = resolveQaRepoPath(import.meta.dirname, relativePath, kind);
|
||||
repoPathCache.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function hasQaScenarioPack(): boolean {
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
import { resolveQaRepoPath, type QaRepoPathKind } from "./repo-path.js";
|
||||
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
||||
|
||||
export const QA_MATURITY_TAXONOMY_PATH = "taxonomy.yaml";
|
||||
@@ -191,31 +192,8 @@ type MaturityCoverageRef = {
|
||||
surfaceId: string;
|
||||
};
|
||||
|
||||
function walkUpDirectories(start: string): string[] {
|
||||
const roots: string[] = [];
|
||||
let current = path.resolve(start);
|
||||
while (true) {
|
||||
roots.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return roots;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepoPath(relativePath: string, kind: "file" | "directory" = "file") {
|
||||
for (const dir of walkUpDirectories(import.meta.dirname)) {
|
||||
const candidate = path.join(dir, relativePath);
|
||||
if (!fs.existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(candidate);
|
||||
if ((kind === "file" && stat.isFile()) || (kind === "directory" && stat.isDirectory())) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
function resolveRepoPath(relativePath: string, kind: QaRepoPathKind = "file") {
|
||||
return resolveQaRepoPath(import.meta.dirname, relativePath, kind);
|
||||
}
|
||||
|
||||
function repoRootFromPath(filePath: string) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtim
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "./cli-paths.js";
|
||||
import type { QaCliBackendAuthMode } from "./gateway-child.js";
|
||||
import type { QaProviderMode } from "./model-selection.js";
|
||||
import { splitQaModelRef as splitModelRef, type QaProviderMode } from "./model-selection.js";
|
||||
import { getQaProvider } from "./providers/index.js";
|
||||
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
|
||||
import { applyQaMergePatch, isQaMergePatchObject } from "./suite-merge-patch.js";
|
||||
@@ -14,17 +14,6 @@ const DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS = 1_500;
|
||||
|
||||
type QaSeedScenario = ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"][number];
|
||||
|
||||
function splitModelRef(ref: string) {
|
||||
const slash = ref.indexOf("/");
|
||||
if (slash <= 0 || slash === ref.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: ref.slice(0, slash),
|
||||
model: ref.slice(slash + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeQaConfigString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { QaSuiteInfraError } from "./errors.js";
|
||||
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
|
||||
import { applyQaMergePatch } from "./suite-merge-patch.js";
|
||||
import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
|
||||
import type { QaConfigSnapshot, QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
|
||||
@@ -302,7 +302,7 @@ async function runConfigMutation(params: {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw toLintErrorObject(
|
||||
throw toQaErrorObject(
|
||||
lastConflict ?? new Error(`${params.action} failed after retrying config hash conflicts`),
|
||||
"Non-Error thrown",
|
||||
);
|
||||
@@ -372,17 +372,3 @@ export {
|
||||
waitForQaChannelReady,
|
||||
waitForTransportReady,
|
||||
};
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type QaReportScenario,
|
||||
} from "openclaw/plugin-sdk/qa-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { QaSuiteArtifactError } from "./errors.js";
|
||||
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
|
||||
import { buildQaSuiteEvidenceSummary, QA_EVIDENCE_FILENAME } from "./evidence-summary.js";
|
||||
import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js";
|
||||
import type {
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
normalizeQaProviderMode,
|
||||
type QaProviderMode,
|
||||
} from "./model-selection.js";
|
||||
import {
|
||||
parseQaProgressBooleanEnv as parseQaSuiteBooleanEnv,
|
||||
sanitizeQaProgressValue as sanitizeQaSuiteProgressValue,
|
||||
} from "./progress-format.js";
|
||||
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "./providers/index.js";
|
||||
import { startQaProviderServer } from "./providers/server-runtime.js";
|
||||
import type { QaThinkingLevel } from "./qa-gateway-config.js";
|
||||
@@ -124,20 +128,6 @@ export type QaSuiteRunParams = {
|
||||
captureRuntimeParityCell?: boolean;
|
||||
};
|
||||
|
||||
function parseQaSuiteBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldLogQaSuiteProgress(env: NodeJS.ProcessEnv = process.env) {
|
||||
const override = parseQaSuiteBooleanEnv(env.OPENCLAW_QA_SUITE_PROGRESS);
|
||||
if (override !== undefined) {
|
||||
@@ -214,20 +204,6 @@ async function waitForQaLabReadyOrStopOwned(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeQaSuiteProgressValue(value: string): string {
|
||||
let normalized = "";
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
continue;
|
||||
}
|
||||
const isControl = code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
normalized += isControl ? " " : char;
|
||||
}
|
||||
normalized = normalized.replace(/\s+/gu, " ").trim();
|
||||
return normalized.length > 0 ? normalized : "<empty>";
|
||||
}
|
||||
|
||||
function requireQaSuiteStartLab(startLab: QaSuiteStartLabFn | undefined): QaSuiteStartLabFn {
|
||||
if (startLab) {
|
||||
return startLab;
|
||||
@@ -908,21 +884,6 @@ async function writeQaSuiteArtifacts(params: {
|
||||
return { evidencePath, report, reportPath, summaryPath };
|
||||
}
|
||||
|
||||
async function assertQaSuiteArtifactWritten(
|
||||
kind: "evidence" | "report" | "summary",
|
||||
filePath: string,
|
||||
) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
throw new QaSuiteArtifactError(
|
||||
`${kind}_missing`,
|
||||
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildQaSuiteRuntimeMetrics(params: {
|
||||
startedAt: Date;
|
||||
finishedAt: Date;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
|
||||
import { isRepoRootRelativeRef, toRepoRelativePath } from "./cli-paths.js";
|
||||
import { QaSuiteArtifactError } from "./errors.js";
|
||||
import {
|
||||
buildPlaywrightEvidenceSummary,
|
||||
buildScriptEvidenceSummary,
|
||||
@@ -542,22 +542,10 @@ async function writeTestFileEvidenceFile(params: {
|
||||
}): Promise<Pick<QaTestFileScenarioRunResult, "evidencePath">> {
|
||||
const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME);
|
||||
await fs.writeFile(evidencePath, `${JSON.stringify(params.evidence, null, 2)}\n`, "utf8");
|
||||
await assertQaTestFileArtifactWritten("evidence", evidencePath);
|
||||
await assertQaSuiteArtifactWritten("evidence", evidencePath);
|
||||
return { evidencePath };
|
||||
}
|
||||
|
||||
async function assertQaTestFileArtifactWritten(kind: "evidence", filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
throw new QaSuiteArtifactError(
|
||||
`${kind}_missing`,
|
||||
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runQaTestFileScenarios(
|
||||
params: QaTestFileScenarioRunParams,
|
||||
): Promise<QaTestFileScenarioRunResult> {
|
||||
|
||||
@@ -84,6 +84,13 @@ function evidenceState(overrides: Partial<UiState> = {}): UiState {
|
||||
}
|
||||
|
||||
describe("QA Lab UI evidence render", () => {
|
||||
it("renders capture startup commands without personal home paths", () => {
|
||||
const html = renderQaLabUi(evidenceState({ activeTab: "capture" }));
|
||||
|
||||
expect(html).toContain("$HOME/.openclaw/debug-proxy/certs/root-ca.pem");
|
||||
expect(html).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("maps blocked and skipped evidence statuses to styled tones", () => {
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
@@ -255,4 +262,152 @@ describe("QA Lab UI evidence render", () => {
|
||||
expect(html).not.toContain("<video controls");
|
||||
expect(html).not.toContain('data-evidence-entry-id="null"');
|
||||
});
|
||||
|
||||
it("redacts secret-like capture payload fields in raw previews", () => {
|
||||
const payload =
|
||||
'{"message":"visible context","message":"duplicate context","completion_tokens":100,"cookies":["session=abc"],"apiToken":"secret-token","tokenValue":"token-value-secret","authTokens":["auth-token-secret"],"tokens":{"refresh":"refresh-token-secret"},"AWS_SECRET_ACCESS_KEY":"aws-secret","secretAccessKey":"access-secret","x-goog-api-key":"goog-secret","nested":{"password":"secret-password"}}';
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout: "raw",
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "application/json",
|
||||
dataText: payload,
|
||||
direction: "outbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:request",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("visible context");
|
||||
expect(html).toContain("duplicate context");
|
||||
expect(html).toContain("completion_tokens");
|
||||
expect(html).toContain("100");
|
||||
expect(html).toContain("apiToken");
|
||||
expect(html).toContain("nested");
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("session=abc");
|
||||
expect(html).not.toContain("secret-token");
|
||||
expect(html).not.toContain("token-value-secret");
|
||||
expect(html).not.toContain("auth-token-secret");
|
||||
expect(html).not.toContain("refresh-token-secret");
|
||||
expect(html).not.toContain("aws-secret");
|
||||
expect(html).not.toContain("access-secret");
|
||||
expect(html).not.toContain("goog-secret");
|
||||
expect(html).not.toContain("secret-password");
|
||||
});
|
||||
|
||||
it("redacts secret-like fields when captured JSON previews are truncated", () => {
|
||||
const payload =
|
||||
'{"apiToken":"secret-token","nested":{"password":"secret-password"},"message":"visible context"';
|
||||
for (const capturePayloadDetailLayout of ["raw", "formatted"] as const) {
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout,
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "application/json",
|
||||
dataText: payload,
|
||||
direction: "outbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:request",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("visible context");
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("secret-token");
|
||||
expect(html).not.toContain("secret-password");
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts secret-like SSE data fields in formatted payloads", () => {
|
||||
const payload = 'event: message\ndata: {"apiToken":"secret-token","message":"visible"}';
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout: "formatted",
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
dataText: payload,
|
||||
direction: "inbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "response",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:response",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("visible");
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("redacts secret-like fields when capture cuts inside a JSON value", () => {
|
||||
const payload = '{"apiToken":"secret-token';
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout: "raw",
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "application/json",
|
||||
dataText: payload,
|
||||
direction: "outbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:request",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,9 +531,32 @@ function renderCaptureHeaders(raw: string | undefined, mode: UiState["captureHea
|
||||
);
|
||||
}
|
||||
|
||||
const NON_SECRET_CAPTURE_TOKEN_FIELDS = new Set([
|
||||
"completiontokens",
|
||||
"inputtokens",
|
||||
"maxcompletiontokens",
|
||||
"maxtokens",
|
||||
"outputtokens",
|
||||
"prompttokens",
|
||||
"reasoningtokens",
|
||||
"totaltokens",
|
||||
]);
|
||||
|
||||
function isSensitiveCaptureField(label: string): boolean {
|
||||
return /authorization|proxy-authorization|cookie|set-cookie|api[-_]?key|x[-_]?api[-_]?key|token|secret|password|session/i.test(
|
||||
label,
|
||||
const normalized = label.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
const tokenMarker =
|
||||
!NON_SECRET_CAPTURE_TOKEN_FIELDS.has(normalized) &&
|
||||
normalized !== "tokenizer" &&
|
||||
normalized.includes("token");
|
||||
return (
|
||||
normalized.includes("authorization") ||
|
||||
normalized.includes("cookie") ||
|
||||
normalized.includes("apikey") ||
|
||||
normalized.includes("accesskey") ||
|
||||
normalized.includes("secret") ||
|
||||
normalized.includes("password") ||
|
||||
normalized.includes("session") ||
|
||||
tokenMarker
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,6 +581,9 @@ function redactCaptureValue(value: unknown, label?: string): unknown {
|
||||
if (typeof value === "string") {
|
||||
return redactCaptureScalar(value, label);
|
||||
}
|
||||
if (label && isSensitiveCaptureField(label)) {
|
||||
return "[redacted]";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactCaptureValue(entry, label));
|
||||
}
|
||||
@@ -571,6 +597,115 @@ function redactCaptureValue(value: unknown, label?: string): unknown {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readCaptureQuotedSpan(
|
||||
value: string,
|
||||
start: number,
|
||||
): { closed: boolean; end: number; raw: string; text: string } {
|
||||
const quote = value[start];
|
||||
let text = "";
|
||||
let index = start + 1;
|
||||
while (index < value.length) {
|
||||
const char = value[index];
|
||||
if (char === "\\") {
|
||||
text += value.slice(index, Math.min(index + 2, value.length));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
return {
|
||||
closed: true,
|
||||
end: index + 1,
|
||||
raw: value.slice(start, index + 1),
|
||||
text,
|
||||
};
|
||||
}
|
||||
text += char;
|
||||
index += 1;
|
||||
}
|
||||
return {
|
||||
closed: false,
|
||||
end: value.length,
|
||||
raw: value.slice(start),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function skipCaptureInlineWhitespace(value: string, start: number): number {
|
||||
let index = start;
|
||||
while (/\s/.test(value[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function consumeCaptureJsonishValue(value: string, start: number): number {
|
||||
const opener = value[start];
|
||||
if (opener === '"' || opener === "'") {
|
||||
return readCaptureQuotedSpan(value, start).end;
|
||||
}
|
||||
if (opener === "{" || opener === "[") {
|
||||
const stack = [opener === "{" ? "}" : "]"];
|
||||
let index = start + 1;
|
||||
while (index < value.length && stack.length > 0) {
|
||||
const char = value[index];
|
||||
if (char === '"' || char === "'") {
|
||||
index = readCaptureQuotedSpan(value, index).end;
|
||||
continue;
|
||||
}
|
||||
if (char === "{" || char === "[") {
|
||||
stack.push(char === "{" ? "}" : "]");
|
||||
} else if (char === stack.at(-1)) {
|
||||
stack.pop();
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
let index = start;
|
||||
while (index < value.length && !/[,\n\r}\]]/.test(value[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function redactCaptureJsonishSecretFields(value: string): string {
|
||||
let redacted = "";
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const char = value[index];
|
||||
if (char !== '"' && char !== "'") {
|
||||
redacted += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const key = readCaptureQuotedSpan(value, index);
|
||||
const colonIndex = skipCaptureInlineWhitespace(value, key.end);
|
||||
if (!key.closed || value[colonIndex] !== ":" || !isSensitiveCaptureField(key.text)) {
|
||||
redacted += key.raw;
|
||||
index = key.end;
|
||||
continue;
|
||||
}
|
||||
const valueStart = skipCaptureInlineWhitespace(value, colonIndex + 1);
|
||||
const valueEnd = consumeCaptureJsonishValue(value, valueStart);
|
||||
const valueQuote =
|
||||
value[valueStart] === "'" || value[valueStart] === '"' ? value[valueStart] : '"';
|
||||
redacted += `${key.raw}${value.slice(key.end, valueStart)}${valueQuote}[redacted]${valueQuote}`;
|
||||
index = valueEnd;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactCaptureInlineSecretPairs(value: string): string {
|
||||
return redactCaptureJsonishSecretFields(value).replace(
|
||||
/\b([A-Za-z][A-Za-z0-9_-]{0,64})=([^&\s"',}\]]+)/gu,
|
||||
(match: string, key: string) => (isSensitiveCaptureField(key) ? `${key}=[redacted]` : match),
|
||||
);
|
||||
}
|
||||
|
||||
function redactCapturePayloadPreview(payload: string): string {
|
||||
return redactCaptureScalar(redactCaptureInlineSecretPairs(payload));
|
||||
}
|
||||
|
||||
function formatCaptureFieldValue(value: unknown, label?: string): string {
|
||||
const redacted = redactCaptureValue(value, label);
|
||||
if (typeof redacted === "string") {
|
||||
@@ -596,7 +731,7 @@ function renderCaptureFormPayload(payload: string): string {
|
||||
}));
|
||||
return rows.length > 0
|
||||
? renderCaptureKeyValueGrid(rows)
|
||||
: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`;
|
||||
: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`;
|
||||
}
|
||||
|
||||
function renderCaptureSsePayload(
|
||||
@@ -621,7 +756,10 @@ function renderCaptureSsePayload(
|
||||
const label =
|
||||
separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() || "field" : "line";
|
||||
const value = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
|
||||
return { label, value: redactCaptureScalar(value, label) };
|
||||
return {
|
||||
label,
|
||||
value: redactCaptureScalar(redactCaptureInlineSecretPairs(value), label),
|
||||
};
|
||||
});
|
||||
const eventName = rows.find((row) => row.label === "event")?.value || "message";
|
||||
const dataText = rows
|
||||
@@ -658,7 +796,7 @@ function renderCaptureSsePayload(
|
||||
});
|
||||
if (frames.length === 0) {
|
||||
return {
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`,
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`,
|
||||
eventCount: 0,
|
||||
visibleCount: 0,
|
||||
};
|
||||
@@ -753,7 +891,7 @@ function renderCapturePayload(
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`,
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`,
|
||||
mode: "text",
|
||||
byteLength,
|
||||
looksStructured: false,
|
||||
@@ -796,7 +934,7 @@ pnpm openclaw gateway --port 18789 --bind loopback`;
|
||||
const qaStart = "pnpm qa:lab:ui --port 43124 --control-ui-url http://127.0.0.1:18789/";
|
||||
const caInstall = "pnpm proxy:install-ca";
|
||||
const caTrust =
|
||||
"sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /Users/thoffman/.openclaw/debug-proxy/certs/root-ca.pem";
|
||||
'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$HOME/.openclaw/debug-proxy/certs/root-ca.pem"';
|
||||
return `<div class="capture-startup-state">
|
||||
<div class="capture-startup-title">Proxy capture is not running yet.</div>
|
||||
<div class="text-dimmed text-sm capture-startup-copy">
|
||||
@@ -2647,7 +2785,9 @@ function renderCaptureView(state: UiState): string {
|
||||
].filter((row) => row.value.trim().length > 0)
|
||||
: [];
|
||||
const rawPayloadBody = selectedEvent?.dataText?.length
|
||||
? `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(selectedEvent.dataText))}</pre>`
|
||||
? `<pre class="report-pre capture-pre">${esc(
|
||||
redactCapturePayloadPreview(selectedEvent.dataText),
|
||||
)}</pre>`
|
||||
: '<div class="empty-state">No inline payload preview for this event.</div>';
|
||||
const availableDetailViews: Array<{
|
||||
value: UiState["captureDetailView"];
|
||||
@@ -3678,7 +3818,11 @@ function renderCaptureView(state: UiState): string {
|
||||
selectedLaneEvent.errorText
|
||||
? `<div class="capture-timeline-quick-preview-error">${esc(selectedLaneEvent.errorText)}</div>`
|
||||
: selectedLaneEvent.payloadPreview
|
||||
? `<div class="capture-timeline-quick-preview-snippet">${esc(selectedLaneEvent.payloadPreview)}</div>`
|
||||
? `<div class="capture-timeline-quick-preview-snippet">${esc(
|
||||
redactCapturePayloadPreview(
|
||||
selectedLaneEvent.payloadPreview,
|
||||
),
|
||||
)}</div>`
|
||||
: ""
|
||||
}
|
||||
</div>`
|
||||
@@ -3868,7 +4012,11 @@ function renderCaptureView(state: UiState): string {
|
||||
${paired ? '<div class="capture-pair-badge">paired counterpart</div>' : ""}
|
||||
${
|
||||
event.payloadPreview
|
||||
? `<div class="capture-event-card-preview">${esc(event.payloadPreview)}</div>`
|
||||
? `<div class="capture-event-card-preview">${esc(
|
||||
redactCapturePayloadPreview(
|
||||
event.payloadPreview,
|
||||
),
|
||||
)}</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
@@ -3917,7 +4065,9 @@ function renderCaptureView(state: UiState): string {
|
||||
}
|
||||
${
|
||||
event.payloadPreview
|
||||
? `<div class="capture-event-card-preview">${esc(event.payloadPreview)}</div>`
|
||||
? `<div class="capture-event-card-preview">${esc(
|
||||
redactCapturePayloadPreview(event.payloadPreview),
|
||||
)}</div>`
|
||||
: ""
|
||||
}
|
||||
${event.errorText ? `<div class="capture-error" style="margin-top:8px">${esc(event.errorText)}</div>` : ""}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Qwen plugin module implements model definitions behavior.
|
||||
export {
|
||||
buildQwenDefaultModelDefinition,
|
||||
buildQwenModelDefinition,
|
||||
QWEN_CN_BASE_URL,
|
||||
QWEN_DEFAULT_COST,
|
||||
QWEN_DEFAULT_MODEL_ID,
|
||||
QWEN_DEFAULT_MODEL_REF,
|
||||
QWEN_GLOBAL_BASE_URL,
|
||||
QWEN_STANDARD_CN_BASE_URL,
|
||||
QWEN_STANDARD_GLOBAL_BASE_URL,
|
||||
buildModelStudioDefaultModelDefinition,
|
||||
buildModelStudioModelDefinition,
|
||||
MODELSTUDIO_CN_BASE_URL,
|
||||
MODELSTUDIO_DEFAULT_COST,
|
||||
MODELSTUDIO_DEFAULT_MODEL_ID,
|
||||
MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
MODELSTUDIO_GLOBAL_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_CN_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
|
||||
} from "./models.js";
|
||||
@@ -1,5 +1,4 @@
|
||||
// Synology Chat tests cover channel.integration plugin behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildChannelInboundEventContextMock,
|
||||
@@ -11,12 +10,6 @@ import {
|
||||
} from "./channel.test-mocks.js";
|
||||
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
|
||||
|
||||
type _RegisteredRoute = {
|
||||
path: string;
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
let createSynologyChatPlugin: typeof import("./channel.js").createSynologyChatPlugin;
|
||||
|
||||
function makeStartContext<T>(cfg: T, accountId: string, abortSignal: AbortSignal) {
|
||||
|
||||
@@ -2970,17 +2970,6 @@ export class WorkboardStore {
|
||||
return await this.promoteDependencyReady(nextChild.id);
|
||||
}
|
||||
|
||||
async linkParents(childId: string, parentIds: readonly string[]): Promise<WorkboardCard> {
|
||||
let child = await this.get(childId);
|
||||
if (!child) {
|
||||
throw new Error(`card not found: ${childId}`);
|
||||
}
|
||||
for (const parentId of parentIds) {
|
||||
child = await this.linkCards(parentId, child.id);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
private async dependencyTargetStatus(card: WorkboardCard, now: number): Promise<WorkboardStatus> {
|
||||
const scheduledAt = card.metadata?.automation?.scheduledAt;
|
||||
const parents = cardParentIds(card);
|
||||
|
||||
@@ -210,7 +210,9 @@ import {
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
type PluginsUiDescriptorsParams,
|
||||
type PluginsUiDescriptorsResult,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
PluginsUiDescriptorsResultSchema,
|
||||
ErrorCodes,
|
||||
type EnvironmentSummary,
|
||||
EnvironmentSummarySchema,
|
||||
@@ -880,6 +882,9 @@ export const validatePluginApprovalResolveParams = lazyCompile<PluginApprovalRes
|
||||
export const validatePluginsUiDescriptorsParams = lazyCompile<PluginsUiDescriptorsParams>(
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
);
|
||||
export const validatePluginsUiDescriptorsResult = lazyCompile<PluginsUiDescriptorsResult>(
|
||||
PluginsUiDescriptorsResultSchema,
|
||||
);
|
||||
export const validatePluginsSessionActionParams = lazyCompile<PluginsSessionActionParams>(
|
||||
PluginsSessionActionParamsSchema,
|
||||
);
|
||||
@@ -1133,6 +1138,7 @@ export {
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
PluginsUiDescriptorsResultSchema,
|
||||
ModelsListParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
ToolsCatalogParamsSchema,
|
||||
|
||||
@@ -31,6 +31,23 @@ function streamingTextResponse(params: {
|
||||
return new Response(stream, { status: params.status, headers: params.headers });
|
||||
}
|
||||
|
||||
function stallingResponse(params: { status: number; onCancel: () => void }): Response {
|
||||
const reader = {
|
||||
read: () => new Promise<ReadableStreamReadResult<Uint8Array>>(() => {}),
|
||||
cancel: async () => {
|
||||
params.onCancel();
|
||||
},
|
||||
releaseLock: () => undefined,
|
||||
} as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
return {
|
||||
status: params.status,
|
||||
ok: params.status >= 200 && params.status < 300,
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("uploadBatchJsonlFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -108,4 +125,70 @@ describe("uploadBatchJsonlFile", () => {
|
||||
).rejects.toThrow("file upload failed: response body too large: 64 bytes (limit: 8 bytes)");
|
||||
expect(canceled).toBe(true);
|
||||
});
|
||||
|
||||
it("passes caller abort signals through non-ok file-upload response snippets", async () => {
|
||||
let canceled = false;
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
return await params.onResponse(
|
||||
stallingResponse({
|
||||
status: 500,
|
||||
onCancel: () => {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const upload = uploadBatchJsonlFile({
|
||||
client: {
|
||||
baseUrl: "https://memory.example/v1",
|
||||
headers: { Authorization: "Bearer test" },
|
||||
},
|
||||
requests: [{ input: "one" }],
|
||||
errorPrefix: "file upload failed",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
controller.abort(new Error("upload aborted"));
|
||||
|
||||
await expect(upload).rejects.toThrow("upload aborted");
|
||||
expect(canceled).toBe(true);
|
||||
expect(remoteHttpMock.mock.calls[0]?.[0].signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("passes caller abort signals through successful file-upload JSON reads", async () => {
|
||||
let canceled = false;
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
return await params.onResponse(
|
||||
stallingResponse({
|
||||
status: 200,
|
||||
onCancel: () => {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const upload = uploadBatchJsonlFile({
|
||||
client: {
|
||||
baseUrl: "https://memory.example/v1",
|
||||
headers: { Authorization: "Bearer test" },
|
||||
},
|
||||
requests: [{ input: "one" }],
|
||||
errorPrefix: "file upload failed",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
controller.abort(new Error("upload json aborted"));
|
||||
|
||||
await expect(upload).rejects.toThrow("upload json aborted");
|
||||
expect(canceled).toBe(true);
|
||||
expect(remoteHttpMock.mock.calls[0]?.[0].signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function uploadBatchJsonlFile(params: {
|
||||
requests: unknown[];
|
||||
errorPrefix: string;
|
||||
maxResponseBytes?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const baseUrl = normalizeBatchBaseUrl(params.client);
|
||||
const jsonl = params.requests.map((request) => JSON.stringify(request)).join("\n");
|
||||
@@ -31,6 +32,7 @@ export async function uploadBatchJsonlFile(params: {
|
||||
url: `${baseUrl}/files`,
|
||||
ssrfPolicy: params.client.ssrfPolicy,
|
||||
fetchImpl: params.client.fetchImpl,
|
||||
signal: params.signal,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: buildBatchHeaders(params.client, { json: false }),
|
||||
@@ -38,12 +40,13 @@ export async function uploadBatchJsonlFile(params: {
|
||||
},
|
||||
onResponse: async (fileRes) => {
|
||||
if (!fileRes.ok) {
|
||||
const text = await readResponseTextSnippet(fileRes);
|
||||
const text = await readResponseTextSnippet(fileRes, { signal: params.signal });
|
||||
throw new Error(`${params.errorPrefix}: ${fileRes.status} ${text}`);
|
||||
}
|
||||
return (await readResponseJsonWithLimit(fileRes, {
|
||||
errorPrefix: params.errorPrefix,
|
||||
maxBytes: params.maxResponseBytes,
|
||||
signal: params.signal,
|
||||
})) as { id?: string };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import { EventHub } from "./event-hub.js";
|
||||
import { normalizeGatewayEvent } from "./normalize.js";
|
||||
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
|
||||
import type {
|
||||
AgentsCreateParams,
|
||||
AgentsDeleteParams,
|
||||
AgentsUpdateParams,
|
||||
AgentRunParams,
|
||||
ApprovalDecisionParams,
|
||||
ArtifactQuery,
|
||||
ArtifactsDownloadResult,
|
||||
ArtifactsGetResult,
|
||||
@@ -25,6 +29,7 @@ import type {
|
||||
TasksGetResult,
|
||||
TasksListParams,
|
||||
TasksListResult,
|
||||
ToolsEffectiveParams,
|
||||
ToolInvokeParams,
|
||||
ToolInvokeResult,
|
||||
} from "./types.js";
|
||||
@@ -234,6 +239,18 @@ function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery
|
||||
return params;
|
||||
}
|
||||
|
||||
function hasToolsEffectiveSessionKey(params: unknown): params is ToolsEffectiveParams {
|
||||
const record = asRecord(params);
|
||||
return typeof record.sessionKey === "string" && record.sessionKey.trim().length > 0;
|
||||
}
|
||||
|
||||
function requireToolsEffectiveSessionKey(params: unknown): ToolsEffectiveParams {
|
||||
if (!hasToolsEffectiveSessionKey(params)) {
|
||||
throw new Error("oc.tools.effective requires sessionKey");
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function readChatProjection(event: OpenClawEvent): ChatProjection | undefined {
|
||||
const raw = event.raw;
|
||||
if (event.type !== "raw" || raw?.event !== "chat") {
|
||||
@@ -708,22 +725,22 @@ export class AgentsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("agents.list", params);
|
||||
return await this.client.request("agents.list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Agent> {
|
||||
return new Agent(this.client, id);
|
||||
}
|
||||
|
||||
async create(params: Record<string, unknown>): Promise<unknown> {
|
||||
async create(params: AgentsCreateParams): Promise<unknown> {
|
||||
return await this.client.request("agents.create", params);
|
||||
}
|
||||
|
||||
async update(params: Record<string, unknown>): Promise<unknown> {
|
||||
async update(params: AgentsUpdateParams): Promise<unknown> {
|
||||
return await this.client.request("agents.update", params);
|
||||
}
|
||||
|
||||
async delete(params: Record<string, unknown>): Promise<unknown> {
|
||||
async delete(params: AgentsDeleteParams): Promise<unknown> {
|
||||
return await this.client.request("agents.delete", params);
|
||||
}
|
||||
}
|
||||
@@ -733,7 +750,7 @@ export class SessionsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.list", params);
|
||||
return await this.client.request("sessions.list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async create(params: SessionCreateParams = {}): Promise<Session> {
|
||||
@@ -817,7 +834,7 @@ export class TasksNamespace extends RpcNamespace {
|
||||
}
|
||||
|
||||
async list(params?: TasksListParams): Promise<TasksListResult> {
|
||||
return await this.call("list", params);
|
||||
return await this.call("list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async get(taskId: string): Promise<TasksGetResult> {
|
||||
@@ -839,7 +856,7 @@ export class ModelsNamespace extends RpcNamespace {
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.call("list", params);
|
||||
return await this.call("list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async status(params?: unknown): Promise<unknown> {
|
||||
@@ -854,11 +871,11 @@ export class ToolsNamespace extends RpcNamespace {
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.call("catalog", params);
|
||||
return await this.call("catalog", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async effective(params?: unknown): Promise<unknown> {
|
||||
return await this.call("effective", params);
|
||||
async effective(params: ToolsEffectiveParams): Promise<unknown> {
|
||||
return await this.call("effective", requireToolsEffectiveSessionKey(params));
|
||||
}
|
||||
|
||||
async invoke(name: string, params?: ToolInvokeParams): Promise<ToolInvokeResult> {
|
||||
@@ -903,11 +920,14 @@ export class ApprovalsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.client.request("exec.approval.list", params);
|
||||
return await this.client.request("exec.approval.list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async respond(approvalId: string, decision: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("exec.approval.resolve", { approvalId, ...decision });
|
||||
async respond(approvalId: string, params: ApprovalDecisionParams): Promise<unknown> {
|
||||
return await this.client.request("exec.approval.resolve", {
|
||||
id: approvalId,
|
||||
decision: params.decision,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,7 +938,7 @@ export class EnvironmentsNamespace extends RpcNamespace {
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<EnvironmentsListResult> {
|
||||
return await this.call("list", params ?? {});
|
||||
return await this.call("list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async create(params?: unknown): Promise<unknown> {
|
||||
|
||||
@@ -332,6 +332,7 @@ async function createFakeGateway(port = 0): Promise<FakeGateway> {
|
||||
}
|
||||
|
||||
if (frame.method === "exec.approval.resolve") {
|
||||
expect(frame.params).toMatchObject({ id: "approval-1", decision: "allow-once" });
|
||||
reply({ ok: true, params: frame.params as JsonObject | undefined });
|
||||
return;
|
||||
}
|
||||
@@ -445,14 +446,19 @@ describe("OpenClaw SDK websocket e2e", () => {
|
||||
const identity = expectJsonObject(await agent.identity({ sessionKey: "sdk-session" }));
|
||||
expect(identity.agentId).toBe("main");
|
||||
expect(identity.sessionKey).toBe("sdk-session");
|
||||
const createAgent = expectJsonObject(await oc.agents.create({ id: "sdk-agent" }));
|
||||
const createAgent = expectJsonObject(
|
||||
await oc.agents.create({ name: "SDK Agent", workspace: "/tmp/sdk-agent" }),
|
||||
);
|
||||
expect(createAgent.method).toBe("agents.create");
|
||||
expect(createAgent.params).toEqual({ name: "SDK Agent", workspace: "/tmp/sdk-agent" });
|
||||
const updateAgent = expectJsonObject(
|
||||
await oc.agents.update({ id: "sdk-agent", label: "SDK Agent" }),
|
||||
await oc.agents.update({ agentId: "sdk-agent", name: "Renamed SDK Agent" }),
|
||||
);
|
||||
expect(updateAgent.method).toBe("agents.update");
|
||||
const deleteAgent = expectJsonObject(await oc.agents.delete({ id: "sdk-agent" }));
|
||||
expect(updateAgent.params).toEqual({ agentId: "sdk-agent", name: "Renamed SDK Agent" });
|
||||
const deleteAgent = expectJsonObject(await oc.agents.delete({ agentId: "sdk-agent" }));
|
||||
expect(deleteAgent.method).toBe("agents.delete");
|
||||
expect(deleteAgent.params).toEqual({ agentId: "sdk-agent" });
|
||||
|
||||
const sessions = expectJsonObject(await oc.sessions.list());
|
||||
expect(sessions.sessions).toEqual([{ key: "sdk-session" }]);
|
||||
@@ -508,7 +514,7 @@ describe("OpenClaw SDK websocket e2e", () => {
|
||||
const approvals = expectJsonObject(await oc.approvals.list());
|
||||
expect(approvals.approvals).toEqual([]);
|
||||
const approvalResult = expectJsonObject(
|
||||
await oc.approvals.respond("approval-1", { decision: "approve" }),
|
||||
await oc.approvals.respond("approval-1", { decision: "allow-once" }),
|
||||
);
|
||||
expect(approvalResult.ok).toBe(true);
|
||||
|
||||
@@ -537,6 +543,14 @@ describe("OpenClaw SDK websocket e2e", () => {
|
||||
"exec.approval.list",
|
||||
"exec.approval.resolve",
|
||||
]);
|
||||
const requestParams = new Map(
|
||||
gateway.requests.map((request) => [request.method, request.params]),
|
||||
);
|
||||
expect(requestParams.get("agents.list")).toEqual({});
|
||||
expect(requestParams.get("sessions.list")).toEqual({});
|
||||
expect(requestParams.get("models.list")).toEqual({});
|
||||
expect(requestParams.get("tools.catalog")).toEqual({});
|
||||
expect(requestParams.get("exec.approval.list")).toEqual({});
|
||||
} finally {
|
||||
await oc.close();
|
||||
await gateway.close();
|
||||
|
||||
@@ -676,6 +676,86 @@ describe("OpenClaw SDK", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends empty params for no-arg Gateway list helpers", async () => {
|
||||
const transport = new FakeTransport({
|
||||
"agents.list": { agents: [] },
|
||||
"sessions.list": { sessions: [] },
|
||||
"tasks.list": { tasks: [] },
|
||||
"models.list": { models: [] },
|
||||
"tools.catalog": { tools: [] },
|
||||
"exec.approval.list": { approvals: [] },
|
||||
"environments.list": { environments: [] },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await expect(oc.agents.list()).resolves.toEqual({ agents: [] });
|
||||
await expect(oc.sessions.list()).resolves.toEqual({ sessions: [] });
|
||||
await expect(oc.tasks.list()).resolves.toEqual({ tasks: [] });
|
||||
await expect(oc.models.list()).resolves.toEqual({ models: [] });
|
||||
await expect(oc.tools.list()).resolves.toEqual({ tools: [] });
|
||||
await expect(oc.approvals.list()).resolves.toEqual({ approvals: [] });
|
||||
await expect(oc.environments.list()).resolves.toEqual({ environments: [] });
|
||||
|
||||
expect(transport.calls).toEqual([
|
||||
{ method: "agents.list", params: {}, options: undefined },
|
||||
{ method: "sessions.list", params: {}, options: undefined },
|
||||
{ method: "tasks.list", params: {}, options: undefined },
|
||||
{ method: "models.list", params: {}, options: undefined },
|
||||
{ method: "tools.catalog", params: {}, options: undefined },
|
||||
{ method: "exec.approval.list", params: {}, options: undefined },
|
||||
{ method: "environments.list", params: {}, options: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves explicit null params for Gateway list validation", async () => {
|
||||
type ListMethod = (this: unknown, params: unknown) => Promise<unknown>;
|
||||
const transport = new FakeTransport({
|
||||
"agents.list": { agents: [] },
|
||||
"sessions.list": { sessions: [] },
|
||||
"tasks.list": { tasks: [] },
|
||||
"models.list": { models: [] },
|
||||
"tools.catalog": { tools: [] },
|
||||
"exec.approval.list": { approvals: [] },
|
||||
"environments.list": { environments: [] },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await (oc.agents.list as unknown as ListMethod).call(oc.agents, null);
|
||||
await (oc.sessions.list as unknown as ListMethod).call(oc.sessions, null);
|
||||
await (oc.tasks.list as unknown as ListMethod).call(oc.tasks, null);
|
||||
await oc.models.list(null);
|
||||
await oc.tools.list(null);
|
||||
await oc.approvals.list(null);
|
||||
await oc.environments.list(null);
|
||||
|
||||
expect(transport.calls).toEqual([
|
||||
{ method: "agents.list", params: null, options: undefined },
|
||||
{ method: "sessions.list", params: null, options: undefined },
|
||||
{ method: "tasks.list", params: null, options: undefined },
|
||||
{ method: "models.list", params: null, options: undefined },
|
||||
{ method: "tools.catalog", params: null, options: undefined },
|
||||
{ method: "exec.approval.list", params: null, options: undefined },
|
||||
{ method: "environments.list", params: null, options: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects tools.effective without a session key before RPC", async () => {
|
||||
type EffectiveMethod = (this: unknown, params?: unknown) => Promise<unknown>;
|
||||
const transport = new FakeTransport({
|
||||
"tools.effective": { tools: [] },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await expect((oc.tools.effective as unknown as EffectiveMethod).call(oc.tools)).rejects.toThrow(
|
||||
"oc.tools.effective requires sessionKey",
|
||||
);
|
||||
await expect(
|
||||
(oc.tools.effective as unknown as EffectiveMethod).call(oc.tools, {}),
|
||||
).rejects.toThrow("oc.tools.effective requires sessionKey");
|
||||
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps close terminal when it races a pending connect", async () => {
|
||||
const transport = new DelayedConnectTransport({
|
||||
"agents.list": { agents: [] },
|
||||
@@ -697,6 +777,33 @@ describe("OpenClaw SDK", () => {
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("calls exec approval Gateway RPCs with protocol params", async () => {
|
||||
const transport = new FakeTransport({
|
||||
"exec.approval.list": { approvals: [] },
|
||||
"exec.approval.resolve": { ok: true },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await expect(oc.approvals.list()).resolves.toEqual({ approvals: [] });
|
||||
const staleDecision = { id: "stale-approval", decision: "allow-once" as const };
|
||||
await expect(oc.approvals.respond("approval-123", staleDecision)).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(transport.calls).toEqual([
|
||||
{
|
||||
method: "exec.approval.list",
|
||||
options: undefined,
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
method: "exec.approval.resolve",
|
||||
options: undefined,
|
||||
params: { id: "approval-123", decision: "allow-once" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not request after close races event pump startup", async () => {
|
||||
const transport = new ClosingEventPumpTransport({
|
||||
"agents.list": { agents: [] },
|
||||
|
||||
@@ -20,7 +20,11 @@ export { EventHub, isGatewayEvent } from "./event-hub.js";
|
||||
export { normalizeGatewayEvent } from "./normalize.js";
|
||||
export { GatewayClientTransport, isConnectableTransport } from "./transport.js";
|
||||
export type {
|
||||
AgentsCreateParams,
|
||||
AgentsDeleteParams,
|
||||
AgentsUpdateParams,
|
||||
AgentRunParams,
|
||||
ApprovalDecisionParams,
|
||||
ApprovalMode,
|
||||
ArtifactQuery,
|
||||
ArtifactSummary,
|
||||
@@ -52,6 +56,7 @@ export type {
|
||||
TasksGetResult,
|
||||
TasksListParams,
|
||||
TasksListResult,
|
||||
ToolsEffectiveParams,
|
||||
ToolInvokeParams,
|
||||
ToolInvokeResult,
|
||||
WorkspaceSelection,
|
||||
|
||||
@@ -68,6 +68,10 @@ export type WorkspaceSelection = {
|
||||
|
||||
export type ApprovalMode = "ask" | "never" | "auto" | "trusted";
|
||||
|
||||
export type ApprovalDecisionParams = {
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
};
|
||||
|
||||
/** Terminal and non-terminal status values returned by Run.wait. */
|
||||
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
|
||||
|
||||
@@ -188,6 +192,11 @@ export type SDKError = {
|
||||
};
|
||||
|
||||
/** Parameters for direct tool invocation through the SDK. */
|
||||
export type ToolsEffectiveParams = {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
export type ToolInvokeParams = {
|
||||
args?: JsonObject;
|
||||
sessionKey?: string;
|
||||
@@ -324,3 +333,25 @@ export type SessionTarget = {
|
||||
};
|
||||
|
||||
export type RunCreateParams = AgentRunParams;
|
||||
|
||||
export type AgentsCreateParams = {
|
||||
name: string;
|
||||
workspace: string;
|
||||
model?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type AgentsUpdateParams = {
|
||||
agentId: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
model?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type AgentsDeleteParams = {
|
||||
agentId: string;
|
||||
deleteFiles?: boolean;
|
||||
};
|
||||
|
||||
29
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
29
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
title: OpenAI-compatible chat tools HTTP API
|
||||
|
||||
scenario:
|
||||
id: openai-compatible-chat-tools
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- gateway.openai-compatible-apis
|
||||
secondary:
|
||||
- runtime.hosted-tool-use
|
||||
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
|
||||
successCriteria:
|
||||
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
|
||||
- The generated config preserves strict positive gateway port and timeout values.
|
||||
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
|
||||
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
|
||||
docsRefs:
|
||||
- docs/gateway/protocol.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-chat-tools/client.mjs
|
||||
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
|
||||
- scripts/e2e/openai-chat-tools-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
|
||||
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.
|
||||
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
title: OpenAI web_search minimal reasoning gate
|
||||
|
||||
scenario:
|
||||
id: openai-web-search-minimal
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.reasoning-and-cache-controls
|
||||
secondary:
|
||||
- web-search.openai-native-web-search
|
||||
- tools.web-search
|
||||
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
|
||||
successCriteria:
|
||||
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
|
||||
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
|
||||
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
|
||||
- Gateway ports are parsed strictly before connecting.
|
||||
docsRefs:
|
||||
- docs/tools/web.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
|
||||
- scripts/e2e/openai-web-search-minimal-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
|
||||
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.
|
||||
@@ -0,0 +1,30 @@
|
||||
title: OpenAI native web_search request assertions
|
||||
|
||||
scenario:
|
||||
id: openai-web-search-native-assertions
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- web-search.openai-native-web-search
|
||||
- plugins.web-search-and-fetch
|
||||
secondary:
|
||||
- web-search.model-and-filter-routing
|
||||
- tools.web-search
|
||||
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
|
||||
successCriteria:
|
||||
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
|
||||
- Large request logs are scanned without missing later success requests.
|
||||
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
|
||||
- Function-shaped `web_search` is rejected as native Responses proof.
|
||||
docsRefs:
|
||||
- docs/tools/web.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
|
||||
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
|
||||
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
|
||||
summary: Vitest coverage for native OpenAI web_search request-log assertions.
|
||||
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: OpenWebUI OpenAI-compatible API probe
|
||||
|
||||
scenario:
|
||||
id: openwebui-openai-compatible
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- gateway.openai-compatible-apis
|
||||
secondary:
|
||||
- runtime.hosted-provider-turns
|
||||
- runtime.provider-specific-model-options
|
||||
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
|
||||
successCriteria:
|
||||
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
|
||||
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
|
||||
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
|
||||
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/openwebui-probe.mjs
|
||||
- scripts/e2e/openwebui-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
|
||||
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.
|
||||
@@ -66,15 +66,12 @@ export function parseArgs(argv: string[]): CliOptions {
|
||||
break;
|
||||
}
|
||||
case "--version": {
|
||||
explicitVersion = argv[index + 1] ?? null;
|
||||
explicitVersion = readOptionValue(argv, index, "--version");
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--version-code": {
|
||||
const value = argv[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Missing value for --version-code.");
|
||||
}
|
||||
const value = readOptionValue(argv, index, "--version-code");
|
||||
explicitVersionCode = parseExplicitVersionCode(value);
|
||||
index += 1;
|
||||
break;
|
||||
@@ -84,10 +81,7 @@ export function parseArgs(argv: string[]): CliOptions {
|
||||
break;
|
||||
}
|
||||
case "--root": {
|
||||
const value = argv[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Missing value for --root.");
|
||||
}
|
||||
const value = readOptionValue(argv, index, "--root");
|
||||
rootDir = path.resolve(value);
|
||||
index += 1;
|
||||
break;
|
||||
@@ -114,6 +108,14 @@ export function parseArgs(argv: string[]): CliOptions {
|
||||
return { explicitVersion, explicitVersionCode, fromGateway, rootDir, sync };
|
||||
}
|
||||
|
||||
function readOptionValue(argv: string[], index: number, flag: string): string {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value === "" || value.startsWith("-")) {
|
||||
throw new Error(`Missing value for ${flag}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function pinAndroidVersion(params: CliOptions): PinAndroidVersionResult {
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
let previousVersion: string | null;
|
||||
|
||||
@@ -1,58 +1,40 @@
|
||||
// Android Sync Versioning script supports OpenClaw repository automation.
|
||||
import path from "node:path";
|
||||
import { syncAndroidVersioning } from "./lib/android-version.ts";
|
||||
import { parseVersionSyncArgs } from "./lib/version-script-args.ts";
|
||||
|
||||
type Mode = "check" | "write";
|
||||
export { parseVersionSyncArgs as parseArgs } from "./lib/version-script-args.ts";
|
||||
|
||||
export function parseArgs(argv: string[]): { mode: Mode; rootDir: string } {
|
||||
let mode: Mode = "write";
|
||||
let rootDir = path.resolve(".");
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--check": {
|
||||
mode = "check";
|
||||
break;
|
||||
}
|
||||
case "--write": {
|
||||
mode = "write";
|
||||
break;
|
||||
}
|
||||
case "--root": {
|
||||
const value = argv[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Missing value for --root.");
|
||||
}
|
||||
rootDir = path.resolve(value);
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "-h":
|
||||
case "--help": {
|
||||
console.log(
|
||||
"Usage: node --import tsx scripts/android-sync-versioning.ts [--write|--check] [--root dir]",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { mode, rootDir };
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = syncAndroidVersioning({ mode: options.mode, rootDir: options.rootDir });
|
||||
|
||||
if (options.mode === "check") {
|
||||
process.stdout.write("Android versioning artifacts are up to date.\n");
|
||||
} else if (result.updatedPaths.length === 0) {
|
||||
process.stdout.write("Android versioning artifacts already up to date.\n");
|
||||
} else {
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
`Updated Android versioning artifacts:\n- ${result.updatedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
|
||||
"Usage: node --import tsx scripts/android-sync-versioning.ts [--write|--check] [--root dir]\n",
|
||||
);
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)): number {
|
||||
const options = parseVersionSyncArgs(argv);
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = syncAndroidVersioning({ mode: options.mode, rootDir: options.rootDir });
|
||||
|
||||
if (options.mode === "check") {
|
||||
process.stdout.write("Android versioning artifacts are up to date.\n");
|
||||
} else if (result.updatedPaths.length === 0) {
|
||||
process.stdout.write("Android versioning artifacts already up to date.\n");
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Updated Android versioning artifacts:\n- ${result.updatedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -1,78 +1,47 @@
|
||||
// Android Version script supports OpenClaw repository automation.
|
||||
import path from "node:path";
|
||||
import { resolveAndroidVersion } from "./lib/android-version.ts";
|
||||
import { parseVersionQueryArgs } from "./lib/version-script-args.ts";
|
||||
|
||||
type CliOptions = {
|
||||
field: string | null;
|
||||
format: "json" | "shell";
|
||||
rootDir: string;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let field: string | null = null;
|
||||
let format: "json" | "shell" = "json";
|
||||
let rootDir = path.resolve(".");
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--field": {
|
||||
field = argv[index + 1] ?? null;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--json": {
|
||||
format = "json";
|
||||
break;
|
||||
}
|
||||
case "--shell": {
|
||||
format = "shell";
|
||||
break;
|
||||
}
|
||||
case "--root": {
|
||||
const value = argv[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Missing value for --root.");
|
||||
}
|
||||
rootDir = path.resolve(value);
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "-h":
|
||||
case "--help": {
|
||||
console.log(
|
||||
`Usage: node --import tsx scripts/android-version.ts [--json|--shell] [--field name] [--root dir]\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { field, format, rootDir };
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const version = resolveAndroidVersion(options.rootDir);
|
||||
|
||||
if (options.field) {
|
||||
const value = version[options.field as keyof typeof version];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Unknown Android version field '${options.field}'.`);
|
||||
}
|
||||
process.stdout.write(`${value}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (options.format === "shell") {
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
[
|
||||
`OPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}`,
|
||||
`OPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}`,
|
||||
].join("\n") + "\n",
|
||||
"Usage: node --import tsx scripts/android-version.ts [--json|--shell] [--field name] [--root dir]\n\n",
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify(version, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)): number {
|
||||
const options = parseVersionQueryArgs(argv);
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const version = resolveAndroidVersion(options.rootDir);
|
||||
|
||||
if (options.field) {
|
||||
const value = version[options.field as keyof typeof version];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Unknown Android version field '${options.field}'.`);
|
||||
}
|
||||
process.stdout.write(`${value}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.format === "shell") {
|
||||
process.stdout.write(
|
||||
[
|
||||
`OPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}`,
|
||||
`OPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}`,
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify(version, null, 2)}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,22 @@ const DEFAULT_WARMUP = 1;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_ENTRY = "openclaw.mjs";
|
||||
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
|
||||
const VALUE_FLAGS = new Set([
|
||||
"--case",
|
||||
"--compare-baseline",
|
||||
"--compare-candidate",
|
||||
"--cpu-prof-dir",
|
||||
"--entry",
|
||||
"--entry-primary",
|
||||
"--entry-secondary",
|
||||
"--heap-prof-dir",
|
||||
"--output",
|
||||
"--preset",
|
||||
"--runs",
|
||||
"--timeout-ms",
|
||||
"--warmup",
|
||||
]);
|
||||
const BOOLEAN_FLAGS = new Set(["--help", "--json"]);
|
||||
|
||||
const COMMAND_CASES: readonly CommandCase[] = [
|
||||
{
|
||||
@@ -451,6 +467,24 @@ function parseRepeatableFlag(flag: string): string[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
function validateCliArgs(argv: readonly string[] = process.argv.slice(2)): void {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${arg} requires a value`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: string | undefined, fallback: number, label = "value"): number {
|
||||
return parseStrictIntegerOption({ fallback, label, min: 1, raw });
|
||||
}
|
||||
@@ -1044,6 +1078,7 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
validateCliArgs();
|
||||
const options = parseOptions();
|
||||
if (options.compareBaseline || options.compareCandidate) {
|
||||
if (!options.compareBaseline || !options.compareCandidate) {
|
||||
@@ -1148,12 +1183,13 @@ export const testing = {
|
||||
parseNonNegativeInt,
|
||||
parsePositiveInt,
|
||||
readBenchmarkComparison: readBenchmarkComparisonForTesting,
|
||||
validateCliArgs,
|
||||
writeJsonOutput,
|
||||
};
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : String(error));
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +178,21 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_POST_READY_DELAY_MS = 250;
|
||||
const DEFAULT_ENTRY = "dist/entry.js";
|
||||
const RESTART_INTENT_FILENAME = "gateway-restart-intent.json";
|
||||
const BOOLEAN_FLAGS = new Set(["--allow-failures", "--help", "-h", "--json"]);
|
||||
const VALUE_FLAGS = new Set([
|
||||
"--case",
|
||||
"--entry",
|
||||
"--output",
|
||||
"--post-ready-delay-ms",
|
||||
"--restarts",
|
||||
"--runs",
|
||||
"--timeout-ms",
|
||||
"--warmup",
|
||||
]);
|
||||
|
||||
class CliArgumentError extends Error {
|
||||
override name = "CliArgumentError";
|
||||
}
|
||||
|
||||
const BASE_CONFIG = {
|
||||
browser: { enabled: false },
|
||||
@@ -233,11 +248,26 @@ const GATEWAY_CASES: readonly GatewayBenchCase[] = [
|
||||
function readRequiredFlagValue(argv: string[], index: number, flag: string): string {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
throw new CliArgumentError(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateCliArgs(argv: string[]): void {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index] ?? "";
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
readRequiredFlagValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new CliArgumentError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseFlagValue(argv: string[], flag: string): string | undefined {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === flag) {
|
||||
@@ -319,6 +349,7 @@ function resolveCases(caseIds: string[]): GatewayBenchCase[] {
|
||||
}
|
||||
|
||||
function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions {
|
||||
validateCliArgs(argv);
|
||||
return {
|
||||
allowFailures: hasFlag(argv, "--allow-failures"),
|
||||
cases: resolveCases(parseRepeatableFlag(argv, "--case")),
|
||||
@@ -1649,6 +1680,7 @@ export const testing = {
|
||||
shouldFailBenchmark,
|
||||
stopChild,
|
||||
summarizeCase,
|
||||
validateCliArgs,
|
||||
waitForRestartProbe,
|
||||
writeConfig,
|
||||
writeRestartIntent,
|
||||
@@ -1656,6 +1688,11 @@ export const testing = {
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main().catch((err: unknown) => {
|
||||
if (err instanceof CliArgumentError) {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.error(err instanceof Error ? err.stack : String(err));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user