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

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

45 changes: 44 additions & 1 deletion app/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<html><head><script>steal()</script></head><body><h1>${req.url === "/docs/cookie" ? "Cookie signing" : "Session lifetime"}</h1><p>Sign cookies with an HMAC; keep sessions for 7 days.</p></body></html>`);
}
if (req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ data: [{ id: "e2e-model" }] }));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -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("<p>")) throw new Error(`본문: ${mock.fetchResult}`);
});

scenario("편집기 안 즉시 수정 (Ctrl+K): diff·영향 반경 → 적용 → 되돌리기", async () => {
await run(() => {
document.querySelector('.ab-item[data-view="explorer"]').click();
Expand Down
3 changes: 2 additions & 1 deletion app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,6 +32,7 @@ notify = "8"
trash = "5"
tauri-plugin-updater = "2"
regex = "1"
html2text = "0.17.1"

[dev-dependencies]
tempfile = "3"
2 changes: 1 addition & 1 deletion app/src-tauri/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ async fn run_inner(
push_user(&mut history, format!("{text}\n\n<project_context>\n{}\n</project_context>", 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");
Expand Down
3 changes: 2 additions & 1 deletion app/src-tauri/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ 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",
"get_symbol",
"find_references",
"read_file",
"list_dir",
"fetch_url",
"write_file",
"edit_file",
"run_command",
Expand Down
3 changes: 3 additions & 0 deletions app/src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ pub struct AgentConfig {
pub auto_approve: Vec<String>,
#[serde(default)]
pub allowed_commands: Vec<String>,
/// fetch_url이 승인 없이 열 사이트 (도메인과 그 하위 도메인)
#[serde(default)]
pub allowed_domains: Vec<String>,
/// 파일을 고치고 끝낼 때 관련 테스트를 돌려 확인한다 (실패하면 다시 고치게)
#[serde(default = "default_true")]
pub verify_tests: bool,
Expand Down
6 changes: 3 additions & 3 deletions app/src-tauri/src/e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand Down Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod review;
mod semantic;
mod mcp_client;
mod verify;
mod webfetch;
mod tasks;
mod worktree;
mod completion;
Expand Down
15 changes: 15 additions & 0 deletions app/src-tauri/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub fn specs(allowed: &[String]) -> Vec<ToolSpec> {
json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]})),
("read_file", "Read a file with line numbers (`N<TAB>text`). 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.",
Expand Down Expand Up @@ -70,6 +72,8 @@ pub struct ToolCtx<'a> {
pub workdir: Option<std::path::PathBuf>,
/// 외부 MCP 도구: 모델에 보이는 이름 → (서버 연결, 서버의 도구 이름)
pub mcp: HashMap<String, (Arc<crate::mcp_client::Conn>, String)>,
/// 이번 작업에서 사용자가 열어도 된다고 한 사이트
pub fetched_hosts: std::sync::Mutex<std::collections::HashSet<String>>,
}

pub struct ToolOutcome {
Expand Down Expand Up @@ -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}"),
}
Expand Down
Loading
Loading