Files
jubaoliangandjubaoliang 18ab9f6ba9 feat: workspace backend sandboxing, execute-env injection, and ephemeral image refs (#376)
* feat: workspace backend sandboxing, execute-env injection, and ephemeral image refs

Workspace file-I/O hardening across backend and dashboard:
- Host directory sandboxing via root_dir allow-list and denied prefixes
- Execute-environment defaults injected into harness backend specs
- Ephemeral workspace image rematerialization for model calls
- Attachment hint expansion and inbound store improvements
- Portable memory backend and skill workspace catalog updates
- Dashboard UI for agent backend fields and root-dir selection
- Bump harness-agent 0.9.23 / harness-memory 0.9.7; docs + install notes

* feat: add user invitation codes with admin management and redeem flow

- Add user_invites table (migration 009) plus SQLite/Postgres reconcile paths
- Add InviteRepo and InviteService for create/revoke/list/redeem
- Expose public validate/redeem endpoints and admin create/revoke/list routers
- Extract default-agent bootstrap into octop.infra.agents.default_agent
- Add invite error codes and en/zh i18n strings
- Dashboard: invite drawer, login redeem UI, users list, and locales

* fix: add Windows cross-platform guards for pre-existing sandbox/rootfs tests

- Guard POSIX/container-only tests (rootfs-absolute /.octop/... workspace
  paths) with posix_only in test_thread_artifacts.py and test_agent_manager.py
- Normalize the rendered workspace path assertion in test_octop_builtin_skills.py
  (forward slashes on Windows, not str(tmp_path) backslashes)
- Canonicalize ws_dir before the prefix check in attachment hint/path tests
  (tempfile may yield an 8.3 short path on Windows while the resolved
  attachment path uses the long form)

These failures predate the user-invites commit and are unrelated to it; the
product path logic is correct.

---------

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
2026-08-21 22:32:48 +08:00

146 lines
4.7 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from octop.infra.knowledge import embed
def test_remote_embedding_routes_to_provider_embeddings_endpoint(monkeypatch) -> None:
provider = SimpleNamespace(base_url="https://example.test/v1/", api_key="secret")
services = SimpleNamespace(
settings_repo=SimpleNamespace(
get=lambda key: {
"knowledge_embedding_backend": "remote",
"knowledge_embedding_model": "embed-1",
"knowledge_embedding_provider_id": "7",
}.get(key)
),
provider_repo=SimpleNamespace(
get=lambda provider_id: provider if provider_id == 7 else None
),
)
captured = {}
class Response:
def raise_for_status(self):
return None
def json(self):
return {"data": [{"embedding": [1.0, 2.0]}]}
class Client:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def post(self, url, *, headers, json):
captured.update(url=url, headers=headers, json=json)
return Response()
monkeypatch.setattr(embed.httpx, "Client", lambda **_kwargs: Client())
assert embed.embed_knowledge_texts(services, ["hello"]) == [[1.0, 2.0]]
assert captured["url"] == "https://example.test/v1/embeddings"
assert captured["json"] == {"model": "embed-1", "input": ["hello"]}
assert captured["headers"]["Authorization"] == "Bearer secret"
def test_remote_embedding_merges_provider_extra_headers(monkeypatch) -> None:
provider = SimpleNamespace(
base_url="https://example.test/v1/",
api_key="secret",
extra_json='{"headers": {"X-Custom": "1"}}',
)
services = SimpleNamespace(
settings_repo=SimpleNamespace(
get=lambda key: {
"knowledge_embedding_backend": "remote",
"knowledge_embedding_model": "embed-1",
"knowledge_embedding_provider_id": "7",
}.get(key)
),
provider_repo=SimpleNamespace(
get=lambda provider_id: provider if provider_id == 7 else None
),
)
captured = {}
class Response:
def raise_for_status(self):
return None
def json(self):
return {"data": [{"embedding": [0.5]}]}
class Client:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def post(self, url, *, headers, json):
captured.update(url=url, headers=headers, json=json)
return Response()
monkeypatch.setattr(embed.httpx, "Client", lambda **_kwargs: Client())
assert embed.embed_knowledge_texts(services, ["hello"]) == [[0.5]]
assert captured["headers"]["Authorization"] == "Bearer secret"
assert captured["headers"]["X-Custom"] == "1"
def test_remote_embedding_batches_large_input(monkeypatch) -> None:
provider = SimpleNamespace(base_url="https://example.test/v1/", api_key="secret")
services = SimpleNamespace(
settings_repo=SimpleNamespace(
get=lambda key: {
"knowledge_embedding_backend": "remote",
"knowledge_embedding_model": "embed-1",
"knowledge_embedding_provider_id": "7",
}.get(key)
),
provider_repo=SimpleNamespace(
get=lambda provider_id: provider if provider_id == 7 else None
),
)
calls: list[dict] = []
class Response:
def __init__(self, batch: list[str]) -> None:
# One embedding per input, tagged with the input's global position.
self._data = [{"embedding": [float(t.split("-")[1])]} for t in batch]
def raise_for_status(self):
return None
def json(self):
return {"data": self._data}
class Client:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def post(self, url, *, headers, json):
calls.append({"url": url, "json": json})
return Response(json["input"])
monkeypatch.setattr(embed.httpx, "Client", lambda **_kwargs: Client())
texts = [f"chunk-{i}" for i in range(45)]
result = embed.embed_knowledge_texts(services, texts)
# AssertiveProviders cap at 20 -> 45 inputs => 3 requests (20/20/5).
assert len(calls) == 3
assert calls[0]["json"]["input"] == texts[0:20]
assert calls[1]["json"]["input"] == texts[20:40]
assert calls[2]["json"]["input"] == texts[40:45]
# Merged vectors stay aligned with input order.
assert len(result) == 45
assert result == [[float(i)] for i in range(45)]