fix(docker): read attached binary files in backend (#76577)
This commit is contained in:
parent
cbb8cee47d
commit
464e7e4e5f
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export type GatewayEventPayload = {
|
|||
running?: boolean
|
||||
cwd?: string
|
||||
branch?: string
|
||||
terminal_backend?: string
|
||||
credential_warning?: string
|
||||
install_warning?: string
|
||||
personality?: string
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)``.
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue