CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-travel

Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

google_calendar_client.pyskills/flight-assist/

"""HTTP client for the native Google Calendar REST API (v3).

Replaces `composio_client.py` (nanoclaw#638). Used by the calendar
`reconcile` script and by the drive-engine sweep (both its `calendar_apply`
writes and its `fetch_events` window read) to converge calendars
deterministically — list calendars, find events in a time window, create /
patch / delete events — without the agent in the loop. Mirrors
`byair_client.py` / `maps_client.py`: stdlib-only `urllib`, HTTP-mockable in
CI, one client per process.

Credential model (the whole point of #638)
------------------------------------------
This container holds NO Google credential. OneCLI's TLS-MITM gateway owns the
OAuth connection: it injects `Authorization: Bearer` on the wire to the Google
API host and auto-refreshes the token. So construction takes no credential,
there is no `from_env`, and this client sends NO auth header — a request
carrying its own Authorization header would mean a credential leaked into the
container, not a fallback. `COMPOSIO_API_KEY` / `COMPOSIO_USER_ID` are gone.

The gateway reaches this process via `HTTPS_PROXY` + the mounted CA bundle,
both set on the spawn by the orchestrator. Nothing here configures the proxy;
`urllib` honours `HTTPS_PROXY` from the environment.

Errors, and why `.status_code` survives the migration
-----------------------------------------------------
Composio reported an API-level failure as HTTP 200 with `successful: false`
and faked the upstream status in `data.status_code`. Google reports the same
failures as REAL HTTP status codes. The envelope dies; `.status_code` does
NOT — callers gate idempotency on it (a delete that 404s means the event is
already gone, which is success), so it is still populated, now from the
actual HTTP status:

    GoogleCalendarError  — a non-2xx from Calendar; `.status_code` is the HTTP
        status (404 on a missing event, 403 rateLimitExceeded, ...). This is
        the per-op failure type callers catch and collect.
    GatewayNotInjecting  — 401. NOT a GoogleCalendarError: the gateway is off
        this process's request path, or the Google app is disconnected. No
        retry fixes either, and a per-op handler that collected it would
        silently defer every op forever, so it propagates past them to the
        process boundary where the operator gets told what to fix.
    TierAccessRestricted — 403 + `access_restricted`. Also not a
        GoogleCalendarError: the agent's OneCLI secretMode is `selective` and
        the untrusted tier is gated from Google by design (#638). Correct
        behaviour, not a fault.

Transport failures (network, body-read timeout) propagate as
`urllib.error.URLError` per `jbaruch/coding-policy: error-handling`
("Specific Exceptions").

stdlib-only: `urllib.request` + `json` per `jbaruch/coding-policy:
dependency-management` (Stdlib First).

Public API:
    # The skill bundle dir is added to sys.path at invocation time; this
    # module is imported by its bare name (matches nanoclaw-core's convention).
    from google_calendar_client import GoogleCalendarClient, GoogleCalendarError

    client = GoogleCalendarClient()
    calendars = client.list_calendars()               # -> {"items": [...]}
    events = client.find_events({"calendar_id": cid,  # -> {"items": [...]}
                                 "timeMin": lo, "timeMax": hi})
    client.delete_event({"calendar_id": cid, "event_id": eid})

Argument contract: `calendar_id` / `event_id` are THIS client's own keys —
routing, not payload. They are popped and URL-quoted onto the path (calendar
ids are email addresses, so the `@` must survive). Every OTHER key is native
Google Calendar: query params on `find_events`, and the event-resource body on
`create_event` / `patch_event` (`summary`, `location`, `description`,
`transparency`, and nested `start` / `end` objects). Nothing is translated —
what a caller passes is what Google receives.
"""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.parse
import urllib.request

# The Google Calendar v3 base. This is the exact host OneCLI's app connection
# is bound to — changing it without adding the matching OneCLI app binding
# means the gateway won't inject a Bearer and every call 401s.
_DEFAULT_BASE_URL = "https://www.googleapis.com/calendar/v3"

# Test-only override, mirroring nanoclaw-admin's GOOGLE_API_BASES. Tests point
# the client at a local fixture server; unset in production. Named for this
# client because this bundle talks to one surface only.
_BASE_URL_VAR = "GOOGLE_CALENDAR_API_BASE"

# events.list pagination. Google caps a page at 2500 events; left to its
# default the endpoint returns 250 and a caller reconciles against a partial
# window (#171: truncated current_blocks defeats dedup -> duplicate storm).
# 2500 drains any realistic window in a single call; the nextPageToken loop is
# the safety net for a window that still exceeds one page. _MAX_PAGES bounds
# the loop so a token that never clears can't spin forever.
_FIND_EVENT_PAGE_SIZE = 2500
_FIND_EVENT_MAX_PAGES = 40

GATEWAY_NOT_INJECTING_HINT = (
    "the OneCLI gateway is not authenticating this request. Check that the spawn "
    "carries HTTPS_PROXY + the mounted CA (src/container-runner.ts), and that the "
    "Google app is still connected in the vault (`onecli apps list` on the NAS)"
)

TIER_ACCESS_RESTRICTED_HINT = (
    "this agent's OneCLI secretMode is 'selective' — the untrusted tier is gated "
    "from Google by design (nanoclaw#638). Calendar writes do not run at this tier"
)


class GoogleCalendarError(Exception):
    """Raised when Google Calendar answers a call with a non-2xx status.

    `status_code` is that HTTP status (e.g. 404 from a missing event), else
    None for a failure with no status to report. Callers gate idempotency on
    it — a delete that 404s means the event is already gone, which is success.
    """

    def __init__(self, message: str, *, status_code: int | None = None):
        super().__init__(message)
        self.message = message
        self.status_code = status_code


class GatewayNotInjecting(RuntimeError):
    """Google answered 401, so no Bearer reached it.

    Deliberately NOT a GoogleCalendarError: callers catch that per-op and
    collect the failure for the next cycle, which for a config error would
    mean deferring every op forever with nothing actionable surfaced. This
    propagates to the process boundary, which prints
    GATEWAY_NOT_INJECTING_HINT and exits non-zero.
    """


class TierAccessRestricted(RuntimeError):
    """The OneCLI gateway refused to inject for this agent's tier.

    Expected on the untrusted tier (secretMode=selective). Callers report
    "unavailable at this tier" rather than treating it as a fault.
    """


class GoogleCalendarClient:
    """Thin REST client for the native Google Calendar v3 API.

    Takes no credential: the OneCLI gateway injects the Bearer on the wire
    (see the module docstring). Not thread-safe — one client per process is
    the intended shape, matching `byair_client.ByAirClient`.
    """

    def __init__(self, *, base_url: str | None = None, timeout: float = 30.0):
        # Read the override per-construction rather than at import so a test
        # can set it after this module is loaded.
        resolved = base_url or os.environ.get(_BASE_URL_VAR) or _DEFAULT_BASE_URL
        self._base_url = resolved.rstrip("/")
        self._timeout = timeout

    # --- Calendar surface -------------------------------------------------

    def list_calendars(self, arguments: dict | None = None) -> dict:
        """List the operator's calendars (`GET /users/me/calendarList`).

        Returns the native calendarList resource; the calendars are in `items`.
        """
        return self._request("GET", "/users/me/calendarList", params=arguments or {})

    def find_events(self, arguments: dict) -> dict:
        """Find events by calendar + time window, draining the COMPLETE window.

        `arguments` carries this client's `calendar_id` plus native
        events.list query params (`timeMin`, `timeMax`, `singleEvents`, ...).
        Note `orderBy: "startTime"` is only legal alongside `singleEvents:
        true` — Google rejects it otherwise.

        Sets `maxResults` and follows `nextPageToken` until the window is
        exhausted, then returns a single `{"items": [...]}` resource holding
        the merged events — the same shape a one-page response has, so a
        caller's event extraction is unchanged whether the window took one
        page or ten (#171). Without this, callers reconcile against a
        truncated first page and re-create everything they can't see.

        Raises:
            GoogleCalendarError: on a non-2xx (per `_request`), or if the
                window needs more than `_FIND_EVENT_MAX_PAGES` pages to drain
                (an implausibly large window, or a `nextPageToken` that never
                clears).
        """
        args = dict(arguments)
        calendar_id = args.pop("calendar_id")
        path = f"/calendars/{_quote(calendar_id)}/events"
        merged: list = []
        params = {**args, "maxResults": _FIND_EVENT_PAGE_SIZE}
        for _ in range(_FIND_EVENT_MAX_PAGES):
            page = self._request("GET", path, params=params)
            items = page.get("items")
            merged.extend(items if isinstance(items, list) else [])
            token = page.get("nextPageToken")
            if not isinstance(token, str) or not token:
                return {"items": merged}
            params = {**params, "pageToken": token}
        raise GoogleCalendarError(
            f"find_events: window did not drain within {_FIND_EVENT_MAX_PAGES} pages "
            f"(>{_FIND_EVENT_MAX_PAGES * _FIND_EVENT_PAGE_SIZE} events) — the time window "
            f"is implausibly large or nextPageToken is not clearing; narrow the window"
        )

    def create_event(self, arguments: dict) -> dict:
        """Create a calendar event (`POST /calendars/{calendarId}/events`).

        `arguments` carries this client's `calendar_id`; everything else is
        the native event-resource body. Returns the created event resource,
        whose new id is top-level `id`.
        """
        body = dict(arguments)
        calendar_id = body.pop("calendar_id")
        return self._request("POST", f"/calendars/{_quote(calendar_id)}/events", body=body)

    def patch_event(self, arguments: dict) -> dict:
        """Partial-update an event (`PATCH /calendars/{calendarId}/events/{eventId}`).

        Only the fields present in `arguments` are sent, so a patch never
        clobbers a field it did not touch — that is what makes PATCH the right
        verb here rather than the whole-resource `update` (PUT).
        """
        body = dict(arguments)
        calendar_id = body.pop("calendar_id")
        event_id = body.pop("event_id")
        path = f"/calendars/{_quote(calendar_id)}/events/{_quote(event_id)}"
        return self._request("PATCH", path, body=body)

    def delete_event(self, arguments: dict) -> dict:
        """Delete an event (`DELETE /calendars/{calendarId}/events/{eventId}`).

        Google answers 204 with an empty body; that surfaces here as `{}` (see
        `_request`). A missing event raises `GoogleCalendarError(status_code=404)`;
        callers treat that as an idempotent success (event already gone).
        """
        args = dict(arguments)
        calendar_id = args.pop("calendar_id")
        event_id = args.pop("event_id")
        path = f"/calendars/{_quote(calendar_id)}/events/{_quote(event_id)}"
        return self._request("DELETE", path)

    # --- transport --------------------------------------------------------

    def _request(self, method: str, path: str, *, params: dict | None = None, body=None) -> dict:
        """Issue one native Calendar call; return the parsed JSON object.

        Raises:
            GatewayNotInjecting: on 401 (see the module docstring).
            TierAccessRestricted: on 403 + `access_restricted`.
            GoogleCalendarError: on any other non-2xx; `.status_code` is the
                HTTP status.
            urllib.error.URLError: on network/transport failure (incl. a
                body-read timeout, normalized for a single transport-error
                type per this module's contract).
        """
        url = f"{self._base_url}{path}"
        if params:
            # `doseq` keeps a list arg as repeated keys rather than
            # url-encoding the Python list repr — Google's list endpoints take
            # repeated keys for multi-value params.
            query = urllib.parse.urlencode(
                {k: _query_value(v) for k, v in params.items()}, doseq=True
            )
            url = f"{url}?{query}"

        data = None
        headers = {"Accept": "application/json"}
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"

        # No Authorization header by design — the OneCLI gateway injects it on
        # the wire. Setting one here would either be overwritten or shadow the
        # injection; either way it means a credential leaked into the container.
        request = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                raw = response.read()
        except urllib.error.HTTPError as http_err:
            raise _classify(http_err) from http_err
        except TimeoutError as timeout_err:
            # A timeout during response.read() surfaces as raw TimeoutError
            # (socket.timeout is aliased to TimeoutError since Python 3.10);
            # normalize to URLError so callers see one transport-error type.
            # Mirrors byair_client per #28.
            raise urllib.error.URLError(f"timed out: {timeout_err}") from timeout_err

        if not raw:
            # 204 No Content — Calendar's DELETE. An empty body is success, not
            # a JSONDecodeError for every caller to guard.
            return {}
        return json.loads(raw.decode("utf-8"))


def _quote(value: object) -> str:
    """URL-quote an id that becomes a path segment.

    Calendar ids are routinely email addresses, so `safe=''` is load-bearing:
    the `@` and any `+` must reach Calendar percent-encoded rather than being
    read as URL syntax.
    """
    return urllib.parse.quote(str(value), safe="")


def _query_value(value):
    """Render one param value for the query string.

    JSON booleans must reach Google as `true`/`false`. Left alone, urlencode
    stringifies Python's bool as `True`/`False`, which Google REJECTS as a
    malformed value — and callers hand us real booleans (`singleEvents`).
    Serializing here rather than at each call site means one encoder for every
    caller. Lists are mapped element-wise so `doseq` still sees a list.
    """
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, list):
        return [_query_value(v) for v in value]
    return value


def _classify(http_err: urllib.error.HTTPError) -> Exception:
    """Map an HTTPError to this module's error for its failure mode.

    Reads the body once — it is the only place the `access_restricted` marker
    appears, and it carries the reason Google actually sent
    (`rateLimitExceeded`, `insufficientPermissions`). Reading it is
    DESTRUCTIVE: an HTTPError handed back afterwards has `.read() == b""` and
    `str(e)` has lost the diagnostic. So the detail is folded into the message
    of whatever this returns, and nothing hands the drained original back.
    """
    try:
        raw = http_err.read()
    except OSError:
        raw = b""
    detail = raw.decode("utf-8", "replace")[:300]

    if http_err.code == 401:
        return GatewayNotInjecting(f"{GATEWAY_NOT_INJECTING_HINT} (Google said: {detail})")
    if http_err.code == 403 and "access_restricted" in detail:
        return TierAccessRestricted(TIER_ACCESS_RESTRICTED_HINT)
    reason = f"{http_err.reason} ({detail})" if detail else http_err.reason
    return GoogleCalendarError(
        f"Google Calendar returned {http_err.code}: {reason}",
        status_code=http_err.code,
    )

README.md

tile.json