superpowers-mcp 6.2.2 → 6.2.4

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env pwsh
2
2
  # Start the brainstorm server and output connection info.
3
- # Usage: ./start-server.ps1 [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
3
+ # Usage: ./start-server.ps1 [--project-dir <path>] [--host <loopback-host>] [--url-host <loopback-host>] [--foreground] [--background]
4
4
 
5
5
  $ErrorActionPreference = "Stop"
6
6
 
@@ -29,11 +29,15 @@ function Set-ProcessEnvironment {
29
29
 
30
30
  function Protect-PathForCurrentUser {
31
31
  param([string]$Path)
32
- if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
33
- return
34
- }
32
+ $isWindowsPlatform = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT
35
33
  try {
36
34
  $item = Get-Item -LiteralPath $Path -ErrorAction Stop
35
+ if (-not $isWindowsPlatform) {
36
+ $mode = if ($item.PSIsContainer) { "700" } else { "600" }
37
+ & chmod $mode $item.FullName
38
+ if ($LASTEXITCODE -ne 0) { throw "chmod failed" }
39
+ return
40
+ }
37
41
  $acl = Get-Acl -LiteralPath $item.FullName
38
42
  $acl.SetAccessRuleProtection($true, $false)
39
43
  $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
@@ -112,6 +116,28 @@ if ($urlHost -eq "") {
112
116
  }
113
117
  }
114
118
 
119
+ function Test-LoopbackHost {
120
+ param([string]$HostValue)
121
+ $normalized = $HostValue.Trim().TrimStart('[').TrimEnd(']').ToLowerInvariant()
122
+ if ($normalized -eq "localhost" -or $normalized -eq "::1" -or $normalized.StartsWith("127.")) {
123
+ return $true
124
+ }
125
+ $address = $null
126
+ if ([System.Net.IPAddress]::TryParse($normalized, [ref]$address)) {
127
+ return [System.Net.IPAddress]::IsLoopback($address)
128
+ }
129
+ return $false
130
+ }
131
+
132
+ if (-not (Test-LoopbackHost $bindHost)) {
133
+ Write-JsonError "Refusing insecure non-loopback HTTP bind; use a TLS reverse proxy or tunnel to 127.0.0.1"
134
+ exit 1
135
+ }
136
+ if (-not (Test-LoopbackHost $urlHost)) {
137
+ Write-JsonError "--url-host must be a loopback hostname or address"
138
+ exit 1
139
+ }
140
+
115
141
  if ($idleTimeoutMinutes -ne "") {
116
142
  $parsedIdle = 0
117
143
  if ((-not [int]::TryParse($idleTimeoutMinutes, [ref]$parsedIdle)) -or $parsedIdle -lt 1) {
@@ -130,10 +156,17 @@ $brainstormRoot = ""
130
156
  if ($projectDir -ne "") {
131
157
  $brainstormRoot = Join-Path $projectDir ".superpowers/brainstorm"
132
158
  $sessionDir = Join-Path $brainstormRoot $sessionId
159
+ # Reuse the last bound port and session key so a restart keeps an
160
+ # already-open browser tab connected to the same URL with a valid cookie.
133
161
  $env:BRAINSTORM_PORT_FILE = Join-Path $brainstormRoot ".last-port"
134
162
  $env:BRAINSTORM_TOKEN_FILE = Join-Path $brainstormRoot ".last-token"
135
163
  } else {
136
164
  $sessionDir = Join-Path ([System.IO.Path]::GetTempPath()) "brainstorm-$sessionId"
165
+ # $env: assignments persist in the invoking pwsh session; a stale project
166
+ # token/port file from an earlier --project-dir run must not leak into an
167
+ # ephemeral session (it would defeat key rotation and could overwrite the
168
+ # project's .last-token).
169
+ Remove-Item Env:BRAINSTORM_TOKEN_FILE, Env:BRAINSTORM_PORT_FILE -ErrorAction SilentlyContinue
137
170
  }
138
171
 
139
172
  $stateDir = Join-Path $sessionDir "state"
@@ -151,17 +184,71 @@ Protect-PathForCurrentUser -Path $contentDir
151
184
  Protect-PathForCurrentUser -Path $stateDir
152
185
 
153
186
  $serverId = New-ServerId
154
- Set-Content -Path $serverIdFile -Value $serverId -NoNewline -Encoding utf8
155
- Protect-PathForCurrentUser -Path $serverIdFile
156
187
 
188
+ function Read-ExpectedServerId {
189
+ if (-not (Test-Path -LiteralPath $serverIdFile)) { return $null }
190
+ $id = (Get-Content -LiteralPath $serverIdFile -Raw -ErrorAction SilentlyContinue).Trim()
191
+ if ($id -match '^[A-Za-z0-9_-]{32,64}$') { return $id }
192
+ return $null
193
+ }
194
+
195
+ function Test-BrainstormServer {
196
+ param([int]$ProcessId)
197
+ $expected = Read-ExpectedServerId
198
+ if (-not $expected) { return $false }
199
+ # $IsWindows is undefined on Windows PowerShell 5.1; fall back to the
200
+ # OSVersion check so the Windows branch is taken there too.
201
+ $isWinPlatform = ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
202
+ if ($isWinPlatform) {
203
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
204
+ if (-not $process) { return $false }
205
+ return ($process.CommandLine -like "*--brainstorm-server-id=$expected*")
206
+ }
207
+ # Unix: ps -p prints the full command line; the id is validated
208
+ # hex above, so no regex escaping is needed.
209
+ $cmd = (& ps -p $ProcessId -o command= 2>$null)
210
+ if (-not $cmd) { return $false }
211
+ return ($cmd -match "--brainstorm-server-id=$expected")
212
+ }
213
+
214
+ function Get-ProcessStartToken {
215
+ param([int]$ProcessId)
216
+ $isWinPlatform = ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
217
+ if ($isWinPlatform) {
218
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
219
+ if (-not $process) { return $null }
220
+ return [string]$process.CreationDate
221
+ }
222
+ $start = (& ps -p $ProcessId -o lstart= 2>$null)
223
+ if (-not $start) { return $null }
224
+ return (($start | Out-String).Trim())
225
+ }
226
+
227
+ # Kill any existing server — only after proving the PID and its process start
228
+ # identity are ours. The final identity re-check narrows the PID-reuse window.
229
+ # Must run BEFORE the new server-instance-id below overwrites serverIdFile.
157
230
  if (Test-Path -LiteralPath $pidFile) {
158
- $oldPid = (Get-Content -LiteralPath $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
159
- if ($oldPid) {
160
- Stop-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue
231
+ $oldPidText = (Get-Content -LiteralPath $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
232
+ $oldPid = 0
233
+ $oldStartToken = $null
234
+ if ([int]::TryParse($oldPidText, [ref]$oldPid) -and (Test-BrainstormServer -ProcessId $oldPid)) {
235
+ $oldStartToken = Get-ProcessStartToken -ProcessId $oldPid
236
+ }
237
+ if ($oldStartToken -and (Test-BrainstormServer -ProcessId $oldPid) -and
238
+ ((Get-ProcessStartToken -ProcessId $oldPid) -eq $oldStartToken)) {
239
+ Stop-Process -Id $oldPid -ErrorAction SilentlyContinue
240
+ } else {
241
+ Write-Warning "stale server.pid ignored: PID is not a running brainstorm server"
161
242
  }
162
243
  Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
163
244
  }
164
245
 
246
+ # WriteAllText: UTF-8 without BOM on every PowerShell version (PS 5.1's
247
+ # Set-Content -Encoding utf8 emits a BOM, which breaks the bash-side
248
+ # read_expected_server_id regex when a session dir is shared across shells).
249
+ [System.IO.File]::WriteAllText($serverIdFile, $serverId)
250
+ Protect-PathForCurrentUser -Path $serverIdFile
251
+
165
252
  $envValues = @{
166
253
  BRAINSTORM_DIR = $sessionDir
167
254
  BRAINSTORM_HOST = $bindHost
@@ -199,6 +286,9 @@ if ($foreground) {
199
286
 
200
287
  Set-ProcessEnvironment -Values $envValues
201
288
  $errFile = Join-Path $stateDir "server.err"
289
+ New-Item -ItemType File -Force -Path $logFile, $errFile | Out-Null
290
+ Protect-PathForCurrentUser -Path $logFile
291
+ Protect-PathForCurrentUser -Path $errFile
202
292
  $process = Start-Process `
203
293
  -FilePath "node" `
204
294
  -ArgumentList @("server.cjs", "--brainstorm-server-id=$serverId") `
@@ -8,9 +8,9 @@
8
8
  # Options:
9
9
  # --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
10
10
  # instead of /tmp. Files persist after server stops.
11
- # --host <bind-host> Host/interface to bind (default: 127.0.0.1).
12
- # Use 0.0.0.0 in remote/containerized environments.
13
- # --url-host <host> Hostname shown in returned URL JSON.
11
+ # --host <bind-host> Loopback host/interface to bind (default: 127.0.0.1).
12
+ # Use an SSH tunnel or TLS reverse proxy for remote access.
13
+ # --url-host <host> Loopback hostname shown in returned URL JSON.
14
14
  # --idle-timeout-minutes <n> Shut down after n minutes idle (default 240 = 4h).
15
15
  # --open Auto-open the browser on the first screen (use only
16
16
  # after the user approves the visual companion).
@@ -71,6 +71,26 @@ if [[ -z "$URL_HOST" ]]; then
71
71
  fi
72
72
  fi
73
73
 
74
+ # Resolve a relative --project-dir against the caller's cwd up front. Later
75
+ # steps cd into SCRIPT_DIR, and a relative session path would then resolve
76
+ # against the wrong directory (or the server would inherit a relative
77
+ # BRAINSTORM_DIR it can't locate).
78
+ if [[ -n "$PROJECT_DIR" && "$PROJECT_DIR" != /* ]]; then
79
+ requested_project_dir="$PROJECT_DIR"
80
+ if [[ -d "$requested_project_dir" ]]; then
81
+ resolved_project_dir="$(cd "$requested_project_dir" 2>/dev/null && pwd -P || true)"
82
+ if [[ -n "$resolved_project_dir" ]]; then
83
+ PROJECT_DIR="$resolved_project_dir"
84
+ else
85
+ # cd failed (e.g. permission) — fall back to a lexical join against the
86
+ # caller's cwd instead of silently dropping the requested project dir.
87
+ PROJECT_DIR="$(pwd -P 2>/dev/null || pwd)/${requested_project_dir#./}"
88
+ fi
89
+ else
90
+ PROJECT_DIR="$(pwd -P 2>/dev/null || pwd)/$requested_project_dir"
91
+ fi
92
+ fi
93
+
74
94
  if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then
75
95
  if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then
76
96
  echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}"
@@ -106,8 +126,8 @@ if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
106
126
  fi
107
127
  fi
108
128
 
109
- # Session files (server.log, server-info, .last-token) embed the session key —
110
- # keep everything this script and the server create owner-only.
129
+ # Session files (server.log and server-info) embed the session key — keep
130
+ # everything this script and the server create owner-only.
111
131
  umask 077
112
132
 
113
133
  # Generate unique session directory
@@ -115,8 +135,8 @@ SESSION_ID="$$-$(date +%s)"
115
135
 
116
136
  if [[ -n "$PROJECT_DIR" ]]; then
117
137
  SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
118
- # Persist the bound port and key per project so a restart reuses them and an
119
- # already-open browser tab reconnects to the same URL with a valid cookie.
138
+ # Reuse the last bound port and session key so a restart keeps an
139
+ # already-open browser tab connected to the same URL with a valid cookie.
120
140
  export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port"
121
141
  export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token"
122
142
  else
@@ -129,7 +149,83 @@ LOG_FILE="${STATE_DIR}/server.log"
129
149
  SERVER_ID_FILE="${STATE_DIR}/server-instance-id"
130
150
 
131
151
  # Create fresh session directory with content and state peers
132
- mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"
152
+ if ! mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"; then
153
+ echo '{"error": "Failed to create brainstorm session directory"}'
154
+ exit 1
155
+ fi
156
+
157
+ # --- Process identity verification (shared with stop-server.sh) -----------
158
+ # A stale server.pid may point at an unrelated process after a reboot or PID
159
+ # wraparound. Before signalling a PID, prove it is actually a brainstorm
160
+ # server belonging to this session via the per-start server-instance-id.
161
+
162
+ read_expected_server_id() {
163
+ [[ -f "$SERVER_ID_FILE" ]] || return 1
164
+ local id
165
+ id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)"
166
+ [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1
167
+ printf '%s\n' "$id"
168
+ }
169
+
170
+ command_has_server_id() {
171
+ local pid="$1"
172
+ local expected="$2"
173
+ local expected_arg="--brainstorm-server-id=$expected"
174
+ if [[ -r "/proc/$pid/cmdline" ]]; then
175
+ local arg
176
+ while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do
177
+ [[ "$arg" == "$expected_arg" ]] && return 0
178
+ done < "/proc/$pid/cmdline"
179
+ return 1
180
+ fi
181
+ local command_line
182
+ command_line="$(ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true)"
183
+ [[ -n "$command_line" ]] || return 1
184
+ case " $command_line " in
185
+ *" $expected_arg "*) return 0 ;;
186
+ *) return 1 ;;
187
+ esac
188
+ }
189
+
190
+ is_brainstorm_server() {
191
+ kill -0 "$1" 2>/dev/null || return 1
192
+ local expected_id
193
+ expected_id="$(read_expected_server_id)" || return 1
194
+ command_has_server_id "$1" "$expected_id" || return 1
195
+ return 0
196
+ }
197
+
198
+ process_start_token() {
199
+ local pid="$1"
200
+ if [[ -r "/proc/$pid/stat" ]]; then
201
+ local stat_line
202
+ stat_line="$(cat "/proc/$pid/stat" 2>/dev/null || true)"
203
+ # After the comm field, /proc/<pid>/stat field 20 is the process start
204
+ # time (the original field 22), measured in clock ticks since boot.
205
+ [[ -n "$stat_line" ]] || return 1
206
+ printf '%s\n' "${stat_line##*) }" | awk '{print $20}'
207
+ return 0
208
+ fi
209
+ ps -ww -p "$pid" -o lstart= 2>/dev/null | sed 's/^ *//' || true
210
+ }
211
+
212
+ # Kill any existing server — only after proving the PID and its process start
213
+ # identity are ours. The final identity re-check narrows the PID-reuse window.
214
+ # This must run before the new server-instance-id overwrites SERVER_ID_FILE.
215
+ if [[ -f "$PID_FILE" ]]; then
216
+ old_pid=$(cat "$PID_FILE")
217
+ old_start_token=""
218
+ if [[ "$old_pid" =~ ^[0-9]+$ ]] && is_brainstorm_server "$old_pid"; then
219
+ old_start_token="$(process_start_token "$old_pid")"
220
+ fi
221
+ if [[ -n "$old_start_token" ]] && is_brainstorm_server "$old_pid" &&
222
+ [[ "$(process_start_token "$old_pid")" == "$old_start_token" ]]; then
223
+ kill "$old_pid" 2>/dev/null
224
+ else
225
+ echo '{"warn": "stale server.pid ignored: PID is not a running brainstorm server"}' >&2
226
+ fi
227
+ rm -f "$PID_FILE"
228
+ fi
133
229
 
134
230
  SERVER_ID=""
135
231
  if [[ -r /dev/urandom ]]; then
@@ -141,13 +237,6 @@ fi
141
237
  printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE"
142
238
  chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true
143
239
 
144
- # Kill any existing server
145
- if [[ -f "$PID_FILE" ]]; then
146
- old_pid=$(cat "$PID_FILE")
147
- kill "$old_pid" 2>/dev/null
148
- rm -f "$PID_FILE"
149
- fi
150
-
151
240
  cd "$SCRIPT_DIR" || exit 1
152
241
 
153
242
  # Resolve the harness PID (grandparent of this script).
@@ -34,7 +34,10 @@ function Test-BrainstormServer {
34
34
  param([int]$ProcessId)
35
35
  $expected = Read-ExpectedServerId
36
36
  if (-not $expected) { return $false }
37
- if ($IsWindows) {
37
+ # $IsWindows is undefined on Windows PowerShell 5.1; fall back to the
38
+ # OSVersion check so the Windows branch is taken there too.
39
+ $isWinPlatform = ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
40
+ if ($isWinPlatform) {
38
41
  $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
39
42
  if (-not $process) { return $false }
40
43
  return ($process.CommandLine -like "*--brainstorm-server-id=$expected*")
@@ -47,10 +50,38 @@ function Test-BrainstormServer {
47
50
  return ($cmd -match "--brainstorm-server-id=$expected")
48
51
  }
49
52
 
53
+ function Get-ProcessStartToken {
54
+ param([int]$ProcessId)
55
+ $isWinPlatform = ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
56
+ if ($isWinPlatform) {
57
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
58
+ if (-not $process) { return $null }
59
+ return [string]$process.CreationDate
60
+ }
61
+ $start = (& ps -p $ProcessId -o lstart= 2>$null)
62
+ if (-not $start) { return $null }
63
+ return (($start | Out-String).Trim())
64
+ }
65
+
50
66
  if (Test-Path -LiteralPath $pidFile) {
51
67
  $pidText = (Get-Content -LiteralPath $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
52
68
  $serverPid = 0
53
- if ((-not [int]::TryParse($pidText, [ref]$serverPid)) -or -not (Test-BrainstormServer -ProcessId $serverPid)) {
69
+ $startToken = $null
70
+ if ([int]::TryParse($pidText, [ref]$serverPid) -and (Test-BrainstormServer -ProcessId $serverPid)) {
71
+ $startToken = Get-ProcessStartToken -ProcessId $serverPid
72
+ }
73
+ if (-not $startToken -or -not (Test-BrainstormServer -ProcessId $serverPid) -or
74
+ ((Get-ProcessStartToken -ProcessId $serverPid) -ne $startToken)) {
75
+ Remove-Item -LiteralPath $pidFile, $serverIdFile -Force -ErrorAction SilentlyContinue
76
+ Mark-Stopped "stale_pid"
77
+ [pscustomobject]@{ status = "stale_pid" } | ConvertTo-Json -Compress
78
+ exit 0
79
+ }
80
+
81
+ # Re-check identity immediately before signalling to narrow the PID-reuse
82
+ # window as much as shell-level process management permits.
83
+ if (-not (Test-BrainstormServer -ProcessId $serverPid) -or
84
+ ((Get-ProcessStartToken -ProcessId $serverPid) -ne $startToken)) {
54
85
  Remove-Item -LiteralPath $pidFile, $serverIdFile -Force -ErrorAction SilentlyContinue
55
86
  Mark-Stopped "stale_pid"
56
87
  [pscustomobject]@{ status = "stale_pid" } | ConvertTo-Json -Compress
@@ -70,12 +70,38 @@ is_brainstorm_server() {
70
70
  return 0
71
71
  }
72
72
 
73
+ process_start_token() {
74
+ local pid="$1"
75
+ if [[ -r "/proc/$pid/stat" ]]; then
76
+ local stat_line
77
+ stat_line="$(cat "/proc/$pid/stat" 2>/dev/null || true)"
78
+ [[ -n "$stat_line" ]] || return 1
79
+ printf '%s\n' "${stat_line##*) }" | awk '{print $20}'
80
+ return 0
81
+ fi
82
+ ps -ww -p "$pid" -o lstart= 2>/dev/null | sed 's/^ *//' || true
83
+ }
84
+
73
85
  if [[ -f "$PID_FILE" ]]; then
74
86
  pid=$(cat "$PID_FILE")
75
87
 
76
88
  # Refuse to signal a PID we can't prove is our server. A stale pid file may
77
89
  # point at an unrelated process after a reboot/PID wraparound.
78
- if ! is_brainstorm_server "$pid"; then
90
+ start_token=""
91
+ if is_brainstorm_server "$pid"; then
92
+ start_token="$(process_start_token "$pid")"
93
+ fi
94
+ if [[ -z "$start_token" ]] || ! is_brainstorm_server "$pid" ||
95
+ [[ "$(process_start_token "$pid")" != "$start_token" ]]; then
96
+ rm -f "$PID_FILE" "$SERVER_ID_FILE"
97
+ mark_stopped "stale_pid"
98
+ echo '{"status": "stale_pid"}'
99
+ exit 0
100
+ fi
101
+
102
+ # Re-check identity immediately before signalling to narrow the PID-reuse
103
+ # window as much as shell-level process management permits.
104
+ if ! is_brainstorm_server "$pid" || [[ "$(process_start_token "$pid")" != "$start_token" ]]; then
79
105
  rm -f "$PID_FILE" "$SERVER_ID_FILE"
80
106
  mark_stopped "stale_pid"
81
107
  echo '{"status": "stale_pid"}'
@@ -109,9 +135,15 @@ if [[ -f "$PID_FILE" ]]; then
109
135
  rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log"
110
136
  mark_stopped "stop-server.sh"
111
137
 
112
- # Only delete ephemeral /tmp directories
113
- if [[ "$SESSION_DIR" == /tmp/* ]]; then
114
- rm -rf "$SESSION_DIR"
138
+ # Only delete ephemeral /tmp directories. Resolve both paths canonically
139
+ # (cd + pwd -P, like stop-server.ps1) so a session dir such as
140
+ # "/tmp/../home/user/project" can't trick the prefix check into deleting
141
+ # a directory outside the temp root. (Plain variables: this code runs at
142
+ # top level, where `local` is a syntax error on bash 3.2 / macOS.)
143
+ tmp_root="$(cd /tmp 2>/dev/null && pwd -P || true)"
144
+ resolved="$(cd "$SESSION_DIR" 2>/dev/null && pwd -P || true)"
145
+ if [[ -n "$tmp_root" && -n "$resolved" && "$resolved" == "$tmp_root/"* ]]; then
146
+ rm -rf "$resolved"
115
147
  fi
116
148
 
117
149
  echo '{"status": "stopped"}'
@@ -28,7 +28,7 @@ A question *about* a UI topic is not automatically a visual question. "What kind
28
28
 
29
29
  The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn.
30
30
 
31
- **Content fragments vs full documents:** If your HTML file starts with `<!DOCTYPE` or `<html`, the server serves it as-is (just injects the helper script). Otherwise, the server automatically wraps your content in the frame template — adding the header, CSS theme, connection status, and all interactive infrastructure. **Write content fragments by default.** Only write full documents when you need complete control over the page.
31
+ **Content fragments vs full documents:** If your HTML file starts with `<!DOCTYPE` or `<html`, the server serves it as-is (just injects the helper script). Otherwise, the server automatically wraps your content in the frame template — adding the header, CSS theme, connection status, and all interactive infrastructure. **Write content fragments by default.** Only write full documents when you need complete control over the page. Screen scripts are blocked by the server's nonce CSP; use `data-choice` elements and the injected helper instead of inline scripts or event handlers.
32
32
 
33
33
  ## Starting a Session
34
34
 
@@ -49,12 +49,15 @@ scripts/start-server.ps1 --project-dir C:\path\to\project --open
49
49
  Save `screen_dir` and `state_dir` from the response. With `--open`, the browser opens itself when you push the first screen — you don't need to ask the user to open it, but still share the URL as a fallback (headless/remote setups won't auto-open).
50
50
 
51
51
  **The URL contains a session key (`?key=…`).** The server rejects any request
52
- without it, so always give the user the **complete** URL from the `url` field —
53
- never strip the query string, and never hand out a bare `http://host:port`. The
54
- key gates HTTP and WebSocket access so a stray browser tab or another machine on
55
- the network can't read the screens or inject events. After the first load the
56
- browser remembers the key via a cookie, so reloads and `/files/*` assets work
57
- without repeating it.
52
+ without it, so always give the user the **complete** URL from the `url` field for
53
+ the first load — never strip the query string. The key gates HTTP access, then
54
+ moves into an `HttpOnly`/`SameSite=Strict` cookie; the browser's same-origin
55
+ WebSocket automatically sends that cookie. The key is never stored in
56
+ page-readable storage. With `--project-dir`, the key is persisted to
57
+ `.superpowers/brainstorm/.last-token` and reused across restarts, so an
58
+ already-open tab stays connected; delete that file (with the server stopped)
59
+ to force a fresh key. Without it, a new server invocation rotates the key and
60
+ a restarted server requires the new URL.
58
61
 
59
62
  **Finding connection info:** The server writes its startup JSON to `$STATE_DIR/server-info`. If you launched the server in the background and didn't capture stdout, read that file to get the URL and port. When using `--project-dir`, check `<project>/.superpowers/brainstorm/` for the session directory.
60
63
 
@@ -96,21 +99,15 @@ scripts/start-server.sh --project-dir /path/to/project --open --foreground
96
99
 
97
100
  **Other environments:** The server must keep running in the background across conversation turns. If your environment reaps detached processes, use `--foreground` and launch the command with your platform's background execution mechanism.
98
101
 
99
- If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host:
100
-
101
- ```bash
102
- scripts/start-server.sh \
103
- --project-dir /path/to/project \
104
- --host 0.0.0.0 \
105
- --url-host localhost
106
- ```
107
-
108
- Use `--url-host` to control what hostname is printed in the returned URL JSON.
102
+ The server only permits loopback HTTP binds. For a remote browser, keep the
103
+ server on `127.0.0.1` and use an authenticated SSH tunnel; do not expose the
104
+ companion's plain HTTP port directly to a network interface.
105
+ The `--url-host` value must also be a loopback hostname or address.
109
106
 
110
107
  ## The Loop
111
108
 
112
109
  1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`:
113
- - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it reuses the same port, so the user's open tab reconnects on its own (it shows a "paused" overlay while the server is down) and you don't need to send a new URL. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`).
110
+ - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it reuses the same port and session key (from `.last-token`), so an already-open tab keeps working; without `--project-dir` the key rotates, so share the new URL from `server-info` with the user. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`).
114
111
  - Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html`
115
112
  - **Never reuse filenames** — each screen gets a fresh file
116
113
  - Use your file-creation tool — **never use cat/heredoc** (dumps noise into terminal)
@@ -152,14 +149,14 @@ Write just the content that goes inside the page. The server wraps it in the fra
152
149
  <p class="subtitle">Consider readability and visual hierarchy</p>
153
150
 
154
151
  <div class="options">
155
- <div class="option" data-choice="a" onclick="toggleSelect(this)">
152
+ <div class="option" data-choice="a">
156
153
  <div class="letter">A</div>
157
154
  <div class="content">
158
155
  <h3>Single Column</h3>
159
156
  <p>Clean, focused reading experience</p>
160
157
  </div>
161
158
  </div>
162
- <div class="option" data-choice="b" onclick="toggleSelect(this)">
159
+ <div class="option" data-choice="b">
163
160
  <div class="letter">B</div>
164
161
  <div class="content">
165
162
  <h3>Two Column</h3>
@@ -179,7 +176,7 @@ The frame template provides these CSS classes for your content:
179
176
 
180
177
  ```html
181
178
  <div class="options">
182
- <div class="option" data-choice="a" onclick="toggleSelect(this)">
179
+ <div class="option" data-choice="a">
183
180
  <div class="letter">A</div>
184
181
  <div class="content">
185
182
  <h3>Title</h3>
@@ -201,7 +198,7 @@ The frame template provides these CSS classes for your content:
201
198
 
202
199
  ```html
203
200
  <div class="cards">
204
- <div class="card" data-choice="design1" onclick="toggleSelect(this)">
201
+ <div class="card" data-choice="design1">
205
202
  <div class="card-image"><!-- mockup content --></div>
206
203
  <div class="card-body">
207
204
  <h3>Name</h3>