diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index f445f85..a4f7527 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -906,8 +906,12 @@ ipcMain.handle("schutz:runCommand", async (e, opts) => { const root = String(opts?.cwd || ""); const command = String(opts?.command || "").trim(); if (!command) return { ok: false, error: "빈 명령" }; + // 열려 있는 워크스페이스에서만 돈다. 예전엔 존재하는 디렉터리이기만 하면 됐다 — + // 렌더러가 넘긴 아무 경로에서나(홈 디렉터리라도) 명령이 돌 수 있었다는 뜻이다. + // 다른 fs 핸들러는 전부 assertRoot 를 태우는데 여기만 빠져 있었다. + try { assertRoot(root); } catch { return { ok: false, error: "열려 있지 않은 워크스페이스" }; } let rootOk = false; - try { rootOk = !!root && require("fs").existsSync(root); } catch { rootOk = false; } + try { rootOk = require("fs").existsSync(root); } catch { rootOk = false; } if (!rootOk) return { ok: false, error: "워크스페이스가 없습니다" }; const id = String(opts.id || ("run" + Date.now())); @@ -936,7 +940,9 @@ ipcMain.handle("schutz:runCommand", async (e, opts) => { let child; try { - // shell:true — 사용자가 터미널에 치는 것과 같은 해석. cwd 로 워크스페이스에 가둔다. + // shell:true — 사용자가 터미널에 치는 것과 같은 해석. cwd 는 워크스페이스에서 + // **시작한다**는 뜻일 뿐, 경계가 아니다(명령이 cd 든 절대경로든 쓸 수 있다). + // 진짜 경계는 그 위의 승인 게이트다 — 여기 주석이 "가둔다" 고 잘못 적혀 있었다. child = spawn(command, { cwd: root, shell: true, env: process.env, windowsHide: true }); } catch (err) { return finish({ ok: false, error: err && err.message ? err.message : String(err) }); diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 5506d5e..f0a4f0d 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -32,6 +32,7 @@ import { resolveRenameTarget, isMove } from "./engine/movePath"; import { applyProposal } from "./engine/editApply"; import { planRun, langFor, LANGS as RUN_LANGS } from "./engine/runFile"; import { AskQueue } from "./engine/askQueue"; +import { encodingMessage } from "./encodingNote"; import { getRunOverride, getRunOverrides, setRunOverride } from "./settings"; import { emptyNav, push as navPush, back as navBack, forward as navForward, current as navCurrent, dropMissing as navDropMissing, type NavState } from "./engine/navHistory"; import { shouldProbeQuota } from "./engine/quotaPoll"; @@ -1955,7 +1956,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // 그건 코드를 한 프레임에 갈아끼우고 에디터를 깜빡이게 하며 스크롤을 날렸다. await this.animateEditIntoModel(ws.root, p.rel, newContent, editStart, editEnd, p.find, p.replace); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + // 인코딩 거절 코드가 카드에 그대로 뜨지 않게 사람 말로 바꾼다. + const msg = encodingMessage(e instanceof Error ? e.message : String(e)); this._proposalsById.set(id, { ...p, status: "failed", error: msg }); this.setState(s => ({ proposals: s.proposals.map(x => x.id === id ? { ...x, status: "failed" as const, error: msg } : x), @@ -3610,7 +3612,9 @@ ${(r.output || "").slice(0, 2000)}`; return "알 수 없는 도구: " + call.name; } catch (e) { this.setTool(toolId, { st: "done", note: t("sc2.noteError") }); - return "오류: " + (e instanceof Error ? e.message : String(e)); + // 인코딩 거절은 코드(`SCHUTZ_ENCODING:...`)로 온다. 그대로 넘기면 모델이 무슨 + // 일인지 몰라 같은 파일을 계속 다시 읽으며 라운드를 태운다 — 사람 말로 바꾼다. + return "오류: " + encodingMessage(e instanceof Error ? e.message : String(e)); } } diff --git a/ide/src/editor/MonacoPane.tsx b/ide/src/editor/MonacoPane.tsx index eacbb91..ff3df98 100644 --- a/ide/src/editor/MonacoPane.tsx +++ b/ide/src/editor/MonacoPane.tsx @@ -6,17 +6,9 @@ import * as projectModels from "./projectModels"; import { t } from "../i18n"; import { useLang } from "../i18n/useLang"; -/** 메인이 "UTF-8 이 아니라 안 열었다" 고 답한 경우를 사람 말로 바꾼다. - * 왜 안 열리는지 말해 주지 않으면 그냥 고장 난 편집기로 보인다. */ -function encodingMessage(raw: string): string { - const mark = "SCHUTZ_ENCODING:"; - const i = raw.indexOf(mark); - if (i < 0) return raw; - const kind = raw.slice(i + mark.length).trim().split(/\s/)[0]; - if (kind === "binary") return t("enc.binary"); - if (kind === "utf16le" || kind === "utf16be") return t("enc.utf16"); - return t("enc.notUtf8"); -} +// 왜 안 열리는지 말해 주지 않으면 그냥 고장 난 편집기로 보인다. +// 문구는 encodingNote 하나로 모았다 — 에이전트도 같은 문장을 받아야 한다. +import { encodingMessage } from "../encodingNote"; // @ts-ignore — monaco-vim 타입 미제공 import { initVimMode } from "monaco-vim"; diff --git a/ide/src/encodingNote.test.ts b/ide/src/encodingNote.test.ts new file mode 100644 index 0000000..f69adcf --- /dev/null +++ b/ide/src/encodingNote.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { encodingKind, encodingMessage } from "./encodingNote"; + +describe("encodingKind", () => { + it("코드가 없으면 null", () => { + expect(encodingKind("그냥 오류")).toBeNull(); + expect(encodingKind("")).toBeNull(); + }); + + it("Error.message 앞에 붙는 접두사를 지나 읽는다", () => { + // IPC 를 건너오며 "Error invoking remote method '...': Error: " 가 앞에 붙는다. + expect(encodingKind("Error invoking remote method 'schutz:readFile': Error: SCHUTZ_ENCODING:not-utf8")).toBe("not-utf8"); + }); + + it("종류를 그대로 읽는다", () => { + expect(encodingKind("SCHUTZ_ENCODING:utf16le")).toBe("utf16le"); + expect(encodingKind("SCHUTZ_ENCODING:binary")).toBe("binary"); + }); + + it("뒤에 딸린 말은 종류에 섞지 않는다", () => { + expect(encodingKind("SCHUTZ_ENCODING:binary\n at foo (bar.js:1)")).toBe("binary"); + }); +}); + +describe("encodingMessage", () => { + it("인코딩 오류가 아니면 받은 문장을 그대로 돌려준다", () => { + expect(encodingMessage("파일이 너무 큽니다 (900 KB)")).toBe("파일이 너무 큽니다 (900 KB)"); + }); + + it("원시 코드를 사람에게 보이지 않는다", () => { + for (const k of ["not-utf8", "utf16le", "utf16be", "binary"]) { + const msg = encodingMessage("SCHUTZ_ENCODING:" + k); + expect(msg).not.toContain("SCHUTZ_ENCODING"); + expect(msg.length).toBeGreaterThan(10); + } + }); + + it("UTF-16 두 방향이 같은 문장을 쓴다", () => { + expect(encodingMessage("SCHUTZ_ENCODING:utf16le")).toBe(encodingMessage("SCHUTZ_ENCODING:utf16be")); + }); + + it("종류마다 다른 문장이다", () => { + const a = encodingMessage("SCHUTZ_ENCODING:not-utf8"); + const b = encodingMessage("SCHUTZ_ENCODING:utf16le"); + const c = encodingMessage("SCHUTZ_ENCODING:binary"); + expect(new Set([a, b, c]).size).toBe(3); + }); + + it("모르는 종류는 일반 안내로 떨어진다(빈칸이 아니다)", () => { + expect(encodingMessage("SCHUTZ_ENCODING:koi8r")).toBe(encodingMessage("SCHUTZ_ENCODING:not-utf8")); + }); +}); diff --git a/ide/src/encodingNote.ts b/ide/src/encodingNote.ts new file mode 100644 index 0000000..80f2f2d --- /dev/null +++ b/ide/src/encodingNote.ts @@ -0,0 +1,28 @@ +// 메인이 "UTF-8 이 아니라 안 열었다" 고 답한 것을 사람 말로 바꾼다. +// +// 메인은 IPC 로 `SCHUTZ_ENCODING:` 라는 코드만 던진다(전자는 언어를 모른다). +// 그 코드를 번역하는 자리가 편집기 안에만 있어서, 같은 오류가 다른 길로 나오면 +// 원시 코드가 그대로 보였다. 특히 **에이전트**가 그랬다 — read_file 결과로 +// `오류: SCHUTZ_ENCODING:not-utf8` 을 받으면 무슨 일인지 알 수 없어 같은 파일을 +// 계속 다시 읽으며 라운드를 태운다. 그래서 한 군데로 모은다. + +import { t } from "./i18n"; + +const PREFIX = "SCHUTZ_ENCODING:"; + +/** 이 오류가 인코딩 거절인가 — 맞으면 종류, 아니면 null. */ +export function encodingKind(raw: string): string | null { + const s = String(raw ?? ""); + const i = s.indexOf(PREFIX); + if (i < 0) return null; + return s.slice(i + PREFIX.length).trim().split(/\s/)[0] || null; +} + +/** 사람이 읽을 문장. 인코딩 거절이 아니면 받은 문자열을 그대로 돌려준다. */ +export function encodingMessage(raw: string): string { + const kind = encodingKind(raw); + if (!kind) return String(raw ?? ""); + if (kind === "binary") return t("enc.binary"); + if (kind === "utf16le" || kind === "utf16be") return t("enc.utf16"); + return t("enc.notUtf8"); +} diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index 462158c..09e8d7a 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ "src/theme.test.ts", "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", - "src/uiMode.test.ts", "src/asyncErrors.test.ts", + "src/uiMode.test.ts", "src/asyncErrors.test.ts", "src/encodingNote.test.ts", "src/agentTimeline.test.ts", "src/conversations.test.ts", "src/cliChats.test.ts",