重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,87 @@
---
name: openclaw-ghsa-maintainer
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
---
# OpenClaw GHSA Maintainer
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
## Respect advisory guardrails
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
- Ask permission before any publish action.
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
## Fetch and inspect advisory state
Fetch the current advisory and the latest published npm version:
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
npm view openclaw version --userconfig "$(mktemp)"
```
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
## Verify private fork PRs are closed
Before publishing, verify that the advisory's private fork has no open PRs:
```bash
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
gh pr list -R "$fork" --state open
```
The PR list must be empty before publish.
## Prepare advisory Markdown and JSON safely
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
Example pattern:
```bash
cat > /tmp/ghsa.desc.md <<'EOF'
<markdown description>
EOF
jq -n --rawfile desc /tmp/ghsa.desc.md \
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
> /tmp/ghsa.patch.json
```
## Apply PATCH calls in the correct sequence
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
- Use separate calls when the advisory requires both fields.
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
Example shape:
```bash
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
--input /tmp/ghsa.patch.json
```
## Publish and verify success
After publish, re-fetch the advisory and confirm:
- `state=published`
- `published_at` is set
- the description does not contain literal escaped `\\n`
Verification pattern:
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
```
## Common GHSA footguns
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.

View file

@ -0,0 +1,150 @@
---
name: openclaw-parallels-smoke
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
---
# OpenClaw Parallels Smoke
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
## Global rules
- Use the snapshot most closely matching the requested fresh baseline.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
- Pass `--json` for machine-readable summaries.
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
- Hard-cap every top-level Parallels lane with host `timeout --foreground` (or `gtimeout --foreground` if that is the available binary) so a stalled install, snapshot switch, or `prlctl exec` transport cannot consume the rest of the testing window. Defaults:
- macOS: `75m`
- Linux: `75m`
- Windows: `90m`
- aggregate npm-update wrapper: `150m`
If a lane hits the cap, stop there, inspect the newest `/tmp/openclaw-parallels-*` run directory and phase log, then fix or rerun the smallest affected lane. Do not keep waiting on a capped lane.
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: install phases should finish within 7 minutes, and update phases should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
- For a full OS matrix, prefer running independent guest-family lanes in parallel when host capacity allows:
- `timeout --foreground 75m pnpm test:parallels:macos -- --json`
- `timeout --foreground 90m pnpm test:parallels:windows -- --json`
- `timeout --foreground 75m pnpm test:parallels:linux -- --json`
Keep each lane in its own shell/session and track the run directory for each one.
- Do not run multiple smoke lanes against the same guest family at once. Tahoe lanes share the host HTTP port, and Windows/Linux lanes can collide on snapshot restore/start state if two jobs touch the same VM concurrently.
- Do not run the aggregate `pnpm test:parallels:npm-update` wrapper in parallel with individual macOS/Windows/Linux smoke lanes; it touches the same guest families and snapshots.
- Do not start Parallels lanes while any host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run the build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
- While running or optimizing the matrix, record wall-clock duration per lane and the slowest phase from `/tmp/openclaw-parallels-*` logs. Use that timing before changing smoke order, timeouts, or helper behavior.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `openclaw update --channel dev` lanes, remember the guest clones GitHub `main`, not your local worktree. If a local fix exists but the rerun still fails inside the cloned dev checkout, do not treat that as disproof of the fix until the branch has been pushed.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
- If the workflow installs OpenClaw from a repo checkout instead of the site installer/npm release, finish by installing a real guest CLI shim and verifying it in a fresh guest shell. `pnpm openclaw ...` inside the repo is not enough for handoff parity.
- On macOS guests, prefer a user-global install plus a stable PATH-visible shim:
- install with `NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g .`
- make sure `~/.local/bin/openclaw` exists or `~/.npm-global/bin` is on PATH
- verify from a brand-new guest shell with `which openclaw` and `openclaw --version`
## npm install then update
- Preferred entrypoint: `pnpm test:parallels:npm-update`
- Required coverage: every release/update regression run must include both lanes:
- fresh snapshot -> install requested package/baseline -> smoke
- same guest baseline -> run the guest's installed `openclaw update ...` command -> smoke again
- The update lane must exercise OpenClaw's internal updater. Do not count a direct `npm install -g <tgz-or-spec>` or harness-side package swap as update-flow coverage; those are install smokes only.
- For published targets, install the old baseline package first (for example `openclaw@2026.4.9`), then run the installed guest CLI with the intended channel/tag (for example `openclaw update --channel beta --yes --json`) and verify `openclaw --version`, `openclaw update status --json`, gateway RPC, and an agent turn after the command.
- For unpublished targets, pack the candidate on the host, serve the `.tgz` over the harness HTTP server, and point the guest updater at that served package. Prefer `openclaw update --tag http://<host-ip>:<port>/openclaw-<version>.tgz --yes --json`; when channel persistence also matters, pass `--channel <stable|beta>` and set `OPENCLAW_UPDATE_PACKAGE_SPEC` to the same served URL in the guest update environment. The command under test must still be `openclaw update`, not direct npm.
- For unpublished local-fix validation, remember the old baseline updater code still controls the first hop. A fix that lives only in the new updater code cannot change that already-running old process; the served candidate must either keep package/plugin metadata compatible with the baseline host or the baseline itself must include the updater fix.
- For beta/stable verification, resolve the tag immediately before the run (`npm view openclaw@beta version dist.tarball` or `npm view openclaw@latest ...`). Tags can move while a long VM matrix is already running; restart the matrix when the intended prerelease appears after an earlier registry 404/tag-lag check.
- Source Peter's profile in the host shell (`set -a; source "$HOME/.profile"; set +a`) before OpenAI/Anthropic lanes. Do not print profile contents or env dumps; pass provider secrets through the guest exec environment.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.
- The npm-update aggregate's macOS update leg writes the guest update script as root, then runs it as the desktop user. If `prlctl exec "$MACOS_VM" --current-user ...` cannot authenticate, retry through plain root `prlctl exec` plus `sudo -u <desktop-user> /usr/bin/env HOME=/Users/<desktop-user> USER=<desktop-user> LOGNAME=<desktop-user> PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/bin:/bin:/usr/sbin:/sbin ...`. That is a Parallels transport fallback; still verify `openclaw --version`, gateway RPC, and an agent turn after the update.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- In those Windows same-guest update checks, do not treat one nonzero `openclaw gateway restart` as definitive failure. Current login-item restarts can report failure before the background service becomes observable again; follow with a longer RPC-ready wait and use `gateway start` only as a recovery step if readiness still never returns.
- After that Windows restart, do not trust one `gateway status --deep --require-rpc` call after a fixed sleep. Retry the RPC-ready probe for roughly 30 seconds and log each attempt; current guests can keep port `18789` bound while the fresh RPC endpoint is still coming up.
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
- The npm-update wrapper now prints per-lane progress from the nested log files. If a lane still looks stuck, inspect the nested logs in `runDir` first (`macos-fresh.log`, `windows-fresh.log`, `linux-fresh.log`, `macos-update.log`, `windows-update.log`, `linux-update.log`) instead of assuming the outer wrapper hung.
- If the wrapper fails a lane, read the auto-dumped tail first, then the full nested lane log under `/tmp/openclaw-parallels-npm-update.*`.
- Current known macOS update-lane transport signature when the fallback is missing or bypassed: `Unable to authenticate the user. Make sure that the specified credentials are correct and try again.` Treat that as Parallels current-user authentication before blaming npm or OpenClaw.
## CLI invocation footgun
- The Parallels smoke shell scripts should tolerate a literal bare `--` arg so `pnpm test:parallels:* -- --json` and similar forwarded invocations work without needing to call `bash scripts/e2e/...` directly.
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`
- Default upgrade coverage on macOS should now include: fresh snapshot -> site installer pinned to the latest stable tag -> `openclaw update --channel dev` on the guest. Treat this as part of the default Tahoe regression plan, not an optional side quest.
- `parallels-macos-smoke.sh --mode upgrade` should run that release-to-dev lane by default. Keep the older host-tgz upgrade path only when the caller explicitly passes `--target-package-spec`.
- Because the default upgrade lane no longer needs a host tgz, skip `npm pack` + host HTTP server startup for `--mode upgrade` unless `--target-package-spec` is set. Keep the pack/server path for `fresh` and `both`.
- If that release-to-dev lane fails with `reason=preflight-no-good-commit` and repeated `sh: pnpm: command not found` tails from `preflight build`, treat it as an updater regression first. The fix belongs in the git/dev updater bootstrap path, not in Parallels retry logic.
- Until the public stable train includes that updater bootstrap fix, the macOS release-to-dev lane may seed a temporary guest-local `pnpm` shim immediately before `openclaw update --channel dev`. Keep that workaround scoped to the smoke harness and remove it once the latest stable no longer needs it.
- In Tahoe `prlctl exec --current-user` runs, prefer explicit `node .../openclaw.mjs ...` invocations for the release->dev handoff itself and for post-update verification. The shebanged global `openclaw` wrapper can fail with `env: node: No such file or directory`, and self-updating through the wrapper is a weaker lane than invoking the entrypoint under a fixed `node`.
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- `parallels-macos-smoke.sh` now retries `snapshot-switch` once after force-stopping a stuck running/suspended guest. If Tahoe still times out after that recovery path, then treat it as a real Parallels/host issue and rerun manually.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- For Tahoe `fresh.gateway-status`, prefer non-TTY `prlctl exec --current-user ... openclaw gateway status ...` plus a few short retries. `prlctl enter` can spam TTY control bytes and hang the phase log even when the CLI itself is healthy.
- If a Tahoe lane times out in `fresh.first-agent-turn` and the phase log stops right after `__OPENCLAW_RC__:0` from `models set`, suspect the `prlctl enter` / `expect` wrapper before blaming auth or the model lane. That pattern means the first guest command finished but the transport never released for the next `guest_current_user_cli` call.
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
- The same wrapper rule applies when bypassing `--current-user`: write a tiny `/tmp/*.sh` on the guest and execute `/bin/bash /tmp/*.sh` through the sudo desktop-user environment. Do not pass `openclaw agent --message '...'` directly as one raw `prlctl exec` command.
- When ref-mode onboarding stores `OPENAI_API_KEY` as an env secret ref, the post-onboard agent verification should also export `OPENAI_API_KEY` for the guest command. The gateway can still reject with pairing-required and fall back to embedded execution, and that fallback needs the env-backed credential available in the shell.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
## Windows flow
- Preferred entrypoint: `pnpm test:parallels:windows`
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
- Default upgrade coverage on Windows should now include: fresh snapshot -> site installer pinned to the requested stable tag -> `openclaw update --channel dev` on the guest. Keep the older host-tgz upgrade path only when the caller explicitly passes `--target-package-spec`.
- Optional exact npm-tag baseline on Windows: `bash scripts/e2e/parallels-windows-smoke.sh --mode upgrade --target-package-spec openclaw@<tag> --json`. That lane installs the published npm tarball as baseline, then runs `openclaw update --channel dev`.
- Optional forward-fix Windows validation: `bash scripts/e2e/parallels-windows-smoke.sh --mode upgrade --upgrade-from-packed-main --json`. That lane installs the packed current-main npm tgz as baseline, then runs `openclaw update --channel dev`.
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
- Current Windows Node installs expose `corepack` as a `.cmd` shim. If a release-to-dev lane sees `corepack` on PATH but `openclaw update --channel dev` still behaves as if corepack is missing, treat that as an exec-shim regression first.
- If an exact published-tag Windows lane fails during preflight with `npm run build` and `'pnpm' is not recognized`, remember that the guest is still executing the old published updater. Validate the fix with `--upgrade-from-packed-main`, then wait for the next tagged npm release before expecting the historical tag lane to pass.
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
- If a Windows retry sees the VM become `suspended` or `stopped`, resume/start it before the next `prlctl exec`; otherwise the second attempt just repeats the same `rc=255`.
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- When those Windows global installs stay quiet, the useful progress often lives in the guest npm debug log, not the helper phase log. The smoke script now streams incremental `npm-cache/_logs/*-debug-0.log` deltas into the phase log during long baseline/package installs; read those lines before assuming the lane is stalled.
- The Windows baseline-package helpers now auto-dump the latest guest `npm-cache/_logs/*-debug-0.log` tail on timeout or nonzero completion. Read that tail in the phase log before opening a second guest shell.
- The same incremental npm-debug streaming also applies to `--upgrade-from-packed-main` / packaged-install baseline phases. A phase log that still says only `install.start`, `install.download-tgz`, `install.install-tgz` can still be healthy if the streamed npm-debug section shows registry fetches or bundled-plugin postinstall work.
- Fresh Windows tgz install phases should also use the background PowerShell runner plus done-file/log-drain pattern; do not rely on one long-lived `prlctl exec ... powershell ... npm install -g` transport for package installs.
- Windows release-to-dev helpers should log `where pnpm` before and after the update and require `where pnpm` to succeed post-update. That proves the updater installed or enabled `pnpm` itself instead of depending on a smoke-only bootstrap.
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
- Fresh Windows daemon-health reachability should use `openclaw gateway probe --json` with a longer timeout and treat `ok: true` as success; full `gateway status --require-rpc` checks are too eager during initial startup on current main.
- Fresh Windows ref-mode agent verification should set `OPENAI_API_KEY` in the PowerShell environment before invoking `openclaw.cmd agent`, for the same pairing-required fallback reason as macOS.
- The standalone Windows upgrade smoke lane should stop the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`. Restarting before onboard can leave the old process alive on the pre-onboard token while onboard rewrites `~/.openclaw/openclaw.json`, which then fails `gateway-health` with `unauthorized: gateway token mismatch`.
- If standalone Windows upgrade fails with a gateway token mismatch but `pnpm test:parallels:npm-update` passes, trust the mismatch as a standalone ref-onboard ordering bug first; the npm-update helper does not re-run ref-mode onboard on the same guest.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.
## Linux flow
- Preferred entrypoint: `pnpm test:parallels:linux`
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
- If that exact VM is missing on the host, any Ubuntu guest with major version `>= 24` is acceptable; prefer the closest versioned Ubuntu guest with a fresh poweroff snapshot. On Peter's host today, that is `Ubuntu 25.10`.
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
- The Linux smoke now falls back to a manual `setsid openclaw gateway run --bind loopback --port 18789 --force` launch with `HOME=/root` and the provider secret exported, then verifies `gateway status --deep --require-rpc` when available.
- The Linux manual gateway launch should wait for `gateway status --deep --require-rpc` inside the `gateway-start` phase; otherwise the first status probe can race the background bind and fail a healthy lane.
- If Linux gateway bring-up fails, inspect `/tmp/openclaw-parallels-linux-gateway.log` in the guest phase logs first; the common failure mode is a missing provider secret in the launched gateway environment.
## Discord roundtrip
- Discord roundtrip is optional and should be enabled with:
- `--discord-token-env`
- `--discord-guild-id`
- `--discord-channel-id`
- Keep the Discord token only in a host env var.
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.

View file

@ -0,0 +1,75 @@
---
name: openclaw-pr-maintainer
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
---
# OpenClaw PR Maintainer
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
## Apply close and triage labels correctly
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
- Do not manually close plus manually comment for these reasons.
- `r:*` labels can be used on both issues and PRs.
- Current reasons:
- `r: skill`
- `r: support`
- `r: no-ci-pr`
- `r: too-many-prs`
- `r: testflight`
- `r: third-party-extension`
- `r: moltbook`
- `r: spam`
- `invalid`
- `dirty` for PRs only
## Enforce the bug-fix evidence bar
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. a fix that touches the implicated code path
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
## Handle GitHub text safely
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
## Search broadly before deciding
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
- Use `--repo openclaw/openclaw` with `--match title,body` first.
- Add `--match comments` when triaging follow-up discussion.
- Do not stop at the first 500 results when the task requires a full search.
Examples:
```bash
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
--json number,title,state,url,updatedAt -- "auto update" \
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
```
## Follow PR review and landing hygiene
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.
- Group related changes; avoid bundling unrelated refactors.
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
## Extra safety
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.

View file

@ -0,0 +1,148 @@
---
name: openclaw-qa-testing
description: Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.
---
# OpenClaw QA Testing
Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
## Read first
- `docs/concepts/qa-e2e-automation.md`
- `docs/help/testing.md`
- `docs/channels/qa-channel.md`
- `qa/README.md`
- `qa/scenarios/index.md`
- `extensions/qa-lab/src/suite.ts`
- `extensions/qa-lab/src/character-eval.ts`
## Model policy
- Live OpenAI lane: `openai/gpt-5.4`
- Fast mode: on
- Do not use:
- `openai/gpt-5.4-pro`
- `openai/gpt-5.4-mini`
- Only change model policy if the user explicitly asks.
## Default workflow
1. Read the scenario pack and current suite implementation.
2. Decide lane:
- mock/dev: `mock-openai`
- real validation: `live-frontier`
3. For live OpenAI, use:
```bash
OPENCLAW_LIVE_OPENAI_KEY="${OPENAI_API_KEY}" \
pnpm openclaw qa suite \
--provider-mode live-frontier \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--output-dir .artifacts/qa-e2e/run-all-live-frontier-<tag>
```
4. Watch outputs:
- summary: `.artifacts/qa-e2e/run-all-live-frontier-<tag>/qa-suite-summary.json`
- report: `.artifacts/qa-e2e/run-all-live-frontier-<tag>/qa-suite-report.md`
5. If the user wants to watch the live UI, find the current `openclaw-qa` listen port and report `http://127.0.0.1:<port>`.
6. If a scenario fails, fix the product or harness root cause, then rerun the full lane.
## Character evals
Use `qa character-eval` for style/persona/vibe checks across multiple live models.
```bash
pnpm openclaw qa character-eval \
--model openai/gpt-5.4,thinking=xhigh \
--model openai/gpt-5.2,thinking=xhigh \
--model openai/gpt-5,thinking=xhigh \
--model anthropic/claude-opus-4-6,thinking=high \
--model anthropic/claude-sonnet-4-6,thinking=high \
--model zai/glm-5.1,thinking=high \
--model moonshot/kimi-k2.5,thinking=high \
--model google/gemini-3.1-pro-preview,thinking=high \
--judge-model openai/gpt-5.4,thinking=xhigh,fast \
--judge-model anthropic/claude-opus-4-6,thinking=high \
--concurrency 16 \
--judge-concurrency 16 \
--output-dir .artifacts/qa-e2e/character-eval-<tag>
```
- Runs local QA gateway child processes, not Docker.
- Preferred model spec syntax is `provider/model,thinking=<level>[,fast|,no-fast|,fast=<bool>]` for both `--model` and `--judge-model`.
- Do not add new examples with separate `--model-thinking`; keep that flag as legacy compatibility only.
- Defaults to candidate models `openai/gpt-5.4`, `openai/gpt-5.2`, `openai/gpt-5`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-6`, `zai/glm-5.1`, `moonshot/kimi-k2.5`, and `google/gemini-3.1-pro-preview` when no `--model` is passed.
- Candidate thinking defaults to `high`, with `xhigh` for OpenAI models that support it. Prefer inline `--model provider/model,thinking=<level>`; `--thinking <level>` and `--model-thinking <provider/model=level>` remain compatibility shims.
- OpenAI candidate refs default to fast mode so priority processing is used where supported. Use inline `,fast`, `,no-fast`, or `,fast=false` for one model; use `--fast` only to force fast mode for every candidate.
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
- Scenario source should stay markdown-driven under `qa/scenarios/`.
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.
- Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay.
## Codex CLI model lane
Use model refs shaped like `codex-cli/<codex-model>` whenever QA should exercise Codex as a model backend.
Examples:
```bash
pnpm openclaw qa suite \
--provider-mode live-frontier \
--model codex-cli/<codex-model> \
--alt-model codex-cli/<codex-model> \
--scenario <scenario-id> \
--output-dir .artifacts/qa-e2e/codex-<tag>
```
```bash
pnpm openclaw qa manual \
--model codex-cli/<codex-model> \
--message "Reply exactly: CODEX_OK"
```
- Treat the concrete Codex model name as user/config input; do not hardcode it in source, docs examples, or scenarios.
- Live QA preserves `CODEX_HOME` so Codex CLI auth/config works while keeping `HOME` and `OPENCLAW_HOME` sandboxed.
- Mock QA should scrub `CODEX_HOME`.
- If Codex returns fallback/auth text every turn, first check `CODEX_HOME`, `~/.profile`, and gateway child logs before changing scenario assertions.
- For model comparison, include `codex-cli/<codex-model>` as another candidate in `qa character-eval`; the report should label it as an opaque model name.
## Repo facts
- Seed scenarios live in `qa/`.
- Main live runner: `extensions/qa-lab/src/suite.ts`
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
- Synthetic channel: `extensions/qa-channel/`
## What “done” looks like
- Full suite green for the requested lane.
- User gets:
- watch URL if applicable
- pass/fail counts
- artifact paths
- concise note on what was fixed
## Common failure patterns
- Live timeout too short:
- widen live waits in `extensions/qa-lab/src/suite.ts`
- Discovery cannot find repo files:
- point prompts at `repo/...` inside seeded workspace
- Subagent proof too brittle:
- prefer stable final reply evidence over transient child-session listing
- Harness “rebuild” delay:
- dirty tree can trigger a pre-run build; expect that before ports appear
## When adding scenarios
- Add or update scenario markdown under `qa/scenarios/`
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
- Prefer end-to-end assertions over mock-only checks
- Save outputs under `.artifacts/qa-e2e/`

View file

@ -0,0 +1,4 @@
interface:
display_name: "QA Test OpenClaw"
short_description: "Run and debug qa-lab and qa-channel scenarios"
default_prompt: "Use $openclaw-qa-testing to run or extend the OpenClaw QA suite with qa-lab and qa-channel, using regular openai/gpt-5.4 in fast mode for live OpenAI runs."

View file

@ -0,0 +1,305 @@
---
name: openclaw-release-maintainer
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
---
# OpenClaw Release Maintainer
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
## Respect release guardrails
- Do not change version numbers without explicit operator approval.
- Ask permission before any npm publish or release step.
- This skill should be sufficient to drive the normal release flow end-to-end.
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
## Keep release channel naming aligned
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
## Handle versions and release files consistently
- Version locations include:
- `package.json`
- `apps/android/app/build.gradle.kts`
- `apps/ios/Sources/Info.plist`
- `apps/ios/Tests/Info.plist`
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- Before creating a release tag, make every version location above match the version encoded by that tag.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
- Every OpenClaw release ships the npm package and macOS app together.
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
- That shared production Sparkle feed is stable-only. Beta mac releases may
upload assets to the GitHub prerelease, but they must not replace the shared
`appcast.xml` unless a separate beta feed exists.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
`APP_BUILD` / Sparkle build than the original release so existing installs
see it as newer.
## Build changelog-backed release notes
- Changelog entries should be user-facing, not internal release-process notes.
- When cutting a mac release with a beta GitHub prerelease:
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
- use release notes from the matching `CHANGELOG.md` version section
- attach at least the zip and dSYM zip, plus dmg if available
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first
- `### Fixes` deduped with user-facing fixes first
## Run publish-time validation
Before tagging or publishing, run:
```bash
pnpm check:architecture
pnpm build
pnpm ui:build
pnpm release:check
pnpm test:install:smoke
```
For a non-root smoke path:
```bash
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
```
After npm publish, run:
```bash
node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
```
- This verifies the published registry install path in a fresh temp prefix.
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
silently leave existing global installs on the old base stable payload.
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
now fails the candidate update tarball when npm reports an oversized
`unpackedSize`, so release-time e2e cannot miss pack bloat that would risk
low-memory install/startup failures.
- Keep direct npm global coverage enabled in install smoke. It exercises plain
`npm install -g <candidate>` fresh installs and npm-driven update installs,
because many users install with npm even when docs prefer pnpm.
- Use `pnpm test:live:media video` for bounded video-provider smoke when video
generation is in release scope. The default video smoke skips `fal`, runs one
text-to-video attempt per provider with a one-second lobster prompt, and caps
each provider operation with `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS`
(`180000` by default).
- Run `pnpm test:live:media video --video-providers fal` only when FAL-specific
proof is required. Its queue latency can dominate release time.
- Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` only when intentionally
validating the slower image-to-video and video-to-video transform lanes.
## Check all relevant release builds
- Always validate the OpenClaw npm release path before creating the tag.
- Default release checks:
- `pnpm check`
- `pnpm check:architecture`
- `pnpm build`
- `pnpm ui:build`
- `pnpm release:check`
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- Check all release-related build surfaces touched by the release, not only the npm package.
- For beta-style full e2e batteries, hard-cap top-level long lanes instead of letting them run indefinitely. Use host `timeout --foreground`/`gtimeout --foreground` caps such as:
- `45m` for `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- `90m` for `pnpm test:docker:all`
- Parallels caps from the `openclaw-parallels-smoke` skill
If a lane hits its cap, stop and inspect/fix the affected lane before continuing; do not continue to wait on the same process.
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
- Serialize host build/package mutations ahead of VM lanes. Finish `pnpm build`, `pnpm ui:build`, `pnpm release:check`, install smoke, and any Docker/package-prep lanes before starting Parallels `npm pack` lanes; otherwise `dist` can disappear during VM pack prep and produce false failures.
- Include mac release readiness in preflight by running the public validation
workflow in `openclaw/openclaw` and the real mac preflight in
`openclaw/releases-private` for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
- Any fix after preflight means a new commit. Delete and recreate the tag and
matching GitHub release from the fixed commit, then rerun preflight from
scratch before publishing.
- For stable mac releases, generate the signed `appcast.xml` before uploading
public release assets so the updater feed cannot lag the published binaries.
- Serialize stable appcast-producing runs across tags so two releases do not
generate replacement `appcast.xml` files from the same stale seed.
- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable.
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
## Use the right auth flow
- OpenClaw publish uses GitHub trusted publishing.
- Stable npm promotion from `beta` to `latest` is an explicit mode on
`.github/workflows/openclaw-npm-release.yml`, but it still needs a valid
`NPM_TOKEN` because `npm dist-tag` management is separate from trusted
publishing.
- Direct stable publishes can also run the same workflow with
`sync_stable_dist_tags=true` to point both `latest` and `beta` at the
already-published stable version. This also needs the `npm-release`
environment approval and `NPM_TOKEN`.
- The publish run must be started manually with `workflow_dispatch`.
- The npm workflow and the private mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- Real npm publish requires a prior successful npm preflight run id so the
publish job promotes the prepared tarball instead of rebuilding it.
- Real private mac publish requires a prior successful private mac preflight
run id so the publish job promotes the prepared artifacts instead of
rebuilding or renotarizing them again.
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- Validation-only runs may be dispatched from a branch when you are testing a
workflow change before merge.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo. It still rebuilds the JS outputs needed for
release validation, but it does not sign, notarize, or publish macOS
artifacts.
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
is the required private mac validation lane for `swift test`; keep it green
before any real mac publish run starts.
- Real mac preflight and real mac publish both use
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac validation lane runs on GitHub's standard macOS runner.
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- npm preflight, public mac validation, private mac validation, and private mac
preflight must all pass before any real publish run starts.
- Real publish runs must be dispatched from `main`; branch-dispatched publish
attempts should fail before the protected environment is reached.
- The release workflows stay tag-based; rely on the documented release sequence
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
private mac preflight artifact preparation and real publish artifact
promotion.
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
private repo `mac-release` environment.
- For stable releases, the agent must also download the signed
`macos-appcast-<tag>` artifact from the successful private mac workflow and
then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- The private repo targets a dedicated `mac-release` environment. If the GitHub
plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on private repo access and
CODEOWNERS until those settings can be enabled.
- Do not use `NPM_TOKEN` or the plugin OTP flow for the OpenClaw package
publish path; package publishing uses trusted publishing.
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## Fallback local mac publish
- Keep the original local macOS publish workflow available as a fallback in case
CI/CD mac publishing is unavailable or broken.
- Preserve the existing maintainer workflow Peter uses: run it on a real Mac
with local signing, notary, and Sparkle credentials already configured.
- Follow the private maintainer macOS runbook for the local steps:
`scripts/package-mac-dist.sh` to build, sign, notarize, and package the app;
manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the
`appcast.xml` commit to `main`.
- `scripts/package-mac-dist.sh` now fails closed for release builds if the
bundled app comes out with a debug bundle id, an empty Sparkle feed URL, or a
`CFBundleVersion` below the canonical Sparkle build floor for that short
version. For correction tags, set a higher explicit `APP_BUILD`.
- `scripts/make_appcast.sh` first uses `generate_appcast` from `PATH`, then
falls back to the SwiftPM Sparkle tool output under `apps/macos/.build`.
- For stable tags, the local fallback may update the shared production
`appcast.xml`.
- For beta tags, the local fallback still publishes the mac assets but must not
update the shared production `appcast.xml` unless a separate beta feed exists.
- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow
when it is working.
- After any stable mac publish, verify all of the following before you call the
release finished:
- the GitHub release has `.zip`, `.dmg`, and `.dSYM.zip` assets
- `appcast.xml` on `main` points at the new stable zip
- the packaged app reports the expected short version and a numeric
`CFBundleVersion` at or above the canonical Sparkle build floor
## Run the release sequence
1. Confirm the operator explicitly wants to cut a release.
2. Choose the exact target version and git tag.
3. Make every repo version location match that tag before creating it.
4. Update `CHANGELOG.md` and assemble the matching GitHub release notes.
5. Run the full preflight for all relevant release builds, including mac readiness.
6. Confirm the target npm version is not already published.
7. Create and push the git tag.
8. Create or refresh the matching GitHub release.
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
and choose the intended `npm_dist_tag` (`beta` default; `latest` only for
an intentional direct stable publish). Wait for it to pass. Save that run id
because the real publish requires it to reuse the prepared npm tarball.
10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait
for the public validation-only run to pass.
11. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
with the same tag and wait for the private mac validation lane to pass.
12. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
13. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes.
14. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
the real publish, choose `npm_dist_tag` (`beta` default, `latest` only when
you intentionally want direct stable publish), keep it the same as the
preflight run, and pass the successful npm `preflight_run_id`.
15. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
16. If the stable release was published to `beta`, start
`.github/workflows/openclaw-npm-release.yml` again after beta validation
passes with the same stable tag, `promote_beta_to_latest=true`,
`preflight_only=false`, empty `preflight_run_id`, and `npm_dist_tag=beta`,
then verify `latest` now points at that version.
17. If the stable release was published directly to `latest` and `beta` should
follow it, start `.github/workflows/openclaw-npm-release.yml` again with
the same stable tag, `sync_stable_dist_tags=true`,
`promote_beta_to_latest=false`, `preflight_only=false`, empty
`preflight_run_id`, and `npm_dist_tag=latest`, then verify both `latest`
and `beta` point at that version.
18. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
19. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
20. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed.
21. For beta releases, publish the mac assets but expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
22. After publish, verify npm and the attached release artifacts.
## GHSA advisory work
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.

View file

@ -0,0 +1,220 @@
---
name: openclaw-secret-scanning-maintainer
description: Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
---
# OpenClaw Secret Scanning Maintainer
**Maintainer-only.** This skill requires repo admin / maintainer permissions to edit or delete other users' comments and resolve secret scanning alerts.
Use this skill when processing alerts from `https://github.com/openclaw/openclaw/security/secret-scanning`.
**Language rule:** All notification comments and replacement comments MUST be written in English.
## Script
All mechanical operations (API calls, temp file management, security enforcements) are handled by:
```
$REPO_ROOT/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs
```
The script enforces:
- `hide_secret=true` on all alert fetches (no plaintext secrets in stdout)
- `mktemp` with random UUIDs for all temp files
- `-F body=@file` for all body uploads (no inline shell quoting)
- Notification templates branched by location type
- Never prints `.secret` or `.body` to stdout
## Overall Flow
Supports single or multiple alerts. For multiple alerts, process in ascending order.
For each alert:
1. **Identify** — `fetch-alert` + `fetch-content` to get metadata and body
2. **Decide** — Agent reads the body file, identifies all secrets, produces redacted version
3. **Redact** — `redact-body` for issue/PR body; skip for comments (delete directly)
4. **Purge** — `delete-comment` + `recreate-comment` for comments; cannot purge body history
5. **Notify** — `notify` posts the right template per location type
6. **Resolve** — `resolve` closes the alert
7. **Summary** — `summary` prints formatted results
## Step 1: Identify
```bash
# List all open alerts
node secret-scanning.mjs list-open
# Fetch specific alert metadata + locations
node secret-scanning.mjs fetch-alert <NUMBER>
# Fetch content for each location (saves body to temp file)
node secret-scanning.mjs fetch-content '<location-json>'
```
The `fetch-content` output includes:
- `body_file`: path to temp file with full body content
- `author`: who posted it
- `issue_number` / `pr_number`: where it is
- `edit_history_count`: number of existing edits
- `type`: location type for routing
- For `discussion_comment`, it also includes `comment_node_id`, `discussion_node_id`, and `reply_to_node_id` when the original comment was a reply.
### Location type routing
| type | Flow |
| ----------------------------- | --------------------------------------------- |
| `issue_comment` | Comment: delete+recreate |
| `pull_request_comment` | Comment: delete+recreate |
| `pull_request_review_comment` | Comment: delete+recreate |
| `discussion_comment` | Discussion comment: delete+recreate (GraphQL) |
| `issue_body` | Body: redact in place |
| `pull_request_body` | Body: redact in place |
| `commit` | Notify only |
| _other_ | Skip and report |
## Step 2: Decide (Agent)
The agent reads the body file from `fetch-content` output and:
1. Identifies ALL secrets in the content (there may be more than the alert flagged)
2. Replaces each secret with `[REDACTED <secret_type>]` — **no partial values, no prefix/suffix**
3. Saves the redacted content to a new temp file
This is the only step that requires semantic understanding. Everything else is mechanical.
## Step 3: Redact
### For comments (issue_comment / PR comments)
**Do NOT redact.** Skip directly to Step 4 (delete + recreate). PATCHing before DELETE creates an unnecessary edit history revision.
### For issue_body / pull_request_body
```bash
node secret-scanning.mjs redact-body <issue|pr> <NUMBER> <redacted-body-file>
```
## Step 4: Purge Edit History
### Comments — Delete and Recreate
For issue/PR comments:
```bash
# Delete original (all edit history gone)
node secret-scanning.mjs delete-comment <COMMENT_ID>
# Recreate with redacted content
node secret-scanning.mjs recreate-comment <ISSUE_NUMBER> <body-file>
```
For discussion comments (uses GraphQL):
```bash
# Delete original
node secret-scanning.mjs delete-discussion-comment <COMMENT_NODE_ID>
# Recreate with redacted content
node secret-scanning.mjs recreate-discussion-comment <DISCUSSION_NODE_ID> <body-file> [REPLY_TO_NODE_ID]
```
The `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands. When the original discussion comment was a reply, it also includes `reply_to_node_id`; pass that optional third argument so the redacted replacement stays in the original thread.
The recreated comment should follow this format:
```
> **Note:** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
---
<redacted original content>
```
### issue_body / pull_request_body — Cannot Purge
Editing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API.
**Output to maintainer terminal only (never in public comments):**
```
⚠️ Issue/PR body edit history still contains plaintext secrets.
Contact GitHub Support to purge: https://support.github.com/contact
Request purge of issue/PR #{NUMBER} userContentEdits.
```
> **CRITICAL:** Do NOT mention edit history or the "edited" button in any public comment or resolution_comment.
### Commits
Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
## Step 5: Notify
```bash
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
```
- For non-discussion types, `<TARGET>` is the issue/PR number.
- For `discussion_comment`, `<TARGET>` is the `discussion_node_id` returned by `fetch-content`.
- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.
Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"`
The script picks the right template:
- **comment types**: "your comment … removed and replaced"
- **body types**: "your issue/PR description … redacted in place"
- **commit**: "code you committed"
## Step 6: Resolve
```bash
node secret-scanning.mjs resolve <ALERT_NUMBER>
# or with custom resolution:
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Custom comment"
```
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to redact + notify. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
## Step 7: Summary
After processing, create a JSON results file and pass it to the summary command:
```bash
node secret-scanning.mjs summary /tmp/results.json
```
The script outputs a block delimited by `---BEGIN SUMMARY---` and `---END SUMMARY---`. **You MUST output the content between these markers verbatim to the user. Do NOT rephrase, reformat, abbreviate, or create your own summary.** The script already includes full URLs for every alert and location.
The JSON format:
```json
[
{
"number": 72,
"secret_type": "Discord Bot Token",
"location_label": "Issue #63101 comment",
"location_url": "https://github.com/openclaw/openclaw/issues/63101#issuecomment-xxx",
"actions": "Deleted+Recreated+Notified",
"history_cleared": true
}
]
```
For unsupported types, add `"skipped": true, "unsupported_type": "<type>"`.
## Safety Rules
- **Agent reads content, identifies secrets, produces redaction.** Script handles all API calls.
- **Never include any portion of a secret** in public comments, redaction markers, or terminal output.
- **Never include alert URLs or numbers** in public comments.
- **For comments, skip PATCH — go directly to DELETE + recreate.**
- **Never mention edit history, "edited" button, or commit SHAs** in any public content.
- **Ask for confirmation** before deleting any comment.
- **One alert at a time** unless user requests batch.
- **All public comments in English.**
- **Skip unsupported location types** and report in summary.

View file

@ -0,0 +1,797 @@
#!/usr/bin/env node
// Secret scanning alert handler for OpenClaw maintainers.
// Usage: node secret-scanning.mjs <command> [options]
import { execFileSync, spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
// ─── Helpers ────────────────────────────────────────────────────────────────
function fail(message) {
console.error(`error: ${message}`);
process.exit(1);
}
function tmpFile(purpose) {
const filePath = path.join(os.tmpdir(), `secretscan-${purpose}-${crypto.randomUUID()}`);
// 预创建文件,限制权限为 owner-only
fs.writeFileSync(filePath, "", { mode: 0o600 });
return filePath;
}
function gh(args, { json = true, allowFailure = false } = {}) {
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
if (proc.status !== 0 && !allowFailure) {
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
}
if (proc.status !== 0) {
return {
gh_failed: true,
status: proc.status,
stdout: proc.stdout,
stderr: proc.stderr,
};
}
if (!json) return proc.stdout;
try {
return JSON.parse(proc.stdout);
} catch {
return proc.stdout;
}
}
function ghGraphQL(query, options = {}) {
return gh(["api", "graphql", "-f", `query=${query}`], options);
}
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (
result.stderr ||
result.stdout ||
`gh exited with status ${result.status}`
).trim();
fail(`${message}: ${details}`);
}
if (Array.isArray(result?.errors) && result.errors.length > 0) {
fail(`${message}: ${JSON.stringify(result.errors)}`);
}
}
function escapeGraphQLString(value) {
return String(value)
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n");
}
function formatGraphQLAfterClause(cursor) {
return cursor ? `, after: "${escapeGraphQLString(cursor)}"` : "";
}
function findDiscussionCommentNode(nodes, discussionCommentDbId) {
return nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null;
}
function fetchDiscussionReplyPage(commentNodeId, cursor) {
const afterClause = formatGraphQLAfterClause(cursor);
return ghGraphQL(`{
node(id: "${escapeGraphQLString(commentNodeId)}") {
... on DiscussionComment {
replies(first: 100${afterClause}) {
pageInfo { hasNextPage endCursor }
nodes {
id
databaseId
author { login }
body
url
replyTo { id }
userContentEdits(first: 50) {
totalCount
}
}
}
}
}
}}`);
}
function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
const [owner, name] = REPO.split("/");
let discussionId = null;
let cursor = null;
let hasNextPage = true;
while (hasNextPage) {
const afterClause = formatGraphQLAfterClause(cursor);
const gql = ghGraphQL(
`{
repository(owner: "${owner}", name: "${name}") {
discussion(number: ${discussionNumber}) {
id
comments(first: 50${afterClause}) {
pageInfo { hasNextPage endCursor }
nodes {
id
databaseId
author { login }
body
url
replyTo { id }
userContentEdits(first: 50) {
totalCount
}
replies(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
id
databaseId
author { login }
body
url
replyTo { id }
userContentEdits(first: 50) {
totalCount
}
}
}
}
}
}
}
}`,
{ allowFailure: true },
);
failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);
const discussion = gql?.data?.repository?.discussion;
if (!discussion)
fail(
`Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,
);
discussionId = discussion.id;
for (const topLevelComment of discussion.comments.nodes) {
if (String(topLevelComment.databaseId) === String(discussionCommentDbId)) {
return { discussionId, comment: topLevelComment };
}
let reply = findDiscussionCommentNode(topLevelComment.replies.nodes, discussionCommentDbId);
let replyCursor = topLevelComment.replies.pageInfo.endCursor;
let hasMoreReplies = topLevelComment.replies.pageInfo.hasNextPage;
while (!reply && hasMoreReplies) {
const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);
failOnGraphQLFailure(
replyPage,
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
const replies = replyPage?.data?.node?.replies;
if (!replies)
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
replyCursor = replies.pageInfo.endCursor;
}
if (reply) return { discussionId, comment: reply };
}
hasNextPage = discussion.comments.pageInfo.hasNextPage;
cursor = discussion.comments.pageInfo.endCursor;
}
return { discussionId, comment: null };
}
function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
const replyToClause = replyToNodeId ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` : "";
const result = ghGraphQL(
`mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`,
);
if (result?.errors) {
fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`);
}
return result?.data?.addDiscussionComment?.comment;
}
// ─── Commands ───────────────────────────────────────────────────────────────
/**
* fetch-alert <number>
* Fetch alert metadata + locations. Never exposes .secret.
*/
function cmdFetchAlert(alertNumber) {
if (!alertNumber) fail("Usage: fetch-alert <number>");
const alert = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]);
const locations = gh([
"api",
`repos/${REPO}/secret-scanning/alerts/${alertNumber}/locations`,
"--paginate",
"--slurp",
]);
// --paginate + --slurp 确保多页结果合并为一个 JSON 数组
const flatLocations = Array.isArray(locations?.[0])
? locations.flat()
: Array.isArray(locations)
? locations
: [];
const result = {
number: alert.number,
state: alert.state,
secret_type: alert.secret_type,
secret_type_display_name: alert.secret_type_display_name,
validity: alert.validity,
html_url: alert.html_url,
locations: flatLocations.map((loc) => ({
type: loc.type,
details: loc.details,
})),
};
console.log(JSON.stringify(result, null, 2));
}
/**
* fetch-content <location-json>
* Fetch the content and metadata for a specific location.
* Saves full body to a temp file. Prints metadata + file path to stdout.
*/
function cmdFetchContent(locationJson) {
if (!locationJson) fail("Usage: fetch-content '<location-json>'");
const location = JSON.parse(locationJson);
const type = location.type;
const details = location.details;
if (type === "discussion_comment") {
const commentUrl = details.discussion_comment_url;
if (!commentUrl) fail("No discussion_comment_url in location details");
const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/);
if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
const { discussionId, comment } = fetchDiscussionComment(
discussionNumber,
discussionCommentDbId,
);
if (!comment)
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
);
const bodyFile = tmpFile("body.md");
fs.writeFileSync(bodyFile, comment.body || "");
console.log(
JSON.stringify(
{
type,
comment_node_id: comment.id,
discussion_node_id: discussionId,
reply_to_node_id: comment.replyTo?.id ?? null,
discussion_number: Number(discussionNumber),
discussion_comment_db_id: Number(discussionCommentDbId),
author: comment.author?.login,
html_url: comment.url || commentUrl,
edit_history_count: comment.userContentEdits?.totalCount ?? 0,
body_file: bodyFile,
},
null,
2,
),
);
} else if (
type === "issue_comment" ||
type === "pull_request_comment" ||
type === "pull_request_review_comment"
) {
// Extract comment ID from URL
const commentUrl =
details.issue_comment_url ||
details.pull_request_comment_url ||
details.pull_request_review_comment_url;
if (!commentUrl) fail(`No comment URL in location details`);
const comment = gh(["api", commentUrl]);
const bodyFile = tmpFile("body.md");
fs.writeFileSync(bodyFile, comment.body || "");
// Fetch edit history
const nodeId = comment.node_id;
const typeName =
type === "pull_request_review_comment" ? "PullRequestReviewComment" : "IssueComment";
const gql = ghGraphQL(`{
node(id: "${nodeId}") {
... on ${typeName} {
userContentEdits(first: 50) {
totalCount
}
}
}
}`);
const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;
// Extract issue number from html_url
const htmlUrl = comment.html_url || details.html_url || "";
const issueMatch = htmlUrl.match(/\/(issues|pull)\/(\d+)/);
const issueNumber = issueMatch ? issueMatch[2] : null;
console.log(
JSON.stringify(
{
type,
comment_id: comment.id,
node_id: nodeId,
author: comment.user?.login,
issue_number: issueNumber,
html_url: htmlUrl,
edit_history_count: editCount,
body_file: bodyFile,
},
null,
2,
),
);
} else if (type === "issue_body") {
const issueUrl = details.issue_body_url || details.issue_url;
if (!issueUrl) fail("No issue URL in location details");
const issue = gh(["api", issueUrl]);
const bodyFile = tmpFile("body.md");
fs.writeFileSync(bodyFile, issue.body || "");
const nodeId = issue.node_id;
const number = issue.number;
const gql = ghGraphQL(`{
node(id: "${nodeId}") {
... on Issue {
userContentEdits(first: 50) {
totalCount
}
}
}
}`);
const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;
console.log(
JSON.stringify(
{
type,
issue_number: number,
node_id: nodeId,
author: issue.user?.login,
html_url: issue.html_url,
edit_history_count: editCount,
body_file: bodyFile,
},
null,
2,
),
);
} else if (type === "pull_request_body") {
const prUrl = details.pull_request_body_url || details.pull_request_url;
if (!prUrl) fail("No PR URL in location details");
const pr = gh(["api", prUrl]);
const bodyFile = tmpFile("body.md");
fs.writeFileSync(bodyFile, pr.body || "");
const nodeId = pr.node_id;
const number = pr.number;
const gql = ghGraphQL(`{
node(id: "${nodeId}") {
... on PullRequest {
userContentEdits(first: 50) {
totalCount
}
}
}
}`);
const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;
console.log(
JSON.stringify(
{
type,
pr_number: number,
node_id: nodeId,
author: pr.user?.login,
merged: pr.merged,
state: pr.state,
html_url: pr.html_url,
edit_history_count: editCount,
body_file: bodyFile,
},
null,
2,
),
);
} else if (type === "commit") {
console.log(
JSON.stringify(
{
type,
commit_sha: details.commit_sha,
path: details.path,
start_line: details.start_line,
end_line: details.end_line,
html_url: details.html_url || details.commit_url || details.blob_url || null,
// No body file for commits
body_file: null,
},
null,
2,
),
);
} else {
console.log(
JSON.stringify(
{
type,
unsupported: true,
details,
},
null,
2,
),
);
}
}
/**
* redact-body <issue|pr> <number> <redacted-body-file>
* PATCH the issue or PR body with redacted content from a file.
*/
function cmdRedactBody(kind, number, bodyFile) {
if (!kind || !number || !bodyFile) {
fail("Usage: redact-body <issue|pr> <number> <redacted-body-file>");
}
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
const endpoint =
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
gh(["api", endpoint, "-X", "PATCH", "-F", `body=@${bodyFile}`]);
console.log(JSON.stringify({ ok: true, kind, number: Number(number) }));
}
/**
* delete-comment <comment-id>
* Delete a comment (and all its edit history).
*/
function cmdDeleteComment(commentId) {
if (!commentId) fail("Usage: delete-comment <comment-id>");
gh(["api", `repos/${REPO}/issues/comments/${commentId}`, "-X", "DELETE"], { json: false });
console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));
}
/**
* delete-discussion-comment <node-id>
* Delete a discussion comment via GraphQL (and all its edit history).
*/
function cmdDeleteDiscussionComment(nodeId) {
if (!nodeId) fail("Usage: delete-discussion-comment <node-id>");
const result = ghGraphQL(
`mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`,
);
if (result?.errors) {
fail(`Failed to delete discussion comment: ${JSON.stringify(result.errors)}`);
}
console.log(JSON.stringify({ ok: true, deleted_node_id: nodeId }));
}
/**
* recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]
* Create a new discussion comment via GraphQL.
*/
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
if (!discussionNodeId || !bodyFile)
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
const body = fs.readFileSync(bodyFile, "utf8");
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
console.log(
JSON.stringify({
ok: true,
node_id: newComment?.id,
html_url: newComment?.url,
}),
);
}
/**
* recreate-comment <issue-number> <body-file>
* Create a new comment from a file.
*/
function cmdRecreateComment(issueNumber, bodyFile) {
if (!issueNumber || !bodyFile) fail("Usage: recreate-comment <issue-number> <body-file>");
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
const result = gh([
"api",
`repos/${REPO}/issues/${issueNumber}/comments`,
"-X",
"POST",
"-F",
`body=@${bodyFile}`,
]);
console.log(
JSON.stringify({
ok: true,
comment_id: result.id,
html_url: result.html_url,
}),
);
}
/**
* notify <target> <author> <location-type> <secret-types> [reply-to-node-id]
* Post a notification comment with the correct template for the location type.
* target = issue/PR number for non-discussion types, discussion node ID for discussion_comment.
*/
function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
if (!target || !author || !locationType || !secretTypes) {
fail(
"Usage: notify <target> <author> <location-type> <secret-types-comma-sep> [reply-to-node-id]",
);
}
const types = secretTypes.split(",").map((s) => s.trim());
const typeList = types.map((t, i) => `${i + 1}. **${t}**`).join("\n");
let locationDesc;
let actionDesc;
if (
locationType === "issue_comment" ||
locationType === "pull_request_comment" ||
locationType === "pull_request_review_comment" ||
locationType === "discussion_comment"
) {
locationDesc = "your comment";
actionDesc = "The affected comment has been removed and replaced with a redacted version.";
} else if (locationType === "issue_body") {
locationDesc = "your issue description";
actionDesc = "The affected content has been redacted in place.";
} else if (locationType === "pull_request_body") {
locationDesc = "your pull request description";
actionDesc = "The affected content has been redacted in place.";
} else if (locationType === "commit") {
locationDesc = "code you committed";
actionDesc = "";
} else {
locationDesc = "your content";
actionDesc = "";
}
const body = [
`@${author} :warning: **Security Notice: Secret Leakage Detected**`,
"",
`GitHub Secret Scanning detected the following exposed secret types in ${locationDesc}:`,
"",
typeList,
"",
actionDesc,
"",
"**Please rotate these credentials immediately.**",
"",
"These secrets were publicly exposed and should be considered compromised.",
]
.filter((line) => line !== undefined)
.join("\n");
// Discussion comments must be notified via GraphQL
if (locationType === "discussion_comment") {
const newComment = createDiscussionComment(target, body, replyToNodeId);
console.log(
JSON.stringify({
ok: true,
node_id: newComment?.id,
html_url: newComment?.url,
}),
);
return;
}
// Issue/PR comments via REST
const bodyFile = tmpFile("notify.md");
fs.writeFileSync(bodyFile, body);
const result = gh([
"api",
`repos/${REPO}/issues/${target}/comments`,
"-X",
"POST",
"-F",
`body=@${bodyFile}`,
]);
console.log(
JSON.stringify({
ok: true,
comment_id: result.id,
html_url: result.html_url,
}),
);
}
/**
* resolve <alert-number> [resolution] [comment]
* Close a secret scanning alert.
*/
function cmdResolve(alertNumber, resolution, comment) {
if (!alertNumber) fail("Usage: resolve <alert-number> [resolution] [comment]");
const res = resolution || "revoked";
const resComment = comment || "Content redacted and author notified to rotate credentials.";
const result = gh([
"api",
`repos/${REPO}/secret-scanning/alerts/${alertNumber}`,
"-X",
"PATCH",
"-f",
`state=resolved`,
"-f",
`resolution=${res}`,
"-f",
`resolution_comment=${resComment}`,
]);
console.log(
JSON.stringify({
ok: true,
number: result.number,
state: result.state,
resolution: result.resolution,
resolved_at: result.resolved_at,
}),
);
}
/**
* list-open
* List all open secret scanning alerts.
*/
function cmdListOpen() {
const alerts = gh([
"api",
`repos/${REPO}/secret-scanning/alerts?hide_secret=true&state=open`,
"--paginate",
"--slurp",
]);
// --slurp 将分页结果合并为 [[page1], [page2], ...] 需要 flat
const flat = Array.isArray(alerts?.[0]) ? alerts.flat() : Array.isArray(alerts) ? alerts : [];
const rows = flat.map((a) => ({
number: a.number,
secret_type_display_name: a.secret_type_display_name,
html_url: a.html_url,
first_location_html_url: a.first_location_detected?.html_url || null,
}));
console.log(JSON.stringify(rows, null, 2));
}
/**
* summary <json-file>
* Print a formatted summary table from a JSON results file.
*/
function cmdSummary(jsonFile) {
if (!jsonFile) fail("Usage: summary <json-file>");
if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`);
const results = JSON.parse(fs.readFileSync(jsonFile, "utf8"));
const lines = [];
lines.push("---BEGIN SUMMARY---");
lines.push("");
lines.push("## Secret Scanning Results");
lines.push("");
lines.push("| Alert | Type | Location | Actions | Edit History |");
lines.push("|-------|------|----------|---------|--------------|");
const needsPurge = [];
for (const r of results) {
const alertLink = `#${r.number} ${REPO_URL}/security/secret-scanning/${r.number}`;
const locationLink = r.location_url
? `${r.location_label} ${r.location_url}`
: r.location_label;
const history = r.history_cleared ? "Cleared" : "⚠️ History remains";
lines.push(`| ${alertLink} | ${r.secret_type} | ${locationLink} | ${r.actions} | ${history} |`);
if (!r.history_cleared && r.location_url) {
needsPurge.push(r);
}
}
if (needsPurge.length > 0) {
lines.push("");
lines.push("Issues requiring GitHub Support to purge edit history:");
for (const r of needsPurge) {
lines.push(`- ${r.location_label} ${r.location_url} — ${r.secret_type}`);
}
lines.push(
`Contact: https://support.github.com/contact — request purge of userContentEdits for the above issues.`,
);
}
const skipped = results.filter((r) => r.skipped);
if (skipped.length > 0) {
lines.push("");
lines.push(
"⚠️ The following alerts were skipped because their location type is not supported:",
);
for (const r of skipped) {
lines.push(
`- Alert #${r.number}: unsupported type "${r.unsupported_type}" — ${REPO_URL}/security/secret-scanning/${r.number}`,
);
}
lines.push("Please update the skill to define handling for these types.");
}
lines.push("");
lines.push("---END SUMMARY---");
console.log(lines.join("\n"));
}
// ─── Dispatch ───────────────────────────────────────────────────────────────
const [command, ...args] = process.argv.slice(2);
const commands = {
"fetch-alert": () => cmdFetchAlert(args[0]),
"fetch-content": () => cmdFetchContent(args[0]),
"redact-body": () => cmdRedactBody(args[0], args[1], args[2]),
"delete-comment": () => cmdDeleteComment(args[0]),
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
"recreate-discussion-comment": () => cmdRecreateDiscussionComment(args[0], args[1], args[2]),
notify: () => cmdNotify(args[0], args[1], args[2], args[3], args[4]),
resolve: () => cmdResolve(args[0], args[1], args[2]),
"list-open": () => cmdListOpen(),
summary: () => cmdSummary(args[0]),
};
if (!command || !commands[command]) {
console.error(
[
"Usage: node secret-scanning.mjs <command> [args]",
"",
"Commands:",
" fetch-alert <number> Fetch alert metadata + locations",
" fetch-content '<location-json>' Fetch content for a location",
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
" delete-comment <comment-id> Delete a comment",
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
" recreate-comment <issue-n> <file> Create replacement comment",
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
" resolve <n> [resolution] [comment] Close alert",
" list-open List open alerts",
" summary <json-file> Print formatted summary",
].join("\n"),
);
process.exit(1);
}
commands[command]();

View file

@ -0,0 +1,75 @@
---
name: openclaw-test-heap-leaks
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, triage likely transformed-module retention versus likely runtime leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
---
# OpenClaw Test Heap Leaks
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available. Treat snapshot-name deltas as triage evidence, not proof, until retainers or dominators support the call.
## Workflow
1. Reproduce the failing shape first.
- Match the real entrypoint if possible. For Linux CI-style unit failures, start with:
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
- If the report is about a specific shard or worker budget, preserve that shape.
- Before you analyze snapshots, identify the real lane names from `[test-parallel] start ...` lines or `pnpm test --plan`. Do not assume a single `unit-fast` lane; local plans often split into `unit-fast-batch-*`.
2. Wait for repeated snapshots before concluding anything.
- Take at least two intervals from the same lane.
- Compare snapshots from the same PID inside the real lane directory such as `.tmp/heapsnap/unit-fast-batch-2/`.
- Use `.agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
- If the helper suggests transformed-module retention, confirm the top entries in DevTools retainers/dominators before calling it solved.
3. Classify the growth before choosing a fix.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as likely retained module graph growth in long-lived workers.
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
- If the names are ambiguous, stop short of a confident label and inspect retainers/dominators in DevTools for the top deltas.
4. Fix the right layer.
- For likely retained transformed-module growth in shared workers:
- Prefer timing and hotspot-driven scheduling fixes first. Check whether the file is already represented in `test/fixtures/test-timings.unit.json` and whether `scripts/test-update-memory-hotspots.mjs` should refresh the measured hotspot manifest before hand-editing behavior overrides.
- Move hotspot files out of the real shared lane by updating `test/fixtures/test-parallel.behavior.json` only when timing-driven peeling is insufficient.
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
- For real leaks:
- Patch the implicated test or runtime cleanup path.
- Look for missing `afterEach`/`afterAll`, module-reset gaps, retained global state, unreleased DB handles, or listeners/timers that survive the file.
5. Verify with the most direct proof.
- Re-run the targeted lane or file with heap snapshots enabled if the suite still finishes in reasonable time.
- If snapshot overhead pushes tests over Vitest timeouts, fall back to the same lane without snapshots and confirm the RSS trend or OOM is reduced.
- For wrapper-only changes, at minimum verify the expected lanes start and the snapshot files are written.
## Heuristics
- Do not call everything a leak. In this repo, large `unit-fast` or `unit-fast-batch-*` growth can be a worker-lifetime problem rather than an application object leak.
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition, then confirm ambiguous calls with retainer evidence.
## Snapshot Comparison
- Direct comparison:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
- Auto-select earliest/latest snapshots per PID within one lane:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast-batch-2`
- Useful flags:
- `--top 40`
- `--min-kb 32`
- `--pid 16133`
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak. If the names alone do not settle it, open the same snapshot pair in DevTools and inspect retainers/dominators for the top rows before declaring root cause.
## Output Expectations
When using this skill, report:
- The exact reproduce command.
- Which lane and PID were compared.
- The dominant retained object families from the snapshot delta.
- Whether the issue is a likely real leak or likely shared-worker retained module growth, plus whether retainers/dominators confirmed it.
- The concrete fix or impact-reduction patch.
- What you verified, and what snapshot overhead prevented you from verifying.

View file

@ -0,0 +1,4 @@
interface:
display_name: "Test Heap Leaks"
short_description: "Investigate test OOMs with heap snapshots"
default_prompt: "Use $openclaw-test-heap-leaks to investigate test memory growth with heap snapshots and reduce its impact."

View file

@ -0,0 +1,553 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
function printUsage() {
console.error(
"Usage: node heapsnapshot-delta.mjs <before.heapsnapshot> <after.heapsnapshot> [--top N] [--min-kb N]",
);
console.error(
" or: node heapsnapshot-delta.mjs --lane-dir <dir> [--pid PID] [--top N] [--min-kb N]",
);
}
function fail(message) {
console.error(message);
process.exit(1);
}
function parseArgs(argv) {
const options = {
top: 30,
minKb: 64,
laneDir: null,
pid: null,
files: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--top") {
options.top = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
if (arg === "--min-kb") {
options.minKb = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
if (arg === "--lane-dir") {
options.laneDir = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === "--pid") {
options.pid = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
options.files.push(arg);
}
if (!Number.isFinite(options.top) || options.top <= 0) {
fail("--top must be a positive integer");
}
if (!Number.isFinite(options.minKb) || options.minKb < 0) {
fail("--min-kb must be a non-negative integer");
}
if (options.pid !== null && (!Number.isInteger(options.pid) || options.pid <= 0)) {
fail("--pid must be a positive integer");
}
return options;
}
class JsonStreamScanner {
constructor(filePath) {
this.stream = fs.createReadStream(filePath, {
encoding: "utf8",
highWaterMark: 1024 * 1024,
});
this.iterator = this.stream[Symbol.asyncIterator]();
this.buffer = "";
this.offset = 0;
this.done = false;
}
compactBuffer() {
if (this.offset > 65536) {
this.buffer = this.buffer.slice(this.offset);
this.offset = 0;
}
}
async ensureAvailable(count = 1) {
while (!this.done && this.buffer.length - this.offset < count) {
const next = await this.iterator.next();
if (next.done) {
this.done = true;
break;
}
this.buffer += next.value;
}
}
async peek() {
await this.ensureAvailable(1);
return this.buffer[this.offset] ?? null;
}
async next() {
await this.ensureAvailable(1);
if (this.offset >= this.buffer.length) {
return null;
}
const char = this.buffer[this.offset];
this.offset += 1;
this.compactBuffer();
return char;
}
async skipWhitespace() {
while (true) {
const char = await this.peek();
if (char === null || !/\s/u.test(char)) {
return;
}
await this.next();
}
}
async expectChar(expected) {
const char = await this.next();
if (char !== expected) {
fail(`Expected ${expected} but found ${char ?? "<eof>"}`);
}
}
async find(sequence) {
let matched = 0;
while (true) {
const char = await this.next();
if (char === null) {
fail(`Could not find ${sequence}`);
}
if (char === sequence[matched]) {
matched += 1;
if (matched === sequence.length) {
return;
}
continue;
}
matched = char === sequence[0] ? 1 : 0;
if (matched === sequence.length) {
return;
}
}
}
async readBalancedObject() {
const start = await this.next();
if (start !== "{") {
fail(`Expected { but found ${start ?? "<eof>"}`);
}
let text = "{";
let depth = 1;
let inString = false;
let escaped = false;
while (depth > 0) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading JSON object");
}
text += char;
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
}
return text;
}
async parseNumberArray(onValue) {
await this.skipWhitespace();
await this.expectChar("[");
await this.skipWhitespace();
if ((await this.peek()) === "]") {
await this.next();
return;
}
let token = "";
let index = 0;
const flush = () => {
if (token.length === 0) {
fail("Unexpected empty number token");
}
const value = Number.parseInt(token, 10);
if (!Number.isFinite(value)) {
fail(`Invalid numeric token: ${token}`);
}
onValue(value, index);
index += 1;
token = "";
};
while (true) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading number array");
}
if (char === "]") {
flush();
return;
}
if (char === ",") {
flush();
continue;
}
if (/\s/u.test(char)) {
continue;
}
token += char;
}
}
async readJsonString() {
await this.expectChar('"');
let value = "";
while (true) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading JSON string");
}
if (char === '"') {
return value;
}
if (char !== "\\") {
value += char;
continue;
}
const escaped = await this.next();
if (escaped === null) {
fail("Unexpected EOF while reading JSON string escape");
}
if (escaped === "u") {
let hex = "";
for (let index = 0; index < 4; index += 1) {
const hexChar = await this.next();
if (hexChar === null) {
fail("Unexpected EOF while reading JSON unicode escape");
}
hex += hexChar;
}
value += String.fromCharCode(Number.parseInt(hex, 16));
continue;
}
value +=
escaped === "b"
? "\b"
: escaped === "f"
? "\f"
: escaped === "n"
? "\n"
: escaped === "r"
? "\r"
: escaped === "t"
? "\t"
: escaped;
}
}
async parseStringArray(onValue) {
await this.skipWhitespace();
await this.expectChar("[");
await this.skipWhitespace();
if ((await this.peek()) === "]") {
await this.next();
return;
}
let index = 0;
while (true) {
const value = await this.readJsonString();
onValue(value, index);
index += 1;
await this.skipWhitespace();
const separator = await this.next();
if (separator === "]") {
return;
}
if (separator !== ",") {
fail(`Expected , or ] but found ${separator ?? "<eof>"}`);
}
await this.skipWhitespace();
}
}
}
function parseHeapFilename(filePath) {
const base = path.basename(filePath);
const match = base.match(
/^Heap\.(?<stamp>\d{8}\.\d{6})\.(?<pid>\d+)\.0\.(?<seq>\d+)\.heapsnapshot$/u,
);
if (!match?.groups) {
return null;
}
return {
filePath,
pid: Number.parseInt(match.groups.pid, 10),
stamp: match.groups.stamp,
sequence: Number.parseInt(match.groups.seq, 10),
};
}
function resolvePair(options) {
if (options.laneDir) {
const entries = fs
.readdirSync(options.laneDir)
.map((name) => parseHeapFilename(path.join(options.laneDir, name)))
.filter((entry) => entry !== null)
.filter((entry) => options.pid === null || entry.pid === options.pid)
.toSorted((left, right) => {
if (left.pid !== right.pid) {
return left.pid - right.pid;
}
if (left.stamp !== right.stamp) {
return left.stamp.localeCompare(right.stamp);
}
return left.sequence - right.sequence;
});
if (entries.length === 0) {
fail(`No matching heap snapshots found in ${options.laneDir}`);
}
const groups = new Map();
for (const entry of entries) {
const group = groups.get(entry.pid) ?? [];
group.push(entry);
groups.set(entry.pid, group);
}
const candidates = Array.from(groups.values())
.map((group) => ({
pid: group[0].pid,
before: group[0],
after: group.at(-1),
count: group.length,
}))
.filter((entry) => entry.count >= 2);
if (candidates.length === 0) {
fail(`Need at least two snapshots for one PID in ${options.laneDir}`);
}
const chosen =
options.pid !== null
? (candidates.find((entry) => entry.pid === options.pid) ?? null)
: candidates.toSorted((left, right) => right.count - left.count || left.pid - right.pid)[0];
if (!chosen) {
fail(`No PID with at least two snapshots matched in ${options.laneDir}`);
}
return {
before: chosen.before.filePath,
after: chosen.after.filePath,
pid: chosen.pid,
snapshotCount: chosen.count,
};
}
if (options.files.length !== 2) {
printUsage();
process.exit(1);
}
return {
before: options.files[0],
after: options.files[1],
pid: null,
snapshotCount: 2,
};
}
async function parseSnapshotMeta(scanner) {
await scanner.find('"snapshot":');
await scanner.skipWhitespace();
const metaObjectText = await scanner.readBalancedObject();
const parsed = JSON.parse(metaObjectText);
return parsed?.meta ?? null;
}
async function buildSummary(filePath) {
const scanner = new JsonStreamScanner(filePath);
const meta = await parseSnapshotMeta(scanner);
if (!meta) {
fail(`Invalid heap snapshot: ${filePath}`);
}
const nodeFieldCount = meta.node_fields.length;
const typeNames = meta.node_types[0];
const typeIndex = meta.node_fields.indexOf("type");
const nameIndex = meta.node_fields.indexOf("name");
const selfSizeIndex = meta.node_fields.indexOf("self_size");
if (typeIndex === -1 || nameIndex === -1 || selfSizeIndex === -1) {
fail(`Unsupported heap snapshot schema: ${filePath}`);
}
const summaryByIndex = new Map();
let nodeCount = 0;
let currentTypeId = 0;
let currentNameId = 0;
let currentSelfSize = 0;
await scanner.find('"nodes":');
await scanner.parseNumberArray((value, index) => {
const fieldIndex = index % nodeFieldCount;
if (fieldIndex === typeIndex) {
currentTypeId = value;
return;
}
if (fieldIndex === nameIndex) {
currentNameId = value;
return;
}
if (fieldIndex === selfSizeIndex) {
currentSelfSize = value;
}
if (fieldIndex !== nodeFieldCount - 1) {
return;
}
const key = `${currentTypeId}\t${currentNameId}`;
const current = summaryByIndex.get(key) ?? {
typeId: currentTypeId,
nameId: currentNameId,
selfSize: 0,
count: 0,
};
current.selfSize += currentSelfSize;
current.count += 1;
summaryByIndex.set(key, current);
nodeCount += 1;
});
const requiredNameIds = new Set(
Array.from(summaryByIndex.values(), (entry) => entry.nameId).filter((value) => value >= 0),
);
const nameStrings = new Map();
await scanner.find('"strings":');
await scanner.parseStringArray((value, index) => {
if (requiredNameIds.has(index)) {
nameStrings.set(index, value);
}
});
const summary = new Map();
for (const entry of summaryByIndex.values()) {
const key = `${typeNames[entry.typeId] ?? "unknown"}\t${nameStrings.get(entry.nameId) ?? ""}`;
summary.set(key, {
type: typeNames[entry.typeId] ?? "unknown",
name: nameStrings.get(entry.nameId) ?? "",
selfSize: entry.selfSize,
count: entry.count,
});
}
return {
nodeCount,
summary,
};
}
function formatBytes(bytes) {
if (Math.abs(bytes) >= 1024 ** 2) {
return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
}
if (Math.abs(bytes) >= 1024) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
return `${bytes} B`;
}
function formatDelta(bytes) {
return `${bytes >= 0 ? "+" : "-"}${formatBytes(Math.abs(bytes))}`;
}
function truncate(text, maxLength) {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const pair = resolvePair(options);
const before = await buildSummary(pair.before);
const after = await buildSummary(pair.after);
const minBytes = options.minKb * 1024;
const rows = [];
for (const [key, next] of after.summary) {
const previous = before.summary.get(key) ?? { selfSize: 0, count: 0 };
const sizeDelta = next.selfSize - previous.selfSize;
const countDelta = next.count - previous.count;
if (sizeDelta < minBytes) {
continue;
}
rows.push({
type: next.type,
name: next.name,
sizeDelta,
countDelta,
afterSize: next.selfSize,
afterCount: next.count,
});
}
rows.sort(
(left, right) => right.sizeDelta - left.sizeDelta || right.countDelta - left.countDelta,
);
console.log(`before: ${pair.before}`);
console.log(`after: ${pair.after}`);
if (pair.pid !== null) {
console.log(`pid: ${pair.pid} (${pair.snapshotCount} snapshots found)`);
}
console.log(
`nodes: ${before.nodeCount} -> ${after.nodeCount} (${after.nodeCount - before.nodeCount >= 0 ? "+" : ""}${after.nodeCount - before.nodeCount})`,
);
console.log(`filter: top=${options.top} min=${options.minKb} KiB`);
console.log("");
if (rows.length === 0) {
console.log("No entries exceeded the minimum delta.");
return;
}
for (const row of rows.slice(0, options.top)) {
console.log(
[
formatDelta(row.sizeDelta).padStart(11),
`count ${row.countDelta >= 0 ? "+" : ""}${row.countDelta}`.padStart(10),
row.type.padEnd(16),
truncate(row.name || "(empty)", 96),
].join(" "),
);
}
}
await main();

View file

@ -0,0 +1,134 @@
---
name: openclaw-test-performance
description: Benchmark, diagnose, and optimize OpenClaw test performance without losing coverage. Use when Codex needs to reassess `pnpm test`, compare grouped Vitest reports, identify CPU/memory/import hotspots, fix slow tests or cold runtime paths, preserve behavior proofs, update the performance report, add AGENTS guardrails, and make scoped commits/pushes for OpenClaw test-speed work.
---
# OpenClaw Test Performance
Use evidence first. The goal is real `pnpm test` speed/RSS improvement with
coverage intact, not runner tuning by guesswork.
## Workflow
1. Read the relevant local `AGENTS.md` files before editing:
- `src/agents/AGENTS.md` for agent/import hotspots.
- `src/channels/AGENTS.md` and `src/plugins/AGENTS.md` for plugin/channel
laziness.
- `src/gateway/AGENTS.md` for server lifecycle tests.
- `test/helpers/AGENTS.md` and `test/helpers/channels/AGENTS.md` for shared
contract helpers.
- `src/infra/outbound/AGENTS.md` for outbound/media/action tests.
2. Establish a baseline before changing code:
- Prefer `pnpm test:perf:groups --full-suite --allow-failures --output <file>`
for full-suite ranking.
- For a scoped hotspot use:
`/usr/bin/time -l pnpm test <file-or-files> --maxWorkers=1 --reporter=verbose`
- For import-heavy suspicion add:
`OPENCLAW_VITEST_IMPORT_DURATIONS=1 OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1`.
3. Separate wall/runner noise from real file cost:
- Compare Vitest duration, test body timing, import breakdown, wall time, and
max RSS.
- Re-run single files when grouped/full-suite numbers look stale or noisy.
- If a full-suite grouped run reports a lane failure but JSON says tests
passed, capture that as harness/noise and verify the suspect file directly.
4. Pick the next attack by return and risk:
- High return: one file/test dominates seconds or RSS and has a clear root.
- Lower risk: static descriptors, target parsing, routing, auth bypass,
setup hints, registry fixtures, or test server lifecycle.
- Higher risk: real memory/runtime behavior, live providers, protocol
contracts, or broad production refactors.
5. Fix the root cause, not the symptom:
- Move static metadata/parsing into narrow helpers or lightweight artifacts
reused by full runtime and fast paths.
- Prefer dependency injection, loaded-plugin-only lookup, explicit fixtures,
and pure helpers over broad mocks.
- Reuse suite-level servers/clients when a fresh handshake is irrelevant.
- Keep schedulers/background loops off unless the test proves scheduling.
6. Preserve coverage shape:
- Do not delete a slow integration proof unless the exact production
composition is extracted into a named helper and tested.
- Keep one cheap integration smoke when cross-component wiring matters.
- State explicitly what incidental coverage was removed, if any.
7. Re-benchmark the same command after the change and compute seconds plus
percent gain.
8. Update the running report when requested or when this thread is tracking one.
Include before/after commands, artifacts, coverage notes, verification, and
next attack order.
9. Commit with `scripts/committer "<message>" <paths...>` and push when the
user asked for commits/pushes. Stage only files touched for this attack.
## Common Root Causes
- Full bundled channel/plugin runtime loaded for static data.
- `getChannelPlugin()` fallback used when an already-loaded fixture or pure
parser would suffice.
- Broad `api.ts`, `runtime-api.ts`, `test-api.ts`, or plugin-sdk barrels pulled
into hot tests.
- Partial-real mocks using `importActual()` around broad modules.
- `vi.resetModules()` plus fresh imports in per-test loops.
- Test plugin registry seeded in `beforeAll` while runtime state resets in
`afterEach`.
- Per-test gateway/server/client startup when state reset would suffice.
- Runtime/default model/auth selection paid by idle snapshots or fixtures.
- Plugin-owned media/action discovery triggered before checking whether args
contain plugin-owned fields.
## Benchmark Commands
Scoped file:
```bash
timeout 240 /usr/bin/time -l pnpm test <file> --maxWorkers=1 --reporter=verbose
```
Scoped file with import breakdown:
```bash
timeout 240 /usr/bin/time -l env \
OPENCLAW_VITEST_IMPORT_DURATIONS=1 \
OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1 \
pnpm test <file> --maxWorkers=1 --reporter=verbose
```
Grouped suite:
```bash
pnpm test:perf:groups --full-suite --allow-failures \
--output .artifacts/test-perf/<name>.json
```
Reuse an existing Vitest JSON report:
```bash
pnpm test:perf:groups --report <vitest-json> \
--output .artifacts/test-perf/<name>.json
```
## Verification
- Always run the targeted test surface that proves the change.
- Run `pnpm check` before commit unless the change is docs-only and the hook
handles it.
- Run `pnpm build` when touching lazy-loading, bundled artifacts, package
boundaries, dynamic imports, build output, or public surfaces.
- If deps are missing/stale, run `pnpm install` and retry the exact failed
command once.
- Use the report format:
```markdown
| Metric | Before | After | Gain |
| -------------- | -----: | ----: | ------------: |
| File wall time | `Xs` | `Ys` | `-Zs` (`P%`) |
| Max RSS | `XMB` | `YMB` | `-ZMB` (`P%`) |
```
## Handoff
Keep the final concise:
- Root cause.
- Files changed.
- Before/after numbers.
- Coverage retained.
- Verification commands.
- Commit hash and push status.

View file

@ -0,0 +1,6 @@
interface:
display_name: "OpenClaw Test Performance"
short_description: "Benchmark and fix slow OpenClaw tests"
default_prompt: "Use $openclaw-test-performance to reassess the OpenClaw test benchmark, identify the next real hotspot, fix it without losing coverage, update the report, and commit scoped changes."
policy:
allow_implicit_invocation: false

View file

@ -0,0 +1,62 @@
---
name: parallels-discord-roundtrip
description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback.
---
# Parallels Discord Roundtrip
Use when macOS Parallels smoke must prove Discord two-way delivery end to end.
## Goal
Cover:
- install on fresh macOS snapshot
- onboard + gateway health
- guest `message send` to Discord
- host sees that message on Discord
- host posts a new Discord message
- guest `message read` sees that new message
## Inputs
- host env var with Discord bot token
- Discord guild ID
- Discord channel ID
- `OPENAI_API_KEY`
## Preferred run
```bash
export OPENCLAW_PARALLELS_DISCORD_TOKEN="$(
ssh peters-mac-studio-1 'jq -r ".channels.discord.token" ~/.openclaw/openclaw.json' | tr -d '\n'
)"
pnpm test:parallels:macos \
--discord-token-env OPENCLAW_PARALLELS_DISCORD_TOKEN \
--discord-guild-id 1456350064065904867 \
--discord-channel-id 1456744319972282449 \
--json
```
## Notes
- Snapshot target: closest to `macOS 26.3.1 fresh`.
- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint.
- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots.
- Harness configures Discord inside the guest; no checked-in token/config.
- Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way.
- Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated like array indexes.
- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase.
- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load.
- Harness cleanup deletes the temporary Discord smoke messages at exit.
- Per-phase logs: `/tmp/openclaw-parallels-smoke.*`
- Machine summary: pass `--json`
- If roundtrip flakes, inspect `fresh.discord-roundtrip.log` and `discord-last-readback.json` in the run dir first.
## Pass criteria
- fresh lane or upgrade lane requested passes
- summary reports `discord=pass` for that lane
- guest outbound nonce appears in channel history
- host inbound nonce appears in `openclaw message read` output

View file

@ -0,0 +1,111 @@
---
name: security-triage
description: Triage GitHub security advisories for OpenClaw with high-confidence close/keep decisions, exact tag and commit verification, trust-model checks, optional hardening notes, and a final reply ready to post and copy to clipboard.
---
# Security Triage
Use when reviewing OpenClaw security advisories, drafts, or GHSA reports.
Goal: high-confidence maintainers' triage without over-closing real issues or shipping unnecessary regressions.
## Close Bar
Close only if one of these is true:
- duplicate of an existing advisory or fixed issue
- invalid against shipped behavior
- out of scope under `SECURITY.md`
- fixed before any affected release/tag
Do not close only because `main` is fixed. If latest shipped tag or npm release is affected, keep it open until released or published with the right status.
## Required Reads
Before answering:
1. Read `SECURITY.md`.
2. Read the GHSA body with `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`.
3. Inspect the exact implicated code paths.
4. Verify shipped state:
- `git tag --sort=-creatordate | head`
- `npm view openclaw version --userconfig "$(mktemp)"`
- `git tag --contains <fix-commit>`
- if needed: `git show <tag>:path/to/file`
5. Search for canonical overlap:
- existing published GHSAs
- older fixed bugs
- same trust-model class already covered in `SECURITY.md`
## Review Method
For each advisory, decide:
- `close`
- `keep open`
- `keep open but narrow`
Check in this order:
1. Trust model
- Is the prerequisite already inside trusted host/local/plugin/operator state?
- Does `SECURITY.md` explicitly call this class out as out of scope or hardening-only?
2. Shipped behavior
- Is the bug present in the latest shipped tag or npm release?
- Was it fixed before release?
3. Exploit path
- Does the report show a real boundary bypass, not just prompt injection, local same-user control, or helper-level semantics?
- If data only moves between trusted workspace-memory files called out in `SECURITY.md`, do not treat "injection markers" alone as a security bug.
- In that case, frame sanitization as optional hardening only if it preserves expected memory workflows.
4. Functional tradeoff
- If a hardening change would reduce intended user functionality, call that out before proposing it.
- Prefer fixes that preserve user workflows over deny-by-default regressions unless the boundary demands it.
## Response Format
When preparing a maintainer-ready close reply:
1. Print the GHSA URL first.
2. Then draft a detailed response the maintainer can post.
3. Include:
- exact reason for close
- exact code refs
- exact shipped tag / release facts
- exact fix commit or canonical duplicate GHSA when applicable
- optional hardening note only if worthwhile and functionality-preserving
Keep tone firm, specific, non-defensive.
## Clipboard Step
After drafting the final post body, copy it:
```bash
pbcopy <<'EOF'
<final response>
EOF
```
Tell the user that the clipboard now contains the proposed response.
## Useful Commands
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
gh api /repos/openclaw/openclaw/security-advisories --paginate
git tag --sort=-creatordate | head -n 20
npm view openclaw version --userconfig "$(mktemp)"
git tag --contains <commit>
git show <tag>:<path>
gh search issues --repo openclaw/openclaw --match title,body,comments -- "<terms>"
gh search prs --repo openclaw/openclaw --match title,body,comments -- "<terms>"
```
## Decision Notes
- “fixed on main, unreleased” is usually not a close.
- “needs attacker-controlled trusted local state first” is usually out of scope.
- “same-host same-user process can already read/write local state” is usually out of scope.
- “trusted workspace memory promotes/reindexes trusted workspace memory” is usually out of scope unless it crosses a documented boundary.
- “helper function behaves differently than documented config semantics” is usually invalid.
- If only the severity is wrong but the bug is real, keep it open and narrow the impact in the reply.