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:
committed by
Teknium
co-authored by
LeonSGP43
parent
e687292eb4
commit
dbbd1d4d05
+193
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user