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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py

- Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (168: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (740: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog)
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (752: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog)
- CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh <branch>`, never an empty commit
- Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3`
- Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py <N>` (did the merge land a tree its reviews named)
Expand Down
53 changes: 52 additions & 1 deletion emrg/gui/renderer/src/lib/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { FENCE_END_RE, isFenceClosed, createMarkdownRenderer, type MarkedLike } from "./markdown";
import {
FENCE_END_RE,
isFenceClosed,
createMarkdownRenderer,
normalizeImageSrc,
isLocalImageSrc,
type MarkedLike,
} from "./markdown";

/**
* markdown.test.ts — marked/DOMPurify/highlight 封装纯逻辑测试(Batch 1 remainder)。
Expand Down Expand Up @@ -269,3 +276,47 @@ describe("块投影:live 代码围栏 未闭合→纯文本 / 闭合→完整
expect(body.querySelectorAll(".md-block:not(.live)")).toHaveLength(1);
});
});

describe("normalizeImageSrc:本地图片两种写法归一(issue #2638)", () => {
it("根绝对路径 → file:///…(origin 根不是宿主文件系统)", () => {
expect(normalizeImageSrc("/Users/me/shot.png")).toBe("file:///Users/me/shot.png");
});

it("已是 file: 的原样返回(不重复加 scheme)", () => {
expect(normalizeImageSrc("file:///Users/me/shot.png")).toBe("file:///Users/me/shot.png");
});

it("其它 scheme / 协议相对 / 相对路径原样返回", () => {
expect(normalizeImageSrc("https://x.dev/a.png")).toBe("https://x.dev/a.png");
expect(normalizeImageSrc("data:image/png;base64,AAA")).toBe("data:image/png;base64,AAA");
expect(normalizeImageSrc("//cdn.example.com/a.png")).toBe("//cdn.example.com/a.png");
expect(normalizeImageSrc("shots/a.png")).toBe("shots/a.png");
});

it("Windows 盘符路径 → file:///(单字母不是 scheme)", () => {
expect(normalizeImageSrc("C:\\shots\\a.png")).toBe("file:///C:/shots/a.png");
expect(normalizeImageSrc("C:/shots/a.png")).toBe("file:///C:/shots/a.png");
});

it("空值原样(不造出 file:// 空路径)", () => {
expect(normalizeImageSrc("")).toBe("");
expect(normalizeImageSrc(undefined)).toBe("");
});
});

describe("isLocalImageSrc:放行条件只认 img[src] 的 file:(issue #2638)", () => {
it("img + src + file: → true(大小写不敏感)", () => {
expect(isLocalImageSrc("img", "src", "file:///a.png")).toBe(true);
expect(isLocalImageSrc("IMG", "SRC", "FILE:///a.png")).toBe(true);
});

it("同一 URL 挂在别的元素/属性上 → false(放行范围不扩)", () => {
expect(isLocalImageSrc("a", "href", "file:///a.png")).toBe(false);
expect(isLocalImageSrc("img", "onerror", "file:///a.png")).toBe(false);
expect(isLocalImageSrc("iframe", "src", "file:///a.png")).toBe(false);
});

it("img[src] 但不是 file: → false(javascript:/data: 走默认策略)", () => {
expect(isLocalImageSrc("img", "src", "javascript:alert(1)")).toBe(false);
});
});
90 changes: 87 additions & 3 deletions emrg/gui/renderer/src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,69 @@ export interface MarkedLike {
parse(text: string, opts?: Record<string, unknown>): string | Promise<string>;
lexer(text: string): Array<Record<string, unknown>>;
parser(tokens: Array<Record<string, unknown>>): string;
use(opts: { renderer?: { code?: (code: string, infostring: string, escaped: boolean) => string } }): unknown;
use(opts: {
renderer?: {
code?: (code: string, infostring: string, escaped: boolean) => string;
image?: (href: string, title: string | null, text: string) => string;
};
}): unknown;
}
export interface DomPurifyLike {
sanitize(html: string): string;
/** 属性级钩子的注册入口(DOMPurify addHook);没有它的替身只能走默认策略,file: 不会被放行 */
addHook?(name: string, fn: SanitizeAttributeHook): void;
}

/** DOMPurify `uponSanitizeAttribute` 钩子的入参(只声明这里用到的字段) */
export interface SanitizeAttributeHookArg {
attrName?: unknown;
attrValue?: unknown;
forceKeepAttr?: unknown;
}
/** 钩子签名:node 是被消毒的元素,data 是当前属性的可改写描述 */
export type SanitizeAttributeHook = (
node: { nodeName?: unknown },
data: SanitizeAttributeHookArg,
) => void;
export interface HljsLike {
getLanguage(lang: string): boolean;
highlight(code: string, opts: { language: string; ignoreIllegals: boolean }): { value: string };
highlightAuto(code: string): { value: string };
}

/**
* 本地图片路径归一(issue #2638)。
*
* 渲染进程打包后是 `file://` 文档,`/Users/me/shot.png` 这种根绝对路径会被当成
* **origin 根**解析(应用自己的 dist 目录),不是宿主文件系统 —— 所以在进 DOM 前
* 改写成 `file:///Users/me/shot.png`。已有 scheme(`file:` / `http:` / `data:`…)、
* 协议相对(`//host/a.png`)与相对路径原样返回,归一只管根绝对写法。
*/
export function normalizeImageSrc(href: unknown): string {
const src = String(href ?? "").trim();
if (!src) return src;
if (src.startsWith("//")) return src; // 协议相对:保留原意,不猜 scheme
// 盘符路径 C:\x 或 C:/x —— 单字母"scheme"不是 scheme
if (/^[a-zA-Z]:[\\/]/.test(src)) return `file:///${src.replace(/\\/g, "/")}`;
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(src)) return src;
if (src.startsWith("/")) return `file://${src}`;
return src;
}

/**
* `file:` 是否只该在这一处放行:`img[src]`。
*
* 判断条件收在一个函数里,钩子与测试读同一份 —— 放行范围一旦扩出去(`a[href]`、
* `iframe[src]`、任何事件属性),这里就是唯一会红的地方。
*/
export function isLocalImageSrc(tagName: unknown, attrName: unknown, value: unknown): boolean {
return (
String(tagName ?? "").toLowerCase() === "img" &&
String(attrName ?? "").toLowerCase() === "src" &&
/^file:/i.test(String(value ?? ""))
);
}

/** 代码块复制按钮文案(默认英文;与 vanilla "复制代码"/"Copy code" 对齐) */
type Translate = (key: string) => string;

Expand Down Expand Up @@ -86,11 +138,43 @@ export function createMarkdownRenderer(deps: {
return `<div class="code-block"><div class="code-head"><button type="button" class="code-copy" title="${copyLabel()}">${copyLabel()}</button></div><pre>${codeHtml}</pre></div>`;
}

/** marked.use 自定义 renderer 只配置一次 */
/**
* marked image renderer:src 先归一再转义(`/abs.png` → `file:///abs.png`)。
* alt/title 一并转义 —— 默认 renderer 把 text 直接拼进属性,靠消毒器兜底;
* 这里自己转义,属性边界不由 HTML 的宽容解析决定。
*/
function imageRenderer(href: string, title: string | null, text: string): string {
const src = escapeHtml(normalizeImageSrc(href));
const alt = escapeHtml(text ?? "");
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `<img src="${src}" alt="${alt}"${titleAttr}>`;
}

/**
* 放行 `img[src]` 的 `file:`(issue #2638)。
*
* DOMPurify 的默认 URI 策略不含 `file:`,会把 `src` 整个剥掉,只剩一个带 alt 的
* 破图 —— 本地图片于是只有 base64 一条路。这里的钩子只在 isLocalImageSrc 成立时
* 置 `forceKeepAttr`:DOMPurify 3.x 里它只绕过 URI/白名单检查,`SAFE_FOR_XML` 与
* `attributename` 检查仍在;`javascript:` 与其它元素/属性因此逐字走默认策略。
* 没有 addHook 的替身不做任何放行 —— 默认策略就是它的全部行为。
*/
function installFileImageHook(): void {
if (!DOMPurify || typeof DOMPurify.addHook !== "function") return;
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
if (!isLocalImageSrc(node && node.nodeName, data && data.attrName, data && data.attrValue)) {
return;
}
data.forceKeepAttr = true;
});
}

/** marked.use 自定义 renderer / 消毒钩子只配置一次 */
let configured = false;
function ensureConfigured(): void {
if (!marked || configured) return;
marked.use({ renderer: { code: codeRenderer } });
marked.use({ renderer: { code: codeRenderer, image: imageRenderer } });
installFileImageHook();
configured = true;
}

Expand Down
36 changes: 36 additions & 0 deletions emrg/gui/renderer/src/lib/vendorMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,39 @@ describe("vendorMarkdown", () => {
expect(html).toContain("<strong>ok</strong>");
});
});

/**
* 本地图片(issue #2638):`file:` 只对 `img[src]` 放行。
*
* 这一组必须用**真实 vendor** DOMPurify 断言 —— 断言的对象是它 3.4.13 的策略,
* 不是本仓库对策略的建模。撤掉 markdown.ts 里的钩子,前两条会退化成只剩 alt 的破图
* (`<img alt="shot">`),第四条会跟着变绿;这正是它们要拦住的方向。
*/
describe("本地图片:file: 只对 img[src] 放行(issue #2638)", () => {
it("file:/// 写法 → 带 src 的 img(不是只剩 alt 的破图)", async () => {
const md = createProdMarkdownRenderer();
const html = await md.renderMarkdown("![shot](file:///Users/me/shot.png)");
expect(html).toContain("<img");
expect(html).toContain('src="file:///Users/me/shot.png"');
});

it("/绝对路径 写法 → 归一成 file:///,同样带 src", async () => {
const md = createProdMarkdownRenderer();
const html = await md.renderMarkdown("![shot](/Users/me/shot.png)");
expect(html).toContain('src="file:///Users/me/shot.png"');
});

it("javascript: 仍被剥(img 与 a 都不放行)", async () => {
const md = createProdMarkdownRenderer();
const html = await md.renderMarkdown(
'<img src="javascript:alert(1)">\n\n<a href="javascript:alert(2)">x</a>',
);
expect(html).not.toContain("javascript:");
});

it("同一个 file: URL 挂到 a[href] 上仍被剥(放行范围只有 img[src])", async () => {
const md = createProdMarkdownRenderer();
const html = await md.renderMarkdown("[open](file:///Users/me/shot.png)");
expect(html).not.toContain('href="file:');
});
});
8 changes: 8 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,11 @@
.error-boundary-actions button:hover {
background: var(--bg-soft);
}

/* 聊天区本地图片(issue #2638):本地截图动辄几千像素宽,只收宽不放高——
小于容器的图保持原始尺寸。 */
.assistant-html img,
.md-stream img {
max-width: 100%;
height: auto;
}
9 changes: 8 additions & 1 deletion emrg/gui/renderer/src/vendor.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ declare module "*/vendor/marked.min.js" {
parse(text: string, opts?: Record<string, unknown>): string | Promise<string>;
lexer(text: string): Array<Record<string, unknown>>;
parser(tokens: Array<Record<string, unknown>>): string;
use(opts: { renderer?: { code?: (code: string, infostring: string, escaped: boolean) => string } }): unknown;
use(opts: {
renderer?: {
code?: (code: string, infostring: string, escaped: boolean) => string;
image?: (href: string, title: string | null, text: string) => string;
};
}): unknown;
}
const marked: MarkedLike;
export default marked;
Expand All @@ -17,6 +22,8 @@ declare module "*/vendor/marked.min.js" {
declare module "*/vendor/dompurify.min.js" {
interface DomPurifyLike {
sanitize(html: string): string;
/** DOMPurify addHook —— 放行 img[src] 的 file: 只有这一条路(issue #2638) */
addHook?(name: string, fn: (node: { nodeName?: unknown }, data: { attrName?: unknown; attrValue?: unknown; forceKeepAttr?: unknown }) => void): void;
}
const DOMPurify: DomPurifyLike;
export default DOMPurify;
Expand Down
6 changes: 6 additions & 0 deletions emrg/server/prompts/system.j2
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ Session `{{ session.id }}` lives in `{{ session.dir_path }}/`.
- **Outward-facing GitHub text is English**, whatever language the request, rant or conversation used: PR titles and bodies, review comments, issue replies, commit messages, blog posts, upstream issues. A quotation from another language is carried in English with its source named; the original script does not appear in the artifact.
- **Internal records keep their own language**: memory files, session notes, cycle records, run logs, closing summaries.

### Images in a reply

The GUI chat renders a local image from a path and from nothing else. Write it as Markdown image syntax with an absolute path — `![the login form](/Users/me/shot.png)` or `![the login form](file:///Users/me/shot.png)`; both forms render. The TUI has no image surface, so there the same line reads as text, and the alt text must carry what the picture shows.

Never inline the picture as a `data:` URI, and never paste an encoded blob in place of a path. One screenshot then runs to hundreds of thousands of characters, a single broken newline ends the string, and the rest of the payload floods the transcript as body text — which is what the host ends up reading instead of the picture.

## Rants

A rant (吐槽) is the host's feedback — a complaint, a bug, a feature request, a suggestion about EMRG or about any registered agent. It arrives as ordinary conversation ("this is bad", "there's a bug", "it should…", "why not…"), so there is no mode to enter and no prefix to wait for.
Expand Down
Loading
Loading