diff --git a/agent/context_references.py b/agent/context_references.py index 6f428bf87badd..a44b90d90704e 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -555,6 +555,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: return files[:limit] +def _agent_visible_path(path: Path) -> str: + """Map a host path to the path the agent's tools can read in the active backend. + + Under a container backend (docker) the gateway host path dangles inside the + sandbox — the container has its own filesystem and the host path is not + mounted. Files staged into an auto-mounted cache dir (``images/``, + ``attachments/``, ...) are translated to their in-container path via the + existing ``tools.credential_files`` machinery (#76577). Falls back to the + host path when the backend is local or translation is unavailable. + """ + try: + # Desktop/in-process gateways may not have bridged ``terminal.*`` + # config into ``TERMINAL_ENV`` at startup; run the idempotent bridge so + # the credential_files translation gate sees the active backend. + from tools.terminal_tool import _ensure_terminal_env_bridged + + _ensure_terminal_env_bridged() + from tools.credential_files import to_agent_visible_cache_path + + return to_agent_visible_cache_path(str(path)) + except Exception: + return str(path) + + def _binary_reference_block(ref: ContextReference, path: Path) -> str: mime, _ = mimetypes.guess_type(path.name) mime = mime or "application/octet-stream" @@ -564,7 +588,7 @@ def _binary_reference_block(ref: ContextReference, path: Path) -> str: size = "unknown size" return ( f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. " - f"It is available on disk at `{path}`. Use your tools to work with it " + f"It is available on disk at `{_agent_visible_path(path)}`. Use your tools to work with it " f"(read or convert it, extract its text, or view/render it as needed); " f"do not tell the user the file type is unsupported." ) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 7ae6943cbc974..b0d6a0f57de24 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -59,6 +59,7 @@ import { setCurrentUsage, setMessages, setSessions, + setTerminalBackend, setTurnStartedAt, setWorkspaceCwdOwner, setYoloActive @@ -464,6 +465,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setCurrentBranch(payload.branch) } + if (typeof payload?.terminal_backend === 'string') { + setTerminalBackend(payload.terminal_backend) + } + if (typeof payload?.personality === 'string') { setCurrentPersonality(normalizePersonalityValue(payload.personality)) } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index fc810946e3bb4..c8e2ef1d5e060 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -16,6 +16,7 @@ import { $currentUsage, $messages, $sessions, + $terminalBackend, $turnStartedAt, setCurrentUsage, setMessages, @@ -2535,6 +2536,56 @@ describe('usePromptActions file attachment sync', () => { expect(uploaded.path).toBe('/root/tmp/photo.jpg') }) + it('uploads file bytes when the terminal backend is a container (docker)', async () => { + // Container backends have their own filesystem: the host drop path would + // dangle inside the sandbox, so the bytes must cross via file.attach's + // data_url pipeline and be staged into a bind-mounted cache dir (#76577). + $connection.set({ mode: 'local' } as never) + $terminalBackend.set('docker') + const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'file.attach') { + return { + attached: true, + path: '/root/.hermes/attachments/report.txt', + ref_text: '@file:/root/.hermes/attachments/report.txt', + uploaded: true + } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + ) + + expect(await handle!.submitText('summarize', { attachments: [fileAttachment()] })).toBe(true) + expect(readFileDataUrl).toHaveBeenCalledWith('/Users/alice/Downloads/report.txt') + expect(calls[0]).toEqual({ + method: 'file.attach', + params: { + data_url: 'data:text/plain;base64,aGVsbG8=', + name: 'report.txt', + path: '/Users/alice/Downloads/report.txt', + session_id: RUNTIME_SESSION_ID + } + }) + + // Don't leak the container-backend state into later tests. + $terminalBackend.set('') + }) + it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => { // Submit-layer contract: only attachments that carry a `path` are upload // candidates. A path-less ref (an @-mention/context ref or pasted text) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 478c868b0bacb..9811c678fa0a0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -27,6 +27,7 @@ import { $connection, $currentCwd, $messages, + $terminalBackend, setActiveSessionId, setAwaitingResponse, setBusy, @@ -80,11 +81,29 @@ interface HandoffResult { const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/ const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/ +// Terminal backends whose execution environment has its own filesystem +// (docker/ssh/singularity/modal/...) cannot see the desktop's host paths — +// they must be crossed as bytes, like remote attachments. Mirrors the +// container_backend set in tools/terminal_tool.py::_get_env_config. +const CONTAINER_TERMINAL_BACKENDS = new Set([ + 'docker', + 'ssh', + 'singularity', + 'modal', + 'daytona', + 'vercel_sandbox' +]) + // `mode: local` means the gateway was launched locally, not necessarily that // Electron and the gateway share a filesystem. Windows Desktop can front a // WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the -// boundary as bytes just like a remote attachment. -function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean { +// boundary as bytes just like a remote attachment. Container terminal backends +// (docker, ssh, ...) always need bytes: the sandbox has its own filesystem and +// the host path would dangle inside it (#76577). +function attachmentPathNeedsUpload(path: string, backendCwd?: null | string, terminalBackend?: string): boolean { + if (CONTAINER_TERMINAL_BACKENDS.has((terminalBackend || '').trim().toLowerCase())) { + return true + } return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '') } @@ -107,12 +126,13 @@ export async function uploadComposerAttachment( storedSessionId?: null | string /** Called when the attach recovered onto a fresh live id. */ onSessionRecovered?: (sessionId: string) => void + terminalBackend?: string } ): Promise { - const { backendCwd, remote, requestGateway, storedSessionId, onSessionRecovered } = opts + const { backendCwd, remote, requestGateway, storedSessionId, onSessionRecovered, terminalBackend } = opts const path = attachment.path ?? '' const label = attachment.label || pathLabel(path) - const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd) + const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd, terminalBackend) // Read bytes/paths ONCE, outside the retry. Only the session-scoped RPC is // replayed on recovery — re-reading a multi-MB file to retry a dead session @@ -364,7 +384,8 @@ export function usePromptActions({ requestGateway, sessionId: liveSessionId, storedSessionId, - onSessionRecovered + onSessionRecovered, + terminalBackend: $terminalBackend.get() }) // Update-only: never resurrect a chip the user removed mid-upload. @@ -409,7 +430,8 @@ export function usePromptActions({ backendCwd: $currentCwd.get(), remote, requestGateway, - sessionId + sessionId, + terminalBackend: $terminalBackend.get() }) ) } catch (err) { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index dbb90459ccee8..c7af4d56e050a 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -74,7 +74,7 @@ import { Loader2Icon } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' -import { $connection } from '@/store/session' +import { $connection, $terminalBackend } from '@/store/session' import { notifyThreadEditClose } from '@/store/thread-scroll' interface UserEditComposerProps { @@ -427,7 +427,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess try { const uploaded = await uploadComposerAttachment( { detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path }, - { backendCwd: cwd, remote, requestGateway, sessionId } + { backendCwd: cwd, remote, requestGateway, sessionId, terminalBackend: $terminalBackend.get() } ) const ref = attachmentDisplayText(uploaded) diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 1e0bd1b0a4548..f58cd9d16f27e 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -61,6 +61,7 @@ export type GatewayEventPayload = { running?: boolean cwd?: string branch?: string + terminal_backend?: string credential_warning?: string install_warning?: string personality?: string diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 24a0b8beff619..953745f8112d6 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -527,6 +527,12 @@ export const $currentCwd = atom(getRememberedWorkspaceCwd()) // would collapse the workspace panes and drop file-tree state on every switch, // so the path stays put and is simply marked as not-yet-owned. export const $workspaceCwdOwner = atom(null) + +// Terminal execution backend (local | docker | ssh | ...) mirrored from the +// gateway (session.info). Drives attachment upload decisions: container +// backends have their own filesystem, so a dropped host path must be uploaded +// as bytes and staged into a bind-mounted cache dir (#76577). +export const $terminalBackend = atom('') export const $newChatWorkspaceTarget = atom(undefined) export const $newChatWorkspaceTargetGeneration = atom(0) export const $currentBranch = atom('') @@ -648,6 +654,8 @@ export const setCurrentCwd = (next: Updater) => { persistString(workspaceCwdKey(), $currentCwd.get().trim() || null) } +export const setTerminalBackend = (next: Updater) => updateAtom($terminalBackend, next) + export const setCurrentCwdTransient = (next: Updater) => updateAtom($currentCwd, next) // Released-ownership marker: the live path belongs to no conversation. `null` diff --git a/tests/agent/test_context_references.py b/tests/agent/test_context_references.py index aac7c251849b2..b58623584b8ff 100644 --- a/tests/agent/test_context_references.py +++ b/tests/agent/test_context_references.py @@ -121,6 +121,60 @@ def test_missing_file_becomes_warning(sample_repo: Path): assert "not found" in result.message.lower() +def test_binary_reference_block_maps_host_attachment_to_container_path(tmp_path: Path, monkeypatch): + """Docker backend: a staged binary attachment's host path is rendered as the + bind-mounted in-container path so the agent's tools can read it. + + Regression test for #76577 — the container has its own filesystem, so the + gateway host path would dangle inside the sandbox. + """ + from agent.context_references import preprocess_context_references + + hermes_home = tmp_path / ".hermes" + attachments = hermes_home / "attachments" + attachments.mkdir(parents=True) + payload = attachments / "archive.zip" + payload.write_bytes(b"PK\x03\x04binary-zip-bytes") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TERMINAL_ENV", "docker") + + result = preprocess_context_references( + f"Read the attachment @file:{payload}", + cwd=tmp_path, + context_length=100_000, + ) + + assert result.expanded + # Default container base for the docker backend is /root/.hermes. + assert "/root/.hermes/attachments/archive.zip" in result.message + assert "binary file, not inlined" in result.message + + +def test_binary_reference_block_keeps_host_path_on_local_backend(tmp_path: Path, monkeypatch): + """Local backend: no translation — the agent's tools run on the host.""" + from agent.context_references import preprocess_context_references + + hermes_home = tmp_path / ".hermes" + attachments = hermes_home / "attachments" + attachments.mkdir(parents=True) + payload = attachments / "archive.zip" + payload.write_bytes(b"PK\x03\x04binary-zip-bytes") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TERMINAL_ENV", "local") + + result = preprocess_context_references( + f"Read the attachment @file:{payload}", + cwd=tmp_path, + context_length=100_000, + ) + + assert result.expanded + assert str(payload) in result.message + assert "/root/.hermes/attachments/" not in result.message + + diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 27aa067236a88..c3d8e5d313510 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -8048,15 +8048,20 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch): def test_file_attach_uploads_remote_file_into_session_workspace(monkeypatch, tmp_path): - """Remote case: client path doesn't exist on gateway → decode data_url bytes.""" + """Remote case: client path doesn't exist on gateway → decode data_url bytes. + + Staged into the session home's ``attachments/`` dir (bind-mounted into + container backends) rather than the workspace (#76577). + """ workspace = tmp_path / "workspace" workspace.mkdir() + home = tmp_path / "home" fake_cli = types.ModuleType("cli") fake_cli._detect_file_drop = lambda raw: None fake_cli._split_path_input = lambda raw: (raw, "") fake_cli._resolve_attachment_path = lambda raw: None - server._sessions["sid"] = _session(cwd=str(workspace)) + server._sessions["sid"] = _session(cwd=str(workspace), profile_home=str(home)) monkeypatch.setitem(sys.modules, "cli", fake_cli) try: @@ -8073,11 +8078,11 @@ def test_file_attach_uploads_remote_file_into_session_workspace(monkeypatch, tmp } ) - stored = workspace / ".hermes" / "desktop-attachments" / "report.txt" + stored = home / "attachments" / "report.txt" assert resp["result"]["attached"] is True assert resp["result"]["uploaded"] is True assert resp["result"]["path"] == str(stored) - assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/report.txt" + assert resp["result"]["ref_text"] == f"@file:{stored}" assert stored.read_text(encoding="utf-8") == "hello world" finally: server._sessions.pop("sid", None) @@ -8087,6 +8092,7 @@ def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, """Local case: gateway can see the file but it's outside the workspace → copy in.""" workspace = tmp_path / "workspace" workspace.mkdir() + home = tmp_path / "home" source = tmp_path / "outside.txt" source.write_text("outside workspace", encoding="utf-8") fake_cli = types.ModuleType("cli") @@ -8094,7 +8100,7 @@ def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, fake_cli._split_path_input = lambda raw: (raw, "") fake_cli._resolve_attachment_path = lambda raw: source - server._sessions["sid"] = _session(cwd=str(workspace)) + server._sessions["sid"] = _session(cwd=str(workspace), profile_home=str(home)) monkeypatch.setitem(sys.modules, "cli", fake_cli) try: @@ -8106,10 +8112,10 @@ def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, } ) - stored = workspace / ".hermes" / "desktop-attachments" / "outside.txt" + stored = home / "attachments" / "outside.txt" assert resp["result"]["attached"] is True assert resp["result"]["uploaded"] is True - assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/outside.txt" + assert resp["result"]["ref_text"] == f"@file:{stored}" assert stored.read_text(encoding="utf-8") == "outside workspace" finally: server._sessions.pop("sid", None) @@ -8141,8 +8147,10 @@ def test_file_attach_uses_in_workspace_file_without_copying(monkeypatch, tmp_pat assert resp["result"]["attached"] is True assert resp["result"]["uploaded"] is False assert resp["result"]["ref_text"] == "@file:data/exam.csv" - # No copy: nothing staged under desktop-attachments. + # No copy: nothing staged under desktop-attachments or the home + # attachments dir. assert not (workspace / ".hermes" / "desktop-attachments").exists() + assert not (tmp_path / "home" / "attachments").exists() finally: server._sessions.pop("sid", None) @@ -8183,7 +8191,7 @@ def test_file_attach_quotes_ref_with_spaces(monkeypatch, tmp_path): fake_cli._split_path_input = lambda raw: (raw, "") fake_cli._resolve_attachment_path = lambda raw: None - server._sessions["sid"] = _session(cwd=str(workspace)) + server._sessions["sid"] = _session(cwd=str(workspace), profile_home=str(tmp_path / "home")) monkeypatch.setitem(sys.modules, "cli", fake_cli) try: @@ -8199,8 +8207,10 @@ def test_file_attach_quotes_ref_with_spaces(monkeypatch, tmp_path): } ) + stored = tmp_path / "home" / "attachments" / "my exam schedule.csv" assert resp["result"]["attached"] is True - assert resp["result"]["ref_text"] == "@file:`.hermes/desktop-attachments/my exam schedule.csv`" + assert resp["result"]["ref_text"] == f"@file:`{stored}`" + assert stored.read_text(encoding="utf-8") == "a,b\n" finally: server._sessions.pop("sid", None) diff --git a/tools/credential_files.py b/tools/credential_files.py index e57af0bbb4679..a0d5edb889332 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -400,6 +400,11 @@ _CACHE_DIRS: list[tuple[str, str]] = [ # reach uploads inside sandbox containers (#69575). No legacy alias exists, # so both tuple slots are ``images``. ("images", "images"), + # Desktop non-image file attachments (tui_gateway ``file.attach`` staging) + # land in the flat top-level ``attachments/`` dir. Mount it so the agent's + # file tools can read dropped binaries (zip/pdf/...) from inside sandbox + # containers instead of dangling host paths (#76577). + ("attachments", "attachments"), ] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0b776f03547db..1a6f84d04aac0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2527,6 +2527,27 @@ def _is_local_terminal_backend() -> bool: return not backend or backend == "local" +def _effective_terminal_backend() -> str: + """Active terminal backend name (``local``, ``docker``, ``ssh``, ...). + + ``TERMINAL_ENV`` is authoritative when set (launchers bridge + ``terminal.backend`` into env at startup). Desktop/TUI in-process gateways + skip that bridge, so fall back to the ``terminal.backend`` config key — + the same rule ``_terminal_task_cwd`` uses. + """ + backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower() + if not backend or backend == "local": + try: + terminal_cfg = _load_cfg().get("terminal", {}) + if isinstance(terminal_cfg, dict): + cfg_backend = str(terminal_cfg.get("backend") or "").strip().lower() + if cfg_backend and cfg_backend != "local": + backend = cfg_backend + except Exception: + pass + return backend or "local" + + def _display_session_cwd(session: dict | None) -> str: """Session cwd for display/probe surfaces, healed past deleted worktrees. @@ -5171,6 +5192,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd), + "terminal_backend": _effective_terminal_backend(), "personality": str(personality or ""), "running": bool((session or {}).get("running")), "title": _session_live_title(session or {}, session_key) if session_key else "", @@ -10492,7 +10514,19 @@ def _attachment_ref_path(session: dict, target: Path) -> str: def _desktop_attachment_dir(session: dict) -> Path: - root = Path(_session_cwd(session)).resolve() / ".hermes" / "desktop-attachments" + """Resolve the file-attachment staging dir against the session's effective home. + + Anchored on the session profile's ``attachments/`` dir (same rule as + ``_session_images_dir``): ``file.attach`` runs BEFORE ``prompt.submit`` + installs the session's profile HERMES_HOME override, while the docker/ssh + sandbox mounts are resolved against the *session profile's* home at run + time — so the staged file must land where the bind mount points, or the + container can never see it (#76577). ``attachments/`` is registered in + ``tools.credential_files._CACHE_DIRS`` and auto-mounted into containers. + """ + profile_home = session.get("profile_home") + base = Path(profile_home) if profile_home else _hermes_home + root = base / "attachments" root.mkdir(parents=True, exist_ok=True) return root @@ -10572,10 +10606,11 @@ def _stage_session_file_attachment( 1. The path resolves to a file already INSIDE the session workspace — use it as-is (no copy, ``uploaded=False``). 2. The path resolves to a gateway-visible file OUTSIDE the workspace — copy - it into ``.hermes/desktop-attachments/`` so the ``@file:`` ref resolves. + it into the session home's ``attachments/`` dir (bind-mounted into + container backends) so the ``@file:`` ref resolves inside the sandbox. 3. The path doesn't exist on the gateway (the common remote case: it's a path on the CLIENT's disk) — decode the uploaded ``data_url`` bytes and - write them into ``.hermes/desktop-attachments/``. + write them into the session home's ``attachments/`` dir. Returns ``(stored_path, uploaded)``. """