Add remote request permissions integration coverage (#25867)

## Stack

1. #25850 - Key request-permission grants by environment: stores and
applies sticky permission grants per environment id.
2. #25858 - Add `environmentId` to `request_permissions`: lets the model
target a selected environment and resolves relative permission paths
against it.
3. #25862 - Propagate permission approval environment id: carries the
selected environment id through approval events, app-server requests,
TUI prompts, and delegate forwarding.
4. This PR (#25867) - Add remote request permissions integration
coverage: verifies the selected remote environment across request,
approval, grant reuse, and exec.

This PR is stacked on #25862 and should be reviewed after #25850,
#25858, and #25862.

## Why

The environment-scoped permission stack needs one end-to-end check that
exercises the CCA-shaped path, not only unit-level parsing. This
verifies that a model-sent `environmentId` on `request_permissions`
reaches the approval event, stores the grant under the selected
environment, and is reused by a later tool call in that same
environment.

## What Changed

- Adds a remote executor integration test for `request_permissions` with
`environmentId: remote` and a relative write root.
- Asserts the permission event reports the remote environment and cwd,
and that the normalized grant resolves under the remote cwd.
- Approves the grant, then runs a remote `exec_command` without explicit
per-call permissions and verifies it completes without another exec
approval and writes only in the remote filesystem.

## Verification

- Not run locally per instruction.
- `git diff --check`
This commit is contained in:
jif
2026-06-02 23:55:08 +02:00
committed by GitHub
Unverified
parent 68e2c8ed69
commit d55e5a9bde
+207
View File
@@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_exec_server::RemoveOptions;
use codex_features::Feature;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -22,6 +23,9 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
@@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
};
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::User;
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config
.features
.enable(Feature::ExecPermissionApprovals)
.expect("test config should allow feature update");
config
.features
.enable(Feature::RequestPermissionsTool)
.expect("test config should allow feature update");
});
let test = builder.build_with_remote_and_local_env(&server).await?;
let local_cwd = TempDir::new()?;
let remote_cwd = PathBuf::from(format!(
"/tmp/codex-remote-request-permissions-{}",
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
))
.abs();
let relative_write_root = "granted";
let relative_target_path = "granted/request-permissions-output.txt";
let remote_write_root = remote_cwd.join(relative_write_root);
let remote_target_path = remote_cwd.join(relative_target_path);
let local_write_root = local_cwd.path().join(relative_write_root);
let local_target_path = local_cwd.path().join(relative_target_path);
fs::create_dir(&local_write_root)?;
test.fs()
.create_directory(
&remote_write_root,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
let expected_permissions = RequestPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![remote_write_root.clone()]),
)),
..RequestPermissionProfile::default()
};
let approved_response = RequestPermissionsResponse {
permissions: expected_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
};
let command = format!(
"printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}"
);
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-request-permissions-remote-1"),
ev_function_call(
"permissions-call",
"request_permissions",
&json!({
"environment_id": REMOTE_ENVIRONMENT_ID,
"reason": "Allow writing inside the selected remote environment",
"permissions": {
"file_system": {
"write": [relative_write_root],
},
},
})
.to_string(),
),
ev_completed("resp-request-permissions-remote-1"),
]),
sse(vec![
ev_response_created("resp-request-permissions-remote-2"),
ev_function_call(
"exec-call",
"exec_command",
&json!({
"shell": "/bin/sh",
"cmd": command,
"login": false,
"yield_time_ms": 1_000,
"environment_id": REMOTE_ENVIRONMENT_ID,
})
.to_string(),
),
ev_completed("resp-request-permissions-remote-2"),
]),
sse(vec![
ev_response_created("resp-request-permissions-remote-3"),
ev_assistant_message("msg-request-permissions-remote-1", "done"),
ev_completed("resp-request-permissions-remote-3"),
]),
],
)
.await;
submit_turn_with_approval_and_environments(
&test,
"request permissions, then write in the remote environment",
vec![
TurnEnvironmentSelection {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: local_cwd.path().abs(),
},
TurnEnvironmentSelection {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: remote_cwd.clone(),
},
],
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::RequestPermissions(request) = event else {
panic!("expected remote request_permissions before completion: {event:?}");
};
assert_eq!(request.call_id, "permissions-call");
assert_eq!(
request.environment_id.as_deref(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert_eq!(request.cwd.as_ref(), Some(&remote_cwd));
assert_eq!(request.permissions, expected_permissions);
test.codex
.submit(Op::RequestPermissionsResponse {
id: "permissions-call".to_string(),
response: approved_response.clone(),
})
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::TurnComplete(_) => {}
EventMsg::ExecApprovalRequest(approval) => {
panic!("remote request_permissions grant should preapprove exec: {approval:?}");
}
other => panic!("unexpected event: {other:?}"),
}
let permissions_output: RequestPermissionsResponse = serde_json::from_str(
&response_mock
.function_call_output_text("permissions-call")
.expect("expected request_permissions output"),
)?;
assert_eq!(permissions_output, approved_response);
let exec_output = response_mock
.function_call_output_text("exec-call")
.expect("expected exec output");
assert!(
exec_output.contains("remote-request-permissions-ok"),
"unexpected exec output: {exec_output}",
);
assert_eq!(
test.fs()
.read_file_text(&remote_target_path, /*sandbox*/ None)
.await?,
"remote-request-permissions-ok"
);
assert!(
!local_target_path.exists(),
"remote exec should not write through the local environment"
);
test.fs()
.remove(
&remote_cwd,
RemoveOptions {
recursive: true,
force: true,
},
/*sandbox*/ None,
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> {
skip_if_no_network!(Ok(()));