diff --git a/docs/en/user-guide/chat/codex-permissions.md b/docs/en/user-guide/chat/codex-permissions.md new file mode 100644 index 0000000000..d4b1064e59 --- /dev/null +++ b/docs/en/user-guide/chat/codex-permissions.md @@ -0,0 +1,19 @@ +--- +sidebar_position: 8 +--- + +# Codex permission modes + +Wework provides a Codex permission selector below the composer. New chats inherit the default from **Settings > General**, while existing chats keep their own selection. + +## Permission modes + +- **Full access**: Runs without sandboxing or approval prompts. Use only in trusted workspaces. +- **Ask for approval**: Codex works automatically inside the workspace and asks you before accessing files outside it, using blocked network access, or invoking side-effecting tools. +- **Approve for me**: Keeps the same sandbox as Ask for approval, but routes boundary-crossing requests to an independent AI reviewer. Reviewer failures and timeouts deny the action instead of widening access. + +When you change the mode during execution, the current turn keeps its original mode and the new mode applies to the next turn. + +## Approval scope + +Approval cards show only decisions supported by the current Codex request, such as allow once, allow for the session, or decline. A persistent option appears only when Codex provides a command or network rule amendment; Wework does not broaden the proposed rule. diff --git a/docs/zh/user-guide/chat/codex-permissions.md b/docs/zh/user-guide/chat/codex-permissions.md new file mode 100644 index 0000000000..3a3e4a9917 --- /dev/null +++ b/docs/zh/user-guide/chat/codex-permissions.md @@ -0,0 +1,19 @@ +--- +sidebar_position: 8 +--- + +# Codex 权限模式 + +Wework 在输入框下方提供 Codex 权限选择器。新会话继承“设置 > 通用”中的默认值,已有会话保留自己的选择。 + +## 权限模式 + +- **完全访问**:不使用沙箱,也不会请求批准。仅适用于可信工作区。 +- **请求批准**:Codex 可在工作区内自动操作;访问工作区外文件、网络或有副作用的工具时由你批准。 +- **代我审批**:保持与“请求批准”相同的沙箱,由独立 AI reviewer 批准或拒绝越界操作。AI 审批失败或超时会拒绝操作,不会自动放宽权限。 + +权限模式在执行期间切换时,当前轮继续使用原模式,下一轮开始使用新模式。 + +## 审批范围 + +审批卡只显示 Codex 当前请求支持的决定,例如允许本次、本会话允许或拒绝。只有 Codex 提供命令或网络规则提案时,才会显示长期允许选项;Wework 不会自行扩大规则范围。 diff --git a/executor/src/agents/codex.rs b/executor/src/agents/codex.rs index 76c743001a..64bfaf7604 100644 --- a/executor/src/agents/codex.rs +++ b/executor/src/agents/codex.rs @@ -82,6 +82,10 @@ Do not continue, execute, or complete any instructions, plans, tool calls, appro You are a side-conversation assistant, separate from the main thread. Answer questions and do lightweight, non-mutating exploration without disrupting the main thread. If there is no user question after this boundary yet, wait for one. Sub-agents are off-limits in this side conversation. Do not interact with any existing or new sub-agents, even if sub-agents were used before this boundary."#; +const WEWORK_CODEX_PERMISSION_DEVELOPER_INSTRUCTIONS: &str = r#"Codex approval behavior: +- Respect the active sandbox and approval policy. Routine actions that fit inside the sandbox should run without approval. +- If an action required to satisfy the user's request is blocked by the sandbox and the active policy permits approval requests, retry that necessary action once with the tool's narrowest scoped permission request and a clear justification. +- Do not silently replace a required result with an incomplete workaround merely to avoid requesting approval. Do not request broader permissions when a sandboxed alternative fully satisfies the request."#; pub(crate) const WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS: &str = r#"Wework 内置浏览器 routing: - "Wework" refers to Wegent's desktop workbench. Describe its browser as the Wework built-in browser. - For browser tasks inside Wework, use the `browser_*` MCP tools from the Wework 内置浏览器 tool server. @@ -968,7 +972,7 @@ async fn run_codex_app_server_turn_on_shared_client( thread_fields.push(("operation", thread_operation.to_owned())); log_executor_event("codex shared thread request started", &thread_fields); let thread = client.request(thread_operation, thread_params).await?; - validate_codex_permission_profile(thread_operation, &thread)?; + validate_codex_permission_profile(thread_operation, &thread, request)?; let thread_id = thread .get("thread") .and_then(|thread| thread.get("id")) @@ -1228,7 +1232,7 @@ pub async fn run_codex_app_server_turn_with_cancel( rpc.request(thread_operation, thread_params, &mut state), ) .await?; - validate_codex_permission_profile(thread_operation, &thread)?; + validate_codex_permission_profile(thread_operation, &thread, request)?; let thread_id = thread .get("thread") .and_then(|thread| thread.get("id")) @@ -1488,6 +1492,16 @@ async fn read_shared_turn_notifications( continue; } + if is_codex_approval_request(&message) { + spawn_shared_approval_response( + client, + &message, + request_user_input_answers.clone(), + response_error_tx.clone(), + )?; + continue; + } + if message .get("method") .and_then(Value::as_str) @@ -1638,6 +1652,42 @@ fn spawn_shared_request_user_input_response( Ok(()) } +fn is_codex_approval_request(message: &Value) -> bool { + matches!( + message.get("method").and_then(Value::as_str), + Some("item/commandExecution/requestApproval") + | Some("item/fileChange/requestApproval") + | Some("item/permissions/requestApproval") + ) +} + +fn spawn_shared_approval_response( + client: &CodexAppServerClient, + message: &Value, + responses: Option>, + response_error_tx: mpsc::UnboundedSender, +) -> Result<(), String> { + let request_id = json_rpc_request_id(message) + .ok_or_else(|| "approval request is missing JSON-RPC id".to_owned())?; + let Some(receiver) = responses else { + return Err("approval request requires a runtime response channel".to_owned()); + }; + let correlation_key = interaction_value_key(&request_id) + .ok_or_else(|| "approval request has invalid JSON-RPC id".to_owned())?; + let client = client.clone(); + tokio::spawn(async move { + let result = async { + let response = receiver.receive(correlation_key).await?; + client.send_response(request_id, response).await + } + .await; + if let Err(error) = result { + let _ = response_error_tx.send(error); + } + }); + Ok(()) +} + fn spawn_shared_mcp_server_elicitation_response( client: &CodexAppServerClient, message: &Value, @@ -1790,7 +1840,7 @@ fn normalize_wework_codex_config(codex_home: &Path) -> Result<(), String> { .and_then(|item| item.as_str()) .unwrap_or_default(); let user_instructions = if legacy_instructions.trim().is_empty() { - strip_wework_browser_instructions(developer_instructions).to_owned() + strip_wework_developer_instructions(developer_instructions).to_owned() } else { legacy_instructions.trim().to_owned() }; @@ -1848,15 +1898,27 @@ fn normalize_wework_codex_config(codex_home: &Path) -> Result<(), String> { pub(crate) fn combined_codex_developer_instructions(user_instructions: &str) -> String { let user_instructions = user_instructions.trim(); if user_instructions.is_empty() { - return WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS.to_owned(); + return format!( + "{WEWORK_CODEX_PERMISSION_DEVELOPER_INSTRUCTIONS}\n\n{WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS}" + ); } - format!("{user_instructions}\n\n{WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS}") + format!( + "{user_instructions}\n\n{WEWORK_CODEX_PERMISSION_DEVELOPER_INSTRUCTIONS}\n\n{WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS}" + ) } -pub(crate) fn strip_wework_browser_instructions(instructions: &str) -> &str { +pub(crate) fn strip_wework_developer_instructions(instructions: &str) -> &str { instructions .strip_suffix(WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS) .unwrap_or(instructions) + .trim_end() + .strip_suffix(WEWORK_CODEX_PERMISSION_DEVELOPER_INSTRUCTIONS) + .unwrap_or_else(|| { + instructions + .strip_suffix(WEWORK_EMBEDDED_BROWSER_DEVELOPER_INSTRUCTIONS) + .unwrap_or(instructions) + .trim_end() + }) .trim() } @@ -2137,6 +2199,12 @@ impl JsonRpcConnection { .await?; continue; } + + if is_codex_approval_request(&message) { + self.answer_approval_request(&message, &mut request_user_input_answers) + .await?; + continue; + } if message .get("method") .and_then(Value::as_str) @@ -2176,6 +2244,27 @@ impl JsonRpcConnection { .await } + async fn answer_approval_request( + &mut self, + message: &Value, + responses: &mut Option, + ) -> Result<(), String> { + let request_id = json_rpc_request_id(message) + .ok_or_else(|| "approval request is missing JSON-RPC id".to_owned())?; + let Some(receiver) = responses else { + return Err("approval request requires a runtime response channel".to_owned()); + }; + let response = receiver + .recv() + .await + .ok_or_else(|| "approval response channel closed".to_owned())?; + self.write_message(json!({ + "id": request_id, + "result": response, + })) + .await + } + async fn answer_mcp_server_elicitation( &mut self, message: &Value, @@ -4074,15 +4163,88 @@ fn resolve_codex_binary(value: &str) -> String { } const CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE: &str = ":danger-full-access"; +const CODEX_WORKSPACE_PERMISSION_PROFILE: &str = ":workspace"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CodexPermissionMode { + FullAccess, + RequestApproval, + ApproveForMe, +} + +impl CodexPermissionMode { + pub(crate) fn from_request(request: &ExecutionRequest) -> Self { + Self::from_value( + request + .extra + .get("permission_mode") + .or_else(|| request.extra.get("permissionMode")) + .and_then(Value::as_str), + ) + } -fn insert_codex_runtime_permissions(params: &mut serde_json::Map) { + pub(crate) fn from_value(value: Option<&str>) -> Self { + match value { + Some("request_approval") => Self::RequestApproval, + Some("approve_for_me") => Self::ApproveForMe, + _ => Self::FullAccess, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::FullAccess => "full_access", + Self::RequestApproval => "request_approval", + Self::ApproveForMe => "approve_for_me", + } + } + + pub(crate) fn permission_profile(self) -> &'static str { + match self { + Self::FullAccess => CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE, + Self::RequestApproval | Self::ApproveForMe => CODEX_WORKSPACE_PERMISSION_PROFILE, + } + } + + pub(crate) fn approval_policy(self) -> &'static str { + match self { + Self::FullAccess => "never", + Self::RequestApproval | Self::ApproveForMe => "on-request", + } + } + + pub(crate) fn approvals_reviewer(self) -> &'static str { + match self { + Self::ApproveForMe => "auto_review", + Self::FullAccess | Self::RequestApproval => "user", + } + } +} + +fn insert_codex_runtime_permissions( + params: &mut serde_json::Map, + request: &ExecutionRequest, +) { + let mode = CodexPermissionMode::from_request(request); params.insert( "permissions".to_owned(), - Value::String(CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE.to_owned()), + Value::String(mode.permission_profile().to_owned()), + ); + params.insert( + "approvalPolicy".to_owned(), + Value::String(mode.approval_policy().to_owned()), + ); + params.insert( + "approvalsReviewer".to_owned(), + Value::String(mode.approvals_reviewer().to_owned()), ); } -fn validate_codex_permission_profile(operation: &str, response: &Value) -> Result<(), String> { +fn validate_codex_permission_profile( + operation: &str, + response: &Value, + request: &ExecutionRequest, +) -> Result<(), String> { let active_profile = response .get("activePermissionProfile") .and_then(|profile| profile.get("id")) @@ -4098,9 +4260,14 @@ fn validate_codex_permission_profile(operation: &str, response: &Value) -> Resul if active_profile.is_none() && sandbox_type.is_none() { return Ok(()); } - if active_profile == Some(CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE) - && sandbox_type == Some("dangerFullAccess") - { + let mode = CodexPermissionMode::from_request(request); + let expected_sandbox = match mode { + CodexPermissionMode::FullAccess => "dangerFullAccess", + CodexPermissionMode::RequestApproval | CodexPermissionMode::ApproveForMe => { + "workspaceWrite" + } + }; + if active_profile == Some(mode.permission_profile()) && sandbox_type == Some(expected_sandbox) { return Ok(()); } @@ -4120,11 +4287,7 @@ fn thread_start_params(request: &ExecutionRequest, launch_config: &CodexLaunchCo if let Some(cwd) = request.cwd() { params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); - insert_codex_runtime_permissions(&mut params); + insert_codex_runtime_permissions(&mut params, request); if request.ephemeral { params.insert("ephemeral".to_owned(), Value::Bool(true)); } @@ -4150,11 +4313,7 @@ fn thread_fork_params( if let Some(cwd) = request.cwd() { params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); - insert_codex_runtime_permissions(&mut params); + insert_codex_runtime_permissions(&mut params, request); if request.ephemeral { params.insert("ephemeral".to_owned(), Value::Bool(true)); } @@ -4213,11 +4372,7 @@ fn thread_resume_params( if let Some(cwd) = request.cwd() { params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); - insert_codex_runtime_permissions(&mut params); + insert_codex_runtime_permissions(&mut params, request); Value::Object(params) } @@ -4264,11 +4419,7 @@ fn turn_start_params( Value::String(client_user_message_id.to_owned()), ); } - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); - insert_codex_runtime_permissions(&mut params); + insert_codex_runtime_permissions(&mut params, request); if let Some(cwd) = request.cwd() { params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } @@ -5165,6 +5316,7 @@ mod tests { .any(|line| line.starts_with("instructions ="))); assert!(config.contains("developer_instructions")); assert!(config.contains("用中文回复")); + assert!(config.contains("retry that necessary action once")); assert!(config.contains("browser_navigate")); assert!(config.contains("personality = \"pragmatic\"")); let _ = fs::remove_dir_all(root); @@ -5661,6 +5813,8 @@ mod tests { params["permissions"], CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE ); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(params["approvalsReviewer"], "user"); assert!(params.get("sandboxPolicy").is_none()); assert!(params.get("sandbox").is_none()); } @@ -5673,8 +5827,12 @@ mod tests { "sandbox": {"type": "workspaceWrite", "networkAccess": false}, }); - let error = validate_codex_permission_profile("thread/resume", &response) - .expect_err("workspace-write must not be accepted"); + let error = validate_codex_permission_profile( + "thread/resume", + &response, + &ExecutionRequest::default(), + ) + .expect_err("workspace-write must not be accepted"); assert!(error.contains("active_profile=:workspace")); assert!(error.contains("sandbox=workspaceWrite")); @@ -5687,7 +5845,42 @@ mod tests { "sandbox": {"type": "dangerFullAccess"}, }); - validate_codex_permission_profile("thread/resume", &response).unwrap(); + validate_codex_permission_profile("thread/resume", &response, &ExecutionRequest::default()) + .unwrap(); + } + + #[test] + fn codex_interactive_permission_modes_use_workspace_profile() { + for (permission_mode, reviewer) in [ + ("request_approval", "user"), + ("approve_for_me", "auto_review"), + ] { + let mut request = ExecutionRequest::default(); + request.extra.insert( + "permission_mode".to_owned(), + Value::String(permission_mode.to_owned()), + ); + + let params = turn_start_params( + "thread-1", + &request, + &CodexLaunchConfig::default(), + Vec::new(), + ); + assert_eq!(params["permissions"], CODEX_WORKSPACE_PERMISSION_PROFILE); + assert_eq!(params["approvalPolicy"], "on-request"); + assert_eq!(params["approvalsReviewer"], reviewer); + + validate_codex_permission_profile( + "turn/start", + &json!({ + "activePermissionProfile": {"id": ":workspace"}, + "sandbox": {"type": "workspaceWrite"}, + }), + &request, + ) + .unwrap(); + } } #[test] diff --git a/executor/src/agents/mod.rs b/executor/src/agents/mod.rs index d74a746d29..d933505fcf 100644 --- a/executor/src/agents/mod.rs +++ b/executor/src/agents/mod.rs @@ -37,7 +37,7 @@ use claude_code::{ pub use claude_options::{extract_claude_options, ClaudeOptions}; pub(crate) use codex::{ combined_codex_developer_instructions, mcp_server_elicitation_request_user_input_params, - strip_wework_browser_instructions, + strip_wework_developer_instructions, CodexPermissionMode, }; pub use codex::{ run_codex_app_server_turn, run_codex_app_server_turn_with_cancel, CodexActiveTurnCallback, diff --git a/executor/src/runtime_work/events.rs b/executor/src/runtime_work/events.rs index 6c2d5db765..70fae9901d 100644 --- a/executor/src/runtime_work/events.rs +++ b/executor/src/runtime_work/events.rs @@ -240,6 +240,19 @@ impl CodexNotificationEventMapper { message.get("id"), ); } + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "item/permissions/requestApproval" => { + emit_approval_request( + event_tx, + device_id, + local_task_id, + request, + ¬ification.method, + notification.params, + message.get("id"), + ); + } "mcpServer/elicitation/request" => { if let Some(params) = mcp_server_elicitation_request_user_input_params(notification.params) @@ -1292,6 +1305,48 @@ fn emit_request_user_input( ); } +fn emit_approval_request( + event_tx: &Option>, + device_id: &str, + local_task_id: &str, + request: &ExecutionRequest, + method: &str, + params: &Value, + message_request_id: Option<&Value>, +) { + let request_id = message_request_id.and_then(value_identifier); + let item_id = params + .get("itemId") + .or_else(|| params.get("item_id")) + .and_then(Value::as_str) + .unwrap_or("approval"); + let mut render_payload = params.clone(); + if let Some(object) = render_payload.as_object_mut() { + object.insert("kind".to_owned(), Value::String("approval".to_owned())); + object.insert("method".to_owned(), Value::String(method.to_owned())); + if let Some(request_id) = &request_id { + object.insert("requestId".to_owned(), Value::String(request_id.clone())); + } + } + emit_response_event( + event_tx, + device_id, + "response.block.created", + local_task_id, + request, + json!({ + "block": { + "id": format!("approval-{}", request_id.as_deref().unwrap_or(item_id)), + "type": "tool", + "tool_name": "approval", + "status": "pending", + "timestamp": now_ms(), + "render_payload": render_payload, + } + }), + ); +} + fn value_identifier(value: &Value) -> Option { match value { Value::String(value) if !value.trim().is_empty() => Some(value.clone()), @@ -3273,6 +3328,39 @@ mod tests { assert_eq!(block["render_payload"]["questions"][0]["id"], "goal"); } + #[test] + fn maps_codex_approval_to_interactive_tool_block() { + let (event_tx, mut event_rx) = broadcast::channel(4); + let request = ExecutionRequest::default(); + + map_codex_notification( + &Some(event_tx), + "device-1", + "local-1", + &request, + json!({ + "id": 17, + "method": "item/commandExecution/requestApproval", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "item-1", + "command": "git push", + "reason": "Requires network access" + } + }), + ); + + let event = event_rx + .try_recv() + .expect("approval event should be emitted"); + let block = &event["payload"]["data"]["block"]; + assert_eq!(block["id"], "approval-17"); + assert_eq!(block["tool_name"], "approval"); + assert_eq!(block["render_payload"]["kind"], "approval"); + assert_eq!(block["render_payload"]["command"], "git push"); + } + #[test] fn maps_codex_exec_output_delta_to_tool_output_update() { let (event_tx, mut event_rx) = broadcast::channel(4); diff --git a/executor/src/runtime_work/handler.rs b/executor/src/runtime_work/handler.rs index f47055bcc2..0196135400 100644 --- a/executor/src/runtime_work/handler.rs +++ b/executor/src/runtime_work/handler.rs @@ -23,10 +23,10 @@ use tokio::time::sleep; use crate::{ agents::{ - combined_codex_developer_instructions, strip_wework_browser_instructions, + combined_codex_developer_instructions, strip_wework_developer_instructions, CodexActiveTurnCallback, CodexActiveTurnFinishedCallback, CodexAppServerClient, - CodexAppServerTurnOptions, CodexRequestUserInputReceiver, CodexThreadStartedCallback, - CODEX_APP_SERVER_TURN_CANCELLED, + CodexAppServerTurnOptions, CodexPermissionMode, CodexRequestUserInputReceiver, + CodexThreadStartedCallback, CODEX_APP_SERVER_TURN_CANCELLED, }, local::app_ipc::{AppIpcError, RuntimeWorkHandler}, logging::{log_executor_event, wework_debug_log}, @@ -328,6 +328,7 @@ impl RuntimeWorkRpcHandler { "runtime.tasks.transcript" => self.transcript(payload).await, "runtime.tasks.create" => self.create_task(payload).await, "runtime.tasks.send" => self.send_message(payload).await, + "runtime.tasks.permissions.update" => self.update_task_permissions(payload).await, "runtime.tasks.interrupt_and_send" => self.interrupt_and_send(payload).await, "runtime.tasks.rollback" => self.rollback_task(payload).await, "runtime.tasks.guidance" => self.send_guidance(payload).await, @@ -819,7 +820,8 @@ impl RuntimeWorkRpcHandler { .get("developer_instructions") .and_then(Value::as_str) .unwrap_or_default(); - let user_developer_instructions = strip_wework_browser_instructions(developer_instructions); + let user_developer_instructions = + strip_wework_developer_instructions(developer_instructions); let legacy_instructions = config .get("instructions") .and_then(Value::as_str) @@ -1750,6 +1752,7 @@ impl RuntimeWorkRpcHandler { workspace_path.clone(), title.clone(), ); + link.permission_mode = request_permission_mode(&request, None); link.ephemeral = request.ephemeral || bool_field(&payload, "ephemeral").unwrap_or(false); set_runtime_handle_model_selection(&mut link.runtime_handle, &payload); if let Some(message) = cached_user_message(&local_task_id, &request, &payload) { @@ -1831,6 +1834,11 @@ impl RuntimeWorkRpcHandler { .send_request_user_input_response(&local_task_id, response) .await; } + if let Some(response) = approval_response(&payload) { + return self + .send_request_user_input_response(&local_task_id, response) + .await; + } if existing_link .as_ref() .is_some_and(|link| link.running && self.is_active_local_task(&link.local_task_id)) @@ -1862,6 +1870,16 @@ impl RuntimeWorkRpcHandler { .ok_or_else(|| AppIpcError::new("bad_request", "executionRequest is required"))?; apply_runtime_payload_metadata(&mut request, &payload); request.new_session = false; + let permission_mode = request_permission_mode( + &request, + existing_link + .as_ref() + .map(|link| link.permission_mode.as_str()), + ); + request.extra.insert( + "permission_mode".to_owned(), + Value::String(permission_mode.clone()), + ); Self::log_execution_request_summary("runtime.tasks.send", &request); if request.project_workspace_path.is_none() && !workspace_path.is_empty() { request.project_workspace_path = Some(workspace_path.clone()); @@ -1914,6 +1932,9 @@ impl RuntimeWorkRpcHandler { &request, &payload, ); + self.store.update_task(&local_task_id, |link| { + link.permission_mode = permission_mode; + }); self.schedule_worktree_prune(); let link_for_send = existing_link.as_ref().or(recovered_link.as_ref()); let ephemeral = request.ephemeral || link_for_send.is_some_and(|link| link.ephemeral); @@ -1940,6 +1961,41 @@ impl RuntimeWorkRpcHandler { })) } + async fn update_task_permissions(&self, payload: Value) -> Result { + let local_task_id = runtime_task_id(&payload) + .ok_or_else(|| AppIpcError::new("bad_request", "taskId is required"))?; + let requested_mode = string_field(&payload, "permissionMode") + .or_else(|| string_field(&payload, "permission_mode")); + let mode = CodexPermissionMode::from_value(requested_mode.as_deref()); + let mode_label = mode.as_str().to_owned(); + let link = self + .store + .update_task(&local_task_id, |link| { + link.permission_mode = mode_label.clone() + }) + .ok_or_else(|| AppIpcError::new("not_found", "runtime task not found"))?; + if let Some(thread_id) = runtime_session_id_from_link(&link) { + self.call_codex_thread_method_without_list_invalidation( + "thread/settings/update", + json!({ + "threadId": thread_id, + "permissions": mode.permission_profile(), + "approvalPolicy": mode.approval_policy(), + "approvalsReviewer": mode.approvals_reviewer(), + }), + ) + .await + .map_err(|error| AppIpcError::new("codex_error", error))?; + } + Ok(json!({ + "success": true, + "accepted": true, + "taskId": local_task_id, + "permissionMode": mode_label, + "effective": if link.running { "next_turn" } else { "effective" }, + })) + } + async fn interrupt_and_send(&self, payload: Value) -> Result { let local_task_id = runtime_task_id(&payload) .ok_or_else(|| AppIpcError::new("bad_request", "taskId is required"))?; @@ -3998,6 +4054,27 @@ fn request_user_input_response(payload: &Value) -> Option { .cloned() } +fn approval_response(payload: &Value) -> Option { + payload + .get("approvalResponse") + .or_else(|| payload.get("approval_response")) + .filter(|value| value.is_object()) + .cloned() +} + +fn request_permission_mode(request: &ExecutionRequest, fallback: Option<&str>) -> String { + CodexPermissionMode::from_value( + request + .extra + .get("permission_mode") + .or_else(|| request.extra.get("permissionMode")) + .and_then(Value::as_str) + .or(fallback), + ) + .as_str() + .to_owned() +} + fn empty_request_user_input_response() -> Value { json!({ "answers": {} }) } @@ -5701,6 +5778,20 @@ mod tests { assert_eq!(content_message["content"], "visible content text"); } + #[test] + fn request_permission_mode_uses_existing_mode_when_request_omits_it() { + let request = ExecutionRequest::default(); + + assert_eq!( + request_permission_mode(&request, Some("request_approval")), + "request_approval" + ); + assert_eq!( + request_permission_mode(&request, Some("unsupported")), + "full_access" + ); + } + #[test] fn cached_user_message_does_not_fallback_to_prompt() { let request = ExecutionRequest { @@ -5825,9 +5916,10 @@ mod tests { let combined = combined_codex_developer_instructions("用中文回复"); assert!(combined.contains("用中文回复")); + assert!(combined.contains("retry that necessary action once")); assert!(combined.contains("browser_navigate")); assert!(combined.contains("Wework built-in browser")); - assert_eq!(strip_wework_browser_instructions(&combined), "用中文回复"); + assert_eq!(strip_wework_developer_instructions(&combined), "用中文回复"); } #[tokio::test] diff --git a/executor/src/runtime_work/response.rs b/executor/src/runtime_work/response.rs index 361fb3ba2f..62e628c393 100644 --- a/executor/src/runtime_work/response.rs +++ b/executor/src/runtime_work/response.rs @@ -36,6 +36,7 @@ pub(crate) struct RuntimeTaskLink { pub runtime_handle: Value, pub parent: Option, pub ephemeral: bool, + pub permission_mode: String, #[serde(skip)] pub list_order: Option, #[serde(skip)] @@ -66,6 +67,7 @@ impl RuntimeTaskLink { runtime_handle: json!({}), parent: None, ephemeral: false, + permission_mode: "full_access".to_owned(), list_order: None, group_workspace_path: None, group_project_key: None, @@ -98,6 +100,7 @@ impl RuntimeTaskLink { runtime_handle, parent: Some(parent), ephemeral: false, + permission_mode: "full_access".to_owned(), list_order: None, group_workspace_path: None, group_project_key: None, @@ -168,6 +171,10 @@ impl RuntimeTaskLink { .unwrap_or_else(|| json!({})), parent: local_link.as_ref().and_then(|link| link.parent.clone()), ephemeral: local_link.as_ref().is_some_and(|link| link.ephemeral), + permission_mode: local_link + .as_ref() + .map(|link| link.permission_mode.clone()) + .unwrap_or_else(|| "full_access".to_owned()), list_order: None, group_workspace_path: None, group_project_key: None, @@ -193,6 +200,7 @@ impl RuntimeTaskLink { runtime_handle: Value::Object(runtime_handle_list_summary_map(&self.runtime_handle)), parent: self.parent.clone(), ephemeral: self.ephemeral, + permission_mode: self.permission_mode.clone(), list_order: self.list_order, group_workspace_path: self.group_workspace_path.clone(), group_project_key: self.group_project_key.clone(), @@ -241,6 +249,7 @@ impl Default for RuntimeTaskLink { runtime_handle: json!({}), parent: None, ephemeral: false, + permission_mode: "full_access".to_owned(), list_order: None, group_workspace_path: None, group_project_key: None, @@ -523,6 +532,10 @@ fn local_task_json(link: RuntimeTaskLink) -> Value { ); task.insert("title".to_owned(), Value::String(link.title)); task.insert("runtime".to_owned(), Value::String(link.runtime)); + task.insert( + "permissionMode".to_owned(), + Value::String(link.permission_mode.clone()), + ); task.insert( "workspaceKind".to_owned(), Value::String(infer_workspace_kind(&link.workspace_path).to_owned()), diff --git a/executor/src/runtime_work/worktrees.rs b/executor/src/runtime_work/worktrees.rs index d3fdc7ac8e..592485b1fd 100644 --- a/executor/src/runtime_work/worktrees.rs +++ b/executor/src/runtime_work/worktrees.rs @@ -970,6 +970,7 @@ mod tests { runtime_handle: Value::Null, parent: None, ephemeral: false, + permission_mode: "full_access".to_owned(), list_order: None, group_workspace_path: None, group_project_key: None, diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index cc156dc708..07cfb35dcf 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -17,6 +17,19 @@ const TASK_PROMPT = 'WEWORK_DESKTOP_E2E_TASK: create the requested verification const COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_COMPLETE' const FOLLOW_UP_PROMPT = 'WEWORK_DESKTOP_E2E_FOLLOW_UP: confirm the completed task.' const FOLLOW_UP_COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_FOLLOW_UP_COMPLETE' +const PERMISSION_PROMPTS = { + request_approval_accept: 'WEWORK_DESKTOP_E2E_PERMISSION_USER_ACCEPT: run the host query.', + request_approval_decline: 'WEWORK_DESKTOP_E2E_PERMISSION_USER_DECLINE: run the host query.', + approve_for_me_initial: 'WEWORK_DESKTOP_E2E_PERMISSION_AI_INITIAL: run the host query.', + approve_for_me_follow_up: 'WEWORK_DESKTOP_E2E_PERMISSION_AI_FOLLOW_UP: run the host query.', +} +const PERMISSION_COMPLETIONS = { + request_approval_accept: 'WEWORK_DESKTOP_E2E_PERMISSION_USER_ACCEPT_COMPLETE', + request_approval_decline: 'WEWORK_DESKTOP_E2E_PERMISSION_USER_DECLINE_COMPLETE', + approve_for_me_initial: 'WEWORK_DESKTOP_E2E_PERMISSION_AI_INITIAL_COMPLETE', + approve_for_me_follow_up: 'WEWORK_DESKTOP_E2E_PERMISSION_AI_FOLLOW_UP_COMPLETE', +} +const PERMISSION_ESCALATION_INSTRUCTION = 'retry that necessary action once' const REQUEST_USER_INPUT_PROMPT = 'WEWORK_DESKTOP_E2E_REQUEST_INPUT: ask which implementation direction to use.' const REQUEST_USER_INPUT_QUESTION = 'Which implementation direction should be used?' @@ -146,6 +159,7 @@ async function appendProcessOutput(stream, destination) { async function sendPrompt(control, selector, prompt) { await control.command('fill', selector, { value: prompt }) + await control.command('waitFor', selector, { text: prompt, timeoutMs: UI_TIMEOUT_MS }) await control.command('clickWhenEnabled', ACTIVE_SEND_BUTTON_SELECTOR, { stableMs: COMPOSER_READY_STABILITY_MS, timeoutMs: UI_TIMEOUT_MS, @@ -214,6 +228,9 @@ async function selectE2EModel(control, modelId = MODEL_ID, modelLabel = MODEL_LA stableMs: COMPOSER_READY_STABILITY_MS, timeoutMs: UI_TIMEOUT_MS, }) + await control.command('waitFor', '[data-testid="model-control-menu-model"]', { + timeoutMs: UI_TIMEOUT_MS, + }) await control.command('hover', '[data-testid="model-control-menu-model"]', { timeoutMs: UI_TIMEOUT_MS, }) @@ -230,6 +247,13 @@ async function selectE2EModel(control, modelId = MODEL_ID, modelLabel = MODEL_LA }) } +async function selectPermissionMode(control, mode) { + const selector = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="codex-permission-mode-selector"]` + await control.command('waitFor', selector, { timeoutMs: UI_TIMEOUT_MS }) + const selected = await control.command('selectValue', selector, { value: mode }) + assert.equal(selected, mode, `Permission selector did not switch to ${mode}`) +} + function createSse(events) { return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('') } @@ -380,6 +404,30 @@ function selectShellTool(request, workspacePath) { throw new Error('Real Codex did not advertise a supported shell tool') } +function selectEscalatedShellTool(request, workspacePath) { + const command = 'ps -Ao pid,command | head -n 3' + const tools = Array.isArray(request.tools) ? request.tools : [] + if (tools.some(tool => tool?.name === 'exec_command')) { + return selectTool(request, 'exec_command', { + cmd: command, + workdir: workspacePath, + yield_time_ms: 1000, + sandbox_permissions: 'require_escalated', + justification: 'Allow the desktop E2E host process query?', + }) + } + if (tools.some(tool => tool?.name === 'shell_command')) { + return selectTool(request, 'shell_command', { + command, + workdir: workspacePath, + timeout_ms: 10_000, + sandbox_permissions: 'require_escalated', + justification: 'Allow the desktop E2E host process query?', + }) + } + throw new Error('Real Codex did not advertise a supported shell tool') +} + function selectApplyPatchTool(request) { const tools = Array.isArray(request.tools) ? request.tools : [] assert.ok( @@ -433,6 +481,7 @@ class DesktopE2EServer { }) this.scenarioRequests = new Map() this.scenarioWaiters = new Map() + this.permissionScenarioStages = new Map() } async start() { @@ -515,6 +564,7 @@ class DesktopE2EServer { 'cancellation', 'retry', 'fresh_chat', + ...Object.keys(PERMISSION_PROMPTS), ].includes(scenario), `Unknown desktop E2E scenario: ${scenario}` ) @@ -751,6 +801,74 @@ class DesktopE2EServer { return } + if (Object.hasOwn(PERMISSION_PROMPTS, this.scenario)) { + const scenario = this.scenario + this.recordScenarioRequest(scenario, modelRequest) + const stage = this.permissionScenarioStages.get(scenario) ?? 'request_tool' + if (stage === 'request_tool') { + assert.ok( + JSON.stringify(body).includes(PERMISSION_PROMPTS[scenario]), + `The real Codex request did not contain the ${scenario} permission prompt` + ) + assert.ok( + JSON.stringify(body).includes(PERMISSION_ESCALATION_INSTRUCTION), + 'The real Codex request did not instruct the model to request narrowly scoped approval after a required sandbox denial' + ) + const tool = selectEscalatedShellTool(body, this.workspacePath) + this.permissionScenarioStages.set(scenario, 'awaiting_tool_output') + this.writeSse(response, [ + responseCreated(responseId), + functionCall(`wework-e2e-${scenario}`, tool.name, tool.arguments), + responseCompleted(responseId), + ]) + return + } + if (!requestContainsToolOutput(body)) { + assert.match( + scenario, + /^approve_for_me_/, + `User approval scenario ${scenario} unexpectedly invoked automatic review` + ) + assert.ok( + JSON.stringify(body).includes('risk_level'), + `The ${scenario} intermediate request was not an automatic approval review` + ) + this.writeSse(response, [ + responseCreated(responseId), + assistantMessage( + JSON.stringify({ + risk_level: 'low', + user_authorization: 'high', + outcome: 'allow', + rationale: 'The E2E host query is read-only and explicitly requested.', + }) + ), + responseCompleted(responseId), + ]) + return + } + assert.equal( + requestContainsToolOutput(body), + true, + `The ${scenario} permission decision did not return tool output to Codex` + ) + if (scenario === 'request_approval_decline') { + const toolOutput = JSON.stringify(body.input).toLowerCase() + assert.match( + toolOutput, + /declin|denied|reject|not approved|permission/, + 'The rejected approval did not return a denial result to Codex' + ) + } + this.permissionScenarioStages.set(scenario, 'complete') + this.writeSse(response, [ + responseCreated(responseId), + assistantMessage(PERMISSION_COMPLETIONS[scenario]), + responseCompleted(responseId), + ]) + return + } + if (this.scenario === 'request_user_input') { this.recordScenarioRequest('request_user_input', modelRequest) if (JSON.stringify(body.input).includes('wework-e2e-request-user-input')) { @@ -1132,6 +1250,7 @@ async function main() { }) await selectE2EModel(control) + await selectPermissionMode(control, 'full_access') phase = 'initial-task' await sendPrompt(control, composerSelector, TASK_PROMPT) await control.command('waitFor', '[data-testid="environment-info-button"]', { @@ -1156,6 +1275,7 @@ async function main() { text: COMPLETION_TEXT, timeoutMs: UI_TIMEOUT_MS, }) + await captureVerificationScreenshot(control, 'permission-01-full-access-initial.png') await control.command('click', '[data-testid="final-processing-toggle"]') await control.command('waitFor', '[data-testid="processing-summary-toggle"]', { timeoutMs: UI_TIMEOUT_MS, @@ -1267,10 +1387,110 @@ async function main() { text: FOLLOW_UP_COMPLETION_TEXT, timeoutMs: UI_TIMEOUT_MS, }) + await captureVerificationScreenshot(control, 'permission-02-full-access-follow-up.png') assert.ok( JSON.stringify(followUpRequest.body).includes(FOLLOW_UP_PROMPT), 'The follow-up request did not preserve the user prompt' ) + const fullAccessSnapshot = JSON.parse(await control.command('snapshot', 'body')) + assert.equal( + fullAccessSnapshot.testIds.includes('runtime-approval-card'), + false, + 'Full access unexpectedly requested user approval' + ) + + phase = 'request-approval-initial-accept' + await control.command('click', '[data-testid="new-chat-button"]') + await control.command('waitFor', composerSelector, { timeoutMs: WORKBENCH_READY_TIMEOUT_MS }) + await selectE2EModel(control) + await selectPermissionMode(control, 'request_approval') + control.setScenario('request_approval_accept') + await sendPromptUntilScenarioRequest( + control, + composerSelector, + PERMISSION_PROMPTS.request_approval_accept, + 'request_approval_accept' + ) + await control.command('waitFor', '[data-testid="runtime-approval-card"]', { + visible: true, + timeoutMs: UI_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'permission-03-user-approval-pending.png') + await control.command('click', '[data-testid="runtime-approval-accept-button"]') + await control.command('waitFor', '[data-testid="message-assistant"]', { + text: PERMISSION_COMPLETIONS.request_approval_accept, + timeoutMs: UI_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'permission-04-user-approval-accepted.png') + + phase = 'request-approval-follow-up-decline' + control.setScenario('request_approval_decline') + await sendPromptUntilScenarioRequest( + control, + composerSelector, + PERMISSION_PROMPTS.request_approval_decline, + 'request_approval_decline' + ) + await control.command('waitFor', '[data-testid="runtime-approval-card"]', { + visible: true, + timeoutMs: UI_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'permission-05-user-approval-reject-pending.png') + await control.command('click', '[data-testid="runtime-approval-decline-button"]') + await control.command('waitFor', '[data-testid="message-assistant"]', { + text: PERMISSION_COMPLETIONS.request_approval_decline, + timeoutMs: UI_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'permission-06-user-approval-rejected.png') + + phase = 'approve-for-me-initial' + await control.command('click', '[data-testid="new-chat-button"]') + await control.command('waitFor', composerSelector, { timeoutMs: WORKBENCH_READY_TIMEOUT_MS }) + await selectE2EModel(control) + await selectPermissionMode(control, 'approve_for_me') + control.setScenario('approve_for_me_initial') + await sendPromptUntilScenarioRequest( + control, + composerSelector, + PERMISSION_PROMPTS.approve_for_me_initial, + 'approve_for_me_initial' + ) + await control.command('waitFor', '[data-testid="message-assistant"]', { + text: PERMISSION_COMPLETIONS.approve_for_me_initial, + timeoutMs: UI_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'permission-07-ai-approval-initial.png') + let approveForMeSnapshot = JSON.parse(await control.command('snapshot', 'body')) + assert.equal( + approveForMeSnapshot.testIds.includes('runtime-approval-card'), + false, + 'AI auto approval unexpectedly rendered a user approval card' + ) + + phase = 'approve-for-me-follow-up' + control.setScenario('approve_for_me_follow_up') + await sendPromptUntilScenarioRequest( + control, + composerSelector, + PERMISSION_PROMPTS.approve_for_me_follow_up, + 'approve_for_me_follow_up' + ) + await control.command('waitFor', '[data-testid="message-assistant"]', { + text: PERMISSION_COMPLETIONS.approve_for_me_follow_up, + timeoutMs: UI_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'permission-08-ai-approval-follow-up.png') + approveForMeSnapshot = JSON.parse(await control.command('snapshot', 'body')) + assert.equal( + approveForMeSnapshot.testIds.includes('runtime-approval-card'), + false, + 'AI auto approval follow-up unexpectedly rendered a user approval card' + ) + await control.command('click', `[data-testid="${taskRowTestId}"]`) + await control.command('waitFor', '[data-testid="model-selector-button"]', { + text: MODEL_LABEL, + timeoutMs: UI_TIMEOUT_MS, + }) } phase = 'background-request-user-input' @@ -1475,6 +1695,11 @@ async function main() { ) console.log(`Wework desktop task-flow E2E passed. Diagnostics: ${resultDir}`) } catch (error) { + await writeFile( + join(resultDir, 'model-requests.json'), + `${JSON.stringify(control.modelRequests, null, 2)}\n`, + 'utf8' + ) await writeFile( join(resultDir, 'scenario-state.json'), `${JSON.stringify( diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index d5317c95bf..7a782e28ed 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -374,6 +374,8 @@ fn env_flag_enabled(key: &str) -> bool { #[derive(Clone, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] struct AppPreferences { + #[serde(default = "default_codex_permission_mode")] + default_codex_permission_mode: String, #[serde(default = "default_true")] close_to_tray_enabled: bool, #[serde(default = "default_true")] @@ -460,10 +462,16 @@ fn default_browser_local_link_target() -> String { "wework".to_string() } +#[cfg(desktop)] +fn default_codex_permission_mode() -> String { + "full_access".to_string() +} + #[cfg(desktop)] impl Default for AppPreferences { fn default() -> Self { Self { + default_codex_permission_mode: default_codex_permission_mode(), close_to_tray_enabled: true, show_main_window_on_launch: true, close_to_tray_hint_seen: false, @@ -488,6 +496,7 @@ impl Default for AppPreferences { #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct AppPreferencesPatch { + default_codex_permission_mode: Option, close_to_tray_enabled: Option, show_main_window_on_launch: Option, close_to_tray_hint_seen: Option, @@ -640,6 +649,11 @@ fn read_app_preferences_impl(app: &tauri::AppHandle) -> Ap #[cfg(desktop)] fn normalize_app_preferences(mut preferences: AppPreferences) -> AppPreferences { + preferences.default_codex_permission_mode = + match preferences.default_codex_permission_mode.as_str() { + "request_approval" | "approve_for_me" => preferences.default_codex_permission_mode, + _ => default_codex_permission_mode(), + }; preferences.browser_external_link_target = normalized_browser_link_target( preferences.browser_external_link_target, &default_browser_external_link_target(), @@ -869,6 +883,9 @@ fn update_app_preferences( patch: AppPreferencesPatch, ) -> Result { let mut preferences = read_app_preferences_impl(&app); + if let Some(value) = patch.default_codex_permission_mode { + preferences.default_codex_permission_mode = value; + } if let Some(value) = patch.close_to_tray_enabled { preferences.close_to_tray_enabled = value; } @@ -926,6 +943,7 @@ fn update_app_preferences( #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] struct AppPreferences { + default_codex_permission_mode: String, close_to_tray_enabled: bool, show_main_window_on_launch: bool, close_to_tray_hint_seen: bool, @@ -948,6 +966,7 @@ struct AppPreferences { #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct AppPreferencesPatch { + default_codex_permission_mode: Option, close_to_tray_enabled: Option, show_main_window_on_launch: Option, close_to_tray_hint_seen: Option, @@ -970,6 +989,7 @@ struct AppPreferencesPatch { #[tauri::command] fn get_app_preferences(_app: tauri::AppHandle) -> Result { Ok(AppPreferences { + default_codex_permission_mode: "full_access".to_string(), close_to_tray_enabled: true, show_main_window_on_launch: true, close_to_tray_hint_seen: false, @@ -996,6 +1016,9 @@ fn update_app_preferences( patch: AppPreferencesPatch, ) -> Result { Ok(AppPreferences { + default_codex_permission_mode: patch + .default_codex_permission_mode + .unwrap_or_else(|| "full_access".to_string()), close_to_tray_enabled: patch.close_to_tray_enabled.unwrap_or(true), show_main_window_on_launch: patch.show_main_window_on_launch.unwrap_or(true), close_to_tray_hint_seen: patch.close_to_tray_hint_seen.unwrap_or(false), diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index aa1bdea4fb..36fdf258ad 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -697,6 +697,7 @@ describe('createLocalAppServices', () => { deviceId: 'local-device', workspacePath: '/Users/me/project', taskId: 'task-1', + permissionMode: 'request_approval', }, message: 'continue', clientMessageId: 'runtime-local-pane-1', @@ -731,6 +732,7 @@ describe('createLocalAppServices', () => { deviceId: 'device-uuid', workspacePath: '/Users/me/project', taskId: 'task-1', + permissionMode: 'request_approval', }, message: 'continue', clientMessageId: 'runtime-local-pane-1', @@ -788,6 +790,7 @@ describe('createLocalAppServices', () => { execution_target_type: 'local', workspace_source: 'local_path', new_session: false, + permission_mode: 'request_approval', collaborationMode: 'default', attachments: [ { diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 6ef48b7300..e0f5ab3ff7 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -101,6 +101,7 @@ import { type LocalModelConfig, } from '@/features/model-settings/localModelSettings' import { getLocalProxyUrl } from '@/features/model-settings/localProxySettings' +import { getAppPreferences, type CodexPermissionMode } from '@/tauri/appPreferences' import { createRuntimeChatStream } from '../runtime/runtimeChatStream' import { createLocalAttachmentApi } from './localAttachments' import { LOCAL_USER, saveLocalUserPreferences } from './localSession' @@ -1057,6 +1058,7 @@ interface BuildLocalRuntimeExecutionRequestInput { newSession: boolean clientMessageId?: string ephemeral?: boolean + permissionMode?: CodexPermissionMode } function buildLocalRuntimeExecutionRequest( @@ -1132,6 +1134,7 @@ function buildLocalRuntimeExecutionRequest( task_mode: 'code', attachments: localRuntimeAttachments(input.attachments, subtaskId), reasoning_config: reasoning, + permission_mode: input.permissionMode ?? 'full_access', } } @@ -1237,6 +1240,8 @@ async function createLocalRuntimeTaskPayload( } if (execution) normalizedData.execution = execution const collaborationMode = runtimeCollaborationMode(normalizedData.modelOptions) + const permissionMode = + normalizedData.permissionMode ?? (await getAppPreferences()).defaultCodexPermissionMode const turnSeed = createRuntimeTurnSeed() const payload = { ...normalizedData } as Record @@ -1264,6 +1269,7 @@ async function createLocalRuntimeTaskPayload( newSession: true, clientMessageId: normalizedData.clientMessageId, ephemeral: normalizedData.ephemeral, + permissionMode, }), } as unknown as Record } @@ -1297,7 +1303,12 @@ function createLocalRuntimeSendPayload( ...(workspacePath ? { workspacePath } : {}), } - if (normalizedData.requestUserInputResponse || normalizedData.request_user_input_response) { + if ( + normalizedData.requestUserInputResponse || + normalizedData.request_user_input_response || + normalizedData.approvalResponse || + normalizedData.approval_response + ) { const payload = { ...normalizedData } as Record delete payload.modelId delete payload.modelType @@ -1324,6 +1335,7 @@ function createLocalRuntimeSendPayload( newSession: false, clientMessageId: normalizedData.clientMessageId, ephemeral: data.ephemeral, + permissionMode: normalizedData.permissionMode ?? normalizedAddress.permissionMode, }), } as unknown as Record } @@ -1354,6 +1366,7 @@ function createLocalRuntimeSendPayload( newSession: false, clientMessageId: normalizedData.clientMessageId, ephemeral: data.ephemeral, + permissionMode: normalizedData.permissionMode ?? normalizedAddress.permissionMode, }), } as unknown as Record } diff --git a/wework/src/components/chat/ApprovalCard.test.tsx b/wework/src/components/chat/ApprovalCard.test.tsx new file mode 100644 index 0000000000..dfd95bb0a6 --- /dev/null +++ b/wework/src/components/chat/ApprovalCard.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' +import { ApprovalCard } from './ApprovalCard' + +describe('ApprovalCard', () => { + test('renders a reason-only approval once', () => { + render() + + expect(screen.getAllByText('Allow host CPU query?')).toHaveLength(1) + }) + + test('submits a one-time approval', async () => { + const onSubmit = vi.fn() + render( + + ) + + await userEvent.click(screen.getByTestId('runtime-approval-accept-button')) + expect(onSubmit).toHaveBeenCalledWith({ decision: 'accept' }) + }) + + test('shows a persistent action only for a Codex amendment', async () => { + const onSubmit = vi.fn() + const { rerender } = render( + + ) + expect(screen.queryByTestId('runtime-approval-rule-button')).not.toBeInTheDocument() + + rerender( + + ) + await userEvent.click(screen.getByTestId('runtime-approval-rule-button')) + expect(onSubmit).toHaveBeenCalledWith({ + decision: { + acceptWithExecpolicyAmendment: { + execpolicyAmendment: { command: ['git', 'status'] }, + }, + }, + }) + }) +}) diff --git a/wework/src/components/chat/ApprovalCard.tsx b/wework/src/components/chat/ApprovalCard.tsx new file mode 100644 index 0000000000..c4b2ce3b57 --- /dev/null +++ b/wework/src/components/chat/ApprovalCard.tsx @@ -0,0 +1,135 @@ +import { ShieldAlert } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from '@/hooks/useTranslation' +import type { RuntimeApprovalResponse } from '@/types/api' +import type { RuntimeApprovalPayload } from './runtimeApproval' + +interface ApprovalCardProps { + payload: RuntimeApprovalPayload + disabled?: boolean + onSubmit?: (response: RuntimeApprovalResponse) => void +} + +function approvalDescription(payload: RuntimeApprovalPayload): string { + if (payload.command) return payload.command + if (payload.grantRoot) return payload.grantRoot + if (payload.cwd) return payload.cwd + return '' +} + +export function ApprovalCard({ payload, disabled = false, onSubmit }: ApprovalCardProps) { + const { t } = useTranslation('chat') + const [submitted, setSubmitted] = useState(false) + const locked = disabled || submitted + + const submit = (response: RuntimeApprovalResponse) => { + setSubmitted(true) + onSubmit?.(response) + } + + const amendment = payload.proposedExecpolicyAmendment + const networkAmendment = payload.proposedNetworkPolicyAmendments?.[0] + const permissionRequest = payload.method === 'item/permissions/requestApproval' + + return ( +
+
+ +
+
+ {t('approval.title', '需要批准')} +
+ {payload.reason &&

{payload.reason}

} + {approvalDescription(payload) && ( +
+              {approvalDescription(payload)}
+            
+ )} +
+
+
+ + + {amendment?.command?.length ? ( + + ) : networkAmendment ? ( + + ) : null} + +
+
+ ) +} diff --git a/wework/src/components/chat/ChatInput.test.tsx b/wework/src/components/chat/ChatInput.test.tsx index aaba075e8f..7bbf7ea28e 100644 --- a/wework/src/components/chat/ChatInput.test.tsx +++ b/wework/src/components/chat/ChatInput.test.tsx @@ -310,6 +310,33 @@ describe('ChatInput', () => { expect(setSelectedModelOption).toHaveBeenCalledWith('collaborationMode', 'default') }) + test('orders desktop quick phrases, permissions, and active mode before model controls', () => { + render( + + ) + + const quickPhrase = screen.getByTestId('quick-phrase-button') + const permissions = screen.getByTestId('codex-permission-mode-selector') + const planMode = screen.getByTestId('plan-mode-pill') + const modelSelector = screen.getByTestId('model-selector-button') + + expect(quickPhrase).not.toHaveTextContent('workbench.quick_phrases') + expect(quickPhrase.compareDocumentPosition(permissions)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(permissions.compareDocumentPosition(planMode)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(planMode.compareDocumentPosition(modelSelector)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + test('hides the plan mode pill while goal draft mode is active', () => { render( { expect(screen.queryByTestId('mobile-context-sheet')).not.toBeInTheDocument() }) + test('orders compact quick phrases, permissions, and active mode before the input', () => { + render( + + ) + + const quickPhrase = screen.getByTestId('quick-phrase-button') + const permissions = screen.getByTestId('codex-permission-mode-selector') + const planMode = screen.getByTestId('plan-mode-pill') + const input = screen.getByTestId('compact-input-pill') + + expect(quickPhrase.compareDocumentPosition(permissions)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(permissions.compareDocumentPosition(planMode)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(planMode.compareDocumentPosition(input)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(permissions).toHaveValue('request_approval') + expect(planMode).toHaveClass('h-11') + }) + test('desktop file picker does not restrict attachment file types', async () => { render( void onResumeGoal?: () => void onClearGoal?: () => void + permissionMode?: CodexPermissionMode + onPermissionModeChange?: (mode: CodexPermissionMode) => void } export interface ChatSubmitOptions { @@ -234,6 +237,8 @@ export function ChatInput({ onPauseGoal, onResumeGoal, onClearGoal, + permissionMode, + onPermissionModeChange, }: ChatInputProps) { const { t } = useTranslation('common') const { t: tChat } = useTranslation('chat') @@ -376,6 +381,8 @@ export function ChatInput({ )} void onOpenLocalSkillFile?: (path: string) => void - onRequestUserInputSubmit?: (response: RequestUserInputResponse) => void + onRequestUserInputSubmit?: (response: RuntimeInteractiveResponse) => void onRequestUserInputIgnore?: (payload: RequestUserInputPayload) => void onOpenAssistantPlan?: (request: AssistantPlanOpenRequest) => void onEditLastUserMessage?: ( @@ -1604,7 +1604,7 @@ function AssistantMessage({ }) => void fileChangesDiffPreviewDisabledSubtaskId?: string | null onOpenWorkspaceFile?: (path: string, options?: WorkspaceFileOpenOptions) => void - onRequestUserInputSubmit?: (response: RequestUserInputResponse) => void + onRequestUserInputSubmit?: (response: RuntimeInteractiveResponse) => void onRequestUserInputIgnore?: (payload: RequestUserInputPayload) => void onOpenAssistantPlan?: (request: AssistantPlanOpenRequest) => void onLoadFullTranscript?: () => Promise | void diff --git a/wework/src/components/chat/ScrollableMessageArea.tsx b/wework/src/components/chat/ScrollableMessageArea.tsx index b3710e3248..5f41f7db41 100644 --- a/wework/src/components/chat/ScrollableMessageArea.tsx +++ b/wework/src/components/chat/ScrollableMessageArea.tsx @@ -6,7 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation' import { cn } from '@/lib/utils' import type { DeviceInfo, - RequestUserInputResponse, + RuntimeInteractiveResponse, RuntimeTurnNavigationItem, TurnFileChangesSummary, } from '@/types/api' @@ -77,7 +77,7 @@ interface ScrollableMessageAreaProps { fileChangesDiffPreviewDisabledSubtaskId?: string | null onOpenWorkspaceFile?: (path: string, options?: WorkspaceFileOpenOptions) => void onOpenLocalSkillFile?: (path: string) => void - onRequestUserInputSubmit?: (response: RequestUserInputResponse) => void + onRequestUserInputSubmit?: (response: RuntimeInteractiveResponse) => void onRequestUserInputIgnore?: (payload: RequestUserInputPayload) => void onOpenAssistantPlan?: (request: AssistantPlanOpenRequest) => void onEditLastUserMessage?: ( diff --git a/wework/src/components/chat/blocks/ToolBlocksDisplay.tsx b/wework/src/components/chat/blocks/ToolBlocksDisplay.tsx index c56be7ab84..f470903144 100644 --- a/wework/src/components/chat/blocks/ToolBlocksDisplay.tsx +++ b/wework/src/components/chat/blocks/ToolBlocksDisplay.tsx @@ -12,7 +12,7 @@ import { Wrench, } from 'lucide-react' import { useTranslation } from '@/hooks/useTranslation' -import type { RequestUserInputResponse } from '@/types/api' +import type { RuntimeInteractiveResponse } from '@/types/api' import type { ProcessingBlock, ToolBlock } from '@/types/workbench' import { isAnsweredRequestUserInputBlock, @@ -27,6 +27,8 @@ import { type RequestUserInputPayload, } from '../RequestUserInputCard' import type { AssistantPlanOpenRequest } from '../AssistantPlanCard' +import { ApprovalCard } from '../ApprovalCard' +import { isRuntimeApprovalPayload } from '../runtimeApproval' import { buildProcessingDisplayRows, getToolActivityFilePaths, @@ -52,6 +54,11 @@ type ProcessingDisplayItem = id: string block: RequestUserInputBlock } + | { + type: 'approval' + id: string + block: ToolBlock + } interface ToolBlocksDisplayProps { blocks: ProcessingBlock[] @@ -67,7 +74,7 @@ interface ToolBlocksDisplayProps { showSummary?: boolean stateKey?: string onOpenWorkspaceFile?: (path: string) => void - onRequestUserInputSubmit?: (response: RequestUserInputResponse) => void + onRequestUserInputSubmit?: (response: RuntimeInteractiveResponse) => void onRequestUserInputIgnore?: (payload: RequestUserInputPayload) => void onOpenAssistantPlan?: (request: AssistantPlanOpenRequest) => void onLoadFullTranscript?: () => Promise | void @@ -143,6 +150,11 @@ export function ToolBlocksDisplay({ } blocks.forEach(block => { + if (block.type === 'tool' && isRuntimeApprovalPayload(block.renderPayload)) { + flushRegularBlocks() + items.push({ type: 'approval', id: block.id, block }) + return + } if (!isRequestUserInputBlock(block)) { pendingRegularBlocks.push(block) return @@ -169,12 +181,15 @@ export function ToolBlocksDisplay({ const rows = useMemo( () => displayItems.filter( - (item): item is ProcessingDisplayRow => item.type !== 'request_user_input' + (item): item is ProcessingDisplayRow => + item.type !== 'request_user_input' && item.type !== 'approval' ), [displayItems] ) const hasPlanResponse = blocks.some(block => block.type === 'plan' && block.content.trim()) - const hasRequestUserInput = displayItems.some(item => item.type === 'request_user_input') + const hasRequestUserInput = displayItems.some( + item => item.type === 'request_user_input' || item.type === 'approval' + ) const hasActiveContextCompaction = blocks.some( block => isContextCompactionToolBlock(block) && block.status !== 'done' && block.status !== 'error' @@ -270,6 +285,16 @@ export function ToolBlocksDisplay({ /> ) } + if (item.type === 'approval' && isRuntimeApprovalPayload(item.block.renderPayload)) { + return ( + + ) + } return item.type === 'activity_group' ? ( void + permissionMode?: CodexPermissionMode + onPermissionModeChange?: (mode: CodexPermissionMode) => void } export function CompactChatComposer({ @@ -100,6 +105,8 @@ export function CompactChatComposer({ isModelSelectionReady = true, isStreaming = false, onPause, + permissionMode, + onPermissionModeChange, }: CompactChatComposerProps) { const { t } = useTranslation('common') const imageInputRef = useRef(null) @@ -194,22 +201,8 @@ export function CompactChatComposer({ {disabledReason} )} - {goalDraftActive ? ( - - ) : planModeActive ? ( - - ) : null}
{ event.preventDefault() debugComposerEvent('compact-form-submit', { @@ -234,7 +227,31 @@ export function CompactChatComposer({ > - + + {permissionMode && onPermissionModeChange && ( + + )} + {goalDraftActive ? ( + + ) : planModeActive ? ( + + ) : null}
void onQuickPhraseSelect: (phrase: QuickPhrase) => void + permissionMode?: CodexPermissionMode + onPermissionModeChange?: (mode: CodexPermissionMode) => void onSubmit: (options?: ComposerSubmitOptions) => void } @@ -61,22 +70,34 @@ export function ComposerToolbar({ isStreaming = false, onPause, onQuickPhraseSelect, + permissionMode, + onPermissionModeChange, onSubmit, }: ComposerToolbarProps) { const { t } = useTranslation('common') return ( -
-
+
+
- + + {permissionMode && onPermissionModeChange && ( + + )} {goalDraftActive ? ( - + ) : planModeActive ? ( ) : null}
-
+
) } @@ -96,9 +99,10 @@ export function ComposerModePill({ interface GoalDraftPillProps { onCancel?: () => void className?: string + mobile?: boolean } -export function GoalDraftPill({ onCancel, className = '' }: GoalDraftPillProps) { +export function GoalDraftPill({ onCancel, className = '', mobile = false }: GoalDraftPillProps) { const { t } = useTranslation('common') return ( @@ -110,6 +114,7 @@ export function GoalDraftPill({ onCancel, className = '' }: GoalDraftPillProps) cancelLabel={t('workbench.cancel_goal_draft', '取消目标')} onCancel={onCancel} className={className} + mobile={mobile} title={t('workbench.confirm_goal', '明确目标')} /> ) diff --git a/wework/src/components/chat/composer/PermissionModeSelector.test.tsx b/wework/src/components/chat/composer/PermissionModeSelector.test.tsx new file mode 100644 index 0000000000..315d815953 --- /dev/null +++ b/wework/src/components/chat/composer/PermissionModeSelector.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, test, vi } from 'vitest' +import { PermissionModeSelector } from './PermissionModeSelector' + +describe('PermissionModeSelector', () => { + test('shows the current mode and changes it from the native selector', () => { + const onChange = vi.fn() + render() + + const selector = screen.getByTestId('codex-permission-mode-selector') + expect(selector).toHaveValue('full_access') + + fireEvent.change(selector, { target: { value: 'request_approval' } }) + + expect(onChange).toHaveBeenCalledWith('request_approval') + }) + + test('uses a mobile-sized target without changing its test id', () => { + render() + + const selector = screen.getByTestId('codex-permission-mode-selector') + expect(selector.closest('label')).toHaveClass('h-11') + expect(selector).toHaveAccessibleName() + }) +}) diff --git a/wework/src/components/chat/composer/PermissionModeSelector.tsx b/wework/src/components/chat/composer/PermissionModeSelector.tsx new file mode 100644 index 0000000000..15428fcb86 --- /dev/null +++ b/wework/src/components/chat/composer/PermissionModeSelector.tsx @@ -0,0 +1,51 @@ +import { ShieldCheck } from 'lucide-react' +import { useTranslation } from '@/hooks/useTranslation' +import type { CodexPermissionMode } from '@/types/api' + +interface PermissionModeSelectorProps { + value: CodexPermissionMode + disabled?: boolean + mobile?: boolean + className?: string + onChange: (mode: CodexPermissionMode) => void +} + +const modes: CodexPermissionMode[] = ['full_access', 'request_approval', 'approve_for_me'] + +export function PermissionModeSelector({ + value, + disabled, + mobile = false, + className = '', + onChange, +}: PermissionModeSelectorProps) { + const { t } = useTranslation('common') + const label = t(`workbench.codex_permission_${value}`) + + return ( + + ) +} diff --git a/wework/src/components/chat/composer/ProjectChatComposer.tsx b/wework/src/components/chat/composer/ProjectChatComposer.tsx index 0382db25b0..9794c2a7b2 100644 --- a/wework/src/components/chat/composer/ProjectChatComposer.tsx +++ b/wework/src/components/chat/composer/ProjectChatComposer.tsx @@ -5,6 +5,7 @@ import type { ModelOptions, RuntimeContextUsage, UnifiedModel, + CodexPermissionMode, } from '@/types/api' import type { CodeCommentContext, WorkspaceFileApi, WorkspaceTarget } from '@/types/workspace-files' import { invoke } from '@tauri-apps/api/core' @@ -64,6 +65,8 @@ interface ProjectChatComposerProps { showProjectWorkBar?: boolean isStreaming?: boolean onPause?: () => void + permissionMode?: CodexPermissionMode + onPermissionModeChange?: (mode: CodexPermissionMode) => void } function hasDraggedFiles(dataTransfer: DataTransfer): boolean { @@ -186,6 +189,8 @@ export function ProjectChatComposer({ showProjectWorkBar = true, isStreaming = false, onPause, + permissionMode, + onPermissionModeChange, }: ProjectChatComposerProps) { const formRef = useRef(null) const disabledRef = useRef(disabled) @@ -387,6 +392,8 @@ export function ProjectChatComposer({ isStreaming={isStreaming} onPause={onPause} onQuickPhraseSelect={handleQuickPhraseSelect} + permissionMode={permissionMode} + onPermissionModeChange={onPermissionModeChange} onSubmit={options => onSubmit(value, options)} /> diff --git a/wework/src/components/chat/composer/QuickPhraseMenu.tsx b/wework/src/components/chat/composer/QuickPhraseMenu.tsx index 6acc25ec79..1d3ce78000 100644 --- a/wework/src/components/chat/composer/QuickPhraseMenu.tsx +++ b/wework/src/components/chat/composer/QuickPhraseMenu.tsx @@ -8,10 +8,11 @@ import { useQuickPhrases } from '@/hooks/useQuickPhrases' interface QuickPhraseMenuProps { disabled?: boolean compact?: boolean + mobile?: boolean onSelect: (phrase: QuickPhrase) => void } -export function QuickPhraseMenu({ disabled, compact, onSelect }: QuickPhraseMenuProps) { +export function QuickPhraseMenu({ disabled, compact, mobile, onSelect }: QuickPhraseMenuProps) { const { t } = useTranslation('common') const phrases = useQuickPhrases() const [open, setOpen] = useState(false) @@ -56,7 +57,9 @@ export function QuickPhraseMenu({ disabled, compact, onSelect }: QuickPhraseMenu onClick={() => setOpen(value => !value)} className={ compact - ? 'flex h-11 w-11 items-center justify-center rounded-full text-text-secondary hover:bg-muted disabled:opacity-40' + ? `flex items-center justify-center text-text-secondary hover:bg-muted disabled:opacity-40 ${ + mobile ? 'h-11 w-11 rounded-full' : 'h-8 w-8 rounded-lg' + }` : 'flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-text-secondary hover:bg-muted disabled:opacity-40' } aria-label={t('workbench.quick_phrases', '快捷短语')} diff --git a/wework/src/components/chat/runtimeApproval.ts b/wework/src/components/chat/runtimeApproval.ts new file mode 100644 index 0000000000..743aa0146d --- /dev/null +++ b/wework/src/components/chat/runtimeApproval.ts @@ -0,0 +1,18 @@ +export interface RuntimeApprovalPayload { + kind: 'approval' + method?: string + reason?: string | null + command?: string | null + cwd?: string | null + grantRoot?: string | null + itemId?: string + proposedExecpolicyAmendment?: { command?: string[] } | null + proposedNetworkPolicyAmendments?: Array<{ host: string; action: string }> | null + permissions?: Record +} + +export function isRuntimeApprovalPayload(value: unknown): value is RuntimeApprovalPayload { + return Boolean( + value && typeof value === 'object' && (value as { kind?: unknown }).kind === 'approval' + ) +} diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 5aa199f9b0..71f7c7fbda 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -1671,6 +1671,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ undefined +function isRuntimeApprovalResponse( + response: RequestUserInputResponse | RuntimeApprovalResponse +): response is RuntimeApprovalResponse { + return !('answers' in response) +} + export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSessionOptions) { + const { t } = useTranslation('common') const { state: workbenchState, projectChat, @@ -145,6 +157,37 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes const input = projectChat.input ?? '' const scopedSetInput = projectChat.setInput ?? noopSetInput const [error, setError] = useState(null) + const [permissionMode, setPermissionMode] = useState( + currentRuntimeTask?.permissionMode ?? 'full_access' + ) + const updatePermissionMode = useCallback( + (mode: CodexPermissionMode) => { + const previous = permissionMode + setPermissionMode(mode) + if (!currentRuntimeTask) return + void requestLocalExecutor('runtime.tasks.permissions.update', { + taskId: currentRuntimeTask.taskId, + permissionMode: mode, + }).catch(updateError => { + console.error('[Wework] Failed to update Codex permission mode', updateError) + setPermissionMode(current => (current === mode ? previous : current)) + setError(t('workbench.codex_permission_update_failed')) + }) + }, + [currentRuntimeTask, permissionMode, t] + ) + useEffect(() => { + let active = true + const nextMode = currentRuntimeTask?.permissionMode + ? Promise.resolve(currentRuntimeTask.permissionMode) + : getAppPreferences().then(preferences => preferences.defaultCodexPermissionMode) + void nextMode.then(mode => { + if (active) setPermissionMode(mode) + }) + return () => { + active = false + } + }, [currentRuntimeTask?.permissionMode, currentRuntimeTask?.taskId]) const setInput = useCallback( (value: string) => { scopedSetInput(value) @@ -1037,6 +1080,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes address: currentRuntimeTask, message: message.content, clientMessageId: message.id, + ...(permissionMode !== 'full_access' ? { permissionMode } : {}), ...(message.modelId ? { modelId: message.modelId, @@ -1056,7 +1100,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes } return sent }, - [appendLocalUserMessage, currentRuntimeTask, sendRuntimePaneMessage] + [appendLocalUserMessage, currentRuntimeTask, permissionMode, sendRuntimePaneMessage] ) const interruptAndSendQueuedMessage = useCallback( @@ -1192,13 +1236,14 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes const sendRequestUserInputResponse = useCallback( async ( - response: RequestUserInputResponse, + response: RequestUserInputResponse | RuntimeApprovalResponse, options: SendRequestUserInputResponseOptions = {} ): Promise => { if (!currentRuntimeTask) return false - const message = requestUserInputResponseText(response) - const requestUserInputKey = requestUserInputResponseKey(response) + const isApproval = isRuntimeApprovalResponse(response) + const message = isApproval ? '' : requestUserInputResponseText(response) + const requestUserInputKey = isApproval ? null : requestUserInputResponseKey(response) setSendPhase('submitting') const runtimeModelOverride = options.forceDefaultCollaborationMode ? { collaborationMode: 'default' } @@ -1218,7 +1263,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes return next }) } - applyLocalRequestUserInputResponse(response) + if (!isApproval) applyLocalRequestUserInputResponse(response) const runtimeModelFields = options.appendUserMessage ? getRuntimeModelFields(runtimeModelOverride) : {} @@ -1226,9 +1271,14 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes const sent = await sendRuntimePaneMessage({ address: currentRuntimeTask, message, + ...(permissionMode !== 'full_access' ? { permissionMode } : {}), ...(appendedUserMessage ? { clientMessageId: appendedUserMessage.id } : {}), ...runtimeModelFields, - ...(options.appendUserMessage ? {} : { requestUserInputResponse: response }), + ...(options.appendUserMessage + ? {} + : isApproval + ? { approvalResponse: response } + : { requestUserInputResponse: response }), ...(additionalContext ? { additionalContext } : {}), }) if (sent) { @@ -1252,6 +1302,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes currentRuntimeTask, dispatchMessages, getRuntimeModelFields, + permissionMode, projectChat, sendRuntimePaneMessage, ] @@ -1643,6 +1694,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes const sent = await sendCurrentInput(submittedInput, { clientMessageId: optimisticMessage.id, initialGoal, + permissionMode, onRuntimeTaskOptimisticOpen: (address, context) => { setPendingGoalState(current => current @@ -1729,7 +1781,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes : null const effectiveSubmittedInput = submittedInput || pendingInitialGoal?.objective.trim() || '' if (!effectiveSubmittedInput && currentAttachments.length === 0 && !hasCodeComments) { - void sendCurrentInput('', { codeCommentContexts }) + void sendCurrentInput('', { codeCommentContexts, permissionMode }) return } @@ -1754,6 +1806,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes clientMessageId: optimisticMessage.id, codeCommentContexts, initialGoal: pendingInitialGoal, + permissionMode, onError: setError, onRuntimeTaskOptimisticOpen: (address, context) => { if (pendingInitialGoal) { @@ -1901,6 +1954,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes interruptAndSendQueuedMessage, pendingGoalState, paneStatus.isBusy, + permissionMode, projectChat, queuedMessages.length, sendCurrentInput, @@ -2228,6 +2282,8 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes codeCommentContexts, input, setInput, + permissionMode, + setPermissionMode: updatePermissionMode, error, status: paneStatus, sending: paneStatus.isSubmitting, diff --git a/wework/src/components/settings/GeneralSettingsPage.tsx b/wework/src/components/settings/GeneralSettingsPage.tsx index 088ad9982f..57a4973d0e 100644 --- a/wework/src/components/settings/GeneralSettingsPage.tsx +++ b/wework/src/components/settings/GeneralSettingsPage.tsx @@ -17,6 +17,7 @@ import { type AppLanguagePreference, type AppPreferences, type AppPreferencesPatch, + type CodexPermissionMode, } from '@/tauri/appPreferences' type BooleanPreferenceKey = keyof AppPreferencesPatch @@ -159,6 +160,29 @@ export function GeneralSettingsPage() { } } + const handleCodexPermissionModeChange = async (mode: CodexPermissionMode) => { + if (mode === preferences.defaultCodexPermissionMode) return + const previousMode = preferences.defaultCodexPermissionMode + setPreferences(current => ({ ...current, defaultCodexPermissionMode: mode })) + setSaving(true) + setError(null) + try { + setPreferences(await updateAppPreferences({ defaultCodexPermissionMode: mode })) + } catch (saveError) { + console.error('[Wework] Failed to update default Codex permission mode', saveError) + setPreferences(current => ({ ...current, defaultCodexPermissionMode: previousMode })) + setError(t('workbench.general_settings_save_failed')) + } finally { + setSaving(false) + } + } + + const codexPermissionModes: CodexPermissionMode[] = [ + 'full_access', + 'request_approval', + 'approve_for_me', + ] + return ( } /> + + {codexPermissionModes.map(mode => { + const active = preferences.defaultCodexPermissionMode === mode + return ( + + ) + })} +
+ } + /> {renderSwitchRow({ preferenceKey: 'showMainWindowOnLaunch', testId: 'general-show-main-window-on-launch-toggle', diff --git a/wework/src/e2e/automation.ts b/wework/src/e2e/automation.ts index cf8ac04ac4..264708bd2c 100644 --- a/wework/src/e2e/automation.ts +++ b/wework/src/e2e/automation.ts @@ -36,6 +36,7 @@ type DesktopControlAction = | 'waitFor' | 'press' | 'selectText' + | 'selectValue' interface DesktopControlCommand { id: string @@ -416,6 +417,33 @@ function fillDesktopControlElement(element: HTMLElement, value: string) { return } + if (element.isContentEditable) { + element.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'KeyA', + key: 'a', + metaKey: true, + }) + ) + element.dispatchEvent( + new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + code: 'KeyA', + key: 'a', + metaKey: true, + }) + ) + const clipboardData = new DataTransfer() + clipboardData.setData('text/plain', value) + element.dispatchEvent( + new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData }) + ) + return + } + const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(element) @@ -519,6 +547,15 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro } case 'selectText': return selectDesktopControlText(command.selector, command.value ?? '') + case 'selectValue': { + const element = findDesktopControlElements(command.selector)[0] + if (!(element instanceof HTMLSelectElement)) { + throw new Error(`Selector "${command.selector}" is not a select element`) + } + element.value = command.value ?? '' + element.dispatchEvent(new Event('change', { bubbles: true })) + return element.value + } } } diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index ab54abfd02..9ec3287a87 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -1391,6 +1391,7 @@ function FollowUpProbe() { {workbench.projectChat.selectedModelOptions.collaborationMode ?? 'default'} + {paneSession.permissionMode} {paneSession.guidanceMessages .map(message => `${message.status}:${message.content}`) @@ -1400,6 +1401,19 @@ function FollowUpProbe() { {paneSession.messages.map(message => `${message.role}:${message.content}`).join('|')} {paneSession.status.isBusy ? 'busy' : 'idle'} + @@ -2734,6 +2748,7 @@ describe('WorkbenchProvider runtime tasks', () => { deviceId: 'device-1', workspacePath: '/workspace/project-alpha', taskId: request.taskId, + permissionMode: 'full_access', limit: 50, }) expect(parseRuntimeTaskRoute(window.location.pathname, window.location.search)).toEqual({ @@ -6389,6 +6404,79 @@ describe('WorkbenchProvider runtime tasks', () => { ) }) + test('preserves the permission mode when sending a runtime follow-up', async () => { + const sendRuntimeMessage = vi.fn().mockResolvedValue({ + accepted: true, + taskId: 'runtime-a', + }) + const runtimeWorkApi = createRuntimeWorkApiMock({ + listRuntimeWork: vi.fn().mockResolvedValue( + createRuntimeWork({ + projects: [ + { + project: { id: 7, name: 'Wegent' }, + deviceWorkspaces: [ + { + id: 22, + projectId: 7, + deviceId: 'device-1', + deviceName: 'Project Device', + deviceStatus: 'online', + workspacePath: '/workspace/project-alpha', + mapped: true, + available: true, + tasks: [ + { + taskId: 'runtime-a', + workspacePath: '/workspace/project-alpha', + title: 'Runtime A', + runtime: 'codex', + permissionMode: 'request_approval', + }, + ], + }, + ], + }, + ], + totalTasks: 1, + }) + ), + getRuntimeTranscript: vi.fn().mockResolvedValue({ + taskId: 'runtime-a', + workspacePath: '/workspace/project-alpha', + runtime: 'codex', + messages: [{ id: 'runtime-a:assistant:1', role: 'assistant', content: 'done' }], + }), + sendRuntimeMessage, + }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + }) + + renderWorkbench( + <> + + + , + services + ) + + await userEvent.click(await screen.findByText('open approval runtime a')) + await waitFor(() => + expect(screen.getByTestId('follow-up-permission-mode')).toHaveTextContent('request_approval') + ) + await userEvent.click(screen.getByText('set follow-up')) + await userEvent.click(screen.getByText('send follow-up')) + + await waitFor(() => expect(sendRuntimeMessage).toHaveBeenCalledTimes(1)) + expect(sendRuntimeMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message: '继续修', + permissionMode: 'request_approval', + }) + ) + }) + test('queues runtime messages while current response is running', async () => { let streamHandlers: ChatStreamHandlers = {} const subscribe = vi.fn((handlers: ChatStreamHandlers) => { diff --git a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts index 5a56609608..bca5e48517 100644 --- a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts +++ b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts @@ -469,7 +469,11 @@ export function useWorkbenchRuntimeMessaging({ activeDeviceId?: string, options?: Pick< SendCurrentInputOptions, - 'clientMessageId' | 'initialGoal' | 'onError' | 'onRuntimeTaskOptimisticOpen' + | 'clientMessageId' + | 'initialGoal' + | 'onError' + | 'onRuntimeTaskOptimisticOpen' + | 'permissionMode' > & { ephemeral?: boolean openInMainPane?: boolean @@ -579,6 +583,7 @@ export function useWorkbenchRuntimeMessaging({ attachmentIds: payload.attachment_ids ?? [], attachments: payload.attachments ?? [], execution: payload.execution, + ...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}), ...(options?.ephemeral ? { ephemeral: true } : {}), ...(options?.sideSource ? { sideSource: options.sideSource } : {}), ...(options?.initialGoal ? { initialGoal: options.initialGoal } : {}), @@ -597,6 +602,7 @@ export function useWorkbenchRuntimeMessaging({ const optimisticAddress: RuntimeTaskAddress = { deviceId: optimisticDeviceId, taskId, + ...(createRequest.permissionMode ? { permissionMode: createRequest.permissionMode } : {}), workspacePath: 'workspacePath' in runtimeTaskTarget ? runtimeTaskTarget.workspacePath : undefined, ...(createRuntimeHandle ? { runtimeHandle: createRuntimeHandle } : {}), @@ -662,6 +668,7 @@ export function useWorkbenchRuntimeMessaging({ taskId: response.taskId || optimisticAddress.taskId, workspacePath: response.workspacePath || optimisticAddress.workspacePath, runtimeHandle: response.runtimeHandle ?? optimisticAddress.runtimeHandle, + ...(createRequest.permissionMode ? { permissionMode: createRequest.permissionMode } : {}), ...(response.taskId || optimisticAddress.taskId ? { taskId: response.taskId || optimisticAddress.taskId } : {}), @@ -829,6 +836,7 @@ export function useWorkbenchRuntimeMessaging({ message: payloadMessage, ...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...runtimeModelFields, + ...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}), ...(attachmentIds.length > 0 ? { attachmentIds } : {}), ...(attachments.length > 0 ? { attachments } : {}), }, @@ -907,6 +915,7 @@ export function useWorkbenchRuntimeMessaging({ onError: options?.onError, onRuntimeTaskOptimisticOpen: options?.onRuntimeTaskOptimisticOpen, clientMessageId: options?.clientMessageId, + permissionMode: options?.permissionMode, } ) if (sent) { @@ -979,6 +988,9 @@ export function useWorkbenchRuntimeMessaging({ message: previousUserMessage.content, clientMessageId: previousUserMessage.id, ...selectedModelExecutionFields(runtimeSelectedModel, runtimeSelectedModelOptions), + ...(state.currentRuntimeTask.permissionMode + ? { permissionMode: state.currentRuntimeTask.permissionMode } + : {}), ...(attachmentIds.length > 0 ? { attachmentIds } : {}), ...(attachments.length > 0 ? { attachments } : {}), }) diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 1dbaa7e3ef..e3f159ccb9 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -4,6 +4,7 @@ import type { BindRuntimeTaskIMSessionsResponse, CreateGitWorkspaceProjectRequest, CreateProjectRequest, + CodexPermissionMode, DeleteDeviceWorkspaceRequest, DeviceWorkspacePrepareRequest, DeviceWorkspacePrepareResponse, @@ -77,6 +78,7 @@ export interface SendCurrentInputOptions { clientMessageId?: string codeCommentContexts?: CodeCommentContext[] initialGoal?: RuntimeGoalCreateInput | null + permissionMode?: CodexPermissionMode onError?: (error: string) => void onRuntimeTaskOptimisticOpen?: ( address: RuntimeTaskAddress, diff --git a/wework/src/features/workbench/workbenchReducer.ts b/wework/src/features/workbench/workbenchReducer.ts index 97f35a8c60..c440ddddd4 100644 --- a/wework/src/features/workbench/workbenchReducer.ts +++ b/wework/src/features/workbench/workbenchReducer.ts @@ -665,6 +665,7 @@ function findRuntimeTaskAddressByTaskId( workspacePath: getRuntimeTaskWorkspacePath(workspace, task), ...(task.taskId ? { taskId: task.taskId } : {}), ...(task.runtimeHandle ? { runtimeHandle: task.runtimeHandle } : {}), + ...(task.permissionMode ? { permissionMode: task.permissionMode } : {}), } if (match && match.deviceId !== address.deviceId) { return null diff --git a/wework/src/features/workbench/workbenchRuntimeHelpers.ts b/wework/src/features/workbench/workbenchRuntimeHelpers.ts index 214311bd38..7f6ecc1c51 100644 --- a/wework/src/features/workbench/workbenchRuntimeHelpers.ts +++ b/wework/src/features/workbench/workbenchRuntimeHelpers.ts @@ -139,6 +139,7 @@ function runtimeTaskAddressFromWorkspace( workspacePath: getRuntimeTaskWorkspacePath(workspace, task), ...(task.taskId ? { taskId: task.taskId } : {}), ...(task.runtimeHandle ? { runtimeHandle: task.runtimeHandle } : {}), + ...(task.permissionMode ? { permissionMode: task.permissionMode } : {}), } } diff --git a/wework/src/i18n/locales/en/chat.json b/wework/src/i18n/locales/en/chat.json index e428d9695e..28cf735397 100644 --- a/wework/src/i18n/locales/en/chat.json +++ b/wework/src/i18n/locales/en/chat.json @@ -69,6 +69,14 @@ "submit": "Submit", "custom_placeholder": "Type your answer" }, + "approval": { + "title": "Approval required", + "decline": "Decline", + "accept_once": "Allow once", + "accept_session": "Allow for session", + "accept_rule": "Always allow this rule", + "submitted": "Submitted" + }, "plan_card": { "title": "Plan", "generating": "Generating", diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index 466a169f99..8f71c74424 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -884,6 +884,15 @@ "general_settings_language_title": "Language", "general_settings_language_preference": "Interface language", "general_settings_language_description": "Change the language used by Wework and the system tray menu.", + "general_settings_codex_permissions": "Default permissions for new Codex chats", + "general_settings_codex_permissions_description": "Set the default sandbox and approval behavior for new chats without changing existing chats.", + "codex_permission_full_access": "Full access", + "codex_permission_full_access_description": "Run without sandboxing or approval prompts. Use only in trusted workspaces.", + "codex_permission_request_approval": "Ask for approval", + "codex_permission_request_approval_description": "Work automatically inside the workspace and ask you before crossing its boundary.", + "codex_permission_approve_for_me": "Approve for me", + "codex_permission_approve_for_me_description": "Work automatically inside the workspace and let AI approve or deny boundary crossings.", + "codex_permission_update_failed": "Failed to update the permission mode", "general_settings_language_system": "Follow system", "general_settings_language_system_short": "System", "general_settings_language_system_description": "Choose automatically from your system language", diff --git a/wework/src/i18n/locales/zh-CN/chat.json b/wework/src/i18n/locales/zh-CN/chat.json index f78a1be5b2..93c92844f5 100644 --- a/wework/src/i18n/locales/zh-CN/chat.json +++ b/wework/src/i18n/locales/zh-CN/chat.json @@ -66,6 +66,14 @@ "submit": "提交", "custom_placeholder": "输入你的回答" }, + "approval": { + "title": "需要批准", + "decline": "拒绝", + "accept_once": "允许本次", + "accept_session": "本会话允许", + "accept_rule": "始终允许此规则", + "submitted": "已提交" + }, "plan_card": { "title": "计划", "generating": "正在生成", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index f22a7883b3..4e6f1bc5b7 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -884,6 +884,15 @@ "general_settings_language_title": "语言", "general_settings_language_preference": "界面语言", "general_settings_language_description": "更改 Wework 界面和系统托盘菜单使用的语言。", + "general_settings_codex_permissions": "新 Codex 会话默认权限", + "general_settings_codex_permissions_description": "设置新会话的默认沙箱与审批方式,不会改变已有会话。", + "codex_permission_full_access": "完全访问", + "codex_permission_full_access_description": "不使用沙箱且不请求批准,仅用于可信工作区。", + "codex_permission_request_approval": "请求批准", + "codex_permission_request_approval_description": "可在工作区内自动操作,越界时由你决定。", + "codex_permission_approve_for_me": "代我审批", + "codex_permission_approve_for_me_description": "可在工作区内自动操作,越界时由 AI 审批并可能拒绝。", + "codex_permission_update_failed": "权限模式更新失败", "general_settings_language_system": "跟随系统", "general_settings_language_system_short": "系统", "general_settings_language_system_description": "根据系统语言自动选择", diff --git a/wework/src/tauri/appPreferences.test.ts b/wework/src/tauri/appPreferences.test.ts index a52f932a78..9fe6eb0fb6 100644 --- a/wework/src/tauri/appPreferences.test.ts +++ b/wework/src/tauri/appPreferences.test.ts @@ -12,6 +12,7 @@ vi.mock('@/lib/runtime-environment', () => ({ })) const mergedDefaultPreferences = { + defaultCodexPermissionMode: 'full_access', closeToTrayEnabled: true, showMainWindowOnLaunch: true, closeToTrayHintSeen: false, diff --git a/wework/src/tauri/appPreferences.ts b/wework/src/tauri/appPreferences.ts index 0a1285a65f..a54245c72e 100644 --- a/wework/src/tauri/appPreferences.ts +++ b/wework/src/tauri/appPreferences.ts @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core' import { isTauriRuntime } from '@/lib/runtime-environment' export interface AppPreferences { + defaultCodexPermissionMode: CodexPermissionMode closeToTrayEnabled: boolean showMainWindowOnLaunch: boolean closeToTrayHintSeen: boolean @@ -31,8 +32,10 @@ export interface QuickPhrase { export type AppLanguagePreference = 'system' | 'zh-CN' | 'en' export type BrowserLinkTarget = 'system' | 'wework' +export type CodexPermissionMode = 'full_access' | 'request_approval' | 'approve_for_me' export interface AppPreferencesPatch { + defaultCodexPermissionMode?: CodexPermissionMode closeToTrayEnabled?: boolean showMainWindowOnLaunch?: boolean closeToTrayHintSeen?: boolean @@ -73,6 +76,7 @@ export const defaultQuickPhrases: QuickPhrase[] = [ ] export const defaultAppPreferences: AppPreferences = { + defaultCodexPermissionMode: 'full_access', closeToTrayEnabled: true, showMainWindowOnLaunch: true, closeToTrayHintSeen: false, @@ -95,6 +99,11 @@ export const APP_PREFERENCES_CHANGED_EVENT = 'wework:app-preferences-changed' const supportedLanguagePreferences = new Set(['system', 'zh-CN', 'en']) const supportedBrowserLinkTargets = new Set(['system', 'wework']) +const supportedCodexPermissionModes = new Set([ + 'full_access', + 'request_approval', + 'approve_for_me', +]) function canInvokeAppPreferencesCommand() { if (typeof window === 'undefined') { @@ -117,6 +126,11 @@ function mergeAppPreferences(value: unknown): AppPreferences { const record = value as Partial return { + defaultCodexPermissionMode: + typeof record.defaultCodexPermissionMode === 'string' && + supportedCodexPermissionModes.has(record.defaultCodexPermissionMode as CodexPermissionMode) + ? (record.defaultCodexPermissionMode as CodexPermissionMode) + : defaultAppPreferences.defaultCodexPermissionMode, closeToTrayEnabled: typeof record.closeToTrayEnabled === 'boolean' ? record.closeToTrayEnabled diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts index f4d674ea9a..90fd5dd18d 100644 --- a/wework/src/types/api.ts +++ b/wework/src/types/api.ts @@ -246,6 +246,7 @@ export interface DeleteProjectWorktreeResponse { } export type RuntimeName = 'codex' | 'claude_code' | 'claude' | string +export type CodexPermissionMode = 'full_access' | 'request_approval' | 'approve_for_me' export interface RuntimeTaskAddress { deviceId: string @@ -253,6 +254,7 @@ export interface RuntimeTaskAddress { threadId?: string | null workspacePath?: string | null runtimeHandle?: Record | null + permissionMode?: CodexPermissionMode } export type RuntimeAdditionalContextKind = 'application' | 'untrusted' @@ -364,6 +366,7 @@ export interface RuntimeTaskSummary { modelSelection?: ModelSelectionConfig | null parent?: Record | null children?: Record[] + permissionMode?: CodexPermissionMode } export interface DeviceWorkspaceUpsert { @@ -569,8 +572,11 @@ export interface RuntimeSendRequest { source?: RuntimeMessageSource | null requestUserInputResponse?: RequestUserInputResponse request_user_input_response?: RequestUserInputResponse + approvalResponse?: RuntimeApprovalResponse + approval_response?: RuntimeApprovalResponse additionalContext?: RuntimeAdditionalContext additional_context?: RuntimeAdditionalContext + permissionMode?: CodexPermissionMode } export type RuntimeInterruptAndSendRequest = RuntimeSendRequest @@ -595,6 +601,14 @@ export interface RequestUserInputResponse { answers: Record } +export interface RuntimeApprovalResponse { + decision?: string | Record + permissions?: Record + scope?: 'turn' | 'session' +} + +export type RuntimeInteractiveResponse = RequestUserInputResponse | RuntimeApprovalResponse + export interface RuntimeSendResponse { accepted: boolean taskId: string @@ -1031,6 +1045,7 @@ export interface RuntimeTaskCreateRequest { initialGoal?: RuntimeGoalCreateInput | null ephemeral?: boolean sideSource?: RuntimeTaskAddress | null + permissionMode?: CodexPermissionMode } export interface RuntimeTaskCreateResponse {