@brunosps00/dev-workflow 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ npx @brunosps00/dev-workflow install-deps
26
26
 
27
27
  ## Commands
28
28
 
29
- dev-workflow v1.1.1 ships **31 commands** organized into four tiers. Most users only invoke Tier 1 + Tier 2.
29
+ dev-workflow v1.1.3 ships **31 commands** organized into four tiers. Most users only invoke Tier 1 + Tier 2.
30
30
 
31
31
  ### Tier 1 — Gateway (3)
32
32
 
@@ -310,7 +310,7 @@ npx @brunosps00/dev-workflow list-installed # Show managed instal
310
310
  npx @brunosps00/dev-workflow doctor # Check wrappers, agents, skills, MCPs, gates
311
311
  npx @brunosps00/dev-workflow repair # Reconcile managed files from recorded install state
312
312
  npx @brunosps00/dev-workflow install-deps # Install Playwright, react-doctor; check security scanners + Docker
313
- npx @brunosps00/dev-workflow setup-wsl-browser # WSL NAT: build CDP relay + add Hyper-V firewall rule
313
+ npx @brunosps00/dev-workflow setup-wsl-browser # WSL NAT: install user-level prebuilt CDP relay (no admin)
314
314
  npx @brunosps00/dev-workflow install-azure-skills # Opt-in: Azure skills + Microsoft Learn MCP
315
315
  npx @brunosps00/dev-workflow install-azure-skills --products=azure-aks,azure-openai # Subset override
316
316
  npx @brunosps00/dev-workflow install-aws-skills # Opt-in: AWS skills + AWS MCP Server (needs uv + aws cli + creds)
@@ -47,7 +47,7 @@ const HELP_TEXT = `
47
47
  repair Reconcile managed files using the recorded install state
48
48
  subtask Create, complete, consume, and list local subagent handoffs
49
49
  install-deps Install system dependencies (Playwright browsers, MCP servers)
50
- setup-wsl-browser (WSL) Build the CDP relay on Windows + add the Hyper-V firewall rule so flows can drive the real Windows browser
50
+ setup-wsl-browser (WSL) Install the user-level prebuilt CDP relay so flows can drive the real Windows browser without admin
51
51
  install-azure-skills Opt-in: clone curated Azure skills from MicrosoftDocs/Agent-Skills
52
52
  into .agents/skills/azure/ and register the Microsoft Learn MCP
53
53
  server (HTTP, no-auth). Interactive category selection.
@@ -172,8 +172,8 @@ function printBrowserGuidance() {
172
172
  if (mirrored) {
173
173
  console.log(' Mirrored networking detected — connects to the Windows browser on 127.0.0.1 directly.');
174
174
  } else {
175
- console.log(' NAT networking — run `npx @brunosps00/dev-workflow setup-wsl-browser` once to build the');
176
- console.log(' cdp-relay.exe (needs Rust on Windows) and add the Hyper-V firewall rule. Otherwise flows');
175
+ console.log(' NAT networking — run `npx @brunosps00/dev-workflow setup-wsl-browser` once to install the');
176
+ console.log(' bundled user-level cdp-relay.exe. No admin prompt or firewall rule is required. Otherwise flows');
177
177
  console.log(' fall back to the local headless Chromium (faithful video, no Windows browser).');
178
178
  }
179
179
  console.log(' Details: .dw/skills/dw-testing-discipline/references/playwright-recipes.md ("Browser on WSL").');
@@ -1,17 +1,15 @@
1
- const { execSync, spawnSync } = require('child_process');
1
+ const { execSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
 
5
- // Builds the Rust CDP relay (cdp-relay.exe) on Windows and adds the one-time Hyper-V firewall
6
- // rule so WSL (NAT networking) can drive the real Windows browser over CDP. Idempotent.
5
+ // Installs the prebuilt CDP relay (cdp-relay.exe) in the Windows user profile. Idempotent.
7
6
  //
8
- // Why: Chromium binds its debug port to Windows loopback only, and the WSL Hyper-V firewall
9
- // defaults inbound to Block. A fixed-port relay + one Hyper-V allow rule bridges WSL -> browser.
10
- // The relay is a tiny static .exe — no Node/Python/PowerShell runtime dependency at run time.
7
+ // Why: Chromium binds its debug port to Windows loopback only. On WSL NAT, dev-workflow uses
8
+ // a reverse relay where the Windows-side .exe connects outbound to a WSL broker, so no admin
9
+ // prompt, firewall rule, Rust, Node, or Python runtime is required on the Windows target.
10
+ // The relay is a tiny prebuilt .exe — no Rust/Node/Python runtime dependency on the Windows target.
11
11
 
12
- const RELAY_PORT = 39222;
13
- const WSL_VM_CREATOR_ID = '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}'; // standard WSL VM creator id
14
- const RULE_NAME = 'dw-cdp-relay';
12
+ const RELAY_EXE_SRC = path.join(__dirname, '..', 'scaffold', 'scripts', 'cdp-relay', 'bin', 'cdp-relay.exe');
15
13
 
16
14
  function isWsl() {
17
15
  if (process.env.WSL_DISTRO_NAME) return true;
@@ -31,27 +29,6 @@ function winEnv(name) {
31
29
  }
32
30
  }
33
31
 
34
- function findWindowsCargo() {
35
- try {
36
- const line = execSync('cmd.exe /c where cargo', { stdio: 'pipe' })
37
- .toString()
38
- .split(/\r?\n/)
39
- .map((s) => s.trim())
40
- .find((s) => /cargo\.exe$/i.test(s));
41
- if (!line) return null;
42
- const wsl = execSync(`wslpath -u "${line}"`, { stdio: 'pipe' }).toString().trim();
43
- return fs.existsSync(wsl) ? { winPath: line, wslPath: wsl } : null;
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
- function copyCrate(srcDir, destWslDir) {
50
- fs.mkdirSync(path.join(destWslDir, 'src'), { recursive: true });
51
- fs.copyFileSync(path.join(srcDir, 'Cargo.toml'), path.join(destWslDir, 'Cargo.toml'));
52
- fs.copyFileSync(path.join(srcDir, 'src', 'main.rs'), path.join(destWslDir, 'src', 'main.rs'));
53
- }
54
-
55
32
  function run() {
56
33
  console.log('\n dev-workflow setup-wsl-browser');
57
34
  console.log(` ${'='.repeat(40)}\n`);
@@ -68,32 +45,18 @@ function run() {
68
45
  }
69
46
  const destWin = `${localAppData}\\dev-workflow`;
70
47
  const destWsl = execSync(`wslpath -u "${destWin}"`, { stdio: 'pipe' }).toString().trim();
71
- const srcWslDir = `${destWsl}/cdp-relay-src`;
72
- const srcWinDir = `${destWin}\\cdp-relay-src`;
73
-
74
- // 1) Build the relay with the Windows Rust toolchain.
75
- const cargo = findWindowsCargo();
76
- if (!cargo) {
77
- console.log(' \x1b[33m—\x1b[0m No Windows `cargo` found (where cargo). Install Rust on Windows: https://rustup.rs');
78
- console.log(' Without the relay, WSL flows fall back to the local headless Chromium (faithful, no Windows browser).');
79
- return;
80
- }
81
48
 
82
- const crateSrc = path.join(__dirname, '..', 'scaffold', 'scripts', 'cdp-relay');
83
- if (!fs.existsSync(path.join(crateSrc, 'Cargo.toml'))) {
84
- console.log(` \x1b[31m✗\x1b[0m Relay crate source not found at ${crateSrc}`);
49
+ // Install the prebuilt Windows relay bundled in the npm package.
50
+ if (!fs.existsSync(RELAY_EXE_SRC)) {
51
+ console.log(` \x1b[31m✗\x1b[0m Prebuilt relay not found at ${RELAY_EXE_SRC}`);
52
+ console.log(' The dev-workflow package is incomplete. Reinstall or update @brunosps00/dev-workflow.');
85
53
  return;
86
54
  }
87
55
 
88
- process.stdout.write(' Building cdp-relay.exe (Windows cargo, release)...');
56
+ process.stdout.write(' Installing cdp-relay.exe (prebuilt Windows x64)...');
89
57
  try {
90
58
  fs.mkdirSync(destWsl, { recursive: true });
91
- copyCrate(crateSrc, srcWslDir);
92
- execSync(`"${cargo.wslPath}" build --release --manifest-path "${srcWinDir}\\Cargo.toml"`, {
93
- stdio: 'pipe',
94
- timeout: 180000,
95
- });
96
- fs.copyFileSync(path.join(srcWslDir, 'target', 'release', 'cdp-relay.exe'), path.join(destWsl, 'cdp-relay.exe'));
59
+ fs.copyFileSync(RELAY_EXE_SRC, path.join(destWsl, 'cdp-relay.exe'));
97
60
  console.log(' \x1b[32m✓\x1b[0m');
98
61
  console.log(` ${destWin}\\cdp-relay.exe`);
99
62
  } catch (err) {
@@ -102,48 +65,12 @@ function run() {
102
65
  return;
103
66
  }
104
67
 
105
- // 2) Add the Hyper-V firewall inbound rule (one-time, elevated -> UAC prompt).
106
- console.log(`\n Adding Hyper-V firewall rule for TCP ${RELAY_PORT} (a UAC prompt will appear — accept it)...`);
107
- const inner = [
108
- `Remove-NetFirewallHyperVRule -Name '${RULE_NAME}' -ErrorAction SilentlyContinue;`,
109
- `New-NetFirewallHyperVRule -Name '${RULE_NAME}' -DisplayName 'dev-workflow cdp-relay'`,
110
- `-Direction Inbound -Action Allow -VMCreatorId '${WSL_VM_CREATOR_ID}' -Protocol TCP -LocalPorts ${RELAY_PORT}`,
111
- ].join(' ');
112
- try {
113
- execSync(
114
- `powershell.exe -NoProfile -Command "Start-Process powershell -Verb RunAs -WindowStyle Hidden -ArgumentList '-NoProfile','-Command',\\"${inner}\\""`,
115
- { stdio: 'pipe', timeout: 60000 },
116
- );
117
- } catch {
118
- // Start-Process returns before the elevated process finishes; verify below regardless.
119
- }
120
-
121
- // 3) Verify.
122
- let ok = false;
123
- for (let i = 0; i < 10 && !ok; i += 1) {
124
- try {
125
- const out = execSync(
126
- `powershell.exe -NoProfile -c "Get-NetFirewallHyperVRule -Name '${RULE_NAME}' -ErrorAction SilentlyContinue | Select-Object -Expand Action"`,
127
- { stdio: 'pipe', timeout: 15000 },
128
- ).toString();
129
- ok = /Allow/i.test(out);
130
- } catch {
131
- ok = false;
132
- }
133
- if (!ok) execSync('sleep 1');
134
- }
135
-
136
68
  console.log(` ${'='.repeat(40)}`);
137
- if (ok) {
138
- console.log(' \x1b[32m✓ Done.\x1b[0m The Windows browser is reachable from WSL over CDP.');
139
- console.log(` Set BROWSER_TEST to a Windows browser exe to use it, e.g.`);
140
- console.log(' BROWSER_TEST="/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"');
141
- } else {
142
- console.log(' \x1b[33m⚠ Hyper-V firewall rule not confirmed.\x1b[0m UAC may have been declined, or this Windows lacks');
143
- console.log(' Hyper-V firewall cmdlets. Flows will fall back to the local headless Chromium (faithful video, no');
144
- console.log(' Windows browser). You can also enable mirrored networking instead. Re-run to retry.');
145
- }
69
+ console.log(' \x1b[32m✓ Done.\x1b[0m The CDP relay is installed in the Windows user profile.');
70
+ console.log(' No admin prompt, firewall rule, or Windows Rust toolchain is required.');
71
+ console.log(` Set BROWSER_TEST to a Windows browser exe to use it, e.g.`);
72
+ console.log(' BROWSER_TEST="/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"');
146
73
  console.log();
147
74
  }
148
75
 
149
- module.exports = { run, RELAY_PORT };
76
+ module.exports = { run };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunosps00/dev-workflow",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "AI-driven development workflow commands for any project. Scaffolds a complete PRD-to-PR pipeline with multi-platform AI assistant support.",
5
5
  "bin": {
6
6
  "dev-workflow": "./bin/dev-workflow.js"
@@ -298,7 +298,7 @@ The runner drives Playwright via the Node API and records video with `page.scree
298
298
  mechanism that works over CDP). It generates `scripts/<slug>.flow.mjs` — a plain ESM module exporting
299
299
  `async function flow({ page, context, expect, baseURL, step, shot })` — not a `@playwright/test` spec,
300
300
  so it needs no project `playwright.config.*`. The browser is chosen by `.dw/scripts/lib/resolve-browser.mjs`
301
- (WSL-resilient: full headless Chromium by default; set `BROWSER_TEST` to a Windows browser exe to drive it over CDP — in NAT run `npx @brunosps00/dev-workflow setup-wsl-browser` once for the cdp-relay.exe + Hyper-V rule, mirrored networking works directly);
301
+ (WSL-resilient: full headless Chromium by default; set `BROWSER_TEST` to a Windows browser exe to drive it over CDP — in NAT run `npx @brunosps00/dev-workflow setup-wsl-browser` once to install the user-level reverse cdp-relay.exe, mirrored networking works directly);
302
302
  see the "Browser on WSL" section of `dw-testing-discipline/references/playwright-recipes.md`. Chromium
303
303
  only — `screencast`/CDP do not support Firefox/WebKit.
304
304
 
@@ -124,7 +124,7 @@ Match the keyword and suggest:
124
124
  | `docker`, `compose`, `container` | `/dw-dockerize` |
125
125
  | `new project`, `bootstrap`, `scaffold` | `/dw-new-project` |
126
126
  | `functional doc`, `screen map`, `e2e doc` | `/dw-functional-doc` |
127
- | `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` when WSL NAT must drive the real Windows browser |
127
+ | `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` installs the user-level reverse relay when WSL NAT must drive the real Windows browser |
128
128
  | `azure`, `microsoft learn`, `azure skills` | `/dw-install-azure-skills` |
129
129
  | `aws`, `aws mcp`, `aws skills` | `/dw-install-aws-skills` |
130
130
  | `incident`, `outage`, `postmortem`, `sev-1`, `sev-2` | (Skill `dw-incident-response` auto-invoked from `/dw-bugfix` for prod-critical) |
@@ -298,7 +298,7 @@ O runner dirige o Playwright pela API Node e grava vídeo com `page.screencast`
298
298
  funciona via CDP). Ele gera `scripts/<slug>.flow.mjs` — um módulo ESM simples que exporta
299
299
  `async function flow({ page, context, expect, baseURL, step, shot })`, não um spec `@playwright/test`,
300
300
  então dispensa o `playwright.config.*` do projeto. O browser é escolhido por `.dw/scripts/lib/resolve-browser.mjs`
301
- (resiliente no WSL: Chromium headless full por padrão; defina `BROWSER_TEST` com o caminho do exe do browser do Windows para dirigi-lo via CDP — em NAT rode `npx @brunosps00/dev-workflow setup-wsl-browser` uma vez para o cdp-relay.exe + regra Hyper-V, em mirrored funciona direto);
301
+ (resiliente no WSL: Chromium headless full por padrão; defina `BROWSER_TEST` com o caminho do exe do browser do Windows para dirigi-lo via CDP — em NAT rode `npx @brunosps00/dev-workflow setup-wsl-browser` uma vez para instalar o cdp-relay.exe reverso no contexto do usuário, em mirrored funciona direto);
302
302
  veja a seção "Browser on WSL" de `dw-testing-discipline/references/playwright-recipes.md`. Só Chromium —
303
303
  `screencast`/CDP não suportam Firefox/WebKit.
304
304
 
@@ -120,7 +120,7 @@ Auto-invocados por comandos primários mas disponíveis standalone.
120
120
  | `docker`, `compose`, `container` | `/dw-dockerize` |
121
121
  | `new project`, `bootstrap`, `scaffold` | `/dw-new-project` |
122
122
  | `functional doc`, `screen map` | `/dw-functional-doc` |
123
- | `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` quando WSL NAT precisa dirigir o browser real do Windows |
123
+ | `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` instala o relay reverso no contexto do usuário quando WSL NAT precisa dirigir o browser real do Windows |
124
124
  | `azure`, `microsoft learn`, `azure skills` | `/dw-install-azure-skills` |
125
125
  | `aws`, `aws mcp`, `aws skills` | `/dw-install-aws-skills` |
126
126
  | `incident`, `outage`, `postmortem`, `sev-1` | (Skill `dw-incident-response` auto-invocada de `/dw-bugfix`) |
@@ -0,0 +1,7 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "cdp-relay"
7
+ version = "0.1.0"
@@ -2,7 +2,7 @@
2
2
  name = "cdp-relay"
3
3
  version = "0.1.0"
4
4
  edition = "2021"
5
- description = "Tiny TCP forwarder so WSL can reach a Windows browser's CDP debug port in NAT mode. Built on Windows by dev-workflow; no runtime dependency."
5
+ description = "Tiny forward/reverse TCP relay so WSL can reach a Windows browser's CDP debug port in NAT mode without admin setup."
6
6
 
7
7
  [[bin]]
8
8
  name = "cdp-relay"
@@ -1,14 +1,20 @@
1
1
  //! cdp-relay — a tiny TCP forwarder.
2
2
  //!
3
- //! Listens on 0.0.0.0:<listen> and forwards every connection to 127.0.0.1:<target>.
4
- //! dev-workflow runs this on the Windows side so WSL (NAT networking) can reach a Windows
5
- //! browser's CDP remote-debugging port, which Chromium binds to loopback only. std-only:
6
- //! no crates, no runtime — a single static .exe built with the user's Windows `cargo`.
3
+ //! Default mode listens on 0.0.0.0:<listen> and forwards every connection to
4
+ //! 127.0.0.1:<target>. Reverse mode connects outbound to a WSL broker, then forwards
5
+ //! each broker-approved session to 127.0.0.1:<target>. dev-workflow uses reverse mode
6
+ //! on WSL NAT so no Windows admin/firewall rule is required. std-only: no crates, no
7
+ //! runtime. dev-workflow ships a prebuilt Windows x64 .exe; this source is kept for
8
+ //! maintainers who need to rebuild it.
7
9
 
8
10
  use std::env;
9
- use std::io;
11
+ use std::io::{self, Read};
10
12
  use std::net::{Shutdown, TcpListener, TcpStream};
11
13
  use std::thread;
14
+ use std::time::Duration;
15
+
16
+ const DEFAULT_REVERSE_POOL: usize = 8;
17
+ const RETRY_DELAY: Duration = Duration::from_millis(200);
12
18
 
13
19
  fn pump(mut from: TcpStream, mut to: TcpStream) {
14
20
  let _ = io::copy(&mut from, &mut to);
@@ -16,29 +22,36 @@ fn pump(mut from: TcpStream, mut to: TcpStream) {
16
22
  let _ = to.shutdown(Shutdown::Write);
17
23
  }
18
24
 
25
+ fn bridge(left: TcpStream, right: TcpStream) -> io::Result<()> {
26
+ left.set_nodelay(true).ok();
27
+ right.set_nodelay(true).ok();
28
+
29
+ let left_rx = left.try_clone()?;
30
+ let right_rx = right.try_clone()?;
31
+ thread::spawn(move || pump(left, right_rx));
32
+ thread::spawn(move || pump(right, left_rx));
33
+ Ok(())
34
+ }
35
+
19
36
  fn handle(client: TcpStream, target_port: u16) -> io::Result<()> {
20
- client.set_nodelay(true).ok();
21
37
  let upstream = TcpStream::connect(("127.0.0.1", target_port))?;
22
- upstream.set_nodelay(true).ok();
23
-
24
- let client_rx = client.try_clone()?;
25
- let upstream_rx = upstream.try_clone()?;
26
- // client -> upstream
27
- thread::spawn(move || pump(client, upstream));
28
- // upstream -> client
29
- thread::spawn(move || pump(upstream_rx, client_rx));
30
- Ok(())
38
+ bridge(client, upstream)
31
39
  }
32
40
 
33
- fn main() {
34
- let args: Vec<String> = env::args().collect();
35
- if args.len() < 3 {
36
- eprintln!("usage: cdp-relay <listen_port> <target_port>");
37
- std::process::exit(2);
41
+ fn wait_for_go(stream: &mut TcpStream) -> io::Result<()> {
42
+ let mut seen = Vec::with_capacity(3);
43
+ let mut byte = [0u8; 1];
44
+ while seen.len() < 3 {
45
+ stream.read_exact(&mut byte)?;
46
+ seen.push(byte[0]);
47
+ if seen == b"GO\n" {
48
+ return Ok(());
49
+ }
38
50
  }
39
- let listen_port: u16 = args[1].parse().expect("invalid listen port");
40
- let target_port: u16 = args[2].parse().expect("invalid target port");
51
+ Err(io::Error::new(io::ErrorKind::InvalidData, "expected GO"))
52
+ }
41
53
 
54
+ fn run_listen(listen_port: u16, target_port: u16) -> io::Result<()> {
42
55
  let listener = TcpListener::bind(("0.0.0.0", listen_port)).expect("bind failed");
43
56
  for stream in listener.incoming() {
44
57
  if let Ok(client) = stream {
@@ -46,4 +59,89 @@ fn main() {
46
59
  let _ = handle(client, target_port);
47
60
  }
48
61
  }
62
+ Ok(())
63
+ }
64
+
65
+ fn handle_reverse_session(mut broker: TcpStream, target_port: u16) -> io::Result<()> {
66
+ broker.set_nodelay(true).ok();
67
+ wait_for_go(&mut broker)?;
68
+ let upstream = TcpStream::connect(("127.0.0.1", target_port))?;
69
+ bridge(broker, upstream)
70
+ }
71
+
72
+ fn reverse_worker(broker_host: String, broker_port: u16, target_port: u16) {
73
+ loop {
74
+ match TcpStream::connect((broker_host.as_str(), broker_port)) {
75
+ Ok(broker) => {
76
+ if handle_reverse_session(broker, target_port).is_err() {
77
+ thread::sleep(RETRY_DELAY);
78
+ }
79
+ }
80
+ Err(_) => thread::sleep(RETRY_DELAY),
81
+ }
82
+ }
83
+ }
84
+
85
+ fn run_reverse(broker_host: String, broker_port: u16, target_port: u16, pool_size: usize) {
86
+ let pool_size = pool_size.max(1);
87
+ for _ in 0..pool_size {
88
+ let host = broker_host.clone();
89
+ thread::spawn(move || reverse_worker(host, broker_port, target_port));
90
+ }
91
+ loop {
92
+ thread::sleep(Duration::from_secs(3600));
93
+ }
94
+ }
95
+
96
+ fn usage() -> ! {
97
+ eprintln!("usage:");
98
+ eprintln!(" cdp-relay <listen_port> <target_port>");
99
+ eprintln!(" cdp-relay --reverse <broker_host> <broker_port> <target_port> [pool_size]");
100
+ std::process::exit(2);
101
+ }
102
+
103
+ fn parse_port(value: &str, name: &str) -> u16 {
104
+ value.parse().unwrap_or_else(|_| {
105
+ eprintln!("invalid {name}");
106
+ std::process::exit(2);
107
+ })
108
+ }
109
+
110
+ fn parse_pool(value: Option<&String>) -> usize {
111
+ value
112
+ .map(|s| {
113
+ s.parse().unwrap_or_else(|_| {
114
+ eprintln!("invalid pool_size");
115
+ std::process::exit(2);
116
+ })
117
+ })
118
+ .unwrap_or(DEFAULT_REVERSE_POOL)
119
+ }
120
+
121
+ fn main() {
122
+ let args: Vec<String> = env::args().collect();
123
+ if args.get(1).map(String::as_str) == Some("--reverse") {
124
+ if args.len() < 5 {
125
+ usage();
126
+ }
127
+ let broker_host = args[2].clone();
128
+ let broker_port = parse_port(&args[3], "broker_port");
129
+ let target_port = parse_port(&args[4], "target_port");
130
+ run_reverse(
131
+ broker_host,
132
+ broker_port,
133
+ target_port,
134
+ parse_pool(args.get(5)),
135
+ );
136
+ }
137
+
138
+ if args.len() < 3 {
139
+ usage();
140
+ }
141
+ let listen_port = parse_port(&args[1], "listen_port");
142
+ let target_port = parse_port(&args[2], "target_port");
143
+ if let Err(err) = run_listen(listen_port, target_port) {
144
+ eprintln!("cdp-relay: {err}");
145
+ std::process::exit(1);
146
+ }
49
147
  }
@@ -19,10 +19,9 @@
19
19
  //
20
20
  // NOTE on WSL networking: the Chromium debug port binds Windows loopback only. In mirrored
21
21
  // networking mode WSL shares that loopback, so 127.0.0.1 connects directly. In NAT mode the
22
- // helper starts the prebuilt cdp-relay.exe on Windows (0.0.0.0:<fixed port> -> 127.0.0.1:debug)
23
- // and connects via the Windows host gateway. That needs the one-time setup performed by
24
- // `npx @brunosps00/dev-workflow setup-wsl-browser` (builds the relay + adds the Hyper-V firewall
25
- // rule). Without it, the helper falls back to the local headless Chromium.
22
+ // helper starts the prebuilt cdp-relay.exe on Windows in reverse mode: the Windows process
23
+ // connects outbound to a WSL broker, and Playwright connects to a local WSL proxy. The one-time
24
+ // setup only installs the relay in the Windows user profile; no admin/firewall rule is needed.
26
25
 
27
26
  import fs from "node:fs";
28
27
  import net from "node:net";
@@ -35,6 +34,8 @@ const BUNDLED_LAUNCH = { channel: "chromium" };
35
34
 
36
35
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
36
  const dedupe = (items) => [...new Set(items.filter(Boolean))];
37
+ const REVERSE_RELAY_POOL = 8;
38
+ const CLIENT_WAIT_TIMEOUT_MS = 5000;
38
39
 
39
40
  export function detectWsl() {
40
41
  if (process.env.WSL_DISTRO_NAME) {
@@ -96,6 +97,42 @@ function getFreePort() {
96
97
  });
97
98
  }
98
99
 
100
+ function listen(server, port, host) {
101
+ return new Promise((resolve, reject) => {
102
+ const onError = (error) => {
103
+ server.off("listening", onListening);
104
+ reject(error);
105
+ };
106
+ const onListening = () => {
107
+ server.off("error", onError);
108
+ resolve(server.address().port);
109
+ };
110
+ server.once("error", onError);
111
+ server.listen(port, host, onListening);
112
+ });
113
+ }
114
+
115
+ function removeItem(items, item) {
116
+ const index = items.indexOf(item);
117
+ if (index >= 0) items.splice(index, 1);
118
+ }
119
+
120
+ function destroyQuietly(item) {
121
+ try {
122
+ item.destroy();
123
+ } catch {
124
+ // ignore
125
+ }
126
+ }
127
+
128
+ function closeServerQuietly(server) {
129
+ try {
130
+ server.close();
131
+ } catch {
132
+ // ignore
133
+ }
134
+ }
135
+
99
136
  function windowsHostIp() {
100
137
  // In WSL2 NAT mode the Windows host is the default-route gateway (the vEthernet
101
138
  // adapter), NOT the /etc/resolv.conf nameserver (which can be a DNS-tunneling proxy
@@ -120,6 +157,27 @@ function windowsHostIp() {
120
157
  return null;
121
158
  }
122
159
 
160
+ function wslBrokerHosts() {
161
+ const hosts = ["127.0.0.1"];
162
+ try {
163
+ const out = spawnSync("ip", ["-4", "-o", "addr", "show", "dev", "eth0", "scope", "global"], { encoding: "utf8" }).stdout ?? "";
164
+ const match = out.match(/\binet\s+([0-9.]+)\//);
165
+ if (match) hosts.push(match[1]);
166
+ } catch {
167
+ // ignore
168
+ }
169
+ if (hosts.length === 1) {
170
+ try {
171
+ const out = spawnSync("hostname", ["-I"], { encoding: "utf8" }).stdout ?? "";
172
+ const fallback = out.split(/\s+/).find((ip) => /^[0-9.]+$/.test(ip) && !ip.startsWith("127."));
173
+ if (fallback) hosts.push(fallback);
174
+ } catch {
175
+ // ignore
176
+ }
177
+ }
178
+ return dedupe(hosts);
179
+ }
180
+
123
181
  function httpGetJson(url, timeoutMs = 1000) {
124
182
  return new Promise((resolve) => {
125
183
  const request = http.get(url, { timeout: timeoutMs }, (response) => {
@@ -190,24 +248,22 @@ function windowsTempDir() {
190
248
  return wslPath ? { winPath, wslPath } : null;
191
249
  }
192
250
 
193
- // Fixed port the Windows-side relay listens on, so a single Hyper-V firewall rule covers it.
194
- const DEFAULT_RELAY_PORT = 39222;
195
-
196
251
  function relayPort(projectRoot) {
197
252
  const fromEnv = Number(process.env.DW_RELAY_PORT);
198
- if (fromEnv) return fromEnv;
253
+ if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) return fromEnv;
199
254
  if (projectRoot) {
200
255
  try {
201
256
  const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
202
- if (Number(cfg?.relayPort)) return Number(cfg.relayPort);
257
+ const fromConfig = Number(cfg?.relayPort);
258
+ if (Number.isInteger(fromConfig) && fromConfig > 0 && fromConfig < 65536) return fromConfig;
203
259
  } catch {
204
260
  // ignore
205
261
  }
206
262
  }
207
- return DEFAULT_RELAY_PORT;
263
+ return 0;
208
264
  }
209
265
 
210
- // The prebuilt Rust relay (cdp-relay.exe), built on Windows by `setup-wsl-browser`. Order:
266
+ // The prebuilt Windows relay (cdp-relay.exe), installed by `setup-wsl-browser`. Order:
211
267
  // .dw/config.json relayExe -> %LOCALAPPDATA%\dev-workflow\cdp-relay.exe. Returns { winPath, wslPath } or null.
212
268
  function findRelayExe(projectRoot) {
213
269
  const candidates = [];
@@ -237,13 +293,183 @@ function findRelayExe(projectRoot) {
237
293
  return null;
238
294
  }
239
295
 
296
+ async function startReverseRelayBroker({ relay, debugPort, projectRoot }) {
297
+ const idleRelays = [];
298
+ const pendingClients = [];
299
+ const relaySockets = [];
300
+ const localSockets = [];
301
+ const relayChildren = [];
302
+ const relayWaiters = [];
303
+ let closed = false;
304
+
305
+ const notifyRelayReady = () => {
306
+ while (idleRelays.length > 0 && relayWaiters.length > 0) {
307
+ const waiter = relayWaiters.shift();
308
+ clearTimeout(waiter.timer);
309
+ waiter.resolve(true);
310
+ }
311
+ };
312
+
313
+ const waitForRelayReady = (timeoutMs) => {
314
+ if (idleRelays.length > 0) return Promise.resolve(true);
315
+ return new Promise((resolve) => {
316
+ const waiter = { resolve, timer: null };
317
+ waiter.timer = setTimeout(() => {
318
+ removeItem(relayWaiters, waiter);
319
+ resolve(false);
320
+ }, timeoutMs);
321
+ relayWaiters.push(waiter);
322
+ });
323
+ };
324
+
325
+ const removePendingClient = (pending) => {
326
+ clearTimeout(pending.timer);
327
+ removeItem(pendingClients, pending);
328
+ };
329
+
330
+ const pairSockets = (client, relaySocket) => {
331
+ const closePair = () => {
332
+ destroyQuietly(client);
333
+ destroyQuietly(relaySocket);
334
+ };
335
+ client.on("error", closePair);
336
+ relaySocket.on("error", closePair);
337
+ client.on("close", () => destroyQuietly(relaySocket));
338
+ relaySocket.on("close", () => destroyQuietly(client));
339
+ relaySocket.write("GO\n", (error) => {
340
+ if (error) closePair();
341
+ });
342
+ client.pipe(relaySocket);
343
+ relaySocket.pipe(client);
344
+ };
345
+
346
+ const drainPending = () => {
347
+ while (pendingClients.length > 0 && idleRelays.length > 0) {
348
+ const pending = pendingClients.shift();
349
+ clearTimeout(pending.timer);
350
+ const relaySocket = idleRelays.shift();
351
+ if (pending.client.destroyed || relaySocket.destroyed) {
352
+ destroyQuietly(pending.client);
353
+ destroyQuietly(relaySocket);
354
+ continue;
355
+ }
356
+ pairSockets(pending.client, relaySocket);
357
+ }
358
+ };
359
+
360
+ const brokerServer = net.createServer((socket) => {
361
+ if (closed) {
362
+ socket.destroy();
363
+ return;
364
+ }
365
+ socket.setNoDelay(true);
366
+ relaySockets.push(socket);
367
+ idleRelays.push(socket);
368
+ socket.on("error", () => destroyQuietly(socket));
369
+ socket.on("close", () => {
370
+ removeItem(relaySockets, socket);
371
+ removeItem(idleRelays, socket);
372
+ });
373
+ notifyRelayReady();
374
+ drainPending();
375
+ });
376
+
377
+ const localServer = net.createServer((client) => {
378
+ if (closed) {
379
+ client.destroy();
380
+ return;
381
+ }
382
+ client.setNoDelay(true);
383
+ localSockets.push(client);
384
+ client.on("close", () => removeItem(localSockets, client));
385
+ client.on("error", () => destroyQuietly(client));
386
+ const pending = {
387
+ client,
388
+ timer: setTimeout(() => {
389
+ removePendingClient(pending);
390
+ destroyQuietly(client);
391
+ }, CLIENT_WAIT_TIMEOUT_MS),
392
+ };
393
+ client.on("close", () => removePendingClient(pending));
394
+ pendingClients.push(pending);
395
+ drainPending();
396
+ });
397
+
398
+ const cleanup = async () => {
399
+ closed = true;
400
+ for (const waiter of relayWaiters.splice(0)) {
401
+ clearTimeout(waiter.timer);
402
+ waiter.resolve(false);
403
+ }
404
+ for (const pending of pendingClients.splice(0)) {
405
+ clearTimeout(pending.timer);
406
+ destroyQuietly(pending.client);
407
+ }
408
+ for (const socket of [...localSockets, ...relaySockets]) destroyQuietly(socket);
409
+ for (const child of relayChildren) {
410
+ try {
411
+ child.kill();
412
+ } catch {
413
+ // ignore
414
+ }
415
+ }
416
+ closeServerQuietly(localServer);
417
+ closeServerQuietly(brokerServer);
418
+ };
419
+
420
+ let localPort;
421
+ let brokerPort;
422
+ try {
423
+ localPort = await listen(localServer, relayPort(projectRoot), "127.0.0.1");
424
+ brokerPort = await listen(brokerServer, 0, "0.0.0.0");
425
+ } catch (error) {
426
+ await cleanup();
427
+ throw new Error(`Could not start the WSL reverse relay broker: ${error.message}`);
428
+ }
429
+
430
+ let selectedHost = null;
431
+ for (const brokerHost of wslBrokerHosts()) {
432
+ const child = spawn(
433
+ relay.wslPath,
434
+ ["--reverse", brokerHost, String(brokerPort), String(debugPort), String(REVERSE_RELAY_POOL)],
435
+ { detached: true, stdio: "ignore" },
436
+ );
437
+ relayChildren.push(child);
438
+ child.unref();
439
+
440
+ if (await waitForRelayReady(brokerHost === "127.0.0.1" ? 1800 : 3000)) {
441
+ selectedHost = brokerHost;
442
+ break;
443
+ }
444
+
445
+ try {
446
+ child.kill();
447
+ } catch {
448
+ // ignore
449
+ }
450
+ }
451
+
452
+ if (!selectedHost) {
453
+ await cleanup();
454
+ throw new Error(
455
+ "WSL is in NAT mode, but the Windows relay could not connect back to the WSL broker. " +
456
+ "Run `npx @brunosps00/dev-workflow setup-wsl-browser` to reinstall the relay, or enable mirrored networking.",
457
+ );
458
+ }
459
+
460
+ return {
461
+ hosts: ["127.0.0.1", "localhost"],
462
+ port: localPort,
463
+ relayHost: selectedHost,
464
+ cleanup,
465
+ };
466
+ }
467
+
240
468
  // Launches the Windows browser in remote-debugging mode and returns a CDP descriptor.
241
469
  // - mirrored networking: WSL shares the Windows loopback (Hyper-V LoopbackEnabled), connect 127.0.0.1.
242
- // - NAT networking: the prebuilt cdp-relay.exe bridges 0.0.0.0:<relayPort> -> 127.0.0.1:<debug> on the
243
- // Windows side, and WSL connects via the Windows host gateway. This needs a one-time Hyper-V firewall
244
- // inbound rule for <relayPort> (added by `setup-wsl-browser`), because WSL's Hyper-V firewall defaults
245
- // inbound to Block. The browser echoes the request Host into webSocketDebuggerUrl, so the ws stays
246
- // reachable through the relay.
470
+ // - NAT networking: the prebuilt cdp-relay.exe runs on Windows in reverse mode. It connects outbound
471
+ // to a WSL broker, which exposes a local proxy for Playwright. The browser echoes the request Host
472
+ // into webSocketDebuggerUrl, so the ws stays reachable through the local proxy.
247
473
  // --remote-allow-origins=* is required or Chromium drops the DevTools ws (anti-DNS-rebinding).
248
474
  async function launchWindowsBrowser(exePath, { headless, projectRoot }) {
249
475
  const mirrored = isMirroredNetworking();
@@ -282,39 +508,42 @@ async function launchWindowsBrowser(exePath, { headless, projectRoot }) {
282
508
  }
283
509
  };
284
510
 
285
- let hosts;
286
- let port;
287
- if (mirrored) {
288
- hosts = ["127.0.0.1", "localhost"];
289
- port = debugPort;
290
- } else {
291
- const relay = findRelayExe(projectRoot);
292
- if (!relay) {
293
- await cleanup();
511
+ try {
512
+ let hosts;
513
+ let port;
514
+ let sourceSuffix = "";
515
+ if (mirrored) {
516
+ hosts = ["127.0.0.1", "localhost"];
517
+ port = debugPort;
518
+ } else {
519
+ const relay = findRelayExe(projectRoot);
520
+ if (!relay) {
521
+ throw new Error(
522
+ "WSL is in NAT mode; reaching the Windows browser needs cdp-relay.exe. Run " +
523
+ "`npx @brunosps00/dev-workflow setup-wsl-browser` to install the prebuilt relay in your Windows user profile. " +
524
+ "Alternatively enable mirrored networking or set BROWSER_TEST to a CDP URL.",
525
+ );
526
+ }
527
+ const reverseRelay = await startReverseRelayBroker({ relay, debugPort, projectRoot });
528
+ cleanups.unshift(reverseRelay.cleanup);
529
+ hosts = reverseRelay.hosts;
530
+ port = reverseRelay.port;
531
+ sourceSuffix = "+reverse-relay";
532
+ }
533
+
534
+ const found = await waitForCdp(hosts, port);
535
+ if (!found) {
294
536
  throw new Error(
295
- "WSL is in NAT mode; reaching the Windows browser needs cdp-relay.exe. Run " +
296
- "`npx @brunosps00/dev-workflow setup-wsl-browser` (builds the relay on Windows and adds the " +
297
- "Hyper-V firewall rule). Alternatively enable mirrored networking or set BROWSER_TEST to a CDP URL.",
537
+ `Launched ${path.basename(exePath)} but its CDP endpoint was not reachable (${hosts.join("/")}:${port}). ` +
538
+ "Run `npx @brunosps00/dev-workflow setup-wsl-browser` to reinstall the user-level relay, or enable mirrored networking.",
298
539
  );
299
540
  }
300
- const rport = relayPort(projectRoot);
301
- const relayChild = spawn(relay.wslPath, [String(rport), String(debugPort)], { detached: true, stdio: "ignore" });
302
- relayChild.unref();
303
- cleanups.unshift(() => relayChild.kill());
304
- hosts = [windowsHostIp(), "127.0.0.1"].filter(Boolean);
305
- port = rport;
306
- }
307
541
 
308
- const found = await waitForCdp(hosts, port);
309
- if (!found) {
542
+ return { mode: "cdp", endpoint: found.base, source: `launched:${path.basename(exePath)}${sourceSuffix}`, cleanup };
543
+ } catch (error) {
310
544
  await cleanup();
311
- throw new Error(
312
- `Launched ${path.basename(exePath)} but its CDP endpoint was not reachable (${hosts.join("/")}:${port}). ` +
313
- "The Hyper-V firewall rule may be missing — run `npx @brunosps00/dev-workflow setup-wsl-browser`.",
314
- );
545
+ throw error;
315
546
  }
316
-
317
- return { mode: "cdp", endpoint: found.base, source: `launched:${path.basename(exePath)}${mirrored ? "" : "+relay"}`, cleanup };
318
547
  }
319
548
 
320
549
  const noopCleanup = async () => {};
@@ -288,22 +288,22 @@ Resolution order: `BROWSER_TEST` env → `.dw/config.json` (`browserTest`) → d
288
288
  Tries `127.0.0.1` first, then the Windows host (default-route gateway) for NAT.
289
289
  - **`BROWSER_TEST` = Windows exe** (e.g. `/mnt/c/.../msedge.exe`): launches it with a temp profile in
290
290
  remote-debugging mode (`--remote-allow-origins=*`) and connects over CDP. **Works in both WSL2 modes:**
291
- in mirrored networking it connects to `127.0.0.1` directly; in NAT mode it starts the prebuilt
292
- **`cdp-relay.exe`** (a tiny Rust binary, no runtime) on Windows — `0.0.0.0:39222` → `127.0.0.1:<debug>` —
293
- and connects via the Windows host gateway. Falls back to headless Chromium if the relay/rule is absent.
291
+ in mirrored networking it connects to `127.0.0.1` directly; in NAT mode it starts the bundled
292
+ **`cdp-relay.exe`** (a tiny prebuilt Windows x64 binary, no runtime) on Windows in reverse mode.
293
+ The relay opens outbound connections to a WSL broker, which exposes a local CDP proxy. Falls back
294
+ to headless Chromium if the relay is absent.
294
295
  - **`BROWSER_TEST` = channel** (`chrome` | `msedge` | `chromium`): launches that channel locally.
295
296
 
296
- NAT mode needs a one-time setup (the WSL **Hyper-V firewall** defaults inbound to Block, and Chromium
297
- binds its debug port to loopback only):
297
+ NAT mode needs a one-time user-level setup because Chromium binds its debug port to Windows loopback only:
298
298
 
299
299
  ```bash
300
300
  npx @brunosps00/dev-workflow setup-wsl-browser
301
301
  ```
302
302
 
303
- This builds `cdp-relay.exe` with the Windows Rust toolchain (`rustup` on Windows) and adds a Hyper-V
304
- firewall inbound allow rule for TCP 39222 (one UAC prompt). After that, the *real* Windows browser is
305
- driven from WSL — including `page.screencast` video — under NAT. The launched browser and relay use a
306
- throwaway profile and are killed on exit.
303
+ This installs the `cdp-relay.exe` bundled with dev-workflow into `%LOCALAPPDATA%\dev-workflow`.
304
+ No admin prompt, firewall rule, or Rust toolchain is required on the Windows target. After that,
305
+ the *real* Windows browser is driven from WSL — including `page.screencast` video — under NAT.
306
+ The launched browser and relay use a throwaway profile and are killed on exit.
307
307
 
308
308
  Two env vars, two jobs — keep them distinct:
309
309
  - `BROWSER` — opens URLs for a human (used by `/dw-generate-pr`).