fix(runtime): preserve browser recovery and SSH compatibility

- Recover browser request proofs on cookie-authenticated same-origin or direct
  page loads; retain origin-scoped sessionStorage and document recovery limits.
  Keep bounded independent stream tickets for tabs connecting together.
- Place IgnoreUnknown before KnownHostsCommand so OpenSSH 7.x clients retain
  strict known-host checks without rejecting the newer option.
- Correct the SSH authentication example and protocol field documentation;
  document the host_key_fingerprint migration to known_hosts in the changelog.
- Retry transient stream-ticket failures with capped backoff, stop on HTTP
  401/403, and discard superseded or stopped connection attempts.

Validation (all Cargo commands used CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true):
- cargo test -p codewhale-tui --lib -- runtime_surface_review_
  runtime_surface_hardening_ssh_requires_known_host_verification
  fleet_host_ssh_command fleet_host_ssh_config runtime_api::web::tests
  runtime_api::auth::tests --test-threads=1
  test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 13461 filtered out; finished in 0.01s
- node --test crates/tui/tests/runtime_web_client.test.mjs
  tests 42; pass 42; fail 0.
- The proof recovery, independent-ticket and SSH compatibility checks fail
  when their respective production changes are removed. The corrected docs
  regression run against the old example reports:
  test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 13476 filtered out; finished in 0.06s
  It fails with the expected unsupported host_key_fingerprint error.
- Each focused JavaScript regression fails without its corresponding change:
  page proof recovery, transient ticket retry, and pending-stream ownership.
  Each reports tests 1; pass 0; fail 1. All changes were restored afterward.
- cargo fmt --all; sh scripts/sync-changelog.sh;
  sh scripts/sync-changelog.sh --check; git diff --check: passed.
- OpenSSH 10.0p2 ssh -G checks preserve strict checking and a known-hosts path
  containing spaces, and confirm IgnoreUnknown handling. No native 7.x run.
- web_bootstrap_sets_strict_cookie_once_and_preserves_v1_auth:
  needs socket run; the sandbox cannot bind the loopback listener.
- cargo clippy -p codewhale-tui --all-targets -- -D warnings (run once):
  exit 101; could not compile `codewhale-tui` (lib test) due to 19 previous errors.
  All 19 diagnostics are clippy::too_many_arguments. Each reported function
  signature is unchanged from both the starting HEAD and HEAD^; these are
  pre-existing, unrelated signatures. No lint allowances were added.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-27 01:19:03 -07:00
1 parent 3a9e4d8bc2
commit fad53facdf
11 files changed
+524 -163

No files matched your search

+5 -1
View File
@@ -20,7 +20,11 @@ quieter, and Fleet runs can be checked before they spend anything.
- Harden local runtime browser sessions, fleet SSH trust, agent continuation
ownership, task gate approval, plugin tool registration, bridge action tokens,
and release metadata credential forwarding.
and release metadata credential forwarding. Browser sessions recover across
reloads and new tabs, and stream tickets retry after transient failures.
Fleet SSH known-host checks support OpenSSH 7.x and later. SSH host configs
using `host_key_fingerprint` must migrate to `known_hosts` with verified host
keys; the unsupported fingerprint field now fails at configuration load.
### Contributors
+5 -4
View File
@@ -432,9 +432,8 @@ pub enum FleetHostSpec {
/// Known hosts file for host-key verification.
#[serde(skip_serializing_if = "Option::is_none")]
known_hosts: Option<PathBuf>,
/// Expected host key fingerprint (SHA256:...) for key pinning.
/// When set, the connection is only trusted if the server's
/// host key matches this fingerprint exactly.
/// Legacy field retained for decoding; the SSH host adapter rejects it
/// when set. Configure `known_hosts` for host-key verification instead.
#[serde(skip_serializing_if = "Option::is_none")]
host_key_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -666,7 +665,9 @@ pub enum FleetWorkerAuth {
/// Known hosts file for host-key verification.
#[serde(skip_serializing_if = "Option::is_none")]
known_hosts: Option<PathBuf>,
/// Expected host key fingerprint for pinning.
/// Legacy metadata; does not enforce key pinning. The SSH host adapter
/// rejects `FleetHostSpec::Ssh::host_key_fingerprint` when set; configure
/// `known_hosts` on the host spec for host-key verification instead.
#[serde(skip_serializing_if = "Option::is_none")]
host_key_fingerprint: Option<String>,
/// SSH user for the connection.
+5 -1
View File
@@ -20,7 +20,11 @@ quieter, and Fleet runs can be checked before they spend anything.
- Harden local runtime browser sessions, fleet SSH trust, agent continuation
ownership, task gate approval, plugin tool registration, bridge action tokens,
and release metadata credential forwarding.
and release metadata credential forwarding. Browser sessions recover across
reloads and new tabs, and stream tickets retry after transient failures.
Fleet SSH known-host checks support OpenSSH 7.x and later. SSH host configs
using `host_key_fingerprint` must migrate to `known_hosts` with verified host
keys; the unsupported fingerprint field now fails at configuration load.
### Contributors
+38 -1
View File
@@ -643,6 +643,10 @@ impl SshFleetHostAdapter {
args.push("-o".to_string());
args.push("GlobalKnownHostsFile=none".to_string());
args.push("-o".to_string());
// IgnoreUnknown predates OpenSSH 7.x. Older clients have no
// KnownHostsCommand; newer ones must disable that extra trust source.
args.push("IgnoreUnknown=KnownHostsCommand".to_string());
args.push("-o".to_string());
args.push("KnownHostsCommand=none".to_string());
args.push("-o".to_string());
args.push("VerifyHostKeyDNS=no".to_string());
@@ -2129,7 +2133,20 @@ mod tests {
.args
.contains(&"GlobalKnownHostsFile=none".to_string())
);
assert!(command.args.contains(&"KnownHostsCommand=none".to_string()));
let ignore = command
.args
.iter()
.position(|arg| arg == "IgnoreUnknown=KnownHostsCommand")
.expect("OpenSSH 7.x must ignore the newer KnownHostsCommand option");
let command_option = command
.args
.iter()
.position(|arg| arg == "KnownHostsCommand=none")
.expect("disable additional known-host sources on newer clients");
assert!(
ignore < command_option,
"IgnoreUnknown applies only to later options"
);
assert!(command.args.contains(&"VerifyHostKeyDNS=no".to_string()));
}
}
@@ -2140,6 +2157,26 @@ mod tests {
assert!(err.message.contains("configure known_hosts"));
}
#[test]
fn runtime_surface_review_documented_ssh_host_loads() {
let docs = include_str!("../../../../docs/zh_hans/FLEET.md");
let example = docs
.split_once("### Worker 认证")
.expect("worker authentication guidance")
.1
.split("```json\n")
.skip(1)
.filter_map(|block| {
serde_json::from_str::<serde_json::Value>(block.split("```").next()?).ok()
})
.find(|value| value["id"] == "builder-1")
.expect("documented SSH worker example");
let host: FleetHostSpec = serde_json::from_value(example["host"].clone()).unwrap();
let config = SshFleetHostConfig::from_host_spec(&host)
.expect("documented host must load without migration errors");
assert!(config.known_hosts.is_some());
}
#[test]
fn fleet_host_ssh_config_requires_explicit_safe_fields() {
let tmp = TempDir::new().unwrap();
+13 -1
View File
@@ -1914,7 +1914,7 @@ async fn web_bootstrap_sets_strict_cookie_once_and_preserves_v1_auth() -> Result
)
.await?
else {
eprintln!("needs socket run: loopback listener is unavailable");
tracing::warn!("needs socket run: loopback listener is unavailable");
return Ok(());
};
let client = crate::tls::reqwest_client_builder()
@@ -1989,6 +1989,18 @@ async fn web_bootstrap_sets_strict_cookie_once_and_preserves_v1_auth() -> Result
.split(';')
.next()
.context("missing web session cookie pair")?;
for site in ["same-origin", "none"] {
let reloaded = client
.get(format!("http://{addr}/"))
.header(header::COOKIE, cookie_pair)
.header("sec-fetch-site", site)
.send()
.await?;
assert_eq!(reloaded.headers()[header::CACHE_CONTROL], "no-store");
assert!(reloaded.text().await?.contains(&format!(
"name=\"codewhale-web-request\" content=\"{proof}\""
)));
}
for method in [reqwest::Method::GET, reqwest::Method::POST] {
for metadata in [false, true] {
for presented in [None, Some("wrong-proof")] {
+144 -28
View File
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use axum::Json;
use axum::body::Body;
use axum::extract::{ConnectInfo, Path, Request, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use uuid::Uuid;
@@ -34,7 +34,7 @@ pub(super) struct RuntimeWebState {
bootstrap: Arc<Mutex<Option<BootstrapCapability>>>,
session_token: Arc<str>,
request_proof: Arc<Mutex<Option<String>>>,
stream_ticket: Arc<Mutex<Option<BootstrapCapability>>>,
stream_tickets: Arc<Mutex<Vec<BootstrapCapability>>>,
session_expires_at: Instant,
}
@@ -69,7 +69,7 @@ impl RuntimeWebState {
}))),
session_token: session_token.into(),
request_proof: Arc::new(Mutex::new(None)),
stream_ticket: Arc::new(Mutex::new(None)),
stream_tickets: Arc::new(Mutex::new(Vec::new())),
session_expires_at: Instant::now() + session_ttl,
};
(state, nonce)
@@ -123,8 +123,9 @@ impl RuntimeWebState {
})
}
// Like mobile streams, reconnects consume a new short-lived ticket. Only
// one pending ticket is retained for this single browser session.
// Reconnects consume short-lived, single-use tickets. Retain up to 32
// pending tickets so independent tabs do not replace each other's ticket;
// excess requests evict the oldest pending ticket.
pub(super) fn refresh_stream_ticket(
&self,
cookie_header: Option<&str>,
@@ -138,9 +139,18 @@ impl RuntimeWebState {
Uuid::new_v4().simple(),
Uuid::new_v4().simple()
);
*self.stream_ticket.lock().unwrap_or_else(|p| p.into_inner()) = Some(BootstrapCapability {
let mut tickets = self
.stream_tickets
.lock()
.unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
tickets.retain(|issued| now < issued.expires_at);
if tickets.len() == 32 {
tickets.remove(0);
}
tickets.push(BootstrapCapability {
nonce: ticket.clone(),
expires_at: Instant::now() + super::mobile::STREAM_TICKET_TTL,
expires_at: now + super::mobile::STREAM_TICKET_TTL,
});
Some(ticket)
}
@@ -153,17 +163,20 @@ impl RuntimeWebState {
if !self.matches_session_cookie(cookie_header) {
return false;
}
let mut slot = self.stream_ticket.lock().unwrap_or_else(|p| p.into_inner());
let matches = slot.as_ref().is_some_and(|issued| {
Instant::now() < issued.expires_at
&& ticket.is_some_and(|ticket| {
constant_time_eq(ticket.as_bytes(), issued.nonce.as_bytes())
})
});
if matches {
*slot = None;
}
matches
let mut tickets = self
.stream_tickets
.lock()
.unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
tickets.retain(|issued| now < issued.expires_at);
let Some(index) = tickets.iter().position(|issued| {
ticket
.is_some_and(|ticket| constant_time_eq(ticket.as_bytes(), issued.nonce.as_bytes()))
}) else {
return false;
};
tickets.remove(index);
true
}
pub(super) fn matches_session_cookie(&self, cookie_header: Option<&str>) -> bool {
@@ -235,11 +248,35 @@ pub(super) async fn refresh_stream_ticket(
response
}
pub(super) async fn web_page(State(state): State<RuntimeApiState>) -> Response {
if state.web.is_none() {
pub(super) async fn web_page(State(state): State<RuntimeApiState>, headers: HeaderMap) -> Response {
let Some(web) = state.web.as_ref() else {
return not_found();
};
web_page_response(web, &headers)
}
fn web_page_response(web: &RuntimeWebState, headers: &HeaderMap) -> Response {
let mut html = WEB_HTML.to_owned();
// Recover the origin-scoped proof for reloads and independent tabs. A
// cookie alone is insufficient: require same-origin or direct navigation
// Fetch Metadata. Older clients without it still use the bootstrap proof.
// Proofs are generated hex strings, so no HTML escaping is needed here.
if headers
.get("sec-fetch-site")
.is_some_and(|site| site == "same-origin" || site == "none")
&& web.matches_session_cookie(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()))
&& let Some(proof) = web
.request_proof
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_deref()
{
html = html.replace(
"name=\"codewhale-web-request\" content=\"\"",
&format!("name=\"codewhale-web-request\" content=\"{proof}\""),
);
}
secured_asset("text/html; charset=utf-8", WEB_HTML)
secured_asset("text/html; charset=utf-8", html)
}
pub(super) async fn web_styles(State(state): State<RuntimeApiState>) -> Response {
@@ -296,7 +333,7 @@ fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
== 0
}
fn secured_asset(content_type: &'static str, body: &'static str) -> Response {
fn secured_asset(content_type: &'static str, body: impl IntoResponse) -> Response {
let mut response = body.into_response();
secure_headers(&mut response, content_type);
response
@@ -352,12 +389,7 @@ mod tests {
.unwrap();
assert!(!web.consume_stream_ticket(None, Some(&ticket)));
assert!(!web.consume_stream_ticket(Some(&cookie), Some("wrong-ticket")));
web.stream_ticket
.lock()
.unwrap()
.as_mut()
.unwrap()
.expires_at = Instant::now();
web.stream_tickets.lock().unwrap()[0].expires_at = Instant::now();
assert!(!web.consume_stream_ticket(Some(&cookie), Some(&ticket)));
let ticket = web
.refresh_stream_ticket(Some(&cookie), Some(&proof))
@@ -368,6 +400,90 @@ mod tests {
assert!(!expired.consume_stream_ticket(Some(&cookie), Some(&ticket)));
}
#[tokio::test]
async fn runtime_surface_review_web_page_recovers_proof_for_reload_and_second_tab() {
let (web, nonce) = RuntimeWebState::new();
let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
let cookie = web_session_cookie(&token);
for site in [
None,
Some("same-origin"),
Some("none"),
Some("same-site"),
Some("cross-site"),
] {
for valid_cookie in [false, true] {
for expired in [false, true] {
let mut session = web.clone();
if expired {
session.session_expires_at = Instant::now();
}
let mut headers = HeaderMap::new();
if valid_cookie {
headers.insert(header::COOKIE, cookie.parse().unwrap());
}
if let Some(site) = site {
headers.insert("sec-fetch-site", site.parse().unwrap());
}
let response = web_page_response(&session, &headers);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::CONTENT_SECURITY_POLICY]
.to_str()
.unwrap()
.contains("frame-ancestors 'none'")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = std::str::from_utf8(&body).unwrap();
let recovered = html.contains(&format!(
"name=\"codewhale-web-request\" content=\"{proof}\""
));
assert_eq!(
recovered,
valid_cookie && !expired && matches!(site, Some("same-origin" | "none")),
"site={site:?}, valid_cookie={valid_cookie}, expired={expired}"
);
assert!(!html.contains(&token));
if recovered {
assert!(session.matches_request(Some(&cookie), Some(&proof)));
}
}
}
}
assert_eq!(
web.consume(&nonce, "127.0.0.1".parse().unwrap()),
Err(BootstrapError::Invalid)
);
}
#[test]
fn runtime_surface_review_web_tabs_keep_independent_bounded_tickets() {
let (web, nonce) = RuntimeWebState::new();
let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
let cookie = web_session_cookie(&token);
let first = web
.refresh_stream_ticket(Some(&cookie), Some(&proof))
.unwrap();
let second = web
.refresh_stream_ticket(Some(&cookie), Some(&proof))
.unwrap();
assert!(web.consume_stream_ticket(Some(&cookie), Some(&first)));
assert!(web.consume_stream_ticket(Some(&cookie), Some(&second)));
assert!(!web.consume_stream_ticket(Some(&cookie), Some(&first)));
assert!(!web.consume_stream_ticket(Some(&cookie), Some(&second)));
let oldest = web
.refresh_stream_ticket(Some(&cookie), Some(&proof))
.unwrap();
for _ in 0..32 {
web.refresh_stream_ticket(Some(&cookie), Some(&proof))
.unwrap();
}
assert_eq!(web.stream_tickets.lock().unwrap().len(), 32);
assert!(!web.consume_stream_ticket(Some(&cookie), Some(&oldest)));
}
#[test]
fn bootstrap_is_loopback_only_one_time_and_expires() {
let (state, nonce) =
+155 -119
View File
@@ -483,11 +483,12 @@ export function eventStreamUrl(threadId, latestSeq, ticket = "") {
return ticket ? `${url}&web_stream_ticket=${encodeURIComponent(ticket)}` : url;
}
export function createWebSessionFetch({ location, history, storage, fetch }) {
export function createWebSessionFetch({ location, history, storage, fetch, pageProof = "" }) {
const key = "codewhale_web_request_proof";
let proof = new URLSearchParams(location.hash.slice(1)).get("p") || "";
const fragmentProof = new URLSearchParams(location.hash.slice(1)).get("p");
if (fragmentProof) history.replaceState(null, "", location.pathname + location.search);
let proof = pageProof || fragmentProof || "";
if (proof) {
history.replaceState(null, "", location.pathname + location.search);
try { storage().setItem(key, proof); } catch (_) { /* Memory-only session. */ }
} else {
try { proof = storage().getItem(key) || ""; } catch (_) { /* Storage unavailable. */ }
@@ -502,6 +503,117 @@ export function createWebSessionFetch({ location, history, storage, fetch }) {
};
}
// One owner for pending ticket requests, EventSources, and reconnect timers.
// Ticket HTTP 401/403 ends retries; EventSource hides HTTP status, so its
// errors retry through a fresh ticket request. Snapshot recovery owns its
// own retry when it requests an open handshake.
export function createStreamConnector({
app, api, EventSource, receive, setConnection, showStatus,
setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout,
}) {
let attempt = 0;
let retryDelay = 900;
function stopStream() {
attempt += 1;
if (app.streamOpenCancel) app.streamOpenCancel();
app.streamOpenCancel = null;
if (app.stream) app.stream.close();
app.stream = null;
if (app.reconnectTimer) clearTimeout(app.reconnectTimer);
app.reconnectTimer = null;
}
async function connectStream(threadId, sequence, generation, waitForOpen = false) {
if (generation !== app.generation || threadId !== app.selectedThreadId) return;
stopStream();
const currentAttempt = attempt;
const isCurrent = () => currentAttempt === attempt
&& generation === app.generation && threadId === app.selectedThreadId;
const reconnect = () => {
if (!isCurrent() || app.stream !== null) return;
setConnection("", "Reconnecting to local runtime…");
app.reconnectTimer = setTimeout(() => {
app.reconnectTimer = null;
if (!isCurrent() || app.stream !== null) return;
void connectStream(threadId, app.threadState.latestSeq, generation).catch(() => {});
}, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30_000);
};
let ticket;
try {
({ stream_ticket: ticket } = await api("/__codewhale/web/stream-ticket", { method: "POST" }));
} catch (error) {
if (!isCurrent()) return;
showStatus(error.message);
setConnection("error", "Runtime connection failed");
if (!waitForOpen && error.status !== 401 && error.status !== 403) reconnect();
throw error;
}
if (!isCurrent() || app.stream !== null) return;
const stream = new EventSource(eventStreamUrl(threadId, sequence, ticket), { withCredentials: true });
app.stream = stream;
let opened = false;
let resolveOpen;
let rejectOpen;
const openHandshake = waitForOpen
? new Promise((resolve, reject) => {
resolveOpen = resolve;
rejectOpen = reject;
})
: undefined;
const cancelOpen = () => {
if (!rejectOpen) return;
const reject = rejectOpen;
resolveOpen = null;
rejectOpen = null;
reject(new Error("Runtime event stream open was cancelled"));
};
if (waitForOpen) app.streamOpenCancel = cancelOpen;
const clearOpenHandshake = () => {
if (app.streamOpenCancel === cancelOpen) app.streamOpenCancel = null;
};
stream.onopen = () => {
if (!isCurrent() || app.stream !== stream) {
stream.close();
return;
}
retryDelay = 900;
opened = true;
setConnection("ready", "Local runtime connected");
clearOpenHandshake();
if (resolveOpen) resolveOpen();
resolveOpen = null;
rejectOpen = null;
};
for (const name of STREAM_EVENT_NAMES) {
stream.addEventListener(name, (message) => {
if (isCurrent() && app.stream === stream) receive(stream, message, threadId, generation);
});
}
stream.onerror = () => {
if (app.stream !== stream) {
stream.close();
return;
}
stream.close();
app.stream = null;
if (generation !== app.generation || threadId !== app.selectedThreadId) return;
if (waitForOpen && !opened) {
clearOpenHandshake();
const reject = rejectOpen;
resolveOpen = null;
rejectOpen = null;
reject?.(new Error("Runtime event stream did not reopen"));
return;
}
reconnect();
};
return openHandshake;
}
return { connectStream, stopStream };
}
export function saveDraft(drafts, threadId, value) {
if (!threadId) return;
if (value) drafts.set(threadId, value);
@@ -957,6 +1069,7 @@ function startBrowserClient() {
location: window.location,
history: window.history,
storage: () => window.sessionStorage,
pageProof: document.querySelector('meta[name="codewhale-web-request"]')?.content,
fetch: (...args) => fetch(...args),
});
@@ -973,7 +1086,7 @@ function startBrowserClient() {
if (response.status === 401) {
message = "This browser session is not authenticated. Restart `codewhale web` to open a fresh one-time session.";
}
throw new Error(message);
throw Object.assign(new Error(message), { status: response.status });
}
if (response.status === 204) return null;
const contentType = response.headers.get("content-type") || "";
@@ -1186,14 +1299,37 @@ function startBrowserClient() {
dom.peek.append(resume);
}
function stopStream() {
if (app.streamOpenCancel) app.streamOpenCancel();
app.streamOpenCancel = null;
if (app.stream) app.stream.close();
app.stream = null;
if (app.reconnectTimer) clearTimeout(app.reconnectTimer);
app.reconnectTimer = null;
}
const { connectStream, stopStream } = createStreamConnector({
app, api, EventSource, setConnection, showStatus,
receive: (stream, message, threadId, generation) => {
try {
const envelope = JSON.parse(message.data);
if (runtimeEventContinuity(app.threadState, envelope) === "gap") {
app.streamGap = true;
renderStreamCursor();
showStatus("Runtime event continuity changed; refreshing the thread snapshot…");
void recoverProjection(threadId, generation, stream);
return;
}
if (!applyRuntimeEvent(app.threadState, envelope)) return;
renderAll(true);
if (
envelope.event === "turn.completed"
|| envelope.event === "thread.updated"
|| envelope.event === "approval.required"
|| envelope.event === "approval.decided"
|| envelope.event === "approval.timeout"
|| envelope.event === "user_input.required"
|| envelope.event === "user_input.answered"
|| envelope.event === "user_input.canceled"
) {
loadThreads().catch((error) => showStatus(error.message));
}
} catch (error) {
showStatus(`Could not read a Runtime event: ${error.message}`);
}
},
});
async function selectThread(threadId) {
if (!threadId) return;
@@ -1237,105 +1373,6 @@ function startBrowserClient() {
}
}
async function connectStream(threadId, sequence, generation, waitForOpen = false) {
if (generation !== app.generation || threadId !== app.selectedThreadId) return;
if (app.streamOpenCancel) app.streamOpenCancel();
app.streamOpenCancel = null;
if (app.stream) app.stream.close();
const { stream_ticket: ticket } = await api("/__codewhale/web/stream-ticket", { method: "POST" });
if (generation !== app.generation || threadId !== app.selectedThreadId) return;
const stream = new EventSource(eventStreamUrl(threadId, sequence, ticket), { withCredentials: true });
app.stream = stream;
let opened = false;
let resolveOpen;
let rejectOpen;
const openHandshake = waitForOpen
? new Promise((resolve, reject) => {
resolveOpen = resolve;
rejectOpen = reject;
})
: undefined;
const cancelOpen = () => {
if (!rejectOpen) return;
const reject = rejectOpen;
resolveOpen = null;
rejectOpen = null;
reject(new Error("Runtime event stream open was cancelled"));
};
if (waitForOpen) app.streamOpenCancel = cancelOpen;
const clearOpenHandshake = () => {
if (app.streamOpenCancel === cancelOpen) app.streamOpenCancel = null;
};
stream.onopen = () => {
opened = true;
setConnection("ready", "Local runtime connected");
clearOpenHandshake();
if (resolveOpen) resolveOpen();
resolveOpen = null;
rejectOpen = null;
};
const receive = (message) => {
if (
app.stream !== stream
|| generation !== app.generation
|| threadId !== app.selectedThreadId
) return;
try {
const envelope = JSON.parse(message.data);
if (runtimeEventContinuity(app.threadState, envelope) === "gap") {
app.streamGap = true;
renderStreamCursor();
showStatus("Runtime event continuity changed; refreshing the thread snapshot…");
void recoverProjection(threadId, generation, stream);
return;
}
if (!applyRuntimeEvent(app.threadState, envelope)) return;
renderAll(true);
if (
envelope.event === "turn.completed"
|| envelope.event === "thread.updated"
|| envelope.event === "approval.required"
|| envelope.event === "approval.decided"
|| envelope.event === "approval.timeout"
|| envelope.event === "user_input.required"
|| envelope.event === "user_input.answered"
|| envelope.event === "user_input.canceled"
) {
loadThreads().catch((error) => showStatus(error.message));
}
} catch (error) {
showStatus(`Could not read a Runtime event: ${error.message}`);
}
};
for (const name of STREAM_EVENT_NAMES) stream.addEventListener(name, receive);
stream.onerror = () => {
if (app.stream !== stream) {
stream.close();
return;
}
stream.close();
app.stream = null;
if (generation !== app.generation || threadId !== app.selectedThreadId) return;
if (waitForOpen && !opened) {
clearOpenHandshake();
const reject = rejectOpen;
resolveOpen = null;
rejectOpen = null;
reject?.(new Error("Runtime event stream did not reopen"));
return;
}
setConnection("", "Reconnecting to local runtime…");
app.reconnectTimer = setTimeout(
() => connectStream(threadId, app.threadState.latestSeq, generation).catch((error) => {
showStatus(error.message);
setConnection("error", "Runtime connection failed");
}),
900,
);
};
return openHandshake;
}
async function recoverProjection(threadId, generation, sourceStream = null) {
if (
generation !== app.generation
@@ -1343,10 +1380,7 @@ function startBrowserClient() {
|| (sourceStream && app.stream !== sourceStream)
) return;
if (app.stream) app.stream.close();
app.stream = null;
if (app.reconnectTimer) clearTimeout(app.reconnectTimer);
app.reconnectTimer = null;
stopStream();
setConnection("", "Refreshing thread snapshot…");
try {
@@ -1369,10 +1403,12 @@ function startBrowserClient() {
if (generation !== app.generation || threadId !== app.selectedThreadId) return;
showStatus(`Could not refresh the thread snapshot: ${error.message}`);
setConnection("error", "Runtime recovery failed");
app.reconnectTimer = setTimeout(
() => recoverProjection(threadId, generation),
900,
);
if (error.status !== 401 && error.status !== 403) {
app.reconnectTimer = setTimeout(
() => recoverProjection(threadId, generation),
900,
);
}
}
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="codewhale-web-request" content="">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#020711">
<title>Codewhale</title>
@@ -12,6 +12,7 @@ import {
claimInFlight,
createThreadState,
createWebSessionFetch,
createStreamConnector,
eventStreamUrl,
formatRuntimeProvenance,
imageInputPresentation,
@@ -1092,3 +1093,144 @@ test("web session fetch keeps request proof outside cookies and strips bootstrap
await memoryOnly("/v1/threads");
assert.equal(calls.at(-1).options.headers.get("x-codewhale-web-request"), "memory-proof");
});
test("web session recovers from the page in an independent tab or without storage", async () => {
for (const storedProof of [null, "old-process-proof", "unavailable"]) {
const stored = new Map([["codewhale_web_request_proof", storedProof]]);
const calls = [];
const request = createWebSessionFetch({
location: { hash: "" },
history: { replaceState: () => assert.fail("no fragment to clear") },
storage: () => {
if (storedProof === "unavailable") throw new Error("storage unavailable");
return { getItem: (key) => stored.get(key), setItem: (key, value) => stored.set(key, value) };
},
pageProof: "current-page-proof",
fetch: async (_path, options) => { calls.push(options); return { ok: true }; },
});
await request("/v1/threads");
await request("/__codewhale/web/stream-ticket", { method: "POST" });
assert.equal(calls.length, 2);
for (const options of calls) {
assert.equal(options.headers.get("x-codewhale-web-request"), "current-page-proof");
}
if (storedProof !== "unavailable") {
assert.equal(stored.get("codewhale_web_request_proof"), "current-page-proof");
}
}
});
function streamHarness(api) {
const app = {
generation: 1, selectedThreadId: "thread-a", threadState: { latestSeq: 4 },
stream: null, streamOpenCancel: null, reconnectTimer: null,
};
const streams = [];
const timers = new Map();
const statuses = [];
let nextTimer = 0;
class EventSource {
constructor(url) { this.url = url; this.closed = false; streams.push(this); }
close() { this.closed = true; }
addEventListener() {}
}
const connector = createStreamConnector({
app, api, EventSource, receive: () => {}, setConnection: () => {},
showStatus: (message) => statuses.push(message),
setTimeout: (callback, delay) => { timers.set(++nextTimer, { callback, delay }); return nextTimer; },
clearTimeout: (id) => timers.delete(id),
});
return {
...connector, app, streams, timers, statuses,
tick: async () => {
assert.equal(timers.size, 1, "exactly one reconnect should be pending");
const [id, { callback, delay }] = timers.entries().next().value;
timers.delete(id);
callback();
await new Promise((resolve) => setImmediate(resolve));
return delay;
},
};
}
test("stream retries ticket network and 5xx failures with capped backoff and resumes the latest cursor", async () => {
let requests = 0;
const h = streamHarness(async () => {
requests += 1;
if (requests === 2) throw new TypeError("network unavailable");
if (requests > 2 && requests < 10) throw Object.assign(new Error("temporarily unavailable"), { status: 503 });
return { stream_ticket: `ticket-${requests}` };
});
await h.connectStream("thread-a", 4, 1);
h.streams[0].onopen();
h.streams[0].onerror();
assert.equal(h.streams[0].closed, true);
h.app.threadState.latestSeq = 9;
const delays = [];
while (h.streams.length === 1) delays.push(await h.tick());
assert.deepEqual(delays, [900, 1800, 3600, 7200, 14400, 28800, 30000, 30000, 30000]);
assert.match(h.streams[1].url, /since_seq=9&web_stream_ticket=ticket-10$/);
h.streams[1].onopen();
h.streams[1].onerror();
assert.equal(await h.tick(), 900, "opening a stream resets backoff");
h.stopStream();
assert.equal(h.timers.size, 0);
});
test("stream stops ticket retries on 401 and 403", async () => {
for (const status of [401, 403]) {
let requests = 0;
const h = streamHarness(async () => {
if (++requests === 1) return { stream_ticket: "first" };
throw Object.assign(new Error("session ended"), { status });
});
await h.connectStream("thread-a", 4, 1);
h.app.stream.onerror();
await h.tick();
assert.equal(h.timers.size, 0);
assert.equal(h.app.stream, null);
assert.deepEqual(h.statuses, ["session ended"]);
}
});
test("stream ignores superseded ticket completions and failures, including stopped connections", async () => {
const pending = [];
const h = streamHarness(() => new Promise((resolve, reject) => pending.push({ resolve, reject })));
const first = h.connectStream("thread-a", 4, 1);
const second = h.connectStream("thread-a", 4, 1);
pending[1].resolve({ stream_ticket: "current" });
await second;
pending[0].resolve({ stream_ticket: "stale" });
await first;
assert.equal(h.streams.length, 1, "a late ticket cannot create an orphan stream");
assert.match(h.app.stream.url, /current$/);
const third = h.connectStream("thread-a", 4, 1);
assert.equal(h.streams[0].closed, true);
h.stopStream();
pending[2].resolve({ stream_ticket: "stopped" });
await third;
assert.equal(h.streams.length, 1);
assert.equal(h.app.stream, null);
const fourth = h.connectStream("thread-a", 4, 1);
h.app.generation += 1;
pending[3].reject(new Error("old selection failed"));
await fourth;
assert.equal(h.timers.size, 0);
assert.deepEqual(h.statuses, []);
});
test("stream recovery handshake leaves retries to snapshot recovery until open", async () => {
const h = streamHarness(async () => ({ stream_ticket: "recovery" }));
const opened = h.connectStream("thread-a", 4, 1, true);
await new Promise((resolve) => setImmediate(resolve));
h.app.stream.onerror();
await assert.rejects(opened, /did not reopen/);
assert.equal(h.timers.size, 0);
const replacement = h.connectStream("thread-a", 4, 1, true);
await new Promise((resolve) => setImmediate(resolve));
h.app.stream.onopen();
await replacement;
h.app.stream.onerror();
assert.equal(h.timers.size, 1, "normal reconnect resumes after the handshake");
h.stopStream();
});
+14 -5
View File
@@ -568,11 +568,20 @@ local terminal and transits the OS browser launcher's argument list. A same-user
process could race the browser to the exchange, which is why the capability is
single-use, loopback-only, and expires after ten minutes — and why a same-user
attacker has strictly easier local avenues than this race.
Existing bearer/header/cookie authorization for `/v1/*` is unchanged outside
web mode. In web mode, cookie-authenticated unsafe requests must also carry the
exact local web origin, and Fetch Metadata identifying a cross-origin cookie
request is rejected. Explicit bearer and Runtime-token header clients keep
their existing behavior.
Web fetches require the session cookie plus an origin-scoped request proof;
streams use a fresh single-use ticket. The initial redirect carries the proof
in a fragment, which the client removes and saves in origin-scoped
`sessionStorage`. On reload or in a second tab, an authenticated `GET /` also
embeds the proof in a meta tag when `Sec-Fetch-Site` is `same-origin` or `none`
(direct navigation). The page uses `no-store`, disallows framing, and grants no
cross-origin read access. This lets a new tab recover without reusing the
bootstrap URL, including when storage is unavailable. Clients without Fetch
Metadata can only reuse the fragment or their existing stored proof; recovery
does not extend the server session or replace an expired cookie.
Cross-origin Fetch Metadata or a mismatched Origin is rejected on web API
requests. Explicit bearer and Runtime-token header clients keep their existing
behavior. Transient stream-ticket failures retry with capped backoff; HTTP
401/403 stops ticket retries until a fresh session is opened.
The embedded client provides a responsive thread/search rail, Runtime-owned
session facts, transcript and tool receipts, and a bottom composer. It can
+2 -3
View File
@@ -488,11 +488,11 @@ Secret 引用在日志与 ledger 条目中一律脱敏:`<secret:env.GH_TOKEN>`
workers 用四种方法之一向 fleet manager 认证:
- **None** — 共享相同 uid 的本地 worker(默认)
- **SSH key** — 可选的宿主密钥指纹固定与 known-hosts 验证。`host_key_fingerprint` 字段(SHA256:...)固定预期服务器密钥,防止首次连接时的 MITM 攻击。
- **SSH key** — 使用 known-hosts 文件严格验证宿主密钥。SSH host 配置不支持 `host_key_fingerprint`;设置该字段会导致配置错误,请改用 `known_hosts`。
- **Token** — 从 `FleetSecretRef` 解析的 bearer token,适用于 fleet 代理后的远程 worker。
- **mTLS** — 带客户端证书与秘密支撑私钥的相互 TLS。
SSH workers 在生产中应始终设置 `host_key_fingerprint`:
SSH workers 应配置包含已核验宿主密钥的 `known_hosts` 文件:
```json
{
@@ -505,7 +505,6 @@ SSH workers 在生产中应始终设置 `host_key_fingerprint`:
"user": "codewhale",
"port": 22,
"identity": "~/.ssh/codewhale_fleet",
"host_key_fingerprint": "SHA256:aLGqZo1M6c...",
"known_hosts": "~/.ssh/known_hosts",
"working_directory": "/srv/codewhale/work",
"env_allowlist": ["CODEWHALE_PROFILE"],