diff --git a/CHANGELOG.md b/CHANGELOG.md index 74df878..606116c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,6 +255,17 @@ Overview, how to try it and the known limits: docs/autonomy.md. ### Fixed +- **Memory store** — a symbolic link *above* the configured memory root (macOS `/tmp` and + `/var`, a symlinked home or project folder, `STIRA_CHAT_DATA_DIR` or a `--base-dir` under + them) no longer refuses the store with `MemorySymlinkError`: the ancestors are the + operator's choice and are resolved once when the store is built. A link at the root + itself, at `agents//memory`, at a layer or `emotion/` directory, at `index.sqlite3` + or at an entry file is still refused, also when swapped in later; the per-read check now + starts at the root, which halves the `lstat` calls of a read (#296). +- **Dependencies** — `strands-agents-tools` is held below 0.8.10 (`>=0.8.0,<0.8.10`): the + exec bridge refuses a release it was not audited against (0.8.9), so a fresh install + got 0.8.10 and no exec-class tools. Deliberate until a newer release is audited (#296). + - **Schedules** — Resume on `/schedules` with the default browser confirmation channel opens the confirmation (checkbox) dialog after the resume dialog instead of answering "Cancelled; nothing was applied": the shared dialog lets the pending request's own host confirmation open diff --git a/README.md b/README.md index 1057493..9d76561 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ yet. Known gaps on current `main`: hidden by default and have no sandbox or background execution yet. Even with `STIRA_ENABLE_EXEC_TOOLS=1` they are registered only when the installed `strands-agents-tools` is a release the exec bridge was audited against — today **0.8.9** only. `pyproject.toml` - allows `>=0.8.0`, so a fresh install can get a newer release (0.8.10 at the time of writing); - the log then says `Not registering shell: …`. Check with - `python -m pip show strands-agents-tools`, pin with - `python -m pip install 'strands-agents-tools==0.8.9'`. + therefore holds `strands-agents-tools` below 0.8.10 (`>=0.8.0,<0.8.10`), so a fresh install + gets 0.8.9; this pin is a deliberate safety choice until a newer release is audited. An older + or a manually upgraded release is refused for the exec class (the log says + `Not registering shell: …`). Check with `python -m pip show strands-agents-tools`. - **Emotion classification is basic.** With the default install the stimulus classifier is the rule-based one: its patterns are English and it detects almost nothing in Japanese. The Japanese Sokudan classifier needs the `jev` extra on Python 3.11 @@ -536,7 +536,7 @@ tests/ # test suite (offline by default) - Python 3.11+ (3.11 is the baseline on every OS; the `jev` extra needs 3.11) - strands-agents >= 1.57.1 -- strands-agents-tools >= 0.8.0 +- strands-agents-tools >= 0.8.0, < 0.8.10 (held at the release the exec bridge was audited against) - A model provider: Amazon Bedrock (default; AWS credentials) **or** any Strands provider without AWS — Ollama, llama.cpp, OpenAI, Anthropic, Gemini, … — see [Model providers](docs/providers.md) diff --git a/README_ja.md b/README_ja.md index 06b2d3f..9d3b957 100644 --- a/README_ja.md +++ b/README_ja.md @@ -18,7 +18,7 @@ StiraCore は**アルファ版**です。動きますが、未完成の部分が - **安定性の約束はありません。** バージョンは `0.1.0.dev0` です。Python API、設定キー、イベントやファイルの形式、データディレクトリ(`.local/stira/`)の構成は、移行コードなしにコミット間で変わることがあります([CHANGELOG](CHANGELOG.md))。 - **ローカル・単一ユーザー向けのサーバーです。** Web アプリにログインはありません。ループバックで待ち受け、`STIRA_ALLOW_REMOTE=1` がない限りそれ以外のアドレスを拒否します。1 つのデータディレクトリで動かせるサーバーは 1 つです([セキュリティモデル](#セキュリティモデル)、[運用 §1](docs/operations_ja.md#1-起動停止状態確認))。 - **目標グラント(自律)は実験的で、既定オフです。** Amazon Bedrock のみ、反復内のプロンプトキャッシュなし、費用は見守り付きの 1 回の実行からの推定だけ、グラント内の `run_checks` は macOS のみです([既知の制約](docs/autonomy_ja.md#4-既知の制約))。 -- **実行系ツール**(`shell`・`batch`・`cron`・`graph`・`swarm`・`think`・`use_agent`)は既定で非公開で、sandbox もバックグラウンド実行もまだありません。`STIRA_ENABLE_EXEC_TOOLS=1` にしても、導入済みの `strands-agents-tools` が実行系ブリッジの監査済みリリース(現在は **0.8.9** のみ)でなければ登録されません。`pyproject.toml` は `>=0.8.0` を許すので、新しく入れると新しいリリース(執筆時点で 0.8.10)が入ることがあり、そのときログに `Not registering shell: …` と出ます。確認は `python -m pip show strands-agents-tools`、固定は `python -m pip install 'strands-agents-tools==0.8.9'` です。 +- **実行系ツール**(`shell`・`batch`・`cron`・`graph`・`swarm`・`think`・`use_agent`)は既定で非公開で、sandbox もバックグラウンド実行もまだありません。`STIRA_ENABLE_EXEC_TOOLS=1` にしても、導入済みの `strands-agents-tools` が実行系ブリッジの監査済みリリース(現在は **0.8.9** のみ)でなければ登録されません。そのため `pyproject.toml` は `strands-agents-tools` を 0.8.10 未満(`>=0.8.0,<0.8.10`)に固定しており、新規インストールでは 0.8.9 が入ります。この固定は、新しいリリースを監査するまでの意図的な安全上の選択です。古いリリースや手動で上げたリリースでは実行系は登録されず、ログに `Not registering shell: …` と出ます。確認は `python -m pip show strands-agents-tools` です。 - **感情の分類は簡素です。** 既定のインストールでは刺激分類器はルールベースで、そのパターンは英語のため、日本語ではほとんど何も検出しません。日本語の Sokudan 分類器には Python 3.11 と `jev` extra が必要です([刺激分類器](#刺激分類器)、[日本語ルール分類器(任意)](docs/ja-rules-classifier_ja.md))。失敗・苛立ち・圧力の刺激カテゴリはまだありません。 - **一方向の長い対話では Neural State が飽和します。** stability は 0 に近づき、ブレーカーはほとんどのターンで CRITICAL(書き込みツールが制限される)になります。これは設計どおりで、調整できる設定は [neural-saturation_ja](docs/neural-saturation_ja.md) にあります。 - **記憶ページ**は、各層の最新 20 件を本文だけ表示する読み取り専用の一覧です。検索・編集はできず、重要度や保持期間も表示されません。手続き記憶の提案はこのページでレビューできますが、意味記憶の staging はコマンドライン(`stira_ctl.sh staging`)だけです。 @@ -315,7 +315,7 @@ tests/ # テスト(既定はオフライン) - Python **3.11 以上**(全 OS の基準は 3.11。`jev` extra は 3.11 が必要) - `strands-agents >= 1.57.1` -- `strands-agents-tools >= 0.8.0` +- `strands-agents-tools >= 0.8.0, < 0.8.10`(実行系ブリッジの監査済みリリースに固定) - モデルプロバイダ: Amazon Bedrock(既定。AWS 認証が必要)**または** AWS 不要の Strands プロバイダ(Ollama / llama.cpp / OpenAI / Anthropic / Gemini など)。[モデルプロバイダ](docs/providers_ja.md)を参照 - Web アプリには Flask(`dashboard` または `dev` extra) - Sokudan 分類器を使う場合は **Python 3.11** と `jev` extra diff --git a/conftest.py b/conftest.py index 788ab6d..83bc458 100644 --- a/conftest.py +++ b/conftest.py @@ -124,6 +124,78 @@ def _yolo_off_unless_asked(monkeypatch): yield +# --- YOLO clocks off macOS: the runtime gate sees a measured clock (#296) --------------- +# ADR 0012 §7 measured the clocks on macOS only, so the product refuses a YOLO window +# elsewhere (``clock_not_measured``). The window logic itself is platform independent: +# off macOS the tests see the runtime gate (``stiracore.yolo.runtime.clock_support``) as +# measured, and the readings stay the product's own fallback (``host_ops.default_clock``: +# wall + ``time.monotonic``). Nothing changes on macOS. The refusal itself stays covered +# with explicit platforms / ``clocks_supported=False`` (test_yolo_allowance, +# test_yolo_requests); a test that needs the real gate uses ``@pytest.mark.real_yolo_clock``. + + +@pytest.fixture(autouse=True) +def _yolo_clock_gate_measured_off_macos(request, monkeypatch): + try: + from stiracore.yolo import allowance, runtime + except ImportError: # pragma: no cover + yield + return + real = allowance.clock_support() + if not real.supported and request.node.get_closest_marker("real_yolo_clock") is None: + measured = allowance.ClockSupport(True, None, real.platform) + monkeypatch.setattr(runtime, "clock_support", lambda: measured) + yield + + +# --- Server apps built by a test are stopped and closed after it (#296) ---------------- +# ``create_app`` registers its stops with ``atexit`` (one app per process in production) +# and holds ``server.lock`` until the process exits. A test process builds hundreds of +# apps: each kept its SQLite connections (with -wal/-shm) and its lock descriptor open, +# ~14 descriptors per app, which exhausted the macOS runner's limit of 256 (EMFILE). +# After each test the app ``atexit`` stops it registered (``stiracore`` / ``dashboard`` +# functions only) run (idempotent) and are removed, +# and the data-directory locks it took are released. Higher-scoped fixtures are untouched. + + +@pytest.fixture(autouse=True) +def _close_what_the_test_built(monkeypatch): + import atexit + import gc + + from stiracore import owner_lock + + registered, locks = [], [] + real_register, real_acquire = atexit.register, owner_lock.OwnerLockFile.acquire + + def register(function, *args, **kwargs): + # Only the app's own stops: pytest's (tmp_path cleanup) and others stay at exit. + if getattr(function, "__module__", "").startswith( + ("stiracore", "dashboard", "stira_server") + ): + registered.append(function) + return real_register(function, *args, **kwargs) + + def acquire(self, *args, **kwargs): + result = real_acquire(self, *args, **kwargs) + locks.append(self) + return result + + monkeypatch.setattr(atexit, "register", register) + monkeypatch.setattr(owner_lock.OwnerLockFile, "acquire", acquire) + yield + for function in reversed(registered): + atexit.unregister(function) + with contextlib.suppress(Exception): + function() + for lock in locks: + if lock.held: + with contextlib.suppress(Exception): + lock.release() + if registered or locks: + gc.collect() + + # --- YOLO host dialog: never on the developer's screen (ADR 0012 §8b) ------------------ # A test that builds the server with STIRA_YOLO_ALLOW would otherwise check for, and # show, the macOS host-confirmation dialog. Every osascript call answers "no" here (no diff --git a/docs/deploy-ec2.md b/docs/deploy-ec2.md index a87e62c..0a2aa6f 100644 --- a/docs/deploy-ec2.md +++ b/docs/deploy-ec2.md @@ -79,11 +79,13 @@ from view, it keeps its lock and databases open on the old file system, and a se started on the new, empty directory ran at the same time (the data-directory lock no longer excluded it). An existing installation is moved with the steps in §6. -The checkout and `.local/stira` must be real directories: **a symbolic link anywhere on the -data path stops the server** (measured: with `.local/stira` as a symlink to another -directory the server dies at start-up with `MemorySymlinkError: Memory paths must not -contain symbolic links.` and a traceback). To put the data on its own volume, mount it at -that path ([§6](#6-data-backup-and-restore)). +**A symbolic link at or below the memory directory (`.local/stira/memory`) stops the +server** at start-up with `MemorySymlinkError: Memory paths must not contain symbolic +links.` and a traceback. The directories above it are the operator's choice and are +resolved once at start (#296); before that change a symlinked `.local/stira` stopped the +server too (measured on this guide's instance; the new behaviour was not re-measured on +EC2). To put the data on its own volume, mount it at that path +([§6](#6-data-backup-and-restore)). ## 3. Reaching the server without opening it @@ -677,7 +679,7 @@ Run on 2026-10-18 (Ubuntu 24.04 aarch64 VM, systemd 255, Python 3.12.3, the serv | Flush before the snapshot; migration and `environ` check as a normal administrator | `sync -f` (exit 0 / 1 for a bad path), `fsfreeze -f/-u`, `cd` into `0700` `.local`, `sudo tr < …/environ` vs `sudo cat` | `sync -f` and the absolute-path forms work; the redirect forms are refused | | Volume mounted over a running server | start, then `mount` | files hidden, old file system still open, a second server ran: mount before the first start; moving existing data (copy + `diff`) works | | Anchor drop-in without `daemon-reload` | add drop-in to a running unit, `restart` | warning, variable and state directory absent; with `daemon-reload` both present | -| Symlinked data directory | symlink as `.local/stira` | server dies: `MemorySymlinkError` | +| Symlinked data directory (before #296) | symlink as `.local/stira` | server dies: `MemorySymlinkError` (since #296 only a link at or below `.local/stira/memory`) | | Restore lock | `stira tool-rules lock` / `status` as `stira` | `locked (restored_backup)`, status exit 1 | | Rule-rollback anchor with `StateDirectory=` | `init-anchor`, then data directory wiped | `LOCKED (anchor_rollback)`; variable set before the anchor exists → `anchor_missing` | | `smoke_server.py` as `stira` | run | `smoke: PASS in 1.0s` | diff --git a/docs/deploy-ec2_ja.md b/docs/deploy-ec2_ja.md index 6bbd64d..87bab04 100644 --- a/docs/deploy-ec2_ja.md +++ b/docs/deploy-ec2_ja.md @@ -71,10 +71,11 @@ sudo install -d -o stira -g stira -m 0700 /opt/stira/StiraCore/.local /opt/stira サーバーは古いファイルシステム上でロックとデータベースを開いたままで、新しい空のディレクトリで起動した 2 台目のサーバーが同時に 動きました(データディレクトリのロックでは排除されなくなる)。既存の環境を移す手順は §6 にあります。 -チェックアウトと `.local/stira` は実ディレクトリでなければなりません。**データのパスのどこかにシンボリックリンクがあると -サーバーは止まります**(実測: `.local/stira` を別ディレクトリへのシンボリックリンクにすると、起動時に -`MemorySymlinkError: Memory paths must not contain symbolic links.` とトレースバックを出して終了)。データを専用ボリュームに -置くには、そのパスにマウントします([§6](#6-データバックアップ復元))。 +**メモリのディレクトリ(`.local/stira/memory`)またはその下にシンボリックリンクがあるとサーバーは止まります** +(起動時に `MemorySymlinkError: Memory paths must not contain symbolic links.` とトレースバックを出して終了)。 +それより上のディレクトリは運用者の選択で、起動時に一度だけ実パスに解決されます(#296)。この変更の前は `.local/stira` +自体がシンボリックリンクでも止まりました(このガイドのインスタンスで実測。新しい挙動は EC2 では再測定していません)。 +データを専用ボリュームに置くには、そのパスにマウントします([§6](#6-データバックアップ復元))。 ## 3. サーバーを開けずに使う @@ -590,7 +591,7 @@ AWS 上のものは**実施していません**。 | スナップショット前のフラッシュ。通常の管理者としての移行と `environ` の確認 | `sync -f`(終了コード 0 / 不正パスで 1)、`fsfreeze -f/-u`、`0700` の `.local` への `cd`、`sudo tr < …/environ` と `sudo cat` | `sync -f` と絶対パスの形は動き、リダイレクトの形は拒否される | | 稼働中のサーバーの上にボリュームをマウント | 起動してから `mount` | ファイルが見えなくなり、古いファイルシステムが開いたままで 2 台目が動いた: 最初の起動前にマウント。既存データの移動(コピー + `diff`)は動く | | `daemon-reload` なしのアンカーのドロップイン | 稼働中のユニットにドロップインを足し `restart` | 警告が出て、変数も状態ディレクトリも無い。`daemon-reload` すると両方ある | -| シンボリックリンクのデータディレクトリ | `.local/stira` をシンボリックリンクに | サーバーが停止: `MemorySymlinkError` | +| シンボリックリンクのデータディレクトリ(#296 以前) | `.local/stira` をシンボリックリンクに | サーバーが停止: `MemorySymlinkError`(#296 以降は `.local/stira/memory` 以下のリンクのみ) | | 復元時のロック | `stira` として `stira tool-rules lock` / `status` | `locked (restored_backup)`、終了コード 1 | | `StateDirectory=` を使ったルール巻き戻し防止のアンカー | `init-anchor`、その後データディレクトリを消去 | `LOCKED (anchor_rollback)`。アンカー作成前に変数を設定 → `anchor_missing` | | `stira` としての `smoke_server.py` | 実行 | `smoke: PASS in 1.0s` | diff --git a/docs/install.md b/docs/install.md index c0600cd..7a946f7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -220,7 +220,8 @@ Sources: [tool audit](tool-audit.md), current and upstream `strands-agents-tools` 0.8.9 dependency/source inspection. The audit is a catalog decision record, **not an OS test matrix**. Entries below describe expected native-OS support, not successful end-to-end execution on every OS. -Dependency versions can change because installation is not pinned. +`strands-agents-tools` is held below 0.8.10 (`pyproject.toml`, the audited 0.8.9); other +dependency versions are not pinned and can change. - ✅ = registered candidate with no known OS-specific blocker in this inspection; still requires successful dependency imports, permissions, and any service setup. diff --git a/docs/install_ja.md b/docs/install_ja.md index 343fb21..fecd6e6 100644 --- a/docs/install_ja.md +++ b/docs/install_ja.md @@ -219,8 +219,8 @@ py -3.11 -m venv .venv311 [`READ_TOOLS` / `WRITE_TOOLS` registry](../src/stiracore/tools/tool_registry.py)、 および上流 `strands-agents-tools` 0.8.9 の依存・ソース確認です。棚卸しは採否の記録であり、 **OS ごとの動作試験結果ではありません**。以下はネイティブ OS で期待される対応状況で、 -全 OS でのエンドツーエンド実行成功を示しません。インストールはバージョン固定ではないため、 -依存の更新で変わる場合があります。 +全 OS でのエンドツーエンド実行成功を示しません。`strands-agents-tools` は 0.8.10 未満(監査済みの 0.8.9)に +固定していますが、他の依存は固定していないため更新で変わる場合があります。 - ✅ = 登録候補で、今回の確認では OS 固有の障害が見つかっていないもの。 依存の import 成功、権限、サービス設定などは別途必要です。 diff --git a/docs/tool-execution-paths.md b/docs/tool-execution-paths.md index a48c9aa..005bb90 100644 --- a/docs/tool-execution-paths.md +++ b/docs/tool-execution-paths.md @@ -102,7 +102,8 @@ roots; they have their own boundary, set by the host: `memory_tools.memory_write(layer, key, content)` → only `episodic` (other layers: "denied") → `MemoryStore.write` → `_path`: `key` must be a relative POSIX path without `.`/`..`/`\`/NUL, under the host-configured `memory_dir//`, no symlinked -component, resolved inside `memory_dir` → `_require_writable` (floor: SOUL/IPD and the +component at or below the store root (its ancestors are resolved once when the store is +built, #296), resolved inside `memory_dir` → `_require_writable` (floor: SOUL/IPD and the store's `read_only_paths`) → symlink re-check → `mkdir(parents)` below `memory_dir/` → resolved parent inside `memory_dir` → `NamedTemporaryFile` in that same parent → `os.replace` onto the checked path. So the temp file, the rename and the diff --git a/pyproject.toml b/pyproject.toml index 7575b7a..2e37167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,9 @@ authors = [ dependencies = [ "strands-agents>=1.57.1", - "strands-agents-tools>=0.8.0", + # Held below 0.8.10: the exec bridge refuses a release it was not audited against + # (tool_consent.VERIFIED_EXEC_UPSTREAM = 0.8.9). Raise only together with an audit. + "strands-agents-tools>=0.8.0,<0.8.10", "requests>=2.32", # web_tools stops a body read at its deadline with HTTPResponse.shutdown (2.3+, #601). "urllib3>=2.3", @@ -107,4 +109,5 @@ pythonpath = [".", "src"] timeout = 120 markers = [ "live: opt-in smoke against a real model provider (needs STIRA_LIVE_MODEL)", + "real_yolo_clock: keep the real YOLO clock gate off macOS (conftest.py, ADR 0012 §7)", ] diff --git a/scripts/eval_associative_recall.py b/scripts/eval_associative_recall.py index b6e9b27..57cd4a9 100755 --- a/scripts/eval_associative_recall.py +++ b/scripts/eval_associative_recall.py @@ -145,7 +145,8 @@ def evaluate() -> dict[str, Any]: """Run the fixture; returns rates per (state, ON/OFF) and per query (keys only).""" topics = {key: topic for key, topic, _text in EPISODES} with tempfile.TemporaryDirectory() as tmp: - store = build_store(Path(tmp)) + # the real path: the memory store refuses a symlink anywhere in its path (macOS /var) + store = build_store(Path(os.path.realpath(tmp))) out: dict[str, Any] = {"episodes": len(EPISODES), "queries": len(QUERIES)} for state_name, values in STATES.items(): state = _state(values) diff --git a/scripts/eval_emotion_proximity.py b/scripts/eval_emotion_proximity.py index 364796e..a4cabf1 100644 --- a/scripts/eval_emotion_proximity.py +++ b/scripts/eval_emotion_proximity.py @@ -477,7 +477,8 @@ def counting(*args, **kwargs): def evaluate(*, with_cost: bool = False) -> dict: """Both fixture versions (prereg §3 and "Changes and deviations"), and the cost.""" with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) + # the real path: the memory store refuses a symlink anywhere in its path (macOS /var) + root = Path(os.path.realpath(tmp)) result = { "v1": _evaluate_store(*build_store(root)), "v2_hard": _evaluate_store(*build_store(root, hard=True)), diff --git a/scripts/smoke_server.py b/scripts/smoke_server.py index 8daa1f2..3425f36 100644 --- a/scripts/smoke_server.py +++ b/scripts/smoke_server.py @@ -258,7 +258,9 @@ def run_smoke(port: int = 0, timeout: float = 120.0) -> Result: started = time.monotonic() deadline = started + timeout port = port or free_port() - scratch = Path(tempfile.mkdtemp(prefix="stira-smoke-")) + # The real path: on macOS the temp root /var/folders is reached through the /var -> /private/var + # link, and the memory store refuses any symbolic link in its path (MemoryStore._reject_symlinks). + scratch = Path(os.path.realpath(tempfile.mkdtemp(prefix="stira-smoke-"))) result = Result(port=port, scratch=scratch, status={path: 0 for path in PATHS}) data_dir = scratch / "data" for sub in ("home", "tmp", "no-aws"): diff --git a/src/stiracore/tools/episodic_emotion.py b/src/stiracore/tools/episodic_emotion.py index 4f6315b..da77735 100644 --- a/src/stiracore/tools/episodic_emotion.py +++ b/src/stiracore/tools/episodic_emotion.py @@ -297,8 +297,13 @@ def delete_emotion_snapshot(store: MemoryStore, key: str) -> bool: return True -def _read_record(path: Path) -> dict[str, Any] | None: - MemoryStore._reject_symlinks(path.parent) +def _store_root(store: MemoryStore) -> Path | None: + """The resolved root of a real store (its ancestors were resolved when it was built).""" + return store.base_dir if isinstance(store, MemoryStore) else None + + +def _read_record(path: Path, root: Path | None = None) -> dict[str, Any] | None: + MemoryStore._reject_symlinks(path.parent, root=root) if not _NOFOLLOW: # Windows has no O_NOFOLLOW: refuse a link by lstat first (best effort, racy). try: @@ -336,7 +341,7 @@ def read_emotion_snapshot(store: MemoryStore, entry: MemoryEntry) -> EmotionSnap metadata = entry.metadata if isinstance(entry.metadata, Mapping) else {} if entry.layer is not MemoryLayer.EPISODIC or metadata.get("format") != FORMAT_V2: return empty_snapshot("v1") - record = _read_record(emotion_record_path(store, entry.key)) + record = _read_record(emotion_record_path(store, entry.key), _store_root(store)) if record is None or record.get("schema") != RECORD_SCHEMA: return empty_snapshot("missing") if record.get("key") != entry.key or record.get("content_sha256") != _digest(entry.content): @@ -390,7 +395,7 @@ def find_orphan_records(store: MemoryStore, *, now: float | None = None) -> list orphans.append(OrphanRecord(name, "unreadable")) # a link or a special file continue try: - record = _read_record(path) + record = _read_record(path, _store_root(store)) except (OSError, ValueError): record = None key = record.get("key") if isinstance(record, dict) else None diff --git a/src/stiracore/tools/memory_store.py b/src/stiracore/tools/memory_store.py index 198a831..97c3e38 100644 --- a/src/stiracore/tools/memory_store.py +++ b/src/stiracore/tools/memory_store.py @@ -59,23 +59,27 @@ class MemoryStore: def __init__(self, base_dir: Path, agent_id: str, read_only_paths: set[str] | None = None): self._validate_component(agent_id, "agent_id") - base_path = Path(base_dir) + base_path = Path(base_dir).absolute() + # The ancestors of the configured root are the operator's choice (macOS /tmp and + # /var are links, a home or project folder may be one): they are resolved once + # here. A link at the root itself or anywhere below it is still refused (#296). + base_path = base_path.parent.resolve() / base_path.name self._reject_symlinks(base_path) self.base_dir = base_path.resolve() self.agent_id = agent_id self.memory_dir = self.base_dir / "agents" / agent_id / "memory" self.read_only_paths: set[str] = set(DEFAULT_READ_ONLY_PATHS) | set(read_only_paths or ()) - self._reject_symlinks(self.memory_dir) + self._reject_links(self.memory_dir) for layer in MemoryLayer: directory = self.memory_dir / layer.value - self._reject_symlinks(directory) + self._reject_links(directory) directory.mkdir(parents=True, exist_ok=True) - self._reject_symlinks(directory) + self._reject_links(directory) if not directory.resolve().is_relative_to(self.base_dir): raise ValueError("Memory directories must remain inside base_dir.") self.index_path = self.memory_dir / "index.sqlite3" - self._reject_symlinks(self.index_path) + self._reject_links(self.index_path) with self._connection() as connection: connection.execute( """ @@ -102,13 +106,25 @@ def __init__(self, base_dir: Path, agent_id: str, read_only_paths: set[str] | No ) @staticmethod - def _reject_symlinks(path: Path) -> None: - """Reject symbolic links in the path itself or any parent component.""" + def _reject_symlinks(path: Path, *, root: Path | None = None) -> None: + """Reject symbolic links in the path itself or any parent component. + + With ``root`` (a store's resolved ``base_dir``) and a ``path`` lexically inside it, + only ``root`` itself and the components below it are checked: the ancestors were + resolved once when the store was built (the operator's choice). Any other path + (outside ``root``, or with a ``..``) is checked from the filesystem root.""" path = Path(path).absolute() - for component in (*reversed(path.parents), path): + components = (*reversed(path.parents), path) + if root is not None and ".." not in path.parts and path.is_relative_to(root): + components = components[len(root.parents) :] + for component in components: if component.is_symlink(): raise MemorySymlinkError("Memory paths must not contain symbolic links.") + def _reject_links(self, path: Path) -> None: + """:meth:`_reject_symlinks` for a path of this store: from ``base_dir`` down.""" + self._reject_symlinks(path, root=self.base_dir) + @staticmethod def _validate_component(value: str, name: str) -> None: if ( @@ -134,14 +150,14 @@ def _path(self, layer: MemoryLayer, key: str) -> Path: parts = self._validate_key(key) directory = self.memory_dir / layer.value path = directory.joinpath(*parts[:-1], f"{parts[-1]}.md") - self._reject_symlinks(path) + self._reject_links(path) if not path.resolve().is_relative_to(self.memory_dir.resolve()): raise ValueError("Memory entries must remain inside the memory directory.") return path @contextmanager def _connection(self) -> Iterator[sqlite3.Connection]: - self._reject_symlinks(self.index_path) + self._reject_links(self.index_path) connection = sqlite3.connect(self.index_path, timeout=30) try: with connection: @@ -232,7 +248,7 @@ def _open_entry(self, path: Path): raise try: opened = os.fstat(descriptor) - self._reject_symlinks(path) # a parent swapped before the open + self._reject_links(path) # a parent swapped before the open now = os.stat(path) if not stat.S_ISREG(opened.st_mode) or (now.st_dev, now.st_ino) != ( opened.st_dev, @@ -344,9 +360,9 @@ def write_text( self._require_writable(path) self._parse(text) # invalid frontmatter is refused before anything is replaced - self._reject_symlinks(path.parent) + self._reject_links(path.parent) path.parent.mkdir(parents=True, exist_ok=True) - self._reject_symlinks(path.parent) + self._reject_links(path.parent) if not path.parent.resolve().is_relative_to(self.memory_dir.resolve()): raise ValueError("Memory entries must remain inside the memory directory.") @@ -437,11 +453,11 @@ def _read_unless_broken(self, layer: MemoryLayer, key: str) -> MemoryEntry | Non def _entry_keys(self, layer: MemoryLayer) -> Iterator[str]: """Keys of the regular ``*.md`` entries of ``layer``; no file is opened.""" directory = self.memory_dir / layer.value - self._reject_symlinks(directory) + self._reject_links(directory) for path in sorted(directory.rglob("*.md")): if path.is_symlink() or not path.is_file(): continue - self._reject_symlinks(path) + self._reject_links(path) yield path.relative_to(directory).with_suffix("").as_posix() def count_entries(self, layer: MemoryLayer, *, exclude_prefix: str | None = None) -> int: diff --git a/tests/breaker_reasons_test.cjs b/tests/breaker_reasons_test.cjs index a518b88..ffbc192 100644 --- a/tests/breaker_reasons_test.cjs +++ b/tests/breaker_reasons_test.cjs @@ -4,6 +4,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..'); const words = require(path.join(ROOT, 'dashboard', 'static', 'js', 'breaker_reasons.js')); @@ -58,7 +59,7 @@ test('no code reaches the screen, in either language', () => { }); test('the Stage adapter forwards codes and numbers only', async () => { - const { fromTurnEvent } = await import(path.join(ROOT, 'dashboard', 'stage', 'js', 'adapter.js')); + const { fromTurnEvent } = await import(pathToFileURL(path.join(ROOT, 'dashboard', 'stage', 'js', 'adapter.js')).href); const [ev] = fromTurnEvent({ type: 'breaker.transition', turn_id: 't', seq: 2, data: { from: 'normal', to: 'warning', reasons: ['stability_below_warning', 'Has Text!'], diff --git a/tests/chat_new_session_test.cjs b/tests/chat_new_session_test.cjs index c4192f4..381b042 100644 --- a/tests/chat_new_session_test.cjs +++ b/tests/chat_new_session_test.cjs @@ -12,7 +12,8 @@ const ja = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/cat const en = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')); const tja = (key, vars) => String(ja[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => (vars && k in vars ? vars[k] : `{${k}}`)); const t = (key, vars) => (vars ? `${key}(${JSON.stringify(vars)})` : key); -const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +// LF only: a Windows checkout (core.autocrlf) has CRLF line ends +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n'); const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body }); function fakeFetch(outcomes) { diff --git a/tests/stage_activity_ctl_test.cjs b/tests/stage_activity_ctl_test.cjs index 1435441..13e0bdc 100644 --- a/tests/stage_activity_ctl_test.cjs +++ b/tests/stage_activity_ctl_test.cjs @@ -7,6 +7,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); let JSDOM = null; try { @@ -16,7 +17,7 @@ try { } const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const Poll = require(path.join(ROOT, 'dashboard', 'static', 'js', 'poll.js')); const CAT = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')); const t = (key, vars = {}) => { diff --git a/tests/stage_activity_test.cjs b/tests/stage_activity_test.cjs index 1bf61fe..9ddc143 100644 --- a/tests/stage_activity_test.cjs +++ b/tests/stage_activity_test.cjs @@ -5,6 +5,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); let JSDOM = null; try { @@ -14,7 +15,7 @@ try { } const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const CAT = { ja: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')), en: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')), diff --git a/tests/stage_adapter_test.cjs b/tests/stage_adapter_test.cjs index 5b15aec..f70894e 100644 --- a/tests/stage_adapter_test.cjs +++ b/tests/stage_adapter_test.cjs @@ -10,10 +10,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const TURNS = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'stage_turn_events.json'), 'utf8')); const PENDING = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'stage_hitl_pending.json'), 'utf8')); const turnWith = (name) => TURNS.find((t) => t.events.some((e) => e.data && e.data.name === name)); diff --git a/tests/stage_art_lint_test.cjs b/tests/stage_art_lint_test.cjs index a42cff2..a615318 100644 --- a/tests/stage_art_lint_test.cjs +++ b/tests/stage_art_lint_test.cjs @@ -9,12 +9,13 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const { decodePng } = require('./stage_png.cjs'); const ROOT = process.env.STAGE_ROOT || path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage'); const CHAR = path.join(STAGE, 'assets', 'char'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const readJson = (name) => JSON.parse(fs.readFileSync(path.join(CHAR, name), 'utf8')); const png = (name) => decodePng(fs.readFileSync(path.join(CHAR, name))); const REPORT = process.env.STAGE_LINT_REPORT === '1'; diff --git a/tests/stage_back_view_test.cjs b/tests/stage_back_view_test.cjs index a2b1f4c..97b60c2 100644 --- a/tests/stage_back_view_test.cjs +++ b/tests/stage_back_view_test.cjs @@ -7,12 +7,13 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const crypto = require('node:crypto'); const { decodePng } = require('./stage_png.cjs'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage'); const FIXTURE = path.join(__dirname, 'fixtures', 'stage_front_frames.json'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const readJson = (...p) => JSON.parse(fs.readFileSync(path.join(STAGE, ...p), 'utf8')); const CALM = { emotion: 'calm', eyes: 'open', mouth: 'neutral', headY: 0, ears: 0, blush: 0, sweat: 0 }; const hash = (px) => crypto.createHash('sha1').update(px).digest('hex').slice(0, 16); diff --git a/tests/stage_bg_test.cjs b/tests/stage_bg_test.cjs index c89241e..789785d 100644 --- a/tests/stage_bg_test.cjs +++ b/tests/stage_bg_test.cjs @@ -5,9 +5,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(ROOT, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(ROOT, 'js', name)).href); // The page's asset allow-list (src/stiracore/observability/stage.py, _ASSETS) const ALLOWED = /^assets\/(?!source\/)[a-z0-9_-]+\/[a-z0-9_-]+\.(?:png|json)$/; diff --git a/tests/stage_board_audit_test.cjs b/tests/stage_board_audit_test.cjs index 8f629cf..c85fe50 100644 --- a/tests/stage_board_audit_test.cjs +++ b/tests/stage_board_audit_test.cjs @@ -6,6 +6,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); let JSDOM = null; let axeSource = null; @@ -17,7 +18,7 @@ try { } const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const Poll = require(path.join(ROOT, 'dashboard', 'static', 'js', 'poll.js')); const CAT = { ja: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')), diff --git a/tests/stage_board_claim_test.cjs b/tests/stage_board_claim_test.cjs index 7d3913b..754b622 100644 --- a/tests/stage_board_claim_test.cjs +++ b/tests/stage_board_claim_test.cjs @@ -12,6 +12,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const { execFileSync } = require('node:child_process'); let JSDOM = null; @@ -24,7 +25,7 @@ try { } const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const CAT = { ja: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')), en: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')), @@ -247,7 +248,7 @@ test('no claim: the grant panel is the one of before W6c', { skip: JSDOM ? false const old = path.join(dir, 'activity.mjs'); fs.writeFileSync(old, before); for (const dep of fs.readdirSync(STAGE)) if (dep !== 'activity.js') fs.copyFileSync(path.join(STAGE, dep), path.join(dir, dep)); - const A0 = await import(old); + const A0 = await import(pathToFileURL(old).href); const A = await load('activity.js'); const t = tr('ja'); const dom = new JSDOM(BOARD_HTML); diff --git a/tests/stage_board_grant_test.cjs b/tests/stage_board_grant_test.cjs index bc418e2..2e8abfc 100644 --- a/tests/stage_board_grant_test.cjs +++ b/tests/stage_board_grant_test.cjs @@ -6,6 +6,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); let JSDOM = null; try { @@ -15,7 +16,7 @@ try { } const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const CAT = { ja: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')), en: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')), diff --git a/tests/stage_board_test.cjs b/tests/stage_board_test.cjs index e5c44ea..f1c6327 100644 --- a/tests/stage_board_test.cjs +++ b/tests/stage_board_test.cjs @@ -6,29 +6,41 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const core = require('../dashboard/static/js/chat_core.js'); -const SAMPLES = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/research/stage-main-ui-2026-10-17/samples-real.json'), 'utf8')).samples; +// Real model replies (research data). docs/research/ is not in the public snapshot +// (release-exclude.txt): there the checks on them are skipped with this reason, the synthetic +// cases still run. +const SAMPLES_FILE = 'docs/research/stage-main-ui-2026-10-17/samples-real.json'; +const SAMPLES = fs.existsSync(path.join(ROOT, SAMPLES_FILE)) + ? JSON.parse(fs.readFileSync(path.join(ROOT, SAMPLES_FILE), 'utf8')).samples + : null; +const NO_SAMPLES = `${SAMPLES_FILE} is not in this checkout (research data, left out of the public snapshot)`; +// A subtest on the real replies, skipped with the reason when they are absent. +const withSamples = (ctx, name, fn) => ctx.test(name, { skip: SAMPLES ? false : NO_SAMPLES }, () => fn(SAMPLES)); const ja = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')); const en = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')); const t = (key, vars = {}) => String(ja[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => String(vars[k])); // --- the rule --------------------------------------------------------------------------------- -test('classify: the design table (docs/stage-main-ui.md §5.2), real and synthetic replies', async () => { +test('classify: the design table (docs/stage-main-ui.md §5.2), real and synthetic replies', async (ctx) => { const { classify } = await load('markdown.js'); - const real = SAMPLES.find((s) => s.id === 'real-1'); - const c = classify(real.text); - assert.equal(c.mode, 'board'); - assert.deepEqual(c.counts.code, 3); - assert.deepEqual(c.counts.table, 1); - assert.deepEqual(c.counts.heading, 4); - assert.ok(c.lead && c.lead.startsWith('コーヒーのついでに'), 'the first short paragraph is the lead'); - assert.ok(c.blocks.length && c.blocks[0].t !== 'p' || c.blocks[0].rows, 'the board holds the rest'); - const short = SAMPLES.find((s) => s.id === 'real-2'); - if (short) assert.equal(classify(short.text).mode, 'line', short.text); + await withSamples(ctx, 'the real replies', (samples) => { + const real = samples.find((s) => s.id === 'real-1'); + const c = classify(real.text); + assert.equal(c.mode, 'board'); + assert.deepEqual(c.counts.code, 3); + assert.deepEqual(c.counts.table, 1); + assert.deepEqual(c.counts.heading, 4); + assert.ok(c.lead && c.lead.startsWith('コーヒーのついでに'), 'the first short paragraph is the lead'); + assert.ok(c.blocks.length && c.blocks[0].t !== 'p' || c.blocks[0].rows, 'the board holds the rest'); + const short = samples.find((s) => s.id === 'real-2'); + if (short) assert.equal(classify(short.text).mode, 'line', short.text); + }); const cases = [ ['こんにちは。今日もよろしくお願いします。', 'line', []], ['詳しくは https://example.com を見てください。', 'line', []], @@ -55,12 +67,14 @@ test('classify: the design table (docs/stage-main-ui.md §5.2), real and synthet } }); -test('classify is deterministic and independent of anything but the text', async () => { +test('classify is deterministic and independent of anything but the text', async (ctx) => { const { classify } = await load('markdown.js'); - for (const s of SAMPLES) { - const a = JSON.stringify(classify(s.text)); - for (let i = 0; i < 3; i += 1) assert.equal(JSON.stringify(classify(s.text)), a); - } + await withSamples(ctx, 'the real replies', (samples) => { + for (const s of samples) { + const a = JSON.stringify(classify(s.text)); + for (let i = 0; i < 3; i += 1) assert.equal(JSON.stringify(classify(s.text)), a); + } + }); // a seeded random corpus: the same input, the same answer let seed = 7; const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31); @@ -266,11 +280,13 @@ test('renderer: tables ruled and focusable for sideways scroll; headings start a assert.deepEqual([...headingLevels([{ t: 'h', level: 6 }, { t: 'h', level: 2 }])], [[2, 3], [6, 4]]); }); -test('highlighter: tokens join back to the code, for every language and the real replies', async () => { +test('highlighter: tokens join back to the code, for every language and the real replies', async (ctx) => { const { highlight, parse } = await load('markdown.js'); - const codes = SAMPLES.flatMap((s) => parse(s.text).filter((b) => b.t === 'code')); - assert.ok(codes.length >= 3); - for (const b of codes) assert.equal(highlight(b.text, b.lang).map((x) => x.v).join(''), b.text); + await withSamples(ctx, 'the real replies', (samples) => { + const codes = samples.flatMap((s) => parse(s.text).filter((b) => b.t === 'code')); + assert.ok(codes.length >= 3); + for (const b of codes) assert.equal(highlight(b.text, b.lang).map((x) => x.v).join(''), b.text); + }); const py = highlight('def f(x):\n return "a" + 1 # c', 'python'); const kinds = Object.fromEntries(py.filter((x) => x.k !== 'text').map((x) => [x.v.trim(), x.k])); assert.equal(kinds.def, 'kw'); @@ -323,7 +339,7 @@ test('board progress: 270 ms ease-out down and up; reduced motion jumps', async assert.equal(stepProgress(0.5, 0, 1), 0); }); -test('board actions: replace when not pinned, hold when pinned, update the reply in flight', async () => { +test('board actions: replace when not pinned, hold when pinned, update the reply in flight', async (ctx) => { const { boardAction, boardStats, boardTitle } = await load('board.js'); const { classify } = await load('markdown.js'); assert.equal(boardAction({ pinned: false, open: false, currentKey: null, key: 'a' }), 'open'); @@ -332,8 +348,10 @@ test('board actions: replace when not pinned, hold when pinned, update the reply assert.equal(boardAction({ pinned: true, open: true, currentKey: 'a', key: 'b', explicit: true }), 'replace'); assert.equal(boardAction({ pinned: true, open: true, currentKey: 'a', key: 'a' }), 'update'); assert.equal(boardAction({ pinned: false, open: false, currentKey: 'a', key: 'a' }), 'keep'); - const c = classify(SAMPLES[0].text); - assert.equal(boardStats(c, t), `見出し 4 · コード 3 · 表 1 · ${c.counts.visible} 字`); + await withSamples(ctx, 'board stats of a real reply', (samples) => { + const c = classify(samples[0].text); + assert.equal(boardStats(c, t), `見出し 4 · コード 3 · 表 1 · ${c.counts.visible} 字`); + }); assert.equal(boardTitle(classify('## タイトル\n\n本文'), t), 'タイトル'); assert.equal(boardTitle(classify('```\nx\n```'), t), ja['stage.board_title']); }); @@ -615,7 +633,7 @@ test('languages: inherited names (constructor, __proto__, toString) are not lang assert.equal(langOf('ts'), 'js'); }); -test('the end of a reply is classified from scratch: the board rolls up when the final text is a line', async () => { +test('the end of a reply is classified from scratch: the board rolls up when the final text is a line', async (ctx) => { const { classify, classifyStream } = await load('markdown.js'); const { replyView } = await load('talk.js'); const { finalBoardAction } = await load('board.js'); @@ -632,9 +650,12 @@ test('the end of a reply is classified from scratch: the board rolls up when the assert.equal(finalBoardAction({ finalMode: 'line', boardKey: 'other', key: 'k', open: true, pinned: false }), 'keep'); assert.equal(finalBoardAction({ finalMode: 'board', boardKey: 'k', key: 'k', open: true, pinned: false }), 'keep'); // for every real and synthetic reply the end result equals the log's - for (const s of [...SAMPLES.map((x) => x.text), HOSTILE, prefix, final, '1\n2\n3\n4\n5', '- a\n- b']) { + for (const s of [HOSTILE, prefix, final, '1\n2\n3\n4\n5', '- a\n- b']) { assert.equal(replyView(s, null, t).mode, classify(s).mode); } + await withSamples(ctx, 'the real replies', (samples) => { + for (const s of samples) assert.equal(replyView(s.text, null, t).mode, classify(s.text).mode); + }); }); test('/new: refused with images, any case, surrounding spaces; the shared sender refuses it too', async () => { diff --git a/tests/stage_board_tick_test.cjs b/tests/stage_board_tick_test.cjs index a951687..e1371bf 100644 --- a/tests/stage_board_tick_test.cjs +++ b/tests/stage_board_tick_test.cjs @@ -4,10 +4,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const vm = require('node:vm'); const ROOT = path.join(__dirname, '..'); -const load = (name) => import(path.join(ROOT, 'dashboard', 'stage', 'js', name)); +const load = (name) => import(pathToFileURL(path.join(ROOT, 'dashboard', 'stage', 'js', name)).href); const CAT = { ja: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')), en: JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')), diff --git a/tests/stage_body_test.cjs b/tests/stage_body_test.cjs index 4361bc7..fa4f89b 100644 --- a/tests/stage_body_test.cjs +++ b/tests/stage_body_test.cjs @@ -11,6 +11,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const crypto = require('node:crypto'); const { decodePng } = require('./stage_png.cjs'); @@ -19,7 +20,7 @@ const STAGE = path.join(ROOT, 'dashboard', 'stage'); const CHAR = path.join(STAGE, 'assets', 'char'); const FIXTURE = path.join(__dirname, 'fixtures', 'stage_body_outside.json'); const REPORT = process.env.STAGE_BODY_REPORT === '1'; -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const readJson = (name) => JSON.parse(fs.readFileSync(path.join(CHAR, name), 'utf8')); const png = (name) => decodePng(fs.readFileSync(path.join(CHAR, name))); const CALM = { emotion: 'calm', eyes: 'open', mouth: 'neutral', headY: 0, ears: 0, blush: 0, sweat: 0 }; diff --git a/tests/stage_desk_test.cjs b/tests/stage_desk_test.cjs index e7cfb71..e5d62e7 100644 --- a/tests/stage_desk_test.cjs +++ b/tests/stage_desk_test.cjs @@ -4,10 +4,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const { decodePng } = require('./stage_png.cjs'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const META = JSON.parse(fs.readFileSync(path.join(STAGE, 'assets', 'char', 'desk.json'), 'utf8')); const ATLAS = decodePng(fs.readFileSync(path.join(STAGE, 'assets', 'char', 'desk.png'))); const PARTS = JSON.parse(fs.readFileSync(path.join(STAGE, 'assets', 'char', 'parts.json'), 'utf8')); diff --git a/tests/stage_expr_test.cjs b/tests/stage_expr_test.cjs index cf77fbf..67e87b3 100644 --- a/tests/stage_expr_test.cjs +++ b/tests/stage_expr_test.cjs @@ -6,10 +6,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const { decodePng } = require('./stage_png.cjs'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const CHAR = path.join(STAGE, 'assets', 'char'); const META = JSON.parse(fs.readFileSync(path.join(CHAR, 'parts.json'), 'utf8')); const ATLAS = decodePng(fs.readFileSync(path.join(CHAR, 'parts.png'))); diff --git a/tests/stage_face_test.cjs b/tests/stage_face_test.cjs index c6694cc..bfa759b 100644 --- a/tests/stage_face_test.cjs +++ b/tests/stage_face_test.cjs @@ -7,13 +7,14 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const crypto = require('node:crypto'); const { decodePng } = require('./stage_png.cjs'); const ROOT = process.env.STAGE_ROOT || path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage'); const FIXTURE = path.join(__dirname, 'fixtures', 'stage_face_outside.json'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const readJson = (...p) => JSON.parse(fs.readFileSync(path.join(STAGE, ...p), 'utf8')); const CALM = { emotion: 'calm', eyes: 'open', mouth: 'neutral', headY: 0, ears: 0, blush: 0, sweat: 0 }; const W = 278; diff --git a/tests/stage_gadgets_test.cjs b/tests/stage_gadgets_test.cjs index 6761144..f67df3a 100644 --- a/tests/stage_gadgets_test.cjs +++ b/tests/stage_gadgets_test.cjs @@ -4,9 +4,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(ROOT, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(ROOT, 'js', name)).href); const N = (values) => ({ type: 'neural', values }); const T = { type: 'turn.start' }; const start = (id, kind, name, extra = {}) => ({ type: 'tool.start', id, kind, name, ...extra }); @@ -319,7 +320,7 @@ test('drones launch from and land beside her on their own side (docks), clear of assert.deepEqual(droneDock([228, 94], [362, 148], null), [362, 148], 'no docks: the single dock'); // the docks (a drone 25 x 19 and its bubble above) lie off her body at rest const { decodePng } = require('./stage_png.cjs'); - const { CharRig, NEUTRAL_FRAME } = await import(path.join(STAGE, 'js', 'char_rig.js')); + const { CharRig, NEUTRAL_FRAME } = await import(pathToFileURL(path.join(STAGE, 'js', 'char_rig.js')).href); const pm = JSON.parse(fs.readFileSync(path.join(STAGE, 'assets', 'char', 'parts.json'), 'utf8')); const pa = decodePng(fs.readFileSync(path.join(STAGE, 'assets', 'char', 'parts.png'))); const px = new CharRig(pm, pa).compose({ ...NEUTRAL_FRAME }).pixels; diff --git a/tests/stage_hair_test.cjs b/tests/stage_hair_test.cjs index 7fa38c1..0f4b717 100644 --- a/tests/stage_hair_test.cjs +++ b/tests/stage_hair_test.cjs @@ -6,11 +6,12 @@ const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const zlib = require('node:zlib'); const ROOT = path.join(__dirname, '..', 'dashboard', 'stage'); const CHAR = path.join(ROOT, 'assets', 'char'); -const load = (name) => import(path.join(ROOT, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(ROOT, 'js', name)).href); const META = JSON.parse(fs.readFileSync(path.join(CHAR, 'hair.json'), 'utf8')); // Minimal PNG reader (8-bit RGB / RGBA, not interlaced: what build_char/build_hair write). @@ -388,7 +389,7 @@ test('the ear beside the face shows skin and the earbud, not hair colour', () => // --- the hair stays in the character layer -------------------------------------------------- async function fakeStage() { - const THREE = await import(path.join(ROOT, 'vendor', 'three.stage.min.js')); + const THREE = await import(pathToFileURL(path.join(ROOT, 'vendor', 'three.stage.min.js')).href); const scene = new THREE.Scene(); const mk = (name, x, y, order) => { const mesh = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.ShaderMaterial({ uniforms: { uLevel: { value: 0.5 } } })); diff --git a/tests/stage_hands_test.cjs b/tests/stage_hands_test.cjs index 13bd1e2..88a2372 100644 --- a/tests/stage_hands_test.cjs +++ b/tests/stage_hands_test.cjs @@ -6,10 +6,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const { decodePng } = require('./stage_png.cjs'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const CHAR = path.join(STAGE, 'assets', 'char'); const json = (n) => JSON.parse(fs.readFileSync(path.join(CHAR, n), 'utf8')); const png = (n) => decodePng(fs.readFileSync(path.join(CHAR, n))); diff --git a/tests/stage_layers_test.cjs b/tests/stage_layers_test.cjs index 2f984d2..fea91d5 100644 --- a/tests/stage_layers_test.cjs +++ b/tests/stage_layers_test.cjs @@ -5,9 +5,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(ROOT, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(ROOT, 'js', name)).href); async function setup(extra = []) { const { buildCatalog, CATALOG } = await load('catalog.js'); @@ -126,7 +127,8 @@ test('each layer keeps its own parallax: room < character < props in front', asy }); test('scene.js places the room from roomFrame() only and the character by its own depth', () => { - const src = fs.readFileSync(path.join(ROOT, 'js', 'scene.js'), 'utf8'); + // LF only: a Windows checkout (core.autocrlf) has CRLF line ends + const src = fs.readFileSync(path.join(ROOT, 'js', 'scene.js'), 'utf8').replace(/\r\n/g, '\n'); const update = src.slice(src.indexOf(' update(frame) {'), src.indexOf(' // character\n')); // #350: the board's aside input (a UI progress, not a character channel) is the only addition assert.match(update, /roomFrame\(pose, ch, this\.time, this\.parallax, this\.aside\)/); diff --git a/tests/stage_model_test.cjs b/tests/stage_model_test.cjs index 1eef0cd..7375761 100644 --- a/tests/stage_model_test.cjs +++ b/tests/stage_model_test.cjs @@ -4,9 +4,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const N = (values) => ({ type: 'neural', values }); diff --git a/tests/stage_motion_test.cjs b/tests/stage_motion_test.cjs index 2c3c004..e97e36d 100644 --- a/tests/stage_motion_test.cjs +++ b/tests/stage_motion_test.cjs @@ -5,11 +5,12 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const crypto = require('node:crypto'); const { decodePng } = require('./stage_png.cjs'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(STAGE, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, 'js', name)).href); const N = (values) => ({ type: 'neural', values }); let rigCache = null; diff --git a/tests/stage_pacer_test.cjs b/tests/stage_pacer_test.cjs index d2dadeb..192df5d 100644 --- a/tests/stage_pacer_test.cjs +++ b/tests/stage_pacer_test.cjs @@ -6,9 +6,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..', 'dashboard', 'stage'); -const load = (name) => import(path.join(ROOT, 'js', name)); +const load = (name) => import(pathToFileURL(path.join(ROOT, 'js', name)).href); const META = JSON.parse(fs.readFileSync(path.join(ROOT, 'assets', 'char', 'hair.json'), 'utf8')); // A display: rAF timestamps at `hz` with deterministic jitter (ms). Calls onRun(dt, tMs) for each diff --git a/tests/stage_poll_test.cjs b/tests/stage_poll_test.cjs index d5fed8e..1502996 100644 --- a/tests/stage_poll_test.cjs +++ b/tests/stage_poll_test.cjs @@ -8,10 +8,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const poll = require('../dashboard/static/js/poll.js'); const STAGE = path.join(__dirname, '..', 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const TURNS = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'stage_turn_events.json'), 'utf8')); const PENDING = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'stage_hitl_pending.json'), 'utf8')); const NEURAL = { trust: 0.5, curiosity: 0.5, vigilance: 0.1, attachment: 0.5, stability: 0.7, satisfaction: 0.5 }; diff --git a/tests/stage_svg_test.cjs b/tests/stage_svg_test.cjs index 4afa6d1..1cb76e0 100644 --- a/tests/stage_svg_test.cjs +++ b/tests/stage_svg_test.cjs @@ -6,10 +6,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const ja = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.ja.json'), 'utf8')); const en = JSON.parse(fs.readFileSync(path.join(ROOT, 'dashboard/static/i18n/catalog.en.json'), 'utf8')); const t = (key, vars = {}) => String(ja[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => String(vars[k])); @@ -476,7 +477,7 @@ test('main.js: pictures only with data-board-svg="on", checked again by the brow }); test('the browser check finds Chrome for this platform (CHROME first, then the usual places)', async () => { - const { findChrome } = await import(path.join(ROOT, 'scripts', 'find_chrome.mjs')); + const { findChrome } = await import(pathToFileURL(path.join(ROOT, 'scripts', 'find_chrome.mjs')).href); const os = require('node:os'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'find-chrome-')); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'find-chrome-home-')); diff --git a/tests/stage_talk_test.cjs b/tests/stage_talk_test.cjs index 29c2d9c..66a0dd8 100644 --- a/tests/stage_talk_test.cjs +++ b/tests/stage_talk_test.cjs @@ -5,10 +5,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const ROOT = path.join(__dirname, '..'); const STAGE = path.join(ROOT, 'dashboard', 'stage', 'js'); -const load = (name) => import(path.join(STAGE, name)); +const load = (name) => import(pathToFileURL(path.join(STAGE, name)).href); const core = require('../dashboard/static/js/chat_core.js'); // --- typewriter ---------------------------------------------------------------------------- diff --git a/tests/test_emotion_proximity_recall.py b/tests/test_emotion_proximity_recall.py index 9f5e003..f0786d6 100644 --- a/tests/test_emotion_proximity_recall.py +++ b/tests/test_emotion_proximity_recall.py @@ -741,6 +741,10 @@ def _eval_module(): return module +# ~30 s on macOS; more than the default 120 s on the Windows runner, where every memory +# read pays an lstat per path component below the store root (#296). The thread-based +# timeout there ends the whole session, so this evaluation gets its own bound. +@pytest.mark.timeout(900) def test_the_preregistered_criteria_are_met_on_both_fixture_versions(): module = _eval_module() result = module.evaluate() # deterministic: the exact numbers below are asserted diff --git a/tests/test_exec_tool_consent.py b/tests/test_exec_tool_consent.py index 308e74f..650a1df 100644 --- a/tests/test_exec_tool_consent.py +++ b/tests/test_exec_tool_consent.py @@ -501,6 +501,34 @@ def test_pinned_upstream_source_audit(): assert {name: _source_audit(name) for name in EXEC_TOOLS} == AUDITED_SOURCE +def test_pyproject_does_not_admit_a_tools_release_newer_than_the_audited_one(): + # A fresh install resolves to the newest admitted release: it must be one the exec + # bridge accepts, and nothing newer than the audited releases may be admitted (#296). + import tomllib + from pathlib import Path + + from packaging.requirements import Requirement + from packaging.version import Version + + pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + dependencies = tomllib.loads(pyproject.read_text("utf-8"))["project"]["dependencies"] + (requirement,) = [ + Requirement(text) + for text in dependencies + if Requirement(text).name == tool_consent.UPSTREAM_DISTRIBUTION + ] + newest = max(Version(v) for v in tool_consent.VERIFIED_EXEC_UPSTREAM) + assert requirement.specifier.contains(newest) + major, minor, micro = newest.release + newer = [ + f"{major}.{minor}.{micro + 1}", + f"{major}.{minor + 1}.0", + f"{major + 1}.0.0", + f"{major}.{minor}.{micro + 1}rc1", + ] + assert [v for v in newer if requirement.specifier.contains(v, prereleases=True)] == [] + + def test_unaudited_upstream_release_drops_every_exec_tool(monkeypatch, caplog): monkeypatch.setattr(tool_consent, "upstream_version", lambda: "0.9.0") covered = tool_consent.install_consent_bridge() diff --git a/tests/test_identity_guard_memory_body.py b/tests/test_identity_guard_memory_body.py index 0c09a9c..69f0913 100644 --- a/tests/test_identity_guard_memory_body.py +++ b/tests/test_identity_guard_memory_body.py @@ -31,7 +31,12 @@ def data_dir(tmp_path: Path) -> Path: @pytest.fixture() -def guard(data_dir: Path) -> IdentityGuard: +def guard(data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> IdentityGuard: + # The home holds the data directory, as in a real install: "~" names a protected path then. + # Without this the cases "~" depend on where the runner's temp directory is (under the + # home on a workstation, /tmp or /var/folders on a CI runner). + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) read_only = [str(data_dir / n) for n in ("tool-config.json", "neural-state.json")] policy = WritePathPolicy( read_only + ["SOUL.md"], base_dir=data_dir / "workspace", write_roots=[WORKSPACE_ROOT] diff --git a/tests/test_memory_symlink_ancestors.py b/tests/test_memory_symlink_ancestors.py new file mode 100644 index 0000000..8c75ff7 --- /dev/null +++ b/tests/test_memory_symlink_ancestors.py @@ -0,0 +1,168 @@ +"""#296: a link *above* the memory root is the operator's choice; at or below it, refused. + +``MemoryStore`` resolves the ancestors of its configured root once (macOS ``/tmp`` and +``/var`` are links; a home or project folder may be one). A link at the root itself, +at ``agents//memory``, at a layer directory, at ``index.sqlite3`` or at an entry +file is still refused, also when it is swapped in after the store was built. The +target of a refused link is never read or written. +""" + +from __future__ import annotations + +import os +import shutil + +import pytest + +from stiracore.tools import episodic_emotion +from stiracore.tools.memory_store import MemoryLayer, MemoryStore, MemorySymlinkError + +pytestmark = pytest.mark.skipif(os.name == "nt", reason="POSIX symlinks") + +EPISODIC = MemoryLayer.EPISODIC +SECRET = "OUTSIDE-SECRET-296" + + +@pytest.fixture +def outside(tmp_path): + directory = tmp_path / "outside" + directory.mkdir() + (directory / "note.md").write_text(f"{SECRET}\n", encoding="utf-8") + return directory + + +def _untouched(outside): + assert sorted(p.name for p in outside.iterdir()) == ["note.md"] + assert (outside / "note.md").read_text(encoding="utf-8") == f"{SECRET}\n" + + +def test_a_link_above_the_root_is_resolved_and_the_store_works(tmp_path): + real = tmp_path / "real" + (real / "data").mkdir(parents=True) + (tmp_path / "link").symlink_to(real, target_is_directory=True) + + store = MemoryStore(tmp_path / "link" / "data" / "memory", "stira") + store.write(EPISODIC, "e1", "hello", {}) + + assert store.base_dir == (real / "data" / "memory").resolve() + assert store.read(EPISODIC, "e1").content == "hello" + assert [e.key for e in store.list_entries(EPISODIC, 5)] == ["e1"] + assert (real / "data" / "memory" / "agents" / "stira" / "memory" / "episodic").is_dir() + + +def test_a_link_at_the_root_is_refused(tmp_path, outside): + (tmp_path / "memory").symlink_to(outside, target_is_directory=True) + with pytest.raises(MemorySymlinkError): + MemoryStore(tmp_path / "memory", "stira") + _untouched(outside) + + +def test_a_link_at_the_root_below_a_linked_ancestor_is_refused(tmp_path, outside): + real = tmp_path / "real" + real.mkdir() + (tmp_path / "link").symlink_to(real, target_is_directory=True) + (real / "memory").symlink_to(outside, target_is_directory=True) + with pytest.raises(MemorySymlinkError): + MemoryStore(tmp_path / "link" / "memory", "stira") + _untouched(outside) + + +@pytest.mark.parametrize( + "relative", + ["agents", "agents/stira", "agents/stira/memory", "agents/stira/memory/episodic"], +) +def test_a_link_below_the_root_is_refused_at_construction(tmp_path, outside, relative): + root = tmp_path / "memory" + link = root / relative + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(outside, target_is_directory=True) + with pytest.raises(MemorySymlinkError): + MemoryStore(root, "stira") + _untouched(outside) + + +def test_a_linked_index_is_refused_at_construction(tmp_path, outside): + memory = tmp_path / "memory" / "agents" / "stira" / "memory" + memory.mkdir(parents=True) + (memory / "index.sqlite3").symlink_to(outside / "index.sqlite3") + with pytest.raises(MemorySymlinkError): + MemoryStore(tmp_path / "memory", "stira") + _untouched(outside) + + +def _built(tmp_path): + store = MemoryStore(tmp_path / "memory", "stira") + store.write(EPISODIC, "e1", "inside", {}) + return store + + +def _swap_for_link(path, target): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + path.symlink_to(target, target_is_directory=target.is_dir()) + + +@pytest.mark.parametrize( + "relative", + ["", "agents/stira/memory", "agents/stira/memory/episodic"], + ids=["root", "agent-memory", "layer"], +) +def test_a_directory_swapped_for_a_link_after_construction_is_refused(tmp_path, outside, relative): + store = _built(tmp_path) + (outside / "e1.md").write_text(f"{SECRET}\n", encoding="utf-8") + directory = store.base_dir / relative if relative else store.base_dir + copy = tmp_path / "copy" + shutil.copytree(directory, copy) + _swap_for_link(directory, copy if relative != "agents/stira/memory/episodic" else outside) + + with pytest.raises(MemorySymlinkError): + store.read(EPISODIC, "e1") + with pytest.raises(MemorySymlinkError): + store.write(EPISODIC, "e2", "new", {}) + assert sorted(p.name for p in outside.iterdir()) == ["e1.md", "note.md"] + assert not list(copy.rglob("e2.md")) + + +def test_an_index_swapped_for_a_link_after_construction_is_refused(tmp_path, outside): + store = _built(tmp_path) + _swap_for_link(store.index_path, outside / "note.md") + with pytest.raises(MemorySymlinkError): + store.read(EPISODIC, "e1") + _untouched(outside) + + +def test_an_entry_swapped_for_a_link_after_construction_is_refused(tmp_path, outside): + store = _built(tmp_path) + _swap_for_link(store._path(EPISODIC, "e1"), outside / "note.md") + with pytest.raises(MemorySymlinkError): + store.read(EPISODIC, "e1") + assert [e.key for e in store.list_entries(EPISODIC, 5)] == [] + _untouched(outside) + + +def test_a_linked_emotion_directory_is_still_refused(tmp_path, outside): + store = _built(tmp_path) + (store.memory_dir / episodic_emotion.EMOTION_DIRNAME).symlink_to( + outside, target_is_directory=True + ) + with pytest.raises(MemorySymlinkError): + episodic_emotion._private_directory(store) + with pytest.raises(MemorySymlinkError): + episodic_emotion._read_record( + episodic_emotion.emotion_record_path(store, "e1"), episodic_emotion._store_root(store) + ) + _untouched(outside) + + +def test_a_path_outside_the_root_is_checked_from_the_filesystem_root(tmp_path, outside): + base = tmp_path.resolve() # a real path, so only the planted link can refuse + root = base / "root" + root.mkdir() + (base / "link").symlink_to(outside, target_is_directory=True) + MemoryStore._reject_symlinks(base / "root" / "x", root=root) + with pytest.raises(MemorySymlinkError): + MemoryStore._reject_symlinks(base / "link" / "note.md", root=root) + with pytest.raises(MemorySymlinkError): # ".." never shortens the walk + MemoryStore._reject_symlinks(root / ".." / "link" / "note.md", root=root) diff --git a/tests/test_yolo_host_channel.py b/tests/test_yolo_host_channel.py index 7d0382e..cdc37f8 100644 --- a/tests/test_yolo_host_channel.py +++ b/tests/test_yolo_host_channel.py @@ -7,7 +7,6 @@ import json import os -import pty import secrets import selectors import subprocess @@ -309,6 +308,8 @@ def test_cli_without_a_server_says_so(tmp_path, capsys): @posix_only def test_the_real_command_on_a_pty_confirms_end_to_end(channel, tmp_path): """``stira approvals decide`` in its own process, on a pseudo-terminal, typing the word.""" + import pty # POSIX only (termios): imported here so the module collects on Windows + desk, _server = channel(own_pid=NOT_IN_ANY_TREE) op = _op(desk) master, slave = pty.openpty() diff --git a/tests/test_yolo_showcase_default.py b/tests/test_yolo_showcase_default.py index 9b952f9..38db6d4 100644 --- a/tests/test_yolo_showcase_default.py +++ b/tests/test_yolo_showcase_default.py @@ -119,6 +119,7 @@ def test_precedence_env_config_default(env, section, enabled, source): assert settings.sources["allow"] == source +@pytest.mark.real_yolo_clock # offered only where the clocks were measured (conftest.py) @pytest.mark.parametrize("page", ["/chat?lang=en", "/stage/"]) def test_the_default_shows_the_button_and_status_says_where_it_came_from(make_app, page): app = make_app()