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` (729: 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 49 Shell + 24 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog)
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (734: 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 49 Shell + 24 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 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
27 changes: 22 additions & 5 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2283,11 +2283,28 @@ async def _reconnect():
elif notice:
chat.add("system", f"Sandbox roots: {notice}")
elif op == "list":
listing = (
", ".join(current_roots) if current_roots
else "none — this session writes only inside its workspace"
)
chat.add("system", f"Extra writable roots: {listing}")
# The whole surface, typed, with where each grant came
# from (issue #2492): the session's own grants and the
# installation's defaults. A default shown without its
# provenance would read as something the host named, and
# one not shown at all could not be withdrawn — which is
# the deployment constant host decision D5 deleted, back
# under a new name.
grants = [
grant for grant in (data.get("grants") or [])
if isinstance(grant, dict)
]
if grants:
listing = "\n ".join(
f"\n {grant.get('target')!s}"
f" [{grant.get('kind')!s}, {grant.get('source')!s}]"
for grant in grants
)
else:
listing = (
"none — this session writes only inside its workspace"
)
chat.add("system", f"Writable grants:{listing}")
elif op == "remove":
chat.add("system", "Sandbox root removed.")
else:
Expand Down
3 changes: 3 additions & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
// 2026-09-30T09:30:16, GUI half) — absent is "the daemon has not said".
sandboxBySid: {},
sandboxRootsBySid: {},
// And the same surface typed, with provenance (issue #2492): absent is "the
// daemon has not said", which is what a renderer holds until the first frame.
sandboxGrantsBySid: {},
sandboxRootsNoticeBySid: {},
// Nothing has asked this session for its input history yet (issue #2349,
// part 2/2) — absent is "not read", which is not the same as an empty list.
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ export const DialogHost = forwardRef<DialogHostHandle, DialogHostProps>(function
// 没有可问的主语,界面该显示的是「先开一个会话」而不是一个借来的列表。
// `?? null` 与 `sandboxBySid` 同一个语义:缺席 = daemon 还没说,不是空列表。
roots={sid ? (appState.sandboxRootsBySid[sid] ?? null) : null}
grants={sid ? (appState.sandboxGrantsBySid[sid] ?? null) : null}
notice={sid ? (appState.sandboxRootsNoticeBySid[sid] ?? null) : null}
onOp={onSandboxRootsOp}
onDismiss={() => closeDialog("sandboxRoots")}
Expand Down
30 changes: 30 additions & 0 deletions emrg/gui/renderer/src/components/SandboxRootsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,34 @@ describe("SandboxRootsDialog", () => {
await userEvent.click(screen.getByTestId("sandbox-roots-close"));
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it("默认项也画出来,并标明来源;移除按钮发的就是它的 target(issue #2492)", async () => {
// 需求 3 的两半:默认项必须**可见**(否则主人不知道该撤回什么),也必须能**撤回**。
// 两半是同一次渲染加同一次点击,所以钉在这里。
const onOp = vi.fn();
setup({
onOp,
roots: ["/tmp/named"],
grants: [
{ kind: "directory", target: "/tmp/named", source: "session" },
{ kind: "directory", target: "/tmp/default", source: "default" },
],
});
const rows = screen.getAllByTestId("sandbox-roots-row");
expect(rows.map((row) => row.getAttribute("data-root"))).toEqual([
"/tmp/named",
"/tmp/default",
]);
const sources = screen.getAllByTestId("sandbox-roots-source");
expect(sources.map((node) => node.textContent)).toEqual(["本会话命名", "安装默认"]);

await userEvent.click(screen.getAllByTestId("sandbox-roots-remove")[1]);
expect(onOp).toHaveBeenCalledWith("remove", "/tmp/default");
});

it("只有契约表、没有 grants 时仍按路径画(老状态不是空状态)", () => {
setup({ roots: ["/tmp/a"] });
expect(screen.getAllByTestId("sandbox-roots-row")).toHaveLength(1);
expect(screen.queryAllByTestId("sandbox-roots-source")).toHaveLength(0);
});
});
55 changes: 43 additions & 12 deletions emrg/gui/renderer/src/components/SandboxRootsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useI18n } from "../lib/i18n";
import { Dialog } from "./Dialog";
import type { SandboxRootsNotice } from "../lib/daemonBridge";
import type { SandboxGrant, SandboxRootsNotice } from "../lib/daemonBridge";

/**
* SandboxRootsDialog — the GUI entry to a session's host-named extra writable
Expand Down Expand Up @@ -32,6 +32,16 @@ export interface SandboxRootsDialogProps {
sid: string | null;
/** The daemon's list, or null while nobody has asked. */
roots?: string[] | null;
/**
* The whole surface typed, with provenance (issue #2492), or null while nobody
* has asked.
*
* The rows come from here when it is present, and fall back to `roots` when it
* is not: the manifest's entries are grants the host never typed, and a dialog
* that listed only what they typed could not show them the default that is
* already in force — nor offer the one command that withdraws it.
*/
grants?: SandboxGrant[] | null;
/** What the daemon said about the last op for this session, if anything. */
notice?: SandboxRootsNotice | null;
onOp?: (op: "add" | "remove" | "list", path: string) => void;
Expand All @@ -42,6 +52,7 @@ export function SandboxRootsDialog({
open,
sid,
roots = null,
grants = null,
notice = null,
onOp,
onDismiss,
Expand All @@ -63,6 +74,14 @@ export function SandboxRootsDialog({
}, [open, sid]);

const canWrite = Boolean(sid);
// One row shape for both sources: a typed grant when the daemon sent one, and
// the path alone when this client is reading an older state. The *rows* are the
// display, so the two forms converge here rather than in two lists.
const rows: { target: string; source: string | null }[] =
grants === null
? (roots ?? []).map((target) => ({ target, source: null }))
: grants.map((grant) => ({ target: grant.target, source: grant.source }));
const asked = grants !== null || roots !== null;
const submitAdd = (): void => {
const value = path.trim();
// An empty path is not sent: the daemon would refuse it, and a refusal the
Expand Down Expand Up @@ -122,34 +141,46 @@ export function SandboxRootsDialog({
<div className="help-row" data-testid="sandbox-roots-nosession">
<span className="help-hint">{t("sandboxRoots.noSession")}</span>
</div>
) : roots === null ? (
) : !asked ? (
<div className="help-row" data-testid="sandbox-roots-loading">
<span className="help-hint">{t("dlg.loading")}</span>
</div>
) : roots.length === 0 ? (
) : rows.length === 0 ? (
<div className="help-row" data-testid="sandbox-roots-empty">
<span className="help-hint">{t("sandboxRoots.empty")}</span>
</div>
) : (
roots.map((root) => (
rows.map(({ target, source }) => (
<div
className="help-row"
data-testid="sandbox-roots-row"
data-root={root}
key={root}
data-root={target}
data-source={source ?? undefined}
key={target}
style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)" }}
>
<span className="help-hint" title={root} style={{ flex: 1, wordBreak: "break-all" }}>
{root}
<span className="help-hint" title={target} style={{ flex: 1, wordBreak: "break-all" }}>
{target}
{source === null ? null : (
<span
className="help-hint"
data-testid="sandbox-roots-source"
style={{ marginLeft: "var(--sp-2)", color: "var(--text-2)" }}
>
{source === "default"
? t("sandboxRoots.source.default")
: t("sandboxRoots.source.session")}
</span>
)}
</span>
<button
type="button"
className="btn btn-ghost"
data-testid="sandbox-roots-remove"
data-root={root}
title={t("sandboxRoots.removeTitle", { path: root })}
aria-label={t("sandboxRoots.removeTitle", { path: root })}
onClick={() => onOp?.("remove", root)}
data-root={target}
title={t("sandboxRoots.removeTitle", { path: target })}
aria-label={t("sandboxRoots.removeTitle", { path: target })}
onClick={() => onOp?.("remove", target)}
>
{t("sandboxRoots.remove")}
</button>
Expand Down
59 changes: 59 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,65 @@ describe("createDaemonBridge", () => {
expect(bridge.store.get().sandboxRootsNoticeBySid["s1"]).toBeUndefined();
});

it("sandbox_roots 帧里的 grants 带着来源进 store(issue #2492)", () => {
// 契约表(`roots`)与整张可写面(`grants`)是两个问题:前者是主人点名的那几项,
// 后者连安装自带的默认项一起,并且每项都带 `source`。GUI 两者都要——只画 `roots`
// 的话,默认项在 GUI 里既看不见也就无从撤回,正是 D5 删掉的那个部署常量换了名字。
const { emit, bridge } = setup();
emit({
type: "sandbox_roots",
sid: "s1",
data: {
type: "sandbox_roots", session_id: "s1", op: "list",
roots: ["/tmp/named"],
grants: [
{ kind: "directory", target: "/tmp/named", source: "session" },
{ kind: "directory", target: "/tmp/default", source: "default" },
],
},
});
expect(bridge.store.get().sandboxGrantsBySid["s1"]).toEqual([
{ kind: "directory", target: "/tmp/named", source: "session" },
{ kind: "directory", target: "/tmp/default", source: "default" },
]);
expect(bridge.store.get().sandboxRootsBySid["s1"]).toEqual(["/tmp/named"]);
});

it("读不懂的 grant 条目被丢掉,而不是被猜成一项授权", () => {
// 容忍只朝一个方向:读不懂的条目消失,可写面变小。猜一个会描述出这次运行
// 并不具备的边界。
const { emit, bridge } = setup();
emit({
type: "sandbox_roots",
sid: "s1",
data: {
type: "sandbox_roots", session_id: "s1", op: "list",
grants: [{ kind: "directory", target: "/tmp/ok", source: "session" }, {}, "x", null],
},
});
expect(bridge.store.get().sandboxGrantsBySid["s1"]).toEqual([
{ kind: "directory", target: "/tmp/ok", source: "session" },
]);
});

it("resume_result 的 meta.sandbox_grants → 后开的会话也能看到带来源的整张面", () => {
const { emit, bridge } = setup();
emit({
type: "command_result",
sid: "s1",
data: {
type: "resume_result", session_id: "s1",
meta: {
sandbox_roots: ["/tmp/named"],
sandbox_grants: [{ kind: "file", target: "/tmp/named", source: "session" }],
},
},
});
expect(bridge.store.get().sandboxGrantsBySid["s1"]).toEqual([
{ kind: "file", target: "/tmp/named", source: "session" },
]);
});

it("resume_result 的 meta.sandbox_roots → 后开的会话也能看到清单([] 是「确实没有」)", () => {
const { emit, bridge } = setup();
emit({
Expand Down
Loading
Loading