From b4c7e9e2a27497a2a3e3125f59a5fd3d3902b5df Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Mon, 28 Sep 2026 17:43:29 -0700 Subject: [PATCH 1/2] Read cloud Cowork sessions and carry associated files --- Cargo.toml | 4 +- README.md | 3 +- cli/src/lib.rs | 166 +++-- cli/src/mcp.rs | 27 +- cli/src/view.rs | 7 +- docs/formats/README.md | 1 + docs/formats/claude-chat.md | 5 +- docs/formats/cowork-remote.md | 130 ++++ docs/formats/cowork.md | 18 +- docs/usage.md | 5 +- src/harness/claude_chat.rs | 457 +++++++++++++- src/harness/cowork.rs | 123 +++- src/harness/cowork_files.rs | 206 ++++++ src/harness/cowork_remote.rs | 1100 +++++++++++++++++++++++++++++++++ src/harness/mod.rs | 2 + src/local.rs | 169 ++++- src/transcript.rs | 8 +- src/wasm.rs | 9 +- tests/integration/cowork.rs | 85 ++- 19 files changed, 2394 insertions(+), 131 deletions(-) create mode 100644 docs/formats/cowork-remote.md create mode 100644 src/harness/cowork_files.rs create mode 100644 src/harness/cowork_remote.rs diff --git a/Cargo.toml b/Cargo.toml index 7db0fe5..1ec6c6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,9 @@ harness = false required-features = ["search"] [features] -default = ["opencode", "hermes", "claude_chat", "chatgpt", "search"] +# Explicit cloud Cowork reads reuse the Claude Desktop transport. +cowork_remote = ["claude_chat"] +default = ["opencode", "hermes", "claude_chat", "cowork_remote", "chatgpt", "search"] # The OpenCode harness store reads OpenCode's SQLite database. The OpenCode # codec itself is always available; only the on-disk store needs rusqlite. opencode = ["dep:rusqlite"] diff --git a/README.md b/README.md index a6b39ff..9727819 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,11 @@ Each name links to its format documentation. Use the ID with `--from` and `--wit | [Antigravity](docs/formats/antigravity.md) | `antigravity` | Yes | Yes | | [Hermes Agent](docs/formats/hermes.md) | `hermes` | Yes | No | | [Amp](docs/formats/amp.md) | `amp` | Yes | No | +| [Cloud Cowork](docs/formats/cowork-remote.md) | `cowork_remote` | Live account | No | | [Claude Chat](docs/formats/claude-chat.md) | `claude_chat` | Live account | No | | [ChatGPT](docs/formats/chatgpt.md) | `chatgpt` | Live account | No | -Local discovery skips the live accounts. Select `--from claude_chat` or `--from chatgpt` explicitly to read them. These sources use private web APIs and reuse an existing app login; requirements and limitations are in their linked docs. +Local discovery skips the live accounts. Select `--from claude_chat`, `--from cowork_remote`, or `--from chatgpt` explicitly to read them. These sources use private web APIs and reuse an existing app login; requirements and limitations are in their linked docs. ### Bring another agent diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 3bc370a..fadca96 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -54,7 +54,7 @@ use std::path::PathBuf; use std::process::ExitCode; use clap::{CommandFactory, Parser, Subcommand}; -use txcript::harness::{amp, chatgpt, claude_chat, simple}; +use txcript::harness::{amp, chatgpt, claude_chat, cowork_remote, simple}; use txcript::{Codec, Common, HarnessId, Store, TextCodec, Transcript, local}; pub mod cache; @@ -69,7 +69,7 @@ mod pager; mod view; pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, fx, hermes, \ - amp, antigravity, simple, cowork"; + amp, antigravity, simple, cowork, cowork_remote"; /// The `txcript` binary's command line. #[derive(Parser)] @@ -649,6 +649,54 @@ pub(crate) fn load_direct_chatgpt(source: &str, from: Option) -> Opti })()) } +/// A full cloud Cowork id bypasses listing, like Claude Chat UUID reads. +pub(crate) fn load_direct_cowork_remote( + source: &str, + from: Option, +) -> Option { + if from != Some(HarnessId::CoworkRemote) { + return None; + } + let (id, request) = fragment::parse_ref(source); + let normalized = cowork_remote::normalize_id(id); + // A mistyped link/id must not turn into an account-wide title search. + if normalized.is_err() + && !id.contains("://") + && !id.starts_with("cse_") + && !id.starts_with("session_") + && uuid::Uuid::parse_str(id).is_err() + { + return None; + } + Some((|| { + let id = normalized.map_err(|error| error.to_string())?; + let store = + cowork_remote::CoworkRemoteStore::from_desktop().map_err(|error| error.to_string())?; + let reference = store + .session_ref(&id, None) + .map_err(|error| error.to_string())?; + let native = store.load(&reference).map_err(|error| error.to_string())?; + let common = + cowork_remote::CoworkRemote::to_common(&native).map_err(|error| error.to_string())?; + Ok((common, request)) + })()) +} + +/// Dispatch only an explicitly selected live source with a full provider id. +pub(crate) fn load_direct_remote( + source: &str, + from: Option, +) -> Option<(HarnessId, LoadedSession)> { + let harness = from?; + let loaded = match harness { + HarnessId::ClaudeChat => load_direct_claude_chat(source, from), + HarnessId::ChatGpt => load_direct_chatgpt(source, from), + HarnessId::CoworkRemote => load_direct_cowork_remote(source, from), + _ => return None, + }?; + Some((harness, loaded)) +} + /// Positions of the first occurrence of each distinct id starting with /// `prefix`. Claude Code writes a session resumed from another cwd under the /// same id in a second store; those copies collapse to the first (newest — @@ -855,6 +903,22 @@ mod filter_tests { #[cfg(test)] mod resolve_tests { + + #[test] + fn cowork_urls_require_explicit_source_and_invalid_urls_never_become_searches() { + let valid = "https://claude.ai/chat/00000000-0000-8000-8000-000000000002"; + assert!(super::load_direct_remote(valid, None).is_none()); + for source in [ + "https://evil.test/cowork/cse_one", + "cse_../bad", + "https://claude.ai/chat/00000000-0000-4000-8000-000000000002", + ] { + assert!(matches!( + super::load_direct_remote(source, Some(txcript::HarnessId::CoworkRemote)), + Some((txcript::HarnessId::CoworkRemote, Err(_))) + )); + } + } use super::distinct_prefix_matches; #[test] @@ -1083,6 +1147,13 @@ mod identity_tests { assert_eq!(copy.meta.timestamp, ts); } + #[test] + fn cowork_remote_is_refused_even_for_an_in_place_continue() { + let error = + ensure_resumable_source(HarnessId::CoworkRemote, HarnessId::CoworkRemote).unwrap_err(); + assert!(error.contains("pull-only")); + } + #[test] fn claude_chat_is_refused_even_for_an_in_place_continue() { let error = @@ -1136,6 +1207,9 @@ fn cmd_list( _ => " in that time range".to_string(), }; match from { + Some(HarnessId::CoworkRemote) => { + println!("no cloud Cowork sessions found{scope}{when}"); + } Some(HarnessId::ClaudeChat) => { println!("no Claude Chat sessions found{scope}{when}"); } @@ -1233,23 +1307,23 @@ mod style { const fn color(h: HarnessId) -> &'static str { match h { - HarnessId::ClaudeCode => "\x1b[33m", // yellow - HarnessId::ClaudeChat => "\x1b[38;5;214m", // amber - HarnessId::ChatGpt => "\x1b[38;5;71m", // OpenAI green - HarnessId::Codex => "\x1b[36m", // cyan - HarnessId::OpenCode => "\x1b[32m", // green - HarnessId::Pi => "\x1b[35m", // magenta - HarnessId::Campfire => "\x1b[91m", // bright red - HarnessId::Cursor => "\x1b[34m", // blue - HarnessId::CursorDesktop => "\x1b[96m", // bright cyan - HarnessId::Grok => "\x1b[37m", // white - HarnessId::GrokBot => "\x1b[97m", // bright white - HarnessId::Fx => "\x1b[38;5;39m", // azure - HarnessId::Hermes => "\x1b[93m", // bright yellow - HarnessId::Amp => "\x1b[95m", // bright magenta - HarnessId::Antigravity => "\x1b[94m", // bright blue - HarnessId::Simple => "\x1b[92m", // bright green - HarnessId::Cowork => "\x1b[38;5;208m", // orange + HarnessId::ClaudeCode => "\x1b[33m", // yellow + HarnessId::CoworkRemote | HarnessId::ClaudeChat => "\x1b[38;5;214m", // amber + HarnessId::ChatGpt => "\x1b[38;5;71m", // OpenAI green + HarnessId::Codex => "\x1b[36m", // cyan + HarnessId::OpenCode => "\x1b[32m", // green + HarnessId::Pi => "\x1b[35m", // magenta + HarnessId::Campfire => "\x1b[91m", // bright red + HarnessId::Cursor => "\x1b[34m", // blue + HarnessId::CursorDesktop => "\x1b[96m", // bright cyan + HarnessId::Grok => "\x1b[37m", // white + HarnessId::GrokBot => "\x1b[97m", // bright white + HarnessId::Fx => "\x1b[38;5;39m", // azure + HarnessId::Hermes => "\x1b[93m", // bright yellow + HarnessId::Amp => "\x1b[95m", // bright magenta + HarnessId::Antigravity => "\x1b[94m", // bright blue + HarnessId::Simple => "\x1b[92m", // bright green + HarnessId::Cowork => "\x1b[38;5;208m", // orange } } } @@ -1270,19 +1344,17 @@ fn cmd_crop( ensure_crop_target(target)?; } - if let Some(loaded) = load_direct_claude_chat(source, from) { - let target = with.unwrap_or(HarnessId::ClaudeChat); - ensure_crop_target(target)?; - let (common, request) = loaded?; - return crop_loaded(&common, HarnessId::ClaudeChat, target, request.as_ref()); + if with.is_none() + && let Some(source) = from + { + ensure_crop_target(source)?; } - if let Some(loaded) = load_direct_chatgpt(source, from) { - let target = with.unwrap_or(HarnessId::ChatGpt); + if let Some((source_harness, loaded)) = load_direct_remote(source, from) { + let target = with.unwrap_or(source_harness); ensure_crop_target(target)?; let (common, request) = loaded?; - return crop_loaded(&common, HarnessId::ChatGpt, target, request.as_ref()); + return crop_loaded(&common, source_harness, target, request.as_ref()); } - let sessions = discover_with_spinner(from)?; let (id, request) = match crop_ref(source) { (_, Some(_)) if find_exact(&sessions, from, source).is_some() => (source, None), @@ -1392,27 +1464,16 @@ fn cmd_continue( ); } - if let Some(loaded) = load_direct_claude_chat(id, from) { - let target = with.unwrap_or(HarnessId::ClaudeChat); - ensure_resumable_source(HarnessId::ClaudeChat, target)?; - let (common, request) = loaded?; - return continue_loaded_remote( - common, - HarnessId::ClaudeChat, - target, - request.as_ref(), - out.map(PathBuf::as_path), - wants_resume(target, out.is_some(), no_resume), - metadata, - ); + if let Some(source) = from { + ensure_resumable_source(source, with.unwrap_or(source))?; } - if let Some(loaded) = load_direct_chatgpt(id, from) { - let target = with.unwrap_or(HarnessId::ChatGpt); - ensure_resumable_source(HarnessId::ChatGpt, target)?; + if let Some((source_harness, loaded)) = load_direct_remote(id, from) { + let target = with.unwrap_or(source_harness); + ensure_resumable_source(source_harness, target)?; let (common, request) = loaded?; return continue_loaded_remote( common, - HarnessId::ChatGpt, + source_harness, target, request.as_ref(), out.map(PathBuf::as_path), @@ -1420,9 +1481,6 @@ fn cmd_continue( metadata, ); } - - // Locate the session by id (exact or unambiguous prefix) or exact title, - // optionally scoped to one harness. let sessions = discover_with_spinner(from)?; // A whole-input match (a title that itself contains `#12`) beats the // fragment interpretation. @@ -1833,7 +1891,8 @@ fn continue_loaded_remote( fn ensure_crop_target(target: HarnessId) -> Result<(), String> { if matches!( target, - HarnessId::ClaudeChat + HarnessId::CoworkRemote + | HarnessId::ClaudeChat | HarnessId::ChatGpt | HarnessId::Hermes | HarnessId::Amp @@ -1848,7 +1907,9 @@ fn ensure_crop_target(target: HarnessId) -> Result<(), String> { } fn ensure_resumable_source(source: HarnessId, target: HarnessId) -> Result<(), String> { - if source == HarnessId::ClaudeChat && target == HarnessId::ClaudeChat { + if source == HarnessId::CoworkRemote && target == HarnessId::CoworkRemote { + Err("cloud Cowork is pull-only; choose another --with harness".into()) + } else if source == HarnessId::ClaudeChat && target == HarnessId::ClaudeChat { Err( "Claude Chat is pull-only: choose another --with harness; txcript never continues conversations in Claude" .to_string(), @@ -2043,7 +2104,10 @@ fn resume_workdir(cwd: Option<&str>) -> Option { fn discover_with_spinner(from: Option) -> Result, String> { let spinner = spin::Spinner::start("searching local sessions…"); - let sessions = if matches!(from, Some(HarnessId::ClaudeChat | HarnessId::ChatGpt)) { + let sessions = if matches!( + from, + Some(HarnessId::ClaudeChat | HarnessId::CoworkRemote | HarnessId::ChatGpt) + ) { let harness = from.unwrap_or(HarnessId::ClaudeChat); spinner.set(format!("reading {harness}…")); local::discover_harness(harness).map_err(|error| error.to_string())? diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index 7cbb343..e343bcd 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -271,7 +271,10 @@ impl SessionServer { let from = parse_from(request.from.as_deref())?; // Enumerating a live web account is not offered over MCP; the // refusal is explicit so an agent doesn't read "no sessions" as truth. - if matches!(from, Some(HarnessId::ClaudeChat | HarnessId::ChatGpt)) { + if matches!( + from, + Some(HarnessId::ClaudeChat | HarnessId::CoworkRemote | HarnessId::ChatGpt) + ) { let name = from.map_or("live source", HarnessId::as_str); return Err(ErrorData::invalid_params( format!( @@ -354,13 +357,7 @@ impl SessionServer { Parameters(request): Parameters, ) -> Result { let from = parse_from(request.from.as_deref())?; - if let Some(loaded) = super::load_direct_claude_chat(&request.id, from) { - let (common, span_req) = - loaded.map_err(|error| ErrorData::internal_error(error, None))?; - let src = crate::fragment::parse_ref(&request.id).0; - return render_read_session(src, &common, span_req.as_ref()); - } - if let Some(loaded) = super::load_direct_chatgpt(&request.id, from) { + if let Some((_, loaded)) = super::load_direct_remote(&request.id, from) { let (common, span_req) = loaded.map_err(|error| ErrorData::internal_error(error, None))?; let src = crate::fragment::parse_ref(&request.id).0; @@ -647,6 +644,20 @@ mod tests { assert_eq!(chunk_ranges(&[10, 10], 0, 100), vec![0..2]); } + #[test] + fn list_sessions_refuses_cowork_remote() { + let error = SessionServer::new(None) + .list_sessions(Parameters(ListSessionsRequest { + from: Some("cowork_remote".into()), + cwd: None, + limit: None, + offset: None, + })) + .err() + .unwrap_or_else(|| panic!("cowork_remote listing is refused")); + assert!(error.message.contains("does not enumerate cowork_remote")); + } + #[test] fn list_sessions_refuses_claude_chat() { let error = SessionServer::new(None) diff --git a/cli/src/view.rs b/cli/src/view.rs index b4f4edb..5e6bce0 100644 --- a/cli/src/view.rs +++ b/cli/src/view.rs @@ -35,10 +35,7 @@ pub fn load_source( source: &str, from: Option, ) -> Result<(Transcript, Option), String> { - if let Some(loaded) = super::load_direct_claude_chat(source, from) { - return loaded; - } - if let Some(loaded) = super::load_direct_chatgpt(source, from) { + if let Some((_, loaded)) = super::load_direct_remote(source, from) { return loaded; } let sessions = super::discover_with_spinner(from)?; @@ -52,6 +49,8 @@ pub fn load_source( let session = super::find_session(&sessions, from, src)?.ok_or_else(|| { let (origin, scope) = if from == Some(HarnessId::ClaudeChat) { ("Claude Chat", String::new()) + } else if from == Some(HarnessId::CoworkRemote) { + ("cloud Cowork", String::new()) } else if from == Some(HarnessId::ChatGpt) { ("ChatGPT", String::new()) } else { diff --git a/docs/formats/README.md b/docs/formats/README.md index f500999..633fdde 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -40,6 +40,7 @@ than none. | Document | Harness | Parser | | --- | --- | --- | | [claude-code.md](claude-code.md) | Claude Code (Anthropic) | `src/harness/claude_code.rs` | +| [cowork-remote.md](cowork-remote.md) | Cloud Cowork (Anthropic) | `src/harness/cowork_remote.rs` | | [claude-chat.md](claude-chat.md) | Claude Chat (Anthropic) | `src/harness/claude_chat.rs` | | [chatgpt.md](chatgpt.md) | ChatGPT (OpenAI) | `src/harness/chatgpt.rs` | | [cowork.md](cowork.md) | Cowork (Claude desktop app) | `src/harness/cowork.rs` | diff --git a/docs/formats/claude-chat.md b/docs/formats/claude-chat.md index d052b49..67649fb 100644 --- a/docs/formats/claude-chat.md +++ b/docs/formats/claude-chat.md @@ -110,7 +110,10 @@ turns. Structured `content` takes precedence over the duplicate message-level Live tool names are normalized to their Claude Code equivalents where the arguments fit: `bash_tool` becomes `Bash`, `view` becomes `Read`, and -`create_file` becomes `Write`. `present_files` remains a raw tool event, while +`create_file` becomes `Write` and also contributes a text artifact when its +input includes the full `file_text`. Older `artifacts` create/rewrite tools +similarly contribute their complete content; update patches remain tool +records, since a patch alone is not a complete file. `present_files` remains a raw tool event, while each file it presents becomes a first-class Common artifact carrying its identity, filename, MIME type, and bytes. When writing Claude Code, the generic Common artifact path materializes those bytes under the generated session's diff --git a/docs/formats/cowork-remote.md b/docs/formats/cowork-remote.md new file mode 100644 index 0000000..5b44296 --- /dev/null +++ b/docs/formats/cowork-remote.md @@ -0,0 +1,130 @@ +# Cloud Cowork + +`cowork_remote` reads Cowork sessions stored in Claude's cloud (`cse_…` ids), +including sessions Claude presents through a `/chat/…` URL. +It is separate from `cowork`, which reads and writes local Desktop sessions +(`local_…` ids). Cloud sessions are a pull-only source. + +## Access + +Reuse the signed-in Claude Desktop account on macOS, as with +[Claude Chat](claude-chat.md): + +```sh +txcript list --from cowork_remote +txcript view cse_YOUR_SESSION_ID --from cowork_remote +txcript export cse_YOUR_SESSION_ID --from cowork_remote --out task.json +txcript export 'https://claude.ai/chat/YOUR_COWORK_CHAT_UUID' --from cowork_remote --out task.json +txcript continue cse_YOUR_SESSION_ID --from cowork_remote --with claude_code --no-resume +``` + +`session_…` is accepted as an alias for `cse_…`. Full Cowork URLs, Cowork chat +URLs, and bare version-8 chat UUIDs also load directly without listing sessions +first. Use `--from cowork_remote` for these chat links. Ordinary Claude chats +still use `claude_chat`. An exact title requires explicit cloud discovery. +Message ranges such as `cse_YOUR_SESSION_ID#5-12` work with view, export, crop, +and continue. A crop or continue needs a writable destination. + +The active Desktop organization is used by default. The shared +`TXCRIPT_CLAUDE_CHAT_ORGANIZATION_UUID` setting can select another organization; +library callers can also pass it to `CoworkRemoteStore::session_ref`. +Authentication has the same macOS Keychain, cookie encryption, and platform +requirements as Claude Chat. No new login or credential environment variables +are introduced. + +Aggregate local discovery, search indexing, and `txcript list` without +`--from cowork_remote` do not contact this source. MCP listing also refuses +cloud enumeration; `read_session` can read an explicitly selected source and +full id. Store construction does not list sessions. + +## Library and wire format + +The `cowork_remote` Cargo feature, enabled by default, reuses the `claude_chat` +transport. The native codec remains available without network features and +through WASM. Consumers use the ordinary `Store` and `Codec` traits: + +```rust,no_run +use txcript::{Codec, Store}; +use txcript::harness::cowork_remote::{CoworkRemote, CoworkRemoteStore}; + +# fn main() -> txcript::Result<()> { +let store = CoworkRemoteStore::from_desktop()?; +let reference = store.session_ref("cse_YOUR_SESSION_ID", None)?; +let native = store.load(&reference)?; +let common = CoworkRemote::to_common(&native)?; +# Ok(()) +# } +``` + +The reader uses Claude's private code-session API on `https://claude.ai`: + +- `GET /v1/code/sessions?tags=cowork-remote&limit=200` +- `GET /v1/code/sessions/{id}` +- `GET /v1/code/sessions/{id}/events?limit=500&sort_order=asc` + +Both lists follow `next_cursor`. Cowork tags distinguish these sessions from +Claude Code sessions served by the same API. Organization ids and session ids +are validated before requests. Requests carry the existing Desktop cookies +and the code-session API's organization and version headers. Redirects are +disabled; no provider writes, deletes, launches, or resumes occur. + +For a Cowork chat UUID, the reader first requests an existing-only snapshot +from `POST /claudeai-rpc/anthropic.bard.api.v1alpha.ConversationService/StreamTimeline`. +This is a read RPC using Connect JSON framing. It stops at the initial full +snapshot and never calls mutation or view-reporting methods. A version-8 UUID +only selects this lookup: the returned chat id must match, `CoworkSessionMeta` +must identify it as a presented Cowork session, and `WorkspaceUpgradeState` +must supply one unambiguous Cowork session id. The ordinary paged event reader +then reads that backing session. A workspace attached to an ordinary chat does +not qualify. Failed lookups do not fall back to account enumeration. + +A native document contains the complete detail response under `session` and +the ordered event envelopes under `events`. Unknown fields survive native +round trips. Complete top-level SDK user and assistant messages use the +Claude Code block mapping, including thinking, tools, tool results, inline +images, model, usage, and stop reason. Event timestamps fill missing message +timestamps. Repeated identical event ids convert once; conflicting duplicates +and malformed messages fail explicitly. + +Chat-link reads additionally retain the complete mapping snapshot under +`$txcript_chat_snapshot`. Metadata and Common messages use the canonical +`cse_…` id, so the original URL and the chat URL identify the same session. + +## Boundaries + +- Explicit loads download uploaded attachments and files presented by + `Artifact`, `SendUserFile`, and `present_files`. Bytes, names, and MIME types + are preserved as Common artifacts and under `$txcript_files` in native + exports. VM paths use Claude's session download endpoint; file UUIDs use its + organization file endpoint. A remote path is never opened on the host. +- File downloads are bounded to 64 MB each and 128 MB per session. A missing + or inaccessible referenced file fails the read with its filename. Arbitrary + working-directory files and nested subagent files are not collected. +- Path-based downloads retrieve the current file contents, not historical + versions. Repeated presentations of a path share the downloaded contents. +- The remote working directory stays in the native response; Common does not + advertise it as a usable directory on the recipient's machine. +- Streaming deltas, control events, and nested subagent messages stay in the + native document. Common includes complete top-level messages only. A live + turn can therefore be absent until its complete message is persisted. +- The paged read is not an atomic snapshot of an actively changing session. + Malformed or looping pagination, failed pages, and page/row/byte limits + return an error rather than a successful partial transcript. +- The chat URL is an entry point to its backing Cowork events. This does not + import independent ordinary-chat continuations or the chat interface's + display-only grouping. A chat without a verified Cowork mapping is refused. +- `from_common`, `save`, `delete`, and continuing into `cowork_remote` are + refused. Pull into local `cowork` or another writable harness instead. +- These private endpoints and Desktop authentication can change independently + of txcript. Protocol failures are reported to the explicit caller. + +## Evidence + +Protocol paths, headers, id aliases, detail wrappers, and event envelopes were +inspected in the installed Claude Desktop app's shipped web client on +2026-09-28. Contract tests use synthetic sessions and a local HTTP server; +no customer transcripts or credentials are included in fixtures. + +Related reports: [missing cloud sessions (#61)](https://github.com/skillsynchq/txcript/issues/61) +and [missing Cowork files (#62)](https://github.com/skillsynchq/txcript/issues/62). + diff --git a/docs/formats/cowork.md b/docs/formats/cowork.md index 9e1780e..977d248 100644 --- a/docs/formats/cowork.md +++ b/docs/formats/cowork.md @@ -1,5 +1,7 @@ # Cowork +For cloud `cse_…` sessions, use the explicit [Cloud Cowork](cowork-remote.md) source. This harness handles local `local_…` sessions. + Cowork is the Claude desktop app's local agent mode — the surface through which people who never open a terminal run Claude on their own files. Under the hood it is Claude Code: the app launches the CLI headlessly through the @@ -23,7 +25,7 @@ its bundled JavaScript. │ │ └── /subagents/*.jsonl (not carried) │ ├── audit.jsonl ── Agent SDK stream, HMAC-chained (carried, never written) │ ├── .audit-key ── encrypted chain key (not carried) - │ ├── uploads/, outputs/ ── the user's files (not carried) + │ ├── uploads/, outputs/ ── the user's files (referenced files carried) │ └── .claude/.claude.json, backups/, debug/ (not carried) ├── agent/local_ditto_*.json ── agent-type sessions, same shape, own subdir └── cowork_settings.json, rpm/, debug/, … (app state, skipped) @@ -119,3 +121,17 @@ same conversation always yields the same files. A session id without the `tests/integration/cowork.rs` (fixtures shaped like real sessions). Last verified: 2026-08-20, against src/harness/cowork.rs and real local sessions. + +## Associated files + +An explicit local load includes files named in the uploaded-file manifest and +`Artifact`, `SendUserFile`, or `present_files` calls. Paths must resolve within +the session's `uploads/` or `outputs/`; missing or ambiguous files fail with a +filename. Arbitrary working-directory files are not bundled. Limits are 64 MB +per file and 128 MB per load. + +The native export carries file bytes in its `files` map. Saving that native +export writes a `.txcript-files.json` sidecar so it can be read again without +the original cache. Common/Simple exports carry inline artifacts. Continuing +into local Cowork saves those files under the destination session's uploads +and puts their new paths in the imported history. diff --git a/docs/usage.md b/docs/usage.md index cfa68b4..ca51e64 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -120,7 +120,7 @@ txcript = "0.14" # txcript = { version = "0.14", default-features = false } ``` -Default features: `opencode` (the SQLite stores: OpenCode, both Cursors, Antigravity), `hermes`, `claude_chat`, `chatgpt`, and `search`. +Default features: `opencode` (the SQLite stores: OpenCode, both Cursors, Antigravity), `hermes`, `claude_chat`, `cowork_remote`, `chatgpt`, and `search`. Three layers, smallest to largest: @@ -222,7 +222,7 @@ writeFileSync("session.jsonl", convert(input, "codex", "claude_code")); const common = JSON.parse(toCommon(input, "codex")); // { meta, messages } const pi = fromCommon(JSON.stringify(common), "pi"); -harnesses(); // ["claude_code","claude_chat","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","simple","cowork"] +harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","simple","cowork"] ``` Text-in / text-out: `input` is the source harness's native session text and the result is the target's. Invalid harness names or unparseable input throw a JS `Error`. @@ -244,6 +244,7 @@ const matches = JSON.parse(index.query(JSON.stringify({ pattern: "relay bug" })) | Harness | Session text | |---|---| | `claude_code`, `codex`, `pi`, `campfire` | session JSONL | +| `cowork_remote` | a native document with `session` detail response and ordered `events` (source-only) | | `claude_chat` | one live conversation detail response (source-only; no account export arrays) | | `chatgpt` | one live conversation detail response (source-only; no account export arrays) | | `opencode` | `opencode export` JSON | diff --git a/src/harness/claude_chat.rs b/src/harness/claude_chat.rs index c0ceb4e..6cfd7be 100644 --- a/src/harness/claude_chat.rs +++ b/src/harness/claude_chat.rs @@ -282,6 +282,16 @@ fn push_native_message( } append_role_block(role, block, timestamp, out, &mut pending_role, &mut pending); } + if let Some(artifact) = authored_file(conversation, native, value, block_index) { + append_role_block( + Role::Assistant, + Block::Artifact { artifact }, + timestamp, + out, + &mut pending_role, + &mut pending, + ); + } if value.get("type").and_then(Value::as_str) == Some("tool_result") { append_tool_result_artifacts( conversation, @@ -539,6 +549,67 @@ fn tool_result_block( } } +/// Older chat artifacts and `create_file` tools carry the complete text in +/// their inputs, even when no `local_resource/download` record was emitted. +fn authored_file( + conversation: &Conversation, + message: &Value, + block: &Value, + index: usize, +) -> Option { + if block["type"] != "tool_use" { + return None; + } + let input = &block["input"]; + let (name, text, media_type) = match block["name"].as_str()? { + "create_file" => { + let path = input["path"].as_str()?; + let name = path.rsplit('/').next().filter(|name| !name.is_empty())?; + let mime = if std::path::Path::new(name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + { + "text/markdown" + } else { + "text/plain" + }; + ( + name.to_string(), + input["file_text"].as_str()?, + mime.to_string(), + ) + } + "artifacts" if matches!(input["command"].as_str(), Some("create" | "rewrite")) => { + let mime = input["type"].as_str().unwrap_or("text/plain"); + let title = input["title"].as_str().unwrap_or("artifact"); + let suffix = match mime { + "text/markdown" => ".md", + "text/html" => ".html", + "image/svg+xml" => ".svg", + _ => ".txt", + }; + let name = if title.ends_with(suffix) { + title.to_string() + } else { + format!("{title}{suffix}") + }; + (name, input["content"].as_str()?, mime.to_string()) + } + _ => return None, + }; + Some(Artifact { + id: input["version_uuid"].as_str().map_or_else( + || synthetic_block_id(conversation, message, index, "authored-file"), + str::to_string, + ), + name, + source: ArtifactSource::Text { + text: text.into(), + media_type: Some(media_type), + }, + }) +} + fn artifacts_from_tool_result(conversation: &Conversation, block: &Value) -> Vec { let value = block .get("content") @@ -930,6 +1001,8 @@ mod remote { accept: &'static str, headers: Vec<(&'static str, String)>, max_bytes: u64, + #[cfg(feature = "cowork_remote")] + timeline_body: Option>, reply: mpsc::SyncSender>, } @@ -978,7 +1051,7 @@ mod remote { return; }; while let Ok(request) = receiver.recv() { - let result = runtime.block_on(execute_get(&client, &request)); + let result = runtime.block_on(execute_read(&client, &request)); let _ = request.reply.send(result); } }) @@ -1015,6 +1088,8 @@ mod remote { accept, headers, max_bytes, + #[cfg(feature = "cowork_remote")] + timeline_body: None, reply, }) .map_err(|_| Error::Remote { @@ -1032,17 +1107,207 @@ mod remote { detail, }) } + + #[cfg(feature = "cowork_remote")] + fn cowork_timeline( + &self, + base_url: &str, + cookie: String, + headers: Vec<(&'static str, String)>, + conversation_id: &str, + ) -> Result { + let body = serde_json::to_vec(&serde_json::json!({ + "conversationId": conversation_id, + "existingOnly": true, + }))?; + let length = u32::try_from(body.len()) + .map_err(|_| protocol_error("timeline request exceeds the frame limit"))?; + let mut framed = vec![0]; + framed.extend_from_slice(&length.to_be_bytes()); + framed.extend_from_slice(&body); + let (reply, response) = mpsc::sync_channel(1); + self.sender + .send(BrowserRequest { + url: format!("{base_url}{COWORK_TIMELINE_PATH}"), + cookie, + accept: "application/connect+json", + headers, + max_bytes: MAX_RESPONSE_BYTES, + timeline_body: Some(framed), + reply, + }) + .map_err(|_| protocol_error("timeline worker stopped before the read"))?; + response + .recv() + .map_err(|_| protocol_error("timeline worker stopped during the read"))? + .map_err(|detail| Error::Remote { + harness: ClaudeChat::NAME, + detail, + }) + } + } + + #[cfg(feature = "cowork_remote")] + const COWORK_TIMELINE_PATH: &str = + "/claudeai-rpc/anthropic.bard.api.v1alpha.ConversationService/StreamTimeline"; + + // Connect streams start with heartbeats. Read only as far as the initial + // authoritative snapshot, then close the stream. Never follow updates or + // invoke PerformAction/ReportViewing. All server error details stay private. + #[cfg(feature = "cowork_remote")] + #[derive(Default)] + struct TimelineSnapshot { + bytes: Vec, + frames: usize, + } + + #[cfg(feature = "cowork_remote")] + impl TimelineSnapshot { + fn push( + &mut self, + chunk: &[u8], + max_bytes: u64, + ) -> std::result::Result>, String> { + self.bytes.extend_from_slice(chunk); + while self.bytes.len() >= 5 { + let flags = self.bytes[0]; + let length = u32::from_be_bytes([ + self.bytes[1], + self.bytes[2], + self.bytes[3], + self.bytes[4], + ]); + if u64::from(length) > max_bytes { + return Err("Claude timeline frame exceeded the byte limit".into()); + } + let end = usize::try_from(length) + .ok() + .and_then(|n| n.checked_add(5)) + .ok_or("Claude timeline frame length overflowed")?; + if self.bytes.len() < end { + return Ok(None); + } + self.frames += 1; + if self.frames > 128 { + return Err("Claude returned no snapshot within the frame limit".into()); + } + if flags != 0 { + return Err( + "Claude ended or refused the timeline before a complete snapshot".into(), + ); + } + let value: Value = serde_json::from_slice(&self.bytes[5..end]) + .map_err(|_| "Claude returned an invalid timeline frame")?; + let event = value + .get("event") + .and_then(Value::as_object) + .ok_or("Claude timeline frame is missing its event")?; + if event.contains_key("error") || event.contains_key("conversationDeleted") { + return Err("Claude refused the existing conversation timeline".into()); + } + if let Some(update) = event.get("update") { + if update.get("replaceAllState").and_then(Value::as_bool) != Some(true) + || update + .pointer("/conversation/id") + .and_then(Value::as_str) + .is_none() + { + return Err( + "Claude did not return an authoritative conversation snapshot".into(), + ); + } + return Ok(Some(self.bytes[5..end].to_vec())); + } + self.bytes.drain(..end); + } + Ok(None) + } + } + + #[cfg(all(test, feature = "cowork_remote"))] + #[allow(clippy::unwrap_used)] + mod timeline_tests { + use super::*; + use serde_json::json; + + fn frame(value: &Value) -> Vec { + let body = value.to_string().into_bytes(); + let mut framed = vec![0]; + framed.extend_from_slice(&u32::try_from(body.len()).unwrap().to_be_bytes()); + framed.extend(body); + framed + } + + #[test] + fn snapshot_handles_split_headers_payloads_and_heartbeats() { + let snapshot = json!({"event":{"update":{"replaceAllState":true,"conversation":{"id":"example"}}},"future":true}); + let mut bytes = frame(&json!({"event":{"heartbeat":{}}})); + bytes.extend(frame(&snapshot)); + for size in 1..=bytes.len() { + let mut parser = TimelineSnapshot::default(); + let mut result = None; + for chunk in bytes.chunks(size) { + result = parser.push(chunk, 4096).unwrap().or(result); + } + assert_eq!( + serde_json::from_slice::(&result.unwrap()).unwrap(), + snapshot + ); + } + } + + #[test] + fn snapshot_rejects_errors_partial_state_and_unbounded_streams() { + for value in [ + json!({"event":{"error":{"message":"secret"}}}), + json!({"event":{"conversationDeleted":{}}}), + json!({"event":{"update":{"conversation":{"id":"example"}}}}), + json!({"unexpected":true}), + ] { + let error = TimelineSnapshot::default() + .push(&frame(&value), 4096) + .unwrap_err(); + assert!(!error.contains("secret")); + } + assert!( + TimelineSnapshot::default() + .push(&[0, 255, 255, 255, 255], 4096) + .is_err() + ); + for flags in [1, 2, 3, 255] { + let mut bytes = frame(&json!({"error":{"message":"secret"}})); + bytes[0] = flags; + assert!(TimelineSnapshot::default().push(&bytes, 4096).is_err()); + } + let bytes = frame(&json!({"event":{"heartbeat":{}}})).repeat(129); + assert!(TimelineSnapshot::default().push(&bytes, 65536).is_err()); + } } - async fn execute_get( + fn build_read_request( client: &wreq::Client, request: &BrowserRequest, - ) -> std::result::Result { + ) -> std::result::Result { let mut cookie = wreq::header::HeaderValue::from_str(&request.cookie) .map_err(|_| "could not construct a safe Claude cookie header".to_string())?; cookie.set_sensitive(true); - let mut builder = client - .get(&request.url) + let builder = client.get(&request.url); + #[cfg(feature = "cowork_remote")] + let builder = if let Some(body) = &request.timeline_body { + // This is a read RPC with POST framing, not a general POST client. + // Its route and existing-only request are constructed above. + if !request.url.ends_with(COWORK_TIMELINE_PATH) { + return Err("invalid Cowork timeline read route".into()); + } + client + .post(&request.url) + .body(body.clone()) + .header("content-type", "application/connect+json") + .header("connect-protocol-version", "1") + } else { + builder + }; + let mut builder = builder .header(wreq::header::COOKIE, cookie) .header(wreq::header::ACCEPT, request.accept) .header("referer", "https://claude.ai/new") @@ -1055,6 +1320,14 @@ mod remote { value.set_sensitive(true); builder = builder.header(*name, value); } + Ok(builder) + } + + async fn execute_read( + client: &wreq::Client, + request: &BrowserRequest, + ) -> std::result::Result { + let builder = build_read_request(client, request)?; let response = builder .send() .await @@ -1080,21 +1353,48 @@ mod remote { )); } let mut body = Vec::new(); + let mut total_bytes = 0_u64; + #[cfg(feature = "cowork_remote")] + let mut timeline = (request.timeline_body.is_some() && (200..300).contains(&status)) + .then(TimelineSnapshot::default); + #[cfg(feature = "cowork_remote")] + if timeline.is_some() + && !content_type + .as_deref() + .is_some_and(|mime| mime.starts_with("application/connect+json")) + { + return Err("Claude returned an unexpected timeline content type".into()); + } let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|error| format!("failed reading Claude response: {error}"))?; - let length = u64::try_from(body.len()) - .unwrap_or(u64::MAX) - .saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); - if length > request.max_bytes { + total_bytes = + total_bytes.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + if total_bytes > request.max_bytes { return Err(format!( "Claude response exceeded the {} byte limit", request.max_bytes )); } + #[cfg(feature = "cowork_remote")] + if let Some(snapshot) = &mut timeline { + if let Some(body) = snapshot.push(&chunk, request.max_bytes)? { + return Ok(BrowserResponse { + status, + content_type, + cf_mitigated, + body, + }); + } + continue; + } body.extend_from_slice(&chunk); } + #[cfg(feature = "cowork_remote")] + if timeline.is_some() { + return Err("Claude timeline ended before a complete snapshot".into()); + } Ok(BrowserResponse { status, content_type, @@ -1166,6 +1466,18 @@ mod remote { organization_uuid: Option, ) -> Result { validate_uuid("conversation", &conversation_uuid)?; + let organization_uuid = self.resolve_organization(organization_uuid)?; + Ok(ClaudeChatRef { + organization_uuid, + conversation_uuid, + updated_at: None, + }) + } + + pub(crate) fn resolve_organization( + &self, + organization_uuid: Option, + ) -> Result { let organization_uuid = organization_uuid .or_else(|| self.organization_uuid.clone()) .or_else(|| self.active_organization_uuid.clone()) @@ -1174,11 +1486,7 @@ mod remote { detail: "Claude Desktop has no current organization; open Claude Desktop and select the account or organization containing this chat".to_string(), })?; validate_uuid("organization", &organization_uuid)?; - Ok(ClaudeChatRef { - organization_uuid, - conversation_uuid, - updated_at: None, - }) + Ok(organization_uuid) } fn build( @@ -1247,7 +1555,7 @@ mod remote { Ok(organizations) } - fn organizations(&self) -> Result> { + pub(crate) fn organizations(&self) -> Result> { if let Some(id) = &self.organization_uuid { return Ok(vec![id.clone()]); } @@ -1258,7 +1566,7 @@ mod remote { } #[cfg(test)] - fn for_test( + pub(crate) fn for_test( session_key: &str, organization_uuid: Option, base_url: String, @@ -1443,17 +1751,91 @@ mod remote { } fn get_json_with_cookie(&self, path: &str, cookie: String) -> Result { - let url = format!("{}{path}", self.base_url); + self.get_json_with_headers(path, cookie, self.request_headers()) + } + + // Share Desktop authentication and the bounded, redirect-free GET + // transport with Cowork. Only code-session routes are accepted here. + #[cfg(feature = "cowork_remote")] + pub(crate) fn get_code_json(&self, path: &str, organization: &str) -> Result { + validate_uuid("organization", organization)?; + if !path.starts_with("/v1/code/sessions?") && !path.starts_with("/v1/code/sessions/") { + return Err(protocol_error("invalid code-session route")); + } + let mut headers = self.request_headers(); + headers.extend([ + ("anthropic-version", "2023-06-01".to_string()), + ("anthropic-beta", "ccr-byoc-2025-07-29".to_string()), + ("anthropic-client-feature", "ccr".to_string()), + ("x-organization-uuid", organization.to_string()), + ]); + self.get_json_with_headers(path, self.cookie_header(), headers) + } + + /// Download only a file explicitly carried by this Cowork session. + #[cfg(feature = "cowork_remote")] + pub(crate) fn cowork_file( + &self, + organization: &str, + session: &str, + file: Option<&str>, + path: Option<&str>, + ) -> Result<(Vec, Option)> { + validate_uuid("organization", organization)?; + let session = crate::harness::cowork_remote::normalize_id(session)?; + let route = if let Some(file) = file { + validate_uuid("file", file)?; + format!("/api/organizations/{organization}/files/{file}/contents") + } else if let Some(path) = path { + format!( + "/api/organizations/{organization}/cowork/sessions/{session}/download-file?path={}", + encode_query_component(path) + ) + } else { + return Err(protocol_error("file has no download reference")); + }; let response = self.agent.get( - url, - cookie, - "application/json", + format!("{}{route}", self.base_url), + self.cookie_header(), + "application/octet-stream,*/*;q=0.8", self.request_headers(), - MAX_RESPONSE_BYTES, + MAX_FILE_BYTES, )?; if !(200..300).contains(&response.status) { return Err(remote_error(&response)); } + Ok((response.body, response.content_type)) + } + + #[cfg(feature = "cowork_remote")] + pub(crate) fn cowork_chat_snapshot(&self, id: &str, organization: &str) -> Result { + validate_uuid("organization", organization)?; + validate_uuid("conversation", id)?; + let mut headers = self.request_headers(); + headers.push(("x-organization-uuid", organization.to_string())); + let response = + self.agent + .cowork_timeline(&self.base_url, self.cookie_header(), headers, id)?; + if !(200..300).contains(&response.status) { + return Err(remote_error(&response)); + } + serde_json::from_slice(&response.body) + .map_err(|_| protocol_error("Claude returned invalid snapshot JSON")) + } + + fn get_json_with_headers( + &self, + path: &str, + cookie: String, + headers: Vec<(&'static str, String)>, + ) -> Result { + let url = format!("{}{path}", self.base_url); + let response = + self.agent + .get(url, cookie, "application/json", headers, MAX_RESPONSE_BYTES)?; + if !(200..300).contains(&response.status) { + return Err(remote_error(&response)); + } serde_json::from_slice(&response.body).map_err(|error| Error::Remote { harness: ClaudeChat::NAME, detail: format!("Claude returned unexpected JSON: {error}"), @@ -2815,6 +3197,39 @@ mod codec_tests { use super::*; + #[test] + fn research_reports_and_create_file_text_survive_as_named_files() { + let value = json!({"uuid":"11111111-1111-4111-8111-111111111111","created_at":"2026-01-01T00:00:00Z", + "chat_messages":[{"uuid":"22222222-2222-4222-8222-222222222222","sender":"assistant","content":[ + {"type":"tool_use","name":"artifacts","input":{"command":"create","id":"report","version_uuid":"version-1","title":"Research","type":"text/markdown","content":"# Report\nExact bytes: 世界\n"}}, + {"type":"tool_use","name":"create_file","input":{"path":"/mnt/user-data/outputs/notes.md","file_text":"# Notes\n"}}, + {"type":"tool_use","name":"artifacts","input":{"command":"update","id":"report","old_str":"Report","new_str":"Report 2"}} + ]}]}); + let native = ClaudeChat::from_text(&value.to_string()).expect("fixture parses"); + let common = ClaudeChat::to_common(&native).expect("converts"); + let files = common + .body + .iter() + .flat_map(|message| &message.content) + .filter_map(|block| match block { + Block::Artifact { artifact } => Some(artifact), + _ => None, + }) + .collect::>(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "Research.md"); + assert_eq!(files[0].id, "version-1"); + assert!( + matches!(&files[0].source, ArtifactSource::Text { text, .. } if text == "# Report\nExact bytes: 世界\n") + ); + assert_eq!(files[1].name, "notes.md"); + assert_eq!( + ClaudeChat::from_text(&ClaudeChat::to_text(&native).expect("serializes")) + .expect("parses"), + native + ); + } + #[test] fn data_export_arrays_are_refused() { let error = ClaudeChat::from_text("[]").expect_err("arrays are not live conversations"); diff --git a/src/harness/cowork.rs b/src/harness/cowork.rs index aaa4109..8448f24 100644 --- a/src/harness/cowork.rs +++ b/src/harness/cowork.rs @@ -26,7 +26,7 @@ //! missing logs are both valid states for it). //! //! Not carried: the per-task `.claude/.claude.json` config cache and its -//! backups, `uploads/` and `outputs/` (the user's files), subagent +//! backups, unreferenced files under `uploads/` and `outputs/`, subagent //! transcripts under `/subagents/`, and `debug/`. //! //! `to_common` is the Claude Code mapping over the transcript; the header @@ -47,7 +47,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value}; use uuid::Uuid; -use crate::common::{Block, Message, Meta, Role}; +use crate::common::{Artifact, ArtifactSource, Block, Message, Meta, Role}; use crate::error::{Error, Result}; use crate::harness::claude_code::{self, Record}; use crate::harness::jsonl; @@ -73,6 +73,8 @@ pub struct CoworkSession { pub transcript: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub audit: Vec, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub files: std::collections::BTreeMap, } /// The app's session record (`local_.json`). Only the fields the codec @@ -124,12 +126,19 @@ pub struct Header { impl Codec for Cowork { fn to_common(transcript: &Transcript) -> Result> { + let records = transcript + .body + .transcript + .iter() + .map(|record| { + let mut payload = serde_json::to_value(record)?; + super::cowork_files::attach(&mut payload, &transcript.body.files)?; + Ok(Record::from(payload)) + }) + .collect::>>()?; Ok(Transcript::new( transcript.meta.clone(), - claude_code::records_to_messages( - &transcript.body.transcript, - transcript.meta.timestamp, - ), + claude_code::records_to_messages(&records, transcript.meta.timestamp), )) } @@ -214,6 +223,7 @@ fn body_from_messages(meta: &Meta, messages: &[Message]) -> (Meta, CoworkSession header, transcript, audit: Vec::new(), + files: std::collections::BTreeMap::new(), }, ) } @@ -356,7 +366,7 @@ impl CoworkStore { /// The account tree `save` writes into: the one whose newest session /// record is most recent (the account the app is using), else the only /// one there is. - fn active_account_dir(&self) -> Result { + pub(crate) fn active_account_dir(&self) -> Result { let newest_record = |dir: &Path| { session_files(dir) .iter() @@ -487,11 +497,17 @@ impl Store for CoworkStore { let audit = fs::read_to_string(dir.join("audit.jsonl")) .map(|text| jsonl::parse(&text)) .unwrap_or_default(); - let body = CoworkSession { + let mut body = CoworkSession { header, transcript, audit, + files: if dir.join(".txcript-files.json").exists() { + serde_json::from_slice(&fs::read(dir.join(".txcript-files.json"))?)? + } else { + std::collections::BTreeMap::new() + }, }; + hydrate_local_files(&dir, &mut body)?; let mut meta = meta_from_body(&body); if meta.id.is_empty() { meta.id = jsonl::file_id(reference); @@ -542,6 +558,12 @@ impl Store for CoworkStore { if !body.audit.is_empty() { fs::write(dir.join("audit.jsonl"), jsonl::render(&body.audit)?)?; } + if !body.files.is_empty() { + fs::write( + dir.join(".txcript-files.json"), + serde_json::to_vec(&body.files)?, + )?; + } fs::write(&record, serde_json::to_string(&body.header)?)?; Ok(Saved { id, @@ -600,3 +622,88 @@ impl Store for CoworkStore { Ok(out) } } + +/// Resolve only explicit attachment references within this session's file roots. +fn hydrate_local_files(dir: &Path, body: &mut CoworkSession) -> Result<()> { + use base64::Engine; + use std::io::Read; + let mut total = 0_usize; + for record in &body.transcript { + let payload = serde_json::to_value(record)?; + for file in super::cowork_files::references(&payload) { + if body.files.contains_key(&file.key) { + continue; + } + let path = file.path.as_deref().unwrap_or(&file.name); + let relative = Path::new(path) + .strip_prefix(dir) + .ok() + .and_then(|path| path.to_str()) + .or_else(|| path.strip_prefix("/mnt/user-data/")) + .or_else(|| { + body.header + .cwd + .as_deref() + .and_then(|cwd| path.strip_prefix(cwd)) + .and_then(|rest| rest.strip_prefix('/')) + }) + .unwrap_or(path); + let relative = Path::new(relative); + if relative.is_absolute() + || relative + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(super::cowork_files::error(&format!( + "Could not include {}: file is outside the session's uploads/outputs", + file.name + ))); + } + let candidates = if relative.starts_with("uploads") || relative.starts_with("outputs") { + vec![dir.join(relative)] + } else { + vec![ + dir.join("uploads").join(relative), + dir.join("outputs").join(relative), + ] + }; + let allowed = [dir.join("uploads"), dir.join("outputs")] + .into_iter() + .filter_map(|path| path.canonicalize().ok()) + .collect::>(); + let resolved = candidates + .into_iter() + .filter_map(|path| path.canonicalize().ok()) + .filter(|path| path.is_file() && allowed.iter().any(|root| path.starts_with(root))) + .collect::>(); + if resolved.len() != 1 { + return Err(super::cowork_files::error(&format!( + "Could not include {}: file is missing or ambiguous in the local Cowork cache", + file.name + ))); + } + let mut bytes = Vec::new(); + fs::File::open(&resolved[0])? + .take(64 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + total = total.saturating_add(bytes.len()); + if bytes.len() > 64 * 1024 * 1024 || total > 128 * 1024 * 1024 { + return Err(super::cowork_files::error( + "Cowork attachments exceed the file size limit", + )); + } + body.files.insert( + file.key.clone(), + Artifact { + id: file.key, + name: file.name.clone(), + source: ArtifactSource::Base64 { + data: base64::engine::general_purpose::STANDARD.encode(bytes), + media_type: super::cowork_files::media_type(&file.name).map(str::to_string), + }, + }, + ); + } + } + Ok(()) +} diff --git a/src/harness/cowork_files.rs b/src/harness/cowork_files.rs new file mode 100644 index 0000000..721b5d9 --- /dev/null +++ b/src/harness/cowork_files.rs @@ -0,0 +1,206 @@ +//! Explicit Cowork attachment carriers. Ordinary tool paths are not attachments. +use crate::common::{Artifact, ArtifactSource}; +use crate::{Error, Result}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone)] +pub(super) struct FileRef { + pub key: String, + pub name: String, + pub path: Option, + pub uuid: Option, +} + +// Keep the observed attachment shapes together for review. +#[allow(clippy::too_many_lines)] +pub(super) fn references(payload: &Value) -> Vec { + let mut files = BTreeMap::new(); + let mut add_path = |path: &str, name: Option<&str>| { + let path = path.strip_prefix("computer://").unwrap_or(path); + if path.is_empty() { + return; + } + let name = name + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file")); + files.insert( + format!("path:{path}"), + FileRef { + key: format!("path:{path}"), + name: name.to_string(), + path: Some(path.into()), + uuid: None, + }, + ); + }; + if let Some(blocks) = payload + .pointer("/message/content") + .and_then(Value::as_array) + { + for block in blocks { + if block["type"] == "tool_use" { + let name = block["name"].as_str().unwrap_or_default(); + let input = &block["input"]; + if matches!(name, "Artifact" | "SendUserFile") { + if let Some(path) = input["file_path"] + .as_str() + .or_else(|| input["path"].as_str()) + { + add_path(path, None); + } + } else if name == "present_files" || name.ends_with("__present_files") { + for path in input["filepaths"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + add_path(path, None); + } + } + } + } + } + // The app prefixes the user prompt with its attachment manifest. Never + // scan arbitrary prose, shell commands, or Read/Write tool arguments. + let content = payload.pointer("/message/content"); + let texts: Vec<&str> = match content { + Some(Value::String(text)) => vec![text], + Some(Value::Array(blocks)) => blocks + .iter() + .filter(|b| b["type"] == "text") + .filter_map(|b| b["text"].as_str()) + .collect(), + _ => Vec::new(), + }; + for text in texts { + if let Some(manifest) = + text.trim_start() + .strip_prefix("") + .and_then(|text| { + text.split_once("") + .map(|(manifest, _)| manifest) + }) + { + for tail in manifest.split("").skip(1) { + if let Some((path, _)) = tail.split_once("") { + add_path(path.trim(), None); + } + } + } + } + for file in payload["file_attachments"].as_array().into_iter().flatten() { + // Images already have their exact bytes in SDK image blocks. + if file["is_image"] == true { + continue; + } + if let (Some(uuid), Some(name)) = (file["file_uuid"].as_str(), file["file_name"].as_str()) { + let key = format!("file:{uuid}"); + files.insert( + key.clone(), + FileRef { + key, + name: name.into(), + uuid: Some(uuid.into()), + path: None, + }, + ); + } + } + let values: Vec<_> = files.into_values().collect(); + values + .iter() + .filter(|file| { + !file.path.as_deref().is_some_and(|path| { + values.iter().any(|other| { + other.key != file.key + && other.name == file.name + && (other.uuid.is_some() + || (!path.contains('/') + && other.path.as_deref().is_some_and(|path| path.contains('/')))) + }) + }) + }) + .cloned() + .collect() +} + +pub(super) fn attach(payload: &mut Value, files: &BTreeMap) -> Result<()> { + let refs = references(payload); + if refs.is_empty() { + return Ok(()); + } + let mut blocks = match payload.pointer("/message/content") { + Some(Value::Array(blocks)) => blocks.clone(), + Some(Value::String(text)) => vec![serde_json::json!({"type":"text","text":text})], + _ => Vec::new(), + }; + for file in refs { + let Some(artifact) = files.get(&file.key) else { + continue; + }; + let (kind, data, media_type) = match &artifact.source { + ArtifactSource::Text { text, media_type } => ("text", text, media_type), + ArtifactSource::Base64 { data, media_type } => ("base64", data, media_type), + ArtifactSource::Path { .. } => return Err(error("hydrated file contains a host path")), + }; + let document = serde_json::json!({"type":"document","id":artifact.id,"title":artifact.name, + "source":{"type":kind,"data":data,"media_type":media_type}}); + if let Some(block) = blocks.iter_mut().find(|block| { + block["type"] == "tool_use" + && block["name"] == "Artifact" + && block.pointer("/input/file_path").and_then(Value::as_str) == file.path.as_deref() + }) { + *block = document; + } else { + blocks.push(document); + } + } + payload["message"]["content"] = Value::Array(blocks); + Ok(()) +} + +pub(super) fn error(detail: &str) -> Error { + Error::Malformed { + harness: "cowork", + detail: detail.into(), + } +} + +/// A fallback for local cache files, whose attachment manifests omit MIME types. +pub(super) fn media_type(name: &str) -> Option<&'static str> { + let extension = std::path::Path::new(name) + .extension()? + .to_str()? + .to_ascii_lowercase(); + Some(match extension.as_str() { + "pdf" => "application/pdf", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "csv" => "text/csv", + "md" => "text/markdown", + "txt" => "text/plain", + "json" => "application/json", + "html" => "text/html", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn uploaded_manifest_and_file_id_are_one_attachment() { + let payload = serde_json::json!({ + "type":"user", "message":{"content":"terms.pdfRead this"}, + "file_attachments":[{"file_uuid":"file-id","file_name":"terms.pdf","is_image":false}] + }); + let files = super::references(&payload); + assert_eq!(files.len(), 1); + assert_eq!(files[0].uuid.as_deref(), Some("file-id")); + } +} diff --git a/src/harness/cowork_remote.rs b/src/harness/cowork_remote.rs new file mode 100644 index 0000000..0656f26 --- /dev/null +++ b/src/harness/cowork_remote.rs @@ -0,0 +1,1100 @@ +//! Cloud Cowork sessions, pulled explicitly from the signed-in Claude Desktop +//! account. Native exports retain the session response and event envelopes; +//! Common uses the Claude Code codec for complete top-level SDK messages. +//! Streaming deltas, control events, and subagent messages remain native-only. +//! Explicit attachments and presented files are downloaded on an explicit load. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::common::{ArtifactSource, Block, Meta, Tool}; +use crate::harness::claude_code::{self, Record}; +use crate::{Codec, Common, Error, Harness, Result, TextCodec, Transcript}; + +/// A read-only cloud Cowork source, distinct from the writable local `cowork`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CoworkRemote; + +impl Harness for CoworkRemote { + const NAME: &'static str = "cowork_remote"; + type Body = RemoteSession; +} + +/// The complete detail response and ordered event envelopes. Unknown fields +/// are retained, including tool metadata and attachment references. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteSession { + pub session: Value, + pub events: Vec, + #[serde(flatten)] + pub extra: Map, +} + +impl TextCodec for CoworkRemote { + fn from_text(text: &str) -> Result> { + let body: RemoteSession = serde_json::from_str(text)?; + let meta = metadata(session_detail(&body.session))?; + Ok(Transcript::new(meta, body)) + } + + fn to_text(transcript: &Transcript) -> Result { + Ok(serde_json::to_string_pretty(&transcript.body)?) + } +} + +impl Codec for CoworkRemote { + fn to_common(transcript: &Transcript) -> Result> { + let files: std::collections::BTreeMap = transcript + .body + .extra + .get("$txcript_files") + .map(|value| serde_json::from_value(value.clone())) + .transpose()? + .unwrap_or_default(); + let mut records = Vec::new(); + let mut seen = std::collections::HashMap::new(); + let mut file_tools = std::collections::HashMap::new(); + for event in &transcript.body.events { + if let Some(id) = event.get("event_id").and_then(Value::as_str) + && let Some(previous) = seen.insert(id, event) + { + if previous != event { + return Err(malformed("conflicting duplicate event id")); + } + continue; + } + let mut payload = event + .get("payload") + .cloned() + .ok_or_else(|| malformed("event is missing `payload`"))?; + if !matches!( + payload.get("type").and_then(Value::as_str), + Some("user" | "assistant") + ) || payload + .get("parent_tool_use_id") + .is_some_and(|id| !id.is_null()) + { + continue; + } + if let Some(blocks) = payload + .pointer("/message/content") + .and_then(Value::as_array) + { + for block in blocks { + if block.get("type").and_then(Value::as_str) == Some("tool_use") + && block.get("name").and_then(Value::as_str) == Some("Artifact") + && let Some(id) = block.get("id").and_then(Value::as_str) + { + file_tools.insert( + id.to_string(), + block.get("input").cloned().unwrap_or(Value::Null), + ); + } + } + } + let object = payload + .as_object_mut() + .ok_or_else(|| malformed("message payload is not an object"))?; + if !object.contains_key("uuid") { + let id = event + .get("event_id") + .and_then(Value::as_str) + .ok_or_else(|| malformed("message has neither uuid nor event_id"))?; + object.insert("uuid".into(), Value::String(id.into())); + } + if !object.contains_key("timestamp") + && let Some(timestamp) = event.get("created_at") + { + object.insert("timestamp".into(), timestamp.clone()); + } + super::cowork_files::attach(&mut payload, &files)?; + let record = Record::from(payload); + if matches!(record, Record::Other(_)) { + return Err(malformed("invalid SDK message in Cowork events")); + } + records.push(record); + } + let mut messages = claude_code::records_to_messages(&records, transcript.meta.timestamp); + for message in &mut messages { + for block in &mut message.content { + if let Block::Artifact { artifact } = block + && matches!(artifact.source, ArtifactSource::Path { .. }) + { + // A VM path must never be mistaken for a readable host + // file. Keep its original tool reference until hydrated. + let input = file_tools + .get(&artifact.id) + .cloned() + .ok_or_else(|| malformed("remote artifact is missing its source tool"))?; + *block = Block::ToolUse { + id: artifact.id.clone(), + tool: Tool::Raw { + tool_name: "Artifact".into(), + input, + }, + }; + } + } + } + let mut meta = transcript.meta.clone(); + if meta.model.is_none() { + meta.model = messages.iter().find_map(|message| message.model.clone()); + } + Ok(Transcript::new(meta, messages)) + } + + fn from_common(_: &Transcript) -> Result> { + Err(read_only_error()) + } +} + +/// Validate a full Cowork id or a Claude Cowork/chat URL. Chat aliases use +/// Claude's version-8 UUIDs; resolving them never enumerates the account. +/// +/// # Errors +/// When the input is not a Cowork id, chat alias, or supported claude.ai URL. +pub fn normalize_id(id: &str) -> Result { + if let Some(path) = id.strip_prefix("https://claude.ai/") { + let path = path + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim_end_matches('/'); + if let Some(id) = path.strip_prefix("cowork/") { + return normalize_session_id(id); + } + if let Some(id) = path.strip_prefix("chat/") { + return chat_id(id) + .ok_or_else(|| malformed("expected a Cowork chat URL with a version-8 UUID")); + } + return Err(malformed( + "expected a claude.ai/cowork/ or claude.ai/chat/ URL", + )); + } + if let Some(id) = chat_id(id) { + return Ok(id); + } + normalize_session_id(id) +} + +fn chat_id(id: &str) -> Option { + // Require the normal URL spelling, not UUID parser conveniences such as + // URNs or braces. Version alone selects a lookup, never proves its source. + let uuid = uuid::Uuid::parse_str(id).ok()?; + (id.len() == 36 && uuid.get_version_num() == 8).then(|| uuid.to_string()) +} + +fn normalize_session_id(id: &str) -> Result { + let suffix = id + .strip_prefix("cse_") + .or_else(|| id.strip_prefix("session_")) + .filter(|suffix| { + !suffix.is_empty() + && suffix.len() <= 64 + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) + .ok_or_else(|| malformed("expected a full cse_… or session_… Cowork session id"))?; + Ok(format!("cse_{suffix}")) +} + +#[cfg(feature = "cowork_remote")] +fn session_from_chat(snapshot: &Value, requested_id: &str) -> Result { + let conversation = snapshot + .pointer("/event/update/conversation") + .ok_or_else(|| malformed("chat snapshot is missing its conversation"))?; + if conversation.get("id").and_then(Value::as_str) != Some(requested_id) { + return Err(malformed("Claude returned a different chat id")); + } + let extras = conversation + .get("extras") + .and_then(Value::as_array) + .ok_or_else(|| malformed("chat has no Cowork source metadata"))?; + let has_cowork_meta = extras.iter().any(|extra| { + extra.get("@type").and_then(Value::as_str) + == Some("type.googleapis.com/anthropic.bard.api.v1alpha.CoworkSessionMeta") + }); + if !has_cowork_meta { + return Err(malformed( + "chat is not a presented Cowork session; use claude_chat for ordinary chats", + )); + } + let mut session_id = None; + for extra in extras { + if extra.get("@type").and_then(Value::as_str) + != Some("type.googleapis.com/anthropic.bard.api.v1alpha.WorkspaceUpgradeState") + { + continue; + } + let id = extra + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| malformed("Cowork chat mapping is missing its session id"))?; + let id = normalize_session_id(id)?; + if session_id.as_ref().is_some_and(|previous| previous != &id) { + return Err(malformed("Cowork chat has conflicting session mappings")); + } + session_id = Some(id); + } + session_id.ok_or_else(|| malformed("Cowork chat has no backing session mapping")) +} + +fn session_detail(response: &Value) -> &Value { + response + .get("session") + .or_else(|| { + response + .get("response_shape") + .filter(|value| value.get("id").is_some()) + }) + .unwrap_or(response) +} + +fn metadata(session: &Value) -> Result { + let id = session + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| malformed("session is missing string `id`"))?; + Ok(Meta { + id: normalize_session_id(id)?, + timestamp: timestamp(session, "created_at").unwrap_or(DateTime::::UNIX_EPOCH), + // This is a remote VM path, not a directory on the receiving machine. + cwd: None, + git_branch: None, + title: session + .get("title") + .and_then(Value::as_str) + .map(String::from), + cli_version: None, + model: session + .pointer("/config/model") + .and_then(Value::as_str) + .map(String::from), + lineage: None, + }) +} + +fn timestamp(value: &Value, key: &str) -> Option> { + value.get(key)?.as_str()?.parse().ok() +} + +fn malformed(detail: &str) -> Error { + Error::Malformed { + harness: CoworkRemote::NAME, + detail: detail.into(), + } +} + +pub(crate) fn read_only_error() -> Error { + Error::Unconvertible { + harness: CoworkRemote::NAME, + detail: "cloud Cowork is pull-only; choose a writable destination such as cowork, claude_code, or codex".into(), + } +} + +#[cfg(feature = "cowork_remote")] +mod remote { + use base64::Engine; + use std::collections::HashSet; + + use super::{ + CoworkRemote, RemoteSession, chat_id, malformed, metadata, normalize_id, read_only_error, + session_detail, session_from_chat, timestamp, + }; + use crate::harness::claude_chat::ClaudeChatStore; + use crate::{Discovered, Error, Result, Saved, Store, Transcript}; + use chrono::{DateTime, Utc}; + use serde_json::Value; + + const MAX_PAGES: usize = 1000; + const MAX_ROWS: usize = 500_000; + + /// The account and Cowork id or chat alias needed for an explicit read. + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct CoworkRemoteRef { + pub organization_uuid: String, + pub session_id: String, + pub updated_at: Option>, + } + + /// Read-only store using the same host-owned login as Claude Chat. + pub struct CoworkRemoteStore { + claude: ClaudeChatStore, + } + + impl CoworkRemoteStore { + /// Reuse the signed-in Claude Desktop account without enumerating it. + /// + /// # Errors + /// When Desktop credentials are unavailable or invalid. + pub fn from_desktop() -> Result { + Ok(Self { + claude: ClaudeChatStore::from_desktop().map_err(remote_error)?, + }) + } + + /// Resolve an id in the explicit or Desktop-active organization, + /// without first listing cloud sessions. + /// + /// # Errors + /// When either identifier is invalid or no organization is selected. + pub fn session_ref( + &self, + id: &str, + organization: Option, + ) -> Result { + let session_id = normalize_id(id)?; + Ok(CoworkRemoteRef { + organization_uuid: self + .claude + .resolve_organization(organization) + .map_err(remote_error)?, + session_id, + updated_at: None, + }) + } + + fn get(&self, path: &str, organization: &str) -> Result { + self.claude + .get_code_json(path, organization) + .map_err(remote_error) + } + + fn pages(&self, path: &str, organization: &str) -> Result> { + let mut rows = Vec::new(); + let mut total_bytes = 0_usize; + let mut cursors = HashSet::new(); + let mut cursor = None; + for _ in 0..MAX_PAGES { + let url = cursor.as_ref().map_or_else( + || path.to_string(), + |cursor: &String| format!("{path}&cursor={}", encode(cursor)), + ); + let page = self.get(&url, organization)?; + total_bytes = total_bytes.saturating_add(serde_json::to_vec(&page)?.len()); + if total_bytes > 256 * 1024 * 1024 { + return Err(malformed( + "Cowork pagination exceeded the byte limit; refusing a partial transcript", + )); + } + let data = page + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| malformed("page is missing array `data`"))?; + if rows.len().saturating_add(data.len()) > MAX_ROWS { + return Err(malformed( + "Cowork pagination exceeded the row limit; refusing a partial transcript", + )); + } + rows.extend(data.iter().cloned()); + match page.get("next_cursor") { + None | Some(Value::Null) => return Ok(rows), + Some(Value::String(next)) if next.is_empty() => return Ok(rows), + Some(Value::String(next)) + if !data.is_empty() && cursors.insert(next.clone()) => + { + cursor = Some(next.clone()); + } + _ => { + return Err(malformed( + "invalid or repeated pagination cursor; refusing a partial transcript", + )); + } + } + } + Err(malformed( + "Cowork pagination exceeded the page limit; refusing a partial transcript", + )) + } + } + + impl Store for CoworkRemoteStore { + type H = CoworkRemote; + type Ref = CoworkRemoteRef; + + fn discover(&self) -> Result>> { + let mut found = Vec::new(); + let mut seen = HashSet::new(); + for organization in self.claude.organizations().map_err(remote_error)? { + for row in self.pages( + "/v1/code/sessions?tags=cowork-remote&limit=200", + &organization, + )? { + // The code-session endpoint also serves Claude Code; keep + // the provider's explicit Cowork tag as a second guard. + if !row + .get("tags") + .and_then(Value::as_array) + .is_some_and(|tags| tags.iter().any(|tag| tag == "cowork-remote")) + { + continue; + } + let meta = metadata(&row)?; + if seen.insert((organization.clone(), meta.id.clone())) { + found.push(Discovered { + reference: CoworkRemoteRef { + organization_uuid: organization.clone(), + session_id: meta.id.clone(), + updated_at: timestamp(&row, "last_event_at") + .or_else(|| timestamp(&row, "updated_at")), + }, + meta, + }); + } + } + } + found.sort_by_key(|item| std::cmp::Reverse(item.meta.timestamp)); + Ok(found) + } + + fn load(&self, reference: &Self::Ref) -> Result> { + let id = normalize_id(&reference.session_id)?; + let (id, snapshot) = if chat_id(&id).is_some() { + let snapshot = self + .claude + .cowork_chat_snapshot(&id, &reference.organization_uuid) + .map_err(remote_error)?; + (session_from_chat(&snapshot, &id)?, Some(snapshot)) + } else { + (id, None) + }; + let path = format!("/v1/code/sessions/{id}"); + let session = self.get(&path, &reference.organization_uuid)?; + let detail = session_detail(&session); + let meta = metadata(detail)?; + if meta.id != id { + return Err(malformed("Cowork returned a different session id")); + } + if !detail + .get("tags") + .and_then(Value::as_array) + .is_some_and(|tags| tags.iter().any(|tag| tag == "cowork-remote")) + { + return Err(malformed("session is not tagged cowork-remote")); + } + let events = self.pages( + &format!("{path}/events?limit=500&sort_order=asc"), + &reference.organization_uuid, + )?; + // Reject malformed pages before claiming to have loaded a session. + if events + .iter() + .any(|event| !event.get("payload").is_some_and(Value::is_object)) + { + return Err(malformed("event is missing object `payload`")); + } + let mut extra = serde_json::Map::default(); + let mut files = std::collections::BTreeMap::new(); + let mut total = 0_usize; + for event in &events { + let payload = &event["payload"]; + if !matches!(payload["type"].as_str(), Some("user" | "assistant")) + || payload + .get("parent_tool_use_id") + .is_some_and(|value| !value.is_null()) + { + continue; + } + for file in super::super::cowork_files::references(payload) { + if files.contains_key(&file.key) { + continue; + } + let (bytes, media_type) = self + .claude + .cowork_file( + &reference.organization_uuid, + &id, + file.uuid.as_deref(), + file.path.as_deref(), + ) + .map_err(|error| Error::Remote { + harness: "cowork_remote", + detail: format!("Could not include {}: {error}", file.name), + })?; + total = total.saturating_add(bytes.len()); + if total > 128 * 1024 * 1024 { + return Err(malformed("Cowork files exceed the 128 MB limit")); + } + files.insert( + file.key.clone(), + crate::common::Artifact { + id: file.key, + name: file.name, + source: crate::common::ArtifactSource::Base64 { + data: base64::engine::general_purpose::STANDARD.encode(bytes), + media_type, + }, + }, + ); + } + } + if !files.is_empty() { + extra.insert("$txcript_files".into(), serde_json::to_value(files)?); + } + if let Some(snapshot) = snapshot { + extra.insert("$txcript_chat_snapshot".into(), snapshot); + } + Ok(Transcript::new( + meta, + RemoteSession { + session, + events, + extra, + }, + )) + } + + fn save(&self, _: &Transcript) -> Result> { + Err(read_only_error()) + } + + fn delete(&self, _: &Self::Ref) -> Result<()> { + Err(read_only_error()) + } + } + + fn remote_error(error: Error) -> Error { + match error { + Error::Remote { detail, .. } => Error::Remote { + harness: "cowork_remote", + detail, + }, + other => other, + } + } + + fn encode(value: &str) -> String { + use std::fmt::Write; + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + let _ = write!(encoded, "%{byte:02X}"); + } + } + encoded + } + #[cfg(test)] + #[allow(clippy::unwrap_used, clippy::panic, clippy::needless_pass_by_value)] + mod tests { + use super::*; + use crate::{Codec, Harness, TextCodec}; + use serde_json::json; + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + use std::thread; + + const ORG: &str = "00000000-0000-4000-8000-000000000001"; + + fn server( + responses: Vec<(u16, Value)>, + ) -> (CoworkRemoteStore, thread::JoinHandle>) { + server_bytes( + responses + .into_iter() + .map(|(status, value)| { + (status, "application/json", value.to_string().into_bytes()) + }) + .collect(), + ) + } + + fn server_bytes( + responses: Vec<(u16, &'static str, Vec)>, + ) -> (CoworkRemoteStore, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for (status, content_type, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = String::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + request.push_str(&line); + } + let length = request + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or_default(); + let mut sent = vec![0; length]; + reader.read_exact(&mut sent).unwrap(); + request.push_str(&String::from_utf8_lossy(&sent)); + requests.push(request); + write!(stream, "HTTP/1.1 {status} Test\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).unwrap(); + stream.write_all(&body).unwrap(); + } + requests + }); + let claude = + ClaudeChatStore::for_test("test-session-secret", Some(ORG.into()), base).unwrap(); + (CoworkRemoteStore { claude }, handle) + } + + const CHAT: &str = "00000000-0000-8000-8000-000000000002"; + + fn snapshot() -> Value { + json!({"event":{"update":{"replaceAllState":true,"conversation":{ + "id": CHAT, "title":"Cloud task", "extras":[ + {"@type":"type.googleapis.com/anthropic.bard.api.v1alpha.WorkspaceUpgradeState", "sessionId":"cse_test01"}, + {"@type":"type.googleapis.com/anthropic.bard.api.v1alpha.CoworkSessionMeta", "future":"preserve"} + ]}}}}) + } + + fn frame(value: &Value) -> Vec { + let body = value.to_string().into_bytes(); + let mut frame = vec![0]; + frame.extend_from_slice(&u32::try_from(body.len()).unwrap().to_be_bytes()); + frame.extend(body); + frame + } + + #[test] + fn load_downloads_attached_and_presented_files_once_and_keeps_associations() { + use base64::Engine; + let mut fixture = super::super::tests::fixture(); + fixture["events"][0]["payload"]["file_attachments"] = json!([{ + "file_name":"input.pdf","file_uuid":ORG,"is_image":false + }]); + let path = "/mnt/user-data/outputs/budget #1.xlsx"; + fixture["events"][1]["payload"]["message"]["content"] = json!([ + {"type":"tool_use","id":"file1","name":"mcp__cowork__present_files","input":{"filepaths":[path,path]}} + ]); + let pdf = b"%PDF-1.7\0\xff".to_vec(); + let sheet = b"PK\x03\x04\0\xff".to_vec(); + let (store, handle) = server_bytes(vec![ + ( + 200, + "application/json", + fixture["session"].to_string().into_bytes(), + ), + ( + 200, + "application/json", + json!({"data":fixture["events"]}).to_string().into_bytes(), + ), + (200, "application/pdf", pdf.clone()), + ( + 200, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + sheet.clone(), + ), + ]); + let native = store + .load(&store.session_ref("cse_test01", None).unwrap()) + .unwrap(); + let encoded = CoworkRemote::to_text(&native).unwrap(); + let common = + CoworkRemote::to_common(&CoworkRemote::from_text(&encoded).unwrap()).unwrap(); + for (message, name, expected) in [(0, "input.pdf", pdf), (1, "budget #1.xlsx", sheet)] { + let file = common.body[message] + .content + .iter() + .find_map(|block| match block { + crate::common::Block::Artifact { artifact } => Some(artifact), + _ => None, + }) + .unwrap(); + assert_eq!(file.name, name); + let crate::common::ArtifactSource::Base64 { data, .. } = &file.source else { + panic!("missing bytes") + }; + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + expected + ); + } + let requests = handle.join().unwrap(); + assert_eq!(requests.len(), 4); + assert!(requests.iter().all(|request| request.starts_with("GET "))); + assert!(requests[2].contains(&format!("/files/{ORG}/contents"))); + assert!(requests[3].contains("path=%2Fmnt%2Fuser-data%2Foutputs%2Fbudget%20%231.xlsx")); + } + + #[test] + fn unavailable_file_fails_with_its_name_instead_of_a_partial_success() { + let mut fixture = super::super::tests::fixture(); + fixture["events"][0]["payload"]["file_attachments"] = + json!([{"file_name":"missing.pdf","file_uuid":ORG}]); + let (store, handle) = server(vec![ + (200, fixture["session"].clone()), + (200, json!({"data":fixture["events"]})), + (404, json!({"error":"not found"})), + ]); + let error = store + .load(&store.session_ref("cse_test01", None).unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("missing.pdf")); + assert_eq!(handle.join().unwrap().len(), 3); + } + + #[test] + fn chat_url_resolves_then_reads_all_native_events_without_discovery() { + let fixture = super::super::tests::fixture(); + let snapshot = snapshot(); + let mut stream = frame(&json!({"event":{"heartbeat":{}}})); + stream.extend(frame(&snapshot)); + let (store, handle) = server_bytes(vec![ + (200, "application/connect+json", stream), + ( + 200, + "application/json", + fixture["session"].to_string().into_bytes(), + ), + ( + 200, + "application/json", + json!({"data":fixture["events"],"next_cursor":null}) + .to_string() + .into_bytes(), + ), + ]); + let reference = store + .session_ref(&format!("https://claude.ai/chat/{CHAT}"), None) + .unwrap(); + let native = store.load(&reference).unwrap(); + assert_eq!(native.meta.id, "cse_test01"); + assert_eq!(native.body.extra["$txcript_chat_snapshot"], snapshot); + assert_eq!(CoworkRemote::to_common(&native).unwrap().body.len(), 4); + let text = CoworkRemote::to_text(&native).unwrap(); + assert_eq!(CoworkRemote::from_text(&text).unwrap(), native); + let requests = handle.join().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests[0].starts_with("POST /claudeai-rpc/anthropic.bard.api.v1alpha.ConversationService/StreamTimeline HTTP/1.1")); + assert!(requests[0].contains("\"existingOnly\":true")); + assert!( + requests[0] + .to_ascii_lowercase() + .contains(&format!("x-organization-uuid: {ORG}")) + ); + assert!(requests[1].starts_with("GET /v1/code/sessions/cse_test01 HTTP/1.1")); + assert!(requests[2].contains("/events?limit=500&sort_order=asc")); + } + + #[test] + fn untrusted_or_ordinary_chat_mappings_never_trigger_a_session_read() { + let mut wrong_chat = snapshot(); + wrong_chat["event"]["update"]["conversation"]["id"] = json!(ORG); + let mut ordinary = snapshot(); + ordinary["event"]["update"]["conversation"]["extras"] + .as_array_mut() + .unwrap() + .pop(); + let mut unsafe_id = snapshot(); + unsafe_id["event"]["update"]["conversation"]["extras"][0]["sessionId"] = + json!("cse_../bad"); + let mut conflicting = snapshot(); + conflicting["event"]["update"]["conversation"]["extras"].as_array_mut().unwrap().push( + json!({"@type":"type.googleapis.com/anthropic.bard.api.v1alpha.WorkspaceUpgradeState","sessionId":"cse_other"})); + for bad in [wrong_chat, ordinary, unsafe_id, conflicting] { + let (store, handle) = + server_bytes(vec![(200, "application/connect+json", frame(&bad))]); + assert!(store.load(&store.session_ref(CHAT, None).unwrap()).is_err()); + assert_eq!(handle.join().unwrap().len(), 1); + } + } + + #[test] + fn failed_and_incomplete_timelines_do_not_fall_back_to_account_discovery() { + for (status, mime, body) in [ + (403, "application/json", b"{}".to_vec()), + (302, "text/html", Vec::new()), + (200, "application/json", b"{}".to_vec()), + ( + 200, + "application/connect+json", + frame(&json!({"event":{"heartbeat":{}}})), + ), + (200, "application/connect+json", vec![0, 0, 0, 0, 50, b'{']), + ] { + let (store, handle) = server_bytes(vec![(status, mime, body)]); + assert!(store.load(&store.session_ref(CHAT, None).unwrap()).is_err()); + assert_eq!(handle.join().unwrap().len(), 1); + } + } + + #[test] + fn direct_read_walks_all_events_without_listing() { + let fixture = super::super::tests::fixture(); + let (store, handle) = server(vec![ + (200, fixture["session"].clone()), + ( + 200, + json!({"data": fixture["events"].as_array().unwrap()[..2], "next_cursor":"a/b+?"}), + ), + ( + 200, + json!({"data": fixture["events"].as_array().unwrap()[2..], "next_cursor":null}), + ), + ]); + let reference = store.session_ref("session_test01", None).unwrap(); + let native = store.load(&reference).unwrap(); + assert_eq!( + native.body.events, + fixture["events"].as_array().unwrap().clone() + ); + assert_eq!(CoworkRemote::to_common(&native).unwrap().body.len(), 4); + assert!(store.save(&native).is_err()); + assert!(store.delete(&reference).is_err()); + let requests = handle.join().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests[0].starts_with("GET /v1/code/sessions/cse_test01 HTTP/1.1")); + assert!(requests[1].starts_with( + "GET /v1/code/sessions/cse_test01/events?limit=500&sort_order=asc HTTP/1.1" + )); + assert!(requests[2].contains("&cursor=a%2Fb%2B%3F HTTP/1.1")); + for request in requests { + let request = request.to_ascii_lowercase(); + assert!(request.contains(&format!("x-organization-uuid: {ORG}"))); + assert!(request.contains("anthropic-beta: ccr-byoc-2025-07-29")); + assert!(request.contains("anthropic-client-feature: ccr")); + } + } + + #[test] + fn discovery_pages_and_filters_code_sessions() { + let row = super::super::tests::fixture()["session"]["session"].clone(); + let (store, handle) = server(vec![ + (200, json!({"data":[row], "next_cursor":"next"})), + ( + 200, + json!({"data":[row, {"id":"cse_code", "tags":["code"]}], "next_cursor":null}), + ), + ]); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].reference.organization_uuid, ORG); + assert_eq!(found[0].meta.id, "cse_test01"); + let requests = handle.join().unwrap(); + assert!( + requests[0] + .starts_with("GET /v1/code/sessions?tags=cowork-remote&limit=200 HTTP/1.1") + ); + assert!(requests[1].contains("&cursor=next HTTP/1.1")); + } + + #[test] + fn bad_pagination_never_returns_a_partial_transcript() { + for second in [ + (200, json!({"data":[{}], "next_cursor":"repeat"})), + (200, json!({"data":[], "next_cursor":"new"})), + (200, json!({"unexpected":true})), + (403, json!({"error":"test-session-secret"})), + ] { + let (store, handle) = server(vec![ + (200, json!({"data":[{}], "next_cursor":"repeat"})), + second, + ]); + let error = store + .pages("/v1/code/sessions?tags=cowork-remote&limit=200", ORG) + .unwrap_err(); + assert!(!error.to_string().contains("test-session-secret")); + assert_eq!(handle.join().unwrap().len(), 2); + } + } + + #[test] + fn wrong_session_and_wrong_harness_are_refused_before_events() { + for session in [ + json!({"id":"cse_other", "tags":["cowork-remote"]}), + json!({"id":"cse_test01", "tags":["code"]}), + ] { + let (store, handle) = server(vec![(200, json!({"session":session}))]); + let reference = store.session_ref("cse_test01", None).unwrap(); + assert!(store.load(&reference).is_err()); + assert_eq!(handle.join().unwrap().len(), 1); + } + } + + #[test] + fn invalid_references_and_mutations_make_no_requests() { + let (store, handle) = server(vec![]); + assert!(store.session_ref("cse_../bad", None).is_err()); + assert!(store.session_ref("cse_valid", Some("bad".into())).is_err()); + let reference = CoworkRemoteRef { + organization_uuid: ORG.into(), + session_id: "cse_../bad".into(), + updated_at: None, + }; + assert!(store.load(&reference).is_err()); + assert!(store.delete(&reference).is_err()); + assert!(handle.join().unwrap().is_empty()); + assert_eq!(CoworkRemote::NAME, "cowork_remote"); + } + } +} + +#[cfg(feature = "cowork_remote")] +pub use remote::{CoworkRemoteRef, CoworkRemoteStore}; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, clippy::needless_pass_by_value)] +mod tests { + use super::*; + use crate::common::{Block, Role}; + use serde_json::json; + + #[test] + fn ids_and_claude_urls_are_normalized_without_accepting_foreign_urls() { + let chat = "00000000-0000-8000-8000-000000000002"; + for input in [ + "cse_test01", + "session_test01", + "https://claude.ai/cowork/cse_test01?from=desktop", + ] { + assert_eq!(normalize_id(input).unwrap(), "cse_test01"); + } + assert_eq!(normalize_id(chat).unwrap(), chat); + assert_eq!( + normalize_id(&format!("https://claude.ai/chat/{chat}/?x=1#anchor")).unwrap(), + chat + ); + for input in [ + "https://claude.ai.evil.test/cowork/cse_test01", + "https://claude.ai@evil.test/cowork/cse_test01", + "http://claude.ai/cowork/cse_test01", + "https://claude.ai/cowork/cse_test01/extra", + "https://claude.ai/chat/00000000-0000-4000-8000-000000000002", + "https://claude.ai/chat/cse_test01", + "cse_../bad", + ] { + assert!(normalize_id(input).is_err(), "accepted {input}"); + } + } + + pub(super) fn fixture() -> Value { + json!({ + "session": {"session": {"id":"cse_test01", "title":"Cloud task", + "created_at":"2026-09-24T10:00:00Z", "tags":["cowork-remote"], + "config":{"cwd":"/remote/vm/task", "future":true}}, "future_envelope":42}, + "events": [ + {"event_id":"e1", "sequence_num":1, "created_at":"2026-09-24T10:01:00Z", + "payload":{"type":"user", "uuid":"u1", "session_id":"session_test01", "parent_tool_use_id":null, + "message":{"role":"user", "content":"Make a report"}}}, + {"event_id":"e2", "payload":{"type":"assistant", "uuid":"a1", "message":{ + "role":"assistant", "model":"claude-example", "content":[ + {"type":"thinking", "thinking":"Plan"}, + {"type":"tool_use", "id":"tool1", "name":"Write", "input":{"file_path":"/tmp/report.md", "content":"Report"}} + ]}}}, + {"event_id":"e3", "payload":{"type":"user", "message":{"content":[ + {"type":"tool_result", "tool_use_id":"tool1", "content":"Created report"} + ]}}}, + {"event_id":"e4", "payload":{"type":"assistant", "uuid":"a2", "message":{ + "content":[{"type":"text", "text":"Done"}, {"type":"image", "source":{"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}}], + "stop_reason":"end_turn", "usage":{"input_tokens":10,"output_tokens":4}}}}, + {"event_id":"e5", "payload":{"type":"stream_event", "event":{"delta":{"text":"Done"}}}}, + {"event_id":"e6", "payload":{"type":"control_request", "future":{"file":"/outputs/report.md"}}}, + {"event_id":"e7", "payload":{"type":"assistant", "uuid":"sub1", "parent_tool_use_id":"agent1", "message":{"content":"Subagent"}}} + ], + "future_native": {"unchanged":true} + }) + } + + #[test] + fn native_round_trip_and_common_messages() { + let value = fixture(); + let native = CoworkRemote::from_text(&value.to_string()).unwrap(); + assert_eq!( + serde_json::from_str::(&CoworkRemote::to_text(&native).unwrap()).unwrap(), + value + ); + let common = CoworkRemote::to_common(&native).unwrap(); + assert_eq!(common.meta.id, "cse_test01"); + assert_eq!(common.meta.title.as_deref(), Some("Cloud task")); + assert_eq!(common.meta.model.as_deref(), Some("claude-example")); + assert!(common.meta.cwd.is_none()); + assert_eq!(common.body.len(), 4); + assert_eq!(common.body[0].role, Role::User); + assert_eq!( + common.body[0].timestamp.to_rfc3339(), + "2026-09-24T10:01:00+00:00" + ); + assert!( + matches!(&common.body[1].content[0], Block::Thinking { text, .. } if text == "Plan") + ); + assert!(matches!(&common.body[1].content[1], Block::ToolUse { id, .. } if id == "tool1")); + assert!( + matches!(&common.body[2].content[0], Block::ToolResult { tool_use_id, .. } if tool_use_id == "tool1") + ); + assert!(matches!(&common.body[3].content[1], Block::Image { .. })); + let local = crate::harness::cowork::Cowork::from_common(&common).unwrap(); + let round = crate::harness::cowork::Cowork::to_common(&local).unwrap(); + assert_eq!(round.body, common.body); + assert!(CoworkRemote::from_common(&common).is_err()); + assert!(crate::local::write(crate::HarnessId::CoworkRemote, &common, None).is_err()); + } + + #[test] + fn remote_artifact_paths_remain_tool_references() { + let mut value = fixture(); + let input = + json!({"file_path":"/tmp/host-file", "description":"Cloud report", "future":42}); + value["events"][1]["payload"]["message"]["content"] = json!([ + {"type":"tool_use", "id":"tool1", "name":"Artifact", "input":input} + ]); + let native = CoworkRemote::from_text(&value.to_string()).unwrap(); + let common = CoworkRemote::to_common(&native).unwrap(); + assert!( + matches!(&common.body[1].content[0], Block::ToolUse { id, tool:Tool::Raw { tool_name, input:original } } if id == "tool1" && tool_name == "Artifact" && original == &input) + ); + assert!(common.body.iter().flat_map(|message| &message.content).all(|block| !matches!(block, Block::Artifact { artifact } if matches!(artifact.source, ArtifactSource::Path { .. })))); + } + + #[test] + fn overlapping_event_pages_convert_once_but_conflicts_fail() { + let mut value = fixture(); + let duplicate = value["events"][0].clone(); + value["events"].as_array_mut().unwrap().push(duplicate); + let native = CoworkRemote::from_text(&value.to_string()).unwrap(); + assert_eq!(native.body.events.len(), 8); + assert_eq!(CoworkRemote::to_common(&native).unwrap().body.len(), 4); + value["events"][7]["payload"]["message"]["content"] = json!("Changed"); + let native = CoworkRemote::from_text(&value.to_string()).unwrap(); + assert!(CoworkRemote::to_common(&native).is_err()); + } + + #[test] + fn malformed_messages_are_not_silently_lost() { + let mut value = fixture(); + value["events"][0]["payload"]["message"] = json!(false); + let native = CoworkRemote::from_text(&value.to_string()).unwrap(); + assert!(CoworkRemote::to_common(&native).is_err()); + } + + #[test] + fn identifiers_cannot_escape_the_provider_route() { + assert_eq!(normalize_id("session_abc123").unwrap(), "cse_abc123"); + for id in [ + "cse_", + "cse_../other", + "cse_x?foo=1", + "https://evil.test/cse_x", + "local_x", + "cse_x\n", + ] { + assert!(normalize_id(id).is_err(), "{id}"); + } + assert!(normalize_id(&format!("cse_{}", "a".repeat(65))).is_err()); + assert_eq!( + "cowork-remote".parse::().unwrap(), + crate::HarnessId::CoworkRemote + ); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index a69137a..b550c47 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -13,6 +13,8 @@ pub mod claude_chat; pub mod claude_code; pub mod codex; pub mod cowork; +mod cowork_files; +pub mod cowork_remote; pub mod cursor; pub mod cursor_desktop; pub mod fx; diff --git a/src/local.rs b/src/local.rs index cacf094..4c8dd57 100644 --- a/src/local.rs +++ b/src/local.rs @@ -30,6 +30,8 @@ use crate::harness::{ use crate::harness::chatgpt; #[cfg(feature = "claude_chat")] use crate::harness::claude_chat; +#[cfg(feature = "cowork_remote")] +use crate::harness::cowork_remote; #[cfg(feature = "hermes")] use crate::harness::hermes; @@ -54,6 +56,8 @@ pub struct Session { } enum Locator { + #[cfg(feature = "cowork_remote")] + CoworkRemote(cowork_remote::CoworkRemoteRef), Path(PathBuf), #[cfg(feature = "claude_chat")] ClaudeChatRemote(claude_chat::ClaudeChatRef), @@ -198,6 +202,22 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec /// # Errors /// When the explicitly selected live backend rejects access or changes shape. pub fn discover_harness(harness: HarnessId) -> Result> { + #[cfg(feature = "cowork_remote")] + if harness == HarnessId::CoworkRemote { + let mut out = Vec::new(); + discover_cowork_remote_into(&mut out)?; + out.sort_by_key(|session| std::cmp::Reverse(session.meta.timestamp)); + return Ok(out); + } + #[cfg(not(feature = "cowork_remote"))] + if harness == HarnessId::CoworkRemote { + return Err(Error::Remote { + harness: "cowork_remote", + detail: + "live cloud Cowork support was not compiled in (enable the `cowork_remote` feature)" + .to_string(), + }); + } #[cfg(feature = "claude_chat")] if harness == HarnessId::ClaudeChat { let mut out = Vec::new(); @@ -238,7 +258,7 @@ pub fn discover_harness(harness: HarnessId) -> Result> { /// The sessions a `--from` selection names: every local harness when /// `from` is `None`, else that one harness. This is the only path that /// reaches a live web source, and only when it is named explicitly — an omitted -/// `from` never contacts either one. +/// `from` never contacts a live source. /// /// # Errors /// When the explicitly selected live backend rejects access or changes shape. @@ -249,6 +269,20 @@ pub fn discover_scoped(from: Option) -> Result> { } } +#[cfg(feature = "cowork_remote")] +fn discover_cowork_remote_into(out: &mut Vec) -> Result<()> { + let store = cowork_remote::CoworkRemoteStore::from_desktop()?; + for discovered in Store::discover(&store)? { + out.push(Session { + harness: HarnessId::CoworkRemote, + meta: discovered.meta, + updated_at: discovered.reference.updated_at, + locator: Locator::CoworkRemote(discovered.reference), + }); + } + Ok(()) +} + #[cfg(feature = "claude_chat")] fn discover_claude_chat_into(out: &mut Vec) -> Result<()> { let store = claude_chat::ClaudeChatStore::from_desktop()?; @@ -288,6 +322,10 @@ impl Session { pub fn location(&self) -> String { match &self.locator { Locator::Path(p) => p.display().to_string(), + #[cfg(feature = "cowork_remote")] + Locator::CoworkRemote(reference) => { + format!("https://claude.ai/cowork/{}", reference.session_id) + } #[cfg(feature = "claude_chat")] Locator::ClaudeChatRemote(reference) => { format!("https://claude.ai/chat/{}", reference.conversation_uuid) @@ -318,6 +356,11 @@ impl Session { (HarnessId::ClaudeCode, Locator::Path(p)) => { go(claude_code::ClaudeStore::default_root(), p) } + #[cfg(feature = "cowork_remote")] + (HarnessId::CoworkRemote, Locator::CoworkRemote(reference)) => { + let store = cowork_remote::CoworkRemoteStore::from_desktop()?; + cowork_remote::CoworkRemote::to_common(&store.load(reference)?) + } #[cfg(feature = "claude_chat")] (HarnessId::ClaudeChat, Locator::ClaudeChatRemote(reference)) => { let store = claude_chat::ClaudeChatStore::from_desktop()?; @@ -375,6 +418,10 @@ impl Session { (HarnessId::ClaudeCode, Locator::Path(p)) => { go(claude_code::ClaudeStore::default_root(), p) } + #[cfg(feature = "cowork_remote")] + (HarnessId::CoworkRemote, Locator::CoworkRemote(_)) => { + Err(cowork_remote::read_only_error()) + } #[cfg(feature = "claude_chat")] (HarnessId::ClaudeChat, Locator::ClaudeChatRemote(reference)) => { claude_chat::ClaudeChatStore::from_desktop()?.delete(reference) @@ -580,6 +627,8 @@ impl Session { fn path(&self) -> Option<&PathBuf> { match &self.locator { Locator::Path(p) => Some(p), + #[cfg(feature = "cowork_remote")] + Locator::CoworkRemote(_) => None, #[cfg(feature = "claude_chat")] Locator::ClaudeChatRemote(_) => None, #[cfg(feature = "chatgpt")] @@ -594,6 +643,8 @@ impl Session { match &self.locator { Locator::ClaudeChatRemote(reference) => Some(reference), Locator::Path(_) => None, + #[cfg(feature = "cowork_remote")] + Locator::CoworkRemote(_) => None, #[cfg(feature = "chatgpt")] Locator::ChatGptRemote(_) => None, #[cfg(any(feature = "opencode", feature = "hermes"))] @@ -606,6 +657,8 @@ impl Session { match &self.locator { Locator::ChatGptRemote(reference) => Some(reference), Locator::Path(_) => None, + #[cfg(feature = "cowork_remote")] + Locator::CoworkRemote(_) => None, #[cfg(feature = "claude_chat")] Locator::ClaudeChatRemote(_) => None, #[cfg(any(feature = "opencode", feature = "hermes"))] @@ -619,6 +672,8 @@ impl Session { match &self.locator { Locator::Id(id) => Some(id), Locator::Path(_) => None, + #[cfg(feature = "cowork_remote")] + Locator::CoworkRemote(_) => None, #[cfg(feature = "claude_chat")] Locator::ClaudeChatRemote(_) => None, #[cfg(feature = "chatgpt")] @@ -691,11 +746,13 @@ pub fn write_with( S::H: Codec, S::Ref: std::fmt::Debug, { - let store = match root { - Some(dir) => make(dir.to_path_buf()), - None => make(default_dir(required(store)?)), - }; - let native = ::from_common(common)?; + let dir = root.map_or_else( + || required(store).map(default_dir), + |dir| Ok(dir.to_path_buf()), + )?; + let prepared = materialize_artifacts(common, &dir.join("txcript-artifacts"))?; + let store = make(dir); + let native = ::from_common(&prepared)?; let saved = store.save(&native)?; Ok(Written { id: saved.id, @@ -709,6 +766,7 @@ pub fn write_with( // Live web sources are server-authoritative and have no import. Their // additional in-place-resume refusals live in the CLI because that // path deliberately bypasses `write` for existing sessions. + HarnessId::CoworkRemote => Err(crate::harness::cowork_remote::read_only_error()), HarnessId::ClaudeChat => Err(Error::Unconvertible { harness: "claude_chat", detail: "Claude Chat is a live read-only source; sessions can be pulled out and converted into another harness, but never continued into Claude" @@ -761,14 +819,17 @@ pub fn write_with( HarnessId::GrokBot => { if let Some(dir) = root { let store = grok_bot::GrokBotStore::new(dir); - let native = grok_bot::GrokBot::from_common(common)?; + let prepared = materialize_artifacts(common, &dir.join("txcript-artifacts"))?; + let native = grok_bot::GrokBot::from_common(&prepared)?; let saved = store.save(&native)?; Ok(Written { id: saved.id, location: saved.reference.display().to_string(), }) } else { - let saved = grok_bot::mint_with_history(common, opts.metadata)?; + let store = required(grok_bot::GrokBotStore::default_root())?; + let prepared = materialize_artifacts(common, &store.root.join("txcript-artifacts"))?; + let saved = grok_bot::mint_with_history(&prepared, opts.metadata)?; Ok(Written { id: saved.id, location: saved.reference.display().to_string(), @@ -809,13 +870,18 @@ pub fn write_with( common, |s| s.root, ), - HarnessId::Cowork => go( - cowork::CoworkStore::default_root(), - cowork::CoworkStore::new, - root, - common, - |s| s.root, - ), + HarnessId::Cowork => { + let store = match root { Some(dir) => cowork::CoworkStore::new(dir), None => required(cowork::CoworkStore::default_root())? }; + // Resolve the active account before writing any files. + let account = store.active_account_dir()?; + let native = cowork::Cowork::from_common(common)?; + let mut common = common.clone(); + common.meta.id.clone_from(&native.meta.id); + let prepared = materialize_artifacts(&common, &account.join(&native.meta.id).join("uploads"))?; + let native = cowork::Cowork::from_common(&prepared)?; + let saved = store.save(&native)?; + Ok(Written { id: saved.id, location: saved.reference.display().to_string() }) + } // Simple is an interchange *input*: documents are handed to txcript // directly (a file, stdin, the WASM text API) rather than managed in // a directory of its own, so there is nowhere to write one back to. @@ -860,8 +926,6 @@ fn materialize_artifacts_for_claude_code( common: &Transcript, projects_root: &Path, ) -> Result> { - const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024; - crate::harness::checked_id_component(claude_code::ClaudeCode::NAME, &common.meta.id)?; let artifact_root = projects_root .join(claude_code::encode_project_dir( @@ -869,6 +933,17 @@ fn materialize_artifacts_for_claude_code( )) .join(&common.meta.id) .join("artifacts"); + materialize_artifacts(common, &artifact_root) +} + +fn materialize_artifacts(common: &Transcript, root: &Path) -> Result> { + use std::io::Write; + const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024; + crate::harness::checked_id_component("artifact", &common.meta.id)?; + // A fresh directory prevents a repeated continuation from replacing files + // used by an earlier session, including when two files share a name. + let artifact_root = root.join(format!("{}-{}", common.meta.id, uuid::Uuid::new_v4())); + let mut created = false; let mut prepared = common.clone(); for (message_index, message) in prepared.body.iter_mut().enumerate() { for (block_index, block) in message.content.iter_mut().enumerate() { @@ -876,7 +951,20 @@ fn materialize_artifacts_for_claude_code( continue; }; let (bytes, media_type) = match &artifact.source { - ArtifactSource::Path { .. } => continue, + ArtifactSource::Path { path, media_type } => { + use std::io::Read; + let file = std::fs::File::open(path)?; + if !file.metadata()?.is_file() { + return Err(artifact_error("attachment is not a regular file")); + } + let mut bytes = Vec::new(); + file.take((MAX_ARTIFACT_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(artifact_error("artifact exceeds the size limit")); + } + (bytes, media_type.clone()) + } ArtifactSource::Text { text, media_type } => { if text.len() > MAX_ARTIFACT_BYTES { return Err(artifact_error("artifact exceeds the size limit")); @@ -900,10 +988,19 @@ fn materialize_artifacts_for_claude_code( (bytes, media_type.clone()) } }; + if !created { + std::fs::create_dir_all(root)?; + std::fs::create_dir(&artifact_root)?; + created = true; + } let directory = artifact_root.join(format!("{message_index}-{block_index}")); - std::fs::create_dir_all(&directory)?; + std::fs::create_dir(&directory)?; let path = directory.join(safe_artifact_name(&artifact.name)); - std::fs::write(&path, bytes)?; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)? + .write_all(&bytes)?; let path = std::path::absolute(&path).unwrap_or(path); artifact.source = ArtifactSource::Path { path: path.to_string_lossy().into_owned(), @@ -948,7 +1045,8 @@ fn write_cursor_desktop(common: &Transcript, root: Option<&Path>) -> Res Some(dir) => cursor_desktop::CursorDesktopStore::new(dir.to_path_buf()), None => required(cursor_desktop::CursorDesktopStore::default_root())?, }; - let native = cursor_desktop::CursorDesktop::from_common(common)?; + let prepared = materialize_artifacts(common, &store.user_dir.join("txcript-artifacts"))?; + let native = cursor_desktop::CursorDesktop::from_common(&prepared)?; let saved = store.save(&native)?; Ok(Written { id: saved.id, @@ -968,7 +1066,12 @@ fn write_cursor_desktop(_: &Transcript, _: Option<&Path>) -> Result) -> Result { let store = required(opencode::OpenCodeStore::default_db())?; - let native = opencode::OpenCode::from_common(common)?; + let parent = store + .db_path + .parent() + .ok_or_else(|| artifact_error("OpenCode database has no parent directory"))?; + let prepared = materialize_artifacts(common, &parent.join("txcript-artifacts"))?; + let native = opencode::OpenCode::from_common(&prepared)?; let saved = store.save(&native)?; Ok(Written { id: saved.id, @@ -1002,9 +1105,11 @@ pub fn resume_command(harness: HarnessId, id: &str) -> (String, Vec) { match harness { HarnessId::ClaudeCode => ("claude".into(), vec!["--resume".into(), id]), // Source-only harnesses: the CLI refuses before this fallback. - HarnessId::ClaudeChat | HarnessId::ChatGpt | HarnessId::Simple => { - ("txcript".into(), Vec::new()) - } + HarnessId::ClaudeChat + | HarnessId::CoworkRemote + | HarnessId::ChatGpt + | HarnessId::Simple + | HarnessId::GrokBot => ("txcript".into(), Vec::new()), HarnessId::Codex => ("codex".into(), vec!["resume".into(), id]), HarnessId::OpenCode => ("opencode".into(), vec!["--session".into(), id]), HarnessId::Pi => ("pi".into(), vec!["--session".into(), id]), @@ -1014,9 +1119,6 @@ pub fn resume_command(harness: HarnessId, id: &str) -> (String, Vec) { // the session is in the Agents sidebar. HarnessId::CursorDesktop => ("cursor".into(), Vec::new()), HarnessId::Grok => ("grok".into(), vec!["--resume".into(), id]), - // No CLI resume: continue never launches for grok_bot (mint / - // openAgent already surfaced the agent in the product UI). - HarnessId::GrokBot => ("txcript".into(), Vec::new()), HarnessId::Fx => ("fx".into(), vec!["--resume".into(), id]), HarnessId::Hermes => ("hermes".into(), vec!["--resume".into(), id]), HarnessId::Amp => ("amp".into(), vec!["threads".into(), "continue".into(), id]), @@ -1070,8 +1172,14 @@ mod live_remote_gate_tests { let mut scanned = Vec::new(); let sessions = discover_with(|harness, _| scanned.push(harness)); assert!(!scanned.contains(&HarnessId::ClaudeChat)); + assert!(!scanned.contains(&HarnessId::CoworkRemote)); assert!(!scanned.contains(&HarnessId::ChatGpt)); assert!(sessions.iter().all(|s| s.harness != HarnessId::ClaudeChat)); + assert!( + sessions + .iter() + .all(|s| s.harness != HarnessId::CoworkRemote) + ); assert!(sessions.iter().all(|s| s.harness != HarnessId::ChatGpt)); } @@ -1079,6 +1187,11 @@ mod live_remote_gate_tests { fn an_omitted_from_never_yields_live_web_sessions() { let sessions = discover_scoped(None).unwrap_or_else(|e| panic!("{e}")); assert!(sessions.iter().all(|s| s.harness != HarnessId::ClaudeChat)); + assert!( + sessions + .iter() + .all(|s| s.harness != HarnessId::CoworkRemote) + ); assert!(sessions.iter().all(|s| s.harness != HarnessId::ChatGpt)); } diff --git a/src/transcript.rs b/src/transcript.rs index 1ca8e28..c89ba1e 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -446,6 +446,7 @@ pub struct Saved { pub enum HarnessId { ClaudeCode, ClaudeChat, + CoworkRemote, ChatGpt, Codex, OpenCode, @@ -464,9 +465,10 @@ pub enum HarnessId { } impl HarnessId { - pub const ALL: [HarnessId; 17] = [ + pub const ALL: [HarnessId; 18] = [ HarnessId::ClaudeCode, HarnessId::ClaudeChat, + HarnessId::CoworkRemote, HarnessId::ChatGpt, HarnessId::Codex, HarnessId::OpenCode, @@ -490,6 +492,7 @@ impl HarnessId { match self { HarnessId::ClaudeCode => "claude_code", HarnessId::ClaudeChat => "claude_chat", + HarnessId::CoworkRemote => "cowork_remote", HarnessId::ChatGpt => "chatgpt", HarnessId::Codex => "codex", HarnessId::OpenCode => "opencode", @@ -528,6 +531,9 @@ impl FromStr for HarnessId { "chatgpt" | "chat_gpt" | "chat-gpt" | "openai_chat" | "openai-chat" => { Ok(HarnessId::ChatGpt) } + "cowork_remote" | "cowork-remote" | "cowork_cloud" | "cowork-cloud" => { + Ok(HarnessId::CoworkRemote) + } "codex" => Ok(HarnessId::Codex), "opencode" | "open_code" | "open-code" => Ok(HarnessId::OpenCode), "pi" => Ok(HarnessId::Pi), diff --git a/src/wasm.rs b/src/wasm.rs index e2f78a9..e1bbb6f 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -14,8 +14,8 @@ use wasm_bindgen::prelude::*; use crate::common; use crate::harness::{ - amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cursor, - cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, + amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cowork_remote, + cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, }; use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; @@ -23,6 +23,7 @@ use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; /// /// `input` is the source session text (JSONL for `claude_code`/codex/pi/campfire, /// one live conversation detail object for `claude_chat` or `chatgpt`, +/// a session-and-events document for `cowork_remote`, /// the Cursor JSON DB export for cursor, the JSON dump of the session's /// database rows for `cursor_desktop`, the `opencode export` JSON for /// opencode, the JSON bundle of the session directory for grok, the agent @@ -32,7 +33,7 @@ use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; /// for amp, the JSON dump of the conversation database for antigravity, the /// interchange JSON document for simple, the JSON bundle of the session /// record, transcript and audit log for cowork); `from`/`to` are harness -/// names (`"claude_code"`, `"claude_chat"`, `"chatgpt"`, `"codex"`, `"opencode"`, `"pi"`, `"campfire"`, +/// names (`"claude_code"`, `"claude_chat"`, `"cowork_remote"`, `"chatgpt"`, `"codex"`, `"opencode"`, `"pi"`, `"campfire"`, /// `"cursor"`, `"cursor_desktop"`, `"grok"`, `"grok_bot"`, `"fx"`, `"hermes"`, `"amp"`, /// `"antigravity"`, `"simple"`, `"cowork"`). Returns the target harness's /// native text. @@ -200,6 +201,7 @@ fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result go::(text), HarnessId::ClaudeChat => go::(text), + HarnessId::CoworkRemote => go::(text), HarnessId::ChatGpt => go::(text), HarnessId::Codex => go::(text), HarnessId::OpenCode => go::(text), @@ -225,6 +227,7 @@ fn render_from_common(harness: HarnessId, common: &Transcript) -> crate: match harness { HarnessId::ClaudeCode => go::(common), HarnessId::ClaudeChat => go::(common), + HarnessId::CoworkRemote => go::(common), HarnessId::ChatGpt => go::(common), HarnessId::Codex => go::(common), HarnessId::OpenCode => go::(common), diff --git a/tests/integration/cowork.rs b/tests/integration/cowork.rs index 48db118..a8f7186 100644 --- a/tests/integration/cowork.rs +++ b/tests/integration/cowork.rs @@ -160,6 +160,7 @@ fn write_fixture(root: &std::path::Path) -> std::path::PathBuf { std::fs::create_dir_all(&project).unwrap(); std::fs::create_dir_all(dir.join("outputs")).unwrap(); std::fs::create_dir_all(dir.join("uploads")).unwrap(); + std::fs::write(dir.join("uploads/terms.pdf"), b"%PDF-test\0\xff").unwrap(); std::fs::write(project.join(format!("{CLI_ID}.jsonl")), transcript_jsonl()).unwrap(); // A subagent transcript sits beside the main one and is not a session. let sub = project.join(CLI_ID).join("subagents"); @@ -267,7 +268,10 @@ fn to_common_extracts_the_claude_code_conversation() { // The prompt keeps Cowork's manifest: it is what the // model saw and names the attachment, not boilerplate. assert_eq!(msgs[0].role, Role::User); - assert_eq!(msgs[0].content.len(), 2); + assert_eq!(msgs[0].content.len(), 3); + assert!( + matches!(&msgs[0].content[2], Block::Artifact { artifact } if artifact.name == "terms.pdf") + ); assert_eq!(msgs[0].content[0], png_image_block()); assert!(matches!( &msgs[0].content[1], @@ -676,3 +680,82 @@ fn fingerprints_follow_the_transcript() { assert_ne!(before[&key], after[&key]); assert!(!after[&key].is_empty()); } + +#[test] +fn missing_local_attachment_is_not_silently_exported() { + let root = TempDir::new().unwrap(); + let record = write_fixture(root.path()); + std::fs::remove_file( + root.path() + .join(ORG) + .join(ACCOUNT) + .join(SESSION_ID) + .join("uploads/terms.pdf"), + ) + .unwrap(); + let error = CoworkStore::new(root.path()) + .load(&record) + .unwrap_err() + .to_string(); + assert!(error.contains("terms.pdf") && error.contains("missing")); +} + +fn find(path: &std::path::Path, expected: &[u8]) -> bool { + std::fs::read_dir(path).unwrap().flatten().any(|entry| { + let path = entry.path(); + if path.is_dir() { + find(&path, expected) + } else { + std::fs::read(&path).unwrap() == expected + } + }) +} + +#[test] +fn files_survive_simple_and_continuation_to_cowork_and_codex() { + use base64::Engine; + use txcript::harness::simple::Simple; + use txcript::{Common, HarnessId}; + let root = TempDir::new().unwrap(); + let record = write_fixture(root.path()); + let common = Cowork::to_common(&CoworkStore::new(root.path()).load(&record).unwrap()).unwrap(); + let simple = txcript::convert::(&common).unwrap(); + let text = Simple::to_text(&simple).unwrap(); + let recovered = Simple::to_common(&Simple::from_text(&text).unwrap()).unwrap(); + let Block::Artifact { artifact } = &recovered.body[0].content[2] else { + panic!("missing attachment") + }; + let txcript::common::ArtifactSource::Base64 { data, .. } = &artifact.source else { + panic!("missing bytes") + }; + let expected = base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(); + // Prove continuation uses carried contents after the source has disappeared. + drop(root); + for target in [HarnessId::Cowork, HarnessId::Codex, HarnessId::ClaudeCode] { + let dst = TempDir::new().unwrap(); + if target == HarnessId::Cowork { + std::fs::create_dir_all(dst.path().join(ORG).join(ACCOUNT)).unwrap(); + } + let written = txcript::local::write(target, &recovered, Some(dst.path())).unwrap(); + assert!(!written.id.is_empty()); + assert!( + find(dst.path(), &expected), + "{target} did not save file bytes" + ); + if target == HarnessId::Cowork { + let loaded = CoworkStore::new(dst.path()) + .load(&written.location.into()) + .unwrap(); + let result = Cowork::to_common(&loaded).unwrap(); + assert!( + result + .body + .iter() + .flat_map(|m| &m.content) + .any(|b| matches!(b, Block::Artifact { .. })) + ); + } + } +} From a405a7b1369ac6d7ab7c787ef4e7f07287844072 Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Mon, 28 Sep 2026 17:49:33 -0700 Subject: [PATCH 2/2] fix(cowork): recognize Windows attachment paths --- src/harness/cowork_files.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/harness/cowork_files.rs b/src/harness/cowork_files.rs index 721b5d9..309b42f 100644 --- a/src/harness/cowork_files.rs +++ b/src/harness/cowork_files.rs @@ -23,7 +23,7 @@ pub(super) fn references(payload: &Value) -> Vec { } let name = name .filter(|name| !name.is_empty()) - .unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file")); + .unwrap_or_else(|| path.rsplit(['/', '\\']).next().unwrap_or("file")); files.insert( format!("path:{path}"), FileRef { @@ -117,8 +117,11 @@ pub(super) fn references(payload: &Value) -> Vec { other.key != file.key && other.name == file.name && (other.uuid.is_some() - || (!path.contains('/') - && other.path.as_deref().is_some_and(|path| path.contains('/')))) + || (!path.contains(['/', '\\']) + && other + .path + .as_deref() + .is_some_and(|path| path.contains(['/', '\\'])))) }) }) }) @@ -193,6 +196,23 @@ pub(super) fn media_type(name: &str) -> Option<&'static str> { #[cfg(test)] mod tests { + #[test] + fn relocated_windows_attachment_replaces_the_old_manifest_name() { + let payload = serde_json::json!({"message":{"content":[ + {"type":"text","text":"terms.pdf"}, + {"type":"tool_use","name":"Artifact","input":{"file_path":r"C:\Users\test\uploads\copy\terms.pdf"}} + ]}}); + let files = super::references(&payload); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "terms.pdf"); + assert!( + files[0] + .path + .as_ref() + .is_some_and(|path| path.starts_with("C:")) + ); + } + #[test] fn uploaded_manifest_and_file_id_are_one_attachment() { let payload = serde_json::json!({