Skip to content
Open
90 changes: 75 additions & 15 deletions apps/native/crates/local-api/src/routes/intercept/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
//! | `POST /api/:org/tools/COLLECTION_THREAD_MESSAGES_LIST` | §3.1 | [`thread_tools`] |
//! | `GET /api/:org/watch` | local thread lifecycle SSE | [`watch`] |
//! | `POST /api/:org/sandbox/:virtualMcpId/:branch/{read,write,unlink,mkdir,rename,glob,grep}` | native sandbox filesystem bridge | [`sandbox_fs`] |
//! | any `/api/:org/sandbox/*` carrying [`FAST_PREVIEW_HEADER`] | sandbox-less by definition | never intercepted (`None`) |
//! | any `/api/:org/decopilot/*` | retired native chat transport | local `410`, never forwarded |
//! | any other `/api/:org/tools/:toolName` | — | not intercepted (`None`) |
//!
Expand Down Expand Up @@ -73,21 +74,27 @@ pub(crate) use sandbox_lifecycle::{
pub(crate) mod watch;

use axum::body::Bytes;
use axum::http::Method;
use axum::http::{HeaderMap, Method};
use axum::response::{IntoResponse, Response};

use crate::error::ApiError;
use crate::state::AppState;

/// The single entry point `routes/upstream.rs::proxy` calls before its
/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare
/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or
/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to
/// strip anymore) with its query string supplied separately for `/watch`'s
/// optional `types` filter. Other intercepted routes do not read query
/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride
/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim"
/// contract, map §3.1.
/// Header the webview sets when the project it is acting for is Fast Preview.
///
/// A routing hint, not a trust boundary: it only decides whether to answer
/// LOCALLY, and upstream re-derives the flag from the vMCP's own metadata
/// before serving anything. A wrong value can therefore only send the request
/// to the authority, never around it.
pub const FAST_PREVIEW_HEADER: &str = "x-deco-fast-preview";

fn declares_fast_preview(headers: &HeaderMap) -> bool {
headers
.get(FAST_PREVIEW_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
}

/// Whether `<segment>` in `/api/<segment>/<rest…>` is really an organization.
///
/// NOT every second path segment is one: `/api/config`, `/api/auth/*` and the
Expand All @@ -103,12 +110,22 @@ fn is_org_scoped(segment: &str, rest: &[&str]) -> bool {
!segment.starts_with('_') && rest.first() == Some(&"tools")
}

/// The single entry point `routes/upstream.rs::proxy` calls before its
/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare
/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or
/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to
/// strip anymore) with its query string supplied separately for `/watch`'s
/// optional `types` filter. Other intercepted routes do not read query
/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride
/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim"
/// contract, map §3.1.
pub async fn try_intercept(
state: &AppState,
method: &Method,
path: &str,
query: Option<&str>,
body: &Bytes,
headers: &HeaderMap,
) -> Option<Response> {
let mut segs = path.trim_start_matches('/').split('/');
if segs.next()? != "api" {
Expand All @@ -117,6 +134,17 @@ pub async fn try_intercept(
let org = segs.next().filter(|s| !s.is_empty())?;
let rest: Vec<&str> = segs.collect();

// Fast Preview is sandbox-less by definition, so nothing under
// `/sandbox/*` can be answered from this machine — upstream serves those
// routes from the GitHub API. Declining here rather than letting each
// sandbox interceptor decide is what makes the flag authoritative: the
// worktree handle is derived from the REPOSITORY, so a desktop sandbox
// left over from vibecoding on the same repo otherwise claims the route
// for a branch it has never checked out.
if rest.first().copied() == Some("sandbox") && declares_fast_preview(headers) {
return None;
}

// Start warming this organization's filesystem. A genuinely org-scoped
// request here is exactly "the app booted into this org" or "the user
// switched to it", so the mounts come up while the user is still
Expand Down Expand Up @@ -254,6 +282,30 @@ mod tests {
assert!(is_org_scoped("gimenes-guarana-works", &["tools", "X"]));
}

/// The regression: the worktree handle is derived from the REPOSITORY, so
/// a desktop sandbox left over from vibecoding on the same repo claimed
/// `/sandbox/*/git/*` for a Fast Preview branch it had never checked out
/// and answered `repository not initialized` — forever, since the query
/// stopped retrying.
#[test]
fn fast_preview_is_declared_by_the_header_only_when_set() {
use axum::http::{HeaderMap, HeaderValue};

let mut on = HeaderMap::new();
on.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("1"));
assert!(super::declares_fast_preview(&on));

let mut worded = HeaderMap::new();
worded.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("TRUE"));
assert!(super::declares_fast_preview(&worded));

let mut off = HeaderMap::new();
off.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("0"));
assert!(!super::declares_fast_preview(&off));

assert!(!super::declares_fast_preview(&HeaderMap::new()));
}

use super::*;

#[tokio::test]
Expand All @@ -266,6 +318,7 @@ mod tests {
"/api/acme/tools/SOME_OTHER_TOOL",
None,
&Bytes::from_static(b"{}"),
&HeaderMap::new(),
)
.await;
assert!(res.is_none());
Expand All @@ -275,17 +328,23 @@ mod tests {
async fn non_org_scoped_paths_are_not_intercepted() {
let dir = tempfile::tempdir().unwrap();
let state = test_state(dir.path());
assert!(
try_intercept(&state, &Method::GET, "/api/config", None, &Bytes::new(),)
.await
.is_none()
);
assert!(try_intercept(
&state,
&Method::GET,
"/api/config",
None,
&Bytes::new(),
&HeaderMap::new(),
)
.await
.is_none());
assert!(try_intercept(
&state,
&Method::GET,
"/api/auth/get-session",
None,
&Bytes::new(),
&HeaderMap::new(),
)
.await
.is_none());
Expand All @@ -301,6 +360,7 @@ mod tests {
"/api/acme/decopilot/some-future-route",
None,
&Bytes::new(),
&HeaderMap::new(),
)
.await;
let res = res
Expand Down
11 changes: 9 additions & 2 deletions apps/native/crates/local-api/src/routes/upstream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,15 @@ pub async fn proxy(State(state): State<AppState>, req: Request) -> Response {
// whether there's a valid session), so it must never wait on, or be
// gated by, this proxy's auth machinery. See that module's doc comment
// for the full route table and the map citations behind each entry.
if let Some(response) =
intercept::try_intercept(&state, &parts.method, &path, parts.uri.query(), &body_bytes).await
if let Some(response) = intercept::try_intercept(
&state,
&parts.method,
&path,
parts.uri.query(),
&body_bytes,
&parts.headers,
)
.await
{
return response;
}
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/sections-editor/use-delete-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ export function useDeleteBlock({
{ delete: [blockKey] },
);
setDecofileDraft(queryClient, { orgSlug, virtualMcpId, branch }, draft);
// Same as use-save-block: the commit moved the head, refresh the
// header's branch meta in place of any interval polling.
void queryClient.invalidateQueries({
/**
* Same as use-save-block: the commit moved the head, refresh the
* header's branch meta in place of any interval polling — and await
* it, so observers of this mutation aren't released onto a status
* that is still the pre-delete one.
*/
await queryClient.invalidateQueries({
queryKey: sandboxGitStatusQueryKey(orgSlug, virtualMcpId, branch),
});
return { ok: true as const, existed: true };
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/sections-editor/use-save-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@ export function useSaveBlock({
{ set: { [blockKey]: data } },
);
setDecofileDraft(queryClient, { orgSlug, virtualMcpId, branch }, draft);
// The landed commit moved the branch head — refresh the header's
// branch meta now. This write is the ONLY in-app head mutation, which
// is what lets the status query drop interval polling entirely.
void queryClient.invalidateQueries({
/**
* The landed commit moved the branch head — refresh the header's
* branch meta now. This write is the ONLY in-app head mutation, which
* is what lets the status query drop interval polling entirely.
*
* Awaited, not fired and forgotten: observers key "is a save in
* flight" off this mutation, and releasing them before the re-read
* lands renders the PREVIOUS status as if it were current — a clean
* "Up to date" over an edit that already exists.
*/
await queryClient.invalidateQueries({
queryKey: sandboxGitStatusQueryKey(orgSlug, virtualMcpId, branch),
});
return draft;
Expand Down
Loading
Loading