Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d14cdc1151 | |||
| c80dbf9737 | |||
| 5331a0f110 | |||
| b995521e22 | |||
| 5610017f50 | |||
| 61e7d68d71 |
@@ -515,7 +515,13 @@ def apply_resolved_profile(
|
||||
files_dir = profile_dir / "files"
|
||||
mounts_dir = profile_dir / "mounts"
|
||||
|
||||
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
|
||||
def write_canonical_file(
|
||||
root: Path,
|
||||
relative_path: str,
|
||||
content: str,
|
||||
*,
|
||||
preserve_inode: bool = False,
|
||||
) -> Path | None:
|
||||
path = root / relative_path
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
@@ -523,6 +529,12 @@ def apply_resolved_profile(
|
||||
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
||||
return None
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if preserve_inode and path.is_file():
|
||||
# A file bind mount follows its inode, not its directory entry.
|
||||
# Replacing this path would leave a running container attached to
|
||||
# the old inode, so overwrite the existing file in place.
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
||||
) as temporary_file:
|
||||
@@ -534,7 +546,9 @@ def apply_resolved_profile(
|
||||
# Top-level profile files are individual bind mounts under the working
|
||||
# directory. They therefore cannot mask the workspace directory itself.
|
||||
for file_path, content in resolved.files.items():
|
||||
canonical_file = write_canonical_file(files_dir, file_path, content)
|
||||
canonical_file = write_canonical_file(
|
||||
files_dir, file_path, content, preserve_inode=True
|
||||
)
|
||||
if canonical_file is None:
|
||||
continue
|
||||
volume_mounts.append(
|
||||
@@ -555,7 +569,7 @@ def apply_resolved_profile(
|
||||
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
|
||||
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
|
||||
for file_path, content in mount.files.items():
|
||||
write_canonical_file(mount_dir, file_path, content)
|
||||
write_canonical_file(mount_dir, file_path, content, preserve_inode=True)
|
||||
|
||||
volume_mounts.append(
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ class TestMergeFunctions:
|
||||
|
||||
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||
"""Test that env var overrides are tracked."""
|
||||
overrides = {}
|
||||
overrides: dict[str, str] = {}
|
||||
_merge_env_vars(
|
||||
{"A": "1"},
|
||||
{"A": "2"},
|
||||
@@ -93,7 +93,7 @@ class TestMergeFunctions:
|
||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||
from src.services.config.config_profile_resolver import ResolvedMount
|
||||
|
||||
overrides = {}
|
||||
overrides: dict[str, str] = {}
|
||||
result = _merge_mounts(
|
||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||
@@ -562,6 +562,59 @@ class TestApplyResolvedProfile:
|
||||
]
|
||||
assert canonical_file.read_text() == "setting = true"
|
||||
|
||||
def test_top_level_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
|
||||
"""An individually bind-mounted file must update in place."""
|
||||
profile_id = uuid.uuid4()
|
||||
instance_root = tmp_path / "instances"
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=profile_id,
|
||||
profile_name="test",
|
||||
files={"settings.toml": "value = 1"},
|
||||
)
|
||||
|
||||
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||
canonical_file = (
|
||||
instance_root / "config-profiles" / str(profile_id) / "files" / "settings.toml"
|
||||
)
|
||||
original_inode = canonical_file.stat().st_ino
|
||||
|
||||
resolved.files["settings.toml"] = "value = 2"
|
||||
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||
|
||||
assert canonical_file.stat().st_ino == original_inode
|
||||
assert canonical_file.read_text() == "value = 2"
|
||||
|
||||
def test_mounted_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
|
||||
"""A file inside a profile directory mount must update in place."""
|
||||
profile_id = uuid.uuid4()
|
||||
instance_root = tmp_path / "instances"
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=profile_id,
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"/etc/tool": ResolvedMount(
|
||||
target="/etc/tool", mode="rw", files={"settings.toml": "value = 1"}
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||
canonical_file = (
|
||||
instance_root
|
||||
/ "config-profiles"
|
||||
/ str(profile_id)
|
||||
/ "mounts"
|
||||
/ "etc_tool"
|
||||
/ "settings.toml"
|
||||
)
|
||||
original_inode = canonical_file.stat().st_ino
|
||||
|
||||
resolved.mounts["/etc/tool"].files["settings.toml"] = "value = 2"
|
||||
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||
|
||||
assert canonical_file.stat().st_ino == original_inode
|
||||
assert canonical_file.read_text() == "value = 2"
|
||||
|
||||
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||
"""Different instance paths resolve a profile to one canonical source."""
|
||||
profile_id = uuid.uuid4()
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getTerminalScrollbackLimit,
|
||||
isCurrentWebSocket,
|
||||
shouldRetryWebSocketClose,
|
||||
shouldCopyTerminalSelection,
|
||||
} from "./terminal.tsx";
|
||||
|
||||
describe("getTerminalScrollbackLimit", () => {
|
||||
@@ -30,3 +31,29 @@ describe("getTerminalScrollbackLimit", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCopyTerminalSelection", () => {
|
||||
it("copies a selected terminal region with Ctrl+C or Cmd+C", () => {
|
||||
expect(
|
||||
shouldCopyTerminalSelection(
|
||||
{ ctrlKey: true, metaKey: false, key: "c" },
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldCopyTerminalSelection(
|
||||
{ ctrlKey: false, metaKey: true, key: "C" },
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Ctrl+C as a terminal interrupt without a selection", () => {
|
||||
expect(
|
||||
shouldCopyTerminalSelection(
|
||||
{ ctrlKey: true, metaKey: false, key: "c" },
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,17 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
|
||||
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
||||
}
|
||||
|
||||
export function shouldCopyTerminalSelection(
|
||||
event: Pick<KeyboardEvent, "ctrlKey" | "metaKey" | "key">,
|
||||
hasSelection: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
hasSelection &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === "c"
|
||||
);
|
||||
}
|
||||
|
||||
function matchesByteSequence(
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
@@ -462,8 +473,36 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
term.paste(text);
|
||||
};
|
||||
|
||||
const handleBrowserCopy = (event: ClipboardEvent) => {
|
||||
const selection = term.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.clipboardData?.setData("text/plain", selection);
|
||||
};
|
||||
const copySelection = () => {
|
||||
const selection = term.getSelection();
|
||||
if (!selection) return;
|
||||
const clipboard = navigator.clipboard;
|
||||
if (clipboard) {
|
||||
void clipboard.writeText(selection).catch(() => {
|
||||
document.execCommand("copy");
|
||||
});
|
||||
} else {
|
||||
document.execCommand("copy");
|
||||
}
|
||||
};
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
copySelection();
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleBrowserPaste = (event: ClipboardEvent) => {
|
||||
if (!bracketedPasteEnabledRef.current) return;
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
@@ -473,6 +512,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
event.stopImmediatePropagation();
|
||||
pasteTextRef.current(text);
|
||||
};
|
||||
container.addEventListener("copy", handleBrowserCopy, true);
|
||||
container.addEventListener("paste", handleBrowserPaste, true);
|
||||
|
||||
// Mobile touch scroll.
|
||||
@@ -720,6 +760,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
handleVisibilityChange,
|
||||
);
|
||||
if (touchCleanup) touchCleanup();
|
||||
container.removeEventListener("copy", handleBrowserCopy, true);
|
||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||
pasteTextRef.current = () => {};
|
||||
bracketedPasteEnabledRef.current = false;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Fix Web Terminal Clipboard
|
||||
|
||||
## Problem
|
||||
|
||||
Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode.
|
||||
|
||||
## Required behavior
|
||||
|
||||
- Copying a selected terminal region with standard browser shortcuts (`Ctrl/Cmd+C`) copies text to the system clipboard without sending an interrupt to the terminal.
|
||||
- Copy requests expose the xterm selection as plain text.
|
||||
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
|
||||
- Normal terminal interrupts still work when there is no active selection.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Web Terminal Clipboard Tasks
|
||||
|
||||
- [x] Add selected-text copy interception without suppressing unselected terminal interrupts.
|
||||
- [x] Normalize browser paste handling through the existing paste transport.
|
||||
- [x] Add focused frontend coverage for copy shortcut decisions.
|
||||
- [x] Run frontend tests, typecheck/build, lint, and diagnostics.
|
||||
Reference in New Issue
Block a user