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 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();
|
||||
|
||||
Reference in New Issue
Block a user