use anyhow::{anyhow, bail, Context, Result};
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::runtime;
const ACP_PROTOCOL_VERSION: u32 = 1;
const REDACTED: &str = runtime::redaction::REDACTED_MARKER;
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct AcpSession {
session_id: String,
cwd: PathBuf,
mcp_servers: Vec<Value>,
orchestration_slug: Option<String>,
trace_ids: Vec<String>,
transcript: Vec<AcpTranscriptEntry>,
cancelled: bool,
created_at: String,
updated_at: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct AcpTranscriptEntry {
role: String,
message_id: String,
text: String,
at: String,
}
pub fn serve_stdio(root: &Path) -> Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
serve_lines(root, stdin.lock(), &mut stdout)
}
pub fn serve_lines<R: BufRead, W: Write>(root: &Path, reader: R, writer: &mut W) -> Result<()> {
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(error) => {
writeln!(
writer,
"{}",
jsonrpc_error(Value::Null, -32700, &error.to_string())
)?;
continue;
}
};
for response in handle_request(root, request) {
writeln!(writer, "{}", serde_json::to_string(&response)?)?;
}
writer.flush()?;
}
Ok(())
}
pub fn doctor(root: &Path) -> Value {
let sessions_dir = sessions_dir(root);
let agent_manifest = runtime::agents::agent_manifest("sdd-orchestrator");
json!({
"status": if agent_manifest.is_ok() { "pass" } else { "fail" },
"protocolVersion": ACP_PROTOCOL_VERSION,
"sdk": acp_sdk_marker(),
"sessionsDir": runtime::platform::display_path(&sessions_dir),
"sessionsDirExists": sessions_dir.exists(),
"agent": "sdd-orchestrator",
"capabilities": initialize_agent_capabilities(),
"error": agent_manifest.err().map(|err| err.to_string()),
})
}
pub fn config_actions(
root: &Path,
targets: &str,
force: bool,
dry_run: bool,
) -> Result<Vec<String>> {
let mut actions = Vec::new();
let selected = parse_targets(targets)?;
if selected
.iter()
.any(|target| target == "zed" || target == "all")
{
let rel = ".zed/settings.json";
let target = root.join(rel);
let content = render_zed_acp_settings(root);
if target.exists() && !force {
actions.push(format!("skip existing {}", target.display()));
} else if dry_run {
actions.push(format!("dry-run write {}", target.display()));
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
actions.push(format!("write {}", target.display()));
}
}
Ok(actions)
}
fn handle_request(root: &Path, request: Value) -> Vec<Value> {
let id = request.get("id").cloned().unwrap_or(Value::Null);
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
if id.is_null() && method == "session/cancel" {
if let Err(error) = cancel_session(root, ¶ms) {
return vec![jsonrpc_error(Value::Null, -32603, &error.to_string())];
}
return Vec::new();
}
let result = match method {
"initialize" => Ok(vec![jsonrpc_result(id.clone(), initialize_result(¶ms))]),
"authenticate" => Ok(vec![jsonrpc_result(id.clone(), json!({}))]),
"session/new" => create_session(root, id.clone(), ¶ms),
"session/load" => load_session(root, id.clone(), ¶ms),
"session/prompt" => prompt_session(root, id.clone(), ¶ms),
"session/cancel" => {
cancel_session(root, ¶ms).map(|_| vec![jsonrpc_result(id.clone(), json!({}))])
}
_ => Err(anyhow!("method not found: {method}")),
};
match result {
Ok(responses) => responses,
Err(error) => vec![jsonrpc_error(id, -32603, &error.to_string())],
}
}
fn initialize_result(params: &Value) -> Value {
let requested = params
.get("protocolVersion")
.and_then(Value::as_u64)
.unwrap_or(ACP_PROTOCOL_VERSION as u64);
json!({
"protocolVersion": requested.min(ACP_PROTOCOL_VERSION as u64),
"agentInfo": {
"name": "sdd-layer",
"version": env!("CARGO_PKG_VERSION")
},
"agentCapabilities": initialize_agent_capabilities(),
"authMethods": [],
"_meta": {
"sdd": {
"agent": "sdd-orchestrator",
"runtimeAgnostic": true,
"artifactStoreCanonical": true
}
}
})
}
fn initialize_agent_capabilities() -> Value {
json!({
"loadSession": true,
"promptCapabilities": {
"image": false,
"audio": false,
"embeddedContext": false
},
"mcpCapabilities": {
"http": false,
"sse": false
},
"sessionCapabilities": {},
"auth": {}
})
}
fn create_session(root: &Path, id: Value, params: &Value) -> Result<Vec<Value>> {
let cwd = required_abs_path(params, "cwd")?;
let session_id = new_session_id(&cwd);
let now = now();
let session = AcpSession {
session_id: session_id.clone(),
cwd,
mcp_servers: redacted_mcp_servers(params),
orchestration_slug: None,
trace_ids: Vec::new(),
transcript: Vec::new(),
cancelled: false,
created_at: now.clone(),
updated_at: now,
};
save_session(root, &session)?;
Ok(vec![jsonrpc_result(id, json!({ "sessionId": session_id }))])
}
fn load_session(root: &Path, id: Value, params: &Value) -> Result<Vec<Value>> {
let session_id = required_string(params, "sessionId")?;
let cwd = required_abs_path(params, "cwd")?;
let mut session = read_session(root, &session_id)?;
session.cwd = cwd;
if let Some(servers) = params.get("mcpServers").and_then(Value::as_array) {
session.mcp_servers = redact_mcp_server_array(servers);
}
session.updated_at = now();
save_session(root, &session)?;
let mut responses = Vec::new();
for entry in &session.transcript {
responses.push(session_update_message(
&session.session_id,
&entry.message_id,
if entry.role == "user" {
"user_message_chunk"
} else {
"agent_message_chunk"
},
&entry.text,
));
}
responses.push(jsonrpc_result(id, Value::Null));
Ok(responses)
}
fn prompt_session(root: &Path, id: Value, params: &Value) -> Result<Vec<Value>> {
let session_id = required_string(params, "sessionId")?;
let mut session = read_session(root, &session_id)?;
let prompt = extract_prompt_text(params)?;
let redacted_prompt = runtime::redaction::redact_text(&prompt);
let orchestration_slug = params
.get("orchestration")
.and_then(Value::as_str)
.map(crate::artifact_slug)
.or_else(|| Some(crate::artifact_slug(&redacted_prompt)));
session.orchestration_slug = orchestration_slug;
session.cancelled = false;
let user_message_id = message_id("user", &redacted_prompt);
session.transcript.push(AcpTranscriptEntry {
role: "user".to_string(),
message_id: user_message_id.clone(),
text: redacted_prompt.clone(),
at: now(),
});
let response_text = build_agent_response(&session, &redacted_prompt);
let agent_message_id = message_id("agent", &response_text);
session.transcript.push(AcpTranscriptEntry {
role: "agent".to_string(),
message_id: agent_message_id.clone(),
text: response_text.clone(),
at: now(),
});
session.updated_at = now();
save_session(root, &session)?;
Ok(vec![
session_update_plan(&session.session_id),
session_update_message(
&session.session_id,
&agent_message_id,
"agent_message_chunk",
&response_text,
),
jsonrpc_result(id, json!({ "stopReason": "end_turn" })),
])
}
fn cancel_session(root: &Path, params: &Value) -> Result<()> {
let session_id = required_string(params, "sessionId")?;
let mut session = read_session(root, &session_id)?;
session.cancelled = true;
session.updated_at = now();
save_session(root, &session)
}
fn build_agent_response(session: &AcpSession, prompt: &str) -> String {
let slug = session
.orchestration_slug
.as_deref()
.unwrap_or("sdd-orchestration");
format!(
"Recebi a demanda para o agente `sdd-orchestrator`.\n\nPróximo caminho seguro:\n\n```bash\nsdd init \"{slug}\"\nsdd workflow run agentic-sdd-loop --input {:?} --max-iterations 3 --json\n```\n\nVou tratar ACP como superfÃcie de sessão e manter o artifact store em `docs/{slug}/` como fonte canônica. Nenhuma escrita de código ou aprovação de checkpoint foi executada por este turno ACP.",
prompt
)
}
fn session_update_plan(session_id: &str) -> Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "plan",
"entries": [
{ "content": "Resolver demanda para um ciclo SDD rastreável", "priority": "high", "status": "completed" },
{ "content": "Usar artifact store local como fonte canônica", "priority": "high", "status": "pending" },
{ "content": "Parar em checkpoints humanos", "priority": "high", "status": "pending" }
]
}
}
})
}
fn session_update_message(
session_id: &str,
message_id: &str,
session_update: &str,
text: &str,
) -> Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": session_update,
"messageId": message_id,
"content": {
"type": "text",
"text": text
}
}
}
})
}
fn extract_prompt_text(params: &Value) -> Result<String> {
if let Some(text) = params.get("prompt").and_then(Value::as_str) {
return Ok(text.to_string());
}
if let Some(text) = params.get("message").and_then(Value::as_str) {
return Ok(text.to_string());
}
if let Some(prompt) = params.get("prompt").and_then(Value::as_array) {
let mut parts = Vec::new();
for block in prompt {
if let Some(text) = block.get("text").and_then(Value::as_str) {
parts.push(text.to_string());
continue;
}
if let Some(text) = block
.get("resource")
.and_then(|resource| resource.get("text"))
.and_then(Value::as_str)
{
parts.push(text.to_string());
continue;
}
if let Some(link) = resource_link_summary(block) {
parts.push(link);
}
}
if !parts.is_empty() {
return Ok(parts.join("\n\n"));
}
}
bail!("session/prompt requires a text prompt")
}
fn resource_link_summary(block: &Value) -> Option<String> {
let resource_link = if block.get("type").and_then(Value::as_str) == Some("resource_link") {
Some(block)
} else {
block
.get("resourceLink")
.or_else(|| block.get("resource_link"))
}?;
let uri = resource_link.get("uri").and_then(Value::as_str)?;
let name = resource_link
.get("name")
.and_then(Value::as_str)
.or_else(|| resource_link.get("title").and_then(Value::as_str))
.unwrap_or("resource");
let mime_type = resource_link
.get("mimeType")
.and_then(Value::as_str)
.or_else(|| resource_link.get("mime_type").and_then(Value::as_str));
Some(match mime_type {
Some(mime_type) => format!("ResourceLink: {name} ({mime_type}) <{uri}>"),
None => format!("ResourceLink: {name} <{uri}>"),
})
}
fn redacted_mcp_servers(params: &Value) -> Vec<Value> {
params
.get("mcpServers")
.and_then(Value::as_array)
.map(|servers| redact_mcp_server_array(servers))
.unwrap_or_default()
}
fn redact_mcp_server_array(servers: &[Value]) -> Vec<Value> {
servers.iter().map(redact_mcp_server_value).collect()
}
fn redact_mcp_server_value(value: &Value) -> Value {
match value {
Value::Array(items) => Value::Array(items.iter().map(redact_mcp_server_value).collect()),
Value::Object(map) => {
let mut redacted = serde_json::Map::new();
for (key, value) in map {
if is_sensitive_mcp_value_key(key) {
redacted.insert(key.clone(), Value::String(REDACTED.to_string()));
} else {
redacted.insert(key.clone(), redact_mcp_server_value(value));
}
}
Value::Object(redacted)
}
Value::String(text) => Value::String(runtime::redaction::redact_text(text)),
other => other.clone(),
}
}
fn is_sensitive_mcp_value_key(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"value" | "authorization" | "token" | "secret" | "apikey" | "api_key" | "password"
)
}
fn required_abs_path(params: &Value, key: &str) -> Result<PathBuf> {
let value = required_string(params, key)?;
let path = PathBuf::from(&value);
if !path.is_absolute() {
bail!("{key} must be an absolute path");
}
Ok(path)
}
fn required_string(params: &Value, key: &str) -> Result<String> {
params
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| anyhow!("missing required field `{key}`"))
}
fn read_session(root: &Path, session_id: &str) -> Result<AcpSession> {
let path = session_path(root, session_id)?;
let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
Ok(serde_json::from_str(&text)?)
}
fn save_session(root: &Path, session: &AcpSession) -> Result<()> {
let path = session_path(root, &session.session_id)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(
path,
format!("{}\n", serde_json::to_string_pretty(session)?),
)?;
Ok(())
}
fn session_path(root: &Path, session_id: &str) -> Result<PathBuf> {
if session_id.contains('/') || session_id.contains('\\') || session_id.contains("..") {
bail!("invalid ACP session id `{session_id}`");
}
Ok(sessions_dir(root).join(format!("{session_id}.json")))
}
fn sessions_dir(root: &Path) -> PathBuf {
root.join(".sdd").join("acp").join("sessions")
}
fn new_session_id(cwd: &Path) -> String {
let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
let seed = format!(
"{}:{}:{}:{}",
now(),
std::process::id(),
counter,
cwd.display()
);
format!("sess_{}", short_hash(&seed))
}
fn message_id(role: &str, text: &str) -> String {
format!("msg_{role}_{}", short_hash(text))
}
fn short_hash(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let digest = hasher.finalize();
digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn jsonrpc_result(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn jsonrpc_error(id: Value, code: i32, message: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message }
})
}
fn parse_targets(targets: &str) -> Result<Vec<String>> {
let mut parsed = Vec::new();
for raw in targets.split(',') {
let item = raw.trim().to_ascii_lowercase();
if item.is_empty() {
continue;
}
match item.as_str() {
"all" | "zed" => parsed.push(item),
other => bail!("unsupported ACP config target `{other}` (use zed|all)"),
}
}
if parsed.is_empty() {
parsed.push("all".to_string());
}
Ok(parsed)
}
fn render_zed_acp_settings(root: &Path) -> String {
let root = runtime::platform::display_path(root).replace('\\', "/");
serde_json::to_string_pretty(&json!({
"agent_servers": {
"sdd-layer": {
"command": "sdd",
"args": ["acp", "serve", "--root", root],
"env": {}
}
}
}))
.expect("ACP settings JSON must serialize")
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[cfg(feature = "acp-agent")]
fn acp_sdk_marker() -> &'static str {
std::any::type_name::<agent_client_protocol::Client>()
}
#[cfg(not(feature = "acp-agent"))]
fn acp_sdk_marker() -> &'static str {
"manual-jsonrpc-stdio (compile with acp-agent for agent-client-protocol runtime crate)"
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn serves_initialize_and_session_prompt() {
let root = tempdir().unwrap();
let cwd = root.path().to_string_lossy();
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "protocolVersion": 1 }
});
let session_new = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": { "cwd": cwd, "mcpServers": [] }
});
let input = format!("{initialize}\n{session_new}\n");
let mut output = Vec::new();
serve_lines(root.path(), input.as_bytes(), &mut output).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\"protocolVersion\":1"));
assert!(text.contains("\"sessionId\""));
}
#[test]
fn rejects_relative_cwd() {
let root = tempdir().unwrap();
let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"session/new\",\"params\":{\"cwd\":\"relative\",\"mcpServers\":[]}}\n";
let mut output = Vec::new();
serve_lines(root.path(), &input[..], &mut output).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\"error\""));
assert!(text.contains("absolute path"));
}
#[test]
fn creates_unique_session_ids_for_rapid_new_sessions() {
let root = tempdir().unwrap();
let cwd = root.path().to_string_lossy();
let first = create_session(
root.path(),
json!(1),
&json!({ "cwd": cwd, "mcpServers": [] }),
)
.unwrap();
let second = create_session(
root.path(),
json!(2),
&json!({ "cwd": cwd, "mcpServers": [] }),
)
.unwrap();
let first_id = first[0]["result"]["sessionId"].as_str().unwrap();
let second_id = second[0]["result"]["sessionId"].as_str().unwrap();
assert_ne!(first_id, second_id);
assert!(session_path(root.path(), first_id).unwrap().is_file());
assert!(session_path(root.path(), second_id).unwrap().is_file());
}
#[test]
fn config_supports_zed_dry_run() {
let root = tempdir().unwrap();
let actions = config_actions(root.path(), "zed", false, true).unwrap();
assert_eq!(actions.len(), 1);
assert!(actions[0].contains("dry-run write"));
}
#[test]
fn prompt_persists_redacted_transcript_and_stop_reason() {
let root = tempdir().unwrap();
let cwd = root.path().to_string_lossy();
let responses = create_session(
root.path(),
json!(1),
&json!({ "cwd": cwd, "mcpServers": [{ "name": "sdd" }] }),
)
.unwrap();
let session_id = responses[0]["result"]["sessionId"]
.as_str()
.unwrap()
.to_string();
let prompt =
"implementar feature com OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz1234567890";
let responses = prompt_session(
root.path(),
json!(2),
&json!({
"sessionId": session_id,
"prompt": [{ "type": "text", "text": prompt }]
}),
)
.unwrap();
assert_eq!(
responses.last().unwrap()["result"]["stopReason"],
"end_turn"
);
let session = read_session(root.path(), &session_id).unwrap();
let transcript = serde_json::to_string(&session.transcript).unwrap();
assert!(!transcript.contains("sk-abcdefghijklmnopqrstuvwxyz1234567890"));
assert!(transcript.contains("[REDACTED]"));
}
#[test]
fn session_persistence_redacts_mcp_server_secrets() {
let root = tempdir().unwrap();
let cwd = root.path().to_string_lossy();
let responses = create_session(
root.path(),
json!(1),
&json!({
"cwd": cwd,
"mcpServers": [{
"type": "stdio",
"name": "private-tools",
"command": "mcp-server",
"args": ["--token", "sk-abcdefghijklmnopqrstuvwxyz1234567890"],
"env": [
{ "name": "OPENAI_API_KEY", "value": "plain-secret-value" }
],
"headers": [
{ "name": "Authorization", "value": "Bearer raw-header-secret" }
]
}]
}),
)
.unwrap();
let session_id = responses[0]["result"]["sessionId"].as_str().unwrap();
let session = read_session(root.path(), session_id).unwrap();
let persisted = serde_json::to_string(&session.mcp_servers).unwrap();
assert!(persisted.contains("OPENAI_API_KEY"));
assert!(persisted.contains("Authorization"));
assert!(persisted.contains("[REDACTED]"));
assert!(!persisted.contains("plain-secret-value"));
assert!(!persisted.contains("raw-header-secret"));
assert!(!persisted.contains("sk-abcdefghijklmnopqrstuvwxyz1234567890"));
load_session(
root.path(),
json!(2),
&json!({
"sessionId": session_id,
"cwd": cwd,
"mcpServers": [{
"type": "stdio",
"name": "replacement",
"command": "mcp-server",
"args": [],
"env": [
{ "name": "ANTHROPIC_API_KEY", "value": "second-secret-value" }
]
}]
}),
)
.unwrap();
let session = read_session(root.path(), session_id).unwrap();
let persisted = serde_json::to_string(&session.mcp_servers).unwrap();
assert!(persisted.contains("ANTHROPIC_API_KEY"));
assert!(persisted.contains("[REDACTED]"));
assert!(!persisted.contains("second-secret-value"));
}
#[test]
fn prompt_accepts_resource_link_content_blocks() {
let root = tempdir().unwrap();
let cwd = root.path().to_string_lossy();
let responses = create_session(
root.path(),
json!(1),
&json!({ "cwd": cwd, "mcpServers": [] }),
)
.unwrap();
let session_id = responses[0]["result"]["sessionId"]
.as_str()
.unwrap()
.to_string();
let responses = prompt_session(
root.path(),
json!(2),
&json!({
"sessionId": session_id,
"prompt": [{
"type": "resource_link",
"uri": "sdd://agents/sdd-orchestrator",
"name": "SDD orchestrator manifest",
"mimeType": "application/json"
}]
}),
)
.unwrap();
assert_eq!(
responses.last().unwrap()["result"]["stopReason"],
"end_turn"
);
let session_id = responses[1]["params"]["sessionId"].as_str().unwrap();
let session = read_session(root.path(), session_id).unwrap();
let transcript = serde_json::to_string(&session.transcript).unwrap();
assert!(transcript.contains("ResourceLink: SDD orchestrator manifest"));
assert!(transcript.contains("sdd://agents/sdd-orchestrator"));
}
}