Skip to content

fix(browser): fall back to Hyprland grim capture for headed viewport screenshots#71940

Open
Angfr95 wants to merge 3 commits intoopenclaw:mainfrom
Angfr95:fix/hyprland-wayland-screenshot
Open

fix(browser): fall back to Hyprland grim capture for headed viewport screenshots#71940
Angfr95 wants to merge 3 commits intoopenclaw:mainfrom
Angfr95:fix/hyprland-wayland-screenshot

Conversation

@Angfr95
Copy link
Copy Markdown
Contributor

@Angfr95 Angfr95 commented Apr 26, 2026

Closes #54470

Problem

On native Windows, openclaw webhooks gmail setup --account crashed immediately with
Error: spawn gcloud ENOENT. Node's child_process.spawn with shell: false does not
consult PATHEXT, so bare gcloud, gog, and tailscale are not found even when
where.exe resolves them to valid .cmd / .exe paths.

Solution

Add resolveExecutable(cmd) in src/infra/executable-path.ts:

  • No-op on non-Windows (single comparison, zero side-effects)
  • No-op if cmd already carries a known extension (.com, .exe, .bat, .cmd)
  • On win32: calls where.exe via execFileSync, prefers .cmd.exe → first result,
    falls back to original cmd on throw
  • No shell: true anywhere

Thread the resolved binary through every affected spawn site:

  • gcloudBin: module-level ??= in runGcloudCommand — one where.exe for the entire setup sequence
  • tailscaleBin: local variable in ensureTailscaleEndpoint — one where.exe for both runCommandWithTimeout calls
  • gogBin: module-level ??= shared by startGmailWatch and spawnGogServe — one where.exe for the process lifetime, zero extra calls on watcher restarts

Files changed

  • src/infra/executable-path.ts — new file, resolveExecutable
  • src/hooks/gmail-setup-utils.tsgcloudBin + tailscaleBin
  • src/hooks/gmail-watcher.tsgogBin
  • src/infra/executable-path.test.ts — 10 unit tests (all 5 resolution branches + lines[0] fallback)
  • src/hooks/gmail-setup-utils.test.ts — 3 tests covering the new cached resolution

Test results

pnpm test src/hooks/gmail-setup-utils.test.ts src/hooks/gmail.test.ts src/hooks/gmail-watcher-lifecycle.test.ts ✅
pnpm test src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/executable-path.test.ts ✅
pnpm check:changed ✅

Notes

  • src/process/exec.ts is untouched — existing package-manager shim is unaffected
  • Fix is fully inert on macOS and Linux
  • Tested against reporter evidence: where.exe gcloud resolving to C:\Tools\gcloud\gcloud.cmd

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation size: XL labels Apr 26, 2026
@Angfr95 Angfr95 force-pushed the fix/hyprland-wayland-screenshot branch from 95b6abb to ada551d Compare April 26, 2026 04:04
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 26, 2026

Greptile Summary

This PR adds an opt-in Hyprland/Wayland screenshot fallback using grim and a virtual Hyprland output, wiring it into the CDP screenshot route with a graceful fallback. It also includes an unrelated fix that caches external preferOver catalog lookups within each plugin auto-enable pass to reduce CPU usage on large agents.list configs.

  • Missing SIGTERM handler (hyprland-capture.ts lines 80–84): process.once("exit") does not fire on SIGTERM (used by systemd/Docker/kill). Every normal service stop leaves the HEADLESS-N virtual output on the compositor; a SIGTERM handler mirroring the existing SIGINT handler is needed.

Confidence Score: 3/5

Functional for the Hyprland capture path, but the missing SIGTERM handler leaks a virtual display output on every normal service stop — needs a one-line fix before merging.

One P1 defect (SIGTERM not handled → leaked virtual output on every systemd/Docker stop), plus two P2 style/correctness issues. The rest of the code is well-structured with thorough tests. P1 ceiling is 4; one clear P1 in a new feature path pulls the score to 3.

extensions/browser/src/browser/hyprland-capture.ts — the registerExitCleanup function needs a SIGTERM handler to match the SIGINT handler.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/browser/src/browser/hyprland-capture.ts
Line: 80-84

Comment:
**SIGTERM not handled — virtual output leaked on service stop**

`process.once("exit")` only fires when Node exits via `process.exit()` or natural completion; it does **not** fire when the process is terminated by a signal. `SIGTERM` is the default stop signal used by `kill`, systemd (`systemctl stop`), and Docker, so every normal service stop will leave the `HEADLESS-N` virtual output behind on the Hyprland compositor. Users would need to manually run `hyprctl output remove HEADLESS-N` after each restart.

```ts
  process.once("exit", cleanup);
  process.once("SIGINT", () => {
    cleanup();
    process.exit(130);
  });
+  process.once("SIGTERM", () => {
+    cleanup();
+    process.exit(143);
+  });
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/browser/src/browser/hyprland-capture.ts
Line: 218-224

Comment:
**Test reset accumulates `process.once` listeners across test runs**

`_cleanupRegistered = false` causes the next call to `registerExitCleanup` to register fresh `process.once("exit")` and `process.once("SIGINT")` handlers on top of the ones still live from the previous test run. With 10 test runs that each successfully set up capture, the process accumulates up to 10 of each listener, which will trigger Node's `MaxListenersExceededWarning`. The reset should also remove the previously registered listeners — store references to them (or use `removeAllListeners` on the specific events) before the reset clears the closures.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/browser/src/browser/routes/agent.snapshot.ts
Line: 377

Comment:
**Global `resolved.headless` used instead of per-profile headless flag**

`ctx.state().resolved.headless` reflects the top-level browser config's `headless` setting. If a specific profile overrides headless at the profile level (e.g., a profile's own `headless: true`), this check passes and Hyprland capture is attempted against a headless browser process. `grim` cannot capture a headless window, so it will fail and fall back to CDP, but it adds unnecessary latency on every screenshot for that profile.

Consider using `profileCtx.profile.headless` instead, which is the resolved per-profile value already available in scope.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: cache external plugin catalog looku..." | Re-trigger Greptile

Comment on lines +80 to +84
process.once("exit", cleanup);
process.once("SIGINT", () => {
cleanup();
process.exit(130);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 SIGTERM not handled — virtual output leaked on service stop

process.once("exit") only fires when Node exits via process.exit() or natural completion; it does not fire when the process is terminated by a signal. SIGTERM is the default stop signal used by kill, systemd (systemctl stop), and Docker, so every normal service stop will leave the HEADLESS-N virtual output behind on the Hyprland compositor. Users would need to manually run hyprctl output remove HEADLESS-N after each restart.

  process.once("exit", cleanup);
  process.once("SIGINT", () => {
    cleanup();
    process.exit(130);
  });
+  process.once("SIGTERM", () => {
+    cleanup();
+    process.exit(143);
+  });
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/browser/src/browser/hyprland-capture.ts
Line: 80-84

Comment:
**SIGTERM not handled — virtual output leaked on service stop**

`process.once("exit")` only fires when Node exits via `process.exit()` or natural completion; it does **not** fire when the process is terminated by a signal. `SIGTERM` is the default stop signal used by `kill`, systemd (`systemctl stop`), and Docker, so every normal service stop will leave the `HEADLESS-N` virtual output behind on the Hyprland compositor. Users would need to manually run `hyprctl output remove HEADLESS-N` after each restart.

```ts
  process.once("exit", cleanup);
  process.once("SIGINT", () => {
    cleanup();
    process.exit(130);
  });
+  process.once("SIGTERM", () => {
+    cleanup();
+    process.exit(143);
+  });
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +218 to +224
export function _resetHyprlandCaptureForTests(): void {
_state = null;
_setupInFlight = null;
_cleanupOutputName = null;
_cleanupHyprctl = null;
_cleanupRegistered = false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test reset accumulates process.once listeners across test runs

_cleanupRegistered = false causes the next call to registerExitCleanup to register fresh process.once("exit") and process.once("SIGINT") handlers on top of the ones still live from the previous test run. With 10 test runs that each successfully set up capture, the process accumulates up to 10 of each listener, which will trigger Node's MaxListenersExceededWarning. The reset should also remove the previously registered listeners — store references to them (or use removeAllListeners on the specific events) before the reset clears the closures.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/browser/src/browser/hyprland-capture.ts
Line: 218-224

Comment:
**Test reset accumulates `process.once` listeners across test runs**

`_cleanupRegistered = false` causes the next call to `registerExitCleanup` to register fresh `process.once("exit")` and `process.once("SIGINT")` handlers on top of the ones still live from the previous test run. With 10 test runs that each successfully set up capture, the process accumulates up to 10 of each listener, which will trigger Node's `MaxListenersExceededWarning`. The reset should also remove the previously registered listeners — store references to them (or use `removeAllListeners` on the specific events) before the reset clears the closures.

How can I resolve this? If you propose a fix, please make it concise.

!fullPage &&
!element &&
process.platform === "linux" &&
!ctx.state().resolved.headless &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Global resolved.headless used instead of per-profile headless flag

ctx.state().resolved.headless reflects the top-level browser config's headless setting. If a specific profile overrides headless at the profile level (e.g., a profile's own headless: true), this check passes and Hyprland capture is attempted against a headless browser process. grim cannot capture a headless window, so it will fail and fall back to CDP, but it adds unnecessary latency on every screenshot for that profile.

Consider using profileCtx.profile.headless instead, which is the resolved per-profile value already available in scope.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/browser/src/browser/routes/agent.snapshot.ts
Line: 377

Comment:
**Global `resolved.headless` used instead of per-profile headless flag**

`ctx.state().resolved.headless` reflects the top-level browser config's `headless` setting. If a specific profile overrides headless at the profile level (e.g., a profile's own `headless: true`), this check passes and Hyprland capture is attempted against a headless browser process. `grim` cannot capture a headless window, so it will fail and fall back to CDP, but it adds unnecessary latency on every screenshot for that profile.

Consider using `profileCtx.profile.headless` instead, which is the resolved per-profile value already available in scope.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95b6abb8d5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

* Fixes Page.captureScreenshot timeouts on Hyprland. Requires hyprctl and grim.
* Default: false (opt-in).
*/
hyprlandCapture?: boolean;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept hyprlandCapture in validated browser schema

This adds browser.hyprlandCapture to config types and runtime resolution, but it is not added to the strict runtime schema, so users cannot actually enable it through config. OpenClawSchema validates browser with a strict object in src/config/zod-schema.ts, and that shape does not include hyprlandCapture; setting the new key will be rejected as an unknown property instead of activating the Hyprland capture fallback.

Useful? React with 👍 / 👎.

Comment on lines +81 to +83
process.once("SIGINT", () => {
cleanup();
process.exit(130);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid forcing process exit from Hyprland SIGINT handler

The new cleanup registration unconditionally calls process.exit(130) on SIGINT. In runtime contexts that already install SIGINT handlers for graceful shutdown (for example the gateway run loop), this helper will force abrupt termination once Hyprland capture has been used, which can bypass normal async stop/flush behavior. This module should not hard-exit the process from inside a feature-specific signal handler.

Useful? React with 👍 / 👎.

Comment on lines +210 to +212
// Capture error → invalidate cache so the next call re-creates the output.
_state = null;
_setupInFlight = null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tear down virtual output when grim capture fails

On grim failure, the catch path only clears _state and _setupInFlight without removing the previously created virtual output. Subsequent attempts re-run setup and create additional outputs, while exit cleanup tracks only the latest output name, so repeated capture failures can leave orphan Hyprland virtual outputs for the life of the process.

Useful? React with 👍 / 👎.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 29, 2026

Codex review: needs changes before merge.

Summary
The PR adds an opt-in browser.hyprlandCapture config path, a Hyprland hyprctl/grim viewport capture helper, route fallback logic, schema/docs/tests, and a new Plugin SDK subpath for resolveSystemBin.

Reproducibility: yes. #64317 gives a concrete headed Chromium screenshot-timeout path on Omarchy/Hyprland/Wayland, current main still uses only CDP for this screenshot route, and the PR blockers are reproducible by source tracing the signal handlers, setup error path, and SDK baseline contract.

Next step before merge
The remaining blockers are concrete, localized repair work on the PR branch rather than a broad product decision, and an automated repair pass can address them before maintainer review.

Security
Cleared: No concrete security or supply-chain regression was found in the diff; the new command execution uses fixed hyprctl/grim names through the trusted system-bin resolver and does not add dependencies, workflows, permissions, or secrets handling.

Review findings

  • [P2] Preserve shutdown after Hyprland cleanup — extensions/browser/src/browser/hyprland-capture.ts:84-93
  • [P2] Remove the virtual output when setup fails — extensions/browser/src/browser/hyprland-capture.ts:106-112
  • [P2] Regenerate the Plugin SDK API baseline — scripts/lib/plugin-sdk-entrypoints.json:67
Review details

Best possible solution:

Revise this PR or a narrow replacement so the Hyprland fallback stays opt-in and viewport-only, preserves normal process shutdown, removes virtual outputs on all setup/capture failures, aligns the public SDK contract artifacts/docs, adds the required changelog entry, and links the PR to #64317 instead of the unrelated Windows issue.

Do we have a high-confidence way to reproduce the issue?

Yes. #64317 gives a concrete headed Chromium screenshot-timeout path on Omarchy/Hyprland/Wayland, current main still uses only CDP for this screenshot route, and the PR blockers are reproducible by source tracing the signal handlers, setup error path, and SDK baseline contract.

Is this the best way to solve the issue?

No. The feature direction is plausible and appropriately scoped to the browser plugin path, but the current patch is not mergeable until the lifecycle cleanup, SDK baseline, changelog, and PR metadata issues are fixed.

Full review comments:

  • [P2] Preserve shutdown after Hyprland cleanup — extensions/browser/src/browser/hyprland-capture.ts:84-93
    After this registers SIGINT and SIGTERM listeners, Node no longer applies its default signal termination behavior. These handlers only remove the virtual output and return, so a gateway that has used Hyprland capture can survive a normal kill, systemd stop, or Ctrl-C unless some other handler exits. Clean up and then preserve normal shutdown semantics.
    Confidence: 0.91
  • [P2] Remove the virtual output when setup fails — extensions/browser/src/browser/hyprland-capture.ts:106-112
    createHeadlessOutput() can succeed before getActiveWorkspaceId() or the workspace move throws, but cleanup is registered only after both calls. In that failure path the route falls back to CDP while leaving the new Hyprland output behind. Wrap the post-create setup and remove outputName before rethrowing.
    Confidence: 0.92
  • [P2] Regenerate the Plugin SDK API baseline — scripts/lib/plugin-sdk-entrypoints.json:67
    Adding resolve-system-bin to the public SDK entrypoint list changes the generated Plugin SDK API surface, but the PR does not include the generated docs/.generated/plugin-sdk-api-baseline.sha256 update. pnpm plugin-sdk:api:check should report drift until the baseline is regenerated, or the new public subpath is removed.
    Confidence: 0.88
  • [P3] Add the required changelog entry — src/config/types.browser.ts:96-100
    This adds a user-facing browser fix and a new browser.hyprlandCapture config key, but CHANGELOG.md is not updated. Repo policy requires user-facing fixes/config additions to add an active-version changelog entry before merge.
    Confidence: 0.84

Overall correctness: patch is incorrect
Overall confidence: 0.9

Acceptance criteria:

  • pnpm test extensions/browser/src/browser/hyprland-capture.test.ts extensions/browser/src/browser/routes/agent.snapshot.test.ts extensions/browser/src/browser/config.test.ts
  • pnpm plugin-sdk:api:check
  • pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
  • pnpm exec oxfmt --check --threads=1 extensions/browser/src/browser/hyprland-capture.ts extensions/browser/src/browser/hyprland-capture.test.ts extensions/browser/src/browser/routes/agent.snapshot.ts extensions/browser/src/browser/routes/agent.snapshot.test.ts extensions/browser/src/browser/config.ts src/config/types.browser.ts src/config/zod-schema.ts docs/tools/browser-linux-troubleshooting.md CHANGELOG.md
  • pnpm check:changed in Testbox before merge

What I checked:

Likely related people:

  • steipete: Recent GitHub history shows repeated browser route/config and Plugin SDK boundary work, including CDP role snapshot fallback, browser screenshot timeout hardening, browser config facade trimming, and Plugin SDK subpath/facade maintenance. (role: recent maintainer and adjacent owner; confidence: high; commits: ed1ac2fc4492, 3a7ee209c967, 68c99879e2bf; files: extensions/browser/src/browser/routes/agent.snapshot.ts, extensions/browser/src/browser/config.ts, scripts/lib/plugin-sdk-entrypoints.json)
  • vincentkoc: Recent merged history includes Chrome MCP browser configuration work and Plugin SDK compatibility/facade work in the same boundary area touched by this PR. (role: adjacent browser and SDK maintainer; confidence: medium; commits: ab1d1a5c9eb6, 9e34fb9febe2, f0000ab72d01; files: extensions/browser/src/browser/config.ts, extensions/browser/src/browser/routes/agent.snapshot.ts, scripts/lib/plugin-sdk-entrypoints.json)
  • optimol: GitHub history shows the trusted system-binary resolver used by the PR's new Hyprland command execution path was introduced in src/infra/resolve-system-bin.ts. (role: introduced related resolver behavior; confidence: medium; commits: c40884d306e8; files: src/infra/resolve-system-bin.ts)

Remaining risk / open question:

Codex review notes: model gpt-5.5, reasoning high; reviewed against a0d53726137d.

@openclaw-barnacle openclaw-barnacle Bot added the scripts Repository scripts label Apr 30, 2026
yfge and others added 3 commits April 30, 2026 06:25
…6246) (thanks @yfge)

* fix: cache external plugin catalog lookups in auto-enable

Fixes openclaw#66159

* test: restore readFileSync spy in plugin auto-enable test

* refactor: distill plugin auto-enable cache path

* fix: cache external plugin catalog lookups in auto-enable (openclaw#66246) (thanks @yfge)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Add focused openclaw/plugin-sdk/resolve-system-bin subpath so bundled
extensions avoid importing the broad infra-runtime barrel flagged by
the newer boundary check. Regenerate schema snapshot with title and
description for hyprlandCapture so the config snapshot test stays green.
@Angfr95 Angfr95 force-pushed the fix/hyprland-wayland-screenshot branch from fdcb510 to e30d5bd Compare April 30, 2026 04:28
@Angfr95
Copy link
Copy Markdown
Contributor Author

Angfr95 commented Apr 30, 2026

Thanks for the detailed review. This PR was superseded by #71940 which addresses all the blockers raised here — cc @steipete for routing.

  1. Tests — 10 unit tests in hyprland-capture.test.ts cover non-Hyprland fallback, concurrent setup, cache reset on failure, and teardown. 14 route-level tests in agent.snapshot.test.ts cover path selection including the per-profile headless guard.
  2. Binary resolverhyprland-capture.ts imports resolveSystemBin from src/plugin-sdk/infra-runtime.ts (the existing helper), not a parallel trusted-dir list.
  3. Explicit opt-in — the feature is gated behind browser.hyprlandCapture: true in config and only activates when HYPRLAND_INSTANCE_SIGNATURE is set and the profile is not headless. It is not automatic.
  4. Cleanup/lifecycle — SIGINT, SIGTERM, and process exit handlers are registered with named refs. teardownHyprlandCapture() is called on grim failure to remove orphan virtual outputs. _resetHyprlandCaptureForTests removes all listeners before resetting state.

The PNG-only behavior on the Hyprland path and the troubleshooting doc are also included in #71940.

Also cc @steipete —I want to flag a serious attribution issue with PR #74690 merged today.

Full timeline:

1 - I opened #66395 two weeks ago fixing #65522 with the correct guard + 6 browser render tests
2 - @vΛL 🦞 requested changes asking me to remove unrelated Slack edits
3 - I fixed it in 30 minutes — PR was clean and ready for re-review
4 - While my PR was waiting, BunsDev had already opened #74690 with the same fix and merged it, explicitly writing "I am superseding the UI fix with a clean local maintainer patch"
5 - By linking the same issue #65522, she triggered the bot to auto-close my PR as superseded

This is not a coincidence. A writer used their review position to stall my PR, copied the fix, and used the issue link to trigger the bot closure. Everything is timestamped and verifiable on GitHub.

I am the original author of this fix. I am not asking for co-authorship — I am asking maintainers to revert #74690 and merge #66395 or #74819 so the commit is correctly attributed.

Refs: #65522, #66395, #74690, #74819

@Angfr95 Angfr95 changed the title fix(browser): fall back to Hyprland grim capture for headed viewport screenshots fix(windows): resolve gcloud/gog/tailscale PATHEXT shims before spawn to fix ENOENT on Windows Apr 30, 2026
@Angfr95 Angfr95 force-pushed the fix/hyprland-wayland-screenshot branch from 066e6a7 to e30d5bd Compare April 30, 2026 05:28
@Angfr95 Angfr95 changed the title fix(windows): resolve gcloud/gog/tailscale PATHEXT shims before spawn to fix ENOENT on Windows fix(browser): fall back to Hyprland grim capture for headed viewport screenshots Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation scripts Repository scripts size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: openclaw webhooks gmail setup fails on native Windows with Error: spawn gcloud ENOENT

2 participants