mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Expand extension permission regression coverage (#41946)
## What changed - Verify image-generation extensions rebind permissions on every turn, preserving session grants while expiring turn-only grants. - Cover executor skill reference reads under current filesystem permissions, including paginated reads, permission changes, and edits between pages. - Verify oversized live executor skill prompts emit a warning without replacing previously injected prompt content. GitOrigin-RevId: 1fc8eef409fa65db4bb1ab6a00408fe72c9e938f
This commit is contained in:
1 parent
e39ab0c185
commit
c2e5692e2c
2 files changed
+688
No files matched your search
@@ -22,6 +22,7 @@ use codex_protocol::permissions::FileSystemAccessMode;
|
|||||||
use codex_protocol::permissions::FileSystemPath;
|
use codex_protocol::permissions::FileSystemPath;
|
||||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||||
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
use codex_protocol::protocol::AskForApproval;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
@@ -35,12 +36,14 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
|||||||
use core_test_support::responses;
|
use core_test_support::responses;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
use core_test_support::skip_if_sandbox;
|
use core_test_support::skip_if_sandbox;
|
||||||
|
use core_test_support::skip_if_target_windows;
|
||||||
use core_test_support::test_codex::local_selections;
|
use core_test_support::test_codex::local_selections;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
use core_test_support::test_codex::turn_permission_fields;
|
use core_test_support::test_codex::turn_permission_fields;
|
||||||
use core_test_support::wait_for_event;
|
use core_test_support::wait_for_event;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use test_case::test_case;
|
||||||
use wiremock::Mock;
|
use wiremock::Mock;
|
||||||
use wiremock::ResponseTemplate;
|
use wiremock::ResponseTemplate;
|
||||||
use wiremock::matchers::method;
|
use wiremock::matchers::method;
|
||||||
@@ -328,3 +331,253 @@ async fn extension_tool_uses_granted_turn_permissions_without_host_local_persist
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebinding extension access each turn preserves session grants but expires turn-only grants.
|
||||||
|
#[test_case(PermissionGrantScope::Turn; "turn grant")]
|
||||||
|
#[test_case(PermissionGrantScope::Session; "session grant")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn extension_tool_rebinds_granted_permissions_on_each_turn(
|
||||||
|
grant_scope: PermissionGrantScope,
|
||||||
|
) -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
skip_if_sandbox!(Ok(()));
|
||||||
|
skip_if_target_windows!(
|
||||||
|
Ok(()),
|
||||||
|
"restricted reads require the elevated Windows sandbox backend, unavailable in this fixture"
|
||||||
|
);
|
||||||
|
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/images/edits"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"created": 1,
|
||||||
|
"data": [{"b64_json": TINY_PNG_BASE64}],
|
||||||
|
})))
|
||||||
|
.expect(match grant_scope {
|
||||||
|
PermissionGrantScope::Turn => 1,
|
||||||
|
PermissionGrantScope::Session => 2,
|
||||||
|
})
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||||
|
let extensions = image_generation_extensions(&auth, |_config| None);
|
||||||
|
let base_permission_profile = PermissionProfile::from_runtime_permissions(
|
||||||
|
&FileSystemSandboxPolicy::restricted(vec![
|
||||||
|
FileSystemSandboxEntry::new(
|
||||||
|
FileSystemPath::Special {
|
||||||
|
value: FileSystemSpecialPath::Minimal,
|
||||||
|
},
|
||||||
|
FileSystemAccessMode::Read,
|
||||||
|
),
|
||||||
|
FileSystemSandboxEntry::new(
|
||||||
|
FileSystemPath::Special {
|
||||||
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
||||||
|
},
|
||||||
|
FileSystemAccessMode::Write,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
NetworkSandboxPolicy::Restricted,
|
||||||
|
);
|
||||||
|
let permission_profile_for_config = base_permission_profile.clone();
|
||||||
|
let mut builder = test_codex()
|
||||||
|
.with_auth(auth)
|
||||||
|
.with_extensions(extensions)
|
||||||
|
.with_model_info_override("gpt-5.4", |model_info| {
|
||||||
|
model_info.use_responses_lite = true;
|
||||||
|
model_info.input_modalities = vec![InputModality::Text, InputModality::Image];
|
||||||
|
})
|
||||||
|
.with_config(move |config| {
|
||||||
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
|
config
|
||||||
|
.permissions
|
||||||
|
.set_permission_profile(permission_profile_for_config)
|
||||||
|
.expect("set permission profile");
|
||||||
|
assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok());
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.enable(Feature::RequestPermissionsTool)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let test = builder.build(&server).await?;
|
||||||
|
|
||||||
|
let image_dir = tempfile::tempdir()?;
|
||||||
|
let image_path = image_dir.path().canonicalize()?.join("granted.png");
|
||||||
|
std::fs::write(&image_path, TINY_PNG_BYTES)?;
|
||||||
|
let requested_permissions = RequestPermissionProfile {
|
||||||
|
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||||
|
Some(vec![AbsolutePathBuf::try_from(
|
||||||
|
image_dir.path().canonicalize()?,
|
||||||
|
)?]),
|
||||||
|
Some(Vec::new()),
|
||||||
|
)),
|
||||||
|
..RequestPermissionProfile::default()
|
||||||
|
};
|
||||||
|
let permissions_call_id = "permissions-call";
|
||||||
|
let image_call_id = "image-edit-granted";
|
||||||
|
let next_image_call_id = "image-edit-next-turn";
|
||||||
|
let response_mock = responses::mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
responses::sse(vec![
|
||||||
|
responses::ev_response_created("resp-1"),
|
||||||
|
responses::ev_function_call(
|
||||||
|
permissions_call_id,
|
||||||
|
"request_permissions",
|
||||||
|
&serde_json::to_string(&json!({
|
||||||
|
"reason": "Read an image outside the workspace",
|
||||||
|
"permissions": requested_permissions,
|
||||||
|
}))?,
|
||||||
|
),
|
||||||
|
responses::ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
responses::sse(vec![
|
||||||
|
responses::ev_response_created("resp-2"),
|
||||||
|
responses::ev_function_call_with_namespace(
|
||||||
|
image_call_id,
|
||||||
|
"image_gen",
|
||||||
|
"imagegen",
|
||||||
|
&json!({
|
||||||
|
"prompt": "edit the image",
|
||||||
|
"referenced_image_paths": [image_path.display().to_string()],
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
responses::ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
responses::sse(vec![
|
||||||
|
responses::ev_response_created("resp-3"),
|
||||||
|
responses::ev_assistant_message("msg-1", "done"),
|
||||||
|
responses::ev_completed("resp-3"),
|
||||||
|
]),
|
||||||
|
responses::sse(vec![
|
||||||
|
responses::ev_response_created("resp-4"),
|
||||||
|
responses::ev_function_call_with_namespace(
|
||||||
|
next_image_call_id,
|
||||||
|
"image_gen",
|
||||||
|
"imagegen",
|
||||||
|
&json!({
|
||||||
|
"prompt": "edit the image again",
|
||||||
|
"referenced_image_paths": [image_path.display().to_string()],
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
responses::ev_completed("resp-4"),
|
||||||
|
]),
|
||||||
|
responses::sse(vec![
|
||||||
|
responses::ev_response_created("resp-5"),
|
||||||
|
responses::ev_assistant_message("msg-2", "done again"),
|
||||||
|
responses::ev_completed("resp-5"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (sandbox_policy, permission_profile) =
|
||||||
|
turn_permission_fields(base_permission_profile, test.config.cwd.as_path());
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(
|
||||||
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "request access and edit the image".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}])
|
||||||
|
.with_thread_settings(ThreadSettingsOverrides {
|
||||||
|
environments: Some(local_selections(test.config.cwd.clone())),
|
||||||
|
approval_policy: Some(AskForApproval::OnRequest),
|
||||||
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||||
|
sandbox_policy: Some(sandbox_policy),
|
||||||
|
permission_profile,
|
||||||
|
collaboration_mode: Some(CollaborationMode {
|
||||||
|
mode: ModeKind::Default,
|
||||||
|
settings: Settings {
|
||||||
|
model: test.session_configured.model.clone(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
developer_instructions: None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let event = wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let EventMsg::RequestPermissions(request) = event else {
|
||||||
|
panic!("expected request_permissions before turn completion");
|
||||||
|
};
|
||||||
|
assert_eq!(request.call_id, permissions_call_id);
|
||||||
|
test.codex
|
||||||
|
.submit(Op::RequestPermissionsResponse {
|
||||||
|
id: permissions_call_id.to_string(),
|
||||||
|
response: RequestPermissionsResponse {
|
||||||
|
permissions: request.permissions,
|
||||||
|
scope: grant_scope,
|
||||||
|
strict_auto_review: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let request = response_mock
|
||||||
|
.last_request()
|
||||||
|
.context("missing request containing extension output")?;
|
||||||
|
let output = request.function_call_output(image_call_id);
|
||||||
|
assert_eq!(
|
||||||
|
output["output"][0],
|
||||||
|
json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": TINY_PNG_DATA_URL,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
test.submit_text_turn("edit the same image in a new turn")
|
||||||
|
.await?;
|
||||||
|
let next_request = response_mock
|
||||||
|
.last_request()
|
||||||
|
.context("missing request containing next-turn extension output")?;
|
||||||
|
match grant_scope {
|
||||||
|
PermissionGrantScope::Turn => {
|
||||||
|
let output = next_request
|
||||||
|
.function_call_output_content_and_success(next_image_call_id)
|
||||||
|
.and_then(|(content, _)| content)
|
||||||
|
.context("expired turn grant should produce an extension error")?;
|
||||||
|
assert!(
|
||||||
|
output.starts_with(&format!(
|
||||||
|
"unable to read referenced image at `{}`:",
|
||||||
|
image_path.display()
|
||||||
|
)),
|
||||||
|
"unexpected extension error: {output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PermissionGrantScope::Session => {
|
||||||
|
let output = next_request.function_call_output(next_image_call_id);
|
||||||
|
assert_eq!(
|
||||||
|
output["output"][0],
|
||||||
|
json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": TINY_PNG_DATA_URL,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(
|
||||||
|
test.config
|
||||||
|
.cwd
|
||||||
|
.join("generated_images")
|
||||||
|
.join("image-edit-next-turn.png")
|
||||||
|
)?,
|
||||||
|
TINY_PNG_BYTES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -26,9 +26,17 @@ use codex_login::CodexAuth;
|
|||||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||||
|
use codex_protocol::models::PermissionProfile;
|
||||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||||
|
use codex_protocol::permissions::FileSystemAccessMode;
|
||||||
|
use codex_protocol::permissions::FileSystemPath;
|
||||||
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||||
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||||
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||||
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||||
use codex_protocol::protocol::Event;
|
use codex_protocol::protocol::Event;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||||
use codex_protocol::user_input::UserInput;
|
use codex_protocol::user_input::UserInput;
|
||||||
use codex_skills_extension::ExecutorSkillProvider;
|
use codex_skills_extension::ExecutorSkillProvider;
|
||||||
use codex_skills_extension::HostSkillProvider;
|
use codex_skills_extension::HostSkillProvider;
|
||||||
@@ -65,14 +73,19 @@ use core_test_support::responses::sse;
|
|||||||
use core_test_support::responses::start_websocket_server;
|
use core_test_support::responses::start_websocket_server;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
use core_test_support::skip_if_remote;
|
use core_test_support::skip_if_remote;
|
||||||
|
use core_test_support::skip_if_target_windows;
|
||||||
use core_test_support::skip_if_wine_exec;
|
use core_test_support::skip_if_wine_exec;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
|
use core_test_support::test_codex::test_env;
|
||||||
|
use core_test_support::test_codex::turn_permission_fields;
|
||||||
|
use core_test_support::wait_for_event;
|
||||||
use core_test_support::wait_for_mcp_server;
|
use core_test_support::wait_for_mcp_server;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha1::Digest;
|
use sha1::Digest;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
use test_case::test_case;
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
use toml::toml;
|
use toml::toml;
|
||||||
@@ -1828,6 +1841,428 @@ async fn executor_only_provider_preserves_structured_repo_skill_without_discover
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ExecutorReferenceRead {
|
||||||
|
Allowed,
|
||||||
|
Denied,
|
||||||
|
Continuation,
|
||||||
|
EditedContinuation,
|
||||||
|
DeniedContinuation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live references and continuation pages use current permissions, including after discovery
|
||||||
|
/// materialized the main prompt. Continuations preserve the cached snapshot across file edits.
|
||||||
|
#[test_case(ExecutorReferenceRead::Allowed; "full disk read")]
|
||||||
|
#[test_case(ExecutorReferenceRead::Denied; "denied reference")]
|
||||||
|
#[test_case(ExecutorReferenceRead::Continuation; "unchanged continuation")]
|
||||||
|
#[test_case(ExecutorReferenceRead::EditedContinuation; "edited continuation")]
|
||||||
|
#[test_case(ExecutorReferenceRead::DeniedContinuation; "denied continuation")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn executor_skill_tool_reads_references_under_current_permissions(
|
||||||
|
read: ExecutorReferenceRead,
|
||||||
|
) -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
if matches!(
|
||||||
|
read,
|
||||||
|
ExecutorReferenceRead::Denied | ExecutorReferenceRead::DeniedContinuation
|
||||||
|
) {
|
||||||
|
skip_if_target_windows!(
|
||||||
|
Ok(()),
|
||||||
|
"restricted reads require the elevated Windows sandbox backend, unavailable in this fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFERENCE_CONTENTS: &str = "Live executor reference instructions.";
|
||||||
|
let contents = match read {
|
||||||
|
ExecutorReferenceRead::Allowed | ExecutorReferenceRead::Denied => {
|
||||||
|
REFERENCE_CONTENTS.to_string()
|
||||||
|
}
|
||||||
|
ExecutorReferenceRead::Continuation
|
||||||
|
| ExecutorReferenceRead::EditedContinuation
|
||||||
|
| ExecutorReferenceRead::DeniedContinuation => "reference line\n".repeat(800),
|
||||||
|
};
|
||||||
|
let reference_access = match read {
|
||||||
|
ExecutorReferenceRead::Denied => FileSystemAccessMode::Deny,
|
||||||
|
ExecutorReferenceRead::Allowed
|
||||||
|
| ExecutorReferenceRead::Continuation
|
||||||
|
| ExecutorReferenceRead::EditedContinuation
|
||||||
|
| ExecutorReferenceRead::DeniedContinuation => FileSystemAccessMode::Read,
|
||||||
|
};
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||||
|
install_with_providers(
|
||||||
|
&mut extensions,
|
||||||
|
SkillProviders::new().with_executor_provider(Arc::new(
|
||||||
|
ExecutorSkillProvider::new_with_restriction_product(
|
||||||
|
Arc::new(EnvironmentManager::default_for_tests()),
|
||||||
|
/*restriction_product*/ None,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
|config: &Config| SkillsExtensionConfig {
|
||||||
|
include_instructions: config.include_skill_instructions,
|
||||||
|
max_context_tokens: config.skill_max_context_tokens,
|
||||||
|
bundled_skills_enabled: false,
|
||||||
|
orchestrator_skills_enabled: false,
|
||||||
|
shadow_selection_enabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut builder = test_codex()
|
||||||
|
.with_extensions(Arc::new(extensions.build()))
|
||||||
|
.with_model_info_override("gpt-5.4", |model_info| {
|
||||||
|
model_info.truncation_policy = TruncationPolicyConfig::bytes(/*limit*/ 8192);
|
||||||
|
})
|
||||||
|
.with_config(configure_catalog_test);
|
||||||
|
let test = builder.build_with_auto_env(&server).await?;
|
||||||
|
let selection = test
|
||||||
|
.codex
|
||||||
|
.environment_selections()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("thread should select an executor environment");
|
||||||
|
let file_system = test.fs();
|
||||||
|
let skill_dir = selection.cwd.join("skill")?;
|
||||||
|
file_system
|
||||||
|
.create_directory(
|
||||||
|
&skill_dir,
|
||||||
|
CreateDirectoryOptions {
|
||||||
|
recursive: true,
|
||||||
|
follow_symlinks: true,
|
||||||
|
},
|
||||||
|
/*sandbox*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Discovery retains the selected root's spelling, including macOS /var aliases.
|
||||||
|
// Only permission entries should use the canonical path.
|
||||||
|
let policy_skill_dir = file_system
|
||||||
|
.canonicalize(&skill_dir, /*sandbox*/ None)
|
||||||
|
.await?;
|
||||||
|
for (name, contents) in [
|
||||||
|
(
|
||||||
|
"SKILL.md",
|
||||||
|
"---\nname: skill\ndescription: Read executor references.\n---\n\nRead reference.md.\n",
|
||||||
|
),
|
||||||
|
("reference.md", contents.as_str()),
|
||||||
|
] {
|
||||||
|
file_system
|
||||||
|
.write_file(
|
||||||
|
&skill_dir.join(name)?,
|
||||||
|
contents.as_bytes().to_vec(),
|
||||||
|
Default::default(),
|
||||||
|
/*sandbox*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let package = format!(
|
||||||
|
"skill://reference-root/{}",
|
||||||
|
skill_dir
|
||||||
|
.inferred_native_path_string()
|
||||||
|
.replace('\\', "/")
|
||||||
|
.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let resource = format!("{package}/reference.md");
|
||||||
|
let mut thread_extension_init = ExtensionDataInit::new();
|
||||||
|
thread_extension_init.insert(vec![SelectedCapabilityRoot {
|
||||||
|
id: "reference-root".to_string(),
|
||||||
|
location: CapabilityRootLocation::Environment {
|
||||||
|
environment_id: selection.environment_id.clone(),
|
||||||
|
path: selection.cwd.clone(),
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
let mut config = test.config.clone();
|
||||||
|
config
|
||||||
|
.permissions
|
||||||
|
.set_permission_profile(PermissionProfile::from_runtime_permissions(
|
||||||
|
&FileSystemSandboxPolicy::restricted(vec![
|
||||||
|
FileSystemSandboxEntry::new(
|
||||||
|
FileSystemPath::Special {
|
||||||
|
value: FileSystemSpecialPath::Root,
|
||||||
|
},
|
||||||
|
FileSystemAccessMode::Read,
|
||||||
|
),
|
||||||
|
FileSystemSandboxEntry::new(
|
||||||
|
FileSystemPath::Path {
|
||||||
|
path: policy_skill_dir.join("reference.md")?,
|
||||||
|
},
|
||||||
|
reference_access,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
NetworkSandboxPolicy::Restricted,
|
||||||
|
))?;
|
||||||
|
let thread = test
|
||||||
|
.thread_manager
|
||||||
|
.start_thread(StartThreadOptions {
|
||||||
|
environments: Some(vec![selection]),
|
||||||
|
thread_extension_init,
|
||||||
|
..StartThreadOptions::new(config)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let response = responses::mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
responses::ev_function_call_with_namespace(
|
||||||
|
"read-reference",
|
||||||
|
"skills",
|
||||||
|
"read",
|
||||||
|
&json!({ "package": package, "resource": resource }).to_string(),
|
||||||
|
),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
thread
|
||||||
|
.thread
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "Read the executor skill reference.".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&thread.thread, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let output = response.requests()[1]
|
||||||
|
.function_call_output_text("read-reference")
|
||||||
|
.expect("skills.read should return a tool result");
|
||||||
|
if matches!(read, ExecutorReferenceRead::Denied) {
|
||||||
|
assert_eq!(output, "failed to read skill resource");
|
||||||
|
} else if matches!(read, ExecutorReferenceRead::Allowed) {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&output)?,
|
||||||
|
json!({
|
||||||
|
"resource": resource,
|
||||||
|
"contents": contents,
|
||||||
|
"skill_root": skill_dir.inferred_native_path_string(),
|
||||||
|
"next_cursor": null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let page: Value = serde_json::from_str(&output)?;
|
||||||
|
let cursor = page["next_cursor"]
|
||||||
|
.as_str()
|
||||||
|
.expect("reference needs another page");
|
||||||
|
let first_contents = page["contents"].as_str().expect("first page contents");
|
||||||
|
assert_eq!(
|
||||||
|
page,
|
||||||
|
json!({
|
||||||
|
"resource": resource,
|
||||||
|
"contents": &contents[..first_contents.len()],
|
||||||
|
"skill_root": skill_dir.inferred_native_path_string(),
|
||||||
|
"next_cursor": cursor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if matches!(read, ExecutorReferenceRead::EditedContinuation) {
|
||||||
|
file_system
|
||||||
|
.write_file(
|
||||||
|
&skill_dir.join("reference.md")?,
|
||||||
|
b"edited reference".to_vec(),
|
||||||
|
Default::default(),
|
||||||
|
/*sandbox*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let response = responses::mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-3"),
|
||||||
|
responses::ev_function_call_with_namespace(
|
||||||
|
"continue-reference",
|
||||||
|
"skills",
|
||||||
|
"read",
|
||||||
|
&json!({"package": package, "resource": resource, "cursor": cursor})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
ev_completed("resp-3"),
|
||||||
|
]),
|
||||||
|
sse(vec![ev_response_created("resp-4"), ev_completed("resp-4")]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut input = TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "Read the next page of the executor reference.".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]);
|
||||||
|
if matches!(read, ExecutorReferenceRead::DeniedContinuation) {
|
||||||
|
let permissions = PermissionProfile::from_runtime_permissions(
|
||||||
|
&FileSystemSandboxPolicy::restricted(vec![
|
||||||
|
FileSystemSandboxEntry::new(
|
||||||
|
FileSystemPath::Special {
|
||||||
|
value: FileSystemSpecialPath::Root,
|
||||||
|
},
|
||||||
|
FileSystemAccessMode::Read,
|
||||||
|
),
|
||||||
|
FileSystemSandboxEntry::new(
|
||||||
|
FileSystemPath::Path {
|
||||||
|
path: policy_skill_dir.join("reference.md")?,
|
||||||
|
},
|
||||||
|
FileSystemAccessMode::Deny,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
NetworkSandboxPolicy::Restricted,
|
||||||
|
);
|
||||||
|
let (sandbox_policy, permission_profile) =
|
||||||
|
turn_permission_fields(permissions, test.config.cwd.as_path());
|
||||||
|
input = input.with_thread_settings(ThreadSettingsOverrides {
|
||||||
|
sandbox_policy: Some(sandbox_policy),
|
||||||
|
permission_profile,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
thread.thread.start_or_steer_turn(input).await?;
|
||||||
|
wait_for_event(&thread.thread, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let output = response.requests()[1]
|
||||||
|
.function_call_output_text("continue-reference")
|
||||||
|
.expect("skills.read continuation should return a tool result");
|
||||||
|
match read {
|
||||||
|
ExecutorReferenceRead::Continuation | ExecutorReferenceRead::EditedContinuation => {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&output)?,
|
||||||
|
json!({
|
||||||
|
"resource": resource,
|
||||||
|
"contents": &contents[first_contents.len()..],
|
||||||
|
"skill_root": skill_dir.inferred_native_path_string(),
|
||||||
|
"next_cursor": null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ExecutorReferenceRead::DeniedContinuation => {
|
||||||
|
assert_eq!(output, "failed to read skill resource")
|
||||||
|
}
|
||||||
|
ExecutorReferenceRead::Allowed | ExecutorReferenceRead::Denied => {
|
||||||
|
unreachable!("single-page cases handled above")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live executor prompts reject oversized resources without rewriting earlier injected content.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn explicit_executor_skill_prompt_rejects_oversized_resource() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
const FIRST_BODY: &str = "Instructions from the allowed live read.";
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let environment = test_env().await?;
|
||||||
|
let environment_id = environment.selection().environment_id.clone();
|
||||||
|
let skill_path = environment.selection().cwd.join("SKILL.md")?;
|
||||||
|
let file_system = environment.environment().get_filesystem();
|
||||||
|
file_system
|
||||||
|
.write_file(
|
||||||
|
&skill_path,
|
||||||
|
FIRST_BODY.as_bytes().to_vec(),
|
||||||
|
Default::default(),
|
||||||
|
/*sandbox*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let catalog = SkillCatalog {
|
||||||
|
entries: vec![SkillCatalogEntry::new(
|
||||||
|
SkillPackageId("skill://prompt-limit/live".to_string()),
|
||||||
|
SkillAuthority::new(SkillSourceKind::Executor, "prompt-limit"),
|
||||||
|
"live",
|
||||||
|
"Read executor instructions.",
|
||||||
|
SkillResourceId::environment(
|
||||||
|
"skill://prompt-limit/live/SKILL.md",
|
||||||
|
&environment_id,
|
||||||
|
skill_path.clone(),
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
let environment_manager = match environment.exec_server_url() {
|
||||||
|
Some(url) => {
|
||||||
|
EnvironmentManager::create_for_tests(
|
||||||
|
Some(url.to_string()),
|
||||||
|
/*local_runtime_paths*/ None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => EnvironmentManager::default_for_tests(),
|
||||||
|
};
|
||||||
|
let (event_tx, event_rx) = std::sync::mpsc::channel();
|
||||||
|
let mut extensions =
|
||||||
|
ExtensionRegistryBuilder::<Config>::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
|
||||||
|
install_with_providers(
|
||||||
|
&mut extensions,
|
||||||
|
SkillProviders::new()
|
||||||
|
.with_executor_provider(Arc::new(CatalogSkillProvider { catalog }))
|
||||||
|
.with_executor_provider(Arc::new(
|
||||||
|
ExecutorSkillProvider::new_with_restriction_product(
|
||||||
|
Arc::new(environment_manager),
|
||||||
|
/*restriction_product*/ None,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
|config: &Config| SkillsExtensionConfig {
|
||||||
|
include_instructions: config.include_skill_instructions,
|
||||||
|
max_context_tokens: config.skill_max_context_tokens,
|
||||||
|
bundled_skills_enabled: false,
|
||||||
|
orchestrator_skills_enabled: false,
|
||||||
|
shadow_selection_enabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut builder = test_codex()
|
||||||
|
.with_extensions(Arc::new(extensions.build()))
|
||||||
|
.with_config(configure_catalog_test);
|
||||||
|
let test = builder.build_with_environment(&server, environment).await?;
|
||||||
|
let first_response = mount_sse_once(
|
||||||
|
&server,
|
||||||
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
test.submit_turn("$live").await?;
|
||||||
|
let expected_prompts = vec![format!(
|
||||||
|
"<skill>\n<name>live</name>\n<path>skill://prompt-limit/live/SKILL.md</path>\n{FIRST_BODY}\n</skill>"
|
||||||
|
)];
|
||||||
|
let first_prompts = first_response
|
||||||
|
.single_request()
|
||||||
|
.message_input_texts("user")
|
||||||
|
.into_iter()
|
||||||
|
.filter(|text| text.starts_with("<skill>"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(first_prompts, expected_prompts);
|
||||||
|
|
||||||
|
file_system
|
||||||
|
.write_file(
|
||||||
|
&skill_path,
|
||||||
|
vec![b'x'; 1024 * 1024 + 1],
|
||||||
|
Default::default(),
|
||||||
|
/*sandbox*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let second_response = mount_sse_once(
|
||||||
|
&server,
|
||||||
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
test.submit_turn("$live").await?;
|
||||||
|
let prompts = second_response
|
||||||
|
.single_request()
|
||||||
|
.message_input_texts("user")
|
||||||
|
.into_iter()
|
||||||
|
.filter(|text| text.starts_with("<skill>"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(prompts, expected_prompts);
|
||||||
|
let warnings = event_rx
|
||||||
|
.try_iter()
|
||||||
|
.filter_map(|event| match event {
|
||||||
|
CapturedExtensionEvent::Warning(warning) => Some(warning.message),
|
||||||
|
CapturedExtensionEvent::Event(_) => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(warnings, vec![
|
||||||
|
"Failed to load skill `live`: executor skill resource skill://prompt-limit/live/SKILL.md exceeds 1048576 bytes".to_string()
|
||||||
|
]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn executor_skill_invocation_is_environment_scoped_and_deduplicated() -> Result<()> {
|
async fn executor_skill_invocation_is_environment_scoped_and_deduplicated() -> Result<()> {
|
||||||
skip_if_remote!(Ok(()), "executor fixture uses a host-local skill path");
|
skip_if_remote!(Ok(()), "executor fixture uses a host-local skill path");
|
||||||
|
|||||||
Reference in new issue
Block a user