diff --git a/.changes/planning/fix-view-bridge-hardening-6109a1c4.md b/.changes/planning/fix-view-bridge-hardening-6109a1c4.md new file mode 100644 index 00000000000..545a395aec6 --- /dev/null +++ b/.changes/planning/fix-view-bridge-hardening-6109a1c4.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- Harden the session bridge: the watcher exits after 40 consecutive empty waits (exit 4) so an orphaned watcher no longer keeps the server alive, the view bridge gates the wait and lease-release endpoints with a watcher-only token that the page never receives, and U+2028 and U+2029 are escaped in output lines. The README now states the single-user-workstation limit. diff --git a/.changes/review/fix-view-bridge-hardening-6109b2d5.md b/.changes/review/fix-view-bridge-hardening-6109b2d5.md new file mode 100644 index 00000000000..720cadedef2 --- /dev/null +++ b/.changes/review/fix-view-bridge-hardening-6109b2d5.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- Harden the view bridge: the watcher exits after 40 consecutive empty waits (exit 4) so an orphaned watcher no longer keeps the server alive, the wait and lease-release endpoints need a watcher-only token that the page never receives, and U+2028 and U+2029 are escaped in output lines. diff --git a/.changes/work-items/fix-view-bridge-hardening-6109c3e6.md b/.changes/work-items/fix-view-bridge-hardening-6109c3e6.md new file mode 100644 index 00000000000..f0a02d08c33 --- /dev/null +++ b/.changes/work-items/fix-view-bridge-hardening-6109c3e6.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- Harden the triage board's view bridge: the watcher exits after 40 consecutive empty waits (exit 4) so an orphaned watcher no longer keeps the server alive, the wait and lease-release endpoints need a watcher-only token that the page never receives, and U+2028 and U+2029 are escaped in output lines. diff --git a/lib/session-bridge/README.md b/lib/session-bridge/README.md index 70490f7bb43..85e764744fd 100644 --- a/lib/session-bridge/README.md +++ b/lib/session-bridge/README.md @@ -46,7 +46,10 @@ subclass sets `max_body` and `page_csp` and adds routes in `route_get(url, query sent, must match. - A per-run token from `secrets.token_urlsafe(32)` rides only in the `X--Token` header; every POST and `/api/wait` needs it. Every POST must be `application/json`, 1 to `max_body` bytes, a - JSON object. `watch.sh` hands the header to curl on stdin (`-H @-`), so the token is in no + JSON object. An app may set a separate `watch_token` on its hub: it then alone gates `/api/wait` and + the lease release, is written to the env file as `WATCH_TOKEN` and to the session json as + `watchToken`, and is never served to the page (without one, the page token gates them too). + `watch.sh` hands the header to curl on stdin (`-H @-`), so the token is in no process's argv, which other local users can read on Linux without `hidepid` and on macOS. - On Windows the listener refuses address reuse and binds with `SO_EXCLUSIVEADDRUSE`, so no other local process can bind the same port and receive requests that carry the token. @@ -73,8 +76,16 @@ name for `ensure-running`, `stop` and `lease [--release]`. On events it prints o `dataDir` and `next` (the re-arm command, `bash '/wake.sh' ''`), stores the seq in `.watch-seq` and the replayed seq in `.watch-replay`, and exits 0. It exits 2 when curl, the conf or the env file is missing, the token is rejected, or the server stays unreachable, and 3 when -another watcher holds the lease, its lease was released, or the server was stopped. The watcher id -is `WATCH_ID`, else `CLAUDE_CODE_SESSION_ID`, else `-`. +another watcher holds the lease, its lease was released, or the server was stopped. It exits 4 after +`WAIT_EMPTY` consecutive waits with no event (default 40, about an hour at the default 90 s wait), +printing the re-arm command: a watcher orphaned by a crashed session ends, and the server expires. +A live session re-arms it. There is no parent-pid check, since the parent pid is 1 for a Claude Code +Bash shell on Windows. The watcher id is `WATCH_ID`, else `CLAUDE_CODE_SESSION_ID`, else +`-`. It sends `WATCH_TOKEN` from the env file, else `TOKEN`. + +Output lines (the `/api/wait` body and the channel server's messages) are `json.dumps` with +`ensure_ascii=False`, then U+2028 and U+2029 are escaped (`dumps_line`), so no reader splits a line +on them. ## The client half @@ -102,9 +113,14 @@ carries: an action a page asks for passes the same confirm or permission gate it prints `{url, origin, page, watch}`. 2. The adopter builds its page with `--connect ` into `page`. The builder adds `connect-src ` to the page's policy and nothing else (rendered-views rule 3). -3. The reader opens `url`. The runtime fetches the token from `GET /api/token`, which answers only - a request with `Sec-Fetch-Site: same-origin` and a matching `Host` and `Origin`. The token never - enters the page's markup, so a saved or published copy holds none. +3. The reader opens `url`. The runtime fetches the page token from `GET /api/token`, which answers + only a request with `Sec-Fetch-Site: same-origin` and a matching `Host` and `Origin`. The token + never enters the page's markup, so a saved or published copy holds none. Those checks stop a + browser page from another origin; they do not stop a non-browser local client such as curl, which + can send the same headers, so any local process can fetch the page token. The bridge assumes a + single-user workstation: another OS user on the same machine can reach the loopback port. The + page token opens `POST /api/action` only. `/api/wait` and the lease release need the watcher + token, which lives in the 0600 session env file and is never served by `/api/token`. 4. `POST /api/action` takes `{action, picked, choices, notes}`: `action` and every choice are builder keys (`^[a-z0-9-]{1,32}$`), `picked` holds builder row ids, and `notes` holds the reader's text, at most 20 entries of 4000 characters. Any other field or shape is a 400. @@ -122,7 +138,8 @@ clears the watcher lease. The server also ends on its own once no watcher wait has been in flight for `IDLE_SECONDS` (600, the lease timeout; `--idle-seconds` overrides it), and clears its session files. The watcher is a background task of the session that armed it, so a session that ends stops polling and its token -stops working about 600 seconds after its last wait. A session that leaves the watcher down that long +stops working about 600 seconds after its last wait. (A watcher orphaned by a crash is not that +case: it exits itself after `WAIT_EMPTY` empty waits, see The watcher.) A session that leaves the watcher down that long loses the server too: `watch` then exits 2 asking for `ensure-running`, which starts a server with a new token on the same port, and the reader reloads the page. @@ -132,7 +149,7 @@ new token on the same port, and the reader reloads the page. |---|---| | The page sends only reader input and builder ids | `view-runtime.js` sends picked row ids, option keys and textarea text; `view_bridge.py` refuses anything else | | Page fields reach the session as data, never as the user's message | `DATA_NOTE` on every wait answer, delivered as background-task output (Delivery as data) | -| An unguessable per-session token authenticates every message | `secrets.token_urlsafe(32)` per server run, required on every POST and wait, handed only to same-origin page script, gone when the server stops; the server stops by itself about 600 s after the session's watcher last waited, so the token expires with the session | +| An unguessable per-session token authenticates every message | `secrets.token_urlsafe(32)` per server run, required on every POST (the page token) and wait (the separate watcher token, never served to the page), handed only to same-origin page script (a browser-only check: a local process can fetch it), gone when the server stops; the server stops by itself about 600 s after the session's watcher last waited, so the token expires with the session | | No message triggers a gated action without its gate | The bridge and the view app store and deliver; nothing in either acts on an event | ## The channels adapter diff --git a/lib/session-bridge/session_bridge.py b/lib/session-bridge/session_bridge.py index b7bc293aefc..30ce27b9952 100644 --- a/lib/session-bridge/session_bridge.py +++ b/lib/session-bridge/session_bridge.py @@ -89,6 +89,19 @@ def token_header(name): return f"X-{name[:1].upper()}{name[1:]}-Token" +def dumps_line(value): + """json.dumps for a line a reader splits on line separators: U+2028 and U+2029 stay escaped. + + ensure_ascii=False keeps other non-ASCII text readable; those two are valid raw in JSON + (RFC 8259 section 7) but end a line in some readers. + """ + return ( + json.dumps(value, ensure_ascii=False) + .replace("
", "\\u2028") + .replace("
", "\\u2029") + ) + + def replace_into(tmp, path): """os.replace with retry: Windows refuses while a reader holds the target open.""" for _ in range(20): @@ -171,6 +184,9 @@ def __init__(self, port, data_dir): self.url = f"http://127.0.0.1:{port}/" self.dir = Path(data_dir).resolve() self.token = secrets.token_urlsafe(32) + # Gates /api/wait and the lease release. None means the page token also gates them; an + # app whose page is untrusted script sets its own, which is never served to the page. + self._watch_token = None # Differs on every start; the page tells a restart by it. self.instance = secrets.token_hex(6) self.cond = threading.Condition() @@ -183,6 +199,14 @@ def __init__(self, port, data_dir): # The one watcher allowed: {watcher, since, last, inflight, pid}. In memory, so a restart frees it. self.lease = None + @property + def watch_token(self): + return self._watch_token or self.token + + @watch_token.setter + def watch_token(self, value): + self._watch_token = value + @staticmethod def lease_live(lease, now, timeout): """A lease holds while its watcher has a wait in flight or its last wait ended within timeout.""" @@ -397,7 +421,7 @@ def send(self, code, body, ctype="application/json", csp=None): raw = ( body if isinstance(body, bytes) - else json.dumps(body, ensure_ascii=False).encode("utf-8") + else dumps_line(body).encode("utf-8") ) if code >= 400: # An unread request body must not become the next request. @@ -443,6 +467,11 @@ def token_ok(self): given = self.headers.get(token_header(self.hub.name)) or "" return secrets.compare_digest(given, self.hub.token) + def watch_token_ok(self): + """The watcher's token, which gates /api/wait and the lease release.""" + given = self.headers.get(token_header(self.hub.name)) or "" + return secrets.compare_digest(given, self.hub.watch_token) + def do_GET(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) @@ -471,7 +500,7 @@ def route_get(self, url, query): self.send(404, {"error": "not found"}) def long_poll(self, query): - if not self.token_ok(): + if not self.watch_token_ok(): return self.send(403, {"error": "token required"}) try: after = (query.get("after") or ["0"])[0] @@ -561,7 +590,8 @@ def do_POST(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) url = urlparse(self.path) - if not self.token_ok(): + gate = self.watch_token_ok if url.path == "/api/lease" else self.token_ok + if not gate(): return self.send(403, {"error": "token required"}) if ( not self.headers.get("Content-Type", "") @@ -647,12 +677,14 @@ def start(handler, make_hub, port, nonce=""): "nonce": nonce, "startedAt": now_iso(), } + env = f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\n" + # Only an app's own watcher token is written; without one, readers fall back to the page token. + if hub._watch_token: + session["watchToken"] = hub._watch_token + env += f"WATCH_TOKEN={hub._watch_token}\n" json_name, env_name = session_files(hub.name) write_private(hub.dir / json_name, json.dumps(session, indent=2) + "\n") - write_private( - hub.dir / env_name, - f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\nNONCE={nonce}\n", - ) + write_private(hub.dir / env_name, f"{env}NONCE={nonce}\n") return httpd, hub @@ -830,7 +862,7 @@ def release_lease(s, name): body=json.dumps({"action": "release"}), headers={ "Content-Type": "application/json", - token_header(name): s["token"], + token_header(name): s.get("watchToken") or s["token"], }, ) resp = conn.getresponse() @@ -1240,11 +1272,11 @@ def __init__(self, data_dir, name, control_cmd, ring, watcher): self.last_deliver = 0.0 def session(self): - """(port, token) from the data dir's session env file, or None.""" + """(port, watcher token) from the data dir's session env file, or None.""" try: text = (self.dir / session_files(self.name)[1]).read_text(encoding="utf-8") env = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) - return int(env["PORT"]), env["TOKEN"] + return int(env["PORT"]), env.get("WATCH_TOKEN") or env["TOKEN"] except (OSError, KeyError, ValueError): return None @@ -1292,7 +1324,7 @@ def release(self): s = self.session() if s is not None: try: - release_lease({"port": s[0], "token": s[1]}, self.name) + release_lease({"port": s[0], "watchToken": s[1]}, self.name) except (OSError, http.client.HTTPException): pass @@ -1473,7 +1505,7 @@ def __init__(self, here, out): def send(self, msg): with self.lock: - self.out.write(json.dumps(msg, ensure_ascii=False) + "\n") + self.out.write(dumps_line(msg) + "\n") self.out.flush() def ring(self, relay, text, **meta): @@ -1523,7 +1555,7 @@ def tool_watch(self, args): return f"watching {d}: a channel event announces new page events; read them with the events tool" def tool_events(self, args): - return json.dumps(self.relay_for(args).take(), ensure_ascii=False) + return dumps_line(self.relay_for(args).take()) def tool_unwatch(self, args): relay = self.relay_for(args) diff --git a/lib/session-bridge/test_session_bridge.py b/lib/session-bridge/test_session_bridge.py index ca054b515b7..8b114b7c4b7 100644 --- a/lib/session-bridge/test_session_bridge.py +++ b/lib/session-bridge/test_session_bridge.py @@ -181,6 +181,34 @@ def test_names_follow_the_app_name(self): self.assertEqual(sb.token_header("interview"), "X-Interview-Token") +class TestLineSeparators(BridgeCase): + def test_a_wait_answer_escapes_u2028_and_u2029_but_keeps_other_unicode(self): + text = "a
b
c é" + self.hub.append(text) + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=TIMEOUT) + try: + conn.request( + "GET", + "/api/wait?after=handled&timeout=1", + headers={self.header: self.hub.token}, + ) + raw = conn.getresponse().read().decode("utf-8") + finally: + conn.close() + self.assertNotIn("
", raw) + self.assertNotIn("
", raw) + self.assertIn("\\u2028", raw) + self.assertIn("\\u2029", raw) + self.assertIn("é", raw) + self.assertEqual(json.loads(raw)["events"][0]["text"], text) + + def test_dumps_line_round_trips(self): + value = {"t": "x
y
z"} + line = sb.dumps_line(value) + self.assertEqual(len(line.splitlines()), 1) + self.assertEqual(json.loads(line), value) + + class TestSessionFiles(BridgeCase): def test_session_files_record_port_pid_and_token(self): s = json.loads((self.dir / ".toy-session.json").read_text(encoding="utf-8")) @@ -472,6 +500,21 @@ def test_a_held_lease_exits_3_naming_the_holder(self): self.assertIn("holds this toy's lease: session other,", r.stderr) t.join(TIMEOUT) + def test_an_orphaned_watcher_exits_4_after_n_empty_waits(self): + sb.set_wait_timeout(self.dir, "toy", 1) + r = self.run_script("watch.sh", str(self.dir), env={"WAIT_EMPTY": "2"}) + self.assertEqual(r.returncode, 4, r.stderr) + self.assertEqual(r.stdout, "") + self.assertIn("2 consecutive waits", r.stderr) + self.assertIn(f"bash '{self.bin}/wake.sh'", r.stderr.replace("\\", "/")) + + def test_an_event_before_the_limit_is_delivered(self): + sb.set_wait_timeout(self.dir, "toy", 1) + threading.Timer(0.3, self.hub.append, ["late"]).start() + r = self.run_script("watch.sh", str(self.dir), env={"WAIT_EMPTY": "5"}) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(r.stdout)["events"][0]["text"], "late") + def test_no_env_file_names_the_control_script(self): r = self.run_script("watch.sh", str(self.tmp)) self.assertEqual(r.returncode, 2) @@ -1063,6 +1106,15 @@ def test_a_hostile_holder_never_reaches_the_ring(self): self.assertNotIn(piece, dumped) self.assertNotIn(piece, stderr) + def test_a_message_with_u2028_and_u2029_is_written_escaped(self): + self.server.send({"jsonrpc": "2.0", "params": {"content": "a
b
c é"}}) + written = self.out.getvalue() + self.assertNotIn("
", written) + self.assertNotIn("
", written) + self.assertIn("é", written) + (line,) = written.splitlines() + self.assertEqual(json.loads(line)["params"]["content"], "a
b
c é") + def test_a_held_lease_rings_fixed_text_and_logs_a_plain_holder(self): conflict = sb.Conflict({"error": "lease held", "holder": "other-session.1"}) with unittest.mock.patch.object(self.relay, "wait", side_effect=conflict): diff --git a/lib/session-bridge/test_view_bridge.py b/lib/session-bridge/test_view_bridge.py index bef09c6df86..cce143fe4ca 100644 --- a/lib/session-bridge/test_view_bridge.py +++ b/lib/session-bridge/test_view_bridge.py @@ -97,6 +97,51 @@ def test_only_same_origin_page_script_gets_the_token(self): code, body = self.request("GET", "/api/token", headers=SAME_ORIGIN, token=False) self.assertEqual((code, body["token"]), (200, self.hub.token)) + def test_the_watcher_token_is_separate_and_never_served_to_the_page(self): + self.assertNotEqual(self.hub.watch_token, self.hub.token) + _, body = self.request("GET", "/api/token", headers=SAME_ORIGIN, token=False) + self.assertEqual(body, {"token": self.hub.token}) + s = json.loads((self.dir / ".view-session.json").read_text(encoding="utf-8")) + self.assertEqual(s["watchToken"], self.hub.watch_token) + env = (self.dir / ".view-session.env").read_text(encoding="utf-8") + self.assertIn(f"WATCH_TOKEN={self.hub.watch_token}\n", env) + self.assertIn(f"TOKEN={self.hub.token}\n", env) + + def test_the_page_token_is_refused_on_wait_and_lease_release(self): + page = {"X-View-Token": self.hub.token} + code, _ = self.request("GET", "/api/wait?timeout=1", headers=page, token=False) + self.assertEqual(code, 403) + self.hub.lease = { + "watcher": "w", + "since": time.time(), + "last": time.time(), + "inflight": 0, + "pid": None, + } + code, _ = self.request( + "POST", "/api/lease", {"action": "release"}, headers=page, token=False + ) + self.assertEqual(code, 403) + self.assertIsNotNone(self.hub.lease) + + def test_the_watcher_token_is_accepted_on_wait_and_lease_release(self): + watch = {"X-View-Token": self.hub.watch_token} + code, body = self.request( + "GET", "/api/wait?timeout=1", headers=watch, token=False + ) + self.assertEqual((code, body["timedOut"]), (200, True)) + code, _ = self.request( + "POST", "/api/lease", {"action": "release"}, headers=watch, token=False + ) + self.assertEqual(code, 200) + + def test_the_watcher_token_does_not_open_actions(self): + watch = {"X-View-Token": self.hub.watch_token} + code, _ = self.request( + "POST", "/api/action", {"action": "send"}, headers=watch, token=False + ) + self.assertEqual(code, 403) + def test_an_action_without_the_token_is_refused(self): code, _ = self.request("POST", "/api/action", {"action": "send"}, token=False) self.assertEqual(code, 403) @@ -154,7 +199,10 @@ def test_an_action_reaches_the_session_as_data_under_the_framing_contract(self): canary = self.tmp / "canary" text = INJECTION.format(canary=canary) self.act(notes={"note": text}) - code, body = self.request("GET", "/api/wait?after=handled&timeout=5") + watch = {"X-View-Token": self.hub.watch_token} + code, body = self.request( + "GET", "/api/wait?after=handled&timeout=5", headers=watch, token=False + ) self.assertEqual(code, 200) self.assertEqual(body["events"][0]["notes"]["note"], text) self.assertEqual(body["note"], sb.DATA_NOTE) @@ -184,7 +232,10 @@ def test_replies_reach_the_page_and_handled_actions_stop_waking(self): [(1, True, "Moved #4 to needs-info."), (2, True, None)], ) self.assertNotIn("notes", state["events"][0]) - code, body = self.request("GET", "/api/wait?after=handled&timeout=1") + watch = {"X-View-Token": self.hub.watch_token} + code, body = self.request( + "GET", "/api/wait?after=handled&timeout=1", headers=watch, token=False + ) self.assertEqual((code, body["timedOut"]), (200, True)) def test_apply_refuses_a_reply_to_no_action_and_unknown_fields(self): diff --git a/lib/session-bridge/view_bridge.py b/lib/session-bridge/view_bridge.py index 2b008665078..a4f8d919da7 100644 --- a/lib/session-bridge/view_bridge.py +++ b/lib/session-bridge/view_bridge.py @@ -124,6 +124,8 @@ def __init__(self, port, data_dir, idle=IDLE_SECONDS): self.replies = self.dir / "replies.json" self.page = self.dir / "page.html" self.idle = idle + # The page gets only self.token (at /api/token); the watcher token stays in the 0600 env file. + self.watch_token = secrets.token_urlsafe(32) self.started = time.time() def expired(self): @@ -207,7 +209,10 @@ def route_get(self, url, query): if url.path == "/api/token": # Only the page's own script gets the token: a browser sets Sec-Fetch-Site and no page # can forge it, and do_GET already refused a foreign Host or Origin. The token never - # enters the page's markup, so a saved or published copy holds none. + # enters the page's markup, so a saved or published copy holds none. This stops a + # browser page from another origin, not a local process: curl can send the header. The + # token opens POST /api/action only; /api/wait and the lease release need the watcher + # token, which this route never serves. if self.headers.get("Sec-Fetch-Site") != "same-origin": return self.send(403, {"error": "same-origin page script only"}) return self.send(200, {"token": self.hub.token}) diff --git a/lib/session-bridge/watch.sh b/lib/session-bridge/watch.sh index 93a4d0d3698..b6c3c3e7cb2 100755 --- a/lib/session-bridge/watch.sh +++ b/lib/session-bridge/watch.sh @@ -2,6 +2,7 @@ # session-bridge's watcher (the loopback adapter's client). Run in a background Bash task; it exits # when the page has something new. # bash watch.sh '' +# Authenticates with WATCH_TOKEN from the session env file (TOKEN when an app has no separate one). # Reads NAME and CONTROL from session-bridge.conf beside this script: NAME names the session env # file (.-session.env) and the token header (X--Token); CONTROL is the app's control # script beside this one, named in messages and run by wake.sh. @@ -17,6 +18,9 @@ # time), when this watcher's lease was released while it waited, or when a poll fails after # `CONTROL stop` removed the env file; it prints why to stderr and does not retry. Each poll sends # this process's pid (&pid=$$), which the lease records so `CONTROL stop` can end this watcher. +# Exits 4 after WAIT_EMPTY consecutive waits with no event (default 40, 60 minutes at the default +# 90 s wait), so a watcher orphaned by a crashed session ends and the server can expire; it prints +# the re-arm command for a live session to stderr. # Each poll names this watcher: WATCH_ID, else CLAUDE_CODE_SESSION_ID (Claude Code exports it to # every shell a session runs, so every re-arm shares it), else -. A parent # pid of 1 (a Claude Code Bash shell on Windows reports it, for every session) names no one, so @@ -41,12 +45,15 @@ dir=$(abs_dir "$1") || { echo "no such data dir: $1" >&2; exit 2; } env_file="$dir/.$NAME-session.env" [[ -f "$env_file" ]] || { echo "no $env_file: run $CONTROL ensure-running first" >&2; exit 2; } PORT=$(sed -n 's/^PORT=//p' "$env_file" | tr -d '\r') -TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') +TOKEN=$(sed -n 's/^WATCH_TOKEN=//p' "$env_file" | tr -d '\r') +[[ -n "$TOKEN" ]] || TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') WAIT_TIMEOUT=$(sed -n 's/^WAIT_TIMEOUT=//p' "$env_file" | tr -dc '0-9') WAIT_TIMEOUT=${WAIT_TIMEOUT:-90} [[ -n "$PORT" && -n "$TOKEN" ]] || { echo "server not running (empty $env_file)" >&2; exit 2; } [[ "$PORT" =~ ^[0-9]+$ ]] || { echo "PORT in $env_file is not a number: re-run ensure-running" >&2; exit 2; } max_fails=${WAIT_FAILS:-12} +max_empty=${WAIT_EMPTY:-40} +[[ "$max_empty" =~ ^[0-9]+$ && "$max_empty" -gt 0 ]] || max_empty=40 # after=handled returns every unhandled event; .watch-replay bounds re-delivery to one extra wake. replayed=$(tr -dc '0-9' 2>/dev/null <"$dir/.watch-replay") replayed=${replayed:-0} @@ -92,6 +99,7 @@ watcher=$(url_encode "$watcher") field() { printf '%s' "$out" | sed -n "s/.*\"$1\": \"\\([^\"]*\\)\".*/\\1/p"; } fails=0 +empty=0 while :; do # The body comes back on stdout (no file path reaches curl), with the status on a last line. # The token header goes in on stdin (`-H @-`), so it never appears in curl's argv. @@ -144,6 +152,14 @@ while :; do esac exit 0 ;; - *) ;; + *) + # An orphan (its session crashed) would otherwise poll forever and keep the server and its + # token alive. The parent pid cannot tell: it is 1 for a Claude Code Bash shell on Windows. + empty=$((empty + 1)) + if [[ "$empty" -ge "$max_empty" ]]; then + echo "no events in $empty consecutive waits: this watcher is exiting so an orphan does not keep the $NAME server alive; a live session re-arms with: bash $(shell_quote "$here/wake.sh") $(shell_quote "$dir")" >&2 + exit 4 + fi + ;; esac done diff --git a/plugins/planning/shared/session_bridge.py b/plugins/planning/shared/session_bridge.py index f03d540d4ae..82254514cb1 100644 --- a/plugins/planning/shared/session_bridge.py +++ b/plugins/planning/shared/session_bridge.py @@ -91,6 +91,19 @@ def token_header(name): return f"X-{name[:1].upper()}{name[1:]}-Token" +def dumps_line(value): + """json.dumps for a line a reader splits on line separators: U+2028 and U+2029 stay escaped. + + ensure_ascii=False keeps other non-ASCII text readable; those two are valid raw in JSON + (RFC 8259 section 7) but end a line in some readers. + """ + return ( + json.dumps(value, ensure_ascii=False) + .replace("
", "\\u2028") + .replace("
", "\\u2029") + ) + + def replace_into(tmp, path): """os.replace with retry: Windows refuses while a reader holds the target open.""" for _ in range(20): @@ -173,6 +186,9 @@ def __init__(self, port, data_dir): self.url = f"http://127.0.0.1:{port}/" self.dir = Path(data_dir).resolve() self.token = secrets.token_urlsafe(32) + # Gates /api/wait and the lease release. None means the page token also gates them; an + # app whose page is untrusted script sets its own, which is never served to the page. + self._watch_token = None # Differs on every start; the page tells a restart by it. self.instance = secrets.token_hex(6) self.cond = threading.Condition() @@ -185,6 +201,14 @@ def __init__(self, port, data_dir): # The one watcher allowed: {watcher, since, last, inflight, pid}. In memory, so a restart frees it. self.lease = None + @property + def watch_token(self): + return self._watch_token or self.token + + @watch_token.setter + def watch_token(self, value): + self._watch_token = value + @staticmethod def lease_live(lease, now, timeout): """A lease holds while its watcher has a wait in flight or its last wait ended within timeout.""" @@ -399,7 +423,7 @@ def send(self, code, body, ctype="application/json", csp=None): raw = ( body if isinstance(body, bytes) - else json.dumps(body, ensure_ascii=False).encode("utf-8") + else dumps_line(body).encode("utf-8") ) if code >= 400: # An unread request body must not become the next request. @@ -445,6 +469,11 @@ def token_ok(self): given = self.headers.get(token_header(self.hub.name)) or "" return secrets.compare_digest(given, self.hub.token) + def watch_token_ok(self): + """The watcher's token, which gates /api/wait and the lease release.""" + given = self.headers.get(token_header(self.hub.name)) or "" + return secrets.compare_digest(given, self.hub.watch_token) + def do_GET(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) @@ -473,7 +502,7 @@ def route_get(self, url, query): self.send(404, {"error": "not found"}) def long_poll(self, query): - if not self.token_ok(): + if not self.watch_token_ok(): return self.send(403, {"error": "token required"}) try: after = (query.get("after") or ["0"])[0] @@ -563,7 +592,8 @@ def do_POST(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) url = urlparse(self.path) - if not self.token_ok(): + gate = self.watch_token_ok if url.path == "/api/lease" else self.token_ok + if not gate(): return self.send(403, {"error": "token required"}) if ( not self.headers.get("Content-Type", "") @@ -649,12 +679,14 @@ def start(handler, make_hub, port, nonce=""): "nonce": nonce, "startedAt": now_iso(), } + env = f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\n" + # Only an app's own watcher token is written; without one, readers fall back to the page token. + if hub._watch_token: + session["watchToken"] = hub._watch_token + env += f"WATCH_TOKEN={hub._watch_token}\n" json_name, env_name = session_files(hub.name) write_private(hub.dir / json_name, json.dumps(session, indent=2) + "\n") - write_private( - hub.dir / env_name, - f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\nNONCE={nonce}\n", - ) + write_private(hub.dir / env_name, f"{env}NONCE={nonce}\n") return httpd, hub @@ -832,7 +864,7 @@ def release_lease(s, name): body=json.dumps({"action": "release"}), headers={ "Content-Type": "application/json", - token_header(name): s["token"], + token_header(name): s.get("watchToken") or s["token"], }, ) resp = conn.getresponse() @@ -1242,11 +1274,11 @@ def __init__(self, data_dir, name, control_cmd, ring, watcher): self.last_deliver = 0.0 def session(self): - """(port, token) from the data dir's session env file, or None.""" + """(port, watcher token) from the data dir's session env file, or None.""" try: text = (self.dir / session_files(self.name)[1]).read_text(encoding="utf-8") env = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) - return int(env["PORT"]), env["TOKEN"] + return int(env["PORT"]), env.get("WATCH_TOKEN") or env["TOKEN"] except (OSError, KeyError, ValueError): return None @@ -1294,7 +1326,7 @@ def release(self): s = self.session() if s is not None: try: - release_lease({"port": s[0], "token": s[1]}, self.name) + release_lease({"port": s[0], "watchToken": s[1]}, self.name) except (OSError, http.client.HTTPException): pass @@ -1475,7 +1507,7 @@ def __init__(self, here, out): def send(self, msg): with self.lock: - self.out.write(json.dumps(msg, ensure_ascii=False) + "\n") + self.out.write(dumps_line(msg) + "\n") self.out.flush() def ring(self, relay, text, **meta): @@ -1525,7 +1557,7 @@ def tool_watch(self, args): return f"watching {d}: a channel event announces new page events; read them with the events tool" def tool_events(self, args): - return json.dumps(self.relay_for(args).take(), ensure_ascii=False) + return dumps_line(self.relay_for(args).take()) def tool_unwatch(self, args): relay = self.relay_for(args) diff --git a/plugins/planning/shared/watch.sh b/plugins/planning/shared/watch.sh index 37bf9821f11..157ad1c2327 100755 --- a/plugins/planning/shared/watch.sh +++ b/plugins/planning/shared/watch.sh @@ -4,6 +4,7 @@ # session-bridge's watcher (the loopback adapter's client). Run in a background Bash task; it exits # when the page has something new. # bash watch.sh '' +# Authenticates with WATCH_TOKEN from the session env file (TOKEN when an app has no separate one). # Reads NAME and CONTROL from session-bridge.conf beside this script: NAME names the session env # file (.-session.env) and the token header (X--Token); CONTROL is the app's control # script beside this one, named in messages and run by wake.sh. @@ -19,6 +20,9 @@ # time), when this watcher's lease was released while it waited, or when a poll fails after # `CONTROL stop` removed the env file; it prints why to stderr and does not retry. Each poll sends # this process's pid (&pid=$$), which the lease records so `CONTROL stop` can end this watcher. +# Exits 4 after WAIT_EMPTY consecutive waits with no event (default 40, 60 minutes at the default +# 90 s wait), so a watcher orphaned by a crashed session ends and the server can expire; it prints +# the re-arm command for a live session to stderr. # Each poll names this watcher: WATCH_ID, else CLAUDE_CODE_SESSION_ID (Claude Code exports it to # every shell a session runs, so every re-arm shares it), else -. A parent # pid of 1 (a Claude Code Bash shell on Windows reports it, for every session) names no one, so @@ -43,12 +47,15 @@ dir=$(abs_dir "$1") || { echo "no such data dir: $1" >&2; exit 2; } env_file="$dir/.$NAME-session.env" [[ -f "$env_file" ]] || { echo "no $env_file: run $CONTROL ensure-running first" >&2; exit 2; } PORT=$(sed -n 's/^PORT=//p' "$env_file" | tr -d '\r') -TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') +TOKEN=$(sed -n 's/^WATCH_TOKEN=//p' "$env_file" | tr -d '\r') +[[ -n "$TOKEN" ]] || TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') WAIT_TIMEOUT=$(sed -n 's/^WAIT_TIMEOUT=//p' "$env_file" | tr -dc '0-9') WAIT_TIMEOUT=${WAIT_TIMEOUT:-90} [[ -n "$PORT" && -n "$TOKEN" ]] || { echo "server not running (empty $env_file)" >&2; exit 2; } [[ "$PORT" =~ ^[0-9]+$ ]] || { echo "PORT in $env_file is not a number: re-run ensure-running" >&2; exit 2; } max_fails=${WAIT_FAILS:-12} +max_empty=${WAIT_EMPTY:-40} +[[ "$max_empty" =~ ^[0-9]+$ && "$max_empty" -gt 0 ]] || max_empty=40 # after=handled returns every unhandled event; .watch-replay bounds re-delivery to one extra wake. replayed=$(tr -dc '0-9' 2>/dev/null <"$dir/.watch-replay") replayed=${replayed:-0} @@ -94,6 +101,7 @@ watcher=$(url_encode "$watcher") field() { printf '%s' "$out" | sed -n "s/.*\"$1\": \"\\([^\"]*\\)\".*/\\1/p"; } fails=0 +empty=0 while :; do # The body comes back on stdout (no file path reaches curl), with the status on a last line. # The token header goes in on stdin (`-H @-`), so it never appears in curl's argv. @@ -146,6 +154,14 @@ while :; do esac exit 0 ;; - *) ;; + *) + # An orphan (its session crashed) would otherwise poll forever and keep the server and its + # token alive. The parent pid cannot tell: it is 1 for a Claude Code Bash shell on Windows. + empty=$((empty + 1)) + if [[ "$empty" -ge "$max_empty" ]]; then + echo "no events in $empty consecutive waits: this watcher is exiting so an orphan does not keep the $NAME server alive; a live session re-arms with: bash $(shell_quote "$here/wake.sh") $(shell_quote "$dir")" >&2 + exit 4 + fi + ;; esac done diff --git a/plugins/planning/surface/session_bridge.py b/plugins/planning/surface/session_bridge.py index f03d540d4ae..82254514cb1 100644 --- a/plugins/planning/surface/session_bridge.py +++ b/plugins/planning/surface/session_bridge.py @@ -91,6 +91,19 @@ def token_header(name): return f"X-{name[:1].upper()}{name[1:]}-Token" +def dumps_line(value): + """json.dumps for a line a reader splits on line separators: U+2028 and U+2029 stay escaped. + + ensure_ascii=False keeps other non-ASCII text readable; those two are valid raw in JSON + (RFC 8259 section 7) but end a line in some readers. + """ + return ( + json.dumps(value, ensure_ascii=False) + .replace("
", "\\u2028") + .replace("
", "\\u2029") + ) + + def replace_into(tmp, path): """os.replace with retry: Windows refuses while a reader holds the target open.""" for _ in range(20): @@ -173,6 +186,9 @@ def __init__(self, port, data_dir): self.url = f"http://127.0.0.1:{port}/" self.dir = Path(data_dir).resolve() self.token = secrets.token_urlsafe(32) + # Gates /api/wait and the lease release. None means the page token also gates them; an + # app whose page is untrusted script sets its own, which is never served to the page. + self._watch_token = None # Differs on every start; the page tells a restart by it. self.instance = secrets.token_hex(6) self.cond = threading.Condition() @@ -185,6 +201,14 @@ def __init__(self, port, data_dir): # The one watcher allowed: {watcher, since, last, inflight, pid}. In memory, so a restart frees it. self.lease = None + @property + def watch_token(self): + return self._watch_token or self.token + + @watch_token.setter + def watch_token(self, value): + self._watch_token = value + @staticmethod def lease_live(lease, now, timeout): """A lease holds while its watcher has a wait in flight or its last wait ended within timeout.""" @@ -399,7 +423,7 @@ def send(self, code, body, ctype="application/json", csp=None): raw = ( body if isinstance(body, bytes) - else json.dumps(body, ensure_ascii=False).encode("utf-8") + else dumps_line(body).encode("utf-8") ) if code >= 400: # An unread request body must not become the next request. @@ -445,6 +469,11 @@ def token_ok(self): given = self.headers.get(token_header(self.hub.name)) or "" return secrets.compare_digest(given, self.hub.token) + def watch_token_ok(self): + """The watcher's token, which gates /api/wait and the lease release.""" + given = self.headers.get(token_header(self.hub.name)) or "" + return secrets.compare_digest(given, self.hub.watch_token) + def do_GET(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) @@ -473,7 +502,7 @@ def route_get(self, url, query): self.send(404, {"error": "not found"}) def long_poll(self, query): - if not self.token_ok(): + if not self.watch_token_ok(): return self.send(403, {"error": "token required"}) try: after = (query.get("after") or ["0"])[0] @@ -563,7 +592,8 @@ def do_POST(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) url = urlparse(self.path) - if not self.token_ok(): + gate = self.watch_token_ok if url.path == "/api/lease" else self.token_ok + if not gate(): return self.send(403, {"error": "token required"}) if ( not self.headers.get("Content-Type", "") @@ -649,12 +679,14 @@ def start(handler, make_hub, port, nonce=""): "nonce": nonce, "startedAt": now_iso(), } + env = f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\n" + # Only an app's own watcher token is written; without one, readers fall back to the page token. + if hub._watch_token: + session["watchToken"] = hub._watch_token + env += f"WATCH_TOKEN={hub._watch_token}\n" json_name, env_name = session_files(hub.name) write_private(hub.dir / json_name, json.dumps(session, indent=2) + "\n") - write_private( - hub.dir / env_name, - f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\nNONCE={nonce}\n", - ) + write_private(hub.dir / env_name, f"{env}NONCE={nonce}\n") return httpd, hub @@ -832,7 +864,7 @@ def release_lease(s, name): body=json.dumps({"action": "release"}), headers={ "Content-Type": "application/json", - token_header(name): s["token"], + token_header(name): s.get("watchToken") or s["token"], }, ) resp = conn.getresponse() @@ -1242,11 +1274,11 @@ def __init__(self, data_dir, name, control_cmd, ring, watcher): self.last_deliver = 0.0 def session(self): - """(port, token) from the data dir's session env file, or None.""" + """(port, watcher token) from the data dir's session env file, or None.""" try: text = (self.dir / session_files(self.name)[1]).read_text(encoding="utf-8") env = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) - return int(env["PORT"]), env["TOKEN"] + return int(env["PORT"]), env.get("WATCH_TOKEN") or env["TOKEN"] except (OSError, KeyError, ValueError): return None @@ -1294,7 +1326,7 @@ def release(self): s = self.session() if s is not None: try: - release_lease({"port": s[0], "token": s[1]}, self.name) + release_lease({"port": s[0], "watchToken": s[1]}, self.name) except (OSError, http.client.HTTPException): pass @@ -1475,7 +1507,7 @@ def __init__(self, here, out): def send(self, msg): with self.lock: - self.out.write(json.dumps(msg, ensure_ascii=False) + "\n") + self.out.write(dumps_line(msg) + "\n") self.out.flush() def ring(self, relay, text, **meta): @@ -1525,7 +1557,7 @@ def tool_watch(self, args): return f"watching {d}: a channel event announces new page events; read them with the events tool" def tool_events(self, args): - return json.dumps(self.relay_for(args).take(), ensure_ascii=False) + return dumps_line(self.relay_for(args).take()) def tool_unwatch(self, args): relay = self.relay_for(args) diff --git a/plugins/planning/surface/watch.sh b/plugins/planning/surface/watch.sh index 37bf9821f11..157ad1c2327 100755 --- a/plugins/planning/surface/watch.sh +++ b/plugins/planning/surface/watch.sh @@ -4,6 +4,7 @@ # session-bridge's watcher (the loopback adapter's client). Run in a background Bash task; it exits # when the page has something new. # bash watch.sh '' +# Authenticates with WATCH_TOKEN from the session env file (TOKEN when an app has no separate one). # Reads NAME and CONTROL from session-bridge.conf beside this script: NAME names the session env # file (.-session.env) and the token header (X--Token); CONTROL is the app's control # script beside this one, named in messages and run by wake.sh. @@ -19,6 +20,9 @@ # time), when this watcher's lease was released while it waited, or when a poll fails after # `CONTROL stop` removed the env file; it prints why to stderr and does not retry. Each poll sends # this process's pid (&pid=$$), which the lease records so `CONTROL stop` can end this watcher. +# Exits 4 after WAIT_EMPTY consecutive waits with no event (default 40, 60 minutes at the default +# 90 s wait), so a watcher orphaned by a crashed session ends and the server can expire; it prints +# the re-arm command for a live session to stderr. # Each poll names this watcher: WATCH_ID, else CLAUDE_CODE_SESSION_ID (Claude Code exports it to # every shell a session runs, so every re-arm shares it), else -. A parent # pid of 1 (a Claude Code Bash shell on Windows reports it, for every session) names no one, so @@ -43,12 +47,15 @@ dir=$(abs_dir "$1") || { echo "no such data dir: $1" >&2; exit 2; } env_file="$dir/.$NAME-session.env" [[ -f "$env_file" ]] || { echo "no $env_file: run $CONTROL ensure-running first" >&2; exit 2; } PORT=$(sed -n 's/^PORT=//p' "$env_file" | tr -d '\r') -TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') +TOKEN=$(sed -n 's/^WATCH_TOKEN=//p' "$env_file" | tr -d '\r') +[[ -n "$TOKEN" ]] || TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') WAIT_TIMEOUT=$(sed -n 's/^WAIT_TIMEOUT=//p' "$env_file" | tr -dc '0-9') WAIT_TIMEOUT=${WAIT_TIMEOUT:-90} [[ -n "$PORT" && -n "$TOKEN" ]] || { echo "server not running (empty $env_file)" >&2; exit 2; } [[ "$PORT" =~ ^[0-9]+$ ]] || { echo "PORT in $env_file is not a number: re-run ensure-running" >&2; exit 2; } max_fails=${WAIT_FAILS:-12} +max_empty=${WAIT_EMPTY:-40} +[[ "$max_empty" =~ ^[0-9]+$ && "$max_empty" -gt 0 ]] || max_empty=40 # after=handled returns every unhandled event; .watch-replay bounds re-delivery to one extra wake. replayed=$(tr -dc '0-9' 2>/dev/null <"$dir/.watch-replay") replayed=${replayed:-0} @@ -94,6 +101,7 @@ watcher=$(url_encode "$watcher") field() { printf '%s' "$out" | sed -n "s/.*\"$1\": \"\\([^\"]*\\)\".*/\\1/p"; } fails=0 +empty=0 while :; do # The body comes back on stdout (no file path reaches curl), with the status on a last line. # The token header goes in on stdin (`-H @-`), so it never appears in curl's argv. @@ -146,6 +154,14 @@ while :; do esac exit 0 ;; - *) ;; + *) + # An orphan (its session crashed) would otherwise poll forever and keep the server and its + # token alive. The parent pid cannot tell: it is 1 for a Claude Code Bash shell on Windows. + empty=$((empty + 1)) + if [[ "$empty" -ge "$max_empty" ]]; then + echo "no events in $empty consecutive waits: this watcher is exiting so an orphan does not keep the $NAME server alive; a live session re-arms with: bash $(shell_quote "$here/wake.sh") $(shell_quote "$dir")" >&2 + exit 4 + fi + ;; esac done diff --git a/plugins/planning/view-bridge/session_bridge.py b/plugins/planning/view-bridge/session_bridge.py index f03d540d4ae..82254514cb1 100644 --- a/plugins/planning/view-bridge/session_bridge.py +++ b/plugins/planning/view-bridge/session_bridge.py @@ -91,6 +91,19 @@ def token_header(name): return f"X-{name[:1].upper()}{name[1:]}-Token" +def dumps_line(value): + """json.dumps for a line a reader splits on line separators: U+2028 and U+2029 stay escaped. + + ensure_ascii=False keeps other non-ASCII text readable; those two are valid raw in JSON + (RFC 8259 section 7) but end a line in some readers. + """ + return ( + json.dumps(value, ensure_ascii=False) + .replace("
", "\\u2028") + .replace("
", "\\u2029") + ) + + def replace_into(tmp, path): """os.replace with retry: Windows refuses while a reader holds the target open.""" for _ in range(20): @@ -173,6 +186,9 @@ def __init__(self, port, data_dir): self.url = f"http://127.0.0.1:{port}/" self.dir = Path(data_dir).resolve() self.token = secrets.token_urlsafe(32) + # Gates /api/wait and the lease release. None means the page token also gates them; an + # app whose page is untrusted script sets its own, which is never served to the page. + self._watch_token = None # Differs on every start; the page tells a restart by it. self.instance = secrets.token_hex(6) self.cond = threading.Condition() @@ -185,6 +201,14 @@ def __init__(self, port, data_dir): # The one watcher allowed: {watcher, since, last, inflight, pid}. In memory, so a restart frees it. self.lease = None + @property + def watch_token(self): + return self._watch_token or self.token + + @watch_token.setter + def watch_token(self, value): + self._watch_token = value + @staticmethod def lease_live(lease, now, timeout): """A lease holds while its watcher has a wait in flight or its last wait ended within timeout.""" @@ -399,7 +423,7 @@ def send(self, code, body, ctype="application/json", csp=None): raw = ( body if isinstance(body, bytes) - else json.dumps(body, ensure_ascii=False).encode("utf-8") + else dumps_line(body).encode("utf-8") ) if code >= 400: # An unread request body must not become the next request. @@ -445,6 +469,11 @@ def token_ok(self): given = self.headers.get(token_header(self.hub.name)) or "" return secrets.compare_digest(given, self.hub.token) + def watch_token_ok(self): + """The watcher's token, which gates /api/wait and the lease release.""" + given = self.headers.get(token_header(self.hub.name)) or "" + return secrets.compare_digest(given, self.hub.watch_token) + def do_GET(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) @@ -473,7 +502,7 @@ def route_get(self, url, query): self.send(404, {"error": "not found"}) def long_poll(self, query): - if not self.token_ok(): + if not self.watch_token_ok(): return self.send(403, {"error": "token required"}) try: after = (query.get("after") or ["0"])[0] @@ -563,7 +592,8 @@ def do_POST(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) url = urlparse(self.path) - if not self.token_ok(): + gate = self.watch_token_ok if url.path == "/api/lease" else self.token_ok + if not gate(): return self.send(403, {"error": "token required"}) if ( not self.headers.get("Content-Type", "") @@ -649,12 +679,14 @@ def start(handler, make_hub, port, nonce=""): "nonce": nonce, "startedAt": now_iso(), } + env = f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\n" + # Only an app's own watcher token is written; without one, readers fall back to the page token. + if hub._watch_token: + session["watchToken"] = hub._watch_token + env += f"WATCH_TOKEN={hub._watch_token}\n" json_name, env_name = session_files(hub.name) write_private(hub.dir / json_name, json.dumps(session, indent=2) + "\n") - write_private( - hub.dir / env_name, - f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\nNONCE={nonce}\n", - ) + write_private(hub.dir / env_name, f"{env}NONCE={nonce}\n") return httpd, hub @@ -832,7 +864,7 @@ def release_lease(s, name): body=json.dumps({"action": "release"}), headers={ "Content-Type": "application/json", - token_header(name): s["token"], + token_header(name): s.get("watchToken") or s["token"], }, ) resp = conn.getresponse() @@ -1242,11 +1274,11 @@ def __init__(self, data_dir, name, control_cmd, ring, watcher): self.last_deliver = 0.0 def session(self): - """(port, token) from the data dir's session env file, or None.""" + """(port, watcher token) from the data dir's session env file, or None.""" try: text = (self.dir / session_files(self.name)[1]).read_text(encoding="utf-8") env = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) - return int(env["PORT"]), env["TOKEN"] + return int(env["PORT"]), env.get("WATCH_TOKEN") or env["TOKEN"] except (OSError, KeyError, ValueError): return None @@ -1294,7 +1326,7 @@ def release(self): s = self.session() if s is not None: try: - release_lease({"port": s[0], "token": s[1]}, self.name) + release_lease({"port": s[0], "watchToken": s[1]}, self.name) except (OSError, http.client.HTTPException): pass @@ -1475,7 +1507,7 @@ def __init__(self, here, out): def send(self, msg): with self.lock: - self.out.write(json.dumps(msg, ensure_ascii=False) + "\n") + self.out.write(dumps_line(msg) + "\n") self.out.flush() def ring(self, relay, text, **meta): @@ -1525,7 +1557,7 @@ def tool_watch(self, args): return f"watching {d}: a channel event announces new page events; read them with the events tool" def tool_events(self, args): - return json.dumps(self.relay_for(args).take(), ensure_ascii=False) + return dumps_line(self.relay_for(args).take()) def tool_unwatch(self, args): relay = self.relay_for(args) diff --git a/plugins/planning/view-bridge/view_bridge.py b/plugins/planning/view-bridge/view_bridge.py index bdd8c0b9f13..d9c41801ca5 100644 --- a/plugins/planning/view-bridge/view_bridge.py +++ b/plugins/planning/view-bridge/view_bridge.py @@ -126,6 +126,8 @@ def __init__(self, port, data_dir, idle=IDLE_SECONDS): self.replies = self.dir / "replies.json" self.page = self.dir / "page.html" self.idle = idle + # The page gets only self.token (at /api/token); the watcher token stays in the 0600 env file. + self.watch_token = secrets.token_urlsafe(32) self.started = time.time() def expired(self): @@ -209,7 +211,10 @@ def route_get(self, url, query): if url.path == "/api/token": # Only the page's own script gets the token: a browser sets Sec-Fetch-Site and no page # can forge it, and do_GET already refused a foreign Host or Origin. The token never - # enters the page's markup, so a saved or published copy holds none. + # enters the page's markup, so a saved or published copy holds none. This stops a + # browser page from another origin, not a local process: curl can send the header. The + # token opens POST /api/action only; /api/wait and the lease release need the watcher + # token, which this route never serves. if self.headers.get("Sec-Fetch-Site") != "same-origin": return self.send(403, {"error": "same-origin page script only"}) return self.send(200, {"token": self.hub.token}) diff --git a/plugins/planning/view-bridge/watch.sh b/plugins/planning/view-bridge/watch.sh index 37bf9821f11..157ad1c2327 100755 --- a/plugins/planning/view-bridge/watch.sh +++ b/plugins/planning/view-bridge/watch.sh @@ -4,6 +4,7 @@ # session-bridge's watcher (the loopback adapter's client). Run in a background Bash task; it exits # when the page has something new. # bash watch.sh '' +# Authenticates with WATCH_TOKEN from the session env file (TOKEN when an app has no separate one). # Reads NAME and CONTROL from session-bridge.conf beside this script: NAME names the session env # file (.-session.env) and the token header (X--Token); CONTROL is the app's control # script beside this one, named in messages and run by wake.sh. @@ -19,6 +20,9 @@ # time), when this watcher's lease was released while it waited, or when a poll fails after # `CONTROL stop` removed the env file; it prints why to stderr and does not retry. Each poll sends # this process's pid (&pid=$$), which the lease records so `CONTROL stop` can end this watcher. +# Exits 4 after WAIT_EMPTY consecutive waits with no event (default 40, 60 minutes at the default +# 90 s wait), so a watcher orphaned by a crashed session ends and the server can expire; it prints +# the re-arm command for a live session to stderr. # Each poll names this watcher: WATCH_ID, else CLAUDE_CODE_SESSION_ID (Claude Code exports it to # every shell a session runs, so every re-arm shares it), else -. A parent # pid of 1 (a Claude Code Bash shell on Windows reports it, for every session) names no one, so @@ -43,12 +47,15 @@ dir=$(abs_dir "$1") || { echo "no such data dir: $1" >&2; exit 2; } env_file="$dir/.$NAME-session.env" [[ -f "$env_file" ]] || { echo "no $env_file: run $CONTROL ensure-running first" >&2; exit 2; } PORT=$(sed -n 's/^PORT=//p' "$env_file" | tr -d '\r') -TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') +TOKEN=$(sed -n 's/^WATCH_TOKEN=//p' "$env_file" | tr -d '\r') +[[ -n "$TOKEN" ]] || TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') WAIT_TIMEOUT=$(sed -n 's/^WAIT_TIMEOUT=//p' "$env_file" | tr -dc '0-9') WAIT_TIMEOUT=${WAIT_TIMEOUT:-90} [[ -n "$PORT" && -n "$TOKEN" ]] || { echo "server not running (empty $env_file)" >&2; exit 2; } [[ "$PORT" =~ ^[0-9]+$ ]] || { echo "PORT in $env_file is not a number: re-run ensure-running" >&2; exit 2; } max_fails=${WAIT_FAILS:-12} +max_empty=${WAIT_EMPTY:-40} +[[ "$max_empty" =~ ^[0-9]+$ && "$max_empty" -gt 0 ]] || max_empty=40 # after=handled returns every unhandled event; .watch-replay bounds re-delivery to one extra wake. replayed=$(tr -dc '0-9' 2>/dev/null <"$dir/.watch-replay") replayed=${replayed:-0} @@ -94,6 +101,7 @@ watcher=$(url_encode "$watcher") field() { printf '%s' "$out" | sed -n "s/.*\"$1\": \"\\([^\"]*\\)\".*/\\1/p"; } fails=0 +empty=0 while :; do # The body comes back on stdout (no file path reaches curl), with the status on a last line. # The token header goes in on stdin (`-H @-`), so it never appears in curl's argv. @@ -146,6 +154,14 @@ while :; do esac exit 0 ;; - *) ;; + *) + # An orphan (its session crashed) would otherwise poll forever and keep the server and its + # token alive. The parent pid cannot tell: it is 1 for a Claude Code Bash shell on Windows. + empty=$((empty + 1)) + if [[ "$empty" -ge "$max_empty" ]]; then + echo "no events in $empty consecutive waits: this watcher is exiting so an orphan does not keep the $NAME server alive; a live session re-arms with: bash $(shell_quote "$here/wake.sh") $(shell_quote "$dir")" >&2 + exit 4 + fi + ;; esac done diff --git a/plugins/review/view-bridge/session_bridge.py b/plugins/review/view-bridge/session_bridge.py index f03d540d4ae..82254514cb1 100644 --- a/plugins/review/view-bridge/session_bridge.py +++ b/plugins/review/view-bridge/session_bridge.py @@ -91,6 +91,19 @@ def token_header(name): return f"X-{name[:1].upper()}{name[1:]}-Token" +def dumps_line(value): + """json.dumps for a line a reader splits on line separators: U+2028 and U+2029 stay escaped. + + ensure_ascii=False keeps other non-ASCII text readable; those two are valid raw in JSON + (RFC 8259 section 7) but end a line in some readers. + """ + return ( + json.dumps(value, ensure_ascii=False) + .replace("
", "\\u2028") + .replace("
", "\\u2029") + ) + + def replace_into(tmp, path): """os.replace with retry: Windows refuses while a reader holds the target open.""" for _ in range(20): @@ -173,6 +186,9 @@ def __init__(self, port, data_dir): self.url = f"http://127.0.0.1:{port}/" self.dir = Path(data_dir).resolve() self.token = secrets.token_urlsafe(32) + # Gates /api/wait and the lease release. None means the page token also gates them; an + # app whose page is untrusted script sets its own, which is never served to the page. + self._watch_token = None # Differs on every start; the page tells a restart by it. self.instance = secrets.token_hex(6) self.cond = threading.Condition() @@ -185,6 +201,14 @@ def __init__(self, port, data_dir): # The one watcher allowed: {watcher, since, last, inflight, pid}. In memory, so a restart frees it. self.lease = None + @property + def watch_token(self): + return self._watch_token or self.token + + @watch_token.setter + def watch_token(self, value): + self._watch_token = value + @staticmethod def lease_live(lease, now, timeout): """A lease holds while its watcher has a wait in flight or its last wait ended within timeout.""" @@ -399,7 +423,7 @@ def send(self, code, body, ctype="application/json", csp=None): raw = ( body if isinstance(body, bytes) - else json.dumps(body, ensure_ascii=False).encode("utf-8") + else dumps_line(body).encode("utf-8") ) if code >= 400: # An unread request body must not become the next request. @@ -445,6 +469,11 @@ def token_ok(self): given = self.headers.get(token_header(self.hub.name)) or "" return secrets.compare_digest(given, self.hub.token) + def watch_token_ok(self): + """The watcher's token, which gates /api/wait and the lease release.""" + given = self.headers.get(token_header(self.hub.name)) or "" + return secrets.compare_digest(given, self.hub.watch_token) + def do_GET(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) @@ -473,7 +502,7 @@ def route_get(self, url, query): self.send(404, {"error": "not found"}) def long_poll(self, query): - if not self.token_ok(): + if not self.watch_token_ok(): return self.send(403, {"error": "token required"}) try: after = (query.get("after") or ["0"])[0] @@ -563,7 +592,8 @@ def do_POST(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) url = urlparse(self.path) - if not self.token_ok(): + gate = self.watch_token_ok if url.path == "/api/lease" else self.token_ok + if not gate(): return self.send(403, {"error": "token required"}) if ( not self.headers.get("Content-Type", "") @@ -649,12 +679,14 @@ def start(handler, make_hub, port, nonce=""): "nonce": nonce, "startedAt": now_iso(), } + env = f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\n" + # Only an app's own watcher token is written; without one, readers fall back to the page token. + if hub._watch_token: + session["watchToken"] = hub._watch_token + env += f"WATCH_TOKEN={hub._watch_token}\n" json_name, env_name = session_files(hub.name) write_private(hub.dir / json_name, json.dumps(session, indent=2) + "\n") - write_private( - hub.dir / env_name, - f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\nNONCE={nonce}\n", - ) + write_private(hub.dir / env_name, f"{env}NONCE={nonce}\n") return httpd, hub @@ -832,7 +864,7 @@ def release_lease(s, name): body=json.dumps({"action": "release"}), headers={ "Content-Type": "application/json", - token_header(name): s["token"], + token_header(name): s.get("watchToken") or s["token"], }, ) resp = conn.getresponse() @@ -1242,11 +1274,11 @@ def __init__(self, data_dir, name, control_cmd, ring, watcher): self.last_deliver = 0.0 def session(self): - """(port, token) from the data dir's session env file, or None.""" + """(port, watcher token) from the data dir's session env file, or None.""" try: text = (self.dir / session_files(self.name)[1]).read_text(encoding="utf-8") env = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) - return int(env["PORT"]), env["TOKEN"] + return int(env["PORT"]), env.get("WATCH_TOKEN") or env["TOKEN"] except (OSError, KeyError, ValueError): return None @@ -1294,7 +1326,7 @@ def release(self): s = self.session() if s is not None: try: - release_lease({"port": s[0], "token": s[1]}, self.name) + release_lease({"port": s[0], "watchToken": s[1]}, self.name) except (OSError, http.client.HTTPException): pass @@ -1475,7 +1507,7 @@ def __init__(self, here, out): def send(self, msg): with self.lock: - self.out.write(json.dumps(msg, ensure_ascii=False) + "\n") + self.out.write(dumps_line(msg) + "\n") self.out.flush() def ring(self, relay, text, **meta): @@ -1525,7 +1557,7 @@ def tool_watch(self, args): return f"watching {d}: a channel event announces new page events; read them with the events tool" def tool_events(self, args): - return json.dumps(self.relay_for(args).take(), ensure_ascii=False) + return dumps_line(self.relay_for(args).take()) def tool_unwatch(self, args): relay = self.relay_for(args) diff --git a/plugins/review/view-bridge/view_bridge.py b/plugins/review/view-bridge/view_bridge.py index bdd8c0b9f13..d9c41801ca5 100644 --- a/plugins/review/view-bridge/view_bridge.py +++ b/plugins/review/view-bridge/view_bridge.py @@ -126,6 +126,8 @@ def __init__(self, port, data_dir, idle=IDLE_SECONDS): self.replies = self.dir / "replies.json" self.page = self.dir / "page.html" self.idle = idle + # The page gets only self.token (at /api/token); the watcher token stays in the 0600 env file. + self.watch_token = secrets.token_urlsafe(32) self.started = time.time() def expired(self): @@ -209,7 +211,10 @@ def route_get(self, url, query): if url.path == "/api/token": # Only the page's own script gets the token: a browser sets Sec-Fetch-Site and no page # can forge it, and do_GET already refused a foreign Host or Origin. The token never - # enters the page's markup, so a saved or published copy holds none. + # enters the page's markup, so a saved or published copy holds none. This stops a + # browser page from another origin, not a local process: curl can send the header. The + # token opens POST /api/action only; /api/wait and the lease release need the watcher + # token, which this route never serves. if self.headers.get("Sec-Fetch-Site") != "same-origin": return self.send(403, {"error": "same-origin page script only"}) return self.send(200, {"token": self.hub.token}) diff --git a/plugins/review/view-bridge/watch.sh b/plugins/review/view-bridge/watch.sh index 37bf9821f11..157ad1c2327 100755 --- a/plugins/review/view-bridge/watch.sh +++ b/plugins/review/view-bridge/watch.sh @@ -4,6 +4,7 @@ # session-bridge's watcher (the loopback adapter's client). Run in a background Bash task; it exits # when the page has something new. # bash watch.sh '' +# Authenticates with WATCH_TOKEN from the session env file (TOKEN when an app has no separate one). # Reads NAME and CONTROL from session-bridge.conf beside this script: NAME names the session env # file (.-session.env) and the token header (X--Token); CONTROL is the app's control # script beside this one, named in messages and run by wake.sh. @@ -19,6 +20,9 @@ # time), when this watcher's lease was released while it waited, or when a poll fails after # `CONTROL stop` removed the env file; it prints why to stderr and does not retry. Each poll sends # this process's pid (&pid=$$), which the lease records so `CONTROL stop` can end this watcher. +# Exits 4 after WAIT_EMPTY consecutive waits with no event (default 40, 60 minutes at the default +# 90 s wait), so a watcher orphaned by a crashed session ends and the server can expire; it prints +# the re-arm command for a live session to stderr. # Each poll names this watcher: WATCH_ID, else CLAUDE_CODE_SESSION_ID (Claude Code exports it to # every shell a session runs, so every re-arm shares it), else -. A parent # pid of 1 (a Claude Code Bash shell on Windows reports it, for every session) names no one, so @@ -43,12 +47,15 @@ dir=$(abs_dir "$1") || { echo "no such data dir: $1" >&2; exit 2; } env_file="$dir/.$NAME-session.env" [[ -f "$env_file" ]] || { echo "no $env_file: run $CONTROL ensure-running first" >&2; exit 2; } PORT=$(sed -n 's/^PORT=//p' "$env_file" | tr -d '\r') -TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') +TOKEN=$(sed -n 's/^WATCH_TOKEN=//p' "$env_file" | tr -d '\r') +[[ -n "$TOKEN" ]] || TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') WAIT_TIMEOUT=$(sed -n 's/^WAIT_TIMEOUT=//p' "$env_file" | tr -dc '0-9') WAIT_TIMEOUT=${WAIT_TIMEOUT:-90} [[ -n "$PORT" && -n "$TOKEN" ]] || { echo "server not running (empty $env_file)" >&2; exit 2; } [[ "$PORT" =~ ^[0-9]+$ ]] || { echo "PORT in $env_file is not a number: re-run ensure-running" >&2; exit 2; } max_fails=${WAIT_FAILS:-12} +max_empty=${WAIT_EMPTY:-40} +[[ "$max_empty" =~ ^[0-9]+$ && "$max_empty" -gt 0 ]] || max_empty=40 # after=handled returns every unhandled event; .watch-replay bounds re-delivery to one extra wake. replayed=$(tr -dc '0-9' 2>/dev/null <"$dir/.watch-replay") replayed=${replayed:-0} @@ -94,6 +101,7 @@ watcher=$(url_encode "$watcher") field() { printf '%s' "$out" | sed -n "s/.*\"$1\": \"\\([^\"]*\\)\".*/\\1/p"; } fails=0 +empty=0 while :; do # The body comes back on stdout (no file path reaches curl), with the status on a last line. # The token header goes in on stdin (`-H @-`), so it never appears in curl's argv. @@ -146,6 +154,14 @@ while :; do esac exit 0 ;; - *) ;; + *) + # An orphan (its session crashed) would otherwise poll forever and keep the server and its + # token alive. The parent pid cannot tell: it is 1 for a Claude Code Bash shell on Windows. + empty=$((empty + 1)) + if [[ "$empty" -ge "$max_empty" ]]; then + echo "no events in $empty consecutive waits: this watcher is exiting so an orphan does not keep the $NAME server alive; a live session re-arms with: bash $(shell_quote "$here/wake.sh") $(shell_quote "$dir")" >&2 + exit 4 + fi + ;; esac done diff --git a/plugins/work-items/view-bridge/session_bridge.py b/plugins/work-items/view-bridge/session_bridge.py index f03d540d4ae..82254514cb1 100644 --- a/plugins/work-items/view-bridge/session_bridge.py +++ b/plugins/work-items/view-bridge/session_bridge.py @@ -91,6 +91,19 @@ def token_header(name): return f"X-{name[:1].upper()}{name[1:]}-Token" +def dumps_line(value): + """json.dumps for a line a reader splits on line separators: U+2028 and U+2029 stay escaped. + + ensure_ascii=False keeps other non-ASCII text readable; those two are valid raw in JSON + (RFC 8259 section 7) but end a line in some readers. + """ + return ( + json.dumps(value, ensure_ascii=False) + .replace("
", "\\u2028") + .replace("
", "\\u2029") + ) + + def replace_into(tmp, path): """os.replace with retry: Windows refuses while a reader holds the target open.""" for _ in range(20): @@ -173,6 +186,9 @@ def __init__(self, port, data_dir): self.url = f"http://127.0.0.1:{port}/" self.dir = Path(data_dir).resolve() self.token = secrets.token_urlsafe(32) + # Gates /api/wait and the lease release. None means the page token also gates them; an + # app whose page is untrusted script sets its own, which is never served to the page. + self._watch_token = None # Differs on every start; the page tells a restart by it. self.instance = secrets.token_hex(6) self.cond = threading.Condition() @@ -185,6 +201,14 @@ def __init__(self, port, data_dir): # The one watcher allowed: {watcher, since, last, inflight, pid}. In memory, so a restart frees it. self.lease = None + @property + def watch_token(self): + return self._watch_token or self.token + + @watch_token.setter + def watch_token(self, value): + self._watch_token = value + @staticmethod def lease_live(lease, now, timeout): """A lease holds while its watcher has a wait in flight or its last wait ended within timeout.""" @@ -399,7 +423,7 @@ def send(self, code, body, ctype="application/json", csp=None): raw = ( body if isinstance(body, bytes) - else json.dumps(body, ensure_ascii=False).encode("utf-8") + else dumps_line(body).encode("utf-8") ) if code >= 400: # An unread request body must not become the next request. @@ -445,6 +469,11 @@ def token_ok(self): given = self.headers.get(token_header(self.hub.name)) or "" return secrets.compare_digest(given, self.hub.token) + def watch_token_ok(self): + """The watcher's token, which gates /api/wait and the lease release.""" + given = self.headers.get(token_header(self.hub.name)) or "" + return secrets.compare_digest(given, self.hub.watch_token) + def do_GET(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) @@ -473,7 +502,7 @@ def route_get(self, url, query): self.send(404, {"error": "not found"}) def long_poll(self, query): - if not self.token_ok(): + if not self.watch_token_ok(): return self.send(403, {"error": "token required"}) try: after = (query.get("after") or ["0"])[0] @@ -563,7 +592,8 @@ def do_POST(self): if not self.origin_ok(): return self.send(403, {"error": "bad host or origin"}) url = urlparse(self.path) - if not self.token_ok(): + gate = self.watch_token_ok if url.path == "/api/lease" else self.token_ok + if not gate(): return self.send(403, {"error": "token required"}) if ( not self.headers.get("Content-Type", "") @@ -649,12 +679,14 @@ def start(handler, make_hub, port, nonce=""): "nonce": nonce, "startedAt": now_iso(), } + env = f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\n" + # Only an app's own watcher token is written; without one, readers fall back to the page token. + if hub._watch_token: + session["watchToken"] = hub._watch_token + env += f"WATCH_TOKEN={hub._watch_token}\n" json_name, env_name = session_files(hub.name) write_private(hub.dir / json_name, json.dumps(session, indent=2) + "\n") - write_private( - hub.dir / env_name, - f"PID={pid}\nPORT={port}\nTOKEN={hub.token}\nNONCE={nonce}\n", - ) + write_private(hub.dir / env_name, f"{env}NONCE={nonce}\n") return httpd, hub @@ -832,7 +864,7 @@ def release_lease(s, name): body=json.dumps({"action": "release"}), headers={ "Content-Type": "application/json", - token_header(name): s["token"], + token_header(name): s.get("watchToken") or s["token"], }, ) resp = conn.getresponse() @@ -1242,11 +1274,11 @@ def __init__(self, data_dir, name, control_cmd, ring, watcher): self.last_deliver = 0.0 def session(self): - """(port, token) from the data dir's session env file, or None.""" + """(port, watcher token) from the data dir's session env file, or None.""" try: text = (self.dir / session_files(self.name)[1]).read_text(encoding="utf-8") env = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) - return int(env["PORT"]), env["TOKEN"] + return int(env["PORT"]), env.get("WATCH_TOKEN") or env["TOKEN"] except (OSError, KeyError, ValueError): return None @@ -1294,7 +1326,7 @@ def release(self): s = self.session() if s is not None: try: - release_lease({"port": s[0], "token": s[1]}, self.name) + release_lease({"port": s[0], "watchToken": s[1]}, self.name) except (OSError, http.client.HTTPException): pass @@ -1475,7 +1507,7 @@ def __init__(self, here, out): def send(self, msg): with self.lock: - self.out.write(json.dumps(msg, ensure_ascii=False) + "\n") + self.out.write(dumps_line(msg) + "\n") self.out.flush() def ring(self, relay, text, **meta): @@ -1525,7 +1557,7 @@ def tool_watch(self, args): return f"watching {d}: a channel event announces new page events; read them with the events tool" def tool_events(self, args): - return json.dumps(self.relay_for(args).take(), ensure_ascii=False) + return dumps_line(self.relay_for(args).take()) def tool_unwatch(self, args): relay = self.relay_for(args) diff --git a/plugins/work-items/view-bridge/view_bridge.py b/plugins/work-items/view-bridge/view_bridge.py index bdd8c0b9f13..d9c41801ca5 100644 --- a/plugins/work-items/view-bridge/view_bridge.py +++ b/plugins/work-items/view-bridge/view_bridge.py @@ -126,6 +126,8 @@ def __init__(self, port, data_dir, idle=IDLE_SECONDS): self.replies = self.dir / "replies.json" self.page = self.dir / "page.html" self.idle = idle + # The page gets only self.token (at /api/token); the watcher token stays in the 0600 env file. + self.watch_token = secrets.token_urlsafe(32) self.started = time.time() def expired(self): @@ -209,7 +211,10 @@ def route_get(self, url, query): if url.path == "/api/token": # Only the page's own script gets the token: a browser sets Sec-Fetch-Site and no page # can forge it, and do_GET already refused a foreign Host or Origin. The token never - # enters the page's markup, so a saved or published copy holds none. + # enters the page's markup, so a saved or published copy holds none. This stops a + # browser page from another origin, not a local process: curl can send the header. The + # token opens POST /api/action only; /api/wait and the lease release need the watcher + # token, which this route never serves. if self.headers.get("Sec-Fetch-Site") != "same-origin": return self.send(403, {"error": "same-origin page script only"}) return self.send(200, {"token": self.hub.token}) diff --git a/plugins/work-items/view-bridge/watch.sh b/plugins/work-items/view-bridge/watch.sh index 37bf9821f11..157ad1c2327 100755 --- a/plugins/work-items/view-bridge/watch.sh +++ b/plugins/work-items/view-bridge/watch.sh @@ -4,6 +4,7 @@ # session-bridge's watcher (the loopback adapter's client). Run in a background Bash task; it exits # when the page has something new. # bash watch.sh '' +# Authenticates with WATCH_TOKEN from the session env file (TOKEN when an app has no separate one). # Reads NAME and CONTROL from session-bridge.conf beside this script: NAME names the session env # file (.-session.env) and the token header (X--Token); CONTROL is the app's control # script beside this one, named in messages and run by wake.sh. @@ -19,6 +20,9 @@ # time), when this watcher's lease was released while it waited, or when a poll fails after # `CONTROL stop` removed the env file; it prints why to stderr and does not retry. Each poll sends # this process's pid (&pid=$$), which the lease records so `CONTROL stop` can end this watcher. +# Exits 4 after WAIT_EMPTY consecutive waits with no event (default 40, 60 minutes at the default +# 90 s wait), so a watcher orphaned by a crashed session ends and the server can expire; it prints +# the re-arm command for a live session to stderr. # Each poll names this watcher: WATCH_ID, else CLAUDE_CODE_SESSION_ID (Claude Code exports it to # every shell a session runs, so every re-arm shares it), else -. A parent # pid of 1 (a Claude Code Bash shell on Windows reports it, for every session) names no one, so @@ -43,12 +47,15 @@ dir=$(abs_dir "$1") || { echo "no such data dir: $1" >&2; exit 2; } env_file="$dir/.$NAME-session.env" [[ -f "$env_file" ]] || { echo "no $env_file: run $CONTROL ensure-running first" >&2; exit 2; } PORT=$(sed -n 's/^PORT=//p' "$env_file" | tr -d '\r') -TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') +TOKEN=$(sed -n 's/^WATCH_TOKEN=//p' "$env_file" | tr -d '\r') +[[ -n "$TOKEN" ]] || TOKEN=$(sed -n 's/^TOKEN=//p' "$env_file" | tr -d '\r') WAIT_TIMEOUT=$(sed -n 's/^WAIT_TIMEOUT=//p' "$env_file" | tr -dc '0-9') WAIT_TIMEOUT=${WAIT_TIMEOUT:-90} [[ -n "$PORT" && -n "$TOKEN" ]] || { echo "server not running (empty $env_file)" >&2; exit 2; } [[ "$PORT" =~ ^[0-9]+$ ]] || { echo "PORT in $env_file is not a number: re-run ensure-running" >&2; exit 2; } max_fails=${WAIT_FAILS:-12} +max_empty=${WAIT_EMPTY:-40} +[[ "$max_empty" =~ ^[0-9]+$ && "$max_empty" -gt 0 ]] || max_empty=40 # after=handled returns every unhandled event; .watch-replay bounds re-delivery to one extra wake. replayed=$(tr -dc '0-9' 2>/dev/null <"$dir/.watch-replay") replayed=${replayed:-0} @@ -94,6 +101,7 @@ watcher=$(url_encode "$watcher") field() { printf '%s' "$out" | sed -n "s/.*\"$1\": \"\\([^\"]*\\)\".*/\\1/p"; } fails=0 +empty=0 while :; do # The body comes back on stdout (no file path reaches curl), with the status on a last line. # The token header goes in on stdin (`-H @-`), so it never appears in curl's argv. @@ -146,6 +154,14 @@ while :; do esac exit 0 ;; - *) ;; + *) + # An orphan (its session crashed) would otherwise poll forever and keep the server and its + # token alive. The parent pid cannot tell: it is 1 for a Claude Code Bash shell on Windows. + empty=$((empty + 1)) + if [[ "$empty" -ge "$max_empty" ]]; then + echo "no events in $empty consecutive waits: this watcher is exiting so an orphan does not keep the $NAME server alive; a live session re-arms with: bash $(shell_quote "$here/wake.sh") $(shell_quote "$dir")" >&2 + exit 4 + fi + ;; esac done