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:
2026-07-15 13:21:28 +02:00
parent 4ed0b168d2
commit fa353f6c0b

View File

@ -24,8 +24,13 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use uuid::Uuid;
use crate::commands::open_project_for_adapter;
use crate::dto::{ErrorDto, HealthRequestDto, HealthResponseDto, ProjectListDto};
use application::{GetProjectWorkStateInput, OpenProjectInput};
use domain::Project;
use crate::dto::{
parse_project_id, ErrorDto, HealthRequestDto, HealthResponseDto, ProjectDto, ProjectListDto,
ProjectWorkStateDto,
};
use crate::state::AppState;
const DEFAULT_LISTEN: &str = "127.0.0.1:17373";
@ -525,6 +530,7 @@ async fn invoke(
"health" => invoke_health(&request.args, &state.app),
"list_projects" => invoke_list_projects(&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 {
code: "UNKNOWN_COMMAND".to_owned(),
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(),
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)
}
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>
where
T: for<'de> Deserialize<'de>,
@ -794,10 +828,9 @@ fn serialization_error(err: serde_json::Error) -> ErrorDto {
#[cfg(test)]
mod tests {
use super::*;
use application::CreateProjectInput;
use http::header::HeaderName;
use crate::dto::parse_project_id;
fn test_config() -> ServerConfig {
ServerConfig {
listen: "127.0.0.1:17373".parse().unwrap(),
@ -869,6 +902,25 @@ mod tests {
.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]
fn public_bind_requires_explicit_remote_security() {
let config = ServerConfig {
@ -1104,6 +1156,103 @@ mod tests {
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]
async fn invalid_project_id_is_mapped_to_error_dto() {
let state = state();