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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 58 additions & 26 deletions crates/guest-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,33 +62,65 @@ fn helper_exit_code_from_args() -> Option<i32> {
let mut args = std::env::args_os();
let _program = args.next()?;
let command = args.next()?;
if command != "verify-session-history-identity" {
return None;
}
let metadata_path = args
.next()
.unwrap_or_else(final_session_history_identity_path_from_process_env);
let remaining = args.collect::<Vec<_>>();
let expected = match parse_session_history_identity_expectation(&remaining) {
Ok(expected) => expected,
Err(()) => return Some(SESSION_HISTORY_IDENTITY_VERIFY_EXIT_INVALID_ARGS),
};
Some(
match session_history_identity::verify_final_session_history_identity_file(
metadata_path,
expected.as_ref(),
) {
Ok(()) => SESSION_HISTORY_IDENTITY_VERIFY_EXIT_SUCCESS,
Err(error) => {
let exit_code = error.helper_exit_code();
if exit_code == SESSION_HISTORY_IDENTITY_VERIFY_EXIT_SUCCESS {
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_FAILURE
} else {
exit_code
}
match command.to_str()? {
"verify-session-history-identity" => {
let metadata_path = args
.next()
.unwrap_or_else(final_session_history_identity_path_from_process_env);
let remaining = args.collect::<Vec<_>>();
let expected = match parse_session_history_identity_expectation(&remaining) {
Ok(expected) => expected,
Err(()) => return Some(SESSION_HISTORY_IDENTITY_VERIFY_EXIT_INVALID_ARGS),
};
Some(
match session_history_identity::verify_final_session_history_identity_file(
metadata_path,
expected.as_ref(),
) {
Ok(()) => SESSION_HISTORY_IDENTITY_VERIFY_EXIT_SUCCESS,
Err(error) => session_history_identity_helper_exit_code(&error),
},
)
}
"export-session-history-sidecar" => {
let Some(metadata_path) = args.next() else {
return Some(SESSION_HISTORY_IDENTITY_VERIFY_EXIT_INVALID_ARGS);
};
let Some(export_path) = args.next() else {
return Some(SESSION_HISTORY_IDENTITY_VERIFY_EXIT_INVALID_ARGS);
};
if args.next().is_some() {
return Some(SESSION_HISTORY_IDENTITY_VERIFY_EXIT_INVALID_ARGS);
}
},
)
Some(
match session_history_identity::export_final_session_history_sidecar_file(
metadata_path,
export_path,
) {
Ok(metadata) => match serde_json::to_string(&metadata) {
Ok(json) => {
println!("{json}");
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_SUCCESS
}
Err(_) => SESSION_HISTORY_IDENTITY_VERIFY_EXIT_FAILURE,
},
Err(error) => session_history_identity_helper_exit_code(&error),
},
)
}
_ => None,
}
}

fn session_history_identity_helper_exit_code(
error: &session_history_identity::FinalSessionHistoryIdentityVerifyError,
) -> i32 {
let exit_code = error.helper_exit_code();
if exit_code == SESSION_HISTORY_IDENTITY_VERIFY_EXIT_SUCCESS {
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_FAILURE
} else {
exit_code
}
}

#[allow(clippy::panic)]
Expand Down
60 changes: 59 additions & 1 deletion crates/guest-agent/src/session_history_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use guest_contracts::session_history_identity::{
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_HISTORY_READ,
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_HISTORY_TOO_LARGE,
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_INVALID_METADATA,
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_METADATA_READ,
SESSION_HISTORY_IDENTITY_VERIFY_EXIT_METADATA_READ, SESSION_HISTORY_SIDECAR_MAX_BYTES,
SessionHistorySidecarExportMetadata, SessionHistorySidecarRepresentation,
};
use sha2::{Digest, Sha256};
use std::fmt;
Expand Down Expand Up @@ -73,6 +74,34 @@ pub fn verify_final_session_history_identity_file(
verify_final_session_history_identity(&identity)
}

pub fn export_final_session_history_sidecar_file(
metadata_path: impl AsRef<Path>,
export_path: impl AsRef<Path>,
) -> Result<SessionHistorySidecarExportMetadata, FinalSessionHistoryIdentityVerifyError> {
let identity = read_final_session_history_identity(metadata_path)?;
verify_final_session_history_identity(&identity)?;
let source = session_history::read_session_history_checkpoint_source_from_payload_bounded(
&identity.history_marker_payload,
SESSION_HISTORY_SIDECAR_MAX_BYTES,
)
.map_err(FinalSessionHistoryIdentityVerifyError::HistoryRead)?;
let (representation, bytes) = match source {
session_history::SessionHistoryCheckpointSource::Decoded(bytes) => {
(SessionHistorySidecarRepresentation::Raw, bytes)
}
session_history::SessionHistoryCheckpointSource::CodexZstd { encoded } => {
(SessionHistorySidecarRepresentation::CodexZstd, encoded)
}
};
crate::paths::write_private(export_path.as_ref(), &bytes)
.map_err(AgentError::from)
.map_err(FinalSessionHistoryIdentityVerifyError::HistoryRead)?;
Ok(SessionHistorySidecarExportMetadata {
representation,
encoded_size: bytes.len() as u64,
})
}

fn read_final_session_history_identity(
metadata_path: impl AsRef<Path>,
) -> Result<FinalSessionHistoryIdentity, FinalSessionHistoryIdentityVerifyError> {
Expand Down Expand Up @@ -261,6 +290,35 @@ mod tests {
verify_final_session_history_identity_file(metadata_path, None).unwrap();
}

#[test]
fn exports_claude_literal_history_sidecar() {
let dir = tempfile::tempdir().unwrap();
let history = br#"{"type":"system"}"#;
let history_path = dir.path().join("history.jsonl");
std::fs::write(&history_path, history).unwrap();
let identity = FinalSessionHistoryIdentity::new(
FinalSessionHistoryFramework::ClaudeCode,
"a".repeat(64),
FinalSessionHistoryRefKind::Blob,
hex::encode(Sha256::digest(history)),
history.len() as u64,
history_path.to_string_lossy(),
)
.unwrap();
let metadata_path = write_metadata(&dir, &identity);
let export_path = dir.path().join("sidecar.blob");

let export_metadata =
export_final_session_history_sidecar_file(metadata_path, &export_path).unwrap();

assert_eq!(
export_metadata.representation,
SessionHistorySidecarRepresentation::Raw
);
assert_eq!(export_metadata.encoded_size, history.len() as u64);
assert_eq!(std::fs::read(export_path).unwrap(), history);
}

#[test]
fn verifies_codex_marker_history() {
let dir = tempfile::tempdir().unwrap();
Expand Down
5 changes: 5 additions & 0 deletions crates/guest-contracts/src/runtime_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ pub fn final_session_history_identity_file(run_dir: impl AsRef<Path>) -> PathBuf
file(run_dir, "final-session-history-identity.json")
}

/// Return the run-root session-history sidecar export file.
pub fn session_history_sidecar_export_file(run_dir: impl AsRef<Path>) -> PathBuf {
file(run_dir, "session-history-sidecar")
}

/// Return the run-root `failure-diagnostic.json` file.
pub fn failure_diagnostic_file(run_dir: impl AsRef<Path>) -> PathBuf {
file(run_dir, "failure-diagnostic.json")
Expand Down
23 changes: 23 additions & 0 deletions crates/guest-contracts/src/session_history_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ pub const SESSION_HISTORY_IDENTITY_VERIFY_EXIT_HISTORY_MISMATCH: i32 = 8;
/// Guest helper exit code for local history exceeding the guest verification budget.
pub const SESSION_HISTORY_IDENTITY_VERIFY_EXIT_HISTORY_TOO_LARGE: i32 = 9;

/// Maximum native sidecar bytes runner can copy from guest to host.
pub const SESSION_HISTORY_SIDECAR_MAX_BYTES: u64 = 64 * 1024 * 1024;

/// Framework that owns a final session-history file.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
Expand All @@ -52,6 +55,26 @@ pub enum FinalSessionHistoryRefKind {
Blob,
}

/// Native on-disk representation used for cached session-history sidecars.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SessionHistorySidecarRepresentation {
/// Uncompressed session-history bytes.
Raw,
/// Codex zstd session-history bytes.
CodexZstd,
}

/// Metadata printed by the guest sidecar export helper.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionHistorySidecarExportMetadata {
/// Representation written to the exported sidecar file.
pub representation: SessionHistorySidecarRepresentation,
/// Exact byte length of the exported sidecar file.
pub encoded_size: u64,
}

/// Run-private final session-history identity metadata.
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down
32 changes: 32 additions & 0 deletions crates/runner/src/cmd/start/job_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ fn build_session_history_restore_plan(
| SandboxReuseResult::UnparkFailed => Some(SessionHistoryRestoreFallback::NonReuse),
};

if reuse.result != SandboxReuseResult::Reused {
return SessionHistoryRestorePlan::DeferredHashBacked { fallback };
}

let started_at = Instant::now();
let materializer = SessionHistoryMaterializer::start_cancellable(
http,
Expand Down Expand Up @@ -1215,6 +1219,34 @@ mod tests {
}
}

#[test]
fn restore_plan_defers_hash_backed_history_for_non_reuse() {
let http = test_http_client();
let context = context_with_history_ref("history-hash-a");
let cancel = RunCancellationHandle::new();
let mut timing = RunnerPreSpawnTiming::start_after_claim();

let plan = build_session_history_restore_plan(
&http,
&context,
true,
&cancel,
SessionHistoryRestoreReuse {
entry: None,
result: SandboxReuseResult::PoolMiss,
},
&mut timing,
None,
);

match plan {
SessionHistoryRestorePlan::DeferredHashBacked { fallback } => {
assert_eq!(fallback, Some(SessionHistoryRestoreFallback::NonReuse));
}
_ => panic!("non-reuse hash-backed history should defer materialization"),
}
}

#[tokio::test]
async fn restore_plan_classifies_session_identity_mismatch() {
let http = test_http_client();
Expand Down
2 changes: 2 additions & 0 deletions crates/runner/src/cmd/start/sandbox_finalization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ pub(super) async fn finalize_sandbox_for_completion(
run_id,
sandbox_id,
cli_agent_session_id_override: resolved_cli_agent_session_id,
restored_session_identity: restored_session_identity.as_ref(),
terminal_status,
completed_at: completed_at.clone(),
storage_fingerprints: storage_fingerprints.clone(),
Expand Down Expand Up @@ -726,6 +727,7 @@ mod tests {
run_id,
sandbox_id,
cli_agent_session_id_override: Some(session_id),
restored_session_identity: None,
terminal_status,
completed_at: local_completed_at(),
storage_fingerprints,
Expand Down
1 change: 1 addition & 0 deletions crates/runner/src/cmd/start/tests/support/idle_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ pub(in super::super) async fn seed_idle_pool_with_workspace_promotion(
run_id,
sandbox_id,
cli_agent_session_id_override: Some(spec.session_id),
restored_session_identity: None,
terminal_status: WorkspaceCacheTerminalStatus::Success,
completed_at: TEST_SESSION_LAST_COMPLETED_AT.into(),
storage_fingerprints: StorageFingerprints::default(),
Expand Down
Loading
Loading