feat: choose an available agent Web port from 3080 to 3100

This commit is contained in:
OpenAI Coding Assistant
2026-09-06 13:59:32 -05:00
parent 14285a5ee5
commit a58dd24efe
9 changed files with 213 additions and 31 deletions
+30 -9
View File
@@ -4,6 +4,7 @@ if [[ ${1:-} == --help ]]; then
echo 'Usage: nix run .#agent -- [run | ssh [command ...] | url | stop]'
echo 'Workspace = cwd. RW config/credentials/skills = DSH_HOME (default ~/.dsh)'
echo 'Also shares DSH_AGENTS_HOME/skills (default ~/.agents/skills). RAM/CPU/network: flake.'
echo "Web UI tries $AGENT_WEB_PORT-$AGENT_WEB_PORT_END in order; url prints the selected port."
exit 0
fi
[[ $EUID != 0 ]] || { echo 'Run as your normal host user, not sudo/root.' >&2; exit 1; }
@@ -26,13 +27,18 @@ ssh_cmd=(ssh -F /dev/null -i "$state/client-key" -p "$AGENT_SSH_PORT"
-o StrictHostKeyChecking=yes -o HostKeyAlias=agent-vm -o ConnectTimeout=3
-o "UserKnownHostsFile=$state/known_hosts" -o GlobalKnownHostsFile=/dev/null)
remote="root@$AGENT_SSH_HOST"
# Relative ControlPath avoids Unix-socket path limits with long state directories.
web_control() { (cd "$state" && "${ssh_cmd[@]}" -S web.sock "$@" "$remote"); }
url() {
local found address=$AGENT_WEB_BIND
local found port address=$AGENT_WEB_BIND
[[ -f $state/web-port ]] && read -r port < "$state/web-port" || return 1
[[ $port =~ ^[1-9][0-9]{0,4}$ ]] && (( port <= 65535 )) || return 1
web_control -O check >/dev/null 2>&1 || return 1
[[ $address != 0.0.0.0 ]] || address=127.0.0.1
found=$("${ssh_cmd[@]}" "$remote" 'journalctl -u agent -b -o cat --no-pager' |
grep -oE 'http://127\.0\.0\.1:3080/\?token=[a-zA-Z0-9_%.-]+' | tail -1) || return 1
[[ -n $found ]] || return 1
printf '%s\n' "${found/http:\/\/127.0.0.1:3080/http:\/\/$address:$AGENT_WEB_PORT}"
printf '%s\n' "${found/http:\/\/127.0.0.1:3080/http:\/\/$address:$port}"
}
# Expand cwd inside the guest, not on the host.
# shellcheck disable=SC2016
@@ -52,6 +58,9 @@ for dir in "$state" "$dsh"; do
done
exec 9>"$state/run.lock"
flock -n 9 || { echo 'This project VM is already running.' >&2; exit 1; }
# Clean up only this project's stale forwarding state, after acquiring its lock.
web_control -O exit >/dev/null 2>&1 || true
rm -f "$state/web.sock" "$state/web-port"
for key in client-key ssh-host-key; do
[[ -f $state/$key ]] || ssh-keygen -q -t ed25519 -N '' -C agent-vm -f "$state/$key"
done
@@ -63,15 +72,15 @@ if [[ $AGENT_WEB_BIND != 127.0.0.1 ]]; then
echo 'WARNING: off-host Web access is plaintext HTTP. Use a VPN/TLS; never expose directly to the Internet.' >&2
fi
vm_pid=''
tunnel_pid=''
cleanup() {
trap - EXIT INT TERM
web_control -O exit >/dev/null 2>&1 || true
rm -f "$state/web-port" "$state/web.sock"
if [[ -n $vm_pid ]] && kill -0 "$vm_pid" 2>/dev/null; then
(cd "$state"; timeout 30 "$AGENT_RUNNER/microvm-shutdown") >/dev/null 2>&1 || true
kill "$vm_pid" 2>/dev/null || true
wait "$vm_pid" 2>/dev/null || true
fi
if [[ -n $tunnel_pid ]]; then kill "$tunnel_pid" 2>/dev/null || true; wait "$tunnel_pid" 2>/dev/null || true; fi
}
trap cleanup EXIT
trap 'exit 130' INT
@@ -99,16 +108,28 @@ done
$ready || { echo "SSH boot timeout; see $state/console.log" >&2; exit 1; }
# DSH deliberately refuses --host 0.0.0.0. Keep its own authenticated browser
# endpoint on guest loopback and publish an SSH forward on the chosen host IP.
"${ssh_cmd[@]}" -N -g -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 \
-o ServerAliveCountMax=3 -L "$AGENT_WEB_BIND:$AGENT_WEB_PORT:127.0.0.1:3080" \
"$remote" >>"$state/console.log" 2>&1 &
tunnel_pid=$!
web_control -M -fN -g -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 \
-o ServerAliveCountMax=3 >>"$state/console.log" 2>&1 || {
echo "Cannot start Web SSH tunnel; see $state/console.log" >&2; exit 1;
}
# Ask SSH to actually bind each port: no probe-then-bind race or extra port helper.
for ((port=AGENT_WEB_PORT; port<=AGENT_WEB_PORT_END; port++)); do
if web_control -O forward -L "$AGENT_WEB_BIND:$port:127.0.0.1:3080" >>"$state/console.log" 2>&1; then
printf '%s\n' "$port" > "$state/web-port"
break
fi
done
[[ -f $state/web-port ]] || {
echo "No available Web UI port on $AGENT_WEB_BIND in $AGENT_WEB_PORT-$AGENT_WEB_PORT_END; see $state/console.log" >&2
exit 1
}
echo "Web UI selected host port $port."
echo "Booted. DSH resolves npm @latest on startup; first launch may take a few minutes."
echo 'Use another terminal: nix run .#agent -- url (or: ssh / stop)'
printed=false
while kill -0 "$vm_pid" 2>/dev/null; do
# Normal guest poweroff can close SSH slightly before QEMU exits.
if ! kill -0 "$tunnel_pid" 2>/dev/null; then
if ! web_control -O check >/dev/null 2>&1; then
timeout 30 tail --pid="$vm_pid" -f /dev/null || true
if kill -0 "$vm_pid" 2>/dev/null; then echo "Web tunnel exited; see $state/console.log" >&2; exit 1; fi
break
+1
View File
@@ -35,6 +35,7 @@ let
AGENT_NETWORK = net.mode;
AGENT_WEB_BIND = net.hostAddress;
AGENT_WEB_PORT = toString net.webPort;
AGENT_WEB_PORT_END = toString net.webPortEnd;
AGENT_SSH_HOST = if net.mode == "user" then "127.0.0.1" else net.guestAddress;
AGENT_SSH_PORT = toString (if net.mode == "user" then net.sshPort else 22);
};
+16 -6
View File
@@ -71,12 +71,18 @@ in
webPort = mkOption {
type = types.port;
default = 3080;
description = "Host Web UI port; the guest DSH listener stays on 127.0.0.1:3080.";
description = "First host Web UI port to try; the guest listener stays on 127.0.0.1:3080.";
};
webPortEnd = mkOption {
type = types.port;
# Preserve fixed-port behavior for existing nondefault webPort settings.
default = if net.webPort == 3080 then 3100 else net.webPort;
description = "Last host Web UI port to try, inclusive. Set equal to webPort for a fixed port.";
};
trustedHosts = mkOption {
type = types.listOf (types.strMatching "[a-zA-Z0-9.:-]+");
default = [ ];
description = "Additional exact browser authorities for DSH's Host/Origin protection. Required for wildcard publication.";
description = "Additional host[:port] entries for DSH's Host/Origin protection. A port-less host matches any port. Required for wildcard publication.";
};
tapName = mkOption {
type = types.strMatching "[a-zA-Z0-9_-]{1,15}";
@@ -119,8 +125,12 @@ in
message = "agentVM TAP mode requires network.guestAddress, gateway and dns.";
}
{
assertion = net.webPort >= 1024 && net.sshPort >= 1024 && net.webPort != net.sshPort;
message = "Rootless Web/SSH listeners need distinct unprivileged ports (>=1024).";
assertion =
net.webPort >= 1024
&& net.webPortEnd >= net.webPort
&& net.sshPort >= 1024
&& (net.sshPort < net.webPort || net.sshPort > net.webPortEnd);
message = "Use an ordered, unprivileged Web port range and an unprivileged SSH port outside that range.";
}
{
assertion = net.hostAddress != "0.0.0.0" || net.trustedHosts != [ ];
@@ -369,12 +379,12 @@ in
])
(
net.trustedHosts
++ lib.optional (
++ lib.optionals (
!builtins.elem net.hostAddress [
"127.0.0.1"
"0.0.0.0"
]
) "${net.hostAddress}:${toString net.webPort}"
) (map (port: "${net.hostAddress}:${toString port}") (lib.range net.webPort net.webPortEnd))
)
);
Restart = "on-failure";
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Real SSH forwards in an offline VM; listeners belong only to this test.
set -euo pipefail
launcher=$1
tmp=$(mktemp -d)
export HOME=$tmp/home DSH_HOME=$tmp/dsh DSH_AGENTS_HOME=$tmp/agents XDG_STATE_HOME=$tmp/state
mkdir -p "$HOME" "$tmp/project"
cd "$tmp/project"
state="$XDG_STATE_HOME/agent-vm/$(printf %s "$PWD" | sha256sum | cut -c1-16)"
vm_pid=''
listener=''
cleanup() {
status=$?
if (( status )); then
grep -h . "$tmp/launcher.log" "$tmp/listeners.log" "$state/console.log" | tail -100 || true
fi
"$launcher" stop >/dev/null 2>&1 || true
if [[ -n $vm_pid ]]; then kill "$vm_pid" 2>/dev/null || true; wait "$vm_pid" 2>/dev/null || true; fi
if [[ -n $listener ]]; then kill "$listener" 2>/dev/null || true; wait "$listener" 2>/dev/null || true; fi
rm -rf "$tmp"
return "$status"
}
trap cleanup EXIT
reserve_through() {
rm -f "$tmp/listeners-ready"
node - "$1" "$tmp/listeners-ready" > "$tmp/listeners.log" 2>&1 <<'JS' &
const http = require('node:http');
const fs = require('node:fs');
const listeners = [];
for (let port = 3080; port <= Number(process.argv[2]); port++) {
listeners.push(new Promise((resolve, reject) => {
http.createServer((req, res) => res.end('occupied\n'))
.on('error', reject).listen(port, '127.0.0.1', resolve);
}));
}
Promise.all(listeners).then(() => fs.writeFileSync(process.argv[3], 'ready'));
JS
listener=$!
for ((i=0; i<100; i++)); do
[[ ! -f $tmp/listeners-ready ]] || return 0
kill -0 "$listener"
sleep 0.1
done
echo 'Port fixture startup timed out' >&2; return 1
}
release_listeners() { kill "$listener"; wait "$listener" || true; listener=''; }
expect_port() {
local expected=$1 login=''
"$launcher" run > "$tmp/launcher.log" 2>&1 &
vm_pid=$!
for ((i=0; i<120; i++)); do
kill -0 "$vm_pid"
if login=$("$launcher" url 2>/dev/null); then break; fi
sleep 1
done
[[ $login == "http://127.0.0.1:$expected/?token=offline-test" ]]
[[ $(< "$state/web-port") == "$expected" ]]
[[ $(curl --fail --silent --max-time 5 "$login") == agent-vm-test ]]
if [[ -n $listener ]]; then
[[ $(curl --fail --silent --max-time 5 http://127.0.0.1:3080/) == occupied ]]
fi
"$launcher" stop
wait "$vm_pid"
vm_pid=''
[[ ! -e $state/web-port && ! -e $state/web.sock ]]
if "$launcher" url >/dev/null 2>&1; then echo 'Stale URL after shutdown' >&2; return 1; fi
}
# Pick the first hole, then exercise the inclusive upper endpoint.
reserve_through 3082
expect_port 3083
release_listeners
reserve_through 3099
expect_port 3100
release_listeners
# Exhaustion must fail, shut down its VM, and not leave a remembered URL.
reserve_through 3100
if "$launcher" run > "$tmp/launcher.log" 2>&1; then echo 'Accepted a full port range' >&2; exit 1; fi
grep -Fq 'No available Web UI port on 127.0.0.1 in 3080-3100' "$tmp/launcher.log"
[[ ! -e $state/web-port && ! -e $state/web.sock ]]
if "$launcher" ssh true >/dev/null 2>&1; then echo 'VM survived failed launch' >&2; exit 1; fi
release_listeners
# A new run starts searching at 3080, not at the previously selected port.
expect_port 3080
echo 'PASS: first available port, inclusive 3100 endpoint, full-range failure, real HTTP forwarding, URL persistence and cleanup, restart from 3080'
+45 -3
View File
@@ -16,8 +16,10 @@ let
# Offline infrastructure test. A real DSH startup is tested separately;
# @latest needs the network and is intentionally outside Nix reproducibility.
agentVM.package = pkgs.writeShellScriptBin "dsh" ''
echo 'http://127.0.0.1:3080/?token=offline-test'
exec ${pkgs.coreutils}/bin/sleep infinity
exec ${pkgs.nodejs}/bin/node -e '
require("node:http").createServer((req, res) => res.end("agent-vm-test"))
.listen(3080, "127.0.0.1", () => console.log("http://127.0.0.1:3080/?token=offline-test"));
'
'';
}
];
@@ -35,15 +37,38 @@ let
guestAddress = "192.168.77.2";
gateway = "192.168.77.1";
dns = [ "192.168.77.1" ];
hostAddress = "192.168.77.1";
webPort = 3090;
webPortEnd = 3092;
};
}
];
};
fixed = inputs.self.lib.mkAgentVM {
system = pkgs.stdenv.hostPlatform.system;
project.packages = [ ];
modules = [ { agentVM.network.webPort = 8080; } ];
};
in
{
config =
assert c.microvm.mem == 4096;
assert c.microvm.vcpu == 4;
assert c.agentVM.network.webPort == 3080;
assert c.agentVM.network.webPortEnd == 3100;
assert fixed.nixos.config.agentVM.network.webPortEnd == 8080;
assert builtins.all
(
port:
pkgs.lib.hasInfix "192.168.77.1:${toString port}" tap.nixos.config.systemd.services.agent.serviceConfig.ExecStart
)
[
3090
3091
3092
];
assert
!(pkgs.lib.hasInfix "192.168.77.1:3093" tap.nixos.config.systemd.services.agent.serviceConfig.ExecStart);
assert builtins.length c.microvm.shares == 3;
assert builtins.all (s: !s.readOnly && s.securityModel == "none") c.microvm.shares;
assert c.microvm.storeOnDisk;
@@ -74,7 +99,7 @@ in
];
}
''
shellcheck -s bash ${./launch.sh} ${./boot-test.sh} ${./playwright-test.sh}
shellcheck -s bash ${./launch.sh} ${./boot-test.sh} ${./playwright-test.sh} ${./port-test.sh}
bash -n ${./launch.sh}
touch "$out"
'';
@@ -95,6 +120,23 @@ in
bash ${./playwright-test.sh}
'';
ports =
pkgs.runCommand "agent-vm-port-check"
{
requiredSystemFeatures = [ "kvm" ];
nativeBuildInputs = [
pkgs.bash
pkgs.coreutils
pkgs.gnugrep
pkgs.nodejs
pkgs.curl
];
}
''
bash ${./port-test.sh} ${testVM.package}/bin/agent-vm
touch "$out"
'';
boot =
pkgs.runCommand "agent-vm-boot-check"
{