From 2891486c39b4a6b06d68757a73bb3febdc0c044e Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 10:44:27 +0700 Subject: [PATCH] Decode upstream and Windows remote session output --- docs/CLI.md | 9 +- rust/src/agent_sessions/remote.rs | 306 +++++++++++++++++++++-- rust/src/agent_sessions/tests.rs | 10 +- rust/src/cli/sessions.rs | 56 +++++ rust/src/codex_costs/summary_contract.rs | 11 +- rust/src/codex_costs/tests.rs | 53 +++- 6 files changed, 426 insertions(+), 19 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 75467a35b7d..d3cec5e98e8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -190,7 +190,7 @@ JSON output adds: ### Remote and summary reports -`--remote ` adds one separate native Codex report, fetched over non-interactive SSH. Local and remote histories are never merged. `--summary-only` prints the versioned, path-free JSON contract that the remote comparison uses. It accepts only `--provider codex --format json`. Both modes reject project or session grouping and other providers. +`--remote ` adds one separate native Codex report, fetched over non-interactive SSH. Local and remote histories are never merged. `--summary-only` prints the versioned, path-free JSON contract that the remote comparison uses. It accepts only `--provider codex --format json`. Both modes reject project or session grouping and other providers. The remote host can run this CLI or upstream macOS CodexBar; both print the same `--summary-only` array. When the remote report says its history coverage is incomplete, its totals are dropped and the report is shown as partial. `--provider-native-only` leaves out pi/OMP agent session mirrors. This Windows build has no such mirrors, so the flag is accepted but changes nothing here. @@ -445,6 +445,13 @@ codexbar sessions --ssh-host user@host The CLI always runs its remote discovery: each `--ssh-host` and any online Tailscale peer (standalone `tailscale.exe` on `PATH`). It does not read the desktop **Agent sessions** switch or **Local sessions only** setting, so those settings do not change CLI output. Use the desktop app's settings to limit remote discovery there. +Each SSH host runs `sh -lc ''` in BatchMode, where the chain tries `codexbar sessions --json-v2`, then `codexbar sessions --json`, then the same two commands with the bundled macOS CLI (`/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI`). Two remote outputs are decoded: + +- Upstream macOS CodexBar prints a flat JSON array of sessions with `cwd`, `projectName`, `startedAt` and `lastActivityAt` at the top level of each row. +- This Windows CLI prints `{"sessions": [...], "errors": [...]}`, where each row nests `workspace`, `activity` and `focusTarget`. + +Every remote session is shown under the SSH host you configured, whatever host name the remote payload carries. Entries in the remote `errors` list are shown as that host's error, and its sessions are still listed. Output that is not JSON, or a row with a missing or unknown provider, source or state, fails that host with a message to update CodexBar on the remote host. + ## List Codex workspaces ```powershell diff --git a/rust/src/agent_sessions/remote.rs b/rust/src/agent_sessions/remote.rs index 2eec39784e5..c24619c1e76 100644 --- a/rust/src/agent_sessions/remote.rs +++ b/rust/src/agent_sessions/remote.rs @@ -193,7 +193,7 @@ impl RemoteSessionFetcher { AgentSessionHostResult::failed( host, actionable_message( - "Remote session response was not valid JSON; update CodexBar on the remote host", + "Remote session response could not be decoded; update CodexBar on the remote host", error, ), ) @@ -237,7 +237,9 @@ impl RemoteSessionFetcher { host, "sh".to_string(), "-lc".to_string(), - remote_command, + // ssh joins the remote argv with spaces and the remote login shell + // re-parses it, so the whole `||` chain must reach `sh -lc` as one word. + shell_quote(&remote_command), ], ..CommandOptions::default() }) @@ -419,17 +421,52 @@ impl RemoteSessionFetcher { results } - fn decode_remote_sessions(host: &str, body: &str) -> Result { - if let Ok(mut sessions) = serde_json::from_str::>(body) { - for session in &mut sessions { - session.host = host.to_string(); + /// Decode what a remote `sessions --json-v2` / `--json` printed. + /// + /// Accepts the upstream macOS flat array, the Windows CLI `{sessions, errors}` + /// envelope, a bare array of Windows rows, and the older `{host, sessions, error}` + /// host-result object. Every session is attributed to the SSH target `host`; + /// errors the remote CLI reported become this host's error. + pub(crate) fn decode_remote_sessions( + host: &str, + body: &str, + ) -> Result { + let value: Value = serde_json::from_str(body.trim()) + .map_err(|err| format!("response is not JSON ({err})"))?; + let (rows, errors) = match value { + Value::Array(_) => ( + serde_json::from_value::>(value) + .map_err(|err| format!("unsupported session row ({err})"))?, + Vec::new(), + ), + Value::Object(_) => { + let envelope = serde_json::from_value::(value) + .map_err(|err| format!("unsupported session object ({err})"))?; + let mut errors = envelope.errors.unwrap_or_default(); + errors.extend(envelope.error); + (envelope.sessions, errors) } - return Ok(AgentSessionHostResult::success(host, sessions)); - } - let mut result = Self::decode_host_result(body)?; - result.host = host.to_string(); - for session in &mut result.sessions { - session.host = host.to_string(); + _ => { + return Err( + "expected a JSON array of sessions or a {sessions, errors} object".to_string(), + ); + } + }; + + let mut result = AgentSessionHostResult::success( + host, + rows.into_iter().map(|row| row.into_session(host)).collect(), + ); + let errors = errors + .iter() + .map(|error| error.trim()) + .filter(|error| !error.is_empty()) + .collect::>(); + if !errors.is_empty() { + result.error = Some(crate::logging::safe_error_message(format!( + "Remote host reported: {}", + errors.join("; ") + ))); } Ok(result) } @@ -508,6 +545,104 @@ impl Default for RemoteSessionFetcher { } } +/// One session row from a remote CLI. The Windows CLI nests `workspace`, `activity` +/// and `focusTarget`; upstream macOS (`AgentSession` Codable) prints flat `cwd`, +/// `projectName`, `startedAt` and `lastActivityAt` and has no focus target. +/// The row's own `host` is ignored: the SSH target is authoritative. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteSessionRow { + id: String, + provider: AgentSessionProvider, + #[serde(default)] + dialect: Option, + #[serde(default)] + session_name: Option, + source: AgentSessionSource, + state: AgentSessionState, + #[serde(default)] + pid: Option, + #[serde(default)] + transcript_path: Option, + #[serde(default)] + workspace: Option, + #[serde(default)] + activity: Option, + #[serde(default)] + focus_target: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + project_name: Option, + #[serde(default)] + started_at: Option>, + #[serde(default)] + last_activity_at: Option>, +} + +impl RemoteSessionRow { + fn into_session(self, host: &str) -> AgentSession { + let pid = self + .pid + .and_then(|pid| u32::try_from(pid).ok()) + .filter(|pid| *pid > 0); + let (nested_cwd, nested_project) = self + .workspace + .map(|workspace| (workspace.cwd, workspace.project_name)) + .unwrap_or_default(); + let cwd = nested_cwd.or(self.cwd); + let project_name = nested_project + .or(self.project_name) + .or_else(|| cwd.as_deref().and_then(project_name_from_cwd)); + let (nested_started, nested_last) = self + .activity + .map(|activity| (activity.started_at, activity.last_activity_at)) + .unwrap_or_default(); + let focus_target = + self.focus_target + .unwrap_or_else(|| match (pid, self.transcript_path.as_ref()) { + (Some(pid), _) => AgentSessionFocusTarget::Process { pid }, + (None, Some(transcript_path)) => AgentSessionFocusTarget::Transcript { + transcript_path: transcript_path.clone(), + }, + (None, None) => AgentSessionFocusTarget::None, + }); + AgentSession { + id: self.id, + provider: self.provider, + dialect: self.dialect, + session_name: self.session_name, + source: self.source, + state: self.state, + pid, + transcript_path: self.transcript_path, + host: host.to_string(), + workspace: AgentSessionWorkspace { cwd, project_name }, + activity: AgentSessionActivity { + started_at: nested_started.or(self.started_at), + last_activity_at: nested_last.or(self.last_activity_at), + }, + focus_target, + } + } +} + +/// Object-shaped remote output: the Windows CLI's `{sessions, errors}` and the older +/// `{host, sessions, error}` host result. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteSessionEnvelope { + sessions: Vec, + #[serde(default)] + errors: Option>, + #[serde(default)] + error: Option, +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + #[cfg(windows)] pub(crate) const TAILSCALE_CLI_FILE_NAME: &str = "tailscale.exe"; #[cfg(not(windows))] @@ -553,8 +688,8 @@ fn remote_sessions_command(bundled_cli_fallback: &str) -> String { [ "codexbar sessions --json-v2", "codexbar sessions --json", - &format!("'{bundled_cli_fallback}' sessions --json-v2"), - &format!("'{bundled_cli_fallback}' sessions --json"), + &format!("{} sessions --json-v2", shell_quote(bundled_cli_fallback)), + &format!("{} sessions --json", shell_quote(bundled_cli_fallback)), ] .join(" || ") } @@ -563,6 +698,149 @@ fn remote_sessions_command(bundled_cli_fallback: &str) -> String { mod tests { use super::*; + /// Upstream `codexbar sessions --json-v2` output: `printJSON` of `[AgentSession]` + /// (CLISessionsCommand.swift runSessions, CLIErrorReporting.swift encodeJSON) with + /// `JSONEncoder` defaults, `.iso8601` dates, nil optionals omitted and `/` escaped. + const UPSTREAM_SESSIONS_V2: &str = r#"[{"id":"codex-rollout-1","provider":"codex","source":"ide","state":"active","pid":4242,"cwd":"\/Users\/proof\/src\/proof-app","projectName":"proof-app","sessionName":"Fix session labels","startedAt":"2026-08-03T11:30:00Z","lastActivityAt":"2026-08-03T12:00:00Z","transcriptPath":"\/Users\/proof\/.codex\/sessions\/rollout-1.jsonl","host":"proof-mac.local"},{"id":"claude-file-1","provider":"claude","source":"desktopApp","state":"idle","cwd":"\/Users\/proof\/src\/proof-web","lastActivityAt":"2026-08-03T10:00:00Z","transcriptPath":"\/Users\/proof\/.claude\/projects\/proof-web\/claude-file-1.jsonl","host":"proof-mac.local"},{"id":"pid:77","provider":"pi","dialect":"omp","source":"cli","state":"active","pid":77,"host":"proof-mac.local"}]"#; + + #[test] + fn upstream_flat_array_decodes_with_ssh_target_as_host() { + let result = + RemoteSessionFetcher::decode_remote_sessions("me@proof-mac", UPSTREAM_SESSIONS_V2) + .expect("upstream payload decodes"); + + assert_eq!(result.host, "me@proof-mac"); + assert_eq!(result.error, None); + assert_eq!(result.sessions.len(), 3); + assert!(result.sessions.iter().all(|s| s.host == "me@proof-mac")); + + let codex = &result.sessions[0]; + assert_eq!(codex.provider, AgentSessionProvider::Codex); + assert_eq!(codex.source, AgentSessionSource::Ide); + assert_eq!(codex.state, AgentSessionState::Active); + assert_eq!(codex.pid, Some(4242)); + assert_eq!(codex.session_name.as_deref(), Some("Fix session labels")); + assert_eq!( + codex.workspace.cwd.as_deref(), + Some("/Users/proof/src/proof-app") + ); + assert_eq!(codex.workspace.project_name.as_deref(), Some("proof-app")); + assert_eq!( + codex.activity.started_at, + DateTime::parse_from_rfc3339("2026-08-03T11:30:00Z") + .ok() + .map(|d| d.with_timezone(&Utc)) + ); + assert_eq!( + codex.activity.last_activity_at, + DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z") + .ok() + .map(|d| d.with_timezone(&Utc)) + ); + assert_eq!( + codex.transcript_path.as_deref(), + Some("/Users/proof/.codex/sessions/rollout-1.jsonl") + ); + assert_eq!( + codex.focus_target, + AgentSessionFocusTarget::Process { pid: 4242 } + ); + + let claude = &result.sessions[1]; + assert_eq!(claude.source, AgentSessionSource::DesktopApp); + assert_eq!(claude.state, AgentSessionState::Idle); + assert_eq!(claude.pid, None); + assert_eq!(claude.activity.started_at, None); + // Upstream omitted projectName here; it is derived from cwd like local rows. + assert_eq!(claude.workspace.project_name.as_deref(), Some("proof-web")); + assert_eq!( + claude.focus_target, + AgentSessionFocusTarget::Transcript { + transcript_path: "/Users/proof/.claude/projects/proof-web/claude-file-1.jsonl" + .to_string() + } + ); + + let omp = &result.sessions[2]; + assert_eq!(omp.provider, AgentSessionProvider::Pi); + assert_eq!(omp.dialect, Some(PiSessionDialect::Omp)); + assert_eq!(omp.workspace.cwd, None); + assert_eq!( + omp.focus_target, + AgentSessionFocusTarget::Process { pid: 77 } + ); + } + + #[test] + fn upstream_pretty_output_and_empty_list_decode() { + let value: Value = serde_json::from_str(UPSTREAM_SESSIONS_V2).unwrap(); + let pretty = serde_json::to_string_pretty(&value).unwrap(); + let result = RemoteSessionFetcher::decode_remote_sessions("proof-mac", &pretty).unwrap(); + assert_eq!(result.sessions.len(), 3); + + let empty = RemoteSessionFetcher::decode_remote_sessions("proof-mac", "[]\n").unwrap(); + assert_eq!( + empty, + AgentSessionHostResult::success("proof-mac", Vec::new()) + ); + } + + #[test] + fn host_result_object_still_decodes_and_its_error_is_kept() { + let body = r#"{"host":"other-name","sessions":[{"id":"s1","provider":"codex","source":"cli","state":"active","pid":9,"transcriptPath":null,"host":"other-name","workspace":{"cwd":"/w","projectName":"w"},"activity":{"startedAt":null,"lastActivityAt":null},"focusTarget":{"kind":"process","pid":9}}],"error":"partial scan"}"#; + let result = RemoteSessionFetcher::decode_remote_sessions("proof-host", body).unwrap(); + assert_eq!(result.host, "proof-host"); + assert_eq!(result.sessions[0].host, "proof-host"); + assert_eq!( + result.sessions[0].workspace.project_name.as_deref(), + Some("w") + ); + assert_eq!( + result.error.as_deref(), + Some("Remote host reported: partial scan") + ); + } + + #[test] + fn remote_envelope_errors_surface_as_the_host_error() { + let body = r#"{"sessions":[],"errors":["tailscale: Tailscale status failed", " "]}"#; + let result = RemoteSessionFetcher::decode_remote_sessions("proof-host", body).unwrap(); + assert!(result.sessions.is_empty()); + assert_eq!( + result.error.as_deref(), + Some("Remote host reported: tailscale: Tailscale status failed") + ); + } + + #[test] + fn malformed_remote_payloads_fail_with_a_clear_reason() { + let cases = [ + ("sh: codexbar: not json output", "response is not JSON"), + ("", "response is not JSON"), + ("\"usage\"", "expected a JSON array of sessions"), + ( + r#"[{"id":"x","source":"cli","state":"active"}]"#, + "unsupported session row", + ), + ( + r#"[{"id":"x","provider":"gemini","source":"cli","state":"active"}]"#, + "unsupported session row", + ), + (r#"{"errors":[]}"#, "unsupported session object"), + ]; + for (body, expected) in cases { + let error = + RemoteSessionFetcher::decode_remote_sessions("proof-host", body).expect_err(body); + assert!(error.contains(expected), "{body:?} -> {error}"); + } + } + + #[test] + fn shell_quote_escapes_single_quotes() { + assert_eq!(shell_quote("a b"), "'a b'"); + assert_eq!(shell_quote("it's"), r"'it'\''s'"); + } + #[test] fn codex_cost_options_are_noninteractive_and_summary_only() { let options = RemoteSessionFetcher::codex_cost_options("build-host", 30, false).unwrap(); diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index a1439d21971..09be2d621ad 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -657,7 +657,15 @@ bad line let bundled_v2 = shell.rfind("sessions --json-v2").expect("bundled v2 fallback"); assert!(v2 < v1, "v2 negotiated before legacy PATH fallback"); assert!(v1 < bundled_v2, "PATH fallback before bundled CLI"); - assert!(shell.ends_with("sessions --json"), "bundled legacy fallback present"); + assert!(shell.ends_with("sessions --json'"), "bundled legacy fallback present"); + assert!( + shell.starts_with("'codexbar sessions --json-v2 || "), + "the whole chain reaches sh -lc as one shell word" + ); + assert!( + shell.contains("'\\''/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI'\\'' sessions"), + "bundled path quoting survives the outer quotes" + ); } #[tokio::test] diff --git a/rust/src/cli/sessions.rs b/rust/src/cli/sessions.rs index a8b3a59dc60..1a28b1f76af 100644 --- a/rust/src/cli/sessions.rs +++ b/rust/src/cli/sessions.rs @@ -76,6 +76,62 @@ mod tests { assert_eq!(decoded.dialect, None); assert_eq!(decoded.session_name, None); } + + /// The payload a remote Windows host prints is built from the real + /// `SessionsOutput` encoder, so this cannot drift from `codexbar sessions`. + #[test] + fn windows_cli_envelope_decodes_as_a_remote_payload() { + let mut file_only = sample(); + file_only.id = "codex-file".into(); + file_only.pid = None; + file_only.state = AgentSessionState::Idle; + file_only.transcript_path = Some(r"C:\Users\proof\.codex\rollout.jsonl".into()); + file_only.activity.last_activity_at = chrono::DateTime::from_timestamp(1_785_000_000, 0); + file_only.focus_target = AgentSessionFocusTarget::Transcript { + transcript_path: r"C:\Users\proof\.codex\rollout.jsonl".into(), + }; + let mut pi_session = sample(); + pi_session.id = "pid:7".into(); + pi_session.provider = AgentSessionProvider::Pi; + pi_session.dialect = Some(crate::agent_sessions::PiSessionDialect::Pi); + pi_session.session_name = Some("Proof title".into()); + let sessions = vec![sample(), file_only, pi_session]; + + for (json_v2, pretty) in [(true, false), (true, true), (false, false)] { + let output = SessionsOutput { + sessions: sessions_for_json(&sessions, json_v2), + errors: vec!["tailscale: Tailscale status failed"], + }; + let body = if pretty { + serde_json::to_string_pretty(&output).unwrap() + } else { + serde_json::to_string(&output).unwrap() + }; + + let result = crate::agent_sessions::RemoteSessionFetcher::decode_remote_sessions( + "proof@win-box", + &body, + ) + .expect("Windows envelope decodes"); + + let expected = sessions_for_json(&sessions, json_v2) + .into_iter() + .map(|session| AgentSession { + host: "proof@win-box".into(), + ..session.clone() + }) + .collect::>(); + assert_eq!(result.host, "proof@win-box"); + assert_eq!( + result.sessions, expected, + "json_v2={json_v2} pretty={pretty}" + ); + assert_eq!( + result.error.as_deref(), + Some("Remote host reported: tailscale: Tailscale status failed") + ); + } + } } use crate::agent_sessions::{ AgentSession, AgentSessionDiscovery, AgentSessionDiscoveryMode, AgentSessionDiscoveryResult, diff --git a/rust/src/codex_costs/summary_contract.rs b/rust/src/codex_costs/summary_contract.rs index 94ad2d193a6..21461f4fb24 100644 --- a/rust/src/codex_costs/summary_contract.rs +++ b/rust/src/codex_costs/summary_contract.rs @@ -188,10 +188,19 @@ pub(crate) fn decode_remote_codex_summary( return Err(REMOTE_CODEX_COST_INVALID.to_string()); } - let report = reports + let mut report = reports .into_iter() .next() .ok_or_else(|| REMOTE_CODEX_COST_INVALID.to_string())?; + // Upstream macOS hosts still report the totals they have while history coverage + // is incomplete; this side never shows partial totals, so drop them instead of + // rejecting an otherwise valid report. + if !report.history_coverage_is_established { + for window in [&mut report.today, &mut report.history] { + window.total_tokens = None; + window.cost_usd = None; + } + } report.validate(history_days)?; Ok(report) } diff --git a/rust/src/codex_costs/tests.rs b/rust/src/codex_costs/tests.rs index 385fa2ebca7..07f856c333d 100644 --- a/rust/src/codex_costs/tests.rs +++ b/rust/src/codex_costs/tests.rs @@ -493,7 +493,7 @@ fn remote_summary_decoder_rejects_wrong_version_and_oversized_output() { } #[test] -fn remote_summary_decoder_rejects_numeric_totals_with_incomplete_coverage() { +fn remote_summary_decoder_drops_numeric_totals_with_incomplete_coverage() { let source = CostSummary { history_coverage_established: true, known_zero: true, @@ -511,5 +511,54 @@ fn remote_summary_decoder_rejects_numeric_totals_with_incomplete_coverage() { summary.history.cost_usd = Some(1.25); let wire = serde_json::to_string(&[summary]).unwrap(); - assert!(decode_remote_codex_summary(&wire, 30).is_err()); + let decoded = decode_remote_codex_summary(&wire, 30).unwrap(); + assert!(!decoded.history_coverage_is_established); + assert_eq!(decoded.history.total_tokens, None); + assert_eq!(decoded.history.cost_usd, None); + assert_eq!(decoded.today.total_tokens, None); +} + +/// Upstream `cost --provider codex --format json --summary-only` output: +/// `printJSON([CodexCostSummary])` (RemoteCodexCostFetcher.swift), with the +/// extra `incompleteRequestCount` window field and `.iso8601` dates. +const UPSTREAM_COST_SUMMARY: &str = r#"[{"schemaVersion":1,"provider":"codex","updatedAt":"2026-08-03T12:00:00Z","bucketTimeZone":"America\/Los_Angeles","currencyCode":"USD","historyDays":30,"historyCoverageIsEstablished":true,"today":{"totalTokens":1200,"costUSD":0.42,"incompleteRequestCount":0,"coverage":{"priced":3,"unpriced":0,"unmetered":0,"estimated":3},"provenance":"listPriceEstimate"},"history":{"totalTokens":98000,"costUSD":12.5,"incompleteRequestCount":2,"coverage":{"priced":40,"unpriced":1,"unmetered":0,"estimated":40},"provenance":"listPriceEstimate"}}]"#; + +#[test] +fn remote_summary_decoder_accepts_upstream_summary_output() { + let decoded = decode_remote_codex_summary(UPSTREAM_COST_SUMMARY, 30).unwrap(); + assert_eq!(decoded.bucket_time_zone, "America/Los_Angeles"); + assert_eq!(decoded.today.total_tokens, Some(1200)); + assert_eq!(decoded.history.cost_usd, Some(12.5)); + assert_eq!(decoded.history.coverage.unpriced, 1); + + let partial = UPSTREAM_COST_SUMMARY.replace( + r#""historyCoverageIsEstablished":true"#, + r#""historyCoverageIsEstablished":false"#, + ); + let decoded = decode_remote_codex_summary(&partial, 30).unwrap(); + assert_eq!(decoded.history.total_tokens, None); + assert_eq!(decoded.today.cost_usd, None); + assert!(decode_remote_codex_summary(UPSTREAM_COST_SUMMARY, 7).is_err()); +} + +#[test] +fn remote_summary_decoder_accepts_windows_summary_only_output() { + let source = CostSummary { + history_coverage_established: true, + known_zero: true, + ..Default::default() + }; + let summary = CodexCostSummary::from_summaries_at( + &source, + &source, + 30, + DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + "UTC", + ); + for wire in [ + serde_json::to_string(std::slice::from_ref(&summary)).unwrap(), + serde_json::to_string_pretty(std::slice::from_ref(&summary)).unwrap(), + ] { + assert_eq!(decode_remote_codex_summary(&wire, 30).unwrap(), summary); + } }