Skip to main content

netget/server/webdav/
mod.rs

1//! WebDAV server (RFC 4918) with an LLM-supplied virtual filesystem.
2//!
3//! The server owns HTTP/1.1 framing (via `hyper`) and the DAV:multistatus XML; the model owns
4//! everything a client can observe. There is **no filesystem** behind this protocol — not on
5//! disk, not in memory. `PROPFIND` lists what the model says is there, `GET` returns what the
6//! model says the file contains, and `PUT`/`MKCOL`/`DELETE`/`COPY`/`MOVE` succeed or fail
7//! exactly as the model decides.
8//!
9//! This replaced an implementation that handed every request to `dav_server::memfs::MemFs` — a
10//! real read/write filesystem inside the process — and dropped the `OllamaClient` on startup,
11//! so the server instruction was read by nobody. See `src/server/webdav/CLAUDE.md` for why
12//! implementing `dav_server::fs::DavFileSystem` against the model was rejected in favour of
13//! answering the verbs directly.
14//!
15//! Three methods never reach the model, because they are handshakes with no content to decide:
16//! `OPTIONS` (capability advertisement), `LOCK` and `UNLOCK` (a synthetic, never-enforced lock
17//! token, so clients that refuse to write without one can proceed). Everything else raises
18//! `webdav_request`.
19pub mod actions;
20
21use crate::server::connection::ConnectionId;
22use anyhow::{Context, Result};
23use std::collections::HashMap;
24use std::convert::Infallible;
25use std::net::SocketAddr;
26use std::sync::Arc;
27use tokio::sync::mpsc;
28use tracing::{error, info, trace, warn};
29
30use crate::llm::action_helper::call_llm;
31use crate::llm::actions::protocol_trait::ActionResult;
32use crate::llm::ollama_client::OllamaClient;
33use crate::logging::emit::Log;
34use crate::protocol::Event;
35use crate::server::WebDavProtocol;
36use crate::state::app_state::AppState;
37use actions::WEBDAV_REQUEST_EVENT;
38
39use bytes::Bytes;
40use http_body_util::{BodyExt, Full};
41use hyper::body::Incoming;
42use hyper::server::conn::http1;
43use hyper::service::service_fn;
44use hyper::{Request, Response};
45use hyper_util::rt::TokioIo;
46
47/// Methods a client may use against this server, advertised in `Allow` on `OPTIONS`.
48const ALLOWED_METHODS: &str = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, \
49                               COPY, MOVE, LOCK, UNLOCK";
50
51/// Largest request body this server will read, in bytes.
52///
53/// WebDAV has no authentication step, so anything a peer can reach it can also `PUT` to. The
54/// body used to be read with a bare `.collect()`, which is unbounded: a single request decided
55/// how much memory this process allocated, and the whole of it was then interpolated into the
56/// model's prompt. 8 MiB is far past any real `PROPFIND`/`PROPPATCH` body and still bounded.
57/// A larger body gets `413 Payload Too Large` before any of it reaches the model.
58pub const MAX_REQUEST_BODY: usize = 8 * 1024 * 1024;
59
60/// How long to wait for a peer's first byte after it connects.
61///
62/// HTTP is client-speaks-first — the server says nothing until it has a request line — so a peer
63/// that has connected and sent nothing has begun no request at all, which is the state an
64/// unauthenticated flood lives in, and WebDAV has no authentication step at all. Enforced with
65/// `TcpStream::peek` *before* the socket reaches hyper, so the request line is still there for
66/// hyper to parse afterwards.
67const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
68
69/// How long a connection may do nothing at all between requests.
70///
71/// **This bounds the silence, not the transfer.** A `GET` returns whatever the model says the
72/// file contains, built into a `Full<Bytes>` before the response is returned, so hyper writes an
73/// already-complete body and a client dragging a large file down is draining bytes rather than
74/// idling. The watchdog reads `ConnectionActivity`, which reports a connection with a request in
75/// flight as **not idle at all**, so an LLM round-trip — or a `manual` rule parking a `PUT` for
76/// a human to approve — can never close the connection it is an answer for.
77///
78/// Fifteen minutes is the wait between one request finishing and the next one starting. WebDAV
79/// clients that mount a share (Finder, gvfs, `reqwest_dav`) keep a connection pooled and poll
80/// far more often than that; one that has said nothing for fifteen minutes has unmounted or
81/// died without closing.
82const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
83
84/// Concurrent connections this server admits.
85///
86/// Each admitted connection may buffer a request body of up to [`MAX_REQUEST_BODY`] (8 MiB,
87/// larger than most protocols here) and hold a model round-trip open, so the cap is what turns
88/// that per-connection bound into a total one.
89const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
90
91/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
92///
93/// WebDAV is HTTP, so the protocol's own vocabulary for "come back later" is a `503` with
94/// `Retry-After`. A DAV client reports the status; an unexplained reset would have it record a
95/// permanent fault instead.
96const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
97    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
98
99/// WebDAV server whose entire filesystem is supplied by the LLM
100pub struct WebDavServer;
101
102impl WebDavServer {
103    /// Spawn WebDAV server with integrated LLM actions
104    pub async fn spawn_with_llm_actions(
105        listen_addr: SocketAddr,
106        llm_client: OllamaClient,
107        app_state: Arc<AppState>,
108        status_tx: mpsc::UnboundedSender<String>,
109        server_id: crate::state::ServerId,
110    ) -> Result<SocketAddr> {
111        info!("WebDAV server starting on {}", listen_addr);
112
113        let protocol = Arc::new(WebDavProtocol::new());
114
115        // Bind before spawning. Binding inside the accept task made every failure invisible:
116        // the task logged and returned while spawn_with_llm_actions had already answered Ok,
117        // so the server sat in Running with no socket. It also meant port 0 was reported back
118        // to the caller verbatim instead of the port the kernel actually chose.
119        let listener = crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr)
120            .await
121            .with_context(|| format!("Failed to bind WebDAV listener on {}", listen_addr))?;
122        let local_addr = listener.local_addr()?;
123
124        Log::new(Some(&status_tx)).info(format!("WebDAV server listening on {}", local_addr));
125
126        let task_registrar = app_state.clone();
127        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
128        let accept_handle = tokio::spawn(async move {
129            loop {
130                match crate::server::accept_bounded::accept_bounded(
131                    &listener,
132                    &limiter,
133                    CONNECTION_CAP_REFUSAL,
134                    "WebDAV",
135                    Some(&status_tx),
136                )
137                .await
138                {
139                    Ok((stream, peer_addr, permit)) => {
140                        let connection_id =
141                            ConnectionId::new(app_state.get_next_unified_id().await);
142                        Log::new(Some(&status_tx)).debug(format!(
143                            "WebDAV connection {} from {}",
144                            connection_id, peer_addr
145                        ));
146
147                        // Add connection to ServerInstance
148                        use crate::state::server::{
149                            ConnectionState as ServerConnectionState, ConnectionStatus,
150                            ProtocolConnectionInfo,
151                        };
152                        let now = crate::utils::clock::Instant::now();
153                        let conn_state = ServerConnectionState {
154                            id: connection_id,
155                            remote_addr: peer_addr,
156                            local_addr,
157                            bytes_sent: 0,
158                            bytes_received: 0,
159                            packets_sent: 0,
160                            packets_received: 0,
161                            last_activity: now,
162                            status: ConnectionStatus::Active,
163                            status_changed_at: now,
164                            protocol_info: ProtocolConnectionInfo::empty(),
165                        };
166                        app_state
167                            .add_connection_to_server(server_id, conn_state)
168                            .await;
169                        let _ = status_tx.send("__UPDATE_UI__".to_string());
170
171                        let llm_clone = llm_client.clone();
172                        let app_clone = app_state.clone();
173                        let status_clone = status_tx.clone();
174                        let protocol_clone = protocol.clone();
175                        let app_for_close = app_state.clone();
176                        let status_for_close = status_tx.clone();
177
178                        let task_owner = app_state.clone();
179                        task_owner
180                            .spawn_server_task(server_id, async move {
181                                // Held for the life of the connection: dropping it early
182                                // releases the slot while the peer is still here, which
183                                // silently un-caps the server.
184                                let _permit = permit;
185
186                                // First-byte bound, before hyper sees the socket. hyper owns
187                                // every read once `serve_connection` starts and keeps polling
188                                // for frames *while a request is being answered*, so a deadline
189                                // on reads would fire in the middle of an LLM round-trip.
190                                // `peek` waits for data without consuming it, so the request
191                                // line is still there for hyper afterwards.
192                                match tokio::time::timeout(
193                                    FIRST_BYTE_READ_TIMEOUT,
194                                    stream.peek(&mut [0u8; 1]),
195                                )
196                                .await
197                                {
198                                    Ok(Ok(0)) | Ok(Err(_)) => {
199                                        app_for_close
200                                            .close_connection_on_server(server_id, connection_id)
201                                            .await;
202                                        let _ = status_for_close.send("__UPDATE_UI__".to_string());
203                                        return;
204                                    }
205                                    Ok(Ok(_)) => {}
206                                    Err(_) => {
207                                        Log::new(Some(&status_for_close)).debug(format!(
208                                            "WebDAV peer {} sent nothing for {}s; closing before \
209                                             the request line",
210                                            peer_addr,
211                                            FIRST_BYTE_READ_TIMEOUT.as_secs()
212                                        ));
213                                        app_for_close
214                                            .close_connection_on_server(server_id, connection_id)
215                                            .await;
216                                        let _ = status_for_close.send("__UPDATE_UI__".to_string());
217                                        return;
218                                    }
219                                }
220
221                                let io = TokioIo::new(stream);
222
223                                // Tracks whether this connection is answering anything. A
224                                // request waiting on the model, or parked for a human at the
225                                // dashboard, holds the count above zero, so the idle watchdog
226                                // below cannot close the connection the answer belongs to
227                                // however long it takes — only genuine silence counts.
228                                let activity = Arc::new(
229                                    crate::server::accept_bounded::ConnectionActivity::new(),
230                                );
231                                let activity_for_service = Arc::clone(&activity);
232
233                                let service = service_fn(move |req: Request<Incoming>| {
234                                    let llm = llm_clone.clone();
235                                    let state = app_clone.clone();
236                                    let status = status_clone.clone();
237                                    let proto = protocol_clone.clone();
238                                    let activity = Arc::clone(&activity_for_service);
239                                    async move {
240                                        let _busy = activity.busy();
241                                        handle_webdav_request(
242                                            req,
243                                            connection_id,
244                                            server_id,
245                                            llm,
246                                            state,
247                                            status,
248                                            proto,
249                                        )
250                                        .await
251                                    }
252                                });
253
254                                let conn = http1::Builder::new().serve_connection(io, service);
255                                tokio::pin!(conn);
256                                tokio::select! {
257                                    result = &mut conn => {
258                                        if let Err(err) = result {
259                                            error!("WebDAV connection error: {:?}", err);
260                                        }
261                                    }
262                                    _ = crate::server::accept_bounded::watch_idle(
263                                        Arc::clone(&activity),
264                                        IDLE_BETWEEN_REQUESTS_TIMEOUT,
265                                    ) => {
266                                        trace!(
267                                            "WebDAV connection {} idle for {}s; closing",
268                                            connection_id,
269                                            IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
270                                        );
271                                    }
272                                }
273
274                                app_for_close
275                                    .close_connection_on_server(server_id, connection_id)
276                                    .await;
277                                Log::new(Some(&status_for_close))
278                                    .info(format!("WebDAV connection {} closed", connection_id));
279                                let _ = status_for_close.send("__UPDATE_UI__".to_string());
280                            })
281                            .await;
282                    }
283                    Err(e) => {
284                        Log::new(Some(&status_tx))
285                            .error(format!("Failed to accept WebDAV connection: {}", e));
286                        break;
287                    }
288                }
289            }
290        });
291
292        task_registrar
293            .register_server_task(server_id, accept_handle)
294            .await;
295
296        Ok(local_addr)
297    }
298}
299
300/// Everything the server extracted from one request before deciding what to do with it.
301struct WebDavRequest {
302    method: String,
303    path: String,
304    headers: HashMap<String, String>,
305    body: Bytes,
306    /// The peer sent (or declared) more body than `MAX_REQUEST_BODY`. The request is refused
307    /// with 413 without a model call: there is no content decision to make about a body the
308    /// server declined to read.
309    body_too_large: bool,
310}
311
312async fn extract_request(req: Request<Incoming>) -> WebDavRequest {
313    let method = req.method().to_string().to_ascii_uppercase();
314    // Percent-decoded, query string dropped: a WebDAV path is a resource name, and the model
315    // is asked to echo it back into `send_webdav_listing`, so it must be the human-readable
316    // form rather than the wire form.
317    let raw_path = req.uri().path().to_string();
318    let path = actions::percent_decode(&raw_path);
319
320    let mut headers = HashMap::new();
321    for (name, value) in req.headers() {
322        if let Ok(v) = value.to_str() {
323            headers.insert(name.as_str().to_ascii_lowercase(), v.to_string());
324        }
325    }
326
327    // Refuse on the declared length before reading a byte where the client gave one, then
328    // bound the stream itself for the chunked case where it did not. Checking only the
329    // remainder-after-headers, or only the declared value, is the mistake that let thirty
330    // bytes on the wire buffer toward gigabytes elsewhere in this tree.
331    let declared_too_large = headers
332        .get("content-length")
333        .and_then(|v| v.parse::<u64>().ok())
334        .is_some_and(|len| len > MAX_REQUEST_BODY as u64);
335
336    if declared_too_large {
337        return WebDavRequest {
338            method,
339            path,
340            headers,
341            body: Bytes::new(),
342            body_too_large: true,
343        };
344    }
345
346    let limited = http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY);
347    let (body, body_too_large) = match limited.collect().await {
348        Ok(collected) => (collected.to_bytes(), false),
349        Err(e) => {
350            // `Limited` reports the cap as an error indistinguishable from a transport
351            // failure, and both end the request the same way here: nothing usable was read.
352            warn!("WebDAV: request body rejected: {}", e);
353            (Bytes::new(), true)
354        }
355    };
356
357    WebDavRequest {
358        method,
359        path,
360        headers,
361        body,
362        body_too_large,
363    }
364}
365
366/// Build a response without ever panicking.
367///
368/// Status and headers here are model-influenced, so an out-of-range status becomes 500 and an
369/// illegal header (notably one containing CR/LF, i.e. a response-splitting attempt) is dropped
370/// individually rather than taken down the connection task with a panic.
371fn build_safe_response(
372    status: u16,
373    headers: Vec<(String, String)>,
374    body: String,
375) -> Response<Full<Bytes>> {
376    let status_code = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
377        error!(
378            "WebDAV: invalid HTTP status {} (must be 100-599), sending 500 instead",
379            status
380        );
381        hyper::StatusCode::INTERNAL_SERVER_ERROR
382    });
383
384    // Advertised on every response: clients decide whether a server speaks WebDAV from this
385    // header, not from the multistatus body.
386    let mut builder = Response::builder()
387        .status(status_code)
388        .header("DAV", "1, 2")
389        .header("MS-Author-Via", "DAV");
390
391    for (name, value) in headers {
392        match (
393            hyper::header::HeaderName::from_bytes(name.as_bytes()),
394            hyper::header::HeaderValue::from_str(&value),
395        ) {
396            (Ok(n), Ok(v)) => builder = builder.header(n, v),
397            _ => warn!(
398                "WebDAV: dropping invalid response header {:?} (name or value is not legal HTTP)",
399                name
400            ),
401        }
402    }
403
404    builder
405        .body(Full::new(Bytes::from(body)))
406        .unwrap_or_else(|e| {
407            error!("WebDAV: failed to build response ({}), sending bare 500", e);
408            let mut fallback =
409                Response::new(Full::new(Bytes::from_static(b"Internal Server Error")));
410            *fallback.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR;
411            fallback
412        })
413}
414
415/// Turn the model's action results into an HTTP response.
416///
417/// **Fails closed.** If nothing usable came back — the model emitted no WebDAV action, or the
418/// executor rejected the one it did emit — the client gets `503 Service Unavailable`, not a
419/// permissive default. A model that means to allow something says so with
420/// `send_webdav_status`; silence is not consent.
421///
422/// 503 is *not* reserved: `send_webdav_status` can send one too, so a packet capture cannot
423/// tell "it refused" from "it never answered". The `decision=` token on every exit path is
424/// what does — `model_answer` / `model_reject` when the model decided,
425/// `model_silent` / `fail_closed_bad_action` here, `fail_closed_llm_*` on a backend failure.
426fn build_webdav_response(
427    results: Vec<ActionResult>,
428    had_action_failures: bool,
429    method: &str,
430    path: &str,
431    status_tx: &mpsc::UnboundedSender<String>,
432) -> Response<Full<Bytes>> {
433    fn find(results: &[ActionResult]) -> Option<serde_json::Value> {
434        for result in results {
435            match result {
436                ActionResult::Output(bytes) => {
437                    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
438                        if v.get("status").is_some() {
439                            return Some(v);
440                        }
441                    }
442                }
443                ActionResult::Multiple(inner) => {
444                    if let Some(v) = find(inner) {
445                        return Some(v);
446                    }
447                }
448                _ => {}
449            }
450        }
451        None
452    }
453
454    let Some(payload) = find(&results) else {
455        // Two different failures land here and the tag keeps them apart: the model emitted
456        // an action the executor rejected (`fail_closed_bad_action`), or it emitted nothing
457        // usable at all (`model_silent`). Both answer 503, and 503 is *not* a status the
458        // model is prevented from choosing — `send_webdav_status` will send one — so the log
459        // is the only place these stay distinct from a deliberate refusal.
460        let log = Log::new(Some(status_tx));
461        if had_action_failures {
462            log.error(format!(
463                "WebDAV {} {} decision=fail_closed_bad_action: the model's action(s) failed to \
464                 execute; answering 503",
465                method, path
466            ));
467        } else {
468            log.warn(format!(
469                "WebDAV {} {} decision=model_silent: no send_webdav_* action; answering 503",
470                method, path
471            ));
472        }
473        return build_safe_response(
474            503,
475            vec![("Content-Type".to_string(), "text/plain".to_string())],
476            "WebDAV server has no answer for this request".to_string(),
477        );
478    };
479
480    let status = payload
481        .get("status")
482        .and_then(|v| v.as_u64())
483        .unwrap_or(500)
484        .min(599) as u16;
485    let headers = payload
486        .get("headers")
487        .and_then(|v| v.as_object())
488        .map(|obj| {
489            obj.iter()
490                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
491                .collect::<Vec<_>>()
492        })
493        .unwrap_or_default();
494    let body = payload
495        .get("body")
496        .and_then(|v| v.as_str())
497        .unwrap_or("")
498        .to_string();
499
500    // WebDAV has a real refusal vocabulary (403, 409, 423 Locked, 507 Insufficient Storage),
501    // so a model that means to refuse says so with a status of its own. That is still the
502    // *model* deciding, which is why it is `model_reject` and never `fail_closed_*`.
503    let decision = if status >= 400 {
504        "model_reject"
505    } else {
506        "model_answer"
507    };
508    Log::new(Some(status_tx)).info(format!(
509        "WebDAV {} {} decision={}: answering {} ({} bytes)",
510        method,
511        path,
512        decision,
513        status,
514        body.len()
515    ));
516
517    build_safe_response(status, headers, body)
518}
519
520/// Handle one request, maintaining the per-connection counters on every exit path.
521///
522/// Semantics match the other hyper-based servers: one "packet" is one HTTP message, and the
523/// byte counts are message bodies only — hyper has parsed the request line and headers away
524/// before this sees them. `last_activity` matters beyond bookkeeping:
525/// `AppState::cleanup_old_connections` runs the 10s idle sweep only for servers that declare
526/// `ProtocolMetadataV2::connectionless`, which WebDAV does not — correctly, since clients hold
527/// keep-alive sessions open between bursts and pipeline PROPFIND/GET aggressively.
528async fn handle_webdav_request(
529    req: Request<Incoming>,
530    connection_id: ConnectionId,
531    server_id: crate::state::ServerId,
532    llm_client: OllamaClient,
533    app_state: Arc<AppState>,
534    status_tx: mpsc::UnboundedSender<String>,
535    protocol: Arc<WebDavProtocol>,
536) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
537    app_state
538        .update_connection_stats(server_id, connection_id, None, None, Some(1), None)
539        .await;
540
541    let response = handle_webdav_request_inner(
542        req,
543        connection_id,
544        server_id,
545        llm_client,
546        app_state.clone(),
547        status_tx,
548        protocol,
549    )
550    .await;
551
552    let bytes_sent = {
553        use hyper::body::Body;
554        response.body().size_hint().exact().unwrap_or(0)
555    };
556    app_state
557        .update_connection_stats(
558            server_id,
559            connection_id,
560            None,
561            Some(bytes_sent),
562            None,
563            Some(1),
564        )
565        .await;
566
567    Ok(response)
568}
569
570async fn handle_webdav_request_inner(
571    req: Request<Incoming>,
572    connection_id: ConnectionId,
573    server_id: crate::state::ServerId,
574    llm_client: OllamaClient,
575    app_state: Arc<AppState>,
576    status_tx: mpsc::UnboundedSender<String>,
577    protocol: Arc<WebDavProtocol>,
578) -> Response<Full<Bytes>> {
579    let request = extract_request(req).await;
580
581    if request.body_too_large {
582        Log::new(Some(&status_tx)).warn(format!(
583            "WebDAV {} {} decision=refused_body_too_large: body over {} bytes, answering 413 \
584             with no LLM call",
585            request.method, request.path, MAX_REQUEST_BODY
586        ));
587        return build_safe_response(
588            413,
589            vec![("Content-Type".to_string(), "text/plain".to_string())],
590            "WebDAV request body too large".to_string(),
591        );
592    }
593
594    Log::new(Some(&status_tx)).debug(format!(
595        "WebDAV request: {} {} ({} bytes)",
596        request.method,
597        request.path,
598        request.body.len()
599    ));
600    for (name, value) in &request.headers {
601        trace!("WebDAV header: {}: {}", name, value);
602    }
603
604    if !request.body.is_empty() {
605        app_state
606            .update_connection_stats(
607                server_id,
608                connection_id,
609                Some(request.body.len() as u64),
610                None,
611                None,
612                None,
613            )
614            .await;
615    }
616
617    // Handshake methods: answered by the server, no model call. These carry no content
618    // decision — OPTIONS advertises what this build can parse, and LOCK/UNLOCK exist so that
619    // clients which refuse to write without a lock (macOS Finder, the Windows redirector) can
620    // proceed. The lock is synthetic and never enforced; nothing consults it.
621    match request.method.as_str() {
622        "OPTIONS" => {
623            Log::new(Some(&status_tx)).debug(format!(
624                "WebDAV OPTIONS {} decision=static_answer: 200, capability advertisement, no \
625                 LLM call",
626                request.path
627            ));
628            return build_safe_response(
629                200,
630                // Content-Length is left to hyper: the empty body already yields 0, and
631                // setting it here as well risks a duplicated header.
632                vec![("Allow".to_string(), ALLOWED_METHODS.to_string())],
633                String::new(),
634            );
635        }
636        "LOCK" => {
637            let token = format!("opaquelocktoken:{}", uuid::Uuid::new_v4());
638            let body = format!(
639                "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
640                 <D:prop xmlns:D=\"DAV:\"><D:lockdiscovery><D:activelock>\
641                 <D:locktype><D:write/></D:locktype>\
642                 <D:lockscope><D:exclusive/></D:lockscope>\
643                 <D:depth>infinity</D:depth>\
644                 <D:timeout>Second-3600</D:timeout>\
645                 <D:locktoken><D:href>{}</D:href></D:locktoken>\
646                 </D:activelock></D:lockdiscovery></D:prop>",
647                token
648            );
649            // **LOCK always grants.** The model is never asked, nothing is recorded, and no
650            // request is ever refused with `423 Locked` on this path — so the grant is an
651            // affirmative answer the server invents on every call. It is deliberately NOT a
652            // `fail_closed_*` tag, because the peer got a lock token; `protocol_synthetic_lock`
653            // is the honest name and is grep-able for exactly that reason. See CLAUDE.md.
654            Log::new(Some(&status_tx)).warn(format!(
655                "WebDAV LOCK {} decision=protocol_synthetic_lock: granting an unconditional, \
656                 never-enforced lock; no LLM call and no refusal is possible here",
657                request.path
658            ));
659            return build_safe_response(
660                200,
661                vec![
662                    (
663                        "Content-Type".to_string(),
664                        "application/xml; charset=utf-8".to_string(),
665                    ),
666                    ("Lock-Token".to_string(), format!("<{}>", token)),
667                ],
668                body,
669            );
670        }
671        "UNLOCK" => {
672            Log::new(Some(&status_tx)).debug(format!(
673                "WebDAV UNLOCK {} decision=static_answer: 204, no LLM call (nothing was ever \
674                 locked)",
675                request.path
676            ));
677            return build_safe_response(204, Vec::new(), String::new());
678        }
679        _ => {}
680    }
681
682    // Everything else is a content decision, and belongs to the model.
683    let body_is_binary = std::str::from_utf8(&request.body).is_err();
684    let body_text = String::from_utf8_lossy(&request.body);
685    let mut event_data = serde_json::json!({
686        "method": request.method,
687        "path": request.path,
688        "headers": request.headers,
689        "body": body_text.as_ref(),
690        "body_bytes": request.body.len(),
691    });
692    if body_is_binary {
693        event_data["body_is_binary"] = serde_json::Value::Bool(true);
694    }
695    if let Some(depth) = request.headers.get("depth") {
696        event_data["depth"] = serde_json::Value::String(depth.clone());
697    }
698    if let Some(destination) = request.headers.get("destination") {
699        event_data["destination"] =
700            serde_json::Value::String(actions::percent_decode(&destination_path(destination)));
701    }
702    if let Some(overwrite) = request.headers.get("overwrite") {
703        event_data["overwrite"] = serde_json::Value::String(overwrite.to_ascii_uppercase());
704    }
705
706    let event = Event::new(&WEBDAV_REQUEST_EVENT, event_data);
707
708    match call_llm(
709        &llm_client,
710        &app_state,
711        server_id,
712        Some(connection_id),
713        &event,
714        protocol.as_ref(),
715    )
716    .await
717    {
718        Ok(execution_result) => {
719            // Read `failures` before `protocol_results` is moved into the builder: the
720            // decision has to be computed while both halves are still available.
721            let had_action_failures = !execution_result.failures.is_empty();
722            for msg in execution_result.messages {
723                let _ = status_tx.send(msg);
724            }
725            build_webdav_response(
726                execution_result.protocol_results,
727                had_action_failures,
728                &request.method,
729                &request.path,
730                &status_tx,
731            )
732        }
733        Err(e) => {
734            // An LLM failure is the server's fault, not the client's request being wrong, and
735            // it is distinct from both a refusal (whatever status the model chose) and a
736            // no-answer (503).
737            let decision = match crate::utils::WireFailure::classify(&e) {
738                crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
739                crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
740            };
741            Log::new(Some(&status_tx)).error(format!(
742                "WebDAV {} {} decision={}: answering 500: {}",
743                request.method, request.path, decision, e
744            ));
745            build_safe_response(
746                500,
747                vec![("Content-Type".to_string(), "text/plain".to_string())],
748                "WebDAV server error".to_string(),
749            )
750        }
751    }
752}
753
754/// Reduce a `Destination` header to a path.
755///
756/// RFC 4918 requires an absolute URI (`http://host/path`), but clients send both forms, and
757/// only the path is meaningful to a model that has no idea what host it is being addressed as.
758fn destination_path(destination: &str) -> String {
759    match destination.find("://") {
760        Some(scheme_end) => match destination[scheme_end + 3..].find('/') {
761            Some(path_start) => destination[scheme_end + 3 + path_start..].to_string(),
762            None => "/".to_string(),
763        },
764        None => destination.to_string(),
765    }
766}