workdir: add authenticated runtime session transport

This commit is contained in:
2026-08-03 23:37:15 +09:00
parent 05e8b00bf0
commit e5f0c4168f
10 changed files with 1220 additions and 5 deletions
+423 -4
View File
@@ -7,7 +7,7 @@
//! credentials, registration, and policy.
use crate::auth::{
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, unix_now_seconds,
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, new_token_id, unix_now_seconds,
verify_capability_token,
};
use crate::catalog::{
@@ -32,7 +32,7 @@ use axum::extract::{Extension, Path, Query, State};
use axum::http::{Method, Request, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
#[cfg(feature = "ws-server")]
use futures::{SinkExt, StreamExt};
@@ -45,14 +45,21 @@ use protocol::subscription::{
SubscriptionTerminationCode,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "ws-server")]
use std::collections::HashMap;
use std::fmt;
use std::net::SocketAddr;
#[cfg(feature = "fs-store")]
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use workdir::{
CommandOutput, CommandStatus, WorkdirSessionHandle,
http::{
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId,
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
WorkdirTransportErrorCode,
},
};
const DEFAULT_RUNTIME_HTTP_PORT: u16 = 38800;
@@ -172,6 +179,7 @@ fn runtime_http_router_with_optional_auth(
runtime,
local_token: local_token.map(Arc::<str>::from),
auth: auth.map(Arc::new),
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
};
let router = Router::new()
@@ -188,6 +196,18 @@ fn runtime_http_router_with_optional_auth(
"/v1/working-directories",
get(list_working_directories).post(create_working_directory),
)
.route(
"/v1/working-directories/{working_directory_id}/sessions",
post(open_workdir_session),
)
.route(
"/v1/workdir-sessions/{session_id}/operations",
post(run_workdir_session_operation),
)
.route(
"/v1/workdir-sessions/{session_id}",
delete(close_workdir_session),
)
.route(
"/v1/working-directories/{working_directory_id}",
get(get_working_directory).delete(cleanup_working_directory),
@@ -228,6 +248,12 @@ struct RuntimeHttpState {
runtime: Runtime,
local_token: Option<Arc<str>>,
auth: Option<Arc<RuntimeHttpAuthConfig>>,
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
}
struct RuntimeHttpWorkdirSession {
owner: RuntimeWorkspaceScope,
session: WorkdirSessionHandle,
}
/// `GET /v1/runtime` response.
@@ -472,6 +498,164 @@ async fn get_working_directory(
}))
}
async fn open_workdir_session(
State(state): State<RuntimeHttpState>,
Path(working_directory_id): Path<String>,
auth: Option<Extension<RuntimeAuthContext>>,
body: Result<Json<OpenWorkdirSessionRequest>, JsonRejection>,
) -> Result<Json<OpenWorkdirSessionResponse>, RuntimeHttpWorkdirError> {
let Json(request) = body.map_err(|_| RuntimeHttpWorkdirError::invalid_request())?;
let owner = required_workdir_owner(auth)?;
let owner_worker_ref = request
.owner_worker_id
.as_deref()
.map(|worker_id| {
WorkerId::parse(worker_id)
.map(WorkerRef::new)
.ok_or_else(RuntimeHttpWorkdirError::invalid_request)
})
.transpose()?;
let session = state
.runtime
.open_workdir_session_scoped(&owner, &working_directory_id, owner_worker_ref.as_ref())
.map_err(RuntimeHttpWorkdirError::runtime)?;
let session_id =
WorkdirSessionId::new(new_token_id().map_err(|_| RuntimeHttpWorkdirError::internal())?)
.map_err(|_| RuntimeHttpWorkdirError::internal())?;
let response = OpenWorkdirSessionResponse {
session_id: session_id.clone(),
workdir_id: session.workdir().id().clone(),
capabilities: session.capabilities(),
};
state
.workdir_sessions
.lock()
.map_err(|_| RuntimeHttpWorkdirError::internal())?
.insert(
session_id.as_str().to_string(),
RuntimeHttpWorkdirSession { owner, session },
);
Ok(Json(response))
}
async fn run_workdir_session_operation(
State(state): State<RuntimeHttpState>,
Path(session_id): Path<String>,
auth: Option<Extension<RuntimeAuthContext>>,
body: Result<Json<WorkdirSessionOperation>, JsonRejection>,
) -> Result<Json<WorkdirSessionOperationResult>, RuntimeHttpWorkdirError> {
let Json(operation) = body.map_err(|_| RuntimeHttpWorkdirError::invalid_request())?;
let owner = required_workdir_owner(auth)?;
let session = {
let sessions = state
.workdir_sessions
.lock()
.map_err(|_| RuntimeHttpWorkdirError::internal())?;
let record = sessions
.get(&session_id)
.filter(|record| record.owner == owner)
.ok_or_else(RuntimeHttpWorkdirError::not_found)?;
record.session.clone()
};
let result = match operation {
WorkdirSessionOperation::Stat(request) => {
WorkdirSessionOperationResult::Stat(session.stat(request).await?)
}
WorkdirSessionOperation::Read(request) => {
WorkdirSessionOperationResult::Read(session.read(request).await?)
}
WorkdirSessionOperation::Write(request) => {
WorkdirSessionOperationResult::Write(session.write(request).await?)
}
WorkdirSessionOperation::Edit(request) => {
WorkdirSessionOperationResult::Edit(session.edit(request).await?)
}
WorkdirSessionOperation::List(request) => {
WorkdirSessionOperationResult::List(session.list(request).await?)
}
WorkdirSessionOperation::Glob(request) => {
WorkdirSessionOperationResult::Glob(session.glob(request).await?)
}
WorkdirSessionOperation::Grep(request) => {
WorkdirSessionOperationResult::Grep(session.grep(request).await?)
}
WorkdirSessionOperation::CommandStart(request) => {
WorkdirSessionOperationResult::CommandStart(session.start_command(request).await?)
}
WorkdirSessionOperation::CommandStatus(handle) => {
WorkdirSessionOperationResult::CommandStatus(session.command_status(handle).await?)
}
WorkdirSessionOperation::CommandOutput(request) if request.wait => {
let cursor = request.cursor;
let output = match tokio::time::timeout(
std::time::Duration::from_secs(20),
session.command_output(request),
)
.await
{
Ok(result) => result?,
Err(_) => CommandOutput {
exit_code: None,
timed_out: false,
status: CommandStatus::Running,
content: String::new(),
next_cursor: Some(cursor),
truncated: false,
},
};
WorkdirSessionOperationResult::CommandOutput(output)
}
WorkdirSessionOperation::CommandOutput(request) => {
WorkdirSessionOperationResult::CommandOutput(session.command_output(request).await?)
}
WorkdirSessionOperation::CommandCancel(handle) => {
session.cancel_command(handle).await?;
WorkdirSessionOperationResult::CommandCancel
}
};
Ok(Json(result))
}
async fn close_workdir_session(
State(state): State<RuntimeHttpState>,
Path(session_id): Path<String>,
auth: Option<Extension<RuntimeAuthContext>>,
) -> Result<StatusCode, RuntimeHttpWorkdirError> {
let owner = required_workdir_owner(auth)?;
let session = {
let mut sessions = state
.workdir_sessions
.lock()
.map_err(|_| RuntimeHttpWorkdirError::internal())?;
if sessions
.get(&session_id)
.is_some_and(|record| record.owner == owner)
{
sessions.remove(&session_id).map(|record| record.session)
} else {
None
}
};
if let Some(session) = session {
session.close().await?;
}
Ok(StatusCode::NO_CONTENT)
}
fn required_workdir_owner(
auth: Option<Extension<RuntimeAuthContext>>,
) -> Result<RuntimeWorkspaceScope, RuntimeHttpWorkdirError> {
let Extension(auth) = auth.ok_or_else(RuntimeHttpWorkdirError::forbidden)?;
if auth.workspace_id.trim().is_empty() || auth.server_id.trim().is_empty() {
return Err(RuntimeHttpWorkdirError::forbidden());
}
Ok(RuntimeWorkspaceScope::new(
auth.workspace_id,
auth.server_id,
))
}
async fn cleanup_working_directory(
State(state): State<RuntimeHttpState>,
Path(working_directory_id): Path<String>,
@@ -1263,6 +1447,11 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path == "/v1/workers" && *method == Method::POST {
return Some("workers:create");
}
if path.starts_with("/v1/workdir-sessions")
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
{
return Some("workdirs:operate");
}
if path.starts_with("/v1/config-bundles") || path.starts_with("/v1/working-directories") {
return Some("workers:create");
}
@@ -1293,6 +1482,101 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
None
}
#[derive(Debug)]
struct RuntimeHttpWorkdirError {
status: StatusCode,
payload: WorkdirTransportError,
}
impl RuntimeHttpWorkdirError {
fn new(
status: StatusCode,
code: WorkdirTransportErrorCode,
message: impl Into<String>,
) -> Self {
Self {
status,
payload: WorkdirTransportError {
code,
message: message.into(),
},
}
}
fn invalid_request() -> Self {
Self::new(
StatusCode::BAD_REQUEST,
WorkdirTransportErrorCode::InvalidRequest,
"Workdir operation request is invalid",
)
}
fn forbidden() -> Self {
Self::new(
StatusCode::FORBIDDEN,
WorkdirTransportErrorCode::Unavailable,
"Workdir session is unavailable",
)
}
fn not_found() -> Self {
Self::new(
StatusCode::NOT_FOUND,
WorkdirTransportErrorCode::NotFound,
"Workdir session was not found",
)
}
fn internal() -> Self {
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
WorkdirTransportErrorCode::Internal,
"Workdir operation failed",
)
}
fn runtime(error: RuntimeError) -> Self {
match error {
RuntimeError::WorkingDirectory(diagnostic)
if diagnostic.code == "working_directory_not_found" =>
{
Self::not_found()
}
RuntimeError::WorkspaceOwnerMismatch { .. } => Self::not_found(),
RuntimeError::InvalidRequest(_) => Self::invalid_request(),
_ => Self::new(
StatusCode::SERVICE_UNAVAILABLE,
WorkdirTransportErrorCode::Unavailable,
"Workdir session is unavailable",
),
}
}
}
impl From<workdir::WorkdirError> for RuntimeHttpWorkdirError {
fn from(error: workdir::WorkdirError) -> Self {
let payload = WorkdirTransportError::from_workdir_error(&error);
let status = match payload.code {
WorkdirTransportErrorCode::NotFound | WorkdirTransportErrorCode::UnknownCommand => {
StatusCode::NOT_FOUND
}
WorkdirTransportErrorCode::Conflict => StatusCode::CONFLICT,
WorkdirTransportErrorCode::Unsupported | WorkdirTransportErrorCode::InvalidRequest => {
StatusCode::BAD_REQUEST
}
WorkdirTransportErrorCode::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
WorkdirTransportErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
Self { status, payload }
}
}
impl IntoResponse for RuntimeHttpWorkdirError {
fn into_response(self) -> Response {
(self.status, Json(self.payload)).into_response()
}
}
#[derive(Debug)]
struct RuntimeHttpRestError {
status: StatusCode,
@@ -1434,7 +1718,11 @@ mod tests {
use crate::management::RuntimeOptions;
use axum::body::to_bytes;
use axum::http::Method;
use manifest::{Scope, SharedScope};
use tower::ServiceExt;
use workdir::{
LocalWorkdirSession, StatRequest, Workdir, WorkdirPath, WorkdirSessionCapabilities,
};
fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
ConfigBundle {
@@ -1525,6 +1813,7 @@ mod tests {
"workers:stop",
"workers:protocol",
"workers:delete",
"workdirs:operate",
],
)
}
@@ -1719,6 +2008,27 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_workdir_permission_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/working-directories/wd-1/sessions",
&token,
serde_json::to_vec(&OpenWorkdirSessionRequest::default()).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle(profile.clone());
@@ -1755,6 +2065,115 @@ mod tests {
}
}
#[test]
fn workdir_routes_require_dedicated_operation_permission() {
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
Some("workdirs:operate")
);
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/workdir-sessions/session-1/operations"),
Some("workdirs:operate")
);
assert_eq!(
required_runtime_permission(&Method::DELETE, "/v1/workdir-sessions/session-1"),
Some("workdirs:operate")
);
}
#[tokio::test]
async fn workdir_session_operations_enforce_owner_and_close_terminally() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture");
let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope"));
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("wd-1"),
temp.path().to_path_buf(),
temp.path().to_path_buf(),
scope,
WorkdirSessionCapabilities::ALL,
));
let owner = RuntimeWorkspaceScope::new("workspace-a", "server-a");
let state = RuntimeHttpState {
runtime: Runtime::with_execution_backend(
RuntimeOptions::default(),
Arc::new(AcceptingBackend),
)
.expect("runtime"),
local_token: Some(Arc::from("token")),
auth: None,
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
"session-1".to_string(),
RuntimeHttpWorkdirSession {
owner: owner.clone(),
session: session.clone(),
},
)]))),
};
let auth = RuntimeAuthContext {
server_id: "server-a".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workdirs:operate".to_string()],
token_id: "token-a".to_string(),
expires_at: u64::MAX,
};
let operation = WorkdirSessionOperation::Stat(StatRequest {
path: WorkdirPath::new("hello.txt").expect("logical path"),
});
let Json(result) = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(operation.clone())),
)
.await
.expect("owned operation");
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let wrong_owner = RuntimeAuthContext {
workspace_id: "workspace-b".to_string(),
..auth.clone()
};
let error = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(wrong_owner)),
Ok(Json(operation)),
)
.await
.expect_err("cross-workspace session access must fail");
assert_eq!(error.status, StatusCode::NOT_FOUND);
assert_eq!(
close_workdir_session(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth)),
)
.await
.expect("close"),
StatusCode::NO_CONTENT
);
let closed_error = session
.stat(StatRequest {
path: WorkdirPath::new("hello.txt").expect("logical path"),
})
.await
.expect_err("close must be terminal");
assert!(matches!(
closed_error,
workdir::WorkdirError::Unavailable(_)
));
assert!(
state
.workdir_sessions
.lock()
.expect("session registry")
.is_empty()
);
}
struct AcceptingBackend;
impl WorkerExecutionBackend for AcceptingBackend {