A fixes a genuine auth bypass (mock_user OAuth was previously unguarded, letting anyone log in as any GitHub identity) and makes vote-actor resolution fail-closed instead of silently falling back to anonymous, plus pins the durable dependency by rev and adds Secure cookie flags and open-redirect hardening — all backed by new unit tests. B fixes a real but narrower bug (a shared browser delegate blocking concurrent human votes) with a good regression test, but its scope and security impact are smaller than the auth-bypass and fail-closed fixes in A.
constitution · epochs · watch · epoch 3
c_94135a1c4c58 (tommy-mor) vs c_25172cf8caa0 (tommy-mor)
download prompt · raw event · cmp_765031dad771ec
council reasoning
B fixes a core multi-user breakage: a shared WEB_BROWSER_AGENT sentinel bound on the first browser vote and blocked later humans with AgentBound, by making PendingSession.agent optional and posting browser votes with no delegate, plus a two-user integration test. A’s fail-closed resolve_vote_actor, mock-OAuth gate, Secure cookies, and return_to hardening are real security wins, but they refine an existing auth path rather than removing a flawed design that stopped the product’s main multi-user flow.
Side A delivers several lasting security and correctness improvements: voting now fails closed instead of silently falling back to an anonymous actor, auth cookies gain conditional Secure handling, OAuth mock login is gated behind an environment flag, open-redirect sanitization is tightened, and the external `durable` dependency is pinned by immutable revision. Side B fixes an important multi-user bug by removing shared browser sentinel delegates and updating the session model/tests so browser users have no delegate, but its scope is narrower than A's combination of security hardening, authentication correctness, and reproducibility improvements.
sides
A — c_94135a1c4c58 (tommy-mor)
message
[880eb778] Harden auth: fail-closed votes, mock OAuth gate, Secure cookies. Also show the current alias in the top nav and pin durable by rev. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/AGENTS.md b/AGENTS.md
index babb889d6fbfb1fa7176c9e6b7544ae17b61dd2e..6e0fd8ebb65d665c9c1438e3275971d62b98fd95 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,11 +10,11 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
- **Bootstrap script**: `./scripts/cursor-env-install.sh` (also run via `.cursor/environment.json` on Cloud Agent boot) installs Playwright Chromium, Babashka, bbin, `clj-paren-repair`, and warms the RocksDB build.
- **Rust 1.88+** is required (`rust-toolchain.toml`). The Cloud Dockerfile and `cursor-env-install.sh` install **rustup** 1.88.0 first so `cargo` works while Playwright/Clojure bootstrap continues. Do not rely on `/usr/local/cargo` (often missing or stale).
-- **RocksDB / `durable`**: Ubuntu’s default `c++` is often **clang** without libc++ headers. Set **`CXX=g++`** and **`RUSTFLAGS="-C linker=g++"`** (or `CC=gcc`) before `cargo build` / `cargo test` — both are set in the bootstrap script and `.cursor/environment.json`.
+- **RocksDB / `durable`**: `durable` is an external git dependency (`tommy-mor/durable`, pinned by rev in `server/Cargo.toml`). Ubuntu’s default `c++` is often **clang** without libc++ headers. Set **`CXX=g++`** and **`RUSTFLAGS="-C linker=g++"`** (or `CC=gcc`) before `cargo build` / `cargo test` — both are set in the bootstrap script and `.cursor/environment.json`.
- **System packages** for builds: `build-essential`, `g++`, `clang`, `libclang-dev`, `pkg-config`, `libssl-dev`, `openjdk-21-jre-headless` (for `reqwest` / OpenSSL, `librocksdb-sys`, `zstd-sys` / bindgen, and **bbin** / Clojure JVM). The bootstrap sets **`JAVA_HOME`** when Java is present.
- **Clojure CLI 1.12.0.1530** (used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
- **Babashka / bbin / clj-paren-repair**: installed by `cursor-env-install.sh` into `~/.local/bin` (bb tasks in `bb.edn`, delimiter repair for Clojure edits).
-- **Playwright** (Spel browser tests in `test/vote_compare.clj`): Chromium via `clojure -M -e "(com.microsoft.playwright.CLI/main ...)"` — run once after clone or use the bootstrap script.
+- **Playwright** (Spel browser tests in `test/vote_compare.clj` / `test/auth_login.clj`): Chromium via `clojure -M -e "(com.microsoft.playwright.CLI/main ...)"` — run once after clone or use the bootstrap script.
### Commands (see also `TEST.sh`)
@@ -34,13 +34,17 @@ Environment variables (defaults in `server/src/state.rs`):
- `PORT` — default `8080`
- `SORTER2_DATA_DIR` — default `./data` (created on startup)
- `SORTER2_EVENT_LOG` — default `{data_dir}/events.jsonl`
+- `SORTER2_BASE_URL` — public origin (also drives Secure cookies when `https://`)
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth (optional; login disabled if unset)
+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` (tests only)
Health check: `GET /healthz` → `ok`.
-Core UI flow: `POST /ui` with form field `__rpc__` (JSON). Example vote:
+Core UI flow: `POST /ui` with form field `__rpc__` (JSON). Votes require a session cookie (sign in via `/login`). Example vote:
```bash
curl -sf -X POST http://127.0.0.1:8080/ui \
+ --cookie "sorter2_session=..." \
--data-urlencode '__rpc__={"action":"record_vote","a":"alpha","b":"beta","ratio_left":2,"ratio_right":1}'
```
diff --git a/Cargo.lock b/Cargo.lock
index aa02997ad85777195f135bfd9456bcee0fc9a590..1f8690f3e486d099577a32c2ece48caf57ea7160 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -414,7 +414,7 @@ dependencies = [
[[package]]
name = "durable"
version = "0.2.0"
-source = "git+https://github.com/tommy-mor/durable.git?branch=main#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
+source = "git+https://github.com/tommy-mor/durable.git?rev=a6c14eaa809693140eea0c22b07ef24d8e74adaf#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
dependencies = [
"ciborium",
"durable-derive",
@@ -426,7 +426,7 @@ dependencies = [
[[package]]
name = "durable-derive"
version = "0.2.0"
-source = "git+https://github.com/tommy-mor/durable.git?branch=main#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
+source = "git+https://github.com/tommy-mor/durable.git?rev=a6c14eaa809693140eea0c22b07ef24d8e74adaf#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
dependencies = [
"proc-macro2",
"quote",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index dfa39beddecfa37dcdeaa602cb30f4b547528fbb..bd88687fb0ba47d68f2c08eb5e11d0e08b7c4398 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -25,7 +25,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
rand = "0.8"
urlencoding = "2"
url = "2"
-durable = { git = "https://github.com/tommy-mor/durable.git", branch = "main" }
+durable = { git = "https://github.com/tommy-mor/durable.git", rev = "a6c14eaa809693140eea0c22b07ef24d8e74adaf" }
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index b86581b1f337650564274254d840e8a75b49524d..9da62ffbed07eb28729aa3160bf33b07ce0d7945 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -71,10 +71,16 @@ pub async fn post_ui_html(
return resp;
}
let parent = parent_from_scope(&scope);
- let actor = resolve_vote_actor(
+ let actor = match resolve_vote_actor(
state.projection_store.db(),
session_id_from_jar(&jar).as_deref(),
- );
+ ) {
+ Ok(actor) => actor,
+ Err(_) => {
+ return vote_auth_redirect(&state, &jar)
+ .unwrap_or_else(|| login_redirect_js().into_response());
+ }
+ };
if let Err(e) = state
.record_vote(&parent, &a, &b, ratio_left, ratio_right, &actor)
.await
diff --git a/server/src/auth/config.rs b/server/src/auth/config.rs
index a1f042c655bf3e5234eeb87a7d889f64592807fb..a5976af9a52ea207b35ae87bd1fe927c47a477ca 100644
--- a/server/src/auth/config.rs
+++ b/server/src/auth/config.rs
@@ -1,9 +1,42 @@
pub const AUTH_RETURN_COOKIE: &str = "sorter2_auth_return";
+/// Allow `mock_user` on `/auth/github` (test harness only).
+pub fn mock_oauth_allowed() -> bool {
+ matches!(
+ std::env::var("SORTER2_ALLOW_MOCK_OAUTH").as_deref(),
+ Ok("1") | Ok("true") | Ok("TRUE")
+ )
+}
+
+/// Set the Secure flag on auth cookies when serving over HTTPS.
+pub fn cookies_secure() -> bool {
+ std::env::var("SORTER2_BASE_URL")
+ .map(|u| u.starts_with("https://"))
+ .unwrap_or(false)
+}
+
pub fn sanitize_return_to(raw: &str) -> String {
let s = raw.trim();
- if s.is_empty() || !s.starts_with('/') || s.starts_with("//") {
+ if s.is_empty() || !s.starts_with('/') || s.starts_with("//") || s.starts_with("/\\") {
+ return "/".to_string();
+ }
+ // Reject scheme-relative and protocol-smuggling forms.
+ if s.contains("://") || s.contains('\\') {
return "/".to_string();
}
s.to_string()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sanitize_return_to_blocks_open_redirects() {
+ assert_eq!(sanitize_return_to(""), "/");
+ assert_eq!(sanitize_return_to("//evil.com"), "/");
+ assert_eq!(sanitize_return_to("/\\evil.com"), "/");
+ assert_eq!(sanitize_return_to("https://evil.com"), "/");
+ assert_eq!(sanitize_return_to("/vote?parent=x"), "/vote?parent=x");
+ }
+}
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 5ed535ba199fa736f0048c32623b14c3b1e5de2d..d4a85ef52c15dc35148e4c743f0d646cbbdb056d 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -26,7 +26,7 @@ use crate::{
ui_action::UI_RPC_FIELD,
};
-pub use session::{resolve_vote_actor, session_id_from_jar, VoteActor};
+pub use session::{nav_pseudonym, resolve_vote_actor, session_id_from_jar, VoteActor};
pub fn base_url_from_env(port: u16) -> String {
std::env::var("SORTER2_BASE_URL")
@@ -168,6 +168,10 @@ pub async fn login_page(
"login · sorter2",
login_body(session.as_ref(), &aliases, &providers),
state.views.get_views("/login"),
+ session
+ .as_ref()
+ .filter(|s| !s.pseudonym.trim().is_empty())
+ .map(|s| s.pseudonym.as_str()),
);
(jar, Html(markup.into_string())).into_response()
}
@@ -222,6 +226,7 @@ pub async fn alias_page(
"choose alias · sorter2",
body,
state.views.get_views("/login/alias"),
+ None,
)
.into_string(),
)
@@ -237,7 +242,12 @@ pub async fn github_start(
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());
let state_token = session::new_oauth_state();
- let url = oauth::authorize_url(&cfg, &state_token, query.mock_user.as_deref());
+ let mock_user = if config::mock_oauth_allowed() {
+ query.mock_user.as_deref()
+ } else {
+ None
+ };
+ let url = oauth::authorize_url(&cfg, &state_token, mock_user);
let jar = jar
.add(session::oauth_state_cookie_value(&state_token))
.add(session::auth_return_cookie_value(&return_to));
diff --git a/server/src/auth/session.rs b/server/src/auth/session.rs
index 09659240b9455c6fca12db5652e1d31cf8c2acfc..41df030ded3abcafc0ab3887ab769adf103f9aa0 100644
--- a/server/src/auth/session.rs
+++ b/server/src/auth/session.rs
@@ -5,7 +5,7 @@ use durable::{Db, Durability};
use rand::Rng;
use crate::{
- auth::config::AUTH_RETURN_COOKIE,
+ auth::config::{self, AUTH_RETURN_COOKIE},
fetch::now_ms,
identity::{DEFAULT_ACTOR_UUID, DEFAULT_PSEUDONYM},
storage_dto::{SessionDataV1, SESSION_DATA_VERSION},
@@ -37,6 +37,7 @@ pub struct VoteActor {
}
impl VoteActor {
+ /// Test / bench helper: seed votes as the default pseudonym without a session.
pub fn anon() -> Self {
Self {
pseudonym: DEFAULT_PSEUDONYM.to_string(),
@@ -70,20 +71,51 @@ fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
-pub fn resolve_vote_actor(db: &Db, session_id: Option<&str>) -> VoteActor {
- let Some(session_id) = session_id else {
- return VoteActor::anon();
- };
- let Ok(Some(session)) = load_session(db, session_id) else {
- return VoteActor::anon();
- };
- if session.expires_at <= now_ms() {
- return VoteActor::anon();
+fn build_cookie(name: &'static str, value: String) -> Cookie<'static> {
+ let mut builder = Cookie::build((name, value))
+ .http_only(true)
+ .same_site(SameSite::Lax)
+ .path("/");
+ if config::cookies_secure() {
+ builder = builder.secure(true);
+ }
+ builder.build()
+}
+
+fn clear_cookie(name: &'static str) -> Cookie<'static> {
+ let mut builder = Cookie::build((name, ""))
+ .http_only(true)
+ .same_site(SameSite::Lax)
+ .path("/")
+ .removal();
+ if config::cookies_secure() {
+ builder = builder.secure(true);
+ }
+ builder.build()
+}
+
+/// Resolve the vote actor from a live session. Fail-closed: never falls back to anon.
+pub fn resolve_vote_actor(db: &Db, session_id: Option<&str>) -> Result<VoteActor, &'static str> {
+ let session_id = session_id.ok_or("sign in to vote")?;
+ let session = load_valid_session(db, session_id).ok_or("session expired")?;
+ if !session_has_pseudonym(&session) {
+ return Err("choose an alias first");
}
let trust_weight = user_trust_weight(db, &session.uuid).unwrap_or(1.0);
- VoteActor {
+ Ok(VoteActor {
pseudonym: session.current_pseudonym,
trust_weight,
+ })
+}
+
+/// Display name for the top nav, if any session is active.
+pub fn nav_pseudonym(db: &Db, jar: &CookieJar) -> Option<String> {
+ let sessio
… preview truncated; 10,040 characters omittedB — c_25172cf8caa0 (tommy-mor)
message
[b9476669] Remove browser sentinel delegates so multi-user votes work. Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/cli/src/main.rs b/cli/src/main.rs
index c4f1494df3aedbd8b883aea6249579aa8336abfe..af3e4fee876910a44f6f872b24821bc541a23e20 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1741,8 +1741,8 @@ async fn run() -> Result<()> {
tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
let poll: PendingSessionPollResponse =
expect_json(client.get(&poll_url).send().await?).await?;
- if !poll.agent.trim().is_empty() {
- agent_out = Some(poll.agent.clone());
+ if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ agent_out = Some(a.to_string());
}
if poll.complete {
token_out = poll.token;
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 50dc6af908412b16343829eae25154ebf4e19fca..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,18 +29,6 @@ use crate::{
write_cmd::WriteCmd,
};
-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
-const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
-
-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].
-pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser";
-
-/// True for well-known browser / human-form sentinel delegates (not real AI agents).
-/// HTML attribution should show the human username for these, not `@@uuid:rig:…`.
-pub fn is_browser_sentinel_delegate(agent: &str) -> bool {
- agent == WEB_BROWSER_AGENT || agent == INVITE_BROWSER_AGENT
-}
-
/// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
pub const SLUG_SESSION_COOKIE: &str = "slug_session";
@@ -288,7 +276,7 @@ pub async fn get_join_invite(
let session = format!("p_{}", uuid::Uuid::new_v4().simple());
let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
let s = PendingSession {
- agent: INVITE_BROWSER_AGENT.to_string(),
+ agent: None,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -555,7 +543,7 @@ pub async fn post_choose_username(
};
let sessions = pending_sessions(&state);
- let (provider, provider_id, agent) = {
+ let (provider, provider_id) = {
let sessions_read = sessions.read().await;
let Some(s) = sessions_read.get(&form.session) else {
return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
@@ -566,14 +554,9 @@ pub async fn post_choose_username(
let Some(provider_id) = s.provider_id.clone() else {
return js_form_error_fragment(&form.session, "oauth not completed").into_response();
};
- (provider, provider_id, s.agent.clone())
+ (provider, provider_id)
};
- if let Err(msg) = parse_agent(&agent) {
- return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}"))
- .into_response();
- }
-
let redeem_invite = {
let sessions_read = sessions.read().await;
sessions_read
@@ -643,7 +626,8 @@ pub async fn get_web_login(
let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))
.or_else(|| Some("/".to_string()));
let s = PendingSession {
- agent: WEB_BROWSER_AGENT.to_string(),
+ // Humans sign in via the website with no AI delegate.
+ agent: None,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -704,7 +688,7 @@ pub async fn post_pending_session(
);
let poll_url = format!("/api/v0/pending-session/{session}");
let s = PendingSession {
- agent: agent_naked,
+ agent: Some(agent_naked),
created_ts: now_ms(),
provider: None,
provider_id: None,
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index fdff6db0472b36cd7870a4be68574023e0daf120..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -18,13 +18,11 @@ pub use auth::{
get_choose_username,
get_web_login,
get_logout,
- is_browser_sentinel_delegate,
optional_principal,
resolve_web_session,
session_cookie_header_value,
WebSession,
SLUG_SESSION_COOKIE,
- WEB_BROWSER_AGENT,
};
pub use helpers::{
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 611956d0a46062b49ce350be176e4be139312ff0..6e56c039a184953cd1d0593c34cf4d5abe41f2a3 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -277,7 +277,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
- Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
+ None,
text,
)
.await
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 645d54f2e117ba901e02ed958ada6ff69a78ae34..03ad762e88709c77b9d5dd130c48bc96226616a1 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -560,27 +560,28 @@ fn identity_color_css(seed: &str) -> String {
format!("hsl({hue}, 62%, 66%)")
}
-/// Seed for author color: real AI → delegate uuid; human/sentinel → principal username.
+/// Seed for author color: AI → delegate uuid; human (no delegate) → principal username.
fn authorship_color_seed<'a>(principal: &'a str, delegate: &'a Option<String>) -> &'a str {
match delegate {
- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => {
- d.split(':').next().filter(|s| !s.is_empty()).unwrap_or(d.as_str())
- }
- _ => principal,
+ Some(d) => d
+ .split(':')
+ .next()
+ .filter(|s| !s.is_empty())
+ .unwrap_or(d.as_str()),
+ None => principal,
}
}
-/// Prefer the AI delegate in attribution; fall back to the human username when there is no
-/// delegate or the delegate is a browser/human-form sentinel.
+/// Prefer the AI delegate in attribution; humans post with no delegate and show `@username`.
pub(crate) fn authorship_attr(principal: &str, delegate: &Option<String>) -> AuthorshipAttr {
let color = identity_color_css(authorship_color_seed(principal, delegate));
match delegate {
- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => AuthorshipAttr {
+ Some(d) => AuthorshipAttr {
label: format!("@@{}", actor_label(d)),
author_title: Some(format!("@{principal}")),
color,
},
- _ => AuthorshipAttr {
+ None => AuthorshipAttr {
label: format!("@{principal}"),
author_title: None,
color,
@@ -933,7 +934,6 @@ pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
#[cfg(test)]
mod authorship_tests {
use super::*;
- use crate::api::WEB_BROWSER_AGENT;
#[test]
fn human_or_missing_delegate_shows_username() {
@@ -943,15 +943,6 @@ mod authorship_tests {
assert!(a.color.starts_with("hsl("));
}
- #[test]
- fn browser_sentinel_delegate_shows_username() {
- let d = Some(WEB_BROWSER_AGENT.to_string());
- let a = authorship_attr("alice", &d);
- assert_eq!(a.label, "@alice");
- assert_eq!(a.author_title, None);
- assert_eq!(authorship_address("alice", &d), "@alice");
- }
-
#[test]
fn real_ai_delegate_shows_short_agent_with_username_hover() {
let d = Some(
diff --git a/server/src/state.rs b/server/src/state.rs
index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -21,7 +21,8 @@ pub struct InviteState {
#[derive(Debug, Clone)]
pub struct PendingSession {
- pub agent: String,
+ /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.
+ pub agent: Option<String>,
pub created_ts: i64,
pub provider: Option<String>,
pub provider_id: Option<String>,
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 6b1475b773106a2dd3f326475c9fb4cc727f6b4b..714563a8b330e3917d95a54ae29b4de143e3b8a4 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {
let sessions = state.pending_sessions.read().await;
let pending = sessions.get(&session).expect("pending session");
assert_eq!(pending.redirect_next.as_deref(), Some(next));
+ assert_eq!(
+ pending.agent, None,
+ "browser /login must not invent a sentinel delegate"
+ );
+}
+
+#[tokio::test]
+async fn test_vote_compare_two_users_both_succeed_without_delegate() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ let client = reqwest::Client::new();
+ let alice = test_bearer();
+ let bob = seed_test_identity(&state, "bob", "bobtok", "bobsecret").await;
+
+ // Define items first (votes require existing item bodies).
+ let seed = ui_post_ingest_rpc(
+ "public",
+ "multi-vote",
+ "~/multi-a {alpha}\n~/multi-b {beta}\n",
+ );
+ let seed_resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {alice}"))
+ .form(&[("__rpc__", seed.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);
+ let seed_js = seed_resp.text().await.unwrap();
+ assert!(
+ !seed_js.contains("auth-error"),
+ "item seed must succeed, got: {seed_js}"
+ );
+
+ for (bearer, left, right, explanation) in [
+ (&alice, "3", "1", "alice prefers a"),
+ (&bob, "1", "3", "bob prefers b"),
+ ] {
+ let rpc = ui_vote_compare_post_rpc(
+ "public",
+ "multi-vote",
+ "~/multi-a",
+ "~/multi-b",
+ left,
+ right,
+ explanation,
+ );
+ let resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), reqwest::StatusCode::OK);
+ let js = resp.text().await.unwrap();
+ assert!(
+ !js.contains("delegate already bound"),
+ "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}"
+ );
+ assert!(
+ !js.contains("auth-error"),
+ "human vote must succeed ({explanation}), got: {js}"
+ );
+ assert!(
+ js.contains("vote-edge-history-region"),
+ "vote should morph edge history ({explanation}), got: {js}"
+ );
+ }
+
+ let reduced = state.reduced.read().await;
+ let human_votes: Vec<_> = reduced
+ .ingests_ordered
+ .iter()
+ .filter_map(|id| reduced.ingests_by_id.get(id))
+ .filter(|ing| ing.raw.contains("prefers"))
+ .collect();
+ assert_eq!(human_votes.len(), 2, "expected two vote ingests");
+ let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();
+ principals.sort();
+ assert_eq!(principals, ["bob", "testuser"]);
+ for ing in &human_votes {
+ assert!(
+ ing.delegate.is_none(),
+ "browser votes must have no delegate, principal={} delegate={:?}",
+ ing.principal,
+ ing.delegate
+ );
+ }
+ assert!(
+ reduced.agent_bindings.is_empty(),
+ "human
… preview truncated; 3,004 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.