diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 1e2126258..c59ae02c0 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -243,7 +243,7 @@ fn agent_target_broadcasts_and_resolves_with_the_browser_result() { let (st, ack) = post_json( s.port, "/agent-target-result", - serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "dryRun": true, "matchCount": 1, "element": { "tag": "h1" } }), + serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "dryRun": true, "matchCount": 1, "element": { "tag": "h1" } }), ); assert_eq!(st, 200); assert_eq!(ack, serde_json::json!({ "ok": true, "delivered": true })); @@ -253,7 +253,7 @@ fn agent_target_broadcasts_and_resolves_with_the_browser_result() { assert_eq!(verdict["ok"], serde_json::json!(true)); assert_eq!(verdict["matchCount"], serde_json::json!(1)); // Resolved: a late result reports delivered:false, a late claim says gone. - let (_, late) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true })); + let (_, late) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true })); assert_eq!(late, serde_json::json!({ "ok": true, "delivered": false })); assert_eq!(s.claim(&target_id, "tab-b", true), serde_json::json!({ "ok": true, "granted": false, "pending": false })); } @@ -317,7 +317,7 @@ fn agent_target_lease_lapses_and_a_disconnect_releases_it() { } } assert!(granted, "the disconnect released the lease"); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "ok": true, "sessionId": "aabbccdd" })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["ok"], serde_json::json!(true)); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); @@ -337,7 +337,7 @@ fn agent_target_replays_pending_targets_to_a_late_overlay() { let replayed = b.next(|m| m["type"] == "agent_target"); assert_eq!(replayed["targetId"], serde_json::json!(target_id)); assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(true)); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "ok": true, "sessionId": "aabbccdd" })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); } @@ -362,7 +362,7 @@ fn agent_target_reconnect_keeps_the_overlays_lease_and_word() { // tab-b stays denied, tab-a renews as the holder. assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(false), "the lease survived the reconnect"); assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "sessionId": "aabbccdd" })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); let _ = (&mut a2, &mut b); @@ -446,7 +446,7 @@ fn agent_target_lets_a_late_mount_claim_within_the_resolution_grace() { std::thread::sleep(Duration::from_millis(60)); // The element mounted: the same page claims and serves. assert_eq!(s.claim(&target_id, "tab-a", true), serde_json::json!({ "ok": true, "granted": true, "pending": true })); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "sessionId": "aabbccdd" })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["ok"], serde_json::json!(true), "{verdict}"); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); @@ -471,7 +471,7 @@ fn agent_target_late_overlay_first_no_match_extends_the_grace() { // Tab B's watcher finds the element within its grace and claims. std::thread::sleep(Duration::from_millis(60)); assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(true)); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "ok": true, "sessionId": "aabbccdd" })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"), "{verdict}"); assert!(reported_at.elapsed() < Duration::from_millis(400)); @@ -561,7 +561,7 @@ fn agent_target_welcomes_the_generate_event_of_the_session_that_answered() { let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); // The result post lands first (the common path), then the event. - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "cccccccc" })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "sessionId": "cccccccc" })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["sessionId"], serde_json::json!("cccccccc"), "{verdict}"); let (status, body) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "cccccccc", "tab-a")); @@ -625,14 +625,16 @@ fn agent_target_forwards_the_hidden_bar_request_to_the_overlay() { let (_, status) = post_json(s.port, "/status", serde_json::json!({ "token": s.token })); let _ = status; let target_id = pushed["targetId"].as_str().unwrap().to_string(); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "sessionId": "aabbccdd" })); held.join().unwrap(); // Absent by default, and anything but a boolean is refused. let held = s.hold(serde_json::json!({})); let pushed = a.next(|m| m["type"] == "agent_target"); assert!(pushed.get("hideLiveBar").is_none(), "{pushed}"); let target_id = pushed["targetId"].as_str().unwrap().to_string(); - post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccde" })); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "sessionId": "aabbccde" })); held.join().unwrap(); let (status, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({ "hideLiveBar": "yes" }))); assert_eq!(status, 400, "{body}"); @@ -655,3 +657,33 @@ fn live_bar_route_sets_the_helper_wide_preference() { let mut b = Overlay::connect(s.port, &s.token, "tab-b"); assert_eq!(b.next(|m| m["type"] == "connected")["hideLiveBar"], serde_json::json!(true)); } + +#[test] +fn agent_target_result_is_honored_only_from_the_lease_holder() { + let s = Server::start("holder-only"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + // Nobody holds it yet: a result is refused as unclaimed. + let (status, body) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "ok": true, "sessionId": "b0b0b0b0" })); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["reason"], serde_json::json!("unclaimed")); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + // A bystander that knows the id and the token still cannot answer. + let (status, body) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "ok": true, "sessionId": "b0b0b0b0" })); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["reason"], serde_json::json!("not_holder")); + // Without a client id the post is malformed. + let (status, _) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true })); + assert_eq!(status, 400); + // The holder's word lands, and the request was still pending for it. + let (status, body) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "ok": true, "sessionId": "aabbccdd" })); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["delivered"], serde_json::json!(true)); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"), "{verdict}"); + let _ = &mut b; +} diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index a7937a407..48b832c10 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -2826,14 +2826,31 @@ fn handle_agent_target_result_post( return; } }; + let client_id = match msg.get("clientId") { + Some(Value::String(id)) if !id.is_empty() => id.clone(), + _ => { + respond( + stream, + cors, + json_res(400, json!({ "error": "agent_target_result: missing clientId" })), + ); + return; + } + }; let mut result = Map::new(); for (k, v) in msg { - if k != "token" && k != "targetId" { + if k != "token" && k != "targetId" && k != "clientId" { result.insert(k, v); } } - let delivered = lock(shared).resolve_agent_target(&target_id, Value::Object(result)); - respond(stream, cors, json_res(200, json!({ "ok": true, "delivered": delivered }))); + match lock(shared).resolve_agent_target_as_holder(&target_id, &client_id, Value::Object(result)) { + Ok(delivered) => respond(stream, cors, json_res(200, json!({ "ok": true, "delivered": delivered }))), + Err(reason) => respond( + stream, + cors, + json_res(409, json!({ "error": "agent_target_result: not the holder", "reason": reason, "targetId": target_id })), + ), + } } /// `POST /live-bar` `{token, hidden}`: the helper-wide bar preference, set diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index e7d0c9ff3..122f3f3b7 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -834,6 +834,32 @@ impl ServerState { (target_id, rx) } + /// A result post is only honored from the overlay that holds the + /// target's claim: every connected overlay knows the target id and the + /// helper token, so the token alone must not let a bystander answer + /// for the winner. Ok(delivered) when the holder answered (or nothing + /// awaited the target); Err(reason) when the target is pending but the + /// caller is not its holder. + pub fn resolve_agent_target_as_holder( + &mut self, + target_id: &str, + client_id: &str, + result: Value, + ) -> Result { + if let Some((_, pending)) = self + .pending_agent_targets + .iter() + .find(|(k, _)| k == target_id) + { + match &pending.owner { + Some(owner) if owner == client_id => {} + Some(_) => return Err("not_holder"), + None => return Err("unclaimed"), + } + } + Ok(self.resolve_agent_target(target_id, result)) + } + /// Deliver a verdict to the held request; false when nothing awaits it. pub fn resolve_agent_target(&mut self, target_id: &str, result: Value) -> bool { let Some(pos) = self diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index aedf8c2e3..8beed026c 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1480,7 +1480,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean` / `agent_target: hideLiveBar must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. An accepted generate event carrying the envelope is journaled and queued with `origin: "agent"`, and `live-poll` renders that event's `_instructions` as the fast path (identity from the event's `element.computedStyles` / `cssCustomProperties` / `parentContext`, the action's three dimensions, no parameter knobs unless the prompt asks, one edit, reply done) instead of the interactive planning pointer and the action-reference read. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 (`agent_target_result: missing targetId` / `agent_target_result: missing clientId`); while the target is pending, only its lease holder's `clientId` may answer: another overlay gets 409 `{"error":"agent_target_result: not the holder", reason:'not_holder'|'unclaimed', targetId}` and the request stays pending. Otherwise `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. An accepted generate event carrying the envelope is journaled and queued with `origin: "agent"`, and `live-poll` renders that event's `_instructions` as the fast path (identity from the event's `element.computedStyles` / `cssCustomProperties` / `parentContext`, the action's three dimensions, no parameter knobs unless the prompt asks, one edit, reply done) instead of the interactive planning pointer and the action-reference read. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | | `POST /live-bar` | 401 / 400 Invalid JSON | `hidden` (boolean) required else 400 `{"error":"live_bar: hidden must be a boolean"}`. Sets the helper-wide bar preference; on a change broadcasts `{type:'live_bar', hidden}` to every SSE client. `GET /status` and the SSE `connected` frame carry it as `hideLiveBar`. Answers `{ok:true, hidden}`. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 5ec61c6ee..22623fe5c 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7129,7 +7129,7 @@ fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: TOKEN, targetId, ...result }), + body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }), }).catch(() => { /* server gone; nothing to report to */ }); } @@ -7229,9 +7229,22 @@ setLiveBarHidden(liveBarHiddenByHelper); } + // A plain live session must never notice this code: hiding remembers the + // bar's own display value and restoring puts exactly that back, and a + // restore on a bar that is not hidden is a no-op, so the `connected` + // frame every session receives changes nothing unless the lane asked. function setLiveBarHidden(hidden) { if (!globalBarEl) return; - globalBarEl.style.display = hidden ? 'none' : ''; + if (hidden) { + if (globalBarEl.style.display !== 'none') { + globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex'; + globalBarEl.style.display = 'none'; + } + return; + } + if (globalBarEl.style.display === 'none') { + globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex'; + } } function claimAgentTarget(targetId, report) { diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index ea334b81d..63b4a9026 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -236,9 +236,14 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA assert.equal(pushed.dryRun, true); assert.match(pushed.targetId, /^[0-9a-f]{8}$/); + const claimed = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(claimed.granted, true); const resultRes = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, + clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd', @@ -283,7 +288,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA assert.deepEqual(renew, { ok: true, granted: true, pending: true }, 'the holder renews its own lease'); // Settle the held request so the suite never waits out the timeout. await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); await (await held).json(); } finally { @@ -326,7 +331,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA })).json(); assert.equal(staleRenew.granted, false, 'the lapsed holder cannot renew once a rescuer holds the lease'); await postJson(leaseServer, '/agent-target-result', { - token: leaseServer.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.ok, true); @@ -393,7 +398,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA })).json(); assert.deepEqual(claim, { ok: true, granted: true, pending: true }, 'one busy report does not close a roll call with an idle tab left'); await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.ok, true); @@ -463,7 +468,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA // Still pending: the stale report was withdrawn, so B's report alone // does not complete the roll call. A's result resolves it. const resultRes = await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); assert.deepEqual(await resultRes.json(), { ok: true, delivered: true }); const verdict = await (await held).json(); @@ -526,7 +531,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA })).json(); assert.deepEqual(claim, { ok: true, granted: true, pending: true }); await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.ok, true); @@ -564,7 +569,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } assert.equal(claim.granted, true, 'the disconnect released the lease well inside the 3s lease and the 400ms timeout'); await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.ok, true); @@ -630,7 +635,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA })).json(); assert.equal(renew.granted, true, 'the reconnected overlay still holds it'); await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.sessionId, 'aabbccdd'); @@ -694,7 +699,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA })).json(); assert.deepEqual(claim, { ok: true, granted: true, pending: true }, 'the late mount is served'); await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.ok, true); @@ -729,7 +734,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA })).json(); assert.equal(claim.granted, true, 'the late overlay\'s watcher claims within its grace'); await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, matchCount: 1, sessionId: 'aabbccdd', }); const verdict = await (await held).json(); assert.equal(verdict.sessionId, 'aabbccdd'); @@ -789,7 +794,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, })).json(); assert.equal(claim.granted, true); - await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, ok: true, sessionId: 'cccccccc' }); + await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, sessionId: 'cccccccc' }); assert.equal((await (await held).json()).sessionId, 'cccccccc'); const event = (id, clientId) => postJson(server, '/events', { token: server.token, type: 'generate', id, action: 'bolder', count: 3, pageUrl: '/', @@ -869,7 +874,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } finally { tabB.close(); } const status = await (await fetch(`http://127.0.0.1:${server.port}/status?token=${server.token}`)).json(); assert.equal(status.hideLiveBar, true, '/status carries it'); - await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, ok: true, sessionId: 'aabbccdd' }); + await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, sessionId: 'aabbccdd' }); await (await held).json(); const refused = await postJson(server, '/agent-target', { token: server.token, selector: 'h1', action: 'bolder', count: 3, hideLiveBar: 'yes', @@ -881,6 +886,27 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('honors a result only from the lease holder', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + try { + await tabA.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { token: server.token, selector: 'h1', action: 'bolder', count: 3 }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const claim = await (await postJson(server, '/agent-target-claim', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true })).json(); + assert.equal(claim.granted, true); + const bystander = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, sessionId: 'b0b0b0b0' }); + assert.equal(bystander.status, 409, 'a tab that knows the id and the token still cannot answer for the holder'); + assert.equal((await bystander.json()).reason, 'not_holder'); + const anonymous = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, ok: true }); + assert.equal(anonymous.status, 400, 'a result without a client id is malformed'); + const holder = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, sessionId: 'aabbccdd' }); + assert.equal(holder.status, 200); + assert.equal((await (await held).json()).sessionId, 'aabbccdd', 'the holder answers'); + } finally { + tabA.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); @@ -955,9 +981,13 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA (m) => m.type === 'agent_target' && m.action === action && m.dryRun === true, 10_000, ); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-vocab', eligible: true, + }); await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, + clientId: 'tab-vocab', ok: true, dryRun: true, matchCount: 1, @@ -987,7 +1017,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA assert.equal(verdict.timeoutMs, 400); const late = await postJson(server, '/agent-target-result', { - token: server.token, targetId: pushed.targetId, ok: true, + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, }); assert.deepEqual(await late.json(), { ok: true, delivered: false }); } finally { diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 75852b562..193d48694 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -903,6 +903,11 @@ describe('live-browser source contracts', () => { /body\.error === 'agent_target_already_served' && msg\.type === 'generate'\s*&& msg\.id && msg\.id === currentSessionId\) \{\s*abandonSupersededGo\(msg\.id\);\s*return null;/, 'a Go the helper refused as already served drops this page\'s local session instead of leaving it generating for nothing', ); + assert.match( + SOURCE, + /function postAgentTargetResult\(targetId, result\) \{[\s\S]{0,600}?JSON\.stringify\(\{ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, \.\.\.result \}\)/, + 'a result post names this page, so the helper can refuse a bystander answering for the holder', + ); assert.match( SOURCE, /case 'connected':\s*applyLiveBarPreference\(msg\.hideLiveBar === true\);/, @@ -924,6 +929,11 @@ describe('live-browser source contracts', () => { 'a bar built after the helper spoke still ends up hidden', ); assert.ok(!/sessionStorage\.getItem\('impeccable-live:hide-bar/.test(SOURCE), 'no per-tab memory: the helper is the single source of truth'); + assert.match( + SOURCE, + /function setLiveBarHidden\(hidden\) \{[\s\S]{0,700}?if \(globalBarEl\.style\.display === 'none'\) \{\s*globalBarEl\.style\.display = globalBarEl\.dataset\.liveBarDisplay \|\| 'flex';/, + 'restoring puts the bar\'s own display value back and is a no-op on a bar that is not hidden, so a plain live session\'s connected frame changes nothing', + ); assert.ok(!/releaseHiddenLiveBar/.test(SOURCE), 'no session end brings the bar back: the accept and the bake that follows stay bar-free'); const teardownBody = SOURCE.match(/function teardown\(\) \{[\s\S]*?\n \}/)?.[0] || ''; assert.match(teardownBody, /liveBarHiddenByHelper = false;/, 'only the helper stopping resets it');