Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/logs/
.DS_Store
.env
.env.local
Expand Down
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ npm run dev
This starts electron-vite in watch mode: the main and preload processes rebuild on save, and the renderer hot-reloads via Vite's dev server at `http://localhost:5173`.

> **Note for VS Code / Claude Code users:** VS Code sets `ELECTRON_RUN_AS_NODE=1` in its extension environment, which prevents Electron from initializing its GUI process. The `npm run dev` script automatically unsets this variable, so running via `npm run dev` works correctly. If you invoke electron directly, prefix with `ELECTRON_RUN_AS_NODE=`.
>
> This `VAR=` prefix is POSIX-shell syntax and doesn't work in Windows `cmd.exe`/PowerShell (and `cross-env`, the usual cross-platform fix for this pattern, was tried and didn't resolve it either — worth re-investigating if `dev` needs to run on Windows). It's not an issue for `npm run cli` below: that variable is only ever injected by an editor's *own* integrated terminal, so it's absent in the actual target environment for CLI mode (a Task Scheduler task, a plain terminal window, or any non-editor shell). If you test `npm run cli` from inside VS Code's/Claude Code's integrated terminal on Windows and it misbehaves, open a plain external terminal instead — that's the actual fix, not an env-var prefix.

### Configuration

Expand Down Expand Up @@ -228,6 +230,83 @@ For desktop-app testing, `MacOS_MCP` first:

Both need Accessibility and Screen Recording permissions. Enable one at a time.

### CLI mode (headless, no GUI)

For unattended startup — a Windows Task Scheduler task, a systemd unit, a login-item shortcut — running the full GUI from an open terminal (`npm run dev`) isn't appropriate: the connection dies whenever that terminal closes, and it depends on a signed-in user leaving a window around. CLI mode starts the identical service (tool discovery + Socket.IO connection to tooling) with no `BrowserWindow`, no IPC, no deep-link handling — just line-oriented stdout logging — driven entirely by a config file path:

```bash
# simplest: config.json sitting next to the repo/app itself, no flags needed
npm run cli

# packaged app, explicit path
"Agentic QA - connect.exe" --cli --config C:\path\to\config.json

# from source, explicit path
npm run cli -- --config /path/to/config.json
```

With no `--config` and no `CONNECT_CONFIG_PATH` (below), CLI mode looks for `config.json` next to the current working directory before giving up — this is the common single-machine case (config file checked into or copied alongside the repo) and is what `scripts/windows/start-connect.ps1` relies on implicitly.

> **Windows gotcha:** `npm run cli -- --config <path>` was observed, on a real npm-on-Windows
> install, to silently drop the literal token `--config` from the forwarded args while still
> passing its value through — even past the `--` separator, and not caused by a quoting mistake.
> The symptom is the CLI reporting "No config found" despite a correct-looking command. If that
> happens, set the config path via an environment variable instead — this bypasses npm's argv
> handling entirely and always works:
> ```powershell
> $env:CONNECT_CONFIG_PATH = "C:\path\to\config.json"
> npm run cli
> ```
> `scripts/windows/start-connect.ps1` (the autostart wrapper, below) uses this same env var rather
> than argv for exactly this reason.

It stays running (reconnecting on drops, same as the GUI's Start button) until it receives `SIGINT`/`SIGTERM`, at which point it cleans up sessions and exits. Exits non-zero immediately if the config file is missing or has no `deployment_url`.

The `--config` file is the exact same `AppConfig` JSON the GUI reads/writes (see [Config schema](#config-schema) above) — `deployment_url`, `auth_token`, `servers`, all of it. One additional field is meaningful only here:

- **`installation_id`** (optional) — pins the machine's `connect_app_id` instead of letting one be generated and persisted to a side file next to the config on first run. Needed for scripted/VM-image provisioning: cloning a golden image without this would have every clone race to generate its own random id on first launch, and there's no way to pre-approve a machine's id in workflow's admin panel before it's ever run once. The GUI never sets this field itself, so a config the GUI wrote is unaffected — the fallback (auto-generate-and-persist) is unchanged.

```json
{
"deployment_url": "https://your-tooling-instance.example.com",
"auth_token": "...",
"installation_id": "0123456789abcdef",
"servers": {
"windows_mcp": {
"type": "stdio",
"command": "uv",
"args": ["--directory", "C:\\tools\\Windows-MCP", "run", "windows-mcp", "serve"],
"stateful": true
}
}
}
```

`auth_token` lives in this same file in both modes — treat a CLI config file as a secret (restrictive file ACLs, never checked into a repo or baked verbatim into a shared VM image template).

### Autostart on Windows (Scheduled Task)

Scripts under [`scripts/windows/`](./scripts/windows/) register CLI mode to start automatically at logon, so it survives reboots without anyone opening a terminal:

```powershell
# once, from an elevated PowerShell, as/for the account the VM auto-logs-in as
.\scripts\windows\install-autostart-task.ps1
```

This registers a Scheduled Task (`TestinatorConnectCLI`) with an **At-logon** trigger and **Interactive** logon type — deliberately, not "run whether or not user is logged on" (`ServiceAccount`/S4U). That alternative executes in a non-interactive session with no rendered desktop, which is exactly the Session-0 isolation that breaks `windows-mcp`'s UI Automation: `testinator-connect` spawns `windows-mcp` as a child process over stdio, so it inherits whatever session `testinator-connect` itself runs in. On a headless cloud VM, "logged on" here means the console-attached VNC server + auto-login setup keeps a real desktop session alive continuously — see the windows-mcp VM setup note for that half.

`start-connect.ps1` (what the task actually runs) invokes the built Electron binary directly rather than going through `npm run cli` — see the file's own header comment for why (an npm-on-Windows argv-forwarding quirk). It's a restart loop, not a bare invocation — Task Scheduler only restarts the *task*, not a process that exits inside it, and the connect process exiting (crash, `deployment_url` unreachable at startup, etc.) would otherwise leave the machine silently disconnected. Output goes to `logs/wrapper.log` (the loop itself) and a timestamped `logs/connect-<timestamp>.log` per attempt, both under the repo root (gitignored).

If you use `fnm` (or another Node version manager that adds itself to PATH via a `$PROFILE` hook rather than the system-wide PATH), the script initializes it itself before building — profiles only load for interactive shells, and neither Task Scheduler's `-File` invocation nor a direct manual run of the `.ps1` loads one, so without this `npm`/`node` would be invisible in that process even though they work fine in an ordinary terminal.

To stop it (e.g. before a config change or `git pull`):

```powershell
.\scripts\windows\stop-connect.ps1
```

This kills the wrapper's full process tree (`cmd.exe -> npm -> node -> electron`) — killing only the wrapper's own PID would leave the actual connect process running underneath it, since `Wait-Process` doesn't tie their lifetimes together. Restart it without logging off/on via `Start-ScheduledTask -TaskName TestinatorConnectCLI`, or just log the account back in.

---

## CI/CD — Automated builds
Expand Down
7 changes: 3 additions & 4 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
{
"deployment_url": "http://localhost:8000",
"auth_token": "NOT_USED_NOW",
"timeout": 120,
"ssl_verify": false,
"deployment_url": "http://localhost:3006",
"auth_token": "3d13853daf76e16f5b45b3f761bb9fcfb768b806ab995b90685753a2d8be52ed",
"installation_id": "0123456789abcdef",
"servers": {
"Playwright_MCP": {
"type": "stdio",
Expand Down
19 changes: 19 additions & 0 deletions docs/macos-vm.mcp-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"MacOS_VM": {
"type": "stdio",
"command": "ssh",
"args": [
"-T",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=15",
"lume@VM_IP_ADDRESS",
"/Users/lume/.local/bin/cua-driver",
"mcp"
],
"stateful": true
}
}
227 changes: 227 additions & 0 deletions docs/vm-novpn/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# Keeping the VM off the corporate VPN

Gives the macOS VM its own internet egress on the host's physical network connection,
bypassing a full-tunnel corporate VPN (e.g. GlobalProtect) on the host. Needed when the VM's
traffic must appear to come from the host's real network location rather than the VPN's —
for DNS resolution, geo-restricted content, or anything that behaves differently for a
datacenter/VPN egress than for a normal residential or office one.

## Why this is needed

A full-tunnel VPN on the host routes every packet — including the VM's, since lume's `nat`
networking goes through the host — out through the corporate gateway. Two symptoms follow:

* **No internet in the VM**, because UDP/53 to public DNS resolvers is typically blocked by
corporate policy, so the guest's resolvers time out even though TCP still works over the
tunnel.
* **Anything that behaves differently for a VPN/datacenter IP** does so for the VM too, since
its egress is the same address as the host's tunneled traffic.

This sets the VM up with its own egress on the host's physical uplink instead, while leaving
the host itself on the VPN throughout.

## What does not work

**Bridged networking** (`lume run --network bridged:en0`) needs the
`com.apple.vm.networking` entitlement. A Homebrew-installed `lume` binary is ad-hoc signed
without it, and even with it, a Wi-Fi interface cannot be bridged by Virtualization.framework.

**`pf route-to`.** Do not use `sudo pfctl -f /etc/pf.conf` or `sudo pfctl -d` on a Mac running
a VM with `nat` networking — both flush or disable the runtime pf anchors that *implement*
that NAT, killing the VM's internet outright. `/etc/pf.conf` warns about this in its own
header. Re-enable pf with `sudo pfctl -E` if this happens.

## The constraint that shapes the design

On a Mac running GlobalProtect and/or an EDR agent (e.g. SentinelOne), inbound TCP to the
host's non-loopback addresses can be silently dropped — `accept()` returns, then the first
`recv()` fails with `OSError: [Errno 57] Socket is not connected`. Check for this before
assuming the approach below is necessary:

```
127.0.0.1:PORT -> OK
<bridge/vm-facing> -> FAIL if affected
<LAN address> -> FAIL if affected
```

If affected, the VM cannot connect to a listener on the host's bridge address, so the proxy
below binds to loopback and is reached over an SSH reverse forward instead.

## Architecture

```
VM Chrome ──> 127.0.0.1:1080 (in guest)
│ ssh -R, host-initiated
▼
127.0.0.1:1080 (on host) ── vm_socks.py
│
│ outbound sockets pinned to the physical uplink via IP_BOUND_IF
▼
physical interface ──> real egress (never the VPN's utun interface)
```

`IP_BOUND_IF` is the load-bearing trick: macOS keeps a per-interface scoped default route
(the `I` / `RTF_IFSCOPE` flag), so a socket pinned to the physical interface uses that
interface's own default route and bypasses the VPN tunnel entirely, e.g.:

```
default <vpn-gateway> UGScg utun4
default <physical-gateway> UGScIg en0 <- scoped; what a pinned socket uses
```

`vm_socks.py` also resolves DNS itself, over UDP to the physical gateway from a
pinned socket — not just to work around blocked resolvers, but so DNS-based geo/CDN steering
agrees with the real egress rather than the VPN's.

## Setup

Needs no root on the host and no change to the VPN. Re-run after a host reboot, a VM
restart, or a VPN reconnect — or use the supervisor script below to automate that.

**Prerequisite: an SSH key already trusted in the guest.** Step 2 below uses
`-o BatchMode=yes`, which refuses to fall back to a password prompt — `lume ssh` (used
elsewhere in this doc) authenticates with the `lume`/`lume` password automatically, but a
plain `ssh -R` reverse tunnel needs key-based auth already set up:

```bash
ssh-copy-id -o StrictHostKeyChecking=no lume@192.168.64.2 # password: lume
```

Find your VM's IP (`lume ls`) and physical uplink + gateway before running the commands
below — the values here are examples, not fixed:

```bash
lume ls # ip column
netstat -rn -f inet | awk '$1=="default" && $NF!~/^utun/ {print $NF, $2; exit}' # iface, gateway
```

```bash
cd ~/work/testinator-connect/docs/vm-novpn

# 1. proxy on host loopback, egress pinned to the physical uplink
nohup /usr/bin/python3 vm_socks.py \
--iface en0 --dns <physical-gateway-ip> --listen 127.0.0.1:1080 \
> ~/Library/Logs/lume-vm-novpn.log 2>&1 &

# 2. expose it inside the guest as 127.0.0.1:1080
ssh -N -f -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 \
-R 1080:127.0.0.1:1080 lume@192.168.64.2
```

Step 3 is once-per-VM — `networksetup` persists it inside the guest, so it survives VM
restarts and does not need repeating:

```bash
lume ssh automation-vm -- '
echo lume | sudo -S networksetup -setsocksfirewallproxy Ethernet 127.0.0.1 1080
echo lume | sudo -S networksetup -setsocksfirewallproxystate Ethernet on
echo lume | sudo -S dscacheutil -flushcache
echo lume | sudo -S killall -HUP mDNSResponder'
```

Chrome picks up the system SOCKS setting without a relaunch and does remote DNS through the
proxy, so the guest's own broken resolvers stop mattering.

**Do not** point `--dns` at a public resolver (e.g. `1.1.1.1`) — public resolvers are only
reachable *off* the tunnel, and the proxy's DNS socket is pinned to the physical interface,
so the physical gateway is both correct and faster.

## Verifying

```bash
lume ssh automation-vm -- 'curl -s --socks5-hostname 127.0.0.1:1080 https://ifconfig.me/ip'
# expect the physical uplink's address, NOT the VPN's
```

| check | expected when healthy |
|---|---|
| VM egress | the host's physical-uplink address (not the VPN's) |
| host egress | unchanged — still the VPN's, which is the point |
| `https://www.google.com/` from VM | `200` |

## Teardown

```bash
pkill -f vm_socks.py
pkill -f 'ssh .*-R 1080'
lume ssh automation-vm -- 'echo lume | sudo -S networksetup -setsocksfirewallproxystate Ethernet off'
```

Leaving the guest proxy enabled while the host side is down means the VM has no internet at
all, so turn it off in the guest if you stop the host side for a while.

## Day-to-day: a supervisor for reboots and restarts

[`lume-vm-novpn.sh`](./lume-vm-novpn.sh) automates the two host-side halves — it re-detects
the physical uplink on its own, so it also copes with changing networks. It uses the same
`BatchMode=yes` SSH as Setup above, so it needs the same SSH key already trusted in the
guest. Install it once:

```bash
mkdir -p ~/.local/bin
cp ~/work/testinator-connect/docs/vm-novpn/{lume-vm-novpn.sh,vm_socks.py} ~/.local/bin/
chmod +x ~/.local/bin/lume-vm-novpn.sh
```

Edit the `VM_IP` (and, if the VM's name differs, the `lume ssh` line in
`warn_if_guest_proxy_off`) near the top of the script to match your VM before using it. Then:

```bash
~/.local/bin/lume-vm-novpn.sh once # start whatever is down
~/.local/bin/lume-vm-novpn.sh status # proxy/tunnel state + both egress IPs
~/.local/bin/lume-vm-novpn.sh stop # tear the host side down
```

It never needs the guest password: the guest's `networksetup` proxy setting persists inside
the VM, so the script only maintains the two host-side halves and warns if the guest side
got switched off.

**What survives what:**

| event | proxy | ssh -R tunnel | guest proxy setting |
|---|---|---|---|
| VM restart | survives | dies | survives |
| host reboot | dies | dies | survives |
| VPN reconnect | survives | survives | survives |

So a VM restart needs only the tunnel restarted, and `once` handles either case.

### Optional: run the supervisor at login

Not installed by default — a `LaunchAgents` entry is a login-persistence mechanism, install
it deliberately if you want it. Save as `~/Library/LaunchAgents/com.local.lume-vm-novpn.plist`,
substituting your own home directory for `/Users/YOUR_USERNAME`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.local.lume-vm-novpn</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOUR_USERNAME/.local/bin/lume-vm-novpn.sh</string>
<string>supervise</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>StandardOutPath</key><string>/Users/YOUR_USERNAME/Library/Logs/lume-vm-novpn.log</string>
<key>StandardErrorPath</key><string>/Users/YOUR_USERNAME/Library/Logs/lume-vm-novpn.log</string>
</dict>
</plist>
```

```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.lume-vm-novpn.plist
launchctl bootout gui/$(id -u)/com.local.lume-vm-novpn # to remove
tail -f ~/Library/Logs/lume-vm-novpn.log # what it is doing
```

In `supervise` mode it polls every 30s, so it picks the tunnel back up on its own within half
a minute of a VM restart.

The VM itself still has to be started by hand after a host reboot (`lume run automation-vm`) — it
is a plain foreground process, not a launchd service.
Loading
Loading