fix(docker): read attached binary files in backend (#76577)

This commit is contained in:
webtecnica 2026-08-02 02:40:13 -03:00 committed by Teknium
parent cbb8cee47d
commit 464e7e4e5f
11 changed files with 237 additions and 22 deletions

View File

@ -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."
)

View File

@ -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))
}

View File

@ -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<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness onReady={h => (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)

View File

@ -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<ComposerAttachment> {
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) {

View File

@ -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<UserEditComposerProps> = ({ 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)

View File

@ -61,6 +61,7 @@ export type GatewayEventPayload = {
running?: boolean
cwd?: string
branch?: string
terminal_backend?: string
credential_warning?: string
install_warning?: string
personality?: string

View File

@ -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 | string>(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<NewChatWorkspaceTarget>(undefined)
export const $newChatWorkspaceTargetGeneration = atom(0)
export const $currentBranch = atom('')
@ -648,6 +654,8 @@ export const setCurrentCwd = (next: Updater<string>) => {
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
}
export const setTerminalBackend = (next: Updater<string>) => updateAtom($terminalBackend, next)
export const setCurrentCwdTransient = (next: Updater<string>) => updateAtom($currentCwd, next)
// Released-ownership marker: the live path belongs to no conversation. `null`

View File

@ -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

View File

@ -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)

View File

@ -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"),
]

View File

@ -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)``.
"""