diff --git a/Dockerfile b/Dockerfile index 5dd66c8f45..30774bdf31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -261,10 +261,15 @@ RUN cd plugins/platforms/photon/sidecar && \ # avoids the cross-platform failures that kept [matrix] out of [all] # while still making Matrix work in the published container. Fixes #30399. # +# Google Chat's [google-chat] extra (google-cloud-pubsub + Chat API clients) +# is baked so hosted/immutable images can enable the adapter without writing +# the sealed venv. Runtime --install-deps still routes through lazy_deps into +# HERMES_LAZY_INSTALL_TARGET when the extra is not present. +# # The editable link is created after the source copy below. COPY pyproject.toml uv.lock ./ RUN touch ./README.md -RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix +RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix --extra google-chat # ---------- Frontend build (cached independently from Python source) ---------- # Copy only the frontend source trees first so that Python-only changes don't diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index fe7ce42e6a..c73ddd3bd7 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -185,7 +185,26 @@ def _is_retryable_error(exc: BaseException) -> bool: def check_google_chat_requirements() -> bool: - """Canonical "are the optional deps available" probe; triggers the lazy import.""" + """PASSIVE deps probe; must never install. Registry ``check_fn`` uses this via ``_check_for_registry``.""" + return _load_google_modules() + + +def ensure_google_chat_deps() -> bool: + """ACTIVE installer (registry ``ensure_deps_fn``). + + Routes through ``tools.lazy_deps`` so sealed hosted/Docker images write + ``HERMES_LAZY_INSTALL_TARGET`` instead of the read-only venv. Resets the + failed-import cache so ``create_adapter()`` can load modules after install. + """ + global _google_modules_loaded, GOOGLE_CHAT_AVAILABLE + if GOOGLE_CHAT_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.google_chat", prompt=False) + except Exception: + return False + _google_modules_loaded = False return _load_google_modules() @@ -1704,6 +1723,7 @@ def register(ctx) -> None: label="Google Chat", adapter_factory=lambda cfg: GoogleChatAdapter(cfg), check_fn=_check_for_registry, + ensure_deps_fn=ensure_google_chat_deps, validate_config=_validate_config, is_connected=_is_connected, required_env=["GOOGLE_CHAT_SERVICE_ACCOUNT_JSON"], diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index e176354d5e..623f3e7839 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -237,16 +237,20 @@ def install_deps() -> bool: return True print("Installing Google Chat dependencies...") try: - from hermes_cli.tools_config import _pip_install + from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure - result = _pip_install(["--quiet"] + missing) - if result.returncode != 0: - raise RuntimeError((result.stderr or "install failed").strip()[:300]) + # lazy_deps honors HERMES_LAZY_INSTALL_TARGET on sealed hosted images; + # _pip_install always writes the venv and Permission-denied there. + _lazy_ensure("platform.google_chat", prompt=False) remaining = _missing_required_packages() if remaining: raise RuntimeError("dependencies remain stale after install: " + " ".join(remaining)) print("Dependencies installed.") return True + except FeatureUnavailable as exc: + print(f"ERROR: Failed to install dependencies: {exc.reason}") + print("Run `hermes setup` to repair the managed installation, then retry.") + return False except Exception as exc: print(f"ERROR: Failed to install dependencies: {exc}") print("Run `hermes setup` to repair the managed installation, then retry.") diff --git a/pyproject.toml b/pyproject.toml index 7403d11e3e..c07c227d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -344,6 +344,19 @@ google = [ "httplib2==0.32.0", "pyasn1==0.6.4", ] +google-chat = [ + # Google Chat adapter (Pub/Sub inbound + Chat REST). Kept out of [all] so a + # quarantined google-cloud-pubsub cannot break every fresh install; Docker + # bakes `--extra google-chat` and lazy_deps installs into + # HERMES_LAZY_INSTALL_TARGET on sealed hosted images. + "google-cloud-pubsub==2.39.0", + "google-api-python-client==2.194.0", + "google-auth==2.55.1", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", + "httplib2==0.32.0", + "pyasn1==0.6.4", +] youtube = [ # Required by skills/media/youtube-content and # optional-skills/productivity/memento-flashcards (youtube_quiz.py). @@ -498,6 +511,7 @@ google-api-python-client = false google-auth = false google-auth-httplib2 = false google-auth-oauthlib = false +google-cloud-pubsub = false h2 = false hindsight-client = false honcho-ai = false diff --git a/tests/gateway/test_google_chat_oauth_dependencies.py b/tests/gateway/test_google_chat_oauth_dependencies.py index 43b4227b2c..0d71a8118c 100644 --- a/tests/gateway/test_google_chat_oauth_dependencies.py +++ b/tests/gateway/test_google_chat_oauth_dependencies.py @@ -47,17 +47,17 @@ def test_installer_repairs_stale_transitives(monkeypatch): ) monkeypatch.setattr(oauth, "_missing_required_packages", lambda: next(states)) calls = [] + pip_calls = [] + + def fake_ensure(feature, prompt=False): + calls.append((feature, prompt)) + + monkeypatch.setattr("tools.lazy_deps.ensure", fake_ensure) monkeypatch.setattr( "hermes_cli.tools_config._pip_install", - lambda argv: calls.append(argv) or SimpleNamespace(returncode=0, stderr=""), + lambda argv: pip_calls.append(argv) or SimpleNamespace(returncode=0, stderr=""), ) assert oauth.install_deps() is True - assert calls == [ - [ - "--quiet", - "google-auth==2.55.1", - "httplib2==0.32.0", - "pyasn1==0.6.4", - ] - ] + assert calls == [("platform.google_chat", False)] + assert pip_calls == [] diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 0472988236..bf057619bc 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -150,6 +150,17 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "platform.wecom_callback": ("defusedxml==0.7.1",), # Teams pulls a heavy tree (msal, dependency-injector); also the `teams` extra. "platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"), + # Google Chat — Pub/Sub + Chat API. Not in [all]; Docker bakes `--extra google-chat` + # so hosted/immutable images do not have to write the sealed venv. + "platform.google_chat": ( + "google-cloud-pubsub==2.39.0", + "google-api-python-client==2.194.0", + "google-auth==2.55.1", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", + "httplib2==0.32.0", + "pyasn1==0.6.4", + ), # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), diff --git a/uv.lock b/uv.lock index 45884e828c..b968565317 100644 --- a/uv.lock +++ b/uv.lock @@ -1493,6 +1493,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, ] +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + [[package]] name = "google-api-python-client" version = "2.194.0" @@ -1548,6 +1554,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, ] +[[package]] +name = "google-cloud-pubsub" +version = "2.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "grpcio-status" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/2b/4bf2c17e319ff65340389565b0e1b4d72696d87802b2f5f94390fbefa73c/google_cloud_pubsub-2.39.0.tar.gz", hash = "sha256:eed65e25f57f95bf3e02d96d7ee171688b23922471f9f21b5a91ed90e1282c0f", size = 402096, upload-time = "2026-06-03T15:28:26.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/20/dd0b27d4ad4577c062e77ff968ca3e2d404186cd78c8a2a53a0ef5fe5389/google_cloud_pubsub-2.39.0-py3-none-any.whl", hash = "sha256:7210d691a46d7a66559696899ebe6eb731e63de29b624964b3be4dd2d12d3e19", size = 324665, upload-time = "2026-06-03T15:27:41.119Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.73.0" @@ -1560,6 +1586,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + [[package]] name = "greenlet" version = "3.5.3" @@ -1592,6 +1623,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, ] +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/d0/fa5bdd5f3f421bb68dc6dc162e9caaf942897ca41ce7255b524723c80f0b/grpc_google_iam_v1-0.14.5.tar.gz", hash = "sha256:07fd3a9fafb586588e771831fbfc8f6597050181d0c3b45e039d18b8fdc1aab5", size = 23736, upload-time = "2026-08-06T06:24:54.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ab/be3ad0d46cffe35fd1e7cc3f9947edd6cb3c552229de3be2742f15f7ea47/grpc_google_iam_v1-0.14.5-py3-none-any.whl", hash = "sha256:0f5e680b20aa0a9441e68c769da04d94d70fca4e43751a82d8abb8aa6a7181ca", size = 32674, upload-time = "2026-08-06T06:23:49.467Z" }, +] + [[package]] name = "grpcio" version = "1.81.1" @@ -1633,6 +1678,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, ] +[[package]] +name = "grpcio-status" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/26/0aa9168c87882381fd810d140c279a2490ed6aee655f0515d6f56c5ca404/grpcio_status-1.81.1.tar.gz", hash = "sha256:9389a03e746017b10f0630c064289201458f3ce01f5d7ef4b0bebc1ef6cf82ad", size = 13923, upload-time = "2026-06-11T12:58:48.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/5e/5abfec5f7e89d3b7993d57cfb025ca5f968a2c18656d7fcda2b6919440b9/grpcio_status-1.81.1-py3-none-any.whl", hash = "sha256:08072fa9995f4a95c647fc6f4f85e2411573d00087bcabdf30f260114338f232", size = 14638, upload-time = "2026-06-11T12:58:31.982Z" }, +] + [[package]] name = "grpclib" version = "0.4.9" @@ -1788,6 +1847,15 @@ google = [ { name = "httplib2" }, { name = "pyasn1" }, ] +google-chat = [ + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, + { name = "google-cloud-pubsub" }, + { name = "httplib2" }, + { name = "pyasn1" }, +] hindsight = [ { name = "hindsight-client" }, ] @@ -1950,10 +2018,15 @@ requires-dist = [ { name = "firecrawl-anydoc", specifier = "==0.2.4" }, { name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" }, { name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" }, + { name = "google-api-python-client", marker = "extra == 'google-chat'", specifier = "==2.194.0" }, { name = "google-auth", marker = "extra == 'google'", specifier = "==2.55.1" }, + { name = "google-auth", marker = "extra == 'google-chat'", specifier = "==2.55.1" }, { name = "google-auth", marker = "extra == 'vertex'", specifier = "==2.55.1" }, { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" }, + { name = "google-auth-httplib2", marker = "extra == 'google-chat'", specifier = "==0.3.1" }, { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" }, + { name = "google-auth-oauthlib", marker = "extra == 'google-chat'", specifier = "==1.3.1" }, + { name = "google-cloud-pubsub", marker = "extra == 'google-chat'", specifier = "==2.39.0" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, @@ -1976,6 +2049,7 @@ requires-dist = [ { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" }, { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.2.0" }, { name = "httplib2", marker = "extra == 'google'", specifier = "==0.32.0" }, + { name = "httplib2", marker = "extra == 'google-chat'", specifier = "==0.32.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx2", marker = "extra == 'computer-use'", specifier = "==2.7.0" }, { name = "httpx2", marker = "extra == 'dev'", specifier = "==2.7.0" }, @@ -2009,6 +2083,7 @@ requires-dist = [ { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, { name = "pvporcupine", marker = "extra == 'wake'", specifier = "==4.0.3" }, { name = "pyasn1", marker = "extra == 'google'", specifier = "==0.6.4" }, + { name = "pyasn1", marker = "extra == 'google-chat'", specifier = "==0.6.4" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, @@ -2053,7 +2128,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "otlp", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "otlp", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "google-chat", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -4197,7 +4272,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4252,7 +4327,7 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -4900,11 +4975,11 @@ name = "vercel-workers" version = "0.0.25" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, - { name = "vercel", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "vercel" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/df/04d37021ad7ca53b7599c313e411d91623c7a005c741f491d1eefb7a9f0c/vercel_workers-0.0.25.tar.gz", hash = "sha256:212ded01400b524be51d251df49f801caf115ad7d48cca7eb168cbeceda3def3", size = 64149, upload-time = "2026-06-20T19:26:27.177Z" } wheels = [