mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
3a9e4d8bc2
commit
fad53facdf
11 files changed
+524
-163
No files matched your search
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")] {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user