Compare commits

..

4 Commits

Author SHA1 Message Date
Gio Della-Libera
41fe48a7dd fix(feeds): bound and redact remote feed fetches 2026-06-19 15:36:41 -07:00
Gio Della-Libera
57d27be40d fix(feeds): cap remote feed documents 2026-06-19 09:40:26 -07:00
Gio Della-Libera
504426827e fix(feeds): guard remote feed fetches 2026-06-19 09:14:31 -07:00
Gio Della-Libera
e54a94f5dc feat(feeds): add read-only feed discovery 2026-06-19 09:08:58 -07:00
400 changed files with 8636 additions and 14687 deletions

View File

@@ -107,9 +107,16 @@ Reject:
## PR Body Proof
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.
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:
```
## Existing PR Rules

5
.github/labeler.yml vendored
View File

@@ -322,6 +322,11 @@
- any-glob-to-any-file:
- "extensions/policy/**"
- "docs/cli/policy.md"
"extensions: feeds":
- changed-files:
- any-glob-to-any-file:
- "extensions/feeds/**"
- "docs/plugins/reference/feeds.md"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,57 +1,118 @@
<!--
Optional linked context:
Add a visible `Closes #<issue-number>` or `Related: #<issue-number>` line
below this comment.
## Summary
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
What problem does this PR solve?
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
-->
Why does this matter now?
## What Problem This Solves
What is the intended outcome?
<!--
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 is intentionally out of scope?
Name the affected UI surface or workflow. Do not describe the code-level cause here.
-->
What does success look like?
## Why This Change Was Made
What should reviewers focus on?
<!--
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.
-->
<details>
<summary>Summary guidance</summary>
## User Impact
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.
<!--
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.
-->
Describe the intent and outcome in 2-5 bullets. Avoid restating the diff; reviewers and bots can read the changed files.
## Evidence
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.
<!--
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.
</details>
Reviewers will inspect the code, tests, and CI. Use this section to make the
validation easy to understand, not to restate the diff.
-->
## 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>

View File

@@ -14,10 +14,6 @@ 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"

View File

@@ -13,10 +13,6 @@ 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"

View File

@@ -17,10 +17,6 @@ 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"

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 7 * * *"
concurrency:
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:

View File

@@ -136,7 +136,7 @@ on:
- cron: "30 6 * * *"
concurrency:
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) }}
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 }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 8 * * 1"
concurrency:
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:

View File

@@ -32,7 +32,7 @@ on:
- cron: "0 6 * * *"
concurrency:
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) }}
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -88,27 +88,8 @@ jobs:
if [[ "$release_package_version" =~ ^(.+)-[0-9]+$ ]]; then
fallback_package_version="${BASH_REMATCH[1]}"
fi
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")"
tag_package_version="$(gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' | tr -d '\n' | base64 --decode | jq -r '.version // empty')"
fallback_correction=false
evidence_source_tag="$tag"
if [[ "$release_package_version" != "$fallback_package_version" &&

View File

@@ -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; clear evidence 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; real behavior proof 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,12 +165,13 @@ 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. 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: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- 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 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.
- 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`.
- 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

View File

@@ -106,8 +106,7 @@ 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 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.
- 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.
- 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`
@@ -170,7 +169,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 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 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 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

View File

@@ -128,6 +128,10 @@ 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",

View File

@@ -1,2 +1,2 @@
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
b29fdf14b8b6bd3f8f61699754bd3269e54a6452f0430784f0e42c0bbf6d2be3 plugin-sdk-api-baseline.json
d3a9400a6eb7b9e22ff7264dfe5afdda5bd694a6f8fa6427d146a4c4b1506d3e plugin-sdk-api-baseline.jsonl

View File

@@ -47,21 +47,33 @@ 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.
## PR context and evidence
## Real behavior proof
External contributor PRs run a PR context and evidence gate from
External contributor PRs run a `Real behavior proof` 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 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.
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.
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

View File

@@ -39,13 +39,7 @@ 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 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`.
Use `nodes remove --node <id|name|ip>` to delete a stale gateway-owned node pairing record.
Approval note:

View File

@@ -168,62 +168,11 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
}
```
## Compact a session
Related:
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
}
```
- Session config: [Configuration reference](/gateway/config-agents#session)
## Related
- Session config: [Configuration reference](/gateway/config-agents#session)
- [CLI reference](/cli)
- [Session management](/concepts/session)

View File

@@ -37,7 +37,7 @@ that agent; if you copy credentials manually, copy only portable static
`api_key` or `token` profiles.
</Warning>
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-allowlists).
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -58,14 +58,7 @@ 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 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.remove` - remove a stale paired node entry.
- `node.pair.verify` - verify `{ nodeId, token }`.
Notes:

View File

@@ -160,7 +160,7 @@ it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-skill-roots)
- [Skills config](/tools/skills-config#symlinked-sibling-repos)
- [Configuration examples](/gateway/configuration-examples#symlinked-sibling-skill-repo)
## Anthropic 429 extra usage required for long context

View File

@@ -51,14 +51,8 @@ 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>` 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`.
- `openclaw nodes remove --node <id|name|ip>` deletes stale entries from that
separate gateway-owned node pairing store.
- Approval scope follows the pending request's declared commands:
- commandless request: `operator.pairing`
- non-exec node commands: `operator.pairing` + `operator.write`

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
72 plugins
73 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -89,6 +89,8 @@ Each entry lists the package, distribution route, and description.
- **[fal](/plugins/reference/fal)** (`@openclaw/fal-provider`) - included in OpenClaw. Adds fal model provider support to OpenClaw.
- **[feeds](/plugins/reference/feeds)** (`@openclaw/feeds`) - included in OpenClaw. Adds configured catalog feed source validation for skills and plugins.
- **[file-transfer](/plugins/reference/file-transfer)** (`@openclaw/file-transfer`) - included in OpenClaw. Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.
- **[fireworks](/plugins/reference/fireworks)** (`@openclaw/fireworks-provider`) - included in OpenClaw. Adds Fireworks model provider support to OpenClaw.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 129
generated plugin reference pages by distribution, package, and description.

View File

@@ -0,0 +1,19 @@
---
summary: "Adds configured catalog feed source validation for skills and plugins."
read_when:
- You are installing, configuring, or auditing the feeds plugin
title: "Feeds plugin"
---
# Feeds plugin
Adds configured catalog feed source validation for skills and plugins.
## Distribution
- Package: `@openclaw/feeds`
- Install route: included in OpenClaw
## Surface
plugin

View File

@@ -299,6 +299,25 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
}
/** Runtime.evaluate remote-object subset used by CDP helpers. */
export type CdpRemoteObject = {
type: string;
subtype?: string;
value?: unknown;
description?: string;
unserializableValue?: string;
preview?: unknown;
};
/** Exception details surfaced from CDP Runtime.evaluate. */
export type CdpExceptionDetails = {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
stackTrace?: unknown;
};
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

View File

@@ -854,6 +854,11 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
}
/** Lists available memory tool names understood by Codex workspace memory routing. */
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
const availableToolNames = new Set(

View File

@@ -29,6 +29,26 @@ const loadSharedClientModule = async () => {
return await sharedClientModulePromise;
};
/** Returns the process-shared app-server client for normal attempt reuse. */
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,

View File

@@ -2129,88 +2129,6 @@ 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 () => ({

View File

@@ -80,6 +80,10 @@ class CodexThreadStartRequestError extends Error {
}
}
export function isCodexThreadStartRequestError(error: unknown): boolean {
return error instanceof CodexThreadStartRequestError;
}
export type CodexThreadFinalConfigPatchDecision =
| { action: "resume"; binding: CodexAppServerThreadBinding }
| { action: "start" };

View File

@@ -13,6 +13,7 @@ export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
"text",
"ansi",
] as const satisfies readonly SupportedLanguages[];
export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements client behavior.
import type { APIInteraction } from "discord-api-types/v10";
import type { APIApplicationCommand, APIInteraction } from "discord-api-types/v10";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { DiscordCommandDeployer, type DeployCommandOptions } from "./command-deploy.js";
import type { BaseCommand } from "./commands.js";
@@ -272,10 +272,18 @@ export class Client {
return await this.entityCache.fetchMember(guildId, userId);
}
async getDiscordCommands(): Promise<APIApplicationCommand[]> {
return await this.commandDeployer.getCommands();
}
async deployCommands(options: DeployCommandOptions = {}) {
return await this.commandDeployer.deploy(options);
}
async reconcileCommands() {
return await this.deployCommands({ mode: "reconcile" });
}
async handleInteraction(rawData: APIInteraction, _ctx?: Context): Promise<void> {
await dispatchInteraction(this, rawData);
}

View File

@@ -144,6 +144,9 @@ export abstract class Command extends BaseCommand {
`The ${(interaction as { rawData?: { data?: { name?: string } } }).rawData?.data?.name ?? this.name} command does not support autocomplete`,
);
}
async preCheck(interaction: unknown): Promise<unknown> {
return Boolean(interaction) || true;
}
serializeOptions() {
return this.options?.map((option) => {
if (typeof option.autocomplete === "function") {

View File

@@ -138,6 +138,12 @@ export class Row<T extends BaseMessageInteractiveComponent> extends BaseComponen
addComponent(component: T): void {
this.components.push(component);
}
removeComponent(component: T): void {
this.components = this.components.filter((entry) => entry !== component);
}
removeAllComponents(): void {
this.components = [];
}
serialize(): APIActionRowComponent<APIComponentInMessageActionRow> {
return {
type: this.type,

View File

@@ -462,6 +462,18 @@ export class GatewayPlugin extends Plugin {
return this.outboundLimiter.getStatus();
}
getIntentsInfo() {
const intents = this.options.intents ?? 0;
return {
intents,
hasGuilds: this.hasIntent(GatewayIntentBits.Guilds),
hasGuildMembers: this.hasIntent(GatewayIntentBits.GuildMembers),
hasGuildPresences: this.hasIntent(GatewayIntentBits.GuildPresences),
hasGuildMessages: this.hasIntent(GatewayIntentBits.GuildMessages),
hasMessageContent: this.hasIntent(GatewayIntentBits.MessageContent),
};
}
hasIntent(intent: number): boolean {
return Boolean((this.options.intents ?? 0) & intent);
}

View File

@@ -16,6 +16,7 @@ import {
import {
createInteractionCallback,
createWebhookMessage,
deleteWebhookMessage,
editWebhookMessage,
getWebhookMessage,
} from "./api.js";
@@ -208,6 +209,15 @@ export class BaseInteraction {
return result;
}
async deleteReply(): Promise<unknown> {
return await deleteWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
);
}
async fetchReply(): Promise<unknown> {
return await getWebhookMessage(
this.client.rest,
@@ -283,6 +293,18 @@ export class BaseComponentInteraction extends BaseInteraction {
async showModal(modal: Modal): Promise<unknown> {
return await this.callback(InteractionResponseType.Modal, modal.serialize());
}
async editAndWaitForComponent(
payload: MessagePayload,
message: Message | null = this.message,
timeoutMs = 300_000,
) {
if (!message) {
return null;
}
const editedMessage = await message.edit(payload);
return await this.client.componentHandler.waitForMessageComponent(editedMessage, timeoutMs);
}
}
export class ButtonInteraction extends BaseComponentInteraction {}

View File

@@ -148,6 +148,12 @@ export function createDiscordDraftPreviewController(params: {
finalizedViaPreviewMessage = true;
},
disableBlockStreamingForDraft: draftStream ? true : undefined,
async startProgressDraft() {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
await progressDraft.start();
},
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },

View File

@@ -16,16 +16,16 @@ describe("formatDiscordReplySkip", () => {
);
});
it("renders the internal-only-payload reason with the same shape", () => {
it("renders the reasoning-payload reason with the same shape", () => {
expect(
formatDiscordReplySkip({
kind: "block",
reason: "internal-only payload",
reason: "reasoning payload",
target: "channel:456",
sessionKey: "agent:friday:discord:channel:456",
}),
).toBe(
"discord block reply skipped (internal-only payload): target=channel:456 session=agent:friday:discord:channel:456",
"discord block reply skipped (reasoning payload): target=channel:456 session=agent:friday:discord:channel:456",
);
});
@@ -43,11 +43,11 @@ describe("formatDiscordReplySkip", () => {
expect(
formatDiscordReplySkip({
kind: "tool",
reason: "internal-only payload",
reason: "reasoning payload",
target: "channel:c1",
sessionKey: "",
}),
).toBe("discord tool reply skipped (internal-only payload): target=channel:c1");
).toBe("discord tool reply skipped (reasoning payload): target=channel:c1");
});
it("preserves the kind discriminant in the message prefix", () => {

View File

@@ -2639,20 +2639,17 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers reasoning block payloads to Discord", async () => {
it("suppresses reasoning payload delivery to Discord", async () => {
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
await processStreamOffDiscordMessage();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "thinking...", isReasoning: true }],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers reasoning-tagged final payload to Discord", async () => {
it("suppresses reasoning-tagged final payload delivery to Discord", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({
text: "Reasoning:\nthis should be visible",
text: "Reasoning:\nthis should stay internal",
isReasoning: true,
});
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
@@ -2664,10 +2661,8 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "this should be visible", isReasoning: true }],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).not.toHaveBeenCalled();
});
it("delivers non-reasoning block payloads to Discord", async () => {

View File

@@ -113,7 +113,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason = "aborted before delivery" | "internal-only payload";
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -606,6 +609,18 @@ async function processDiscordMessageInner(
);
return null;
}
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return null;
}
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(payload);
if (!reply.hasMedia && !payload.isError) {
@@ -637,6 +652,18 @@ async function processDiscordMessageInner(
return { visibleReplySent: false };
}
const isFinal = info.kind === "final";
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
if (
isFinal &&
!options?.allowFallbackOnlyToolWarning &&

View File

@@ -90,6 +90,8 @@ let discordProviderSessionRuntimePromise: Promise<DiscordProviderSessionRuntimeM
let fetchDiscordApplicationIdForTesting: typeof fetchDiscordApplicationId | undefined;
let createDiscordNativeCommandForTesting: typeof createDiscordNativeCommand | undefined;
let runDiscordGatewayLifecycleForTesting: typeof runDiscordGatewayLifecycle | undefined;
let createDiscordGatewayPluginForTesting: typeof createDiscordGatewayPlugin | undefined;
let createDiscordGatewaySupervisorForTesting: typeof createDiscordGatewaySupervisor | undefined;
let loadDiscordVoiceRuntimeForTesting: (() => Promise<DiscordVoiceRuntimeModule>) | undefined;
let loadDiscordProviderSessionRuntimeForTesting:
| (() => Promise<DiscordProviderSessionRuntimeModule>)
@@ -435,8 +437,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
discordConfig: discordCfg,
runtime,
createClient: createClientForTesting ?? ((...args) => new Client(...args)),
createGatewayPlugin: createDiscordGatewayPlugin,
createGatewaySupervisor: createDiscordGatewaySupervisor,
createGatewayPlugin: createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin,
createGatewaySupervisor:
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor,
createAutoPresenceController: createDiscordAutoPresenceController,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
});
@@ -640,6 +643,12 @@ export const testing = {
setRunDiscordGatewayLifecycle(mock?: typeof runDiscordGatewayLifecycle) {
runDiscordGatewayLifecycleForTesting = mock;
},
setCreateDiscordGatewayPlugin(mock?: typeof createDiscordGatewayPlugin) {
createDiscordGatewayPluginForTesting = mock;
},
setCreateDiscordGatewaySupervisor(mock?: typeof createDiscordGatewaySupervisor) {
createDiscordGatewaySupervisorForTesting = mock;
},
setLoadDiscordVoiceRuntime(mock?: () => Promise<DiscordVoiceRuntimeModule>) {
loadDiscordVoiceRuntimeForTesting = mock;
},

View File

@@ -141,21 +141,6 @@ describe("deliverDiscordReply", () => {
expect(sendOptions.rest).toBe(rest);
});
it("formats reasoning replies as visible Discord payloads before shared outbound", async () => {
await deliverDiscordReply({
replies: [{ text: "Because it helps", isReasoning: true }],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "block",
});
expect(firstDeliverParams().payloads).toEqual([{ text: "Thinking\n\n_Because it helps_" }]);
});
it("fails when shared outbound accepts a final reply but delivers no Discord message", async () => {
sendDurableMessageBatchMock.mockResolvedValueOnce({ status: "sent", results: [] });

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements reply delivery behavior.
import { formatReasoningMessage, resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import {
buildOutboundSessionContext,
sendDurableMessageBatch,
@@ -156,19 +156,6 @@ function resolveDiscordDeliveryOptions(params: {
};
}
function formatDiscordReasoningPayload(payload: ReplyPayload): ReplyPayload {
if (payload.isReasoning !== true) {
return payload;
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const nextPayload: ReplyPayload = {
...payload,
text: formatReasoningMessage(text),
};
delete nextPayload.isReasoning;
return nextPayload;
}
export async function deliverDiscordReply(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
@@ -191,9 +178,7 @@ export async function deliverDiscordReply(params: {
void params.runtime;
const delivery = resolveDiscordDeliveryOptions(params);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
kind: params.kind,
}).map(formatDiscordReasoningPayload);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
if (payloads.length === 0) {
return;
}

View File

@@ -27,6 +27,11 @@ export type PersistedThreadBindingRecord = ThreadBindingRecord & {
expiresAt?: number;
};
export type PersistedThreadBindingsPayload = {
version: 1;
bindings: Record<string, PersistedThreadBindingRecord>;
};
export type ThreadBindingManager = {
accountId: string;
getIdleTimeoutMs: () => number;

View File

@@ -1,6 +1,5 @@
// Exa provider module implements model/runtime integration.
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
@@ -29,7 +28,6 @@ const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type ExaConfig = {
apiKey?: string;
@@ -78,10 +76,6 @@ async function readExaSearchResults(response: Response): Promise<ExaSearchResult
}
}
async function readExaErrorDetail(response: Response): Promise<string> {
return await readResponseTextLimited(response, EXA_ERROR_BODY_LIMIT_BYTES);
}
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
@@ -413,7 +407,7 @@ async function runExaSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await readExaErrorDetail(res);
const detail = await res.text();
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
return readExaSearchResults(res);
@@ -613,7 +607,6 @@ export const testing = {
resolveExaSearchCount,
resolveExaSearchEndpoint,
resolveFreshnessStartDate,
readExaErrorDetail,
readExaSearchResults,
} as const;
export { testing as __testing };

View File

@@ -1,31 +1,9 @@
// Exa tests cover exa web search provider plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { testing } from "../test-api.js";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
@@ -264,20 +242,4 @@ describe("exa web search provider", () => {
"Exa API returned malformed JSON",
);
});
it("bounds Exa API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const detail = await testing.readExaErrorDetail(tracked.response);
expect(detail).toContain("exa upstream unavailable");
expect(detail).not.toContain("tail");
expect(await testing.readExaErrorDetail(new Response("short"))).toBe("short");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

1
extensions/feeds/api.ts Normal file
View File

@@ -0,0 +1 @@
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";

28
extensions/feeds/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerFeedsCli } from "./src/cli.js";
import { registerFeedsDoctorChecks } from "./src/doctor/register.js";
export default definePluginEntry({
id: "feeds",
name: "Feeds",
description: "Adds configured catalog feed source validation for skills and plugins.",
register(api) {
api.registerCli(
async ({ program }) => {
registerFeedsCli(program);
},
{
descriptors: [
{
name: "feeds",
description: "Inspect configured skill and plugin catalog feeds",
hasSubcommands: true,
},
],
},
);
registerFeedsDoctorChecks();
},
});
export { registerFeedsCli } from "./src/cli.js";
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";

View File

@@ -0,0 +1,52 @@
{
"id": "feeds",
"name": "Feeds",
"description": "Adds configured catalog feed source validation for skills and plugins.",
"activation": {
"onStartup": false,
"onCommands": ["doctor", "feeds"]
},
"commandAliases": [{ "name": "feeds", "kind": "cli" }],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable feeds doctor checks."
},
"sources": {
"type": "array",
"description": "Catalog feed sources used for curated skill and plugin discovery.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "url"],
"properties": {
"id": {
"type": "string",
"description": "Stable local feed identifier."
},
"url": {
"type": "string",
"description": "Absolute https:// or file:// feed document URL."
},
"enabled": {
"type": "boolean",
"description": "Enable this feed source. Defaults to true."
},
"trust": {
"type": "string",
"enum": ["unsigned", "pinned"],
"description": "Whether this source is accepted unsigned or pinned by integrity hash."
},
"integrity": {
"type": "string",
"description": "Optional sha256:<hex> hash for pinned feed documents."
}
}
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/feeds",
"version": "2026.5.28",
"private": true,
"description": "OpenClaw feed source configuration and doctor checks",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.28"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,353 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import {
feedsListCommand,
feedsSearchCommand,
feedsSourcesCommand,
type FeedsCommandRuntime,
} from "./cli.js";
import {
FEED_FETCH_TIMEOUT_MS,
FEED_READ_IDLE_TIMEOUT_MS,
MAX_FEED_DOCUMENT_BYTES,
} from "./feed-document.js";
describe("Feeds CLI", () => {
beforeEach(() => {
vi.useRealTimers();
fetchWithSsrFGuardMock.mockReset();
});
it("lists configured sources", async () => {
const runtime = createRuntime({ sources: [{ id: "approved", url: "file:///feeds.json" }] });
const exitCode = await feedsSourcesCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).sources).toEqual([
{ id: "approved", url: "file:///feeds.json", enabled: true },
]);
});
it("lists an empty source set when no sources are configured", async () => {
const runtime = createRuntime({});
const exitCode = await feedsSourcesCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).sources).toEqual([]);
expect(runtime.stderr).toBe("");
});
it("loads file-backed feed entries", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "excel-review", version: "1.2.3", name: "Excel Review" },
{ type: "plugin", id: "teams-channel", tags: ["m365", "channel"] },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([
expect.objectContaining({
sourceId: "approved",
feedId: "company-approved",
id: "excel-review",
}),
expect.objectContaining({
sourceId: "approved",
feedId: "company-approved",
id: "teams-channel",
}),
]);
});
it("searches across entry metadata", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "excel-review", tags: ["m365"] },
{ type: "plugin", id: "calendar-helper", tags: ["outlook"] },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsSearchCommand("outlook", { json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([
expect.objectContaining({ id: "calendar-helper" }),
]);
});
it("loads HTTPS feeds through the SSRF guard", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] }),
),
release,
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "https://feeds.example.com/feed.json" }],
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([]);
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
url: "https://feeds.example.com/feed.json",
auditContext: "feeds.feed-document",
timeoutMs: FEED_FETCH_TIMEOUT_MS,
});
expect(release).toHaveBeenCalledTimes(1);
});
it("reports SSRF guard blocks for HTTPS feeds", async () => {
fetchWithSsrFGuardMock.mockRejectedValue(new Error("SSRF blocked private network target"));
const runtime = createRuntime({
sources: [{ id: "private", url: "https://127.0.0.1/feed.json" }],
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("SSRF blocked private network target");
});
it("reports oversized HTTPS feed documents and releases the guarded dispatcher", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("x".repeat(MAX_FEED_DOCUMENT_BYTES + 1)),
release,
});
const runtime = createRuntime({
sources: [
{
id: "large",
url: "https://user:secret@feeds.example.com/large.json?token=hidden#frag",
},
],
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain(
"Feed URL https://feeds.example.com/large.json response exceeds " +
MAX_FEED_DOCUMENT_BYTES +
" bytes.",
);
expect(runtime.stderr).not.toContain("secret");
expect(runtime.stderr).not.toContain("token=hidden");
expect(release).toHaveBeenCalledTimes(1);
});
it("reports stalled HTTPS feed bodies with redacted URLs", async () => {
vi.useFakeTimers();
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
new ReadableStream({
start() {
// Keep the stream open without chunks so readResponseWithLimit hits its idle timer.
},
}),
),
release,
});
const runtime = createRuntime({
sources: [
{
id: "slow",
url: "https://user:secret@feeds.example.com/slow.json?token=hidden#frag",
},
],
});
const run = feedsListCommand({ json: true }, runtime);
await vi.advanceTimersByTimeAsync(FEED_READ_IDLE_TIMEOUT_MS);
const exitCode = await run;
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain(
`Feed URL https://feeds.example.com/slow.json response stalled for ${FEED_READ_IDLE_TIMEOUT_MS}ms.`,
);
expect(runtime.stderr).not.toContain("secret");
expect(runtime.stderr).not.toContain("token=hidden");
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: FEED_FETCH_TIMEOUT_MS }),
);
expect(release).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("checks pinned feed integrity while loading entries", async () => {
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex").toUpperCase()}`;
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json", trust: "pinned", integrity }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([]);
});
it("rejects pinned feed sources without integrity", async () => {
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json", trust: "pinned" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("Feed source approved requires integrity for pinned trust.");
});
it("formats install hints without installing feed entries", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
name: "Calendar Helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
{
type: "skill",
id: "excel-review",
install: { source: "clawhub", slug: "excel-review" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
runtime.isTTY = true;
const exitCode = await feedsSearchCommand("calendar", { type: "plugin" }, runtime);
expect(exitCode).toBe(0);
expect(runtime.stdout).toContain("approved\tplugin\tcalendar-helper - Calendar Helper");
expect(runtime.stdout).toContain("Install: openclaw plugins install clawhub:openclaw-calendar");
expect(runtime.stdout).not.toContain("excel-review");
});
it("quotes install hint specs from feed metadata", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "unsafe-helper",
install: { source: "npm", spec: "safe-package && curl example.invalid" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
runtime.isTTY = true;
const exitCode = await feedsSearchCommand("unsafe", { type: "plugin" }, runtime);
expect(exitCode).toBe(0);
expect(runtime.stdout).toContain(
"Install: openclaw plugins install 'safe-package && curl example.invalid'",
);
});
it("filters search results by entry type", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "plugin", id: "calendar-helper", tags: ["shared"] },
{ type: "skill", id: "calendar-review", tags: ["shared"] },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsSearchCommand("shared", { type: "plugin", json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([
expect.objectContaining({
type: "plugin",
id: "calendar-helper",
}),
]);
});
it("rejects unsupported type filters", async () => {
const runtime = createRuntime({ sources: [{ id: "approved", url: "file:///feeds.json" }] });
const exitCode = await feedsListCommand({ type: "tool" }, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("Invalid --type value. Expected skill or plugin.");
});
});
function createRuntime(params: {
readonly sources?: readonly Record<string, unknown>[];
readonly files?: Readonly<Record<string, string>>;
}): FeedsCommandRuntime & { stdout: string; stderr: string; isTTY?: boolean } {
const runtime: FeedsCommandRuntime & { stdout: string; stderr: string; isTTY?: boolean } = {
stdout: "",
stderr: "",
writeStdout(value) {
this.stdout += value;
},
error(value) {
this.stderr += `${value}\n`;
},
async readConfigSnapshot() {
return {
valid: true,
config: {
plugins: { entries: { feeds: { enabled: true, config: { sources: params.sources } } } },
},
};
},
async readFile(path) {
const value = params.files?.[path];
if (value === undefined) {
throw new Error(`missing test file ${path}`);
}
return value;
},
};
return runtime;
}

330
extensions/feeds/src/cli.ts Normal file
View File

@@ -0,0 +1,330 @@
import type { Command } from "commander";
import { readConfigFileSnapshot } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
feedEntryMatchesQuery,
loadFeedDocument,
type FeedDocumentRuntime,
type FeedEntry,
type FeedSourceConfig,
type LoadedFeedDocument,
} from "./feed-document.js";
type FeedConfigSnapshot = {
readonly valid: boolean;
readonly issues?: readonly { readonly message?: string }[];
readonly config: {
readonly plugins?: {
readonly entries?: Record<string, { readonly config?: unknown } | undefined>;
};
};
};
export type FeedsCommandRuntime = FeedDocumentRuntime & {
writeStdout(value: string): void;
error(value: string): void;
isTTY?: boolean;
readConfigSnapshot?: (options: { readonly observe?: boolean }) => Promise<FeedConfigSnapshot>;
};
export type FeedsCommandOptions = {
readonly json?: boolean;
readonly source?: string;
readonly type?: string;
};
export type FeedEntryResult = FeedEntry & {
readonly sourceId: string;
readonly feedId: string;
};
const defaultRuntime: FeedsCommandRuntime = {
isTTY: process.stdout.isTTY,
writeStdout(value) {
process.stdout.write(value);
},
error(value) {
process.stderr.write(`${value}\n`);
},
};
export function registerFeedsCli(program: Command): void {
const feeds = program.command("feeds").description("Inspect configured skill and plugin feeds");
feeds
.command("sources")
.description("List configured feed sources")
.option("--json", "Emit JSON output")
.action(async (options: FeedsCommandOptions) => {
process.exitCode = await feedsSourcesCommand(options);
});
feeds
.command("list")
.description("List entries from configured feed sources")
.option("--source <id>", "Limit to one feed source id")
.option("--type <type>", "Limit to skill or plugin entries")
.option("--json", "Emit JSON output")
.action(async (options: FeedsCommandOptions) => {
process.exitCode = await feedsListCommand(options);
});
feeds
.command("search")
.argument("<query>", "Text to match against feed entry metadata")
.description("Search entries from configured feed sources")
.option("--source <id>", "Limit to one feed source id")
.option("--type <type>", "Limit to skill or plugin entries")
.option("--json", "Emit JSON output")
.action(async (query: string, options: FeedsCommandOptions) => {
process.exitCode = await feedsSearchCommand(query, options);
});
}
export async function feedsSourcesCommand(
options: FeedsCommandOptions,
runtime: FeedsCommandRuntime = defaultRuntime,
): Promise<number> {
try {
const sources = await readConfiguredFeedSources(runtime);
if (options.json === true || runtime.isTTY !== true) {
runtime.writeStdout(JSON.stringify({ sources }, null, 2) + "\n");
} else {
runtime.writeStdout(formatSourceRows(sources));
}
return 0;
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
export async function feedsListCommand(
options: FeedsCommandOptions,
runtime: FeedsCommandRuntime = defaultRuntime,
): Promise<number> {
try {
assertFeedEntryType(options.type);
const loaded = await loadConfiguredFeedDocuments(options, runtime);
const entries = filterEntriesByType(flattenFeedEntries(loaded), options.type);
writeEntries(entries, options, runtime);
return 0;
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
export async function feedsSearchCommand(
query: string,
options: FeedsCommandOptions,
runtime: FeedsCommandRuntime = defaultRuntime,
): Promise<number> {
try {
assertFeedEntryType(options.type);
const loaded = await loadConfiguredFeedDocuments(options, runtime);
const entries = filterEntriesByType(
flattenFeedEntries(loaded).filter((entry) => feedEntryMatchesQuery(entry, query)),
options.type,
);
writeEntries(entries, options, runtime);
return 0;
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
async function loadConfiguredFeedDocuments(
options: FeedsCommandOptions,
runtime: FeedsCommandRuntime,
): Promise<readonly LoadedFeedDocument[]> {
const sources = (await readConfiguredFeedSources(runtime)).filter((source) => source.enabled);
const selected = selectSources(sources, options.source);
return Promise.all(selected.map((source) => loadFeedDocument(source, runtime)));
}
async function readConfiguredFeedSources(
runtime: FeedsCommandRuntime,
): Promise<readonly FeedSourceConfig[]> {
const readSnapshot = runtime.readConfigSnapshot ?? readConfigFileSnapshot;
const snapshot = await readSnapshot({ observe: false });
if (!snapshot.valid) {
const firstIssue = snapshot.issues?.[0]?.message ?? "unknown config parse error";
throw new Error(`OpenClaw config is invalid: ${firstIssue}`);
}
const config = snapshot.config.plugins?.entries?.feeds?.config;
if (config === undefined) {
return [];
}
if (!isRecord(config)) {
throw new Error("plugins.entries.feeds.config must be an object.");
}
if (config.sources === undefined) {
return [];
}
if (!Array.isArray(config.sources)) {
throw new Error("plugins.entries.feeds.config.sources must be an array.");
}
return config.sources.map((source, index) => parseSourceConfig(source, index));
}
function parseSourceConfig(value: unknown, index: number): FeedSourceConfig {
if (!isRecord(value)) {
throw new Error(`Feed source ${index} must be an object.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${index} must declare an id.`);
}
if (typeof value.url !== "string" || value.url.trim() === "") {
throw new Error(`Feed source ${value.id} must declare a url.`);
}
if (value.trust !== undefined && value.trust !== "unsigned" && value.trust !== "pinned") {
throw new Error(`Feed source ${value.id} has unsupported trust value.`);
}
if (value.integrity !== undefined && typeof value.integrity !== "string") {
throw new Error(`Feed source ${value.id} integrity must be a string.`);
}
return {
id: value.id,
url: value.url,
enabled: value.enabled !== false,
...(value.trust === "unsigned" || value.trust === "pinned" ? { trust: value.trust } : {}),
...(typeof value.integrity === "string" ? { integrity: value.integrity } : {}),
};
}
function selectSources(
sources: readonly FeedSourceConfig[],
selectedId: string | undefined,
): readonly FeedSourceConfig[] {
if (selectedId === undefined) {
return sources;
}
const selected = sources.filter((source) => source.id === selectedId);
if (selected.length === 0) {
throw new Error(`No enabled feed source found for '${selectedId}'.`);
}
return selected;
}
function flattenFeedEntries(loaded: readonly LoadedFeedDocument[]): readonly FeedEntryResult[] {
return loaded.flatMap((feed) =>
feed.document.entries.map((entry) => ({
...entry,
sourceId: feed.source.id,
feedId: feed.document.id,
})),
);
}
function writeEntries(
entries: readonly FeedEntryResult[],
options: FeedsCommandOptions,
runtime: FeedsCommandRuntime,
): void {
if (options.json === true || runtime.isTTY !== true) {
runtime.writeStdout(JSON.stringify({ entries }, null, 2) + "\n");
return;
}
if (entries.length === 0) {
runtime.writeStdout("No feed entries found.\n");
return;
}
runtime.writeStdout(
entries
.map((entry) => {
const version = entry.version === undefined ? "" : `@${entry.version}`;
const label = entry.name === undefined ? "" : ` - ${entry.name}`;
const install = formatFeedInstallCommand(entry);
const installHint = install === undefined ? "" : `\n Install: ${install}`;
return `${entry.sourceId}\t${entry.type}\t${entry.id}${version}${label}${installHint}`;
})
.join("\n") + "\n",
);
}
function filterEntriesByType(
entries: readonly FeedEntryResult[],
type: string | undefined,
): readonly FeedEntryResult[] {
if (type === undefined) {
return entries;
}
assertFeedEntryType(type);
return entries.filter((entry) => entry.type === type);
}
function assertFeedEntryType(
type: string | undefined,
): asserts type is "skill" | "plugin" | undefined {
if (type !== undefined && type !== "skill" && type !== "plugin") {
throw new Error("Invalid --type value. Expected skill or plugin.");
}
}
function formatFeedInstallCommand(entry: FeedEntry): string | undefined {
const install = entry.install;
if (!isRecord(install)) {
return undefined;
}
const source = typeof install.source === "string" ? install.source : undefined;
const spec = typeof install.spec === "string" ? install.spec.trim() : "";
const clawhubSpec = typeof install.clawhubSpec === "string" ? install.clawhubSpec.trim() : "";
const npmSpec = typeof install.npmSpec === "string" ? install.npmSpec.trim() : "";
const slug = typeof install.slug === "string" ? install.slug.trim() : "";
if (entry.type === "plugin") {
if (clawhubSpec) {
return formatOpenClawInstallCommand("plugins", normalizeClawHubSpec(clawhubSpec));
}
if (source === "clawhub" && spec) {
return formatOpenClawInstallCommand("plugins", normalizeClawHubSpec(spec));
}
if (npmSpec) {
return formatOpenClawInstallCommand("plugins", npmSpec);
}
if ((source === "npm" || source === "path" || source === "git") && spec) {
return formatOpenClawInstallCommand("plugins", spec);
}
return undefined;
}
if (entry.type === "skill") {
if (slug) {
return formatOpenClawInstallCommand("skills", slug);
}
if (source === "clawhub" && spec) {
return formatOpenClawInstallCommand("skills", spec.replace(/^clawhub:/u, ""));
}
if ((source === "git" || source === "path" || source === "local") && spec) {
return formatOpenClawInstallCommand("skills", spec);
}
}
return undefined;
}
function formatOpenClawInstallCommand(kind: "plugins" | "skills", spec: string): string {
return `openclaw ${kind} install ${quoteCliArg(spec)}`;
}
function quoteCliArg(value: string): string {
return /^[A-Za-z0-9_/:=.,@%+-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
}
function normalizeClawHubSpec(value: string): string {
return value.startsWith("clawhub:") ? value : `clawhub:${value}`;
}
function formatSourceRows(sources: readonly FeedSourceConfig[]): string {
if (sources.length === 0) {
return "No feed sources configured.\n";
}
return (
sources
.map((source) => {
const status = source.enabled ? "enabled" : "disabled";
const trust = source.trust ?? "unsigned";
return `${source.id}\t${status}\t${trust}\t${source.url}`;
})
.join("\n") + "\n"
);
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
evaluateFeedsConfig,
FEEDS_CHECK_IDS,
registerFeedsDoctorChecks,
resetFeedsDoctorChecksForTest,
} from "./register.js";
describe("Feeds doctor checks", () => {
it("registers each feeds health check once", () => {
const registered: string[] = [];
resetFeedsDoctorChecksForTest();
registerFeedsDoctorChecks({
registerHealthCheck(check) {
registered.push(check.id);
},
});
expect(registered).toEqual(FEEDS_CHECK_IDS);
});
it("accepts configured https and file feed sources", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/openclaw/feed.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
id: "local-review",
url: "file:///opt/openclaw/feeds/review.json",
},
],
},
},
},
},
},
});
expect(findings).toEqual([]);
});
it("reports duplicate ids, unsupported urls, and missing pinned integrity", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{ id: "company", url: "https://feeds.example.com/openclaw/feed.json" },
{ id: "company", url: "http://feeds.example.com/feed.json" },
{ id: "pinned", url: "https://feeds.example.com/pinned.json", trust: "pinned" },
],
},
},
},
},
},
});
expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "feeds/source-duplicate-id",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/id",
}),
expect.objectContaining({
checkId: "feeds/source-url-invalid",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/url",
}),
expect.objectContaining({
checkId: "feeds/source-integrity-missing",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#2/integrity",
}),
]),
);
});
it("warns when the enabled feeds plugin has no sources", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {},
},
},
},
},
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "feeds/source-missing",
severity: "warning",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources",
}),
]);
});
});

View File

@@ -0,0 +1,295 @@
import {
registerHealthCheck as registerPluginHealthCheck,
type HealthCheck,
type HealthCheckContext,
type HealthFinding,
} from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const CHECK_IDS = {
configInvalid: "feeds/config-invalid",
sourceMissing: "feeds/source-missing",
sourceDuplicateId: "feeds/source-duplicate-id",
sourceUrlInvalid: "feeds/source-url-invalid",
sourceIntegrityInvalid: "feeds/source-integrity-invalid",
sourceIntegrityMissing: "feeds/source-integrity-missing",
} as const;
export const FEEDS_CHECK_IDS = [
CHECK_IDS.configInvalid,
CHECK_IDS.sourceMissing,
CHECK_IDS.sourceDuplicateId,
CHECK_IDS.sourceUrlInvalid,
CHECK_IDS.sourceIntegrityInvalid,
CHECK_IDS.sourceIntegrityMissing,
] as const;
type FeedsCheckId = (typeof FEEDS_CHECK_IDS)[number];
export type FeedsDoctorRegistrationHost = {
readonly registerHealthCheck: (check: HealthCheck) => void;
};
let registered = false;
export function registerFeedsDoctorChecks(host?: FeedsDoctorRegistrationHost): void {
if (registered) {
return;
}
const registerHealthCheck = host?.registerHealthCheck ?? registerPluginHealthCheck;
for (const check of feedsHealthChecks) {
registerHealthCheck(check);
}
registered = true;
}
export function resetFeedsDoctorChecksForTest(): void {
registered = false;
}
const feedsHealthChecks: readonly HealthCheck[] = FEEDS_CHECK_IDS.map((id) => ({
id,
kind: "plugin",
description: feedsCheckDescription(id),
source: "feeds",
async detect(ctx) {
return evaluateFeedsConfig(ctx).filter((finding) => finding.checkId === id);
},
}));
function feedsCheckDescription(id: FeedsCheckId): string {
switch (id) {
case CHECK_IDS.configInvalid:
return "The Feeds plugin configuration is well-formed.";
case CHECK_IDS.sourceMissing:
return "The enabled Feeds plugin has at least one configured source.";
case CHECK_IDS.sourceDuplicateId:
return "Feed source ids are unique.";
case CHECK_IDS.sourceUrlInvalid:
return "Feed source URLs are supported absolute URLs.";
case CHECK_IDS.sourceIntegrityInvalid:
return "Feed source integrity hashes use sha256:<hex> syntax.";
case CHECK_IDS.sourceIntegrityMissing:
return "Pinned feed sources declare an integrity hash.";
}
const exhaustive: never = id;
return exhaustive;
}
export function evaluateFeedsConfig(
ctx: Pick<HealthCheckContext, "cfg">,
): readonly HealthFinding[] {
const config = ctx.cfg.plugins?.entries?.feeds?.config;
const configPath = "plugins.entries.feeds.config";
const configOcPath = "oc://openclaw.config/plugins/entries/feeds/config";
if (config === undefined) {
return [
{
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has no configured feed sources.",
source: "feeds",
path: configPath,
ocPath: configOcPath,
fixHint: "Add plugins.entries.feeds.config.sources with at least one feed source.",
},
];
}
if (!isRecord(config)) {
return [
invalidConfigFinding({
propertyPath: configPath,
target: configOcPath,
message: "plugins.entries.feeds.config must be an object.",
fixHint: "Set plugins.entries.feeds.config to an object with a sources array.",
}),
];
}
const findings: HealthFinding[] = [];
const sources = config.sources;
if (sources === undefined) {
findings.push({
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has no configured feed sources.",
source: "feeds",
path: `${configPath}.sources`,
ocPath: `${configOcPath}/sources`,
fixHint: "Add at least one feed source.",
});
return findings;
}
if (!Array.isArray(sources)) {
findings.push(
invalidConfigFinding({
propertyPath: `${configPath}.sources`,
target: `${configOcPath}/sources`,
message: "plugins.entries.feeds.config.sources must be an array.",
fixHint: "Set sources to an array of feed source objects.",
}),
);
return findings;
}
if (sources.length === 0) {
findings.push({
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has an empty feed source list.",
source: "feeds",
path: `${configPath}.sources`,
ocPath: `${configOcPath}/sources`,
fixHint: "Add at least one feed source or disable the Feeds plugin.",
});
return findings;
}
const seenIds = new Map<string, number>();
sources.forEach((source, index) => {
findings.push(...evaluateFeedSource(source, index, seenIds));
});
return findings;
}
function evaluateFeedSource(
source: unknown,
index: number,
seenIds: Map<string, number>,
): readonly HealthFinding[] {
const sourcePath = `plugins.entries.feeds.config.sources[${index}]`;
const sourceOcPath = `oc://openclaw.config/plugins/entries/feeds/config/sources/#${index}`;
if (!isRecord(source)) {
return [
invalidConfigFinding({
propertyPath: sourcePath,
target: sourceOcPath,
message: `Feed source ${index} must be an object.`,
fixHint: "Replace this source with an object containing id and url.",
}),
];
}
const findings: HealthFinding[] = [];
const id = typeof source.id === "string" ? source.id.trim() : "";
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(id)) {
findings.push(
invalidConfigFinding({
propertyPath: `${sourcePath}.id`,
target: `${sourceOcPath}/id`,
message: `Feed source ${index} must have a stable lowercase id.`,
fixHint: "Use a lowercase id such as company-approved or clawhub-public.",
}),
);
} else {
const previous = seenIds.get(id);
if (previous !== undefined) {
findings.push({
checkId: CHECK_IDS.sourceDuplicateId,
severity: "error",
message: `Feed source id '${id}' duplicates sources[${previous}].`,
source: "feeds",
path: `${sourcePath}.id`,
ocPath: `${sourceOcPath}/id`,
fixHint: "Give each feed source a unique id.",
});
} else {
seenIds.set(id, index);
}
}
const url = typeof source.url === "string" ? source.url.trim() : "";
if (!isSupportedFeedUrl(url)) {
findings.push({
checkId: CHECK_IDS.sourceUrlInvalid,
severity: "error",
message: `Feed source ${id || index} must use an absolute https:// or file:// URL.`,
source: "feeds",
path: `${sourcePath}.url`,
ocPath: `${sourceOcPath}/url`,
fixHint: "Use an absolute https:// URL for hosted feeds or file:// URL for local feeds.",
});
}
const trust = source.trust;
if (trust !== undefined && trust !== "unsigned" && trust !== "pinned") {
findings.push(
invalidConfigFinding({
propertyPath: `${sourcePath}.trust`,
target: `${sourceOcPath}/trust`,
message: `Feed source ${id || index} has unsupported trust value '${formatUnknown(trust)}'.`,
fixHint: 'Use trust "unsigned" or "pinned".',
}),
);
}
const integrity = source.integrity;
if (integrity !== undefined && !isSha256Integrity(integrity)) {
findings.push({
checkId: CHECK_IDS.sourceIntegrityInvalid,
severity: "error",
message: `Feed source ${id || index} has an invalid integrity hash.`,
source: "feeds",
path: `${sourcePath}.integrity`,
ocPath: `${sourceOcPath}/integrity`,
fixHint: "Use sha256:<64 lowercase or uppercase hexadecimal characters>.",
});
}
if (trust === "pinned" && integrity === undefined) {
findings.push({
checkId: CHECK_IDS.sourceIntegrityMissing,
severity: "error",
message: `Pinned feed source ${id || index} must declare an integrity hash.`,
source: "feeds",
path: `${sourcePath}.integrity`,
ocPath: `${sourceOcPath}/integrity`,
fixHint: 'Add integrity: "sha256:<hex>" or change trust to "unsigned".',
});
}
return findings;
}
function invalidConfigFinding(params: {
readonly propertyPath: string;
readonly target: string;
readonly message: string;
readonly fixHint: string;
}): HealthFinding {
return {
checkId: CHECK_IDS.configInvalid,
severity: "error",
message: params.message,
source: "feeds",
path: params.propertyPath,
ocPath: params.target,
fixHint: params.fixHint,
};
}
function isSupportedFeedUrl(value: string): boolean {
if (value === "") {
return false;
}
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "file:";
} catch {
return false;
}
}
function isSha256Integrity(value: unknown): boolean {
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/iu.test(value);
}
function formatUnknown(value: unknown): string {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return "<unprintable>";
}
}

View File

@@ -0,0 +1,203 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export const MAX_FEED_DOCUMENT_BYTES = 1024 * 1024;
export const FEED_FETCH_TIMEOUT_MS = 15_000;
export const FEED_READ_IDLE_TIMEOUT_MS = 15_000;
export type FeedSourceConfig = {
readonly id: string;
readonly url: string;
readonly enabled: boolean;
readonly trust?: "unsigned" | "pinned";
readonly integrity?: string;
};
export type FeedEntryType = "skill" | "plugin";
export type FeedEntry = {
readonly type: FeedEntryType;
readonly id: string;
readonly version?: string;
readonly name?: string;
readonly description?: string;
readonly tags?: readonly string[];
readonly sourceUrl?: string;
readonly sha256?: string;
readonly install?: Record<string, unknown>;
readonly approval?: Record<string, unknown>;
};
export type FeedDocument = {
readonly schemaVersion: 1;
readonly id: string;
readonly generatedAt?: string;
readonly entries: readonly FeedEntry[];
};
export type LoadedFeedDocument = {
readonly source: FeedSourceConfig;
readonly document: FeedDocument;
readonly sha256: string;
};
export type FeedFetch = (url: string) => Promise<{ readonly ok: boolean; readonly text: string }>;
export type FeedDocumentRuntime = {
readonly fetch?: FeedFetch;
readonly readFile?: (path: string) => Promise<Buffer | string>;
};
export async function loadFeedDocument(
source: FeedSourceConfig,
runtime: FeedDocumentRuntime = {},
): Promise<LoadedFeedDocument> {
const raw = await readFeedBytes(source.url, runtime);
const sha256 = createHash("sha256").update(raw).digest("hex");
if (source.trust === "pinned" && source.integrity === undefined) {
throw new Error(`Feed source ${source.id} requires integrity for pinned trust.`);
}
if (source.integrity !== undefined && source.integrity.toLowerCase() !== `sha256:${sha256}`) {
throw new Error(`Feed source ${source.id} integrity mismatch.`);
}
const parsed = parseFeedDocument(JSON.parse(raw.toString("utf8")), source.id);
return { source, document: parsed, sha256 };
}
export function parseFeedDocument(value: unknown, sourceId = "feed"): FeedDocument {
if (!isRecord(value)) {
throw new Error(`Feed source ${sourceId} must contain a JSON object.`);
}
if (value.schemaVersion !== 1) {
throw new Error(`Feed source ${sourceId} must use schemaVersion 1.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${sourceId} must declare a feed id.`);
}
if (value.generatedAt !== undefined && typeof value.generatedAt !== "string") {
throw new Error(`Feed source ${sourceId} generatedAt must be a string when present.`);
}
if (!Array.isArray(value.entries)) {
throw new Error(`Feed source ${sourceId} entries must be an array.`);
}
return {
schemaVersion: 1,
id: value.id,
...(typeof value.generatedAt === "string" ? { generatedAt: value.generatedAt } : {}),
entries: value.entries.map((entry, index) => parseFeedEntry(entry, sourceId, index)),
};
}
export function feedEntryMatchesQuery(entry: FeedEntry, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (normalized === "") {
return true;
}
const haystack = [
entry.type,
entry.id,
entry.version,
entry.name,
entry.description,
...(entry.tags ?? []),
]
.filter((value): value is string => typeof value === "string")
.join("\n")
.toLowerCase();
return haystack.includes(normalized);
}
async function readFeedBytes(url: string, runtime: FeedDocumentRuntime): Promise<Buffer> {
const parsed = new URL(url);
if (parsed.protocol === "file:") {
const read = runtime.readFile ?? readFile;
const value = await read(fileURLToPath(parsed));
return Buffer.isBuffer(value) ? value : Buffer.from(value);
}
if (parsed.protocol === "https:") {
const fetcher = runtime.fetch ?? defaultFetch;
const response = await fetcher(url);
if (!response.ok) {
throw new Error(
`Feed URL ${formatFeedUrlForDiagnostics(url)} did not return a successful response.`,
);
}
return Buffer.from(response.text, "utf8");
}
throw new Error(`Unsupported feed URL protocol for ${formatFeedUrlForDiagnostics(url)}.`);
}
function formatFeedUrlForDiagnostics(value: string): string {
try {
const parsed = new URL(value);
parsed.username = "";
parsed.password = "";
parsed.search = "";
parsed.hash = "";
return parsed.toString();
} catch {
return value.split(/[?#]/u, 1)[0] ?? value;
}
}
async function defaultFetch(url: string): Promise<{ readonly ok: boolean; readonly text: string }> {
const { response, release } = await fetchWithSsrFGuard({
url,
auditContext: "feeds.feed-document",
timeoutMs: FEED_FETCH_TIMEOUT_MS,
});
try {
const body = await readResponseWithLimit(response, MAX_FEED_DOCUMENT_BYTES, {
chunkTimeoutMs: FEED_READ_IDLE_TIMEOUT_MS,
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(
"Feed URL " +
formatFeedUrlForDiagnostics(url) +
" response stalled for " +
chunkTimeoutMs +
"ms.",
),
onOverflow: ({ maxBytes }) =>
new Error(
"Feed URL " +
formatFeedUrlForDiagnostics(url) +
" response exceeds " +
maxBytes +
" bytes.",
),
});
return { ok: response.ok, text: body.toString("utf8") };
} finally {
await release();
}
}
function parseFeedEntry(value: unknown, sourceId: string, index: number): FeedEntry {
if (!isRecord(value)) {
throw new Error(`Feed source ${sourceId} entry ${index} must be an object.`);
}
if (value.type !== "skill" && value.type !== "plugin") {
throw new Error(`Feed source ${sourceId} entry ${index} must be a skill or plugin.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${sourceId} entry ${index} must declare an id.`);
}
return {
type: value.type,
id: value.id,
...(typeof value.version === "string" ? { version: value.version } : {}),
...(typeof value.name === "string" ? { name: value.name } : {}),
...(typeof value.description === "string" ? { description: value.description } : {}),
...(Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string")
? { tags: value.tags }
: {}),
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
...(typeof value.sha256 === "string" ? { sha256: value.sha256 } : {}),
...(isRecord(value.install) ? { install: value.install } : {}),
...(isRecord(value.approval) ? { approval: value.approval } : {}),
};
}

View File

@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"id": "openclaw-feeds-proof",
"generatedAt": "2026-06-19T00:00:00.000Z",
"entries": [
{
"type": "plugin",
"id": "proof-calendar-helper",
"version": "1.0.0",
"name": "Proof Calendar Helper",
"description": "Fixture entry used by HTTPS feed behavior proof.",
"tags": ["proof", "calendar"],
"install": {
"source": "clawhub",
"spec": "proof-calendar-helper"
}
}
]
}

View File

@@ -1,10 +1,7 @@
// Lmstudio plugin module implements models.fetch behavior.
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import {
readProviderJsonArrayFieldResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -20,7 +17,6 @@ import {
import { buildLmstudioAuthHeaders } from "./runtime.js";
const log = createSubsystemLogger("extensions/lmstudio/models");
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type LmstudioLoadResponse = {
status?: string;
@@ -257,7 +253,7 @@ export async function ensureLmstudioModelLoaded(params: {
});
try {
if (!response.ok) {
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
const body = await response.text();
throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`);
}
let payload: LmstudioLoadResponse;

View File

@@ -44,27 +44,6 @@ describe("lmstudio-models", () => {
}
return JSON.parse(init.body) as unknown;
};
const cancelTrackedResponse = (
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} => {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
};
const createModelLoadFetchMock = (params?: {
loadedContextLength?: number;
maxContextLength?: number;
@@ -507,39 +486,6 @@ describe("lmstudio-models", () => {
).rejects.toThrow("LM Studio model load returned malformed JSON");
});
it("bounds model load error bodies", async () => {
const body = `${"lmstudio load unavailable ".repeat(512)}tail`;
const tracked = cancelTrackedResponse(body, { status: 503 });
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return tracked.response;
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
vi.stubGlobal("fetch", asFetch(fetchMock));
const error = await ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/LM Studio model load failed \(503\): lmstudio load unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reloads model to the clamped default target when already loaded below the default window", async () => {
const fetchMock = createModelLoadFetchMock({
loadedContextLength: 4096,

View File

@@ -3,28 +3,6 @@ import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -52,116 +30,6 @@ describe("normalizeOAuthExpires", () => {
});
describe("loginMiniMaxPortalOAuth", () => {
it("bounds authorization error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(
`${"minimax authorization unavailable ".repeat(1024)}tail`,
{
status: 503,
headers: { "Content-Type": "text/plain" },
},
);
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/MiniMax OAuth authorization failed: minimax authorization unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds token error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"minimax token unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let callCount = 0;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
if (callCount === 1) {
return new Response(
JSON.stringify({
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return tracked.response;
});
vi.stubGlobal("fetch", fetchMock);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("minimax token unavailable");
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds HTTP 200 token bodies before app-level parsing", async () => {
const tracked = cancelTrackedResponse(`${'{"status":"error","detail":"'.repeat(512)}tail`, {
status: 200,
headers: { "Content-Type": "application/json" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let callCount = 0;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
if (callCount === 1) {
return new Response(
JSON.stringify({
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return tracked.response;
});
vi.stubGlobal("fetch", fetchMock);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("MiniMax OAuth failed to parse response.");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("uses MiniMax account OAuth endpoints directly for global and CN login", async () => {
for (const [region, expectedHosts] of [
[

View File

@@ -7,7 +7,6 @@ import {
resolvePositiveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -30,7 +29,6 @@ const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion";
const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code";
const MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD = 1_000_000_000;
const MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD = 1_000_000_000_000;
const MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
@@ -117,7 +115,7 @@ async function requestOAuthCode(params: {
});
try {
if (!response.ok) {
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
const text = await response.text();
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
}
@@ -173,7 +171,7 @@ async function pollOAuthToken(params: {
}
async function parseMiniMaxOAuthTokenResponse(response: Response): Promise<TokenResult> {
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
const text = await response.text();
let payload:
| {
status?: string;

View File

@@ -65,7 +65,7 @@ export {
// `evaluatePredicate`, `getPathLayout`, `parseOrdinalSeg`,
// `parsePredicateSeg`, `parseUnionSeg`, `quoteSeg`, `unquoteSeg`,
// `resolvePositionalSeg`, `splitRespectingBrackets`
// `repackPath`, `resolvePositionalSeg`, `splitRespectingBrackets`
// were exported from earlier prototypes. They're substrate-internal
// helpers — used by `find.ts`, the per-kind resolvers, and the parser
// itself, but not part of the upstream-portable public surface.

View File

@@ -546,7 +546,7 @@ export interface PathSegmentLayout {
export function getPathLayout(path: OcPath): PathSegmentLayout {
// Quote-aware split — `.split('.')` would shred a quoted segment
// containing a literal `.` (e.g. `"a.b"`).
// containing a literal `.` (e.g. `"a.b"`) and break repackPath.
const sectionSubs = path.section === undefined ? [] : splitRespectingBrackets(path.section, ".");
const itemSubs = path.item === undefined ? [] : splitRespectingBrackets(path.item, ".");
const fieldSubs = path.field === undefined ? [] : splitRespectingBrackets(path.field, ".");
@@ -558,6 +558,31 @@ export function getPathLayout(path: OcPath): PathSegmentLayout {
};
}
/**
* Re-pack a concrete sub-segment list into an `OcPath` preserving the
* pattern's slot boundaries. Throws on length mismatch.
*/
export function repackPath(pattern: OcPath, subs: readonly string[]): OcPath {
const layout = getPathLayout(pattern);
if (subs.length !== layout.subs.length) {
fail(
`repack length mismatch: pattern has ${layout.subs.length} sub-segments, got ${subs.length}`,
formatOcPath(pattern),
"OC_PATH_REPACK_LENGTH",
);
}
const sectionSubs = subs.slice(0, layout.sectionLen);
const itemSubs = subs.slice(layout.sectionLen, layout.sectionLen + layout.itemLen);
const fieldSubs = subs.slice(layout.sectionLen + layout.itemLen);
return {
file: pattern.file,
...(sectionSubs.length > 0 ? { section: sectionSubs.join(".") } : {}),
...(itemSubs.length > 0 ? { item: itemSubs.join(".") } : {}),
...(fieldSubs.length > 0 ? { field: fieldSubs.join(".") } : {}),
...(pattern.session !== undefined ? { session: pattern.session } : {}),
};
}
function extractSession(queryPart: string, input: string): string | undefined {
if (queryPart.length === 0) {
return undefined;

View File

@@ -83,28 +83,6 @@ function firstGuardedFetchCall(): Record<string, unknown> {
return call as Record<string, unknown>;
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function expectEmbeddingFetch(
fetchMock: ReturnType<typeof mockEmbeddingFetch>,
url: string,
@@ -339,39 +317,6 @@ describe("ollama embedding provider", () => {
});
});
it("bounds embed error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"ollama embed unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
let error: unknown;
try {
await provider.embedQuery("hello");
} catch (err) {
error = err;
}
expect(String(error)).toContain("Ollama embed HTTP 503");
expect(String(error)).toContain("ollama embed unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reports malformed embed JSON with a provider-owned error", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -6,7 +6,6 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
@@ -58,7 +57,6 @@ export type OllamaEmbeddingClient = {
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const QUERY_INSTRUCTION_TEMPLATES = [
{
@@ -342,11 +340,7 @@ export async function createOllamaEmbeddingProvider(
},
onResponse: async (response) => {
if (!response.ok) {
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},

View File

@@ -1534,28 +1534,6 @@ function getGuardedFetchCall(fetchMock: typeof fetchWithSsrFGuardMock): GuardedF
return (fetchMock.mock.calls.at(0)?.[0] as GuardedFetchCall | undefined) ?? { url: "" };
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
async function createOllamaTestStream(params: {
baseUrl: string;
defaultHeaders?: Record<string, string>;
@@ -2706,14 +2684,12 @@ describe("createOllamaStreamFn", () => {
);
});
it("surfaces bounded non-2xx HTTP response text as a status-prefixed error", async () => {
const tracked = cancelTrackedResponse(`${"Service Unavailable ".repeat(1024)}tail`, {
status: 503,
statusText: "Service Unavailable",
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
it("surfaces non-2xx HTTP response as status-prefixed error", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
response: new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
}),
release: vi.fn(async () => undefined),
});
try {
@@ -2729,10 +2705,6 @@ describe("createOllamaStreamFn", () => {
// The error message must start with the HTTP status code so that
// extractLeadingHttpStatus can parse it for failover/retry logic.
expect(errorEvent.error.errorMessage).toMatch(/^503\b/);
expect(errorEvent.error.errorMessage).toContain("Service Unavailable");
expect(errorEvent.error.errorMessage).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
} finally {
fetchWithSsrFGuardMock.mockReset();
}

View File

@@ -18,7 +18,6 @@ import type {
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
@@ -55,7 +54,6 @@ export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
const OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const GARBLED_VISIBLE_TEXT_MODEL_RE = /\b(?:glm|kimi)\b/i;
const GARBLED_VISIBLE_TEXT_MIN_CHARS = 80;
const GARBLED_VISIBLE_TEXT_SYMBOL_RE = /[$#%&="'_~`^|\\/*+\-[\]{}()<>:;,.!?]/gu;
@@ -1213,10 +1211,7 @@ function createRawOllamaStreamFn(
try {
if (!response.ok) {
const errorText = await readResponseTextLimited(
response,
OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`${response.status} ${errorText}`);
}
if (!response.body) {

View File

@@ -15,28 +15,6 @@ function jsonlBytes(value: string): number {
return jsonlEncoder.encode(value).byteLength;
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function fetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
@@ -265,56 +243,4 @@ describe("OpenAI embedding batch output", () => {
["3", [4]],
]);
});
it("bounds batch resource error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"batch status unavailable ".repeat(1024)}tail`, {
status: 400,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let batchStatusReturned = false;
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = fetchInputUrl(input);
if (url.endsWith("/files") && init?.method === "POST") {
return jsonResponse({ id: "file-0" });
}
if (url.endsWith("/batches") && init?.method === "POST") {
return jsonResponse({ id: "batch-0", status: "in_progress" });
}
if (url.endsWith("/batches/batch-0") && !batchStatusReturned) {
batchStatusReturned = true;
return tracked.response;
}
return new Response("unexpected request", { status: 500 });
});
await expect(
runOpenAiEmbeddingBatches({
openAi: {
baseUrl: "https://openai-compatible.example/v1",
headers: { Authorization: "Bearer test" },
model: "text-embedding-3-small",
fetchImpl,
},
agentId: "main",
requests: [
{
custom_id: "0",
method: "POST",
url: "/v1/embeddings",
body: {
model: "text-embedding-3-small",
input: "payload",
},
},
],
wait: true,
concurrency: 1,
pollIntervalMs: 1000,
timeoutMs: 60_000,
}),
).rejects.toThrow(/openai batch status failed: 400 batch status unavailable/);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -18,7 +18,6 @@ import {
uploadBatchJsonlFile,
withRemoteHttpResponse,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenAiEmbeddingClient } from "./embedding-provider.js";
@@ -56,7 +55,6 @@ const OPENAI_BATCH_MAX_REQUESTS = 50000;
// splitter avoids boundary-size uploads while preserving source-wide batching.
const OPENAI_BATCH_MAX_JSONL_BYTES = 190 * 1024 * 1024;
const OPENAI_BATCH_MAX_POLL_BACKOFF_MS = 5 * 60_000;
const OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
async function submitOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
@@ -128,7 +126,7 @@ async function fetchOpenAiBatchResource<T>(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await readResponseTextLimited(res, OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES);
const text = await res.text();
throw new Error(`${params.errorPrefix} failed: ${res.status} ${text}`);
}
return await params.parse(res);

View File

@@ -18,28 +18,6 @@ function createJsonResponse(body: unknown, init?: { status?: number }) {
});
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function fetchCall(fetchMock: ReturnType<typeof vi.fn<typeof fetch>>, index: number) {
const call = fetchMock.mock.calls[index];
if (!call) {
@@ -194,44 +172,6 @@ describe("loginOpenAICodexDeviceCode", () => {
expect(credentials.expires).toBe(expectedExpiry);
});
it("accepts token exchange JSON above the diagnostic preview limit", async () => {
const accessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
createJsonResponse({
device_auth_id: "device-auth-123",
user_code: "CODE-12345",
interval: "0",
}),
)
.mockResolvedValueOnce(
createJsonResponse({
authorization_code: "authorization-code-123",
code_verifier: "code-verifier-123",
}),
)
.mockResolvedValueOnce(
createJsonResponse({
access_token: accessToken,
refresh_token: "refresh-token-123",
id_token: "x".repeat(10_000),
}),
);
const credentials = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
});
expect(credentials.refresh).toBe("refresh-token-123");
});
it("falls back when device-code intervals and token lifetimes overflow safe milliseconds", async () => {
vi.useFakeTimers();
try {
@@ -301,28 +241,6 @@ describe("loginOpenAICodexDeviceCode", () => {
).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now");
});
it("bounds user-code error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"device code unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn().mockResolvedValueOnce(tracked.response);
const error = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/OpenAI device code request failed: HTTP 503 device code unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("surfaces device authorization failures with sanitized payload details", async () => {
const fetchMock = vi
.fn()

View File

@@ -3,7 +3,6 @@ import {
positiveSecondsToSafeMilliseconds,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { resolveCodexAccessTokenExpiry } from "./openai-chatgpt-auth-identity.js";
import { trimNonEmptyString } from "./openai-chatgpt-shared.js";
@@ -13,8 +12,6 @@ const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000;
const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000;
const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000;
const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
const OPENAI_CODEX_DEVICE_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const OPENAI_CODEX_DEVICE_JSON_BODY_LIMIT_BYTES = 256 * 1024;
function resolveOpenAICodexDeviceCodeHeaders(contentType: string): Record<string, string> {
const version = process.env.OPENCLAW_VERSION?.trim();
@@ -123,15 +120,6 @@ function formatDeviceCodeError(params: {
: `${params.prefix}: HTTP ${params.status}`;
}
async function readOpenAICodexDeviceBody(response: Response): Promise<string> {
return await readResponseTextLimited(
response,
response.ok
? OPENAI_CODEX_DEVICE_JSON_BODY_LIMIT_BYTES
: OPENAI_CODEX_DEVICE_ERROR_BODY_LIMIT_BYTES,
);
}
async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<RequestedDeviceCode> {
const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, {
method: "POST",
@@ -141,7 +129,7 @@ async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<Requ
}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
const bodyText = await response.text();
if (!response.ok) {
if (response.status === 404) {
throw new Error(
@@ -192,7 +180,7 @@ async function pollOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
const bodyText = await response.text();
if (response.ok) {
const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null;
const authorizationCode = trimNonEmptyString(body?.authorization_code);
@@ -242,7 +230,7 @@ async function exchangeOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
const bodyText = await response.text();
if (!response.ok) {
throw new Error(
formatDeviceCodeError({

View File

@@ -44,28 +44,6 @@ function jsonResponse(body: unknown, headers?: Record<string, string>): Response
});
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function readBody(call: EndpointCall): Record<string, unknown> {
if (typeof call.init.body !== "string") {
throw new Error("Expected a JSON string body.");
@@ -292,23 +270,4 @@ describe("runParallelMcpSearch", () => {
/initialize failed \(500\)/,
);
});
it("bounds initialize error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel mcp unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/initialize failed \(503\): parallel mcp unavailable/);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
// Free hosted Search MCP. This keyless transport is used only after the user
@@ -12,7 +11,6 @@ export const PARALLEL_MCP_SEARCH_URL = "https://search.parallel.ai/mcp";
// the server negotiates back on every follow-up request.
const MCP_PROTOCOL_VERSION = "2025-06-18";
const MCP_TIMEOUT_SECONDS = 30;
const PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -217,9 +215,7 @@ async function postMcp(params: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
text: response.ok
? await response.text()
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
text: await response.text(),
sessionIdHeader: response.headers.get("mcp-session-id"),
}),
);

View File

@@ -1,6 +1,5 @@
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
@@ -35,7 +34,6 @@ import {
const PARALLEL_BASE_URL = "https://api.parallel.ai";
const PARALLEL_SEARCH_PATHNAME = "/v1/search";
const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -146,9 +144,7 @@ async function runParallelSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await readResponseTextLimited(res, PARALLEL_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = await res.text().catch(() => "");
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
}
try {
@@ -281,7 +277,6 @@ export const testing = {
resolveParallelConfig,
resolveParallelSearchCount,
resolveParallelSearchEndpoint,
PARALLEL_ERROR_BODY_LIMIT_BYTES,
USER_AGENT,
} as const;

View File

@@ -37,28 +37,6 @@ function readMockedBody(call: EndpointCall | undefined): unknown {
return JSON.parse(call.init.body);
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
import { testing } from "../test-api.js";
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
@@ -551,38 +529,6 @@ describe("parallel web search provider", () => {
expect(body.advanced_settings?.max_results).toBe(5);
});
it("bounds Parallel API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { parallel: { apiKey: "par-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool
.execute({
objective: `parallel-error-body-${Date.now()}`,
search_queries: ["openclaw"],
})
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/Parallel API error \(503\): parallel upstream unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
// Unique objective so this test does not collide with the SDK's
// module-level web-search cache across other cases.

View File

@@ -1,26 +0,0 @@
// 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),
];
}

View File

@@ -1,161 +0,0 @@
// 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"],
},
]);
});
});

View File

@@ -1,543 +0,0 @@
// 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[];

View File

@@ -19,7 +19,13 @@ import {
scanPolicyIngress,
scanPolicyMcpServers,
} from "../policy-state.js";
import { registerPolicyDoctorChecks, resetPolicyDoctorChecksForTest } from "./register.js";
import {
POLICY_RULE_METADATA,
isPolicyValueAtLeastAsStrict,
registerPolicyDoctorChecks,
resetPolicyDoctorChecksForTest,
type PolicyRuleMetadata,
} from "./register.js";
let workspaceDir: string;
let originalOpenClawHome: string | undefined;
@@ -136,6 +142,200 @@ 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

View File

@@ -1,107 +0,0 @@
// 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,
];
}

View File

@@ -1,52 +0,0 @@
// 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,
];
}

View File

@@ -1,123 +0,0 @@
// 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,
];
}

View File

@@ -1,100 +0,0 @@
// 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,
];
}

View File

@@ -1,333 +0,0 @@
// 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.",
};
});
}

View File

@@ -1,232 +0,0 @@
// 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.",
};
});
}

View File

@@ -1,123 +0,0 @@
// 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,
];
}

View File

@@ -1,191 +0,0 @@
// 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,
];
}

View File

@@ -1,44 +0,0 @@
// 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);
});
});

View File

@@ -1,270 +0,0 @@
// 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);
}

View File

@@ -1,35 +0,0 @@
// 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[] };
};

View File

@@ -1,63 +0,0 @@
// 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;
}

View File

@@ -1,18 +0,0 @@
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 },
);
}
}

View File

@@ -3,7 +3,6 @@ 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,
@@ -344,7 +343,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(toQaErrorObject(error, "Non-Error rejection"));
reject(toLintErrorObject(error, "Non-Error rejection"));
return;
}
resolve({ stdout, stderr });
@@ -369,3 +368,17 @@ 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;
}

View File

@@ -34,17 +34,3 @@ 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;
}

View File

@@ -5,9 +5,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFileByIndex,
resolveQaEvidenceArtifactFile,
resolveQaEvidenceProducerFile,
resolveQaEvidenceFile,
} from "./evidence-gallery.js";
import {
@@ -25,28 +23,11 @@ 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,
@@ -145,7 +126,7 @@ describe("evidence gallery", () => {
expect.objectContaining({
exists: true,
kind: "runner-result",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&entryIndex=0&artifactIndex=0",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&artifactPath=runner%2Fresult.json",
mediaKind: "json",
preview: '{\n "ok": true\n}',
}),
@@ -163,152 +144,10 @@ 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",
...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";
const runDir = path.join(suiteDir, "script", "ux-matrix-evidence-dashboard", "run-1");
await fs.mkdir(path.join(runDir, "surfaces", "web-ui", "stages", "first-run"), {
recursive: true,
});
@@ -337,24 +176,22 @@ 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: [`${repoRoot}/ui.control`],
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: `${repoRoot}/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard`,
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: `${repoRoot}/.github/workflows/ux-matrix-qa.yml#ux-matrix-local`,
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
@@ -402,7 +239,7 @@ describe("evidence gallery", () => {
test: {
kind: "ux-matrix-cell",
id: "ux-matrix.web-ui.first-run",
title: `UX Matrix: web-ui / first-run at ${repoRoot}`,
title: "UX Matrix: web-ui / first-run",
source: { path: "scripts/ux-matrix/dashboard.ts" },
},
coverage: [{ id: "ui.control", role: "primary" }],
@@ -423,14 +260,7 @@ describe("evidence gallery", () => {
artifacts: [
{
kind: "screenshot",
path: path.join(
runDir,
"surfaces",
"web-ui",
"stages",
"first-run",
"screenshot.png",
),
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
source: "ux-matrix:web-ui:first-run",
},
],
@@ -462,10 +292,7 @@ describe("evidence gallery", () => {
artifacts: [
{
kind: "log",
path: repoRelativePath(
repoRoot,
path.join(runDir, "surfaces", "cli", "stages", "error-state", "logs.txt"),
),
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
source: "ux-matrix:cli:error-state",
},
],
@@ -499,8 +326,8 @@ describe("evidence gallery", () => {
blocked: 1,
"proof-gap": 1,
},
stages: ["<repo-root>/diagnostics", "first-run", "error-state"],
surfaces: ["<repo-root>/native", "web-ui", "cli"],
stages: ["first-run", "error-state"],
surfaces: ["web-ui", "cli"],
},
releaseLedger: {
counts: {
@@ -513,19 +340,21 @@ describe("evidence gallery", () => {
expect(model.producerContext?.matrix?.cells).toEqual([
{
artifactKinds: ["screenshot"],
artifactPaths: [expect.stringContaining(expectedWebScreenshotNeedle)],
coverageIds: ["<repo-root>/ui.control"],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
],
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: "<repo-root>/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: "<repo-root>/.github/workflows/ux-matrix-qa.yml#ux-matrix-local",
workflow: ".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 at <repo-root>",
title: "UX Matrix: web-ui / first-run",
},
{
artifactKinds: [],
@@ -545,7 +374,9 @@ describe("evidence gallery", () => {
},
{
artifactKinds: ["log"],
artifactPaths: [expect.stringContaining(expectedCliLogNeedle)],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
],
coverageIds: [],
runner: null,
stage: "error-state",
@@ -557,12 +388,9 @@ describe("evidence gallery", () => {
]);
expect(model.producerContext?.scorecard?.preview).toContain("# UX Matrix");
expect(model.producerContext?.scorecard?.href).toContain("/api/evidence/artifact?");
expect(decodeURIComponent(model.producerContext?.scorecard?.href ?? "")).not.toContain(
repoRoot,
);
expect(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");
@@ -572,14 +400,6 @@ 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");
@@ -593,7 +413,8 @@ describe("evidence gallery", () => {
expect(JSON.stringify(symlinkModel)).not.toContain("outside secret");
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: path.relative(repoRoot, path.join(runDir, "scorecard.md")),
artifactPath:
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/scorecard.md",
evidencePath: suiteDir,
repoRoot,
}),

View File

@@ -1,8 +1,6 @@
// 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,
@@ -12,7 +10,7 @@ import type {
QaEvidenceProducerContext,
QaEvidenceProducerContextFile,
} from "../shared/evidence-gallery-types.js";
import { toRepoPath, toRepoRelativePath } from "./cli-paths.js";
import { toRepoRelativePath } from "./cli-paths.js";
import {
QA_EVIDENCE_FILENAME,
validateQaEvidenceSummaryJson,
@@ -31,7 +29,6 @@ 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" },
@@ -43,7 +40,6 @@ 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 {
@@ -65,70 +61,6 @@ 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);
}
@@ -211,86 +143,11 @@ 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: {
@@ -302,13 +159,8 @@ async function resolveArtifactFileWithinRoots(params: {
if (!raw) {
return null;
}
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)) {
const candidates = path.isAbsolute(raw) ? [raw] : [path.resolve(params.evidenceDir, raw)];
if (!path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
candidates.push(path.resolve(params.repoRoot, raw));
}
for (const candidate of candidates) {
@@ -438,66 +290,38 @@ async function readJsonIfExists(
}
}
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));
}
function artifactHref(evidencePath: string, artifactPath: string) {
const params = new URLSearchParams({
evidencePath,
artifactPath,
});
return `/api/evidence/artifact?${params.toString()}`;
}
async function buildProducerContextFile(params: {
allowedRoots: readonly string[];
extraRoots: readonly string[];
artifactPath: 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, { 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),
href: artifactHref(params.hrefEvidencePath, params.artifactPath),
path: repoPath,
preview: await readPreview(realFile, params.previewKind).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> {
@@ -507,16 +331,6 @@ 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,
@@ -524,37 +338,24 @@ async function buildArtifactView(params: {
? "Evidence artifact is not declared by this evidence summary."
: "Evidence artifact not found.",
href: null,
kind: sanitizeGalleryText(params.artifact.kind, params),
kind: params.artifact.kind,
mediaKind,
path: displayPath,
path: params.artifact.path,
preview: null,
source: sanitizeGalleryText(params.artifact.source, params),
source: params.artifact.source,
};
}
return {
exists: true,
error: null,
href: artifactHref(params.hrefEvidencePath, {
artifactIndex: params.artifactIndex,
entryIndex: params.entryIndex,
}),
kind: sanitizeGalleryText(params.artifact.kind, params),
href: artifactHref(params.hrefEvidencePath, params.artifact.path),
kind: params.artifact.kind,
mediaKind,
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),
path: params.artifact.path,
preview: await readPreview(realFile, mediaKind).catch(
(error: unknown) => `Preview unavailable: ${formatErrorMessage(error)}`,
),
source: params.artifact.source,
};
}
@@ -590,26 +391,19 @@ function readStringArray(values: Iterable<unknown>) {
return readOrderedStringArray(values).toSorted();
}
function readMatrixDimensionIds(params: {
extraRoots: readonly string[];
fallback: readonly string[];
repoRoot: string;
value: unknown;
}): string[] {
if (!Array.isArray(params.value)) {
return sanitizeGalleryStringArray(params.fallback, params);
function readMatrixDimensionIds(value: unknown, fallback: readonly string[]): string[] {
if (!Array.isArray(value)) {
return readOrderedStringArray(fallback);
}
const ids = sanitizeGalleryStringArray(
params.value.map((entry) => {
const ids = readOrderedStringArray(
value.map((entry) => {
if (typeof entry === "string") {
return entry;
}
return readString(readRecord(entry)?.id);
}),
params,
);
for (const rawFallbackId of params.fallback) {
const fallbackId = sanitizeGalleryText(rawFallbackId, params);
for (const fallbackId of fallback) {
if (!ids.includes(fallbackId)) {
ids.push(fallbackId);
}
@@ -645,9 +439,7 @@ 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)
@@ -657,54 +449,34 @@ function readMatrixCells(params: {
: [];
const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries);
return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => {
const rawSurface = readString(cell.surface);
const rawStage = readString(cell.stage);
const rawStatus = readString(cell.status) ?? "proof-gap";
if (!rawSurface || !rawStage) {
const surface = readString(cell.surface);
const stage = readString(cell.stage);
const status = readString(cell.status) ?? "proof-gap";
if (!surface || !stage) {
return [];
}
const entry =
rawStatus === "proof-gap" ? null : (entriesByCell.get(`${rawSurface}:${rawStage}`) ?? null);
status === "proof-gap" ? null : (entriesByCell.get(`${surface}:${stage}`) ?? 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) => 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,
),
),
artifactKinds: readStringArray(artifacts.map((artifact) => artifact.kind)),
artifactPaths: artifacts.map((artifact) => artifact.path),
coverageIds: readStringArray(Array.isArray(cell.coverageIds) ? cell.coverageIds : []),
runner: runner
? {
availability: readRunnerString(runner.availability),
command: readRunnerString(runner.command),
lane: readRunnerString(runner.lane),
workflow: readRunnerString(runner.workflow),
availability: readString(runner.availability),
command: readString(runner.command),
lane: readString(runner.lane),
workflow: readString(runner.workflow),
}
: 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,
stage,
status,
surface,
testId: entry?.test.id ?? null,
title: entry?.test.title ?? null,
},
];
});
@@ -761,7 +533,6 @@ async function findUxMatrixProducerRoot(params: {
async function buildProducerContext(params: {
evidencePath: string;
extraRoots: readonly string[];
hrefEvidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
@@ -784,20 +555,16 @@ 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,
extraRoots: params.extraRoots,
artifactPath: toRepoRelativePath(repoRoot, producerPaths[file.key]),
filePath: producerPaths[file.key],
hrefEvidencePath: params.hrefEvidencePath,
previewKind: file.previewKind,
producerFile: file.key,
repoRoot,
}),
]),
@@ -807,9 +574,7 @@ async function buildProducerContext(params: {
QaEvidenceProducerContextFile | null
>;
const matrixCells = readMatrixCells({
extraRoots: params.extraRoots,
matrix,
repoRoot,
summaryEntries: params.summaryEntries,
});
return {
@@ -819,27 +584,23 @@ async function buildProducerContext(params: {
manifest && producerFiles.manifest
? {
...producerFiles.manifest,
runId: runId ? sanitizeGalleryText(runId, params) : null,
runStatus: runStatus ? sanitizeGalleryText(runStatus, params) : null,
runId: readString(readRecord(manifest.run)?.runId),
runStatus: readString(readRecord(manifest.run)?.status),
}
: null,
matrix: matrix
? {
cells: matrixCells,
counts: readCountRecord(matrix.counts),
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,
}),
path: toRepoRelativePath(repoRoot, matrixPath),
stages: readMatrixDimensionIds(
matrix.stages,
matrixCells.map((cell) => cell.stage),
),
surfaces: readMatrixDimensionIds(
matrix.surfaces,
matrixCells.map((cell) => cell.surface),
),
}
: null,
preflight: {
@@ -853,7 +614,7 @@ async function buildProducerContext(params: {
counts: readCountRecord(releaseLedger.counts),
}
: null,
rootPath: displayGalleryPath(rootPath, { extraRoots: params.extraRoots, repoRoot }),
rootPath: toRepoRelativePath(repoRoot, rootPath),
scorecard: producerFiles.scorecard,
};
}
@@ -881,8 +642,7 @@ export async function buildQaEvidenceGalleryModel(params: {
evidencePath: string;
repoRoot: string;
}): Promise<QaEvidenceGalleryModel> {
const requestedRepoRoot = path.resolve(params.repoRoot);
const repoRoot = await fs.realpath(requestedRepoRoot);
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({
inputPath: params.evidencePath,
repoRoot,
@@ -907,47 +667,29 @@ export async function buildQaEvidenceGalleryModel(params: {
});
const limitArtifactView = createConcurrencyLimit(ARTIFACT_VIEW_CONCURRENCY);
const entries = await Promise.all(
summary.entries.map(async (entry, entryIndex): Promise<QaEvidenceGalleryEntryView> => {
summary.entries.map(async (entry): 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, artifactIndex) =>
(entry.execution?.artifacts ?? []).map((artifact) =>
limitArtifactView(() =>
buildArtifactView({
allowedArtifactFiles,
artifact,
artifactIndex,
evidenceDir,
entryIndex,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
}),
),
),
),
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,
coverage: entry.coverage,
failureReason: entry.result.failure?.reason ?? null,
id: entry.test.id,
kind: entry.test.kind,
sourcePath: entry.test.source?.path ?? null,
status: entry.result.status,
title: sanitizeEntryText(entry.test.title),
title: entry.test.title,
};
}),
);
@@ -957,12 +699,9 @@ export async function buildQaEvidenceGalleryModel(params: {
evidenceMode: summary.evidenceMode,
evidencePath: hrefEvidencePath,
generatedAt: summary.generatedAt,
profile: summary.profile
? sanitizeGalleryText(summary.profile, { extraRoots: [requestedRepoRoot], repoRoot })
: null,
profile: summary.profile ?? null,
producerContext: await buildProducerContext({
evidencePath,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
summaryEntries: summary.entries,

View File

@@ -1087,27 +1087,14 @@ 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");
@@ -1132,23 +1119,14 @@ 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>",
"Cookie: <redacted>",
"Set-Cookie: <redacted>",
"x-api-key: <redacted>",
].join("\n"),
"Authorization: Bearer <redacted>",
);
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain(
"was not copied because it may contain credentials or auth tokens",

View File

@@ -25,7 +25,7 @@ import {
resolveQaRuntimeHostVersion,
} from "./bundled-plugin-staging.js";
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
import { QaSuiteInfraError } 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 toQaErrorObject(
throw toLintErrorObject(
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 toQaErrorObject(
throw toLintErrorObject(
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
"Non-Error thrown",
);
@@ -1067,3 +1067,17 @@ 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;
}

View File

@@ -10,35 +10,11 @@ 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, "");
@@ -50,30 +26,8 @@ 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(
@@ -89,18 +43,20 @@ export function redactQaGatewayDebugText(text: string) {
redacted = redactSecretEnvKeyPattern(redacted, pattern);
}
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
redacted = redactSecretValueKey(redacted, key);
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>"`,
);
}
return redacted
.replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "<redacted>")
.replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer <redacted>")
.replaceAll(
new RegExp(
`([?#&](?:${QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS.map(escapeRegExp).join("|")})=)[^&\\s]+`,
"gi",
),
"$1<redacted>",
);
.replaceAll(/([?#&]token=)[^&\s]+/gi, "$1<redacted>");
}
export function formatQaGatewayLogsForError(logs: string) {

View File

@@ -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,6 +98,20 @@ export type HarnessParityResult = {
firstDriftTurn?: number;
};
export type HarnessParityReport = {
generatedAt: string;
providerMode: string;
left: HarnessVariant;
right: HarnessVariant;
results: HarnessParityResult[];
pass: boolean;
failures: string[];
};
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)) {
@@ -123,6 +137,25 @@ 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;
}
@@ -167,6 +200,23 @@ 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) {

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