diff --git a/Cargo.lock b/Cargo.lock index 8de5aea..bfcabe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,7 +882,7 @@ dependencies = [ "bit-set", "cssparser", "foldhash", - "html5ever", + "html5ever 0.38.0", "precomputed-hash", "selectors", "tendril", @@ -1555,6 +1555,18 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "html2text" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8" +dependencies = [ + "html5ever 0.39.0", + "tendril", + "thiserror 2.0.20", + "unicode-width", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1562,7 +1574,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever", + "markup5ever 0.38.0", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", ] [[package]] @@ -2133,6 +2155,7 @@ dependencies = [ "dirs 7.0.0", "futures-util", "globset", + "html2text", "ignore", "keyring", "lantern-context", @@ -2309,6 +2332,17 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "memchr" version = "2.8.3" @@ -5021,6 +5055,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/app/e2e/run.mjs b/app/e2e/run.mjs index c8ba3bd..ae96020 100644 --- a/app/e2e/run.mjs +++ b/app/e2e/run.mjs @@ -74,11 +74,17 @@ const file = (rel) => fs.readFileSync(path.join(DIRS.proj, rel), "utf8"); // ── 모의 모델 (OpenAI 호환, 스트리밍) ──────────────────────── // 1차: read_file → 2차: edit_file → 3차: 완료. 무엇을 읽고 고칠지는 시나리오가 정한다. -const mock = { read: "src/auth/login.ts", edit: "src/auth/session.ts", old: "v + '.sig'", new: "v + '.signed'", requests: 0, testPrompt: "", image: null, embedded: 0, semanticPrompt: "", toolNames: [], verifyFailure: "" }; +const mock = { read: "src/auth/login.ts", edit: "src/auth/session.ts", old: "v + '.sig'", new: "v + '.signed'", requests: 0, testPrompt: "", image: null, embedded: 0, semanticPrompt: "", toolNames: [], verifyFailure: "", fetched: [], fetchResult: "" }; const mockServer = http.createServer((req, res) => { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { + // fetch_url이 읽을 가짜 문서 + if (req.method === "GET" && req.url.startsWith("/docs/")) { + mock.fetched.push(req.url); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + return res.end(`

${req.url === "/docs/cookie" ? "Cookie signing" : "Session lifetime"}

Sign cookies with an HMAC; keep sessions for 7 days.

`); + } if (req.method === "GET") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ data: [{ id: "e2e-model" }] })); @@ -143,6 +149,19 @@ const mockServer = http.createServer((req, res) => { } return edit("v + '.sig'", "v + '.bad'"); } + // 웹 문서: 같은 사이트의 두 페이지를 읽고 요약한다 + if (first.includes("문서를 찾아봐")) { + const fetchCall = (url) => { + send({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `call_f${tools}`, type: "function", function: { name: "fetch_url", arguments: JSON.stringify({ url }) } }] } }] }); + done("tool_calls"); + }; + if (tools === 0) return fetchCall(`http://127.0.0.1:${MOCK_PORT}/docs/cookie`); + if (tools === 1) return fetchCall(`http://127.0.0.1:${MOCK_PORT}/docs/session`); + const result = String(j.messages.filter((m) => m.role === "tool").at(0)?.content ?? ""); + mock.fetchResult = result; + send({ choices: [{ index: 0, delta: { content: "문서 요약: HMAC으로 서명" } }] }); + return done("stop"); + } // 외부 MCP 도구: 티켓을 찾아 그 내용으로 답한다 if (first.includes("티켓 T-1")) { mock.toolNames = (j.tools ?? []).map((t) => t.function?.name); @@ -233,6 +252,7 @@ async function launch(dir = DIRS.proj) { LANTERN_WEBVIEW_DATA_DIR: DIRS.wv, LANTERN_INDEX_DIR: DIRS.index, LANTERN_E2E_CDP_PORT: String(cdpPort), + LANTERN_FETCH_ALLOW_LOCAL: "1", // 가짜 문서 서버가 로컬이라 }, stdio: "ignore", }); @@ -915,6 +935,29 @@ scenario("고친 뒤 관련 테스트로 확인: 실패 → 출력을 받아 다 git("checkout", "--", "src/auth/session.ts"); }); +scenario("웹 문서 가져오기: 처음 여는 사이트만 승인 → 본문 글로 읽기", async () => { + await run(() => { + document.querySelector("#btn-new-task").click(); + return true; + }); + await run(ui.sendTask, "쿠키 서명 문서를 찾아봐", "ask", false); + const card = await waitFor(() => { + const c = document.querySelector(".task-log:not(.hidden) .approval:not(.resolved)"); + return c && { title: c.querySelector(".atitle")?.textContent ?? "", globe: !!c.querySelector(".ahead .codicon-globe"), detail: c.querySelector("pre")?.textContent ?? "" }; + }, 20000, "사이트 승인 카드"); + if (!card.title.includes("127.0.0.1") || !card.globe || !card.detail.includes("/docs/cookie")) throw new Error(`승인 카드: ${JSON.stringify(card)}`); + if (mock.fetched.length) throw new Error("승인 전에 가져옴"); + await run(() => { + [...document.querySelectorAll(".task-log:not(.hidden) .approval:not(.resolved) .actions button")].find((b) => b.textContent.includes("가져오기")).click(); + return true; + }); + await waitFor(() => document.querySelector(".task-log:not(.hidden)")?.innerText.includes("문서 요약"), 20000, "요약"); + // 같은 사이트의 두 번째 페이지는 묻지 않는다 + const approvals = await run(() => document.querySelectorAll(".task-log:not(.hidden) .approval").length); + if (approvals !== 1 || mock.fetched.join() !== "/docs/cookie,/docs/session") throw new Error(`승인 ${approvals}개, 가져온 곳 ${mock.fetched}`); + if (!mock.fetchResult.includes("Cookie signing") || !mock.fetchResult.includes("HMAC") || mock.fetchResult.includes("steal()") || mock.fetchResult.includes("

")) throw new Error(`본문: ${mock.fetchResult}`); +}); + scenario("편집기 안 즉시 수정 (Ctrl+K): diff·영향 반경 → 적용 → 되돌리기", async () => { await run(() => { document.querySelector('.ab-item[data-view="explorer"]').click(); diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index de6a6ab..e61aec2 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -17,7 +17,7 @@ anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1.1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "net"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } futures-util = "0.3" portable-pty = "0.9" @@ -32,6 +32,7 @@ notify = "8" trash = "5" tauri-plugin-updater = "2" regex = "1" +html2text = "0.17.1" [dev-dependencies] tempfile = "3" diff --git a/app/src-tauri/src/agent.rs b/app/src-tauri/src/agent.rs index 78ab0a7..cf37fe3 100644 --- a/app/src-tauri/src/agent.rs +++ b/app/src-tauri/src/agent.rs @@ -352,7 +352,7 @@ async fn run_inner( push_user(&mut history, format!("{text}\n\n\n{}\n", ctx.0), images); let approver = TauriApprover { app: app.clone(), session: session.to_string(), cancel: cancel.clone() }; - let tctx = ToolCtx { project: project.clone(), config: &config, approver: &approver, changed: Default::default(), originals: Default::default(), workdir: workdir.clone(), mcp: mcp.map }; + let tctx = ToolCtx { project: project.clone(), config: &config, approver: &approver, changed: Default::default(), originals: Default::default(), workdir: workdir.clone(), mcp: mcp.map, fetched_hosts: Default::default() }; let max_steps = config.agent.max_steps.max(1); // 고친 뒤 확인: 쓰기·실행이 되는 에이전트, 신뢰한 폴더에서만 let verify_on = config.agent.verify_tests && project.is_trusted() && allowed.iter().any(|t| t == "run_command") && allowed.iter().any(|t| t == "edit_file" || t == "write_file"); diff --git a/app/src-tauri/src/agents.rs b/app/src-tauri/src/agents.rs index 0fd2c04..0c81922 100644 --- a/app/src-tauri/src/agents.rs +++ b/app/src-tauri/src/agents.rs @@ -14,7 +14,7 @@ use serde::Serialize; use std::path::Path; pub const READ_TOOLS: &[&str] = - &["get_context", "search_symbols", "get_symbol", "find_references", "read_file", "list_dir"]; + &["get_context", "search_symbols", "get_symbol", "find_references", "read_file", "list_dir", "fetch_url"]; pub const ALL_TOOLS: &[&str] = &[ "get_context", "search_symbols", @@ -22,6 +22,7 @@ pub const ALL_TOOLS: &[&str] = &[ "find_references", "read_file", "list_dir", + "fetch_url", "write_file", "edit_file", "run_command", diff --git a/app/src-tauri/src/config.rs b/app/src-tauri/src/config.rs index 99b2b2a..3ae2f79 100644 --- a/app/src-tauri/src/config.rs +++ b/app/src-tauri/src/config.rs @@ -206,6 +206,9 @@ pub struct AgentConfig { pub auto_approve: Vec, #[serde(default)] pub allowed_commands: Vec, + /// fetch_url이 승인 없이 열 사이트 (도메인과 그 하위 도메인) + #[serde(default)] + pub allowed_domains: Vec, /// 파일을 고치고 끝낼 때 관련 테스트를 돌려 확인한다 (실패하면 다시 고치게) #[serde(default = "default_true")] pub verify_tests: bool, diff --git a/app/src-tauri/src/e2e_tests.rs b/app/src-tauri/src/e2e_tests.rs index 743e8ce..0f4a962 100644 --- a/app/src-tauri/src/e2e_tests.rs +++ b/app/src-tauri/src/e2e_tests.rs @@ -234,7 +234,7 @@ async fn tools_edit_with_approval_and_rejection() { // 승인하는 경우 let yes = FakeApprover { answer: true, asked: Mutex::new(vec![]) }; - let ctx = ToolCtx { project: p.clone(), config: &cfg, approver: &yes, changed: Default::default(), originals: Default::default(), workdir: None, mcp: Default::default() }; + let ctx = ToolCtx { project: p.clone(), config: &cfg, approver: &yes, changed: Default::default(), originals: Default::default(), workdir: None, mcp: Default::default(), fetched_hosts: Default::default() }; let out = ctx .run("edit_file", &json!({"path":"src/lib.rs","old_string":"hi {name}","new_string":"안녕 {name}"})) .await; @@ -259,7 +259,7 @@ async fn tools_edit_with_approval_and_rejection() { // 거절하는 경우: 파일이 그대로여야 한다 let no = FakeApprover { answer: false, asked: Mutex::new(vec![]) }; - let ctx2 = ToolCtx { project: p.clone(), config: &cfg, approver: &no, changed: Default::default(), originals: Default::default(), workdir: None, mcp: Default::default() }; + let ctx2 = ToolCtx { project: p.clone(), config: &cfg, approver: &no, changed: Default::default(), originals: Default::default(), workdir: None, mcp: Default::default(), fetched_hosts: Default::default() }; let rejected = ctx2.run("write_file", &json!({"path":"src/new.rs","content":"x"})).await; assert!(rejected.is_error && rejected.content.contains("거절")); assert!(!dir.path().join("src/new.rs").exists()); @@ -300,7 +300,7 @@ async fn tools_edit_with_approval_and_rejection() { // 자동 승인 glob cfg.agent.auto_approve = vec!["docs/**".into()]; let never = FakeApprover { answer: false, asked: Mutex::new(vec![]) }; - let ctx3 = ToolCtx { project: p.clone(), config: &cfg, approver: &never, changed: Default::default(), originals: Default::default(), workdir: None, mcp: Default::default() }; + let ctx3 = ToolCtx { project: p.clone(), config: &cfg, approver: &never, changed: Default::default(), originals: Default::default(), workdir: None, mcp: Default::default(), fetched_hosts: Default::default() }; let auto = ctx3.run("write_file", &json!({"path":"docs/a.md","content":"# a"})).await; assert!(!auto.is_error, "{}", auto.content); assert!(never.asked.lock().unwrap().is_empty()); diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index d3506c6..7a59f5d 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -11,6 +11,7 @@ mod review; mod semantic; mod mcp_client; mod verify; +mod webfetch; mod tasks; mod worktree; mod completion; diff --git a/app/src-tauri/src/tools.rs b/app/src-tauri/src/tools.rs index 4c02926..8ecb018 100644 --- a/app/src-tauri/src/tools.rs +++ b/app/src-tauri/src/tools.rs @@ -31,6 +31,8 @@ pub fn specs(allowed: &[String]) -> Vec { json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]})), ("read_file", "Read a file with line numbers (`Ntext`). Optionally a 1-based inclusive line range.", json!({"type":"object","properties":{"path":{"type":"string"},"start_line":{"type":"integer"},"end_line":{"type":"integer"}},"required":["path"]})), + ("fetch_url", "Fetch a web page (http/https) and return it as text, e.g. a library's documentation or an explanation of an error message. Each new site needs the user's approval. Never put project code, file contents or secrets in the URL.", + json!({"type":"object","properties":{"url":{"type":"string"}},"required":["url"]})), ("list_dir", "List a directory (respects .gitignore). Use \".\" for the project root.", json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]})), ("write_file", "Create or overwrite a file with the full content. The user reviews a diff before it is applied. Prefer edit_file for small changes to existing files.", @@ -70,6 +72,8 @@ pub struct ToolCtx<'a> { pub workdir: Option, /// 외부 MCP 도구: 모델에 보이는 이름 → (서버 연결, 서버의 도구 이름) pub mcp: HashMap, String)>, + /// 이번 작업에서 사용자가 열어도 된다고 한 사이트 + pub fetched_hosts: std::sync::Mutex>, } pub struct ToolOutcome { @@ -254,6 +258,17 @@ impl ToolCtx<'_> { self.approver.output(&out); Ok(out) } + "fetch_url" => { + let url = crate::webfetch::check_url(s(input, "url")?)?; + let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); + let known = crate::webfetch::host_allowed(&host, &self.config.agent.allowed_domains) || self.fetched_hosts.lock().unwrap().contains(&host); + if !known && !self.approver.ask("fetch", &format!("웹 페이지 가져오기: {host}"), url.as_str()).await { + bail!("사용자가 {host} 접속을 거절했습니다"); + } + self.fetched_hosts.lock().unwrap().insert(host); + let f = crate::webfetch::fetch(url.as_str()).await?; + Ok(format!("{}\n\n{}", f.url, f.text.trim())) + } other if other.starts_with(crate::mcp_client::PREFIX) => self.mcp_call(other, input).await, other => bail!("알 수 없는 도구: {other}"), } diff --git a/app/src-tauri/src/webfetch.rs b/app/src-tauri/src/webfetch.rs new file mode 100644 index 0000000..2f5b7e5 --- /dev/null +++ b/app/src-tauri/src/webfetch.rs @@ -0,0 +1,172 @@ +//! 웹 문서 가져오기 (에이전트의 fetch_url 도구): 라이브러리 문서, 오류 메시지 설명 등을 글로 읽는다. +//! +//! 사설·로컬 주소는 막는다 (주소를 미리 풀어서 확인하고, 그 주소로만 접속해 DNS를 바꿔치기해도 못 들어가게). +//! 리다이렉트도 한 번씩 같은 확인을 거친다. 크기·시간을 제한하고 HTML은 본문 글로 바꾼다. + +use anyhow::{bail, Context, Result}; +use futures_util::StreamExt; +use reqwest::Url; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +const MAX_BYTES: usize = 2 * 1024 * 1024; +const TIMEOUT: Duration = Duration::from_secs(20); +const MAX_REDIRECTS: usize = 5; +/// HTML을 글로 바꿀 때 한 줄 폭 +const TEXT_WIDTH: usize = 100; + +pub struct Fetched { + pub url: String, + pub text: String, +} + +/// 인터넷의 공개 주소가 아니면 true (루프백·사설·링크 로컬·CGNAT·문서용 등) +pub fn blocked_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v) => { + let o = v.octets(); + v.is_loopback() + || v.is_private() + || v.is_link_local() + || v.is_unspecified() + || v.is_multicast() + || v.is_broadcast() + || v.is_documentation() + || o[0] == 0 + || (o[0] == 100 && (o[1] & 0xc0) == 64) // 100.64.0.0/10 + || (o[0] == 192 && o[1] == 0 && o[2] == 0) // 192.0.0.0/24 + || o[0] >= 240 + } + IpAddr::V6(v) => { + if let Some(v4) = v.to_ipv4_mapped() { + return blocked_ip(IpAddr::V4(v4)); + } + let s = v.segments(); + v.is_loopback() + || v.is_unspecified() + || v.is_multicast() + || (s[0] & 0xfe00) == 0xfc00 // 고유 로컬 + || (s[0] & 0xffc0) == 0xfe80 // 링크 로컬 + || (s[0] == 0x2001 && s[1] == 0x0db8) // 문서용 + } + } +} + +/// 테스트(E2E)에서만 로컬 서버를 허용한다 +fn allow_local() -> bool { + std::env::var("LANTERN_FETCH_ALLOW_LOCAL").is_ok_and(|v| v == "1") +} + +/// 주소를 풀어 공개 주소 하나를 고른다 +async fn resolve(url: &Url) -> Result { + let host = url.host_str().context("주소에 호스트가 없습니다")?; + let port = url.port_or_known_default().unwrap_or(443); + let addrs: Vec = tokio::net::lookup_host((host, port)).await.with_context(|| format!("{host}의 주소를 찾지 못했습니다"))?.collect(); + let first = *addrs.first().with_context(|| format!("{host}의 주소를 찾지 못했습니다"))?; + if !allow_local() && addrs.iter().any(|a| blocked_ip(a.ip())) { + bail!("{host}는 내부망·로컬 주소라 가져오지 않습니다"); + } + Ok(first) +} + +pub fn check_url(url: &str) -> Result { + let u = Url::parse(url.trim()).context("올바른 주소가 아닙니다")?; + if !matches!(u.scheme(), "http" | "https") { + bail!("http·https 주소만 가져옵니다"); + } + if !u.username().is_empty() || u.password().is_some() { + bail!("주소에 계정 정보를 넣을 수 없습니다"); + } + u.host_str().context("주소에 호스트가 없습니다")?; + Ok(u) +} + +pub async fn fetch(url: &str) -> Result { + let mut url = check_url(url)?; + for _ in 0..=MAX_REDIRECTS { + let addr = resolve(&url).await?; + let host = url.host_str().unwrap_or_default().to_string(); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(TIMEOUT) + .resolve(&host, addr) + .user_agent(concat!("Lantern-IDE/", env!("CARGO_PKG_VERSION"))) + .build()?; + let resp = client.get(url.clone()).header("Accept", "text/html, text/plain, text/markdown, application/json;q=0.9, */*;q=0.5").send().await.with_context(|| format!("{host}에 연결하지 못했습니다"))?; + let status = resp.status(); + if status.is_redirection() { + let loc = resp.headers().get(reqwest::header::LOCATION).and_then(|v| v.to_str().ok()).context("리다이렉트 주소가 없습니다")?; + url = check_url(url.join(loc)?.as_str())?; + continue; + } + if !status.is_success() { + bail!("{url} 응답 {status}"); + } + let ctype = resp.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase(); + let mut body = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + body.extend_from_slice(&chunk[..chunk.len().min(MAX_BYTES - body.len())]); + if body.len() >= MAX_BYTES { + break; + } + } + let text = to_text(&ctype, &body)?; + return Ok(Fetched { url: url.to_string(), text }); + } + bail!("리다이렉트가 너무 많습니다") +} + +/// 응답 본문을 모델에 줄 글로 +pub fn to_text(ctype: &str, body: &[u8]) -> Result { + let looks_html = ctype.contains("html") || (ctype.is_empty() && String::from_utf8_lossy(&body[..body.len().min(512)]).to_ascii_lowercase().contains(" bool { + let host = host.to_ascii_lowercase(); + allowed.iter().map(|d| d.trim().trim_start_matches("*.").to_ascii_lowercase()).any(|d| !d.is_empty() && (host == d || host.ends_with(&format!(".{d}")))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_internal_addresses() { + for ip in ["127.0.0.1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "::1", "fd00::1", "fe80::1", "::ffff:127.0.0.1"] { + assert!(blocked_ip(ip.parse().unwrap()), "{ip}"); + } + for ip in ["93.184.216.34", "1.1.1.1", "2606:4700:4700::1111"] { + assert!(!blocked_ip(ip.parse().unwrap()), "{ip}"); + } + } + + #[test] + fn checks_urls_and_hosts() { + assert!(check_url("https://docs.rs/serde").is_ok()); + assert!(check_url("file:///etc/passwd").is_err()); + assert!(check_url("ftp://x.org").is_err()); + assert!(check_url("https://user:pw@x.org").is_err()); + let allowed = vec!["docs.rs".to_string(), "*.mozilla.org".to_string()]; + assert!(host_allowed("docs.rs", &allowed) && host_allowed("developer.mozilla.org", &allowed)); + assert!(!host_allowed("evil-docs.rs", &allowed) && !host_allowed("example.com", &allowed)); + } + + #[test] + fn turns_html_into_text() { + let t = to_text("text/html; charset=utf-8", b"

Title

Hello world

").unwrap(); + assert!(t.contains("Title") && t.contains("Hello") && t.contains("world")); + assert!(!t.contains("bad()") && !t.contains("x{}")); + assert_eq!(to_text("application/json", b"{\"a\":1}").unwrap(), "{\"a\":1}"); + assert!(to_text("image/png", b"\x89PNG").is_err()); + } +} diff --git a/app/src/api.ts b/app/src/api.ts index c37ef6e..f02ee5e 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -25,7 +25,7 @@ export type AgentEvent = { session: string } & ( | { kind: "tool_start"; name: string } | { kind: "tool_call"; id: string; name: string; input: Record } | { kind: "tool_result"; id: string; name: string; content: string; is_error: boolean } - | { kind: "approval"; id: string; approval_kind: "edit" | "command" | "secret" | "mcp"; title: string; detail: string } + | { kind: "approval"; id: string; approval_kind: "edit" | "command" | "secret" | "mcp" | "fetch"; title: string; detail: string } | { kind: "approval_resolved"; id: string; approved: boolean } | { kind: "usage"; request_id: number; model: string; usage: Usage; cost_usd: number | null; month_cost_usd: number; month_limit_usd: number } | { kind: "done"; changed: string[]; checkpoint: string | null } @@ -69,7 +69,7 @@ export interface SettingsSnapshot { context: { budget_tokens: number }; embeddings?: EmbeddingsSettings | null; mcp?: Record; - agent: { max_steps: number; auto_approve: string[]; allowed_commands: string[]; verify_tests: boolean; test_command?: string | null }; + agent: { max_steps: number; auto_approve: string[]; allowed_commands: string[]; allowed_domains?: string[]; verify_tests: boolean; test_command?: string | null }; hooks: { on_save: string[]; on_agent_done: string[] }; }; global_path: string | null; diff --git a/app/src/chat.ts b/app/src/chat.ts index 76e6a9e..49d5303 100644 --- a/app/src/chat.ts +++ b/app/src/chat.ts @@ -150,12 +150,13 @@ function endText(task: Task) { /** mcp__서버__도구 → "서버 · 도구" */ function toolLabel(name: string): string { if (name === "run_tests") return "관련 테스트로 확인"; + if (name === "fetch_url") return "웹 페이지"; const m = name.match(/^mcp__(.+?)__(.+)$/); return m ? `${m[1]} · ${m[2]}` : name; } function toolSummary(input: Record): string { - const v = input.path ?? input.query ?? input.name ?? input.command ?? input.topic ?? ""; + const v = input.path ?? input.query ?? input.name ?? input.command ?? input.url ?? input.topic ?? ""; return typeof v === "string" ? v : JSON.stringify(v); } @@ -455,7 +456,7 @@ function handle(ev: AgentEvent) { const el = h("details", { class: "tool" }, h("summary", {}, h("span", { class: "tstate run" }, codicon("loading", "codicon-modifier-spin")), - ev.name.startsWith("mcp__") ? codicon("plug") : ev.name === "run_tests" ? codicon("beaker") : null, + ev.name.startsWith("mcp__") ? codicon("plug") : ev.name === "run_tests" ? codicon("beaker") : ev.name === "fetch_url" ? codicon("globe") : null, h("span", { class: "tname" }, toolLabel(ev.name)), h("span", { class: "targ" }, toolSummary(ev.input))), h("pre", {}, JSON.stringify(ev.input, null, 2))); @@ -483,13 +484,14 @@ function handle(ev: AgentEvent) { endText(task); const isEdit = ev.approval_kind === "edit"; const isMcp = ev.approval_kind === "mcp"; + const isFetch = ev.approval_kind === "fetch"; const decide = (ok: boolean) => void api.resolveApproval(task.id, ev.id, ok); - const apply = h("button", { class: "btn btn-primary", onclick: () => decide(true) }, codicon("check"), isEdit ? "적용" : "실행"); + const apply = h("button", { class: "btn btn-primary", onclick: () => decide(true) }, codicon("check"), isEdit ? "적용" : isFetch ? "가져오기" : "실행"); const target = isEdit ? diffTarget(ev.detail) : null; const newPath = isEdit ? ev.detail.match(/^\+\+\+ (?:b\/)?(.+)$/m)?.[1]?.trim() : undefined; if (newPath) task.approvalPaths.set(ev.id, newPath); const el = h("div", { class: "approval", role: "group", "aria-label": ev.title, "data-kind": ev.approval_kind }, - h("div", { class: "ahead" }, codicon(isEdit ? "edit" : isMcp ? "plug" : "terminal"), approvalTitle(ev.title, isEdit), h("span", { class: "verdict" })), + h("div", { class: "ahead" }, codicon(isEdit ? "edit" : isMcp ? "plug" : isFetch ? "globe" : "terminal"), approvalTitle(ev.title, isEdit), h("span", { class: "verdict" })), isEdit ? renderDiff(ev.detail) : h("pre", { class: "diff" }, h("div", {}, ev.detail)), target && !replaying ? impactRow(ev.detail, (names) => { task.approvalTouched.set(ev.id, names); @@ -499,7 +501,7 @@ function handle(ev: AgentEvent) { h("div", { class: "actions" }, apply, h("button", { class: "btn btn-secondary", onclick: () => decide(false) }, "거절"), - h("span", { class: "hint" }, isEdit ? "적용하기 전에는 파일이 바뀌지 않습니다" : isMcp ? "외부 MCP 서버의 도구입니다. 설정에서 승인 없이 실행하게 할 수 있습니다" : "허용 목록 밖의 명령입니다"))); + h("span", { class: "hint" }, isEdit ? "적용하기 전에는 파일이 바뀌지 않습니다" : isMcp ? "외부 MCP 서버의 도구입니다. 설정에서 승인 없이 실행하게 할 수 있습니다" : isFetch ? "이 사이트는 이번 작업에서 처음 엽니다. 주소에 코드나 비밀이 들어 있지 않은지 확인하세요" : "허용 목록 밖의 명령입니다"))); task.approvalEls.set(ev.id, el); append(task, el); task.pendingApprovals++; @@ -516,7 +518,7 @@ function handle(ev: AgentEvent) { if (!el) break; el.classList.add("resolved"); const v = el.querySelector(".verdict")!; - v.textContent = !ev.approved ? "거절됨" : el.dataset.kind === "edit" ? "적용됨" : "실행함"; + v.textContent = !ev.approved ? "거절됨" : el.dataset.kind === "edit" ? "적용됨" : el.dataset.kind === "fetch" ? "가져옴" : "실행함"; v.classList.toggle("yes", ev.approved); const p = task.approvalPaths.get(ev.id); if (ev.approved && p) { diff --git a/app/src/locales/en.json b/app/src/locales/en.json index e15fdb5..377e255 100644 --- a/app/src/locales/en.json +++ b/app/src/locales/en.json @@ -693,7 +693,14 @@ "비우면 프로젝트를 보고 고릅니다 (vitest·jest·pytest·go·cargo·maven·gradle). {files}에 관련 테스트 파일이 들어갑니다.": "Leave empty to pick one from the project (vitest, jest, pytest, go, cargo, maven, gradle). {files} is replaced with the related test files.", "에이전트가 파일을 고치고 끝내려 할 때, 영향 반경이 찾은 관련 테스트만 돌립니다. 실패하면 출력을 보고 두 번까지 다시 고치고, 같은 실패가 반복되면 멈춥니다. 실행은 명령 승인 규칙을 따릅니다.": "When the agent finishes editing files, only the tests the impact radius links to them are run. On failure it reads the output and fixes up to twice, and stops if the same failure repeats. Running follows the command approval rules.", "예: pnpm vitest run {files}": "e.g. pnpm vitest run {files}", - "테스트 명령": "Test command" + "테스트 명령": "Test command", + "가져오기": "Fetch", + "가져옴": "Fetched", + "승인 없이 열 사이트": "Sites opened without approval", + "에이전트의 웹 페이지 가져오기(fetch_url)가 묻지 않고 여는 도메인입니다. 하위 도메인도 포함합니다. 내부망·로컬 주소는 늘 막습니다.": "Domains the agent's web page fetch (fetch_url) opens without asking, including subdomains. Internal and local addresses are always blocked.", + "예: docs.rs": "e.g. docs.rs", + "웹 페이지": "Web page", + "이 사이트는 이번 작업에서 처음 엽니다. 주소에 코드나 비밀이 들어 있지 않은지 확인하세요": "This task opens this site for the first time. Check that the address doesn't contain code or secrets" }, "templates": { "'{}'{}을(를) 휴지통으로 옮길까요?": "Move '{}'{} to Trash?", diff --git a/app/src/settingsPage.ts b/app/src/settingsPage.ts index 805293e..6cdcf38 100644 --- a/app/src/settingsPage.ts +++ b/app/src/settingsPage.ts @@ -615,6 +615,7 @@ async function refresh() { numberField("최대 단계", "한 번의 요청에서 도구를 부를 수 있는 최대 횟수입니다.", "agent.max_steps", c.agent.max_steps, { min: 1, max: 200 }), tagField("승인 없이 실행할 명령", "앞부분이 일치하면 바로 실행합니다. &, |, ; 같은 연결 기호가 있으면 항상 묻습니다.", "agent.allowed_commands", c.agent.allowed_commands, "명령 입력 후 Enter"), tagField("승인 없이 수정할 파일", "glob 형식. 예: docs/**, **/*.test.ts", "agent.auto_approve", c.agent.auto_approve, "패턴 입력 후 Enter"), + tagField("승인 없이 열 사이트", "에이전트의 웹 페이지 가져오기(fetch_url)가 묻지 않고 여는 도메인입니다. 하위 도메인도 포함합니다. 내부망·로컬 주소는 늘 막습니다.", "agent.allowed_domains", c.agent.allowed_domains ?? [], "예: docs.rs"), toggleField("고친 뒤 관련 테스트로 확인", "에이전트가 파일을 고치고 끝내려 할 때, 영향 반경이 찾은 관련 테스트만 돌립니다. 실패하면 출력을 보고 두 번까지 다시 고치고, 같은 실패가 반복되면 멈춥니다. 실행은 명령 승인 규칙을 따릅니다.", "agent.verify_tests", c.agent.verify_tests ?? true), textField("테스트 명령", "비우면 프로젝트를 보고 고릅니다 (vitest·jest·pytest·go·cargo·maven·gradle). {files}에 관련 테스트 파일이 들어갑니다.", "agent.test_command", c.agent.test_command ?? "", "예: pnpm vitest run {files}")),