feat(server): allowlist read-only (get_project_work_state, open_project read-only) (#13)
Lot B4 du chantier server/client mode : extension read-only de l'allowlist du serveur --serve pour clôturer le premier incrément livrable (sans PTY). - open_project exposé en read-only via resolve_project_readonly. - get_project_work_state ajouté à l'allowlist. - 4 nouveaux tests. Validé : cargo check --workspace vert, app-tauri 265 tests verts (dont les 4 tests B4), contrat B4↔F2 aligné (list_projects/open_project/ get_project_work_state), desktop non régressé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -24,8 +24,13 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::commands::open_project_for_adapter;
|
use application::{GetProjectWorkStateInput, OpenProjectInput};
|
||||||
use crate::dto::{ErrorDto, HealthRequestDto, HealthResponseDto, ProjectListDto};
|
use domain::Project;
|
||||||
|
|
||||||
|
use crate::dto::{
|
||||||
|
parse_project_id, ErrorDto, HealthRequestDto, HealthResponseDto, ProjectDto, ProjectListDto,
|
||||||
|
ProjectWorkStateDto,
|
||||||
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:17373";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:17373";
|
||||||
@ -525,6 +530,7 @@ async fn invoke(
|
|||||||
"health" => invoke_health(&request.args, &state.app),
|
"health" => invoke_health(&request.args, &state.app),
|
||||||
"list_projects" => invoke_list_projects(&state.app).await,
|
"list_projects" => invoke_list_projects(&state.app).await,
|
||||||
"open_project" => invoke_open_project(&request.args, &state.app).await,
|
"open_project" => invoke_open_project(&request.args, &state.app).await,
|
||||||
|
"get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await,
|
||||||
_ => Err(ErrorDto {
|
_ => Err(ErrorDto {
|
||||||
code: "UNKNOWN_COMMAND".to_owned(),
|
code: "UNKNOWN_COMMAND".to_owned(),
|
||||||
message: format!("unknown command: {}", request.command),
|
message: format!("unknown command: {}", request.command),
|
||||||
@ -565,10 +571,38 @@ async fn invoke_open_project(args: &Value, state: &AppState) -> Result<Value, Er
|
|||||||
code: "INVALID".to_owned(),
|
code: "INVALID".to_owned(),
|
||||||
message: "open_project requires args.projectId".to_owned(),
|
message: "open_project requires args.projectId".to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let output = open_project_for_adapter(project_id.to_owned(), state).await?;
|
let project = resolve_project_readonly(project_id, state).await?;
|
||||||
|
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_get_project_work_state(args: &Value, state: &AppState) -> Result<Value, ErrorDto> {
|
||||||
|
let project_id = args
|
||||||
|
.get("projectId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: "get_project_work_state requires args.projectId".to_owned(),
|
||||||
|
})?;
|
||||||
|
let project = resolve_project_readonly(project_id, state).await?;
|
||||||
|
let output = state
|
||||||
|
.get_project_work_state
|
||||||
|
.execute(GetProjectWorkStateInput { project })
|
||||||
|
.await
|
||||||
|
.map(ProjectWorkStateDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
serde_json::to_value(output).map_err(serialization_error)
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_project_readonly(project_id: &str, state: &AppState) -> Result<Project, ErrorDto> {
|
||||||
|
let id = parse_project_id(project_id)?;
|
||||||
|
state
|
||||||
|
.open_project
|
||||||
|
.execute(OpenProjectInput { project_id: id })
|
||||||
|
.await
|
||||||
|
.map(|output| output.project)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
fn optional_request<T>(args: &Value) -> Result<Option<T>, ErrorDto>
|
fn optional_request<T>(args: &Value) -> Result<Option<T>, ErrorDto>
|
||||||
where
|
where
|
||||||
T: for<'de> Deserialize<'de>,
|
T: for<'de> Deserialize<'de>,
|
||||||
@ -794,10 +828,9 @@ fn serialization_error(err: serde_json::Error) -> ErrorDto {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use application::CreateProjectInput;
|
||||||
use http::header::HeaderName;
|
use http::header::HeaderName;
|
||||||
|
|
||||||
use crate::dto::parse_project_id;
|
|
||||||
|
|
||||||
fn test_config() -> ServerConfig {
|
fn test_config() -> ServerConfig {
|
||||||
ServerConfig {
|
ServerConfig {
|
||||||
listen: "127.0.0.1:17373".parse().unwrap(),
|
listen: "127.0.0.1:17373".parse().unwrap(),
|
||||||
@ -869,6 +902,25 @@ mod tests {
|
|||||||
.to_owned()
|
.to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_project_for_test(state: &Arc<ServerState>, name: &str) -> String {
|
||||||
|
let root = std::env::temp_dir()
|
||||||
|
.join(format!("idea-server-project-{}", Uuid::new_v4()))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let output = state
|
||||||
|
.app
|
||||||
|
.create_project
|
||||||
|
.execute(CreateProjectInput {
|
||||||
|
name: name.to_owned(),
|
||||||
|
root,
|
||||||
|
remote: None,
|
||||||
|
default_profile_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("test project is created");
|
||||||
|
output.project.id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_bind_requires_explicit_remote_security() {
|
fn public_bind_requires_explicit_remote_security() {
|
||||||
let config = ServerConfig {
|
let config = ServerConfig {
|
||||||
@ -1104,6 +1156,103 @@ mod tests {
|
|||||||
assert_eq!(body["note"], "hi");
|
assert_eq!(body["note"], "hi");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_list_projects_returns_tauri_project_list_contract() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Web Read").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let response = request(
|
||||||
|
state,
|
||||||
|
Method::POST,
|
||||||
|
"/api/invoke",
|
||||||
|
json!({ "command": "list_projects", "args": {} }),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (status, body, _) = response_json(response).await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let projects = body
|
||||||
|
.as_array()
|
||||||
|
.expect("ProjectListDto is a transparent array");
|
||||||
|
let project = projects
|
||||||
|
.iter()
|
||||||
|
.find(|project| project["id"] == project_id)
|
||||||
|
.expect("created project is listed");
|
||||||
|
assert_eq!(project["name"], "Web Read");
|
||||||
|
assert!(project["root"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|root| root.contains("idea-server-project-")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_open_project_returns_readonly_project_dto() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Readonly Open").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let response = request(
|
||||||
|
state,
|
||||||
|
Method::POST,
|
||||||
|
"/api/invoke",
|
||||||
|
json!({ "command": "open_project", "args": { "projectId": project_id } }),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (status, body, _) = response_json(response).await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["id"], project_id);
|
||||||
|
assert_eq!(body["name"], "Readonly Open");
|
||||||
|
assert!(body["root"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_get_project_work_state_returns_tauri_contract() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Readonly Work").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let response = request(
|
||||||
|
state,
|
||||||
|
Method::POST,
|
||||||
|
"/api/invoke",
|
||||||
|
json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (status, body, _) = response_json(response).await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(body["agents"].as_array().is_some());
|
||||||
|
assert!(body["conversations"].as_array().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() {
|
||||||
|
let state = state();
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
for command in ["create_project", "open_terminal", "launch_agent"] {
|
||||||
|
let response = request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::POST,
|
||||||
|
"/api/invoke",
|
||||||
|
json!({ "command": command, "args": {} }),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (status, body, _) = response_json(response).await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(
|
||||||
|
body["code"], "UNKNOWN_COMMAND",
|
||||||
|
"{command} must stay blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_project_id_is_mapped_to_error_dto() {
|
async fn invalid_project_id_is_mapped_to_error_dto() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
Reference in New Issue
Block a user