Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f64b06a
fix(mcp): answer reset-refused tools/call with a typed problem
cursoragent Aug 19, 2026
0814d20
fix(daemon-client): read authoritative effects over the response grace
cursoragent Aug 19, 2026
45414c9
feat(sdk): admit the local daemon loopback mount as a remote target
cursoragent Aug 19, 2026
aad875b
feat(cli): mount remote capture, query, and transfer-frame journeys
cursoragent Aug 19, 2026
d707766
Merge branch 'codex/tracedecay-total-redesign-plan' into cursor/remot…
ScriptedAlchemy Aug 19, 2026
f0dacc1
fix(daemon-client): type post-cancel transport failures as indeterminate
cursoragent Aug 19, 2026
175f4ba
Merge branch 'cursor/remote-parity-typed-terminals-c299' of https://g…
cursoragent Aug 19, 2026
3df1e16
docs(next): record green typed-terminal transport legs
cursoragent Aug 19, 2026
3c0f269
fix(sdk): never route the loopback remote target through a proxy
cursoragent Aug 19, 2026
bd1e080
fix(sdk): never proxy loopback remote daemon requests
cursoragent Aug 19, 2026
d1973f4
fix(cli): compose the caller's own spool evidence for remote query
cursoragent Aug 19, 2026
73ea3c9
style(daemon): tighten remote parity and typed-terminal comments
cursoragent Aug 19, 2026
138256c
merge: adopt the shared loopback no-proxy fix and single test
cursoragent Aug 19, 2026
bda96db
Merge remote-tracking branch 'origin/codex/tracedecay-total-redesign-…
ScriptedAlchemy Aug 19, 2026
4bc2e1c
Merge branch 'cursor/remote-parity-typed-terminals-c299' of github.co…
ScriptedAlchemy Aug 19, 2026
f4c6303
merge: adopt base fmt/clippy cleanliness
cursoragent Aug 19, 2026
fe41d79
style(clippy): simplify the liveness-probe skip in invocation tests
cursoragent Aug 19, 2026
8b3031c
merge: adopt base windows build fixes
cursoragent Aug 19, 2026
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
96 changes: 91 additions & 5 deletions crates/tracedecay-sdk/src/remote_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ impl EnrolledRemoteClient {
credential: impl AsRef<[u8]>,
timeout: Duration,
) -> Result<Self, RemoteClientError> {
Self::build(endpoint, credential, timeout, None)
Self::build(endpoint, credential, timeout, None, false)
}

/// Builds a client with one explicit additional HTTPS trust root.
Expand All @@ -107,27 +107,49 @@ impl EnrolledRemoteClient {
credential,
timeout,
Some(root_certificate_pem.as_ref()),
false,
)
}

/// Targets the local daemon's application listener, which nests the same
/// Remote Brain router at `/remote` as the external TLS listener. Same
/// operations, envelopes, credential header, and response validation;
/// plaintext HTTP is admitted for loopback hosts only.
pub fn new_local_daemon(
endpoint: impl AsRef<str>,
credential: impl AsRef<[u8]>,
timeout: Duration,
) -> Result<Self, RemoteClientError> {
Self::build(endpoint, credential, timeout, None, true)
}

fn build(
endpoint: impl AsRef<str>,
credential: impl AsRef<[u8]>,
timeout: Duration,
root_certificate_pem: Option<&[u8]>,
allow_loopback_http: bool,
) -> Result<Self, RemoteClientError> {
let endpoint = reqwest::Url::parse(endpoint.as_ref())
.map_err(|error| RemoteClientError::Configuration(error.to_string()))?;
if endpoint.scheme() != "https"
let scheme_admitted = match endpoint.scheme() {
"https" => true,
"http" => allow_loopback_http && host_is_loopback(&endpoint),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable proxies for loopback HTTP clients

When HTTP_PROXY/ALL_PROXY is configured and the loopback host is not excluded by NO_PROXY, reqwest's default system-proxy behavior can route this newly admitted plaintext request through the proxy. Because execute_mounted attaches the enrollment credential as a Bearer header, the credential then leaves the machine unencrypted despite the hostname check; disable proxy use whenever constructing the loopback-HTTP client.

Useful? React with 👍 / 👎.

_ => false,
};
if !scheme_admitted
|| endpoint.host_str().is_none()
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
|| endpoint.username() != ""
|| endpoint.password().is_some()
{
return Err(RemoteClientError::Configuration(
"Remote Brain endpoint must be a credential-free HTTPS URL".to_owned(),
));
return Err(RemoteClientError::Configuration(if allow_loopback_http {
"local daemon Remote Brain endpoint must be a credential-free loopback HTTP or HTTPS URL"
.to_owned()
} else {
"Remote Brain endpoint must be a credential-free HTTPS URL".to_owned()
}));
}
let credential = credential.as_ref();
if validate_remote_secret_length(credential).is_err() {
Expand All @@ -139,6 +161,13 @@ impl EnrolledRemoteClient {
HeaderValue::from_bytes([b"Bearer ".as_slice(), credential].concat().as_slice())
.map_err(|error| RemoteClientError::Configuration(error.to_string()))?;
let mut builder = HttpClient::builder().timeout(timeout);
if endpoint.scheme() == "http" {
// The loopback-only plaintext admission above is void if a system
// proxy (`HTTP_PROXY`/`ALL_PROXY`) re-routes the request: the
// Bearer enrollment credential would leave the machine
// unencrypted. The loopback target never needs a proxy.
builder = builder.no_proxy();
}
if let Some(pem) = root_certificate_pem {
let mut certificates = reqwest::Certificate::from_pem_bundle(pem)
.map_err(|error| RemoteClientError::Configuration(error.to_string()))?;
Expand Down Expand Up @@ -356,6 +385,22 @@ impl EnrolledRemoteClient {
}
}

/// Whether the endpoint host is a loopback address; any other host requires
/// HTTPS.
fn host_is_loopback(endpoint: &reqwest::Url) -> bool {
let Some(host) = endpoint.host_str() else {
return false;
};
let address = host
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or(host);
if let Ok(address) = address.parse::<std::net::IpAddr>() {
return address.is_loopback();
}
host.eq_ignore_ascii_case("localhost")
}

fn credential_header(credential: &[u8]) -> Result<HeaderValue, RemoteClientError> {
if validate_remote_secret_length(credential).is_err() {
return Err(RemoteClientError::Configuration(
Expand Down Expand Up @@ -547,6 +592,47 @@ mod tests {
assert!(matches!(error, RemoteClientError::Configuration(_)));
}

#[test]
fn local_daemon_target_admits_loopback_http_only() {
let credential = "0123456789abcdef0123456789abcdef";
for endpoint in [
"http://127.0.0.1:39181/remote/",
"http://[::1]:39181/remote/",
"http://localhost:39181/remote/",
"https://remote.example/remote/",
] {
EnrolledRemoteClient::new_local_daemon(endpoint, credential, Duration::from_secs(1))
.unwrap_or_else(|error| {
panic!("local daemon target must admit {endpoint}: {error}")
});
}

for endpoint in [
"http://remote.example/remote/",
"http://10.0.0.7:39181/remote/",
"ftp://127.0.0.1/remote/",
] {
let error = EnrolledRemoteClient::new_local_daemon(
endpoint,
credential,
Duration::from_secs(1),
)
.expect_err("plaintext beyond loopback must fail closed");
assert!(matches!(error, RemoteClientError::Configuration(_)));
}
}

#[test]
fn enrolled_remote_target_still_refuses_loopback_http() {
let error = EnrolledRemoteClient::new(
"http://127.0.0.1:39181/remote/",
"0123456789abcdef0123456789abcdef",
Duration::from_secs(1),
)
.expect_err("the enrolled remote target must stay HTTPS-only");
assert!(matches!(error, RemoteClientError::Configuration(_)));
}

#[test]
fn enrolled_remote_client_rejects_url_credentials() {
let error = EnrolledRemoteClient::new(
Expand Down
105 changes: 105 additions & 0 deletions crates/tracedecay-sdk/tests/remote_client_proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! The plaintext loopback Remote Brain target must never traverse a system
//! proxy: `HTTP_PROXY`/`ALL_PROXY` would carry the Bearer enrollment
//! credential off the machine unencrypted, defeating the loopback-only
//! plaintext admission. This test lives in its own integration binary because
//! it mutates the process-wide proxy environment.

use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;

use tracedecay_application::RequestId;
use tracedecay_application::remote::protocol::{EnrollmentRequestV1, RemoteProtocolRequestV1};
use tracedecay_domain::{
BrainId, BrainNodeId, EntityId, ProjectId, RefId, RemoteCapabilityV1, RemoteRepositoryScopeV1,
RepositoryId, RepositoryStateSnapshotId, UtcMicros, WorktreeId,
};
use tracedecay_sdk::remote_client::{EnrolledRemoteClient, RemoteClientError};

#[test]
fn loopback_http_request_never_routes_through_a_system_proxy() {
let proxy = TcpListener::bind("127.0.0.1:0").expect("bind proxy listener");
let proxy_address = proxy.local_addr().expect("proxy address");
let target = TcpListener::bind("127.0.0.1:0").expect("bind target listener");
let target_port = target.local_addr().expect("target address").port();
let server = thread::spawn(move || {
let (mut stream, _) = target.accept().expect("direct loopback connection");
let mut head = [0u8; 2048];
let _ = stream.read(&mut head).expect("read request head");
stream
.write_all(
b"HTTP/1.1 200 OK\r\n\
content-type: application/json\r\n\
content-length: 2\r\n\
connection: close\r\n\r\n{}",
)
.expect("write canned response");
});

// SAFETY: this integration binary holds only this test, so no other
// thread reads or writes the process environment concurrently.
unsafe {
std::env::set_var("HTTP_PROXY", format!("http://{proxy_address}"));
std::env::set_var("ALL_PROXY", format!("http://{proxy_address}"));
}
let client = EnrolledRemoteClient::new_local_daemon(
format!("http://127.0.0.1:{target_port}/remote/"),
"0123456789abcdef0123456789abcdef",
Duration::from_secs(5),
);
// SAFETY: as above.
unsafe {
std::env::remove_var("HTTP_PROXY");
std::env::remove_var("ALL_PROXY");
}
let client = client.expect("loopback client must build under a proxy environment");

let outcome = client.enroll(&enrollment_request(), *b"fedcba9876543210fedcba9876543210");
// A protocol error proves the request reached the direct loopback target
// and got the canned garbage back; a proxied request would have died in
// transport against the never-accepting proxy listener instead.
match outcome {
Err(RemoteClientError::Protocol(_)) => {}
Err(error) => panic!("expected a protocol error from the direct target, got {error:?}"),
Ok(_) => panic!("the canned non-canonical response must fail as protocol"),
}
server.join().expect("target server thread");

proxy
.set_nonblocking(true)
.expect("nonblocking proxy accept");
match proxy.accept() {
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Ok(_) => panic!("the loopback request must never reach the system proxy"),
Err(error) => panic!("proxy accept failed: {error}"),
}
}

fn enrollment_request() -> RemoteProtocolRequestV1<EnrollmentRequestV1> {
let brain_id = BrainId::new("brain.remote-proxy-test").unwrap();
let node_id = BrainNodeId::new("node.remote-proxy-test").unwrap();
RemoteProtocolRequestV1::new_initial_enrollment(
RequestId::new("request.remote-proxy-test").unwrap(),
brain_id.clone(),
node_id.clone(),
UtcMicros(1_000_000),
EnrollmentRequestV1 {
grant_id: EntityId::new("grant.remote-proxy-test").unwrap(),
grant_revision: 1,
enrollment_id: EntityId::new("enrollment.remote-proxy-test").unwrap(),
brain_id,
node_id,
expires_at: UtcMicros(600_000_000),
capabilities: [RemoteCapabilityV1::Query].into_iter().collect(),
scope: RemoteRepositoryScopeV1 {
project_id: ProjectId::new("project.remote-proxy-test").unwrap(),
repository_id: RepositoryId::new("repository.remote-proxy-test").unwrap(),
worktree_id: WorktreeId::new("worktree.remote-proxy-test").unwrap(),
reference: Some(RefId::new("refs/heads/remote-proxy-test").unwrap()),
snapshot_id: RepositoryStateSnapshotId::new("snapshot.remote-proxy-test").unwrap(),
},
},
)
.unwrap()
}
30 changes: 15 additions & 15 deletions docs/plans/tracedecay-v2/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,21 +472,21 @@ the commit as attribution evidence.
CLI, and both SDKs. Prove the partial-effect committed receipt and reset-only
legal action survive each boundary and physical daemon restart; unit mappers
and synthetic SDK envelopes are necessary but not final journey evidence.
PARTIAL, updated 2026-08-15: both CLI legs are proven green in a clean
worktree at `d7c4a4c43` PartialEffect (`e56fadeff` lineage) AND the
ResetRequired leg, which was un-`#[ignore]`d by `c962cd627` after the
project-open settling gap was fixed; the reset-only legal action survives
a physical restart via CLI. The HTTP/MCP/SDK legs are in flight in
`tests/typed_terminal_restart_acceptance/transport_boundaries.rs`
(checkpoint `29a591519`; module made resolvable by `2d63021e6`). First
full run of those WIP legs, 2026-08-15: both fail with actionable
reasons — `partial_effect_survives_http_mcp_and_rust_sdk_across_restart`
settles without ever reaching the durable commit boundary, and
`reset_required_survives_http_mcp_and_rust_sdk_across_restart` gets a raw
JSON-RPC `-32603` whose `data` carries
`kind:"reset_required"`/reason/retryable instead of the typed problem
envelope the assertion requires (the MCP surface is not wrapping the
reset terminal as a problem envelope).
DONE 2026-08-19: all four legs are green. The CLI legs were proven at
`d7c4a4c43` (PartialEffect via the `e56fadeff` lineage; ResetRequired
un-`#[ignore]`d by `c962cd627`). The HTTP/MCP/Rust-SDK legs in
`tests/typed_terminal_restart_acceptance/transport_boundaries.rs` pass
2/2 after two production fixes: a `tools/call` refused at project open
with `ResetRequired` now answers on the MCP tool surface with the
canonical problem envelope under the operation's own MCP result contract
(`mcp_project_open_reset_refusal`; previously a raw JSON-RPC `-32603`),
and the socket `DaemonInvocationClient` keeps reading an authoritative
effect over `DAEMON_TOOL_RESPONSE_GRACE` after cancel delivery instead of
fabricating `ResetRequired` at the two-second shutdown bound — a
post-cancel transport failure stays the typed indeterminate settlement,
mirroring `settle_in_process_invocation`. Both journeys prove the
partial-effect committed receipt and reset-only legal action across a
physical daemon restart.
- DONE 2026-08-10 (verified 2026-08-13): the ten `schema_unavailable`
application bindings were repaired in `d2b094ca7` — the primitive-surface
read operations gained typed schemas in
Expand Down
27 changes: 27 additions & 0 deletions src/application_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4162,6 +4162,33 @@ fn http_adapter_problem(
.map(|problem| problem.with_owning_layer(ProblemOwningLayer::Adapter))
}

/// The canonical typed terminal for an MCP `tools/call` whose project open
/// was refused because the store requires an explicit reset.
///
/// The refusal settles before any project server exists, so the MCP boundary
/// cannot route the call to its handler; the truthful answer for the named
/// operation is the reset-required terminal under its own mounted MCP result
/// contract. Returns `None` for tools without a mounted application binding.
pub(crate) fn mcp_project_open_reset_refusal(
tool_name: &str,
request_id: RequestId,
authority: &str,
reason: &str,
) -> Option<ApplicationProblemEnvelope> {
let operation = ApplicationSurfaceOperation::from_tool_name(tool_name)?;
let catalog = application_surface_catalog_ref().ok()?;
let resolver = CatalogBindingResolver::new(catalog);
let binding = resolve_application_binding(&resolver, BindingSurface::Mcp, operation)?;
let contract = ResultContractRef::from_schema(&binding.result_schema);
let problem = ApplicationProblem::reset_required(SafeDiagnostic {
code: "application.surface.reset_required".to_owned(),
message: format!("The {authority} requires an explicit reset: {reason}"),
});
ApplicationProblemEnvelope::new(contract, request_id, problem)
.ok()
.map(|envelope| envelope.with_owning_layer(ProblemOwningLayer::Runtime))
}

fn current_micros() -> Result<UtcMicros, ApplicationSurfaceAdapterError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down
25 changes: 25 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,22 @@ pub enum RemoteAction {
#[arg(long, value_name = "FILE")]
enrollment_credential_file: PathBuf,
},
/// Capture one observation into a node's offline spool while the
/// authority is unreachable
Capture {
#[command(flatten)]
authority: RemoteAuthorityArgs,
},
/// Query one exact observation with honest local/remote coverage
Query {
#[command(flatten)]
authority: RemoteAuthorityArgs,
},
/// Transfer one encrypted offline-capture frame to an enrolled node's spool
TransferFrame {
#[command(flatten)]
authority: RemoteAuthorityArgs,
},
/// Replay pending offline-capture frames through the current authority
Replay {
#[command(flatten)]
Expand Down Expand Up @@ -843,6 +859,15 @@ impl From<RemoteAction> for tracedecay::remote_command::RemoteCommand {
args: authority.into(),
enrollment_credential_file,
},
RemoteAction::Capture { authority } => Self::Capture {
args: authority.into(),
},
RemoteAction::Query { authority } => Self::Query {
args: authority.into(),
},
RemoteAction::TransferFrame { authority } => Self::TransferFrame {
args: authority.into(),
},
RemoteAction::Replay { authority } => Self::Replay {
args: authority.into(),
},
Expand Down
22 changes: 16 additions & 6 deletions src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,12 +637,16 @@ status (one project's statistics).";

pub(crate) const REMOTE_LONG_ABOUT: &str = "\
Operates the Remote Brain production journey from the shell: live mounted \
status from the running daemon, plus enrolled enroll/replay/backup/restore/\
failover against an authenticated authority endpoint. `status` never probes \
local stores; it reads the daemon's in-memory remote mount. Protocol actions \
take a typed `RemoteProtocolRequestV1` JSON body from `--request-file` \
(`-` reads stdin) and send credentials only from files. `--json` emits one \
canonical JSON line.";
status from the running daemon, plus enrolled enroll/capture/query/\
transfer-frame/replay/backup/restore/failover against an authenticated \
authority endpoint. `status` never probes local stores; it reads the \
daemon's in-memory remote mount. `capture` spools one observation on a node \
whose authority is unreachable, `transfer-frame` moves one encrypted spool \
frame to an enrolled node, and `query` reads one exact observation and \
renders the honest remote shard coverage next to the caller's own pending \
local spool. Protocol actions take a typed `RemoteProtocolRequestV1` JSON \
body from `--request-file` (`-` reads stdin) and send credentials only from \
files. `--json` emits one canonical JSON line.";

pub(crate) const REMOTE_AFTER_HELP: &str = "\
Examples:
Expand All @@ -651,6 +655,12 @@ Examples:
tracedecay remote enroll --endpoint https://brain.example/remote/ \\
--credential-file grant.bin --enrollment-credential-file enroll.bin \\
--request-file enroll.json --json
tracedecay remote capture --endpoint https://node.example/remote/ \\
--credential-file cred.bin --request-file capture.json --json
tracedecay remote query --endpoint https://brain.example/remote/ \\
--credential-file cred.bin --request-file query.json
tracedecay remote transfer-frame --endpoint https://peer.example/remote/ \\
--credential-file cred.bin --request-file frame.json --json
tracedecay remote replay --endpoint https://brain.example/remote/ \\
--credential-file cred.bin --request-file replay.json
tracedecay remote backup --endpoint https://brain.example/remote/ \\
Expand Down
Loading
Loading