Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function FilterBar({
<Input
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="search sessions..."
placeholder="search sessions, summaries..."
// pr-7 reserves space for the inline clear button so the
// text never sits under the icon.
className="h-8 w-64 rounded-9 border-none bg-card pr-7 pl-8 font-mono text-[13px] text-ink shadow-xs placeholder:text-ink-3 focus-visible:ring-0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ describe('Audit screen', () => {
// (LIVE / FAILED / STOPPED) render a chip in the agent cell.
})

it('renders the task summary snippet under the target when present', () => {
dataOverride = {
...baseData,
tasks: [
{
...sampleTask,
taskSummary:
'Compared two invoicing tools and noted their pricing tiers.',
},
],
}
const html = renderApp()
expect(html).toContain(
'Compared two invoicing tools and noted their pricing tiers.',
)
})

it('hides token usage from the task list', () => {
dataOverride = {
...baseData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,16 @@ export const TASK_COLUMNS: ColumnDef<TaskSummary>[] = [
// the one cell a reader scans rather than skims, and the design calls
// for the platform UI face at reading size here.
cell: ({ row }) => (
<span className="block truncate font-[system-ui,sans-serif] text-[13px] text-ledger-ink">
{row.original.name}
</span>
<div className="min-w-0">
<span className="block truncate font-[system-ui,sans-serif] text-[13px] text-ledger-ink">
{row.original.name}
</span>
{row.original.taskSummary && (
<span className="mt-0.5 line-clamp-2 block text-[11px] text-ledger-ink-2 leading-snug">
{row.original.taskSummary}
</span>
)}
</div>
),
enableSorting: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ async fn contract_summary(task: TaskSummary, live: Option<&Arc<Session>>) -> Ses
.and_then(|session| session.agent().profile_id())
.map(|profile_id| profile_id.as_str().to_string());
summary.site = task.site;
summary.task_summary = task.task_summary;
summary.ended_at = task.ended_at;
summary.latest_screenshot_id = task.last_screenshot_dispatch_id;
summary.token_usage = token_usage;
Expand Down Expand Up @@ -249,6 +250,7 @@ fn contract_live_projection(projection: LiveSessionProjection) -> SessionSummary
summary.harness = harness;
summary.color = Some(color);
summary.site = task.site;
summary.task_summary = task.task_summary;
summary.ended_at = task.ended_at;
summary.latest_screenshot_id = task.last_screenshot_dispatch_id;
summary.token_usage = token_usage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ Shared with other agents:
tabs action="new" and work on that copy; leave the original untouched.
- Preserve useful pages: leave anything the user may want to inspect open
instead of closing it when the task ends.
- Name your session early with name_session: a 2-3 word task label plus the
category that best fits the task; tabs group as <client>/<name>.
- Name your session early with name_session: a 2-3 word task label, the category
that best fits the task, and a short PII-free summary you can search for later;
tabs group as <client>/<name>.
- The user oversees this browser from the BrowserOS neo cockpit (live view,
audit, replay).

Expand Down
132 changes: 129 additions & 3 deletions packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ use uuid::Uuid;
const SERVER_NAME: &str = "browseros-neo";
const SERVER_TITLE: &str = "BrowserOS neo";
const NAME_SESSION_TOOL_NAME: &str = "name_session";
const NAME_SESSION_DESCRIPTION: &str = "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", plus a `category` for the kind of task. Tabs are grouped as <client>/<name>; the label stays on this machine and only the category is used for anonymous aggregate analytics. Call again to update.";
const NAME_SESSION_DESCRIPTION: &str = "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as <client>/<name>; the label and summary stay on this machine (the summary makes the session findable in audit search), and only the category is used for anonymous aggregate analytics. Call again to update.";
const NAME_SESSION_CATEGORY_DESCRIPTION: &str = "The kind of task, for anonymous aggregate analytics only; the free-form name is never sent. Pick the closest fit from the list.";
const NAME_SESSION_SUMMARY_DESCRIPTION: &str = "One or two short lines saying what this task is, phrased so you can find it again by searching later. No names, emails, URLs, file paths, or account numbers.";
const SUMMARY_MAX_LEN: usize = 200;
const NAME_SESSION_INPUT_MAX_LEN: usize = 64;
const SESSION_ARG_DESCRIPTION: &str = "Opaque session handle returned by the server. Pass it back on every call to keep working in the same browser session; omit it to start a new session.";
const SAVE_SKILL_TOOL_NAME: &str = "save_skill";
Expand Down Expand Up @@ -162,6 +164,24 @@ impl ClawMcpService {
);
}
}
// Scrub structural PII from any provided summary before it is persisted or
// indexed for search; stored locally only, never sent to analytics. Last write wins.
let scrubbed_summary = raw_args
.get("summary")
.and_then(Value::as_str)
.map(str::trim)
.filter(|summary| !summary.is_empty())
.map(scrub_summary);
if let Some(clean) = scrubbed_summary.as_deref()
&& !clean.is_empty()
&& let Err(error) = self
.state
.audit_log
.set_task_summary(started.session.id().as_str(), clean)
.await
{
warn!(error = %error, "failed to store task summary");
}
let browser = self.state.browser.session().await;
apply_agent_tab_group_title(
browser.as_ref(),
Expand All @@ -172,13 +192,19 @@ impl ClawMcpService {
)
.await;
let result = ToolResult::text(rename.response, None);
// The audit dispatch persists the raw tool arguments; substitute the scrubbed
// summary so the unsanitized text never reaches the audit detail timeline.
let dispatch_args = match scrubbed_summary.as_deref() {
Some(clean) => with_scrubbed_summary(raw_args, clean),
None => raw_args.clone(),
};
Comment thread
DaniAkash marked this conversation as resolved.
if let Err(error) = record_local_tool_dispatch(
&self.state,
LocalToolDispatch {
session: &started.session,
agent_label: &started.agent_label,
tool_name: NAME_SESSION_TOOL_NAME,
raw_args,
raw_args: &dispatch_args,
result: &result,
duration_ms: i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX),
dispatch_id: dispatch_id.clone(),
Expand Down Expand Up @@ -659,6 +685,62 @@ async fn rename_session(
})
}

/// Best-effort structural PII scrub for an agent-provided task summary before it is
/// stored and indexed for search: drops any whitespace token that looks like an email,
/// URL, file path, bare domain/filename, or a long digit run (phone / card / account
/// number). Collapses whitespace and caps the length. Free prose and names are kept;
/// the agent is instructed to omit those, and the summary never leaves this machine.
fn scrub_summary(raw: &str) -> String {
let scrubbed = raw
.split_whitespace()
.filter(|token| !is_pii_token(token))
.collect::<Vec<_>>()
.join(" ");
if scrubbed.chars().count() > SUMMARY_MAX_LEN {
scrubbed
.chars()
.take(SUMMARY_MAX_LEN)
.collect::<String>()
.trim_end()
.to_string()
} else {
scrubbed
}
}

/// Clones the tool arguments with the `summary` field replaced by its already-scrubbed
/// form, so the audit dispatch timeline persists the sanitized summary rather than the raw
/// one the scrubber removed from `tasks.task_summary` and the search index.
fn with_scrubbed_summary(raw_args: &Value, clean: &str) -> Value {
let mut owned = raw_args.clone();
if let Some(object) = owned.as_object_mut() {
object.insert("summary".to_string(), Value::String(clean.to_string()));
}
owned
}

fn is_pii_token(token: &str) -> bool {
let lower = token.to_ascii_lowercase();
if token.contains('@')
|| lower.contains("://")
|| lower.starts_with("www.")
|| token.contains('/')
|| token.contains('\\')
{
return true;
}
if token.chars().filter(|c| c.is_ascii_digit()).count() >= 7 {
return true;
}
// bare domains / filenames: example.com, crm.internal.acme.com, report.pdf
if let Some((prefix, suffix)) = lower.rsplit_once('.') {
return !prefix.is_empty()
&& (2..=24).contains(&suffix.len())
&& suffix.chars().all(|c| c.is_ascii_alphabetic());
}
false
}
Comment thread
DaniAkash marked this conversation as resolved.

fn name_session_tool() -> Tool {
let Value::Object(input_schema) = json!({
"type": "object",
Expand All @@ -668,6 +750,11 @@ fn name_session_tool() -> Tool {
"type": "string",
"enum": crate::analytics::events::TASK_CATEGORY_VALUES,
"description": NAME_SESSION_CATEGORY_DESCRIPTION
},
"summary": {
"type": "string",
"maxLength": SUMMARY_MAX_LEN,
"description": NAME_SESSION_SUMMARY_DESCRIPTION
}
},
"required": ["name"]
Expand Down Expand Up @@ -1119,7 +1206,7 @@ mod tests {
assert!(instructions.contains("BrowserOS neo — the browser for agents"));
assert!(instructions.contains("Reach for run first"));
assert!(instructions.contains(
"- Name your session early with name_session: a 2-3 word task label plus the\n category that best fits the task; tabs group as <client>/<name>."
"- Name your session early with name_session: a 2-3 word task label, the category\n that best fits the task, and a short PII-free summary you can search for later;\n tabs group as <client>/<name>."
));
assert!(instructions.contains(
"- If the user points you at a tab you don't own, open its URL with\n tabs action=\"new\" and work on that copy; leave the original untouched."
Expand Down Expand Up @@ -1185,6 +1272,11 @@ mod tests {
"enum": crate::analytics::events::TASK_CATEGORY_VALUES,
"description": NAME_SESSION_CATEGORY_DESCRIPTION
},
"summary": {
"type": "string",
"maxLength": SUMMARY_MAX_LEN,
"description": NAME_SESSION_SUMMARY_DESCRIPTION
},
"session": { "type": "string", "description": SESSION_ARG_DESCRIPTION }
},
"required": ["name"]
Expand All @@ -1202,6 +1294,40 @@ mod tests {
Ok(())
}

#[test]
fn scrub_summary_drops_structural_pii_and_keeps_prose() {
let raw = "Downloaded invoices for john@acme.com from \
https://billing.acme.com/portal ref 4155551234 saved to /home/user/out.pdf";
let clean = scrub_summary(raw);
assert!(!clean.contains('@'));
assert!(!clean.contains("://"));
assert!(!clean.contains('/'));
assert!(!clean.contains("4155551234"));
assert!(!clean.to_ascii_lowercase().contains("acme.com"));
assert!(clean.contains("Downloaded"));
assert!(clean.contains("invoices"));
}

#[test]
fn scrub_summary_caps_length() {
let raw = "word ".repeat(200);
assert!(scrub_summary(&raw).chars().count() <= SUMMARY_MAX_LEN);
}

#[test]
fn with_scrubbed_summary_replaces_summary_and_keeps_other_args() {
let raw = "Emailed john@acme.com the invoices";
let clean = scrub_summary(raw);
let sanitized =
with_scrubbed_summary(&json!({ "name": "invoice sync", "summary": raw }), &clean);
// The recorded dispatch args carry the scrubbed copy, never the raw one.
assert_eq!(sanitized["summary"].as_str(), Some(clean.as_str()));
assert!(!clean.contains('@'));
assert!(!clean.to_ascii_lowercase().contains("acme.com"));
// Unrelated arguments are preserved verbatim.
assert_eq!(sanitized["name"].as_str(), Some("invoice sync"));
}

#[tokio::test]
async fn save_skill_is_registered_locally_with_annotations() -> anyhow::Result<()> {
let call = crate::api::mcp::test_support::tool_call("tabs", json!({})).await?;
Expand Down
Loading
Loading