mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
0.10.1: keep local OAuth local and repair release qualification
Pass each admitted endpoint to the shared OAuth client. Disable ambient proxies for permitted loopback HTTP, retain HTTPS proxy support and refuse redirects. All seven consumers use the admitted URL. Omit remote header text from JSON errors and the raw authorization error from recovery warnings. Use the existing Linux renameat2 syscall pattern instead of a missing musl wrapper; preserve retained descriptors and atomic NOREPLACE, with an occupied socket destination regression. A bounded Codex child edited only that file; the captain reviewed the complete diff. Provision parity's real sandbox using the same existing bubblewrap/AppArmor preparation as regular CI. Seal the two shared-process DSH fixtures and forward their existing environment scope to the blocking converter. Prepare RLM admission before its one-second invocation deadline; retain the five-second outside timeout, partial answer and usage assertions. Keep both restore Config instances off the outer default-stack fixture frame; the explicit 2 MiB guard remains unchanged. Verified locally: focused hermetic Rust 130 passed / 0 failed; npm 1289 passed / 0 failed / 7 skipped, including web 770 passed; check:web PASS; repository-policy Clippy for both affected packages/all targets/all features PASS; format/diff and release-parity workflow actionlint PASS. No full local workspace suite was run. Evidence: artifacts/takeover-0101-20261005/security-packaging-local-receipt.json, security-packaging-focused-final.log, security-packaging-clippy.log. Historical hosted failure receipts are retained: Windows had one explicit 2 MiB stack abort, parity had 82 missing-sandbox failures, and two shared-process attempts failed 3 then 2 TUI tests. Fresh-head OS/security/shared-process/RC proof is pending. No publication, deployment, secrets, provider-account or billing changes. Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
a0e0122736
commit
d24b0192f3
9 files changed
+225
-90
No files matched your search
@@ -53,7 +53,8 @@ jobs:
|
||||
echo "apt-get update failed (attempt $i); retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
# Restore after the trusted lockfile is on disk. Key is OS + arch +
|
||||
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
|
||||
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
|
||||
|
||||
@@ -1304,9 +1304,11 @@ impl PrivateDirectory {
|
||||
)
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
// Use the kernel syscall because musl need not export a renameat2 wrapper.
|
||||
// SAFETY: same retained directory; NOREPLACE is required, never emulated by a check.
|
||||
let result = unsafe {
|
||||
libc::renameat2(
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
self.directory_handle.as_raw_fd(),
|
||||
from.as_ptr(),
|
||||
self.directory_handle.as_raw_fd(),
|
||||
@@ -1571,6 +1573,34 @@ mod tests {
|
||||
drop((original, replacement));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn endpoint_move_refuses_to_replace_existing_socket() {
|
||||
let root = root();
|
||||
let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
|
||||
let original = UnixListener::bind(parent.directory.join("owner.sock")).unwrap();
|
||||
let destination = UnixListener::bind(parent.directory.join("retired.sock")).unwrap();
|
||||
let original_identity = parent.socket_identity("owner.sock").unwrap().unwrap();
|
||||
let destination_identity = parent.socket_identity("retired.sock").unwrap().unwrap();
|
||||
|
||||
let error = parent
|
||||
.move_socket_no_replace("owner.sock", "retired.sock")
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<std::io::Error>().unwrap().kind(),
|
||||
std::io::ErrorKind::AlreadyExists
|
||||
);
|
||||
assert_eq!(
|
||||
parent.socket_identity("owner.sock").unwrap(),
|
||||
Some(original_identity)
|
||||
);
|
||||
assert_eq!(
|
||||
parent.socket_identity("retired.sock").unwrap(),
|
||||
Some(destination_identity)
|
||||
);
|
||||
drop((original, destination));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_leaf_symlink_is_never_followed_for_protection_or_retirement() {
|
||||
let root = root();
|
||||
|
||||
@@ -3769,8 +3769,8 @@ impl CodewhaleClient {
|
||||
.timeout(NON_STREAMING_HTTP_TIMEOUT);
|
||||
let request = match self.authorize_plugin_request(request).await {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
self.mark_request_failure(&format!("probe authorization failed: {error}"))
|
||||
Err(_) => {
|
||||
self.mark_request_failure("probe authorization failed")
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
+152
-68
@@ -938,12 +938,21 @@ fn oauth_endpoint_url(raw: &str) -> Result<reqwest::Url> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn oauth_http_client(purpose: &str) -> Result<reqwest::blocking::Client> {
|
||||
crate::tls::reqwest_blocking_client_builder()
|
||||
fn oauth_http_client(endpoint: &reqwest::Url, purpose: &str) -> Result<reqwest::blocking::Client> {
|
||||
let builder = crate::tls::reqwest_blocking_client_builder()
|
||||
// An issuer-approved endpoint cannot delegate credential-bearing forms
|
||||
// to a redirect destination, including HTTPS-to-HTTP downgrades.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(OAUTH_REQUEST_TIMEOUT_SECS))
|
||||
.timeout(Duration::from_secs(OAUTH_REQUEST_TIMEOUT_SECS));
|
||||
// Every caller admits this URL through oauth_endpoint_url. Its HTTP
|
||||
// exception authorizes only a local exchange, never forwarding the form
|
||||
// through an ambient system or environment proxy.
|
||||
let builder = if endpoint.scheme() == "http" {
|
||||
builder.no_proxy()
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
builder
|
||||
.build()
|
||||
.with_context(|| format!("Failed to build OAuth {purpose} client"))
|
||||
}
|
||||
@@ -953,23 +962,6 @@ fn parse_oauth_json<T: serde::de::DeserializeOwned>(
|
||||
operation: &str,
|
||||
) -> Result<(reqwest::StatusCode, T)> {
|
||||
let status = response.status();
|
||||
// Join every content-type value: some test doubles stack a second one
|
||||
// next to the body's implicit type, and the diagnostic must name what
|
||||
// the server actually sent, not whichever header won the map lookup.
|
||||
let content_type = {
|
||||
let joined = response
|
||||
.headers()
|
||||
.get_all(reqwest::header::CONTENT_TYPE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if joined.is_empty() {
|
||||
"missing".to_string()
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
};
|
||||
let mut reader = response.take(OAUTH_RESPONSE_BODY_LIMIT + 1);
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
@@ -985,9 +977,7 @@ fn parse_oauth_json<T: serde::de::DeserializeOwned>(
|
||||
} else {
|
||||
""
|
||||
};
|
||||
anyhow::anyhow!(
|
||||
"{operation} returned HTTP {status} with content type {content_type}; expected JSON{limit}"
|
||||
)
|
||||
anyhow::anyhow!("{operation} returned HTTP {status}; expected JSON{limit}")
|
||||
})?;
|
||||
Ok((status, parsed))
|
||||
}
|
||||
@@ -1079,7 +1069,7 @@ fn discover_oauth_endpoints(params: &OAuthProviderParams, issuer: &str) -> Resul
|
||||
"{}/.well-known/openid-configuration",
|
||||
issuer.trim_end_matches('/')
|
||||
))?;
|
||||
let client = oauth_http_client("OIDC discovery")?;
|
||||
let client = oauth_http_client(&discovery_url, "OIDC discovery")?;
|
||||
#[cfg(test)]
|
||||
crate::external_credentials::record_oauth_network();
|
||||
let response = client
|
||||
@@ -1176,7 +1166,7 @@ fn request_device_grant(
|
||||
scopes: &str,
|
||||
) -> Result<DeviceGrantResponse> {
|
||||
let device_authorization_endpoint = oauth_endpoint_url(device_authorization_endpoint)?;
|
||||
let client = oauth_http_client("device-code")?;
|
||||
let client = oauth_http_client(&device_authorization_endpoint, "device-code")?;
|
||||
let params = [("client_id", client_id), ("scope", scopes)];
|
||||
#[cfg(test)]
|
||||
crate::external_credentials::record_oauth_network();
|
||||
@@ -1218,7 +1208,7 @@ fn poll_device_grant(
|
||||
) -> Result<codewhale_config::device_code::DevicePollOutcome<OAuthTokenMaterial>> {
|
||||
use codewhale_config::device_code::DevicePollOutcome;
|
||||
let token_endpoint = oauth_endpoint_url(token_endpoint)?;
|
||||
let client = oauth_http_client("device-code poll")?;
|
||||
let client = oauth_http_client(&token_endpoint, "device-code poll")?;
|
||||
let params = [
|
||||
("client_id", client_id),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
@@ -1480,8 +1470,10 @@ impl OAuthFormClient for ReqwestOAuthFormClient {
|
||||
issuer == CHATGPT_OAUTH_ISSUER,
|
||||
"ChatGPT revocation issuer is invalid"
|
||||
);
|
||||
let response = oauth_http_client("revocation discovery")?
|
||||
.get(format!("{issuer}/.well-known/openid-configuration"))
|
||||
let discovery_url =
|
||||
oauth_endpoint_url(&format!("{issuer}/.well-known/openid-configuration"))?;
|
||||
let response = oauth_http_client(&discovery_url, "revocation discovery")?
|
||||
.get(discovery_url)
|
||||
.send()
|
||||
.context("ChatGPT revocation discovery failed")?;
|
||||
let (status, document): (_, Value) =
|
||||
@@ -1507,7 +1499,7 @@ impl OAuthFormClient for ReqwestOAuthFormClient {
|
||||
let url = oauth_endpoint_url(url)?;
|
||||
#[cfg(test)]
|
||||
crate::external_credentials::record_oauth_network();
|
||||
let client = oauth_http_client("form")?;
|
||||
let client = oauth_http_client(&url, "form")?;
|
||||
let response = client
|
||||
.post(url)
|
||||
.form(form)
|
||||
@@ -2428,7 +2420,7 @@ fn fetch_chatgpt_jwks(issuer: &str) -> Result<JwkSet> {
|
||||
"{}/.well-known/jwks.json",
|
||||
issuer.trim_end_matches('/')
|
||||
))?;
|
||||
let response = oauth_http_client("identity verification")?
|
||||
let response = oauth_http_client(&url, "identity verification")?
|
||||
.get(url)
|
||||
.send()
|
||||
.context("ChatGPT identity verification keys could not be retrieved")?;
|
||||
@@ -4432,8 +4424,9 @@ fn plugin_token_response(
|
||||
form: &[(&str, &str)],
|
||||
previous_refresh: Option<String>,
|
||||
) -> Result<PluginOAuthTokens> {
|
||||
let response = oauth_http_client("plugin token exchange")?
|
||||
.post(&descriptor.token_endpoint)
|
||||
let endpoint = oauth_endpoint_url(&descriptor.token_endpoint)?;
|
||||
let response = oauth_http_client(&endpoint, "plugin token exchange")?
|
||||
.post(endpoint)
|
||||
.form(form)
|
||||
.send()?;
|
||||
let (status, response): (_, PluginTokenResponse) =
|
||||
@@ -5201,6 +5194,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_loopback_forms_bypass_ambient_proxies() {
|
||||
const PROBE_ENDPOINT: &str = "CODEWHALE_TEST_OAUTH_PROXY_ENDPOINT";
|
||||
if let Ok(endpoint) = std::env::var(PROBE_ENDPOINT) {
|
||||
let (status, _) = ReqwestOAuthFormClient
|
||||
.post_form(&endpoint, &[("refresh_token", "synthetic-refresh")])
|
||||
.expect("local OAuth form remains usable with an ambient proxy");
|
||||
assert_eq!(status, 200);
|
||||
return;
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
use wiremock::matchers::{body_string_contains, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let issuer = MockServer::start().await;
|
||||
let proxy = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.and(body_string_contains("refresh_token=synthetic-refresh"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&issuer)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(502))
|
||||
.expect(0)
|
||||
.mount(&proxy)
|
||||
.await;
|
||||
// A fresh process gives reqwest an uncontaminated proxy cache and
|
||||
// keeps ambient proxy changes out of the shared test process.
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"oauth::tests::oauth_loopback_forms_bypass_ambient_proxies",
|
||||
"--test-threads=1",
|
||||
])
|
||||
.env(PROBE_ENDPOINT, format!("{}/token", issuer.uri()))
|
||||
.env_remove("NO_PROXY")
|
||||
.env_remove("no_proxy");
|
||||
for variable in [
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
] {
|
||||
child.env(variable, proxy.uri());
|
||||
}
|
||||
let output = tokio::task::spawn_blocking(move || child.output().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(issuer.received_requests().await.unwrap().len(), 1);
|
||||
assert!(proxy.received_requests().await.unwrap().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn oauth_transports_never_forward_forms_to_redirect_destinations() {
|
||||
use wiremock::matchers::{method, path};
|
||||
@@ -5620,21 +5680,21 @@ mod tests {
|
||||
assert!(message.contains("HTTP 400"), "{message}");
|
||||
}
|
||||
|
||||
/// Non-JSON answers name the content type, never the body.
|
||||
/// Non-JSON answers disclose neither response metadata nor the body.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn device_transport_reports_non_json_without_echoing_body() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let server = MockServer::start().await;
|
||||
// set_body_bytes carries no implicit content type, so the inserted
|
||||
// text/html is the only one on the wire (set_body_string would
|
||||
// stack text/plain next to it and the diagnostic would name both).
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth2/device-code"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes("<html>sentinel-body-bytes</html>".as_bytes())
|
||||
.insert_header("content-type", "text/html"),
|
||||
.insert_header(
|
||||
"content-type",
|
||||
"text/html; credential=sentinel-header-bytes",
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
@@ -5644,7 +5704,10 @@ mod tests {
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes("<html>sentinel-body-bytes</html>".as_bytes())
|
||||
.insert_header("content-type", "text/html"),
|
||||
.insert_header(
|
||||
"content-type",
|
||||
"text/html; credential=sentinel-header-bytes",
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
@@ -5670,7 +5733,9 @@ mod tests {
|
||||
panic!("non-JSON poll must fail");
|
||||
};
|
||||
for message in [format!("{grant_error:#}"), format!("{poll_error:#}")] {
|
||||
assert!(message.contains("text/html"), "{message}");
|
||||
assert!(message.contains("HTTP 200"), "{message}");
|
||||
assert!(message.contains("expected JSON"), "{message}");
|
||||
assert!(!message.contains("sentinel-header-bytes"), "{message}");
|
||||
assert!(!message.contains("sentinel-body-bytes"), "{message}");
|
||||
}
|
||||
}
|
||||
@@ -9329,35 +9394,54 @@ mod plugin_oauth_tests {
|
||||
async fn plugin_oauth_failed_refresh_preserves_secret_without_exposing_response() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST")).and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error":"invalid_grant", "error_description":"secret-response-material"})))
|
||||
.expect(1).mount(&server).await;
|
||||
let mut config = descriptor();
|
||||
config.issuer = server.uri();
|
||||
config.authorization_endpoint = format!("{}/authorize", server.uri());
|
||||
config.token_endpoint = format!("{}/token", server.uri());
|
||||
let secrets = codewhale_secrets::Secrets::new(std::sync::Arc::new(
|
||||
codewhale_secrets::InMemoryKeyringStore::default(),
|
||||
));
|
||||
let raw = serde_json::to_string(&PluginOAuthTokens {
|
||||
access_token: "still-valid".into(),
|
||||
refresh_token: Some("refresh".into()),
|
||||
expires_at: (now_unix_secs().unwrap() as u64).saturating_add(30),
|
||||
})
|
||||
.unwrap();
|
||||
secrets.set("denied", &raw).unwrap();
|
||||
let (error, unchanged) = tokio::task::spawn_blocking(move || {
|
||||
let error = plugin_oauth_access_token_with_store("denied", &config, false, &secrets)
|
||||
.unwrap_err();
|
||||
(error.to_string(), secrets.get("denied").unwrap().unwrap())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(error.contains("HTTP 400"));
|
||||
assert!(!error.contains("secret-response-material"));
|
||||
assert_eq!(unchanged, raw);
|
||||
server.verify().await;
|
||||
let responses = [
|
||||
ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": "invalid_grant",
|
||||
"error_description": "secret-response-material"
|
||||
})),
|
||||
ResponseTemplate::new(400)
|
||||
.set_body_bytes(b"secret-response-material")
|
||||
.insert_header(
|
||||
"content-type",
|
||||
"text/html; credential=secret-header-material",
|
||||
),
|
||||
];
|
||||
for response in responses {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(response)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = descriptor();
|
||||
config.issuer = server.uri();
|
||||
config.authorization_endpoint = format!("{}/authorize", server.uri());
|
||||
config.token_endpoint = format!("{}/token", server.uri());
|
||||
let secrets = codewhale_secrets::Secrets::new(std::sync::Arc::new(
|
||||
codewhale_secrets::InMemoryKeyringStore::default(),
|
||||
));
|
||||
let raw = serde_json::to_string(&PluginOAuthTokens {
|
||||
access_token: "still-valid".into(),
|
||||
refresh_token: Some("refresh".into()),
|
||||
expires_at: (now_unix_secs().unwrap() as u64).saturating_add(30),
|
||||
})
|
||||
.unwrap();
|
||||
secrets.set("denied", &raw).unwrap();
|
||||
let (error, unchanged) = tokio::task::spawn_blocking(move || {
|
||||
let error =
|
||||
plugin_oauth_access_token_with_store("denied", &config, false, &secrets)
|
||||
.unwrap_err();
|
||||
(error.to_string(), secrets.get("denied").unwrap().unwrap())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(error.contains("HTTP 400"));
|
||||
assert!(!error.contains("secret-response-material"));
|
||||
assert!(!error.contains("secret-header-material"));
|
||||
assert_eq!(unchanged, raw);
|
||||
server.verify().await;
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn plugin_oauth_reviewed_provider_real_client_refresh_chat_catalog_and_revocation() {
|
||||
|
||||
@@ -489,6 +489,7 @@ fn manifests_must_declare_contained_patches() {
|
||||
|
||||
#[test]
|
||||
fn policy_and_dependency_fields_never_widen_activation() {
|
||||
let _home = crate::test_support::SealedHome::new();
|
||||
for field in [
|
||||
"inject: [approvals]",
|
||||
"intercept: {tools: true}",
|
||||
|
||||
@@ -409,7 +409,11 @@ struct ConvertedDsh {
|
||||
/// Parse and convert off the async runtime: conversion reads and copies the
|
||||
/// whole package synchronously.
|
||||
async fn convert_dsh_off_runtime(package: PathBuf) -> Result<ConvertedDsh> {
|
||||
#[cfg(test)]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let canonical = package
|
||||
.canonicalize()
|
||||
.with_context(|| format!("failed to resolve {}", package.display()))?;
|
||||
|
||||
@@ -1058,6 +1058,7 @@ fn write_dsh_package(root: &Path, url: &str) -> PathBuf {
|
||||
#[test]
|
||||
fn dsh_packages_import_through_the_reviewed_install_and_update_flow() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _home = crate::test_support::SealedHome::at(tmp.path());
|
||||
let config = config(tmp.path());
|
||||
let network = allow_all_network();
|
||||
let package = write_dsh_package(tmp.path(), "https://docs.example.invalid/mcp");
|
||||
|
||||
+26
-13
@@ -639,6 +639,9 @@ mod tests {
|
||||
/// request, a Python block, or an event send within the current round.
|
||||
#[tokio::test]
|
||||
async fn wall_clock_deadline_interrupts_pending_work_and_keeps_partial_result() {
|
||||
let _home = crate::test_support::SealedHome::new();
|
||||
use crate::dependencies::ExternalTool as _;
|
||||
assert!(crate::dependencies::Python::resolve().is_some());
|
||||
for pending_model in [true, false] {
|
||||
let partial = if pending_model {
|
||||
"```repl\nprint(_os.environ['RLM_CONTEXT_FILE'])\n```"
|
||||
@@ -650,27 +653,37 @@ mod tests {
|
||||
let client = Arc::new(PendingAfterResponses(mock, 1));
|
||||
let (tx, mut rx) = mpsc::channel(32);
|
||||
let usage = RlmUsageAccumulator::new();
|
||||
// Admission reads configuration and constructs the captured route.
|
||||
// It is fixture setup, before this invocation's one-second budget.
|
||||
let caller =
|
||||
crate::core::engine::tests::rlm_host::caller_for(client.clone(), "root-model");
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
run_admitted_fixture(
|
||||
client.clone(),
|
||||
"root-model".to_string(),
|
||||
"long context".to_string(),
|
||||
None,
|
||||
"child-model".to_string(),
|
||||
tx,
|
||||
0,
|
||||
usage.clone(),
|
||||
tokio::time::Instant::now() + Duration::from_secs(1),
|
||||
Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
|
||||
),
|
||||
caller.dispatch(crate::core::engine::rlm_host::RlmInvocation {
|
||||
prompt: "long context".to_string(),
|
||||
mode: crate::core::engine::rlm_host::RlmMode::Recursive { depth_remaining: 0 },
|
||||
max_tokens: None,
|
||||
task_instructions: None,
|
||||
deadline: tokio::time::Instant::now() + Duration::from_secs(1),
|
||||
gate: Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
|
||||
events: Some(tx),
|
||||
usage: usage.clone(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("the turn deadline must interrupt in-flight work");
|
||||
|
||||
assert_eq!(result.termination, RlmTermination::Error);
|
||||
assert_eq!(result.answer, partial);
|
||||
assert_eq!(
|
||||
result.answer,
|
||||
partial,
|
||||
"error={:?}, iterations={}, calls={}, trace={:?}",
|
||||
result.error,
|
||||
result.iterations,
|
||||
client.0.call_count(),
|
||||
result.trace
|
||||
);
|
||||
assert_eq!(result.iterations, if pending_model { 2 } else { 1 });
|
||||
assert!(
|
||||
result
|
||||
|
||||
@@ -160,11 +160,12 @@ fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
|
||||
let _runtime = crate::test_support::EnvVarGuard::remove("CODEWHALE_RUNTIME_DIR");
|
||||
let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_RUNTIME_DIR");
|
||||
let config = fixture_config();
|
||||
let config = boxed_phase(|| async { Box::new(fixture_config()) }).await;
|
||||
let sessions = SessionManager::default_location()?;
|
||||
let task_config =
|
||||
TaskManagerConfig::from_runtime(&config, root.path().into(), None, Some(1));
|
||||
let (root, config, task_config, sessions) = (&root, &config, &task_config, &sessions);
|
||||
let (root, config, task_config, sessions) =
|
||||
(&root, config.as_ref(), &task_config, &sessions);
|
||||
|
||||
// Phase 1: launch over a saved conversation and bind its Runtime store.
|
||||
let (initial_id, saved_id, binding, automation) = boxed_phase(move || async move {
|
||||
@@ -226,12 +227,12 @@ fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result
|
||||
);
|
||||
let loaded = sessions.load_session(&saved_id)?;
|
||||
assert_eq!(loaded.metadata.runtime_store.as_ref(), Some(&binding));
|
||||
let mut resumed_config = config.clone();
|
||||
let mut resumed_config = boxed_phase(|| async { Box::new(config.clone()) }).await;
|
||||
let (loaded, automation) = (&loaded, &automation);
|
||||
|
||||
// Phase 2: resume with the binding and run the automation for real.
|
||||
{
|
||||
let resumed_config = &mut resumed_config;
|
||||
let resumed_config = resumed_config.as_mut();
|
||||
boxed_phase(move || async move {
|
||||
let mut resumed = Box::new(create_test_app());
|
||||
apply_loaded_session_with_goal(&mut resumed, resumed_config, loaded.clone(), None)
|
||||
@@ -332,7 +333,7 @@ fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result
|
||||
// Phase 4: a host that already owns a foreign scope refuses the switch
|
||||
// and keeps its pending input.
|
||||
{
|
||||
let resumed_config = &mut resumed_config;
|
||||
let resumed_config = resumed_config.as_mut();
|
||||
boxed_phase(move || async move {
|
||||
let mut other_app = Box::new(create_test_app());
|
||||
other_app.runtime_services.task_manager = Some(foreign.clone());
|
||||
|
||||
Reference in new issue
Block a user