superpowers-mcp 6.2.1 → 6.2.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.
@@ -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) {
@@ -131,7 +157,6 @@ if ($projectDir -ne "") {
131
157
  $brainstormRoot = Join-Path $projectDir ".superpowers/brainstorm"
132
158
  $sessionDir = Join-Path $brainstormRoot $sessionId
133
159
  $env:BRAINSTORM_PORT_FILE = Join-Path $brainstormRoot ".last-port"
134
- $env:BRAINSTORM_TOKEN_FILE = Join-Path $brainstormRoot ".last-token"
135
160
  } else {
136
161
  $sessionDir = Join-Path ([System.IO.Path]::GetTempPath()) "brainstorm-$sessionId"
137
162
  }
@@ -151,24 +176,77 @@ Protect-PathForCurrentUser -Path $contentDir
151
176
  Protect-PathForCurrentUser -Path $stateDir
152
177
 
153
178
  $serverId = New-ServerId
154
- Set-Content -Path $serverIdFile -Value $serverId -NoNewline -Encoding utf8
155
- Protect-PathForCurrentUser -Path $serverIdFile
156
179
 
180
+ function Read-ExpectedServerId {
181
+ if (-not (Test-Path -LiteralPath $serverIdFile)) { return $null }
182
+ $id = (Get-Content -LiteralPath $serverIdFile -Raw -ErrorAction SilentlyContinue).Trim()
183
+ if ($id -match '^[A-Za-z0-9_-]{32,64}$') { return $id }
184
+ return $null
185
+ }
186
+
187
+ function Test-BrainstormServer {
188
+ param([int]$ProcessId)
189
+ $expected = Read-ExpectedServerId
190
+ if (-not $expected) { return $false }
191
+ # $IsWindows is undefined on Windows PowerShell 5.1; fall back to the
192
+ # OSVersion check so the Windows branch is taken there too.
193
+ $isWinPlatform = ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
194
+ if ($isWinPlatform) {
195
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
196
+ if (-not $process) { return $false }
197
+ return ($process.CommandLine -like "*--brainstorm-server-id=$expected*")
198
+ }
199
+ # Unix: ps -p prints the full command line; the id is validated
200
+ # hex above, so no regex escaping is needed.
201
+ $cmd = (& ps -p $ProcessId -o command= 2>$null)
202
+ if (-not $cmd) { return $false }
203
+ return ($cmd -match "--brainstorm-server-id=$expected")
204
+ }
205
+
206
+ function Get-ProcessStartToken {
207
+ param([int]$ProcessId)
208
+ $isWinPlatform = ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
209
+ if ($isWinPlatform) {
210
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
211
+ if (-not $process) { return $null }
212
+ return [string]$process.CreationDate
213
+ }
214
+ $start = (& ps -p $ProcessId -o lstart= 2>$null)
215
+ if (-not $start) { return $null }
216
+ return (($start | Out-String).Trim())
217
+ }
218
+
219
+ # Kill any existing server — only after proving the PID and its process start
220
+ # identity are ours. The final identity re-check narrows the PID-reuse window.
221
+ # Must run BEFORE the new server-instance-id below overwrites serverIdFile.
157
222
  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
223
+ $oldPidText = (Get-Content -LiteralPath $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
224
+ $oldPid = 0
225
+ $oldStartToken = $null
226
+ if ([int]::TryParse($oldPidText, [ref]$oldPid) -and (Test-BrainstormServer -ProcessId $oldPid)) {
227
+ $oldStartToken = Get-ProcessStartToken -ProcessId $oldPid
228
+ }
229
+ if ($oldStartToken -and (Test-BrainstormServer -ProcessId $oldPid) -and
230
+ ((Get-ProcessStartToken -ProcessId $oldPid) -eq $oldStartToken)) {
231
+ Stop-Process -Id $oldPid -ErrorAction SilentlyContinue
232
+ } else {
233
+ Write-Warning "stale server.pid ignored: PID is not a running brainstorm server"
161
234
  }
162
235
  Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
163
236
  }
164
237
 
238
+ # WriteAllText: UTF-8 without BOM on every PowerShell version (PS 5.1's
239
+ # Set-Content -Encoding utf8 emits a BOM, which breaks the bash-side
240
+ # read_expected_server_id regex when a session dir is shared across shells).
241
+ [System.IO.File]::WriteAllText($serverIdFile, $serverId)
242
+ Protect-PathForCurrentUser -Path $serverIdFile
243
+
165
244
  $envValues = @{
166
245
  BRAINSTORM_DIR = $sessionDir
167
246
  BRAINSTORM_HOST = $bindHost
168
247
  BRAINSTORM_URL_HOST = $urlHost
169
248
  BRAINSTORM_OWNER_PID = ""
170
249
  BRAINSTORM_PORT_FILE = $env:BRAINSTORM_PORT_FILE
171
- BRAINSTORM_TOKEN_FILE = $env:BRAINSTORM_TOKEN_FILE
172
250
  BRAINSTORM_IDLE_TIMEOUT_MS = $env:BRAINSTORM_IDLE_TIMEOUT_MS
173
251
  BRAINSTORM_OPEN = $env:BRAINSTORM_OPEN
174
252
  }
@@ -199,6 +277,9 @@ if ($foreground) {
199
277
 
200
278
  Set-ProcessEnvironment -Values $envValues
201
279
  $errFile = Join-Path $stateDir "server.err"
280
+ New-Item -ItemType File -Force -Path $logFile, $errFile | Out-Null
281
+ Protect-PathForCurrentUser -Path $logFile
282
+ Protect-PathForCurrentUser -Path $errFile
202
283
  $process = Start-Process `
203
284
  -FilePath "node" `
204
285
  -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,10 +135,9 @@ 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
+ # Reusing a port is safe because the server rotates its authentication key
139
+ # for every logical session.
120
140
  export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port"
121
- export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token"
122
141
  else
123
142
  SESSION_DIR="/tmp/brainstorm-${SESSION_ID}"
124
143
  fi
@@ -129,7 +148,83 @@ LOG_FILE="${STATE_DIR}/server.log"
129
148
  SERVER_ID_FILE="${STATE_DIR}/server-instance-id"
130
149
 
131
150
  # Create fresh session directory with content and state peers
132
- mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"
151
+ if ! mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"; then
152
+ echo '{"error": "Failed to create brainstorm session directory"}'
153
+ exit 1
154
+ fi
155
+
156
+ # --- Process identity verification (shared with stop-server.sh) -----------
157
+ # A stale server.pid may point at an unrelated process after a reboot or PID
158
+ # wraparound. Before signalling a PID, prove it is actually a brainstorm
159
+ # server belonging to this session via the per-start server-instance-id.
160
+
161
+ read_expected_server_id() {
162
+ [[ -f "$SERVER_ID_FILE" ]] || return 1
163
+ local id
164
+ id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)"
165
+ [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1
166
+ printf '%s\n' "$id"
167
+ }
168
+
169
+ command_has_server_id() {
170
+ local pid="$1"
171
+ local expected="$2"
172
+ local expected_arg="--brainstorm-server-id=$expected"
173
+ if [[ -r "/proc/$pid/cmdline" ]]; then
174
+ local arg
175
+ while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do
176
+ [[ "$arg" == "$expected_arg" ]] && return 0
177
+ done < "/proc/$pid/cmdline"
178
+ return 1
179
+ fi
180
+ local command_line
181
+ command_line="$(ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true)"
182
+ [[ -n "$command_line" ]] || return 1
183
+ case " $command_line " in
184
+ *" $expected_arg "*) return 0 ;;
185
+ *) return 1 ;;
186
+ esac
187
+ }
188
+
189
+ is_brainstorm_server() {
190
+ kill -0 "$1" 2>/dev/null || return 1
191
+ local expected_id
192
+ expected_id="$(read_expected_server_id)" || return 1
193
+ command_has_server_id "$1" "$expected_id" || return 1
194
+ return 0
195
+ }
196
+
197
+ process_start_token() {
198
+ local pid="$1"
199
+ if [[ -r "/proc/$pid/stat" ]]; then
200
+ local stat_line
201
+ stat_line="$(cat "/proc/$pid/stat" 2>/dev/null || true)"
202
+ # After the comm field, /proc/<pid>/stat field 20 is the process start
203
+ # time (the original field 22), measured in clock ticks since boot.
204
+ [[ -n "$stat_line" ]] || return 1
205
+ printf '%s\n' "${stat_line##*) }" | awk '{print $20}'
206
+ return 0
207
+ fi
208
+ ps -ww -p "$pid" -o lstart= 2>/dev/null | sed 's/^ *//' || true
209
+ }
210
+
211
+ # Kill any existing server — only after proving the PID and its process start
212
+ # identity are ours. The final identity re-check narrows the PID-reuse window.
213
+ # This must run before the new server-instance-id overwrites SERVER_ID_FILE.
214
+ if [[ -f "$PID_FILE" ]]; then
215
+ old_pid=$(cat "$PID_FILE")
216
+ old_start_token=""
217
+ if [[ "$old_pid" =~ ^[0-9]+$ ]] && is_brainstorm_server "$old_pid"; then
218
+ old_start_token="$(process_start_token "$old_pid")"
219
+ fi
220
+ if [[ -n "$old_start_token" ]] && is_brainstorm_server "$old_pid" &&
221
+ [[ "$(process_start_token "$old_pid")" == "$old_start_token" ]]; then
222
+ kill "$old_pid" 2>/dev/null
223
+ else
224
+ echo '{"warn": "stale server.pid ignored: PID is not a running brainstorm server"}' >&2
225
+ fi
226
+ rm -f "$PID_FILE"
227
+ fi
133
228
 
134
229
  SERVER_ID=""
135
230
  if [[ -r /dev/urandom ]]; then
@@ -141,13 +236,6 @@ fi
141
236
  printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE"
142
237
  chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true
143
238
 
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
239
  cd "$SCRIPT_DIR" || exit 1
152
240
 
153
241
  # 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,12 @@ 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. A new server invocation rotates the key, so a restarted
57
+ server requires the new URL.
58
58
 
59
59
  **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
60
 
@@ -96,21 +96,15 @@ scripts/start-server.sh --project-dir /path/to/project --open --foreground
96
96
 
97
97
  **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
98
 
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.
99
+ The server only permits loopback HTTP binds. For a remote browser, keep the
100
+ server on `127.0.0.1` and use an authenticated SSH tunnel; do not expose the
101
+ companion's plain HTTP port directly to a network interface.
102
+ The `--url-host` value must also be a loopback hostname or address.
109
103
 
110
104
  ## The Loop
111
105
 
112
106
  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`).
107
+ - **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 may reuse the same port, but the authentication 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
108
  - Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html`
115
109
  - **Never reuse filenames** — each screen gets a fresh file
116
110
  - Use your file-creation tool — **never use cat/heredoc** (dumps noise into terminal)
@@ -152,14 +146,14 @@ Write just the content that goes inside the page. The server wraps it in the fra
152
146
  <p class="subtitle">Consider readability and visual hierarchy</p>
153
147
 
154
148
  <div class="options">
155
- <div class="option" data-choice="a" onclick="toggleSelect(this)">
149
+ <div class="option" data-choice="a">
156
150
  <div class="letter">A</div>
157
151
  <div class="content">
158
152
  <h3>Single Column</h3>
159
153
  <p>Clean, focused reading experience</p>
160
154
  </div>
161
155
  </div>
162
- <div class="option" data-choice="b" onclick="toggleSelect(this)">
156
+ <div class="option" data-choice="b">
163
157
  <div class="letter">B</div>
164
158
  <div class="content">
165
159
  <h3>Two Column</h3>
@@ -179,7 +173,7 @@ The frame template provides these CSS classes for your content:
179
173
 
180
174
  ```html
181
175
  <div class="options">
182
- <div class="option" data-choice="a" onclick="toggleSelect(this)">
176
+ <div class="option" data-choice="a">
183
177
  <div class="letter">A</div>
184
178
  <div class="content">
185
179
  <h3>Title</h3>
@@ -201,7 +195,7 @@ The frame template provides these CSS classes for your content:
201
195
 
202
196
  ```html
203
197
  <div class="cards">
204
- <div class="card" data-choice="design1" onclick="toggleSelect(this)">
198
+ <div class="card" data-choice="design1">
205
199
  <div class="card-image"><!-- mockup content --></div>
206
200
  <div class="card-body">
207
201
  <h3>Name</h3>