fix(#108): restreint Code Mode Codex au namespace MCP IdeA et documente les tools au démarrage
Codex Code Mode pouvait appeler n'importe quel outil MCP directement, contournant la médiation d'approbation. On force [features.code_mode].direct_only_tool_namespaces = ["mcp__idea"] sur chaque surface qui écrit le config.toml Codex (permission projector, lifecycle, migration run-dir, assistant de ticket), et on ajoute initialize.instructions côté serveur MCP pour orienter Codex vers le bon outil idea_* dès la connexion, sans dépendre de la recherche sémantique différée. QA : domain 283/0, application 126/0 + agent_lifecycle 73/0 + change_agent_profile 19/0 + ticket_assistant 5/0, infrastructure 339/0 dont mcp_server 37/0, backend 68/0 (7 ignored). Les échecs web-server observés sur cargo test --workspace (Too many open files, cookies) sont une contamination de ressources inter-tests ; les deux tests concernés repassent isolément. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -20,7 +20,10 @@ use domain::ports::{
|
|||||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
||||||
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
use domain::profile::{
|
||||||
|
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TOML,
|
||||||
|
};
|
||||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||||
use domain::{
|
use domain::{
|
||||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||||
@ -3118,6 +3121,11 @@ fn codex_config_toml(
|
|||||||
text = set_top_level_toml_value(&text, "model", model);
|
text = set_top_level_toml_value(&text, "model", model);
|
||||||
}
|
}
|
||||||
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
||||||
|
text = replace_toml_table(
|
||||||
|
&text,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TABLE,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TOML.trim_end(),
|
||||||
|
);
|
||||||
text = ensure_codex_trust(&text, run_dir);
|
text = ensure_codex_trust(&text, run_dir);
|
||||||
text = ensure_codex_trust(&text, project_root);
|
text = ensure_codex_trust(&text, project_root);
|
||||||
if !text.ends_with('\n') {
|
if !text.ends_with('\n') {
|
||||||
|
|||||||
@ -3089,6 +3089,16 @@ impl PermissionProjector for FakeClaudeProjector {
|
|||||||
struct FakeCodexProjector;
|
struct FakeCodexProjector;
|
||||||
|
|
||||||
impl FakeCodexProjector {
|
impl FakeCodexProjector {
|
||||||
|
const CODE_MODE_TOML: &'static str =
|
||||||
|
"[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n";
|
||||||
|
|
||||||
|
fn managed_tables() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"sandbox_workspace_write".to_owned(),
|
||||||
|
"features.code_mode".to_owned(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
fn modes(fallback: Posture) -> (&'static str, &'static str) {
|
fn modes(fallback: Posture) -> (&'static str, &'static str) {
|
||||||
match fallback {
|
match fallback {
|
||||||
Posture::Deny => ("read-only", "on-request"),
|
Posture::Deny => ("read-only", "on-request"),
|
||||||
@ -3121,9 +3131,12 @@ impl PermissionProjector for FakeCodexProjector {
|
|||||||
let network_access = matches!(network, Some(NetworkPolicy::Allow));
|
let network_access = matches!(network, Some(NetworkPolicy::Allow));
|
||||||
let network_file = ProjectedFile::MergeToml {
|
let network_file = ProjectedFile::MergeToml {
|
||||||
rel_path: ".codex/config.toml".to_owned(),
|
rel_path: ".codex/config.toml".to_owned(),
|
||||||
managed_tables: vec!["sandbox_workspace_write".to_owned()],
|
managed_tables: Self::managed_tables(),
|
||||||
managed_keys: Vec::new(),
|
managed_keys: Vec::new(),
|
||||||
contents: format!("[sandbox_workspace_write]\nnetwork_access = {network_access}\n"),
|
contents: format!(
|
||||||
|
"[sandbox_workspace_write]\nnetwork_access = {network_access}\n\n{}",
|
||||||
|
Self::CODE_MODE_TOML
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let Some(eff) = eff else {
|
let Some(eff) = eff else {
|
||||||
return PermissionProjection {
|
return PermissionProjection {
|
||||||
@ -3134,7 +3147,8 @@ impl PermissionProjector for FakeCodexProjector {
|
|||||||
};
|
};
|
||||||
let (sandbox, approval) = Self::modes(eff.fallback());
|
let (sandbox, approval) = Self::modes(eff.fallback());
|
||||||
let contents = format!(
|
let contents = format!(
|
||||||
"sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n"
|
"sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n\n{}",
|
||||||
|
Self::CODE_MODE_TOML
|
||||||
);
|
);
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--sandbox".to_owned(),
|
"--sandbox".to_owned(),
|
||||||
@ -3149,7 +3163,7 @@ impl PermissionProjector for FakeCodexProjector {
|
|||||||
PermissionProjection {
|
PermissionProjection {
|
||||||
files: vec![ProjectedFile::MergeToml {
|
files: vec![ProjectedFile::MergeToml {
|
||||||
rel_path: ".codex/config.toml".to_owned(),
|
rel_path: ".codex/config.toml".to_owned(),
|
||||||
managed_tables: vec!["sandbox_workspace_write".to_owned()],
|
managed_tables: Self::managed_tables(),
|
||||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||||
contents,
|
contents,
|
||||||
}],
|
}],
|
||||||
@ -3665,7 +3679,7 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
// Pre-existing config with an unmanaged top-level key + an unmanaged table.
|
// Pre-existing config with an unmanaged top-level key + an unmanaged table.
|
||||||
fs.seed_read(
|
fs.seed_read(
|
||||||
&cfg_path,
|
&cfg_path,
|
||||||
"user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n",
|
"user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n\n[features.preview]\nenabled = true\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
// First projection.
|
// First projection.
|
||||||
@ -3689,6 +3703,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
first.contains("[mcp_servers.idea]"),
|
first.contains("[mcp_servers.idea]"),
|
||||||
"unmanaged table preserved: {first}"
|
"unmanaged table preserved: {first}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
first.contains("[features.preview]\nenabled = true"),
|
||||||
|
"unmanaged sibling features table preserved: {first}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
first.contains("sandbox_mode = \"workspace-write\""),
|
first.contains("sandbox_mode = \"workspace-write\""),
|
||||||
"managed sandbox_mode upserted: {first}"
|
"managed sandbox_mode upserted: {first}"
|
||||||
@ -3697,6 +3715,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
first.contains("approval_policy = \"never\""),
|
first.contains("approval_policy = \"never\""),
|
||||||
"managed approval_policy upserted: {first}"
|
"managed approval_policy upserted: {first}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
first.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"),
|
||||||
|
"managed Code Mode table upserted: {first}"
|
||||||
|
);
|
||||||
|
|
||||||
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
|
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
|
||||||
sessions.remove(&sid(777));
|
sessions.remove(&sid(777));
|
||||||
@ -3722,10 +3744,19 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
1,
|
1,
|
||||||
"idempotent: no duplicate approval_policy: {second}"
|
"idempotent: no duplicate approval_policy: {second}"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
second.matches("[features.code_mode]").count(),
|
||||||
|
1,
|
||||||
|
"idempotent: no duplicate code mode feature table: {second}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
second.contains("user_key = \"keep-me\""),
|
second.contains("user_key = \"keep-me\""),
|
||||||
"unmanaged key still preserved"
|
"unmanaged key still preserved"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
second.contains("[features.preview]\nenabled = true"),
|
||||||
|
"unmanaged sibling features table still preserved: {second}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- (4) args/env fold into the spawned spec --------------------------------
|
// ---- (4) args/env fold into the spawned spec --------------------------------
|
||||||
|
|||||||
@ -874,6 +874,11 @@ impl PermissionProjector for FakeClaudeProjector {
|
|||||||
/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file.
|
/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file.
|
||||||
struct FakeCodexProjector;
|
struct FakeCodexProjector;
|
||||||
|
|
||||||
|
impl FakeCodexProjector {
|
||||||
|
const CODE_MODE_TOML: &'static str =
|
||||||
|
"[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n";
|
||||||
|
}
|
||||||
|
|
||||||
impl PermissionProjector for FakeCodexProjector {
|
impl PermissionProjector for FakeCodexProjector {
|
||||||
fn key(&self) -> ProjectorKey {
|
fn key(&self) -> ProjectorKey {
|
||||||
ProjectorKey::Codex
|
ProjectorKey::Codex
|
||||||
@ -890,10 +895,12 @@ impl PermissionProjector for FakeCodexProjector {
|
|||||||
PermissionProjection {
|
PermissionProjection {
|
||||||
files: vec![ProjectedFile::MergeToml {
|
files: vec![ProjectedFile::MergeToml {
|
||||||
rel_path: ".codex/config.toml".to_owned(),
|
rel_path: ".codex/config.toml".to_owned(),
|
||||||
managed_tables: Vec::new(),
|
managed_tables: vec!["features.code_mode".to_owned()],
|
||||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||||
contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"
|
contents: format!(
|
||||||
.to_owned(),
|
"sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n\n{}",
|
||||||
|
Self::CODE_MODE_TOML
|
||||||
|
),
|
||||||
}],
|
}],
|
||||||
args: vec![
|
args: vec![
|
||||||
"--sandbox".to_owned(),
|
"--sandbox".to_owned(),
|
||||||
|
|||||||
@ -69,6 +69,7 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
||||||
};
|
};
|
||||||
use domain::remote::RemoteKind;
|
use domain::remote::RemoteKind;
|
||||||
use domain::{
|
use domain::{
|
||||||
@ -3450,6 +3451,11 @@ fn codex_config_toml_for_migration(
|
|||||||
"mcp_servers.idea",
|
"mcp_servers.idea",
|
||||||
mcp_declaration.trim_end(),
|
mcp_declaration.trim_end(),
|
||||||
);
|
);
|
||||||
|
text = replace_toml_table_block(
|
||||||
|
&text,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TABLE,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TOML.trim_end(),
|
||||||
|
);
|
||||||
text = ensure_codex_project_trust(&text, run_dir);
|
text = ensure_codex_project_trust(&text, run_dir);
|
||||||
text = ensure_codex_project_trust(&text, project_root);
|
text = ensure_codex_project_trust(&text, project_root);
|
||||||
if !text.ends_with('\n') {
|
if !text.ends_with('\n') {
|
||||||
@ -3944,11 +3950,18 @@ mod run_dir_migration_tests {
|
|||||||
run_dir.join(".codex/config.toml"),
|
run_dir.join(".codex/config.toml"),
|
||||||
r#"approval_policy = "never"
|
r#"approval_policy = "never"
|
||||||
sandbox_mode = "workspace-write"
|
sandbox_mode = "workspace-write"
|
||||||
|
user_key = "keep-me"
|
||||||
|
|
||||||
[mcp_servers.idea]
|
[mcp_servers.idea]
|
||||||
command = "stale"
|
command = "stale"
|
||||||
args = ["mcp-server"]
|
args = ["mcp-server"]
|
||||||
transport = "stdio"
|
transport = "stdio"
|
||||||
|
|
||||||
|
[features.code_mode]
|
||||||
|
direct_only_tool_namespaces = ["old"]
|
||||||
|
|
||||||
|
[features.preview]
|
||||||
|
enabled = true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -3975,10 +3988,17 @@ transport = "stdio"
|
|||||||
let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap();
|
let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap();
|
||||||
assert!(config.contains(r#"approval_policy = "never""#));
|
assert!(config.contains(r#"approval_policy = "never""#));
|
||||||
assert!(config.contains(r#"sandbox_mode = "workspace-write""#));
|
assert!(config.contains(r#"sandbox_mode = "workspace-write""#));
|
||||||
|
assert!(config.contains(r#"user_key = "keep-me""#));
|
||||||
assert!(config.contains("[mcp_servers.idea]"));
|
assert!(config.contains("[mcp_servers.idea]"));
|
||||||
assert!(config.contains(r#"default_tools_approval_mode = "approve""#));
|
assert!(config.contains(r#"default_tools_approval_mode = "approve""#));
|
||||||
assert!(config.contains("tool_timeout_sec = 86400"));
|
assert!(config.contains("tool_timeout_sec = 86400"));
|
||||||
|
assert!(
|
||||||
|
config.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]")
|
||||||
|
);
|
||||||
|
assert_eq!(config.matches("[features.code_mode]").count(), 1);
|
||||||
assert!(!config.contains(r#"command = "stale""#));
|
assert!(!config.contains(r#"command = "stale""#));
|
||||||
|
assert!(!config.contains(r#"direct_only_tool_namespaces = ["old"]"#));
|
||||||
|
assert!(config.contains("[features.preview]\nenabled = true"));
|
||||||
assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy())));
|
assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy())));
|
||||||
assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str())));
|
assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str())));
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,15 @@ use crate::error::DomainError;
|
|||||||
use crate::ids::{LocalModelServerId, ProfileId};
|
use crate::ids::{LocalModelServerId, ProfileId};
|
||||||
use crate::permission::ProjectorKey;
|
use crate::permission::ProjectorKey;
|
||||||
|
|
||||||
|
/// Codex feature table managed by IdeA to restrict Code Mode direct tool calls
|
||||||
|
/// to the local IdeA MCP namespace.
|
||||||
|
pub const CODEX_CODE_MODE_FEATURES_TABLE: &str = "features.code_mode";
|
||||||
|
|
||||||
|
/// TOML block injected in Codex `config.toml` so Code Mode can call IdeA MCP
|
||||||
|
/// tools directly while every other namespace stays mediated by Codex.
|
||||||
|
pub const CODEX_CODE_MODE_FEATURES_TOML: &str =
|
||||||
|
"[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n";
|
||||||
|
|
||||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||||
///
|
///
|
||||||
/// Invariants:
|
/// Invariants:
|
||||||
@ -803,7 +812,7 @@ impl McpServerWiring {
|
|||||||
let transport = self.transport_label();
|
let transport = self.transport_label();
|
||||||
let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
|
let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
|
||||||
format!(
|
format!(
|
||||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n"
|
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n\n{CODEX_CODE_MODE_FEATURES_TOML}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2129,6 +2138,10 @@ mod mcp_tests {
|
|||||||
toml.contains("tool_timeout_sec = 86400"),
|
toml.contains("tool_timeout_sec = 86400"),
|
||||||
"IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
|
"IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
toml.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"),
|
||||||
|
"Code Mode direct tools must be restricted to IdeA MCP; got: {toml}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||||
|
|||||||
@ -5,7 +5,10 @@ use std::sync::Arc;
|
|||||||
use application::McpRuntime;
|
use application::McpRuntime;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{SecretStore, SessionPlan};
|
use domain::ports::{SecretStore, SessionPlan};
|
||||||
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
use domain::profile::{
|
||||||
|
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TOML,
|
||||||
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
||||||
ContextInjectionPlan, EffectivePermissions, FileSystem, FsError, Issue, IssueRef, MarkdownDoc,
|
ContextInjectionPlan, EffectivePermissions, FileSystem, FsError, Issue, IssueRef, MarkdownDoc,
|
||||||
@ -522,6 +525,11 @@ fn codex_config_toml(
|
|||||||
text = set_top_level_toml_value(&text, "model", model);
|
text = set_top_level_toml_value(&text, "model", model);
|
||||||
}
|
}
|
||||||
text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
||||||
|
text = replace_toml_table_block(
|
||||||
|
&text,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TABLE,
|
||||||
|
CODEX_CODE_MODE_FEATURES_TOML.trim_end(),
|
||||||
|
);
|
||||||
text = ensure_codex_project_trust(&text, run_dir);
|
text = ensure_codex_project_trust(&text, run_dir);
|
||||||
text = ensure_codex_project_trust(&text, project_root);
|
text = ensure_codex_project_trust(&text, project_root);
|
||||||
if !text.ends_with('\n') {
|
if !text.ends_with('\n') {
|
||||||
@ -645,6 +653,60 @@ fn parent_dir(base: &ProjectPath, rel: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod codex_config_toml_tests {
|
||||||
|
use domain::profile::McpTransport;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assistant_codex_config_replaces_code_mode_and_preserves_user_content() {
|
||||||
|
let declaration = McpServerWiring::new(
|
||||||
|
"idea".to_owned(),
|
||||||
|
vec!["mcp-server".to_owned()],
|
||||||
|
McpTransport::Stdio,
|
||||||
|
)
|
||||||
|
.to_config_toml();
|
||||||
|
let existing = "user_key = \"keep\"\n\n[features.code_mode]\ndirect_only_tool_namespaces = [\"old\"]\n\n[features.preview]\nenabled = true\n\n[user.table]\nvalue = 1\n";
|
||||||
|
|
||||||
|
let rendered = codex_config_toml(
|
||||||
|
Some(existing),
|
||||||
|
&declaration,
|
||||||
|
"/run/assistant",
|
||||||
|
"/proj",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
rendered.contains("user_key = \"keep\""),
|
||||||
|
"unmanaged top-level key preserved: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("[user.table]\nvalue = 1"),
|
||||||
|
"unmanaged table preserved: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("[features.preview]\nenabled = true"),
|
||||||
|
"unmanaged sibling features table preserved: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered
|
||||||
|
.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"),
|
||||||
|
"assistant Codex config should inject direct-only IdeA MCP namespace: {rendered}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rendered.matches("[features.code_mode]").count(),
|
||||||
|
1,
|
||||||
|
"assistant Codex config should replace, not duplicate, code mode table: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("[projects.\"/run/assistant\"]")
|
||||||
|
&& rendered.contains("[projects.\"/proj\"]"),
|
||||||
|
"trust entries should still be generated: {rendered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod opencode_provider_config_json_tests {
|
mod opencode_provider_config_json_tests {
|
||||||
use domain::ports::SecretRef;
|
use domain::ports::SecretRef;
|
||||||
|
|||||||
@ -42,6 +42,10 @@ use super::tools::{self, ToolMapError};
|
|||||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||||
|
|
||||||
|
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
||||||
|
/// intentionally self-contained because some clients truncate this field.
|
||||||
|
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*. Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
|
||||||
|
|
||||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||||
///
|
///
|
||||||
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
||||||
@ -340,7 +344,8 @@ impl McpServer {
|
|||||||
json!({
|
json!({
|
||||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
"capabilities": { "tools": {} },
|
"capabilities": { "tools": {} },
|
||||||
"serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") }
|
"serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"instructions": MCP_INSTRUCTIONS
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ use domain::permission::{
|
|||||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||||
ProjectionContext, ProjectorKey,
|
ProjectionContext, ProjectorKey,
|
||||||
};
|
};
|
||||||
|
use domain::profile::{CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML};
|
||||||
use domain::NetworkPolicy;
|
use domain::NetworkPolicy;
|
||||||
|
|
||||||
use super::toml_string;
|
use super::toml_string;
|
||||||
@ -70,13 +71,15 @@ impl PermissionProjector for CodexPermissionProjector {
|
|||||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||||
// permission concern and stay with the MCP wiring (LP3-3).
|
// permission concern and stay with the MCP wiring (LP3-3).
|
||||||
let mut contents = format!(
|
let mut contents = format!(
|
||||||
"sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n",
|
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||||
toml_string(sandbox),
|
toml_string(sandbox),
|
||||||
toml_string(approval),
|
toml_string(approval),
|
||||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
|
||||||
network_access,
|
|
||||||
);
|
);
|
||||||
append_codex_model_config(&mut contents, ctx);
|
append_codex_model_config(&mut contents, ctx);
|
||||||
|
contents.push_str(&format!(
|
||||||
|
"\n[{}]\nnetwork_access = {}\n\n{}",
|
||||||
|
SANDBOX_WORKSPACE_WRITE_TABLE, network_access, CODEX_CODE_MODE_FEATURES_TOML,
|
||||||
|
));
|
||||||
|
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--sandbox".to_owned(),
|
"--sandbox".to_owned(),
|
||||||
@ -111,12 +114,17 @@ fn codex_model_and_network_file(
|
|||||||
ctx: &ProjectionContext,
|
ctx: &ProjectionContext,
|
||||||
network: Option<NetworkPolicy>,
|
network: Option<NetworkPolicy>,
|
||||||
) -> ProjectedFile {
|
) -> ProjectedFile {
|
||||||
let mut contents = format!(
|
let mut contents = String::new();
|
||||||
"[{}]\nnetwork_access = {}\n",
|
append_codex_model_config(&mut contents, ctx);
|
||||||
|
if !contents.is_empty() {
|
||||||
|
contents.push('\n');
|
||||||
|
}
|
||||||
|
contents.push_str(&format!(
|
||||||
|
"[{}]\nnetwork_access = {}\n\n{}",
|
||||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
SANDBOX_WORKSPACE_WRITE_TABLE,
|
||||||
codex_network_access(network),
|
codex_network_access(network),
|
||||||
);
|
CODEX_CODE_MODE_FEATURES_TOML,
|
||||||
append_codex_model_config(&mut contents, ctx);
|
));
|
||||||
ProjectedFile::MergeToml {
|
ProjectedFile::MergeToml {
|
||||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||||
managed_tables: codex_managed_tables(ctx),
|
managed_tables: codex_managed_tables(ctx),
|
||||||
@ -137,7 +145,10 @@ fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec<String> {
|
fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec<String> {
|
||||||
vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
|
vec![
|
||||||
|
SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(),
|
||||||
|
CODEX_CODE_MODE_FEATURES_TABLE.to_owned(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
|
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
|
||||||
@ -198,6 +209,13 @@ mod tests {
|
|||||||
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
|
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expected_managed_tables() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(),
|
||||||
|
CODEX_CODE_MODE_FEATURES_TABLE.to_owned(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// ---- product invariant + ownership ----------------------------------
|
// ---- product invariant + ownership ----------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -213,15 +231,18 @@ mod tests {
|
|||||||
contents,
|
contents,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||||
assert_eq!(
|
assert_eq!(managed_tables, &expected_managed_tables());
|
||||||
managed_tables,
|
|
||||||
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
|
|
||||||
);
|
|
||||||
assert!(managed_keys.is_empty());
|
assert!(managed_keys.is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
|
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
|
||||||
"network is denied by default: {contents:?}"
|
"network is denied by default: {contents:?}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
contents.contains(
|
||||||
|
"[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"
|
||||||
|
),
|
||||||
|
"Code Mode direct tools must be restricted to IdeA MCP: {contents:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||||
}
|
}
|
||||||
@ -255,10 +276,7 @@ mod tests {
|
|||||||
} => {
|
} => {
|
||||||
assert!(managed_keys.contains(&"model".to_owned()));
|
assert!(managed_keys.contains(&"model".to_owned()));
|
||||||
assert!(!managed_keys.contains(&"model_provider".to_owned()));
|
assert!(!managed_keys.contains(&"model_provider".to_owned()));
|
||||||
assert_eq!(
|
assert_eq!(managed_tables, &expected_managed_tables());
|
||||||
managed_tables,
|
|
||||||
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
|
|
||||||
);
|
|
||||||
assert!(contents.contains("model = \"gpt-5\""), "{contents}");
|
assert!(contents.contains("model = \"gpt-5\""), "{contents}");
|
||||||
assert!(!contents.contains("model_provider"), "{contents}");
|
assert!(!contents.contains("model_provider"), "{contents}");
|
||||||
assert!(!contents.contains("model_providers"), "{contents}");
|
assert!(!contents.contains("model_providers"), "{contents}");
|
||||||
@ -296,10 +314,7 @@ mod tests {
|
|||||||
contents,
|
contents,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||||
assert_eq!(
|
assert_eq!(managed_tables, &expected_managed_tables());
|
||||||
managed_tables,
|
|
||||||
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
managed_keys,
|
managed_keys,
|
||||||
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
||||||
@ -316,6 +331,12 @@ mod tests {
|
|||||||
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
|
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
|
||||||
"network is denied by default: {contents:?}"
|
"network is denied by default: {contents:?}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
contents.contains(
|
||||||
|
"[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"
|
||||||
|
),
|
||||||
|
"Code Mode direct tools must be restricted to IdeA MCP: {contents:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1887,6 +1887,35 @@ async fn initialize_answers_minimal_handshake() {
|
|||||||
// Capability for tools is advertised, and the server identifies itself.
|
// Capability for tools is advertised, and the server identifies itself.
|
||||||
assert!(result["capabilities"]["tools"].is_object());
|
assert!(result["capabilities"]["tools"].is_object());
|
||||||
assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator"));
|
assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator"));
|
||||||
|
let instructions = result["instructions"]
|
||||||
|
.as_str()
|
||||||
|
.expect("initialize should advertise compact instructions");
|
||||||
|
let first_512 = instructions.chars().take(512).collect::<String>();
|
||||||
|
for needle in [
|
||||||
|
"idea_ticket_*",
|
||||||
|
"idea_ask_agent",
|
||||||
|
"idea_ask_agents",
|
||||||
|
"idea_memory_*",
|
||||||
|
"idea_context_*",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
first_512.contains(needle),
|
||||||
|
"first 512 chars should route natural intent to {needle}; got {first_512:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for needle in [
|
||||||
|
"idea_skill_*",
|
||||||
|
"idea_template_*",
|
||||||
|
"idea_workstate_*",
|
||||||
|
"background",
|
||||||
|
"agent lifecycle",
|
||||||
|
"sprint",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
instructions.contains(needle),
|
||||||
|
"instructions should cover {needle}; got {instructions:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur
|
/// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur
|
||||||
|
|||||||
Reference in New Issue
Block a user