Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ HERMES_GATEWAY_URL=
FCM_CREDENTIALS=
FCM_PROJECT_ID=

# Optional calendar adapter. The command must be a JSON array, never a shell
# command. Example: ["python","/integration/google_api.py"]
# Optional Google Calendar adapter. Prefer a read-only mount of an authorized
# user token created by Hermes' google-workspace setup. The legacy command must
# be a JSON array, never a shell command, and is used only when credentials are
# not configured. Example: ["python","/integration/google_api.py"]
GOOGLE_CALENDAR_CREDENTIALS=
GOOGLE_CALENDAR_COMMAND=
GOOGLE_CALENDAR_HOME_ID=
GOOGLE_CALENDAR_HOME_NAME=Home
Expand Down
24 changes: 24 additions & 0 deletions integrations/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ Optional settings include `HERMES_DASHBOARD_HOST`,
`HERMES_CONTROL_PLANE_HOST`, `HERMES_SSH_USER`, and `HERMES_SSH_KEY`.
Home Assistant variables are optional.

## Google Calendar

Dante can reuse a Calendar-only authorized-user token created through Hermes'
built-in `google-workspace` setup without exposing it to clients or the model.
Do not mount a broader Gmail/Drive/Workspace token. Copy the Calendar-only
`google_token.json` to an operator-owned secrets directory, keep it mode
`0600`, and mount the copy read-only into the Dante container as shown in
`ops/compose.integrations.example.yml`. Then configure:

```text
GOOGLE_CALENDAR_CREDENTIALS=/run/secrets/dante/google-calendar-token.json
GOOGLE_CALENDAR_HOME_ID=<household-calendar-id>
GOOGLE_CALENDAR_HOME_NAME=Home
```

The token should contain only the Google Calendar scope. Dante refreshes
short-lived access credentials in memory and never rewrites the mounted token.
When the mounted token changes after reauthorization, Dante reloads it on the
next request. Only the configured household calendar ID is used for reads and
mutations. The older
`GOOGLE_CALENDAR_COMMAND` transport remains available for existing installs,
but Hermes' current `google_api.py` does not implement event updates; use the
native credentials transport for the full list/create/update/delete contract.

Host SSH is opt-in. `configure-primary.py` preserves the current terminal
backend unless `HERMES_ENABLE_HOST_SSH=true` is set.

Expand Down
7 changes: 7 additions & 0 deletions ops/compose.integrations.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
FCM_CREDENTIALS: /run/secrets/dante/firebase-service-account.json
FIT_IMAP_PASS_FILE: /run/secrets/dante/fit-imap-password
FIT_IMPORT_POLICY_FILE: /run/secrets/dante/fit-import-policy.json
GOOGLE_CALENDAR_CREDENTIALS: /run/secrets/dante/google-calendar-token.json
TRENING_DIR: /data/training
volumes:
- type: bind
Expand All @@ -25,6 +26,12 @@ services:
source: /path/to/private/fit-import-policy.json
target: /run/secrets/dante/fit-import-policy.json
read_only: true
# Copy a Calendar-only authorized-user token produced by Hermes setup.
# Dante refreshes access credentials in memory, so this mount stays read-only.
- type: bind
source: /path/to/private/google-calendar-token.json
target: /run/secrets/dante/google-calendar-token.json
read_only: true
- type: bind
source: /path/to/read-only/training-data
target: /data/training
Expand Down
199 changes: 194 additions & 5 deletions server/dante/calendar_adapter.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"""Thin, token-free adapter to Hermes' existing google-workspace CLI."""
"""Google Calendar adapter with native OAuth and legacy Hermes CLI transports."""
from __future__ import annotations

import asyncio
import json
import re
import shlex
import threading
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

from . import db, i18n, profile_directory
from .config import (GOOGLE_CALENDAR_COMMAND, GOOGLE_CALENDAR_HOME_ID,
from .config import (GOOGLE_CALENDAR_COMMAND, GOOGLE_CALENDAR_CREDENTIALS,
GOOGLE_CALENDAR_HOME_ID,
GOOGLE_CALENDAR_HOME_NAME, GOOGLE_CALENDAR_TIMEOUT,
DANTE_DEFAULT_TIMEZONE)

Expand All @@ -20,6 +23,8 @@
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_cache: dict[tuple[str, int, str], tuple[float, list[dict]]] = {}
CACHE_SECONDS = 120
_credentials_lock = threading.Lock()
_credentials_cache: tuple[str, tuple[int, int], Any] | None = None


class CalendarError(RuntimeError):
Expand All @@ -43,7 +48,8 @@ class CalendarValidationError(CalendarError):


def configured() -> bool:
return bool(GOOGLE_CALENDAR_COMMAND.strip() and GOOGLE_CALENDAR_HOME_ID.strip())
transport = GOOGLE_CALENDAR_COMMAND.strip() or GOOGLE_CALENDAR_CREDENTIALS.strip()
return bool(transport and GOOGLE_CALENDAR_HOME_ID.strip())


def _command_prefix() -> list[str]:
Expand Down Expand Up @@ -84,6 +90,19 @@ def status(profile: str) -> dict:


async def _execute(args: list[str]) -> Any:
if GOOGLE_CALENDAR_CREDENTIALS.strip():
try:
return await asyncio.wait_for(
asyncio.to_thread(_execute_google, args),
timeout=GOOGLE_CALENDAR_TIMEOUT,
)
except asyncio.TimeoutError as exc:
raise CalendarError(i18n.translate("errors.calendar_timeout")) from exc
except CalendarError:
raise
except Exception as exc:
raise _google_error(exc) from exc

command = [*_command_prefix(), *args]
try:
process = await asyncio.create_subprocess_exec(
Expand All @@ -105,8 +124,17 @@ async def _execute(args: list[str]) -> Any:
if process.returncode != 0:
safe_error = stderr.decode("utf-8", "replace").lower()
if any(marker in safe_error for marker in (
"invalid_grant", "not authenticated", "run the setup script", "reauth",
"token has been expired", "token has been revoked",
"invalid_grant",
"not authenticated",
"not_authenticated",
"refresh_failed",
"run the setup script",
"token is invalid",
"token has been expired",
"token has been revoked",
"insufficient authentication scopes",
"insufficient permission",
"reauth",
)):
raise CalendarReauthRequired(i18n.translate("errors.calendar_reauth_required"))
raise CalendarError(i18n.translate("errors.calendar_no_data"))
Expand All @@ -116,6 +144,167 @@ async def _execute(args: list[str]) -> Any:
raise CalendarError(i18n.translate("errors.calendar_invalid_response")) from exc


def _google_error(exc: Exception) -> CalendarError:
"""Map provider failures without exposing OAuth or API response details."""

safe_type = type(exc).__name__.lower()
safe_message = str(exc).lower()
response_status = getattr(getattr(exc, "resp", None), "status", None)
reauth_markers = (
"invalid_grant",
"insufficient authentication scopes",
"insufficient permission",
"token has been expired",
"token has been revoked",
"reauth",
)
if (
safe_type == "refresherror"
or response_status == 401
or any(marker in safe_message for marker in reauth_markers)
):
return CalendarReauthRequired(i18n.translate("errors.calendar_reauth_required"))
return CalendarError(i18n.translate("errors.calendar_no_data"))


def _credentials() -> Any:
"""Load one read-only authorized-user token and refresh it only in memory."""

global _credentials_cache
configured_path = GOOGLE_CALENDAR_CREDENTIALS.strip()
if not configured_path:
raise CalendarDisabled(i18n.translate("errors.calendar_disabled"))
path = Path(configured_path).expanduser()
credentials_path = str(path)
metadata = path.stat()
fingerprint = (metadata.st_mtime_ns, metadata.st_size)
with _credentials_lock:
if (
_credentials_cache is None
or _credentials_cache[:2] != (credentials_path, fingerprint)
):
from google.oauth2.credentials import Credentials

_credentials_cache = (
credentials_path,
fingerprint,
Credentials.from_authorized_user_file(credentials_path),
)
credentials = _credentials_cache[2]
if not credentials.valid:
if not credentials.refresh_token:
from google.auth.exceptions import RefreshError

raise RefreshError("Google Calendar authorization must be renewed")
from google.auth.transport.requests import Request

credentials.refresh(Request())
return credentials


def _option(args: list[str], name: str, default: str = "") -> str:
try:
return args[args.index(name) + 1]
except (ValueError, IndexError):
return default


def _execute_google(args: list[str]) -> Any:
"""Execute the small allowlisted Calendar contract directly against Google."""

if len(args) < 2 or args[0] != "calendar":
raise CalendarError(i18n.translate("errors.calendar_no_data"))
import httplib2
from google_auth_httplib2 import AuthorizedHttp
from googleapiclient.discovery import build

service = build(
"calendar",
"v3",
http=AuthorizedHttp(
_credentials(),
http=httplib2.Http(timeout=GOOGLE_CALENDAR_TIMEOUT),
),
cache_discovery=False,
)
action = args[1]
# Never accept a provider calendar ID from a caller. Public APIs and model
# tools are always confined to the operator-configured household calendar.
calendar_id = GOOGLE_CALENDAR_HOME_ID

if action == "list":
result = service.events().list(
calendarId=calendar_id,
timeMin=_option(args, "--start"),
timeMax=_option(args, "--end"),
maxResults=int(_option(args, "--max", str(MAX_EVENTS))),
singleEvents=True,
orderBy="startTime",
).execute()
return [
{
"id": event.get("id", ""),
"summary": event.get("summary", ""),
"start": event.get("start", {}).get(
"dateTime", event.get("start", {}).get("date", "")
),
"end": event.get("end", {}).get(
"dateTime", event.get("end", {}).get("date", "")
),
"location": event.get("location", ""),
"description": event.get("description", ""),
"status": event.get("status", ""),
"htmlLink": event.get("htmlLink", ""),
}
for event in result.get("items", [])
]

if action in {"create", "update"}:
event = {
"summary": _option(args, "--summary"),
"start": {"dateTime": _option(args, "--start")},
"end": {"dateTime": _option(args, "--end")},
"location": _option(args, "--location"),
"description": _option(args, "--description"),
}
if action == "create":
result = service.events().insert(
calendarId=calendar_id,
body=event,
).execute()
provider_status = "created"
else:
if len(args) < 3 or not args[2]:
raise CalendarValidationError(
i18n.translate("errors.calendar_event_id_required")
)
result = service.events().patch(
calendarId=calendar_id,
eventId=args[2],
body=event,
).execute()
provider_status = "updated"
return {
"status": provider_status,
"id": result.get("id", ""),
"summary": result.get("summary", ""),
"htmlLink": result.get("htmlLink", ""),
}

if action == "delete":
if len(args) < 3 or not args[2]:
raise CalendarValidationError(
i18n.translate("errors.calendar_event_id_required")
)
service.events().delete(
calendarId=calendar_id,
eventId=args[2],
).execute()
return {"status": "deleted", "eventId": args[2]}

raise CalendarError(i18n.translate("errors.calendar_no_data"))


def _zone(timezone_name: str | None) -> ZoneInfo:
try:
return ZoneInfo(str(timezone_name or DANTE_DEFAULT_TIMEZONE))
Expand Down
24 changes: 22 additions & 2 deletions server/dante/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ def _env(name: str, default: str = "") -> str:
HERMES_GATEWAY_URL = _env("HERMES_GATEWAY_URL").rstrip("/")
HERMES_CHAT_TRANSPORT = _env("HERMES_CHAT_TRANSPORT", "runs_api").lower()

# Cienki adapter do istniejącego skilla Hermes google-workspace. Polecenie jest prefiksem
# JSON-array (bez shella), do którego backend dopisuje `calendar list/create ...`.
# Natywny transport Google Calendar używa montowanego tokenu authorized-user.
# Starszy transport CLI pozostaje prefiksem JSON-array (bez shella).
GOOGLE_CALENDAR_COMMAND = _env("GOOGLE_CALENDAR_COMMAND")
GOOGLE_CALENDAR_CREDENTIALS = _env("GOOGLE_CALENDAR_CREDENTIALS")
GOOGLE_CALENDAR_HOME_ID = _env("GOOGLE_CALENDAR_HOME_ID")
GOOGLE_CALENDAR_HOME_NAME = _env("GOOGLE_CALENDAR_HOME_NAME", "Home")
try:
Expand Down Expand Up @@ -155,6 +156,25 @@ def validate_runtime_config(*, strict: bool | None = None) -> None:
problems.append(
"DEVICE_AUTH_MODE=optional requires DANTE_ALLOW_LEGACY_AUTH=true"
)
calendar_transport = bool(
GOOGLE_CALENDAR_CREDENTIALS or GOOGLE_CALENDAR_COMMAND
)
if calendar_transport and not GOOGLE_CALENDAR_HOME_ID:
problems.append(
"Google Calendar transport requires GOOGLE_CALENDAR_HOME_ID"
)
if GOOGLE_CALENDAR_HOME_ID and not calendar_transport:
problems.append(
"GOOGLE_CALENDAR_HOME_ID requires a Google Calendar transport"
)
if GOOGLE_CALENDAR_CREDENTIALS:
if not os.path.isabs(GOOGLE_CALENDAR_CREDENTIALS):
problems.append("GOOGLE_CALENDAR_CREDENTIALS must be an absolute path")
elif not (
os.path.isfile(GOOGLE_CALENDAR_CREDENTIALS)
and os.access(GOOGLE_CALENDAR_CREDENTIALS, os.R_OK)
):
problems.append("GOOGLE_CALENDAR_CREDENTIALS is not a readable file")
if problems:
raise RuntimeError("Strict runtime configuration failed: " + "; ".join(problems))

Expand Down
Loading
Loading