Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/RUNNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ The runtime tools `register_project` and `create_project` let a client register
an existing directory or create a new one on an online Runner, subject to the
Runner's `allowed_roots` policy.

The admin `POST /api/admin/projects/register` and
`POST /api/admin/projects/create` routes use the same cross-platform path
validation as these tools. Supply an absolute path on the selected Runner,
regardless of the Server's OS: for example `/srv/projects/demo`,
`D:\projects\demo` or `D:/projects/demo`. In JSON, escape backslashes:
`"path": "D:\\projects\\demo"`. Windows verbatim paths are not required.
Empty, relative, drive-relative (`D:demo`), NUL-containing and over-4096-byte
paths are rejected by the admin API. The Runner still owns native path
validation, canonicalization and `allowed_roots` enforcement. On Windows,
existing UNC/verbatim-UNC directories can be registered only under already
authorized roots; creating Projects on network shares remains unsupported.

## Skill sources

`list_skills` presents one catalog while preserving three distinct ownership and
Expand Down
9 changes: 9 additions & 0 deletions docs/RUNNER.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ allowed_roots = ["/root/git"]
runtime 工具 `register_project` 与 `create_project` 让客户端在在线 Runner 上
注册已有目录或创建新目录,受 Runner 的 `allowed_roots` policy 约束。

管理员接口 `POST /api/admin/projects/register` 与
`POST /api/admin/projects/create` 使用与这些工具相同的跨平台路径校验。
无论 Server 运行在哪个操作系统,都应传入所选 Runner 上的绝对路径,例如
`/srv/projects/demo`、`D:\projects\demo` 或 `D:/projects/demo`。
JSON 中的反斜杠需要转义:`"path": "D:\\projects\\demo"`;不必改用 Windows verbatim 路径。
管理员 API 会拒绝空路径、相对路径、盘符相对路径(`D:demo`)、含 NUL 的路径和
超过 4096 字节的路径。原生路径校验、规范化及 `allowed_roots` 检查仍由 Runner 执行。
Windows 上的已有 UNC/verbatim-UNC 目录只能在已授权根目录内注册;仍不支持在网络共享上创建 Project。

## Skill 来源

`list_skills` 继续只暴露一个 catalog,但其中保留三种彼此独立的 ownership / lifecycle:
Expand Down
5 changes: 4 additions & 1 deletion src/admin_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,18 +395,21 @@ async fn dashboard(req: &mut Request, depot: &mut Depot, res: &mut Response) {

#[cfg(test)]
mod tests {
mod project_paths;

use super::*;
use crate::auth::scopes::SCOPE_ADMIN;
use salvo::test::{ResponseExt, TestClient};
use salvo::Service;

fn service(auth: Option<AuthContext>) -> Service {
let (_tmp, db) = crate::test_support::test_db();
let (tmp, db) = crate::test_support::test_db();
let runtime = Arc::new(ToolRuntime::new(
Arc::new(crate::RunnerRegistry::default()),
Arc::new(crate::tool_runtime::RuntimeInfo::default()),
));
let mut router = Router::new()
.hoop(affix_state::inject(Arc::new(tmp)))
.hoop(affix_state::inject(db))
.hoop(affix_state::inject(runtime));
if let Some(auth) = auth {
Expand Down
81 changes: 81 additions & 0 deletions src/admin_http/tests/project_paths.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use super::*;

async fn project_path_call(service: &Service, operation: &str, path: &str) -> (StatusCode, Value) {
let mut response = TestClient::post(format!("http://127.0.0.1/admin/projects/{operation}"))
.add_header("host", "127.0.0.1", true)
.add_header("origin", "http://127.0.0.1", true)
.add_header("content-type", "application/json", true)
.body(
json!({
"client_id": "runner",
"project_id": "demo",
"name": "Demo",
"path": path,
"idempotency_key": "path-validation"
})
.to_string(),
)
.send(service)
.await;
let status = response.status_code.unwrap();
(status, response.take_json().await.unwrap())
}

#[tokio::test]
async fn admin_project_paths_accept_cross_platform_absolute_shapes() {
let mut admin = AuthContext::new(AuthKind::ApiToken);
admin.scopes.push(SCOPE_ADMIN.to_string());
let service = service(Some(admin));
let max_length_path = format!("D:/{}", "a".repeat(4093));
for operation in ["register", "create"] {
for path in [
"/srv/projects/demo",
r"D:\projects\demo",
"d:/projects/demo",
r"\\server\share\demo",
r"\\?\D:\projects\demo",
r"\\?\UNC\server\share\demo",
"//?/D:/projects/demo",
&max_length_path,
] {
let (status, body) = project_path_call(&service, operation, path).await;
// With no Runner connected, a valid request must reach Runner lookup.
// Acceptance here does not assert that its filesystem policy allows it.
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"{operation}: {path:?}: {body}"
);
assert_eq!(body, json!({"error": {"code": "agent_unavailable"}}));
}
}
}

#[tokio::test]
async fn admin_project_paths_reject_relative_nul_and_oversized_inputs() {
let mut admin = AuthContext::new(AuthKind::ApiToken);
admin.scopes.push(SCOPE_ADMIN.to_string());
let service = service(Some(admin));
let oversized_path = format!("D:/{}", "a".repeat(4094));
for operation in ["register", "create"] {
for path in [
"",
"relative/path",
r"relative\path",
"D:",
r"D:projects\demo",
r"\projects\demo",
"/srv/projects/\0demo",
"D:\\projects\\\0demo",
&oversized_path,
] {
let (status, body) = project_path_call(&service, operation, path).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"{operation}: {path:?}: {body}"
);
assert_eq!(body, json!({"error": {"code": "invalid_request"}}));
}
}
}
4 changes: 2 additions & 2 deletions src/admin_project_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::sync::Mutex;
use webcodex_core::runtime_contract::validate_project_op_path;

const WAIT_SECS: u64 = 30;
pub(crate) const IDEMPOTENCY_KEY_MAX: usize = 128;
Expand Down Expand Up @@ -904,9 +905,8 @@ fn validate_common(
}
if name.trim().is_empty()
|| name.len() > 120
|| path.is_empty()
|| path.len() > 4096
|| !path.starts_with('/')
|| validate_project_op_path(path).is_err()
|| description.is_some_and(|v| v.len() > 500)
{
return Err(api_error(400, "invalid_request"));
Expand Down
Loading