feat(desktop+gateway): remote-gateway file attachments via file.attach

@file: attachments now work when the desktop is connected to a remote
gateway. Previously a referenced file resolved to a client-disk path the
gateway couldn't see, so context_references rejected it with "path is
outside the allowed workspace" and the agent never saw the file.

Adds a file.attach RPC (sibling to the existing image.attach_bytes /
pdf.attach byte-upload pipeline): the desktop uploads the file bytes, the
gateway stages them into <workspace>/.hermes/desktop-attachments/ and
returns a workspace-relative @file: ref that resolves cleanly. Local mode
passes the path directly; a gateway-visible file outside the workspace is
copied in; an in-workspace file is referenced as-is with no copy.

Consolidates the file-sync design from #38615 (LeonSGP43) and the
host-file-staging idea from #33455 (Carry00), rebased onto the
image/PDF remote-media helpers already on main.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
This commit is contained in:
teknium1
2026-06-09 00:03:49 -07:00
committed by Teknium
co-authored by LeonSGP43
parent e687292eb4
commit dbbd1d4d05
6 changed files with 603 additions and 53 deletions
+193 -1
View File
@@ -1992,7 +1992,8 @@ def _current_profile_name() -> str:
# backend reporting less than its required value (or none at all — a pre-GUI
# checkout), surfacing a one-click "update to align" prompt instead of failing
# cryptically downstream. Bump whenever the desktop's backend contract changes.
DESKTOP_BACKEND_CONTRACT = 1
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
DESKTOP_BACKEND_CONTRACT = 2
def _session_info(agent, session: dict | None = None) -> dict:
@@ -5631,6 +5632,197 @@ def _(rid, params: dict) -> dict:
)
_ATTACHMENT_REF_NEEDS_QUOTING_RE = None
def _format_ref_value(value: str) -> str:
"""Quote a context-ref value when it contains whitespace or bracket chars.
Mirrors the desktop ``formatRefValue`` so the staged ``@file:`` ref round-trips
through ``agent.context_references`` cleanly.
"""
import re as _re
global _ATTACHMENT_REF_NEEDS_QUOTING_RE
if _ATTACHMENT_REF_NEEDS_QUOTING_RE is None:
_ATTACHMENT_REF_NEEDS_QUOTING_RE = _re.compile(r"""[\s()\[\]{}<>"'`]""")
if not value or not _ATTACHMENT_REF_NEEDS_QUOTING_RE.search(value):
return value
if "`" not in value:
return f"`{value}`"
if '"' not in value:
return f'"{value}"'
if "'" not in value:
return f"'{value}'"
return value
def _attachment_ref_path(session: dict, target: Path) -> str:
"""Workspace-relative path for an attachment, or the absolute path if outside."""
workspace = Path(_session_cwd(session)).resolve()
try:
rel = target.resolve().relative_to(workspace)
return str(rel).replace(os.sep, "/")
except ValueError:
return str(target.resolve())
def _desktop_attachment_dir(session: dict) -> Path:
root = Path(_session_cwd(session)).resolve() / ".hermes" / "desktop-attachments"
root.mkdir(parents=True, exist_ok=True)
return root
def _sanitize_attachment_name(name: str) -> str:
import re as _re
candidate = Path(str(name or "").strip()).name
candidate = _re.sub(r"[\x00-\x1f]+", "_", candidate)
candidate = candidate.strip().strip(".")
return candidate or "attachment"
def _unique_attachment_path(root: Path, filename: str) -> Path:
candidate = root / filename
if not candidate.exists():
return candidate
stem = Path(filename).stem or "attachment"
suffix = Path(filename).suffix
counter = 2
while True:
next_candidate = root / f"{stem}-{counter}{suffix}"
if not next_candidate.exists():
return next_candidate
counter += 1
def _resolve_gateway_attachment_path(raw: str) -> Path | None:
"""Resolve a raw path token to a gateway-visible file, or None."""
if not raw:
return None
try:
from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
except Exception:
return None
dropped = _detect_file_drop(raw)
if dropped:
return Path(dropped["path"]).resolve()
path_token, _remainder = _split_path_input(raw)
resolved = _resolve_attachment_path(path_token)
return Path(resolved).resolve() if resolved is not None else None
def _decode_attachment_data_url(data_url: str) -> bytes:
"""Decode a ``data:<any-mime>;base64,<b64>`` payload to bytes.
Unlike ``_decode_attach_base64`` (image-mime-specific), this accepts any
media type text/csv, application/pdf, etc. so non-image file uploads
round-trip. Also tolerates a bare base64 string with no data-URL prefix.
"""
import base64 as _base64
import binascii as _binascii
import re as _re
cleaned = (data_url or "").strip()
m = _re.match(r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", cleaned, _re.DOTALL | _re.I)
if m:
cleaned = m.group(1)
cleaned = _re.sub(r"\s+", "", cleaned)
try:
return _base64.b64decode(cleaned, validate=True)
except (ValueError, _binascii.Error) as exc:
raise ValueError("invalid data_url payload") from exc
def _stage_session_file_attachment(
session: dict,
*,
raw_path: str,
data_url: str,
name: str,
) -> tuple[Path, bool]:
"""Make a desktop file attachment available to the remote gateway agent.
Three cases:
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.
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/``.
Returns ``(stored_path, uploaded)``.
"""
workspace = Path(_session_cwd(session)).resolve()
resolved = _resolve_gateway_attachment_path(raw_path)
if resolved is not None:
try:
resolved.relative_to(workspace)
return resolved, False
except ValueError:
payload = resolved.read_bytes()
filename = resolved.name
else:
if not data_url:
raise ValueError("file not found on gateway and no data_url provided")
payload = _decode_attachment_data_url(data_url)
filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
upload_dir = _desktop_attachment_dir(session)
target = _unique_attachment_path(upload_dir, _sanitize_attachment_name(filename))
target.write_bytes(payload)
return target.resolve(), True
@method("file.attach")
def _(rid, params: dict) -> dict:
"""Stage a non-image file attachment into the session workspace.
The image/PDF path renders to vision tiles; this one keeps the file as a
readable artifact and returns a workspace-relative ``@file:`` ref so the
agent's file tools (and ``agent.context_references``) can read it. Solves the
remote-gateway case where the desktop passes a path that only exists on the
CLIENT's disk: the client uploads ``data_url`` bytes and we materialize the
file on the gateway.
Params:
session_id (str, required)
path (str): client/host path of the file (used for naming + local-mode
gateway-visible resolution).
data_url (str): ``data:<mime>;base64,<b64>`` upload of the file bytes,
required when the path isn't visible to the gateway.
name (str, optional): preferred filename.
"""
session, err = _sess(params, rid)
if err:
return err
raw = str(params.get("path", "") or "").strip()
data_url = str(params.get("data_url", "") or "").strip()
name = str(params.get("name", "") or "").strip()
if not raw and not data_url:
return _err(rid, 4015, "path or data_url required")
try:
stored_path, uploaded = _stage_session_file_attachment(
session, raw_path=raw, data_url=data_url, name=name
)
ref_path = _attachment_ref_path(session, stored_path)
return _ok(
rid,
{
"attached": True,
"name": stored_path.name,
"path": str(stored_path),
"ref_path": ref_path,
"ref_text": f"@file:{_format_ref_value(ref_path)}",
"uploaded": uploaded,
},
)
except Exception as e:
return _err(rid, 5028, str(e))
@method("image.detach")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)