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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

260 changes: 209 additions & 51 deletions crates/jcode-app-core/src/tool/discover.rs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions crates/jcode-app-core/src/tool/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1525,14 +1525,14 @@ fn the_dialect_sweep_catches_the_issue_754_schema() {
&jcode_schema_dialect::registry::GEMINI,
);
assert!(
unnormalized.iter().any(|e| e.message.contains("propertyNames")),
unnormalized
.iter()
.any(|e| e.message.contains("propertyNames")),
"the checker must flag the raw schema, got {unnormalized:?}"
);

let normalized = jcode_schema_dialect::dialect::apply(
&hostile,
&jcode_schema_dialect::registry::GEMINI,
);
let normalized =
jcode_schema_dialect::dialect::apply(&hostile, &jcode_schema_dialect::registry::GEMINI);
assert!(
jcode_schema_dialect::must_not_contain_unsupported_constructs(
&normalized,
Expand Down
20 changes: 13 additions & 7 deletions crates/jcode-base/src/gateway/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use super::{DeviceRegistry, resolve_connect_host};
/// Parsed `/remote` invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteCommand {
/// Activate or manage the subscription-backed Jcode Cloud host.
Cloud,
/// Show gateway state, dial address, and paired devices.
Status,
/// Enable the gateway in config.
Expand All @@ -39,11 +41,12 @@ pub fn parse_remote_command(input: &str) -> Option<Result<RemoteCommand, String>

let mut parts = rest.split_whitespace();
let Some(sub) = parts.next() else {
return Some(Ok(RemoteCommand::Status));
return Some(Ok(RemoteCommand::Cloud));
};

let command = match sub.to_ascii_lowercase().as_str() {
"status" => RemoteCommand::Status,
"cloud" | "setup" => RemoteCommand::Cloud,
"status" | "local" => RemoteCommand::Status,
"on" | "enable" => RemoteCommand::On,
"off" | "disable" => RemoteCommand::Off,
"pair" => RemoteCommand::Pair,
Expand All @@ -57,15 +60,15 @@ pub fn parse_remote_command(input: &str) -> Option<Result<RemoteCommand, String>
}
other => {
return Some(Err(format!(
"Unknown /remote subcommand: {other}\nUsage: /remote [status|on|off|pair|revoke <device>]"
"Unknown /remote subcommand: {other}\nUsage: /remote [cloud|status|on|off|pair|revoke <device>]"
)));
}
};

// Only `revoke` takes an argument.
if !matches!(command, RemoteCommand::Revoke(_)) && parts.next().is_some() {
return Some(Err(format!(
"/remote {sub} takes no arguments\nUsage: /remote [status|on|off|pair|revoke <device>]"
"/remote {sub} takes no arguments\nUsage: /remote [cloud|status|on|off|pair|revoke <device>]"
)));
}

Expand Down Expand Up @@ -304,21 +307,24 @@ mod tests {
}

#[test]
fn bare_remote_shows_status() {
fn bare_remote_starts_cloud_activation() {
assert_eq!(
parse_remote_command("/remote"),
Some(Ok(RemoteCommand::Status))
Some(Ok(RemoteCommand::Cloud))
);
assert_eq!(
parse_remote_command(" /remote "),
Some(Ok(RemoteCommand::Status))
Some(Ok(RemoteCommand::Cloud))
);
}

#[test]
fn subcommands_and_aliases_parse() {
for (input, expected) in [
("/remote status", RemoteCommand::Status),
("/remote cloud", RemoteCommand::Cloud),
("/remote setup", RemoteCommand::Cloud),
("/remote local", RemoteCommand::Status),
("/remote on", RemoteCommand::On),
("/remote enable", RemoteCommand::On),
("/remote off", RemoteCommand::Off),
Expand Down
75 changes: 72 additions & 3 deletions crates/jcode-base/src/mcp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,12 @@ impl McpClient {
name, config.command, config.args, working_dir
));

let mut env: HashMap<String, String> = std::env::vars().collect();
env.extend(config.env.clone());
// Credentials must be opted into an MCP server explicitly through its
// config. The long-lived jcode daemon contains provider credentials in
// its process environment, and blindly inheriting them exposes those
// credentials to every configured MCP executable (issue #771).
let inherited: HashMap<String, String> = std::env::vars().collect();
let env = mcp_child_env(inherited, &config.env);

let mut command = Command::new(&config.command);
command
Expand Down Expand Up @@ -364,6 +368,34 @@ impl McpClient {
}
}

/// Secrets that an MCP child must not receive merely because jcode has them.
///
/// This intentionally applies only to inherited values. A server can still be
/// given any of these names through `McpServerConfig::env`.
fn is_sensitive_inherited_env_key(key: &str) -> bool {
let key = key.to_ascii_uppercase();
key.ends_with("_API_KEY")
|| key.ends_with("_ACCESS_TOKEN")
|| key.ends_with("_AUTH_TOKEN")
|| matches!(
key.as_str(),
"AWS_ACCESS_KEY_ID"
| "AWS_SECRET_ACCESS_KEY"
| "AWS_SESSION_TOKEN"
| "AZURE_CLIENT_SECRET"
| "GOOGLE_APPLICATION_CREDENTIALS"
)
}

fn mcp_child_env(
mut inherited: HashMap<String, String>,
explicit: &HashMap<String, String>,
) -> HashMap<String, String> {
inherited.retain(|key, _| !is_sensitive_inherited_env_key(key));
inherited.extend(explicit.clone());
inherited
}

impl Drop for McpClient {
fn drop(&mut self) {
let _ = self.child.start_kill();
Expand All @@ -372,8 +404,45 @@ impl Drop for McpClient {

#[cfg(all(test, unix))]
mod tests {
use super::McpClient;
use super::{McpClient, is_sensitive_inherited_env_key, mcp_child_env};
use crate::mcp::protocol::McpServerConfig;
use std::collections::HashMap;

#[test]
fn inherited_mcp_env_scrubs_provider_credentials() {
for key in [
"ANTHROPIC_API_KEY",
"openai_api_key",
"CURSOR_ACCESS_TOKEN",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GOOGLE_APPLICATION_CREDENTIALS",
] {
assert!(is_sensitive_inherited_env_key(key), "must scrub {key}");
}
for key in ["PATH", "HOME", "RUST_LOG", "JCODE_OPENROUTER_API_KEY_NAME"] {
assert!(!is_sensitive_inherited_env_key(key), "must preserve {key}");
}
}

#[test]
fn explicit_mcp_env_can_opt_a_credential_back_in() {
let inherited = HashMap::from([
("PATH".to_string(), "/bin".to_string()),
("ANTHROPIC_API_KEY".to_string(), "daemon-secret".to_string()),
]);
let explicit = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
"server-specific-secret".to_string(),
)]);

let env = mcp_child_env(inherited, &explicit);
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
assert_eq!(
env.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("server-specific-secret")
);
}

/// A minimal fake stdio MCP server (shell script) that reports its own
/// process cwd as the serverInfo name.
Expand Down
26 changes: 22 additions & 4 deletions crates/jcode-build-support/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,20 @@ pub fn selfdev_build_command_for_target(
};
let specs = match target {
SelfDevBuildTarget::Tui => vec![("jcode", "jcode")],
SelfDevBuildTarget::Desktop2 => vec![("jcode-desktop2", "jcode-desktop2")],
// desktop2 launches the harness API bridge as a sibling executable.
// Building only the app leaves a fresh target directory unable to
// start its runtime because the bridge is neither beside it nor on
// PATH.
SelfDevBuildTarget::Desktop2 => vec![
("jcode-desktop2", "jcode-desktop2"),
("jcode-harness-api-server", "jcode-harness-api-bridge"),
],
SelfDevBuildTarget::All | SelfDevBuildTarget::Auto => {
vec![("jcode", "jcode"), ("jcode-desktop2", "jcode-desktop2")]
vec![
("jcode", "jcode"),
("jcode-desktop2", "jcode-desktop2"),
("jcode-harness-api-server", "jcode-harness-api-bridge"),
]
}
};
let wrapper = repo_dir.join("scripts").join("dev_cargo.sh");
Expand Down Expand Up @@ -637,10 +648,17 @@ mod tests {
let repo = repo_fixture(false);
let cases = [
(SelfDevBuildTarget::Tui, vec!["-p jcode "]),
(SelfDevBuildTarget::Desktop2, vec!["-p jcode-desktop2 "]),
(
SelfDevBuildTarget::Desktop2,
vec!["-p jcode-desktop2 ", "--bin jcode-harness-api-bridge"],
),
(
SelfDevBuildTarget::All,
vec!["-p jcode ", "-p jcode-desktop2 "],
vec![
"-p jcode ",
"-p jcode-desktop2 ",
"--bin jcode-harness-api-bridge",
],
),
];
for (target, expected) in cases {
Expand Down
Loading
Loading