From 8a830f6354fd6843c56353f484afe27104f727cf Mon Sep 17 00:00:00 2001 From: Aniket Wagh <78427409+aniketwaghh@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:07:58 +0530 Subject: [PATCH] fix(deps): depend on renamed tenki package instead of tenki-sandbox (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): depend on renamed tenki package instead of tenki-sandbox tenki-sandbox has been removed from PyPI and republished as tenki. Its old wheel URL still resolves, so existing lockfiles keep installing and the breakage is invisible to anyone with a warm lock; any fresh resolution fails with 'tenki-sandbox was not found in the package registry'. tenki 1.0.2 still ships the tenki_sandbox module, so the imports in community/tenki/provider.py and sandbox.py are unchanged. Fixes #5081 * fix(tenki): point install guidance at the renamed distribution The rename to `tenki` left the user-facing remediation still naming the removed package. `_import_client` raised "pip install tenki-sandbox" on the missing-extra path — the exact instruction this change proves now 404s on PyPI, handed to the user at the exact moment they need it to work. Update that message and the remaining `tenki-sandbox` references in the provider, sandbox adapter, README, sandbox AGENTS.md and the test docstring. The imported module stays `tenki_sandbox`, so the distribution and module names now differ; each mention says so rather than just swapping the string. No behavior change beyond the error text. Co-Authored-By: Claude Opus 5 (1M context) * fix(tenki): migrate the provider to the 1.x workspace-only API Renaming the dependency was not enough. tenki 1.0.2 keeps the tenki_sandbox module name but not its contract: Client.create dropped project_id and has no **kwargs to absorb it, and IdentityWorkspace no longer carries `projects` (the attribute is gone from the package entirely). Both configuration paths therefore failed before a sandbox could be created — explicit project scope raised TypeError, and automatic scope raised AttributeError walking workspace.projects. Scope is now the workspace alone. _resolve_scope returns a single workspace id, auto-selecting when the account has exactly one, and project_id is gone from create_kwargs and from the documented config surface. A stale project_id in config.yaml warns rather than fails. SandboxConfig is extra="allow", so simply not reading the key would leave it scoping nothing with no signal; it also used to short-circuit the identity lookup, so operators with more than one workspace need to know they must now set workspace_id. The suite passed against the broken provider because the fake client took **kwargs and swallowed the project_id the real SDK rejects. The double now mirrors 1.0.2 — keyword-only, no **kwargs — so an unexpected argument is a TypeError in tests exactly as it is against the SDK. Reintroducing the old create call fails 20 tests; before this change it failed none. Verified against the exact locked wheels: every other kwarg the provider passes (name, workspace_id, sticky, wait, max_duration, image, cpu_cores, memory_mb, env) and every SDK surface it touches (who_am_i, Identity.workspaces, wait_ready, exec, close, the fs API, the four terminal exception classes) is unchanged in 1.0.2. Reported by willem-bd in review. Co-Authored-By: Claude Opus 5 (1M context) * docs(config): drop sandbox.project_id from the Tenki example The canonical example still documented project_id as a supported optional key after the provider stopped honouring it, so an operator following it could set the key, get no scope from it, and hit a workspace-resolution failure with nothing in the example to explain why. Replaced with a migration note rather than a silent deletion: someone upgrading already has the key in their config.yaml and needs to know it is inert now and that workspace_id is what scopes a sandbox on Tenki 1.x. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Aniket Wagh --- .../deerflow/community/tenki/README.md | 8 +- .../deerflow/community/tenki/__init__.py | 1 - .../deerflow/community/tenki/provider.py | 37 +++---- .../deerflow/community/tenki/sandbox.py | 7 +- .../harness/deerflow/sandbox/AGENTS.md | 2 +- backend/packages/harness/pyproject.toml | 4 +- backend/tests/test_tenki_provider.py | 97 +++++++++++++++---- backend/uv.lock | 16 +-- config.example.yaml | 4 +- 9 files changed, 120 insertions(+), 56 deletions(-) diff --git a/backend/packages/harness/deerflow/community/tenki/README.md b/backend/packages/harness/deerflow/community/tenki/README.md index 2f9ebbf34..e1fa35f87 100644 --- a/backend/packages/harness/deerflow/community/tenki/README.md +++ b/backend/packages/harness/deerflow/community/tenki/README.md @@ -13,7 +13,6 @@ sandbox: api_key: $TENKI_API_KEY # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var base_url: https://tenki.cloud # optional; SDK default when omitted image: my-base-image # optional; Tenki account default base image when omitted - project_id: proj_... # optional; auto-selected if the account has exactly one workspace_id: ws_... # optional; auto-selected if the account has exactly one cpu_cores: 2 # optional per-sandbox vCPUs memory_mb: 2048 # optional per-sandbox memory @@ -32,8 +31,9 @@ Install the optional SDK before selecting this provider: pip install "deerflow-harness[tenki]" ``` -The `tenki-sandbox` package is an optional DeerFlow harness extra, not part of -the default install. Get an API key from . +The `tenki` package (which provides the `tenki_sandbox` module) is an optional +DeerFlow harness extra, not part of the default install. Get an API key from +. ## Design @@ -92,6 +92,6 @@ sandboxes left by a previous gateway process) and a preview-URL surface. Verified end-to-end against live Tenki sandboxes: provider resolution → `execute_command` → full file-op surface (`read`/`write`/`update`/`download`, `list_dir`/`glob`/`grep`) → warm-pool reclaim → terminate, plus the -`/mnt/user-data` sudo symlink. Unit tests run in CI without `tenki-sandbox` +`/mnt/user-data` sudo symlink. Unit tests run in CI without `tenki` installed; `test_integration_real_sandbox` exercises a real microVM when `TENKI_API_KEY` is set. diff --git a/backend/packages/harness/deerflow/community/tenki/__init__.py b/backend/packages/harness/deerflow/community/tenki/__init__.py index 247a83137..0cbeb6c45 100644 --- a/backend/packages/harness/deerflow/community/tenki/__init__.py +++ b/backend/packages/harness/deerflow/community/tenki/__init__.py @@ -16,7 +16,6 @@ Configuration example (``config.yaml``):: api_key: $TENKI_API_KEY # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var base_url: https://tenki.cloud # optional; SDK default when omitted image: my-base-image # optional; Tenki account default base image when omitted - project_id: proj_... # optional; auto-selected if the account has exactly one workspace_id: ws_... # optional; auto-selected if the account has exactly one cpu_cores: 2 # optional per-sandbox vCPUs memory_mb: 2048 # optional per-sandbox memory diff --git a/backend/packages/harness/deerflow/community/tenki/provider.py b/backend/packages/harness/deerflow/community/tenki/provider.py index 2b5acce1d..43b3fe25f 100644 --- a/backend/packages/harness/deerflow/community/tenki/provider.py +++ b/backend/packages/harness/deerflow/community/tenki/provider.py @@ -11,7 +11,7 @@ appear under ``sandbox:`` in ``config.yaml`` even though they are not declared o the model — see this package's ``__init__`` docstring for the full set. The Tenki SDK is imported lazily (``_import_client``) so the harness — and every -other provider — installs without ``tenki-sandbox``; the dependency is only +other provider — installs without ``tenki``; the dependency is only needed once this provider is selected. """ @@ -91,7 +91,7 @@ def _import_client() -> type[Client]: try: from tenki_sandbox import Client except ImportError as e: # pragma: no cover - depends on the optional dependency - raise ImportError("TenkiSandboxProvider requires the optional 'tenki-sandbox' dependency. Install it with: pip install 'deerflow-harness[tenki]' or pip install tenki-sandbox.") from e + raise ImportError("TenkiSandboxProvider requires the optional 'tenki' dependency (it provides the tenki_sandbox module). Install it with: pip install 'deerflow-harness[tenki]' or pip install tenki.") from e return Client @@ -147,6 +147,13 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider # config env is merged into every command and would otherwise only surface # as a confusing SDK error at create/exec time. _validate_extra_env(environment) + # Tenki 1.x removed projects: ``Client.create`` no longer takes project_id + # and IdentityWorkspace no longer carries ``projects``. Scope is the + # workspace alone. Warn rather than fail so an existing config.yaml keeps + # booting — SandboxConfig is extra="allow", so a stale key would otherwise + # be read by nobody and silently change how scope resolves. + if _opt("project_id") is not None: + logger.warning("sandbox.project_id is ignored: Tenki 1.x removed projects. Scope is resolved by workspace alone — set sandbox.workspace_id if the account has more than one.") return { "max_duration": float(max_duration if max_duration is not None else DEFAULT_MAX_DURATION), # Off by default (the SDK default). Warm-pool sandboxes stay running @@ -157,7 +164,6 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider "base_url": _opt("base_url"), "image": _opt("image"), # None → Tenki account default base image "home_dir": _opt("home_dir") or DEFAULT_TENKI_HOME_DIR, - "project_id": _opt("project_id"), "workspace_id": _opt("workspace_id"), "cpu_cores": _opt("cpu_cores"), "memory_mb": _opt("memory_mb"), @@ -177,25 +183,23 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider self._client = client return self._client - def _resolve_scope(self) -> tuple[str | None, str | None]: - """Return (project_id, workspace_id), auto-selecting when unambiguous. + def _resolve_scope(self) -> str | None: + """Return the workspace id to create in, auto-selecting when unambiguous. - Tenki's ``create`` needs a project scope. When the caller didn't set one - in config, pick it if the account has exactly one workspace and project; - otherwise raise with the choices so the operator can set ``project_id``. + Tenki 1.x scopes a sandbox by workspace; the project layer that 0.4.0 + required is gone from both ``Client.create`` and ``IdentityWorkspace``. + When the caller didn't set ``workspace_id`` in config, pick it if the + account has exactly one workspace; otherwise raise with the choices so + the operator can set it. """ - project_id = self._config["project_id"] workspace_id = self._config["workspace_id"] - if project_id is not None: - return project_id, workspace_id + if workspace_id is not None: + return workspace_id identity = self._get_client().who_am_i() workspaces = list(identity.workspaces or []) - if workspace_id is not None: - workspaces = [w for w in workspaces if w.id == workspace_id] workspace = self._require_single(workspaces, "workspace", "workspace_id") - project = self._require_single(list(workspace.projects or []), "project", "project_id") - return project.id, workspace.id + return workspace.id @staticmethod def _require_single(items: list[Any], kind: str, param: str) -> Any: @@ -293,10 +297,9 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider self._log_replicas_soft_cap(replicas, sandbox_id, evicted) client = self._get_client() - project_id, workspace_id = self._resolve_scope() + workspace_id = self._resolve_scope() create_kwargs: dict[str, Any] = { "name": self._sandbox_name(sandbox_id), - "project_id": project_id, "workspace_id": workspace_id, "sticky": self._config["sticky"], # Wait for readiness ourselves (below) instead of inside create(): diff --git a/backend/packages/harness/deerflow/community/tenki/sandbox.py b/backend/packages/harness/deerflow/community/tenki/sandbox.py index c227a7cc7..3aec40d27 100644 --- a/backend/packages/harness/deerflow/community/tenki/sandbox.py +++ b/backend/packages/harness/deerflow/community/tenki/sandbox.py @@ -1,6 +1,7 @@ """``TenkiSandbox`` — DeerFlow :class:`Sandbox` backed by a Tenki cloud sandbox. -Tenki's Python SDK (``tenki-sandbox``) is synchronous, so — unlike +Tenki's Python SDK (the ``tenki`` distribution, which ships the +``tenki_sandbox`` module) is synchronous, so — unlike ``community/boxlite`` — this adapter calls the SDK directly with no event-loop bridge. File transport uses Tenki's native ``sandbox.fs`` API (``read_text`` / ``read_stream`` / ``write_stream`` / ``mkdir`` / ``stat``), which is binary-safe @@ -13,7 +14,7 @@ Tenki base image works. The Tenki SDK is not imported at module load (only its exception *class names* are matched, as strings), so importing this package never requires -``tenki-sandbox`` to be installed — it is needed only once the provider is +``tenki`` to be installed — it is needed only once the provider is selected and a sandbox is actually created. """ @@ -52,7 +53,7 @@ DEFAULT_TENKI_HOME_DIR = "/home/tenki" _STREAM_CHUNK = 1024 * 1024 # Tenki SDK exception *class names* that mean the remote session is gone for -# good — matched as strings so this module imports without ``tenki-sandbox``. +# good — matched as strings so this module imports without ``tenki``. # A terminated/not-found/closed session is unrecoverable; the provider drops it # and rebuilds on the next call. This is only the named-error half of the rule: # _is_terminal_failure ALSO treats the builtin ConnectionError / BrokenPipeError diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index b9c8e772e..8909fc0e7 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -66,7 +66,7 @@ - `BoxliteProvider` (`packages/harness/deerflow/community/boxlite/`) - BoxLite micro-VM isolation. The `boxlite` runtime is optional (`deerflow-harness[boxlite]`) and lazy-imported only when this provider is selected. The provider owns one private asyncio event loop on a daemon thread because BoxLite handles are loop-affine; sync `Sandbox` calls marshal onto that loop with `run_coroutine_threadsafe`. Boxes are named deterministically from `user_id:thread_id`, released into an in-process warm pool after each agent turn, and reclaimed only by the same user/thread. Warm-pool health checks use a short explicit timeout and forward that timeout through both BoxLite `exec(timeout=...)` and the private-loop `.result(timeout)` bridge so a hung VM cannot pin the per-thread acquire lock indefinitely. `sandbox.replicas` caps active + warm VMs per gateway process; if capacity is exhausted, only warm-pool VMs are evicted. `sandbox.idle_timeout` stops idle warm VMs after the configured seconds. `reset()` is intentionally a lightweight registry clear for `reset_sandbox_provider()` and does not close boxes, stop the idle reaper, or close the private loop; full teardown remains `shutdown()`. -- `TenkiSandboxProvider` (`packages/harness/deerflow/community/tenki/`) - Tenki cloud microVM isolation. The `tenki-sandbox` SDK is optional (`deerflow-harness[tenki]`) and lazy-imported (`_import_client`) only when this provider is selected. Unlike Boxlite, the SDK is synchronous, so the adapter calls it directly with no event-loop bridge. File transport uses Tenki's native `sandbox.fs` API (`read_text`/`read_stream`/`write_stream`/`mkdir`/`stat`) — binary-safe and streaming, no base64/shell hop; only directory/content *search* (`list_dir`/`glob`/`grep`) shells out to busybox-portable `find`/`grep`, parsed with the shared `deerflow.sandbox.search` helpers like `community/e2b_sandbox`. Sandboxes run as the unprivileged `tenki` user, so DeerFlow's `/mnt/user-data` prefix is remapped under a writable HOME (`_resolve_path`) and best-effort `sudo`-symlinked at bootstrap. Boxes are named deterministically from `sha256(user_id:thread_id)[:16]` (64-bit, matching E2B; the warm pool is keyed by this id alone with no full-seed fallback), released into an in-process warm pool, and reclaimed only by the same user/thread after a liveness check. A terminal session error (named SDK errors plus builtin `ConnectionError`/`BrokenPipeError`/`EOFError`) routes through `_invalidate_sandbox` to evict the dead microVM. Cross-process orphan reconciliation is a follow-up (single-process warm pool today). +- `TenkiSandboxProvider` (`packages/harness/deerflow/community/tenki/`) - Tenki cloud microVM isolation. The `tenki` SDK is optional (`deerflow-harness[tenki]`, importable as `tenki_sandbox`) and lazy-imported (`_import_client`) only when this provider is selected. Unlike Boxlite, the SDK is synchronous, so the adapter calls it directly with no event-loop bridge. File transport uses Tenki's native `sandbox.fs` API (`read_text`/`read_stream`/`write_stream`/`mkdir`/`stat`) — binary-safe and streaming, no base64/shell hop; only directory/content *search* (`list_dir`/`glob`/`grep`) shells out to busybox-portable `find`/`grep`, parsed with the shared `deerflow.sandbox.search` helpers like `community/e2b_sandbox`. Sandboxes run as the unprivileged `tenki` user, so DeerFlow's `/mnt/user-data` prefix is remapped under a writable HOME (`_resolve_path`) and best-effort `sudo`-symlinked at bootstrap. Boxes are named deterministically from `sha256(user_id:thread_id)[:16]` (64-bit, matching E2B; the warm pool is keyed by this id alone with no full-seed fallback), released into an in-process warm pool, and reclaimed only by the same user/thread after a liveness check. A terminal session error (named SDK errors plus builtin `ConnectionError`/`BrokenPipeError`/`EOFError`) routes through `_invalidate_sandbox` to evict the dead microVM. Cross-process orphan reconciliation is a follow-up (single-process warm pool today). **Shared warm-pool lifecycle:** community sandbox providers that keep released sandboxes alive for fast reuse share `deerflow.community.warm_pool_lifecycle.WarmPoolLifecycleMixin`. The mixin owns the common `DEFAULT_IDLE_TIMEOUT=600`, `IDLE_CHECK_INTERVAL=60`, `DEFAULT_REPLICAS=3`, idle-checker loop, warm-pool expiry, oldest-warm eviction, replica counting, and soft-cap logging. Providers remain responsible for their own active registries, creation/discovery, health checks, and destroy hook (`_destroy_warm_entry`): AIO destroys `SandboxInfo` through its backend; Boxlite closes loop-affine `BoxliteBox` handles; Tenki closes the microVM session (`TenkiSandbox.close`, which terminates the remote sandbox). AIO keeps active-idle cleanup outside the mixin and delegates only warm-pool expiry to the shared helper. diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 83fe66150..b77b0eaba 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -78,7 +78,9 @@ pymupdf = ["pymupdf4llm>=0.0.17"] boxlite = ["boxlite>=0.9.7"] # Tenki cloud sandbox provider (deerflow.community.tenki). Optional so a default # install stays free of the Tenki SDK; only pulled in when the provider is used. -tenki = ["tenki-sandbox>=0.4.0"] +# The distribution is ``tenki``; it still ships the ``tenki_sandbox`` module that +# provider.py and sandbox.py import. +tenki = ["tenki>=1.0.0"] # OpenSandbox remote sandbox provider (deerflow.community.opensandbox). The # sync SDK is loaded only when this provider is selected. opensandbox = ["opensandbox>=0.1.15,<0.2.0"] diff --git a/backend/tests/test_tenki_provider.py b/backend/tests/test_tenki_provider.py index b6c1b8d10..91ccc2dd9 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -1,6 +1,6 @@ """Unit tests for the Tenki community sandbox provider. -These run in CI without ``tenki-sandbox`` installed: they cover the lazy-import +These run in CI without ``tenki`` installed: they cover the lazy-import error path, provider lifecycle, path-safety guards, the native ``fs`` file round-trip, warm-pool mechanics, and scope resolution — none of which need a live sandbox. A single opt-in integration test (``test_integration_real_sandbox``) @@ -10,6 +10,7 @@ exercises a real Tenki microVM end to end when ``TENKI_API_KEY`` is set. from __future__ import annotations import errno +import logging import os import shlex import sys @@ -175,19 +176,14 @@ class _FakeSandbox: raise self.close_error -class _FakeProject: +class _FakeWorkspace: + """Mirrors tenki 1.0.2 ``IdentityWorkspace``: id and name, no ``projects``.""" + def __init__(self, id: str, name: str) -> None: self.id = id self.name = name -class _FakeWorkspace: - def __init__(self, id: str, name: str, projects: list[_FakeProject]) -> None: - self.id = id - self.name = name - self.projects = projects - - class _FakeIdentity: def __init__(self, workspaces: list[_FakeWorkspace]) -> None: self.workspaces = workspaces @@ -200,12 +196,44 @@ class _FakeClient: self._sandbox_factory = sandbox_factory or (lambda: _FakeSandbox()) self.last_sandbox: _FakeSandbox | None = None self._by_id: dict[str, _FakeSandbox] = {} - self._workspaces = workspaces if workspaces is not None else [_FakeWorkspace("ws1", "Workspace", [_FakeProject("proj1", "Project")])] + self._workspaces = workspaces if workspaces is not None else [_FakeWorkspace("ws1", "Workspace")] def who_am_i(self): return _FakeIdentity(self._workspaces) - def create(self, **kwargs): + # Keyword-only and deliberately WITHOUT **kwargs, mirroring tenki 1.0.2's + # Client.create. The 0.4.0-era double accepted **kwargs, so it swallowed the + # project_id the real 1.x rejects and the suite passed against a provider + # that could not create a sandbox. An unexpected kwarg must be a TypeError + # here exactly as it is against the real SDK. + def create( + self, + *, + name=None, + workspace_id=None, + sticky=False, + wait=True, + max_duration=None, + image=None, + cpu_cores=None, + memory_mb=None, + env=None, + ): + kwargs = { + "name": name, + "workspace_id": workspace_id, + "sticky": sticky, + "wait": wait, + } + for key, value in ( + ("max_duration", max_duration), + ("image", image), + ("cpu_cores", cpu_cores), + ("memory_mb", memory_mb), + ("env", env), + ): + if value is not None: + kwargs[key] = value self.create_count += 1 self.create_kwargs.append(kwargs) sandbox = self._sandbox_factory() @@ -648,8 +676,9 @@ def test_create_passes_prefixed_name_and_scope(monkeypatch): assert sid in provider._sandboxes kwargs = client.create_kwargs[0] assert kwargs["name"].startswith("deer-flow-tenki-") - assert kwargs["project_id"] == "proj1" assert kwargs["workspace_id"] == "ws1" + # Tenki 1.x has no project layer; passing one is a TypeError against the SDK. + assert "project_id" not in kwargs provider.shutdown() @@ -897,31 +926,59 @@ def test_shutdown_destroys_all_and_stops_reaper(monkeypatch): # ── Provider: scope resolution ───────────────────────────────────────── -def test_scope_auto_resolves_single(monkeypatch): +def test_scope_auto_resolves_single_workspace(monkeypatch): client = _FakeClient() provider = _install(monkeypatch, client=client) provider.acquire("thread-1", user_id="u1") - assert client.create_kwargs[0]["project_id"] == "proj1" assert client.create_kwargs[0]["workspace_id"] == "ws1" provider.shutdown() -def test_explicit_project_id_skips_lookup(monkeypatch): +def test_explicit_workspace_id_skips_lookup(monkeypatch): client = _FakeClient() - provider = _install(monkeypatch, client=client, config_attrs={"project_id": "explicit"}) + provider = _install(monkeypatch, client=client, config_attrs={"workspace_id": "explicit"}) provider.acquire("thread-1", user_id="u1") - assert client.create_kwargs[0]["project_id"] == "explicit" + assert client.create_kwargs[0]["workspace_id"] == "explicit" provider.shutdown() -def test_ambiguous_project_raises(monkeypatch): - client = _FakeClient(workspaces=[_FakeWorkspace("ws1", "W", [_FakeProject("p1", "A"), _FakeProject("p2", "B")])]) +def test_ambiguous_workspace_raises(monkeypatch): + client = _FakeClient(workspaces=[_FakeWorkspace("ws1", "A"), _FakeWorkspace("ws2", "B")]) provider = _install(monkeypatch, client=client) - with pytest.raises(ValueError, match="project_id"): + with pytest.raises(ValueError, match="workspace_id"): provider.acquire("thread-1", user_id="u1") provider.shutdown() +def test_stale_project_id_is_ignored_with_a_warning(monkeypatch, caplog): + """A 0.4.0-era config keeps booting; the dead key says so instead of going quiet. + + SandboxConfig is ``extra="allow"``, so an unread project_id would sit in + config.yaml scoping nothing. It also used to short-circuit the identity + lookup, so silently dropping it changes how scope resolves. + """ + client = _FakeClient() + with caplog.at_level(logging.WARNING, logger="deerflow.community.tenki.provider"): + provider = _install(monkeypatch, client=client, config_attrs={"project_id": "proj_legacy"}) + assert "sandbox.project_id is ignored" in caplog.text + provider.acquire("thread-1", user_id="u1") + assert "project_id" not in client.create_kwargs[0] + assert client.create_kwargs[0]["workspace_id"] == "ws1" + provider.shutdown() + + +def test_create_rejects_project_id_like_the_real_sdk(monkeypatch): + """Guards the hole that let the 1.x break through: the old double took **kwargs. + + tenki 1.0.2's Client.create is keyword-only with no **kwargs, so a stray + project_id raises TypeError. The double must do the same or a provider that + cannot create a sandbox goes on passing its tests. + """ + client = _FakeClient() + with pytest.raises(TypeError, match="project_id"): + client.create(name="n", workspace_id="ws1", project_id="proj1") + + # ── Live integration (opt-in) ────────────────────────────────────────── diff --git a/backend/uv.lock b/backend/uv.lock index 81ea9affd..02e13b004 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -992,7 +992,7 @@ redis = [ { name = "redis" }, ] tenki = [ - { name = "tenki-sandbox" }, + { name = "tenki" }, ] tui = [ { name = "textual" }, @@ -1049,7 +1049,7 @@ requires-dist = [ { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3.0" }, { name = "tavily-python", specifier = ">=0.7.17" }, - { name = "tenki-sandbox", marker = "extra == 'tenki'", specifier = ">=0.4.0" }, + { name = "tenki", marker = "extra == 'tenki'", specifier = ">=1.0.0" }, { name = "textual", marker = "extra == 'tui'", specifier = ">=0.80" }, { name = "tiktoken", specifier = ">=0.8.0" }, ] @@ -4540,8 +4540,8 @@ name = "standard-aifc" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, + { name = "standard-chunk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } wheels = [ @@ -4624,17 +4624,17 @@ wheels = [ ] [[package]] -name = "tenki-sandbox" -version = "0.4.0" +name = "tenki" +version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/58/f6527c63b2e4c94fd00a48ba4bd93ef52de22dbc72e247110949a17511f6/tenki_sandbox-0.4.0.tar.gz", hash = "sha256:2836e6e7100dfc81715b4d93ecfe80e3f055867498cb9f34be921a02969bb15f", size = 179392, upload-time = "2026-07-17T22:18:09.869Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/d9/1669b9ffe9b729a854985918ec84f39abf1ba032764a00bd105459e7e97d/tenki-1.0.2.tar.gz", hash = "sha256:131947e54758674f814a66699d3b65518adc9f093a62ea6dddbd4394cbc771c4", size = 240024, upload-time = "2026-08-27T19:40:32.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/f7/3c02da98793dc0e56230c520064d7d233c6e0edab13b410c031e1cfe7fd9/tenki_sandbox-0.4.0-py3-none-any.whl", hash = "sha256:76d5ece2e1607b43705587d86277e983ef43601a86993077e379be8033a40b3e", size = 155246, upload-time = "2026-07-17T22:18:08.383Z" }, + { url = "https://files.pythonhosted.org/packages/98/ef/06486b129744c3f7293f9b1f1ea60055104db4d4ad03483c6857c57c0bbb/tenki-1.0.2-py3-none-any.whl", hash = "sha256:6259651ed243e071e88d789fa86a7c570954ec5f0372a40bcccc2d4b9ae2e342", size = 164788, upload-time = "2026-08-27T19:40:30.915Z" }, ] [[package]] diff --git a/config.example.yaml b/config.example.yaml index 4b8fe5231..079632b01 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1482,8 +1482,10 @@ sandbox: # # api_key: $TENKI_API_KEY # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var # # base_url: https://tenki.cloud # optional; SDK default when omitted # # image: my-base-image # optional; Tenki account default base image when omitted -# # project_id: proj_... # optional; auto-selected if the account has exactly one # # workspace_id: ws_... # optional; auto-selected if the account has exactly one +# # Migration: sandbox.project_id is gone — Tenki 1.x removed projects, so scope +# # is the workspace alone. Set workspace_id if the account has more than one. +# # A leftover project_id is ignored and logs a warning at startup. # # cpu_cores: 2 # optional per-sandbox vCPUs # # memory_mb: 2048 # optional per-sandbox memory # # replicas: 3 # active + warm microVM cap per gateway process