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
10 changes: 8 additions & 2 deletions ide/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down Expand Up @@ -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) });
Expand Down
8 changes: 6 additions & 2 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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));
}
}

Expand Down
14 changes: 3 additions & 11 deletions ide/src/editor/MonacoPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
52 changes: 52 additions & 0 deletions ide/src/encodingNote.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
28 changes: 28 additions & 0 deletions ide/src/encodingNote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// 메인이 "UTF-8 이 아니라 안 열었다" 고 답한 것을 사람 말로 바꾼다.
//
// 메인은 IPC 로 `SCHUTZ_ENCODING:<kind>` 라는 코드만 던진다(전자는 언어를 모른다).
// 그 코드를 번역하는 자리가 편집기 안에만 있어서, 같은 오류가 다른 길로 나오면
// 원시 코드가 그대로 보였다. 특히 **에이전트**가 그랬다 — 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");
}
2 changes: 1 addition & 1 deletion ide/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading