diff --git a/apps/chat-ui/package.json b/apps/chat-ui/package.json index 580152c11..237ae36b9 100644 --- a/apps/chat-ui/package.json +++ b/apps/chat-ui/package.json @@ -7,18 +7,18 @@ "dist/*": "dist" }, "dependencies": { - "rocketride": "workspace:*", "@types/ws": "^8.18.1", + "chart.js": "^4.4.0", "lucide-react": "^0.577.0", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", - "chart.js": "^4.4.0", - "react-chartjs-2": "^5.2.0", "recharts": "^2.8.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "rocketride": "workspace:*", "web-vitals": "^5.2.0", "ws": "^8.20.1" }, @@ -29,8 +29,8 @@ "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", + "@types/react": "~18.3.31", + "@types/react-dom": "~18.3.7", "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.18.1", "autoprefixer": "^10.5.0", diff --git a/apps/dropper-ui/package.json b/apps/dropper-ui/package.json index 88b9a4a54..9c0903eaa 100644 --- a/apps/dropper-ui/package.json +++ b/apps/dropper-ui/package.json @@ -7,7 +7,6 @@ "dist/*": "dist" }, "dependencies": { - "rocketride": "workspace:*", "@textea/json-viewer": "^4.0.1", "@types/ws": "^8.18.1", "lucide-react": "^0.577.0", @@ -18,6 +17,7 @@ "recharts": "^2.8.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "rocketride": "workspace:*", "web-vitals": "^5.2.0", "ws": "^8.20.1" }, @@ -28,8 +28,8 @@ "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", + "@types/react": "~18.3.31", + "@types/react-dom": "~18.3.7", "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.18.1", "autoprefixer": "^10.5.0", diff --git a/apps/explorer-ui/src/ExplorerSidebar.tsx b/apps/explorer-ui/src/ExplorerSidebar.tsx index 2f558140e..e40e7655a 100644 --- a/apps/explorer-ui/src/ExplorerSidebar.tsx +++ b/apps/explorer-ui/src/ExplorerSidebar.tsx @@ -114,7 +114,7 @@ const ExplorerSidebar: React.FC = () => { const refresh = useCallback(async () => { if (!client || !isConnected) { setEntries([]); return; } try { - const allEntries = await listRecursive(client, ''); + const allEntries = await listRecursive(client, '@'); setEntries(allEntries); } catch { setEntries([]); @@ -193,7 +193,10 @@ const ExplorerSidebar: React.FC = () => { if (!client) return; try { const name = sourcePath.includes('/') ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath; - const newPath = targetDir ? `${targetDir}/${name}` : name; + // The tree is rooted at the '@' mount (listRecursive above), so an + // empty targetDir IS that root — carry '@' through instead of + // letting a bare name silently target the plain user store. + const newPath = `${targetDir || '@'}/${name}`; if (newPath === sourcePath) return; await client.fsRename(sourcePath, newPath); // Keep open editor tabs in sync with the move. A moved file must @@ -228,7 +231,8 @@ const ExplorerSidebar: React.FC = () => { if (!client) return; try { for (const file of files) { - const path = targetDir ? `${targetDir}/${file.name}` : file.name; + // Same root rule as handleMove: '' targetDir is the '@' mount. + const path = `${targetDir || '@'}/${file.name}`; const { handle } = await client.fsOpen(path, 'w'); const chunkSize = 4 * 1024 * 1024; // 4 MB try { diff --git a/apps/shell-ui/package.json b/apps/shell-ui/package.json index 7929894e5..0f2a72843 100644 --- a/apps/shell-ui/package.json +++ b/apps/shell-ui/package.json @@ -34,8 +34,8 @@ "@rsbuild/plugin-basic-ssl": "^1.2.3", "@rsbuild/plugin-react": "~2.0.1", "@rsbuild/plugin-type-check": "~1.3.5", - "@types/react": "~18.2.21", - "@types/react-dom": "~18.2.7", + "@types/react": "~18.3.31", + "@types/react-dom": "~18.3.7", "dts-bundle-generator": "^9.5.1" } } diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 7228a6c33..b056300ce 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -544,8 +544,8 @@ "@types/mocha": "^10.0.10", "@types/node": "20.x", "@types/proper-lockfile": "^4.1.4", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", "@types/vscode": "~1.99.0", "@types/vscode-webview": "^1.57.5", "@vscode/debugprotocol": "^1.68.0", diff --git a/nodes/src/nodes/anonymize/requirements.txt b/nodes/src/nodes/anonymize/requirements.txt index 4c1c46d2f..da3d04e3e 100644 --- a/nodes/src/nodes/anonymize/requirements.txt +++ b/nodes/src/nodes/anonymize/requirements.txt @@ -1,5 +1,9 @@ gliner # onnxruntime is an indirect dep (via gliner), not imported here. -# Pin 1.20.1 to match the one shared install (rtmlib needs exactly that). -onnxruntime-gpu==1.20.1; platform_system != 'Darwin' -onnxruntime==1.20.1; platform_system == 'Darwin' +# One shared version across every onnxruntime consumer, so the ~200 MB runtime installs once. +# rtmlib/gliner declare it unpinned and faster-whisper wants <2,>=1.14 -- the exact number is +# ours to choose, but all these files must agree. 1.20.1 was withdrawn from PyPI for the -gpu +# build (the CPU build of it survives), which is why this moved; 1.20.2 is the mirror trap -- +# it exists only for -gpu, so pinning it would break macOS. +onnxruntime-gpu==1.22.0; platform_system != 'Darwin' +onnxruntime==1.22.0; platform_system == 'Darwin' diff --git a/nodes/src/nodes/audio_transcribe/requirements.txt b/nodes/src/nodes/audio_transcribe/requirements.txt index df0eb85e8..2015d5445 100644 --- a/nodes/src/nodes/audio_transcribe/requirements.txt +++ b/nodes/src/nodes/audio_transcribe/requirements.txt @@ -6,6 +6,10 @@ tokenizers huggingface-hub tqdm # onnxruntime is an indirect dep (via faster-whisper), not imported here. -# Pin 1.20.1 to match the one shared install (rtmlib needs exactly that). -onnxruntime-gpu==1.20.1; platform_system != 'Darwin' -onnxruntime==1.20.1; platform_system == 'Darwin' +# One shared version across every onnxruntime consumer, so the ~200 MB runtime installs once. +# rtmlib/gliner declare it unpinned and faster-whisper wants <2,>=1.14 -- the exact number is +# ours to choose, but all these files must agree. 1.20.1 was withdrawn from PyPI for the -gpu +# build (the CPU build of it survives), which is why this moved; 1.20.2 is the mirror trap -- +# it exists only for -gpu, so pinning it would break macOS. +onnxruntime-gpu==1.22.0; platform_system != 'Darwin' +onnxruntime==1.22.0; platform_system == 'Darwin' diff --git a/nodes/src/nodes/tool_filesystem/IGlobal.py b/nodes/src/nodes/tool_filesystem/IGlobal.py index a219aa3bb..76bdb7e44 100644 --- a/nodes/src/nodes/tool_filesystem/IGlobal.py +++ b/nodes/src/nodes/tool_filesystem/IGlobal.py @@ -24,9 +24,13 @@ """ File system tool node — global (shared) state. -Resolves the account's ``client_id`` from the ``ROCKETRIDE_CLIENT_ID`` env var -(injected by the task engine in ``task_engine.py``) and builds a single -``FileStore`` scoped to ``users//files/``. The instance exposes +Resolves the account identity and the storage anchor from the TASK FILE the +engine published (``rocketlib.getTask()``: the ``identity`` and ``storage`` +blocks, see task_engine._build_task) and builds a single ``FileStore`` +rooted at that anchor — the owning user's tree +(``users//files/``) for development runs, a task-specific team +subtree (``teams//files/tasks//``) for deployed runs — +so node-level paths are identical in both modes. The instance exposes per-operation allow-flags and a path whitelist that IInstance enforces before every tool call. @@ -37,10 +41,8 @@ from __future__ import annotations -import os import re -from ai.account.store import Store from ai.common.config import Config from rocketlib import IGlobalBase, OPEN_MODE, warning @@ -76,24 +78,32 @@ def beginGlobal(self) -> None: self.path_patterns = self._build_path_patterns(cfg) self.target_dir, self.emit_url, self.url_expires_in = self._sink_config(cfg) - client_id = os.environ.get('ROCKETRIDE_CLIENT_ID', '').strip() - if not client_id: - warning( - 'tool_filesystem: ROCKETRIDE_CLIENT_ID env var is missing; tool methods will be disabled. This usually means the node is running outside the task engine.' - ) - self.client_id = None - self.file_store = None - return - try: - store = Store.create() - self.file_store = store.get_file_store(client_id) - self.client_id = client_id + # Engine identity: this node runs INSIDE the engine subprocess + # with no account session, and its paths come from LLM tool + # calls — so it gets the most restricted context: plain paths + # only, every @/Team, @/Org and @/User path and reserved subtree + # (.logs, .deployments) rejected by the store. It must never + # hold an internal identity (that would let a pipeline address + # any team's storage unchecked). Identity and the storage anchor + # (the owner's tree for dev runs, a task-specific team subtree + # for deploy runs) come from the task file the engine published + # via rocketlib.getTask() — never the environment — and the + # store resolves them itself, so this node needs no plumbing. + from ai.account.store import Store + + self.file_store = Store.engine_file_store() + self.client_id = getattr(self.file_store, '_client_id', None) except Exception as e: warning(f'tool_filesystem: failed to initialise FileStore: {e}') self.client_id = None self.file_store = None + if self.file_store is None: + warning( + 'tool_filesystem: no running task with an identity (rocketlib.getTask); tool methods will be disabled. This usually means the node is running outside the task engine.' + ) + @staticmethod def _build_path_patterns(cfg: dict) -> list[re.Pattern]: """Parse the repeated ``whitelistPattern`` rows into compiled regexes.""" diff --git a/nodes/src/nodes/tool_filesystem/README.md b/nodes/src/nodes/tool_filesystem/README.md index 847cc03e1..10c420066 100644 --- a/nodes/src/nodes/tool_filesystem/README.md +++ b/nodes/src/nodes/tool_filesystem/README.md @@ -5,12 +5,16 @@ A RocketRide tool node that gives an AI agent read/write access to the account-s ## What it does Exposes the account file store, the same storage area the client SDK reaches via its -`fs_*` methods, to an agent as a set of callable tools. All paths are relative to -`users//files/`, so files written by the agent are visible to the client SDK -and vice versa. The account is resolved automatically from the `ROCKETRIDE_CLIENT_ID` -env var injected by the task engine, no account configuration is needed on the node. -If that env var is missing or the account store fails to initialise, a warning is logged -and **all** tool methods are hidden from the agent. +`fs_*` methods, to an agent as a set of callable tools. All paths are plain and +relative to the task's **storage anchor**, which the task file provides: the owning +user's file tree (`users//files/`) for development runs, or a task-specific +subtree of the deployment team's storage (`teams//files/tasks//`) +for deployed runs — so files written by the agent are visible in the file browser and +vice versa, and node behavior is identical in both modes. Identity and the anchor are +resolved automatically from the running task (`rocketlib.getTask()`), never from the +environment; no account configuration is needed on the node. If no task identity is +available or the account store fails to initialise, a warning is logged and **all** +tool methods are hidden from the agent. The node plays two roles. As a **tool** it is connected to agents via the `tool` invoke channel (see *Available tools*). As a **pipeline sink** it also accepts data lanes and @@ -39,7 +43,7 @@ may touch. | `emitUrl` | boolean | Default false. Also attach a time-limited signed download URL to the emitted document metadata. | | `urlExpiresIn` | integer | Default 3600 (max 3600). TTL in seconds for the signed URL when `emitUrl` is on. | | `whitelistPattern` | string | Default empty. | -| `pathWhitelist` | array | Regex patterns applied to the relative path of every operation using re.search semantics, a partial match anywhere in the path is enough, so a pattern like 'secret' will also match 'notsecret/file.txt'. Anchor with ^ and $ if you need a full-path match (e.g. '^docs/.*$'). If non-empty, a path must match at least one pattern. If empty, all paths under users//files/ are allowed. | +| `pathWhitelist` | array | Regex patterns applied to the relative path of every operation using re.search semantics, a partial match anywhere in the path is enough, so a pattern like 'secret' will also match 'notsecret/file.txt'. Anchor with ^ and $ if you need a full-path match (e.g. '^docs/.*$'). If non-empty, a path must match at least one pattern. If empty, all paths under the task's storage anchor are allowed. | ### Path whitelist @@ -143,15 +147,17 @@ subprocess long before the LLM ever sees the result. ## Storage location -Files land under the configured storage backend (defaults to `~/.rocketlib/store/`). For -the default filesystem backend the absolute path is: +Files land under the configured storage backend (defaults to `~/.rocketlib/store/`). +For the default filesystem backend the absolute path is the task's storage anchor +plus the relative path: ```text -/users//files/ +/users//files/ # development runs +/teams//files/tasks// # deployed runs ``` -Each account gets its own isolated `files/` directory, the node picks up the current -account automatically, no configuration needed. +The anchor comes from the task file the engine wrote at spawn; the node picks up +the current task automatically, no configuration needed. --- diff --git a/nodes/src/nodes/tool_filesystem/services.json b/nodes/src/nodes/tool_filesystem/services.json index d24dca658..c187de06b 100644 --- a/nodes/src/nodes/tool_filesystem/services.json +++ b/nodes/src/nodes/tool_filesystem/services.json @@ -115,7 +115,7 @@ }, "filesystem.pathWhitelist": { "title": "Path Whitelist", - "description": "Regex patterns applied to the relative path of every operation using re.search semantics: a partial match anywhere in the path is enough, so a pattern like 'secret' will also match 'notsecret/file.txt'. Anchor with ^ and $ if you need a full-path match (e.g. '^docs/.*$'). If non-empty, a path must match at least one pattern. If empty, all paths under users//files/ are allowed.", + "description": "Regex patterns applied to the relative path of every operation using re.search semantics: a partial match anywhere in the path is enough, so a pattern like 'secret' will also match 'notsecret/file.txt'. Anchor with ^ and $ if you need a full-path match (e.g. '^docs/.*$'). If non-empty, a path must match at least one pattern. If empty, all paths under the task's storage anchor (the owning user's file tree for development runs, the task's team subtree for deployed runs) are allowed.", "type": "array", "optional": true, "minItems": 0, diff --git a/package.json b/package.json index 66bb3e702..9f2688c82 100644 --- a/package.json +++ b/package.json @@ -117,8 +117,8 @@ "@eslint/js": "^9.39.4", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@types/node": "^20.19.41", - "@types/react": "18.2.79", - "@types/react-dom": "18.2.25", + "@types/react": "~18.3.31", + "@types/react-dom": "~18.3.7", "adm-zip": "^0.5.17", "concurrently": "^8.2.2", "dotenv": "^16.4.5", @@ -131,12 +131,12 @@ "gitignore-globs": "^0.1.1", "glob": "^11.0.0", "globals": "^15.12.0", + "lefthook": "^2.1.8", "listr2": "^9.0.5", "npm-run-all": "^4.1.5", "prettier": "^3.8.3", "rimraf": "^5.0.5", "tar": "^7.5.15", - "lefthook": "^2.1.8", "typescript": "^5.3.3", "typescript-eslint": "^8.60.0" } diff --git a/packages/ai/src/ai/account/__init__.py b/packages/ai/src/ai/account/__init__.py index a9b5884ad..7bc6b0af3 100644 --- a/packages/ai/src/ai/account/__init__.py +++ b/packages/ai/src/ai/account/__init__.py @@ -67,7 +67,7 @@ from .report import Reporter from .store import Store, IStore, StorageError, VersionMismatchError, STORE_MAX_RETRY_ATTEMPTS, LOG_PAGE_SIZE from .deployment_store import DeploymentStore -from .models import AccountInfo, DeploymentRecord, resolve_team_permissions +from .models import AccountInfo, DeploymentRecord, RequestContext, resolve_team_permissions __all__ = [ 'Account', @@ -79,6 +79,7 @@ 'KeyStore', 'Reporter', 'Store', + 'RequestContext', 'IStore', 'StorageError', 'VersionMismatchError', diff --git a/packages/ai/src/ai/account/file_store.py b/packages/ai/src/ai/account/file_store.py index dd0d64197..40b633ba3 100644 --- a/packages/ai/src/ai/account/file_store.py +++ b/packages/ai/src/ai/account/file_store.py @@ -5,8 +5,9 @@ over the flat key-value IStore backends (Filesystem, S3, Azure). Each FileStore instance is scoped to a single account via client_id. -Supports handle-based I/O for streaming reads and writes: - handle = await fs.open_write(path, connection_id) +Supports handle-based I/O for streaming reads and writes (handles are owned +by the bound context's conn_id): + handle = await fs.open_write(path) await fs.write_chunk(handle, chunk1) await fs.write_chunk(handle, chunk2) await fs.close_write(handle) @@ -14,12 +15,12 @@ Usage: from ai.account.store import Store - store = Store.create() - fs = store.get_file_store(client_id='user-123') + fs = Store.file_store(ctx) # the handler's RequestContext - await fs.write('data/input.csv', b'col1,col2\\n1,2\\n', connection_id=1) - data = await fs.read('data/input.csv', connection_id=1) + await fs.write('data/input.csv', b'col1,col2\\n1,2\\n') + data = await fs.read('data/input.csv') entries = await fs.list_dir('data/') + shared = await fs.list_dir('@/Team/Development/reports') """ import uuid @@ -28,8 +29,7 @@ from pathlib import PurePosixPath from typing import Any, Optional -from rocketlib import debug - +from .models import RequestContext, resolve_task_permissions from .store import IStore, StorageError # Sentinel file used to represent empty directories on object stores @@ -42,8 +42,8 @@ # Larger files must use the streaming handle API (open_read + read_chunk). MAX_READ_SIZE = 100 * 1024 * 1024 # 100 MB -# Maximum open handles per connection (DoS bound). Only enforced when -# connection_id is non-zero — zero means "unowned", used by tests. +# Maximum open handles per connection (DoS bound). Only enforced when the +# ctx conn_id is non-empty — '' means "unowned", used by tests. MAX_HANDLES_PER_CONNECTION = 64 # Characters forbidden inside any single path segment. ':' blocks Windows @@ -52,6 +52,383 @@ _INVALID_SEGMENT_CHARS = frozenset('*?<>|":\x00') +# ============================================================================= +# SCOPE GRAMMAR + PERMISSION POLICY +# +# The storage layer is the security boundary: every path of every operation +# is authorized HERE, at the single point where it resolves, against the +# RequestContext the FileStore was constructed with (the same per-request +# identity every on_* handler carries — see models.RequestContext). +# +# Scope grammar (wire paths) — the JOINED filesystem rooted at '@': +# → users//files/ (simple mode) +# @/User/ → users//files/ (same tree, +# joined-mode spelling — @/User is implicitly ME) +# @/Team// → teams//files/ (ref required: +# a display name from my membership, or =) +# @/Org/ → orgs//files/ (implicitly MY +# org — org.admin only) +# @/User/=/, @/Org/=/ → cross-boundary id references, +# sys.admin/internal only (support access). +# '@' itself is a virtual mount listing served by cmd_store, never resolved +# here; its children compose by ordinary path joining (listing '@' returns +# 'User'/'Team'/'Org', join gives '@/Team', and so on down the tree). The +# spellings are case-exact and alias-free — one location, one path string. +# @/User and @/Org take no name reference — each caller has exactly one of +# both, so a '='-free next segment is already the path; @/Team keeps its +# mandatory reference (N teams). Names on the wire, ids on disk; name +# resolution consults ONLY the caller's own membership. Unknown, foreign, +# permission-less, and ambiguous references deny with ONE identical message +# (no existence leak). Top-of-scope entry names may not start with '=' (so +# id references can never be shadowed by real files) or '@' (grammar room). +# ============================================================================= + +# SYSTEM-OWNED subtrees: written by the engine, served by their own domain +# APIs (rrext_log today, rrext_deploy later). Through the FILE API they are +# fully reserved — a user could corrupt them, and they can contain +# information the user must not see raw — so only internal identities and +# sys.admin sessions may touch them (sys.admin: anything). Everyone else: +# invisible in listings (cmd_store filters) and denied on access. +SYSTEM_TREES = frozenset({'.logs', '.deployments'}) + +# Everything outside the system trees requires the file-storage permission. +_DEFAULT_PERMISSION = 'task.store' + +# The uniform denial for scoped paths — identical for unknown, foreign, +# permission-less, and ambiguous references so nothing leaks existence. +_DENIED = 'Access denied for scoped path' + + +def _audit_crossing(ctx: 'RequestContext', client_id: str, target: str) -> None: + """Record a sys.admin ``=id`` cross-boundary storage resolution. + + These are the ONLY resolutions where the storage boundary is crossed + rather than enforced (the platform support capability), so each one is + traced server-side with actor, connection, and target scope. Trace-only — + the wire response never distinguishes a crossing from an ordinary resolve, + preserving the uniform-denial / no-oracle posture. + """ + from rocketlib import debug + + debug(f'AUDIT storage crossing (sys.admin): client={client_id} conn={getattr(ctx, "conn_id", None)} -> {target}') + + +def _system_tree(rest: str) -> 'Optional[str]': + """Return the system-tree name when ``rest`` enters one, else None.""" + first = rest.split('/', 1)[0] if rest else '' + return first if first in SYSTEM_TREES else None + + +def normalize_path(path: str) -> str: + """Validate and normalize a user-provided wire path — THE normalization. + + Every consumer that makes a decision about a wire path (the resolver + below via ``FileStore._validate_path``, cmd_store's scope-root filter) + must run this SAME routine first, so no two components can ever disagree + about which location a spelling addresses ('@//User', '@/./User' and + '\\@\\User' all normalize to '@/User' — deciding on the raw spelling + while resolving the normalized one is a filter bypass). + + Rules: backslashes become '/', leading slashes drop, '..' and control / + reserved characters raise, empty and '.' segments collapse away. + + Raises: + ValueError: On path traversal or invalid characters. + """ + path = path.replace('\\', '/') + + if path.startswith('/'): + path = path.lstrip('/') + + parts = path.split('/') + if '..' in parts: + raise ValueError(f'Path traversal not allowed: {path}') + + for part in parts: + if part and any(c in _INVALID_SEGMENT_CHARS or ord(c) < 0x20 for c in part): + raise ValueError(f'Path contains invalid characters: {path}') + + normalized = str(PurePosixPath(path)) if path else '' + if normalized == '.': + normalized = '' + + return normalized + + +def parse_scope(normalized: str) -> 'tuple[str, Optional[str], str]': + """Split a NORMALIZED path into (scope_kind, scope_ref, rest). + + Must be fed the output of ``_validate_path`` — normalizing first is what + defeats the ``\\@\\Team\\...``, ``/@/Team/...``, ``./@/Team/...`` bypass + family (all of those normalize INTO the ``@`` grammar and are parsed + here, never around it). + + Grammar v3 — the joined filesystem rooted at '@'. Scope references: + bare segment = display NAME, resolved through the caller's own + membership (the canonical, round-trippable spelling) + ``=`` = literal id, stripped and used as-is (scripts and + cross-boundary sys.admin access) — ids can never + collide with names by construction. + @/Team takes a MANDATORY reference (a caller has N teams); @/User and + @/Org take NONE (a caller has exactly one of each — they are implicitly + "me" / "my org"), so their next segment is already the path UNLESS it + starts with '=' (a cross-boundary id reference, sys.admin/internal). + Spellings are case-exact ('@/team' is an error, not an alias). + + Kinds: + - no leading '@' -> ('own', None, path) (caller's namespace) + - '@/User[/rest]' -> ('user', None, rest) (alias of own tree) + - '@/User/=[/rest]' -> ('user', '=uid', rest) + - '@/Team/[/rest]' -> ('team', ref, rest) + - '@/Org[/rest]' -> ('org', None, rest) (caller's own org) + - '@/Org/=[/rest]' -> ('org', '=oid', rest) + - bare '@' (a virtual mount listing owned by cmd_store), any other + '@*' first segment, an unknown '@/', or '@/Team' without a + reference -> ValueError. + To keep id references unshadowable, the FIRST rest segment of any scope + may not start with '=' -> ValueError (such names cannot be created, so + they never appear in listings either). + """ + if not normalized.startswith('@'): + kind, ref, rest = 'own', None, normalized + else: + parts = normalized.split('/') + if parts[0] != '@': + # '@team', '@foo', ... — the whole '@*' family stays reserved. + raise ValueError(f'Reserved path segment: {parts[0]!r} (the joined filesystem lives under @/)') + if len(parts) < 2: + raise ValueError("Reserved path segment: '@' is the virtual mount root (list it, not resolve it)") + child = parts[1] + if child not in ('User', 'Team', 'Org'): + raise ValueError(f'Unknown scope {child!r} (only @/User, @/Team and @/Org exist — case-exact)') + if child == 'Team': + # N teams — the reference segment is mandatory. + if len(parts) < 3 or not parts[2]: + raise ValueError('@/Team/ requires a reference segment') + kind, ref, rest = 'team', parts[2], '/'.join(parts[3:]) + elif len(parts) >= 3 and parts[2].startswith('='): + # @/User/@/Org with an explicit cross-boundary id reference. + kind, ref, rest = child.lower(), parts[2], '/'.join(parts[3:]) + else: + # @/User/@/Org implicit self — everything after is already path. + kind, ref, rest = child.lower(), None, '/'.join(parts[2:]) + + # Reserve the '=' and '@' sigils at the top of every scope: a real entry + # named '=x' would shadow (or be shadowed by) an id reference and become + # unaddressable/ambiguous, and '@*' is grammar room (a plain path can + # never start with '@' — it parses as scope grammar above — but a scoped + # rest could smuggle one in). Denied on creation => never listed either. + first = rest.split('/', 1)[0] if rest else '' + if first.startswith('='): + raise ValueError("Names starting with '=' are reserved for id references") + if first.startswith('@'): + raise ValueError("Names starting with '@' are reserved (scope grammar)") + return (kind, ref, rest) + + +def _split_ref(ref: str) -> 'tuple[bool, str]': + """Split a scope reference into (is_id, value) per the ``=`` rule. + + ``=`` means literal id: strip the sigil, then VALIDATE the id as a + path-safe segment — ``=..`` / ``=.`` / ``=`` would otherwise traverse out + of the scope tree when embedded into the physical path (slashes are + already impossible post-normalization). + """ + if not ref.startswith('='): + return (False, ref) + ident = ref[1:] + if not ident or ident in ('.', '..'): + raise ValueError(f'Invalid id reference: {ref!r}') + return (True, ident) + + +def _team_ids(account_info) -> 'set[str]': + """The caller's own team ids, from the session membership list.""" + org = getattr(account_info, 'organization', None) or {} + return {t['id'] for t in (org.get('teams', []) if isinstance(org, dict) else []) if t.get('id')} + + +def validate_storage_root(root: str) -> str: + """Validate a task-file storage anchor and return it normalized. + + The anchor is produced by trusted server code (``_build_task``) and + carried in the task file — but the subprocess feeds it back to us, so + validate shape defensively: a physical path of the form + ``users|teams|orgs//files[/]`` built from path-safe + segments. Anything else raises ValueError. + """ + if not isinstance(root, str) or not root: + raise ValueError('storage root must be a non-empty string') + segments = root.strip('/').split('/') + if len(segments) < 3 or segments[0] not in ('users', 'teams', 'orgs') or segments[2] != 'files': + raise ValueError(f'Invalid storage root: {root!r} (expected users|teams|orgs//files[/...])') + for segment in segments[1:]: + if not segment or segment in ('.', '..') or '\\' in segment or segment.startswith(('@', '=')): + raise ValueError(f'Invalid storage root segment: {segment!r}') + return '/'.join(segments) + + +def _resolve_team_name(account_info, name: str) -> str: + """Resolve a team display name to its id — caller's own org ONLY. + + Names are case-sensitive and must be unique among the caller's teams + (per-org uniqueness is DB-enforced, but fail closed anyway). Unknown or + ambiguous names raise the SAME uniform error — and a foreign org's names + are inexpressible by construction, since the only dictionary consulted is + the caller's own membership list. + """ + org = getattr(account_info, 'organization', None) or {} + teams = org.get('teams', []) if isinstance(org, dict) else [] + matches = [t for t in teams if t.get('name') == name] + if len(matches) == 1 and matches[0].get('id'): + return matches[0]['id'] + raise PermissionError(_DENIED) + + +def resolve_scope( + ctx: RequestContext, + client_id: str, + normalized: str, + engine_root: 'Optional[str]' = None, +) -> 'tuple[str, str, str]': + """Authorize and resolve a normalized path to its physical scope root. + + THE enforcement point: called for every path of every operation, against + the bound RequestContext. Returns ``(kind, scope_root, rest)`` — kind is + the parsed scope kind ('own'/'user'/'team'/'org'), returned so callers + never re-parse a path this function already parsed. + + Identity handling: + - ``ctx.source == 'engine'`` (subprocess tool nodes): plain paths only; + every ``@`` spelling (including the harmless-looking ``@/User`` alias) + and every system tree is rejected — LLM-generated paths can never + wander out of the task owner's namespace. + - ``'internal'`` sysPermission (trusted subsystems): mechanical, id + (``=``) references only, no checks — system trees included (the + run-log writer and the domain APIs act through this identity). + - sys.admin sessions: full access everywhere, system trees included; + team names still resolve in their OWN org only, ``=id`` references + cross boundaries mechanically (the platform support capability). + - ordinary sessions: ``@/User/`` is an alias of the own tree; + team references (name or ``=id``) resolve strictly within their own + membership; ``@/Org`` (implicitly their one org) needs ``org.admin``; + ``task.store`` required elsewhere; SYSTEM TREES ARE FULLY DENIED — + logs/deployments are reachable only through their domain APIs + (rrext_log / rrext_deploy), never the file API. + + Raises: + PermissionError: Identity may not touch the addressed location (or + the reference is unknown/foreign/ambiguous — uniform message). + ValueError: Malformed scope grammar or id reference. + """ + kind, ref, rest = parse_scope(normalized) + + # -- Engine subprocess: most restricted — plain paths, no system trees --- + # Every path is relative to the task's storage anchor (chroot + # semantics): the task file's storage.root — the owner's whole tree for + # dev runs, a task-specific team subtree for deploy runs — so node-level + # paths are identical in both modes and can never leave the anchor. + if ctx.source == 'engine': + if kind != 'own': + raise PermissionError('Scoped (@) paths are not available in the engine context') + if _system_tree(rest): + raise PermissionError(f'{_system_tree(rest)}/ is not available in the engine context') + return (kind, engine_root or f'users/{client_id}/files', rest) + + account_info = ctx.account_info + if account_info is None: + raise PermissionError('Not authenticated') + + sys_perms = account_info.sysPermissions or [] + is_internal = 'internal' in sys_perms + is_sys_admin = 'sys.admin' in sys_perms + + # -- Internal subsystems: mechanical, ids only, no checks ---------------- + # Deliberately BEFORE the system-tree gate below (intent, not + # fall-through): the run-log writer and the domain APIs (rrext_log, + # rrext_deploy) write .logs/.deployments through exactly this identity — + # pinned by test_internal_identity_writes_logs. + if is_internal: + if kind == 'own' or (kind == 'user' and ref is None): + return (kind, f'users/{client_id}/files', rest) + if ref is None: + raise PermissionError(_DENIED) # internal has no org context + is_id, value = _split_ref(ref) + if not is_id: + raise PermissionError(_DENIED) # internal has no name dictionary + root = {'team': 'teams', 'org': 'orgs', 'user': 'users'}[kind] + return (kind, f'{root}/{value}/files', rest) + + # -- System trees: sys.admin may do anything, everyone else nothing ------ + # (Checked before scope resolution so the denial is uniform and early.) + if _system_tree(rest) and not is_sys_admin: + raise PermissionError(f'{_system_tree(rest)}/ is system-owned (use its API)') + + # -- Own namespace: plain paths and their joined-mode alias @/User/ - + if kind == 'own' or (kind == 'user' and ref is None): + if is_sys_admin: + return (kind, f'users/{client_id}/files', rest) + # The caller's OWN tree must not hinge on the defaultTeam pointer — + # an unset or stale defaultTeam would deny a user their own storage. + # The file-storage permission is granted when ANY membership carries + # it (org.admin implies it via full team permissions). + org = account_info.organization if isinstance(account_info.organization, dict) else {} + if not any( + _DEFAULT_PERMISSION in resolve_task_permissions(account_info, team['id']) + for team in org.get('teams', []) + if team.get('id') + ): + raise PermissionError(f'Permission {_DEFAULT_PERMISSION!r} denied') + return (kind, f'users/{client_id}/files', rest) + + # -- @/Org: implicitly MY org — org.admin only; =id crosses for sys.admin - + if kind == 'org': + org = account_info.organization if isinstance(account_info.organization, dict) else {} + if ref is not None: + value = _split_ref(ref)[1] # parse guarantees the '=' form here + if value != org.get('id'): + if is_sys_admin: + _audit_crossing(ctx, client_id, f'orgs/{value}') + return (kind, f'orgs/{value}/files', rest) + raise PermissionError(_DENIED) + if not org.get('id'): + raise PermissionError(_DENIED) + if 'org.admin' not in (org.get('permissions') or []) and not is_sys_admin: + raise PermissionError(_DENIED) + return (kind, f'orgs/{org["id"]}/files', rest) + + is_id, value = _split_ref(ref) + + # -- @/User/=: cross-boundary support spelling — sys.admin only ----- + if kind == 'user': + if not is_sys_admin: + raise PermissionError(_DENIED) + _audit_crossing(ctx, client_id, f'users/{value}') + return (kind, f'users/{value}/files', rest) + + # -- @/Team ----------------------------------------------------------------- + if is_id: + if value in _team_ids(account_info): + team_id = value + elif is_sys_admin: + # A team the caller is NOT a member of — a boundary crossing, + # unlike the member resolution above (which crosses nothing). + _audit_crossing(ctx, client_id, f'teams/{value}') + team_id = value + else: + raise PermissionError(_DENIED) + else: + team_id = _resolve_team_name(account_info, ref) + + if is_sys_admin: + return (kind, f'teams/{team_id}/files', rest) + + perms = resolve_task_permissions(account_info, team_id) + if _DEFAULT_PERMISSION not in perms: + raise PermissionError(_DENIED) + return (kind, f'teams/{team_id}/files', rest) + + def _sanitize_download_name(name: str) -> str: """ Make a filename safe to embed in a ``Content-Disposition`` header value. @@ -85,7 +462,9 @@ class FileHandle: handle_id: str = field(default_factory=lambda: str(uuid.uuid4())) path: str = '' mode: FileHandleMode = FileHandleMode.READ - connection_id: int = 0 + # The owning ctx.conn_id ('' = unowned) — scopes handles per connection + # for ownership checks, the DoS cap, and disconnect cleanup. + connection_id: str = '' context: Any = None bytes_written: int = 0 closed: bool = False @@ -95,8 +474,23 @@ class FileStore: """ General-purpose binary file system interface built on top of IStore. - All paths are scoped to ``users//files/`` within the storage - backend, providing per-account isolation. All I/O is raw bytes. + THE STORAGE SECURITY BOUNDARY: every path of every operation resolves — + and is AUTHORIZED — in one place (``_full_path`` → ``resolve_scope``), + against the ``RequestContext`` bound at construction — the SAME + per-request identity the DAP handlers carry, so identity flows wire → + handler → store unchanged. Plain paths scope to + ``users//files/`` exactly as before; the ``@/Team//`` and + ``@/Org/`` grammar resolves to ``teams//files/`` / + ``orgs//files/`` with the policy table deciding the required + access rule per subtree (system trees are internal/sys.admin only). + + Instances are cheap per-consumer wrappers constructed via + ``Store.file_store(ctx)`` — identity is NEVER cached across + sessions (concurrent sessions of one user can carry different permission + envelopes, e.g. PAT-scoped). The write-lock set and open-handle registry + are SHARED on the owning Store so two instances addressing the same + physical file still exclude each other; both are single-process guards + only — cross-node consistency is the backend's whole-object atomicity. Handle-based operations (open_write/read, write_chunk/read_chunk, close) allow streaming large files without buffering everything in memory. @@ -104,30 +498,65 @@ class FileStore: the owning connection terminates. """ - def __init__(self, store: IStore, client_id: str): - """Initialize FileStore scoped to a specific account.""" + def __init__(self, owner, client_id: str, ctx: RequestContext, root: 'Optional[str]' = None): + """Bind this instance to its owning Store, account, and identity. + + Args: + owner: The Store wrapper providing the IStore backend and the + SHARED write-lock/handle registries. + client_id: Account id for plain-path scoping. + ctx: REQUIRED per-request identity (the handler's ctx, or + RequestContext.internal()/engine() for server subsystems) — + omission is a TypeError, not a silently-unauthorized store. + root: ENGINE contexts only — the storage anchor from the task + file's ``storage.root``; every plain path the subprocess + speaks resolves relative to it (chroot semantics), so dev + and deploy runs use identical node-level paths. Validated + by ``validate_storage_root``; forbidden for other + identities (their anchor is their own namespace). + """ if not client_id: raise ValueError('client_id is required') if '/' in client_id or '\\' in client_id: raise ValueError('client_id must not contain path separators') if client_id in ('.', '..'): raise ValueError('client_id must not be "." or ".."') - self._store = store + if not isinstance(ctx, RequestContext): + raise TypeError('ctx must be a RequestContext') + if root is not None: + if ctx.source != 'engine': + raise ValueError('storage root overrides exist for engine contexts only') + root = validate_storage_root(root) + self._owner = owner + self._store: IStore = owner._store self._client_id = client_id - self._handles: dict[str, FileHandle] = {} - self._write_locks: set[str] = set() + self._ctx = ctx + self._root = root + + # Shared single-process guards live on the owning Store so every + # FileStore instance — regardless of which user or identity constructed + # it — coordinates on the same physical paths. + @property + def _handles(self) -> 'dict[str, FileHandle]': + """The Store-wide open-handle registry.""" + return self._owner._shared_handles + + @property + def _write_locks(self) -> 'set[str]': + """The Store-wide set of physical paths open for writing.""" + return self._owner._shared_write_locks # ========================================================================= # Handle-Based Write Operations # ========================================================================= - async def open_write(self, path: str, connection_id: int) -> str: + async def open_write(self, path: str) -> str: """ - Open a file for writing. + Open a file for writing. The handle is owned by the bound context's + conn_id (cleanup on disconnect; ownership-checked on use). Args: path: Relative path within the account store. - connection_id: ID of the owning connection (for cleanup on disconnect). Returns: Handle ID string. @@ -135,6 +564,7 @@ async def open_write(self, path: str, connection_id: int) -> str: Raises: StorageError: If the path is already open for writing. """ + connection_id = self._ctx.conn_id full_path = self._full_path(path) if full_path in self._write_locks: raise StorageError(f'File already open for writing: {path}') @@ -160,26 +590,25 @@ async def open_write(self, path: str, connection_id: int) -> str: self._handles[handle.handle_id] = handle return handle.handle_id - async def write_chunk(self, handle_id: str, data: bytes, connection_id: int = 0) -> int: + async def write_chunk(self, handle_id: str, data: bytes) -> int: """ - Write data to an open write handle. + Write data to an open write handle (ownership checked via the ctx). Args: handle_id: Handle returned by open_write. data: Bytes to append. - connection_id: Caller's connection ID for ownership check. Returns: Number of bytes written. """ - handle = self._get_handle(handle_id, FileHandleMode.WRITE, connection_id) + handle = self._get_handle(handle_id, FileHandleMode.WRITE) written = await self._store.write_chunk(handle.path, handle.context, data) handle.bytes_written += written return written - async def close_write(self, handle_id: str, connection_id: int = 0) -> None: + async def close_write(self, handle_id: str) -> None: """Close a write handle, committing the data.""" - handle = self._get_handle(handle_id, FileHandleMode.WRITE, connection_id) + handle = self._get_handle(handle_id, FileHandleMode.WRITE) try: await self._store.close_write(handle.path, handle.context) except StorageError as e: @@ -192,17 +621,18 @@ async def close_write(self, handle_id: str, connection_id: int = 0) -> None: # Handle-Based Read Operations # ========================================================================= - async def open_read(self, path: str, connection_id: int) -> dict: + async def open_read(self, path: str) -> dict: """ - Open a file for reading. + Open a file for reading. The handle is owned by the bound context's + conn_id (cleanup on disconnect; ownership-checked on use). Args: path: Relative path within the account store. - connection_id: ID of the owning connection (for cleanup on disconnect). Returns: Dict with 'handle' (str) and 'size' (int). """ + connection_id = self._ctx.conn_id full_path = self._full_path(path) if connection_id and self._count_handles_for(connection_id) >= MAX_HANDLES_PER_CONNECTION: raise StorageError(f'Too many open handles for connection {connection_id}') @@ -216,9 +646,7 @@ async def open_read(self, path: str, connection_id: int) -> dict: self._handles[handle.handle_id] = handle return {'handle': handle.handle_id, 'size': result['size']} - async def read_chunk( - self, handle_id: str, offset: int, length: int = MAX_CHUNK_SIZE, connection_id: int = 0 - ) -> bytes: + async def read_chunk(self, handle_id: str, offset: int, length: int = MAX_CHUNK_SIZE) -> bytes: """ Read data from an open read handle. @@ -235,12 +663,12 @@ async def read_chunk( if length <= 0: raise StorageError(f'Read length must be positive, got {length}') length = min(length, MAX_CHUNK_SIZE) - handle = self._get_handle(handle_id, FileHandleMode.READ, connection_id) + handle = self._get_handle(handle_id, FileHandleMode.READ) return await self._store.read_chunk(handle.path, handle.context, offset, length) - async def close_read(self, handle_id: str, connection_id: int = 0) -> None: + async def close_read(self, handle_id: str) -> None: """Close a read handle.""" - handle = self._get_handle(handle_id, FileHandleMode.READ, connection_id) + handle = self._get_handle(handle_id, FileHandleMode.READ) try: await self._store.close_read(handle.path, handle.context) finally: @@ -250,14 +678,15 @@ async def close_read(self, handle_id: str, connection_id: int = 0) -> None: # Connection Cleanup # ========================================================================= - async def close_all_handles(self, connection_id: int) -> None: + async def close_all_handles(self) -> None: """ - Force-close all handles owned by the given connection. + Force-close all handles owned by this instance's connection. - Called when a connection terminates to prevent resource leaks. - Write handles are committed with whatever data has been written. + Store.close_all_handles(conn_id) is the disconnect-time cleanup that + covers every instance; this is the per-view convenience. """ - handles_to_close = [h for h in self._handles.values() if h.connection_id == connection_id] + conn_id = self._ctx.conn_id + handles_to_close = [h for h in self._handles.values() if h.connection_id == conn_id] for handle in handles_to_close: await self._force_close_handle(handle.handle_id) @@ -265,7 +694,7 @@ async def close_all_handles(self, connection_id: int) -> None: # Convenience Methods (fire-and-forget, use handles internally) # ========================================================================= - async def read(self, path: str, connection_id: int = 0, max_size: int = MAX_READ_SIZE) -> bytes: + async def read(self, path: str, max_size: int = MAX_READ_SIZE) -> bytes: """ Read file contents as raw bytes. @@ -275,7 +704,6 @@ async def read(self, path: str, connection_id: int = 0, max_size: int = MAX_READ Args: path: Relative path within the account store. - connection_id: Owning connection ID. max_size: Maximum file size in bytes (default 100 MB). Files exceeding this are rejected to prevent OOM. @@ -286,7 +714,7 @@ async def read(self, path: str, connection_id: int = 0, max_size: int = MAX_READ StorageError: If the file does not exist, exceeds max_size, or read fails. """ - info = await self.open_read(path, connection_id) + info = await self.open_read(path) # Fail fast if the backend reports a size larger than the cap if info.get('size', 0) > max_size: await self.close_read(info['handle']) @@ -306,19 +734,18 @@ async def read(self, path: str, connection_id: int = 0, max_size: int = MAX_READ finally: await self.close_read(info['handle']) - async def write(self, path: str, data: bytes, connection_id: int = 0) -> None: + async def write(self, path: str, data: bytes) -> None: """ Write raw bytes to a file. Args: path: Relative path within the account store. data: Raw bytes to write. - connection_id: Owning connection ID. Raises: StorageError: If write fails. """ - handle_id = await self.open_write(path, connection_id) + handle_id = await self.open_write(path) try: await self.write_chunk(handle_id, data) await self.close_write(handle_id) @@ -336,7 +763,11 @@ async def delete(self, path: str) -> None: Raises: StorageError: If file does not exist, delete fails, or file is open for writing. """ - full_path = self._full_path(path) + full_path, kind, rest = self._resolve(path) + # Root guard: deleting a root — the caller's own account root + # included — is never meaningful; reject uniformly like rmdir/rename. + if not rest: + raise StorageError('Cannot delete a scope root') if full_path in self._write_locks: raise StorageError(f'Cannot delete file while it is open for writing: {path}') await self._store.delete_file(full_path) @@ -426,6 +857,15 @@ async def rmdir(self, path: str, recursive: bool = False) -> None: if not validated: raise StorageError('rmdir requires a non-empty path') + # Scope-root guard: NO root — '@/Team/', '@/Org', or the + # caller's own account root — may be the rmdir target; each would + # wipe a whole file area. Universal (not kind-conditional) like the + # delete/rename guards, so it holds even if a future normalization + # change lets an own-root spelling slip past the empty-path check. + _full, kind, rest = self._resolve(validated) + if not rest: + raise StorageError('rmdir cannot target a scope root') + full_prefix = self._full_path(validated.rstrip('/') + '/') # Refuse if any open handle or write-lock lives under this prefix; @@ -471,8 +911,14 @@ async def rename(self, old_path: str, new_path: str, overwrite: bool = False) -> writing, the destination already exists without ``overwrite``, or the operation fails. """ - old_full = self._full_path(old_path) - new_full = self._full_path(new_path) + old_full, old_kind, old_rest = self._resolve(old_path) + new_full, new_kind, new_rest = self._resolve(new_path) + # Root guard: NO root — the caller's own account root included — may + # be source or destination. An empty old_rest would make dir_prefix + # the whole account/scope tree and the copy loop below a whole-store + # move (mirror of the rmdir root protection). + if not old_rest or not new_rest: + raise StorageError('rename cannot target a scope root') # Check for directory (has children under old_path/) dir_prefix = old_full.rstrip('/') + '/' @@ -557,8 +1003,8 @@ async def stat(self, path: str) -> dict: # Private Methods # ========================================================================= - def _get_handle(self, handle_id: str, expected_mode: FileHandleMode, connection_id: int = 0) -> FileHandle: - """Look up a handle and validate its state and ownership.""" + def _get_handle(self, handle_id: str, expected_mode: FileHandleMode) -> FileHandle: + """Look up a handle and validate its state and ownership (via ctx).""" handle = self._handles.get(handle_id) if handle is None: raise StorageError(f'Invalid handle: {handle_id}') @@ -566,7 +1012,12 @@ def _get_handle(self, handle_id: str, expected_mode: FileHandleMode, connection_ raise StorageError(f'Handle already closed: {handle_id}') if handle.mode != expected_mode: raise StorageError(f'Wrong handle mode: expected {expected_mode.value}, got {handle.mode.value}') - if connection_id and handle.connection_id and handle.connection_id != connection_id: + # STRICT ownership: the registry is Store-wide (every identity in the + # process), so a lenient empty-conn_id bypass would let any default + # RequestContext reach another connection's live handle. Fail closed — + # only the exact owning conn_id (including '' == '' for unowned + # test contexts) may touch a handle. + if handle.connection_id != self._ctx.conn_id: raise StorageError('Handle belongs to another connection') return handle @@ -577,7 +1028,7 @@ def _release_handle(self, handle: FileHandle) -> None: if handle.mode == FileHandleMode.WRITE: self._write_locks.discard(handle.path) - def _count_handles_for(self, connection_id: int) -> int: + def _count_handles_for(self, connection_id: str) -> int: """Count currently-open handles owned by ``connection_id`` (for the DoS cap).""" return sum(1 for h in self._handles.values() if h.connection_id == connection_id) @@ -598,45 +1049,17 @@ def _assert_no_active_handles_under(self, prefix: str) -> None: raise StorageError(f'Cannot modify: handle open under {prefix}') async def _force_close_handle(self, handle_id: str) -> None: - """Force-close a handle, committing any written data. Best-effort.""" - handle = self._handles.get(handle_id) - if handle is None or handle.closed: - return - try: - if handle.mode == FileHandleMode.WRITE: - await self._store.close_write(handle.path, handle.context) - else: - await self._store.close_read(handle.path, handle.context) - except Exception as e: - # Best-effort cleanup — log at debug level so disconnect-time - # commit failures are traceable rather than silently lost. - debug( - f'FileStore._force_close_handle failed handle={handle_id} mode={handle.mode.value} path={handle.path}: {e}' - ) - finally: - self._release_handle(handle) + """Force-close a handle, committing any written data. Best-effort. + + Delegates to the owning Store's single per-handle teardown sequence + (shared with disconnect-time Store.close_all_handles). + """ + await self._owner._teardown_handle(self._handles.get(handle_id)) @staticmethod def _validate_path(path: str) -> str: - """Validate and normalize a user-provided path.""" - path = path.replace('\\', '/') - - if path.startswith('/'): - path = path.lstrip('/') - - parts = path.split('/') - if '..' in parts: - raise ValueError(f'Path traversal not allowed: {path}') - - for part in parts: - if part and any(c in _INVALID_SEGMENT_CHARS or ord(c) < 0x20 for c in part): - raise ValueError(f'Path contains invalid characters: {path}') - - normalized = str(PurePosixPath(path)) if path else '' - if normalized == '.': - normalized = '' - - return normalized + """Validate and normalize a user-provided path (module normalize_path).""" + return normalize_path(path) async def get_url(self, path: str, expires_in: int = 3600, download_name: Optional[str] = None) -> str: """ @@ -692,9 +1115,16 @@ async def get_url(self, path: str, expires_in: int = 3600, download_name: Option if not signing_key: raise ValueError('RR_SIGNING_KEY not configured — cannot generate fetch URL') + # The claim carries the RESOLVED physical store path, not the wire + # spelling: authorization already ran above (_full_path under THIS + # session's identity), and the signed JWT is the capability. The + # fetch handler serves the claim verbatim — it re-resolves nothing, + # because its internal identity has no name dictionary and no org + # context, so a name-based '@/Team/' or '@/Org' wire path + # could never resolve there (it would 500 instead of serving). payload = { 'sub': self._client_id, - 'path': path, + 'path': full_path, 'exp': int(time.time()) + expires_in, } # Carry the download filename in the signed claim so /task/fetch can set @@ -712,12 +1142,31 @@ async def get_url(self, path: str, expires_in: int = 3600, download_name: Option ) return f'{base_url}/task/fetch?token={token}' - def _full_path(self, path: str) -> str: - """Build the full storage path: users//files/.""" + def _resolve(self, path: str) -> 'tuple[str, str, str]': + """Normalize, authorize, and resolve a path for one operation class. + + THE single resolver+authorizer: normalization happens FIRST (via + ``_validate_path`` — the one normalizer, which is what defeats the + ``\\@\\Team\\...`` / ``/@/Team/...`` / ``./@/Team/...`` bypass family), + then ``resolve_scope`` maps the scope grammar to the + physical root and enforces the policy-required permission for the + bound identity. + + Returns: + (full_physical_path, scope_kind, rest) — scope_kind/rest let + mutating callers (rmdir/rename) apply scope-root guards. + """ validated = self._validate_path(path) - if validated: - return f'users/{self._client_id}/files/{validated}' - return f'users/{self._client_id}/files' + # resolve_scope hands back the kind it parsed — no second parse; the + # scope-root guards key off (kind, rest). + kind, scope_root, rest = resolve_scope(self._ctx, self._client_id, validated, engine_root=self._root) + full = f'{scope_root}/{rest}' if rest else scope_root + return full, kind, rest + + def _full_path(self, path: str) -> str: + """Authorize + resolve ``path`` to its full physical storage path.""" + full, _kind, _rest = self._resolve(path) + return full __all__ = [ diff --git a/packages/ai/src/ai/account/models.py b/packages/ai/src/ai/account/models.py index 30ee6344a..e67f46cb9 100644 --- a/packages/ai/src/ai/account/models.py +++ b/packages/ai/src/ai/account/models.py @@ -27,6 +27,7 @@ # ============================================================================= import time +from dataclasses import dataclass from typing import Literal, Optional, TypedDict from pydantic import BaseModel, Field @@ -131,6 +132,100 @@ def to_connect_result(self) -> dict: return self.model_dump(exclude={'auth'}) +# ============================================================================= +# REQUEST CONTEXT +# Per-request identity passed through the handler chain. In OSS standalone +# mode ctx is built from the connection's _account_info. In pod mode ctx +# is deserialized from the _ctx field injected by the Orchestrator into the +# forwarded request arguments. +# +# MERGE NOTE (feat/alb): the class below — fields, defaults, and docstring — +# is copied VERBATIM from the feat/alb branch, which introduces it and passes +# a ctx into every on_* handler. When feat/alb merges/rebases with this +# branch: +# * Keep ONE RequestContext: alb's core is identical; PRESERVE this +# branch's additions below it — the 'engine' source value and the +# internal()/engine() factories (the storage layer binds FileStore +# instances to a ctx and depends on them). +# * The 'internal'/'sys.admin' short-circuits in resolve_task_permissions +# and resolve_team_permissions exist identically on both branches — +# dedupe to one copy. +# * TaskConn.request_context() on this branch is the pre-alb local +# builder; once handlers receive ctx from alb's _build_request_context, +# replace request_context() call sites with the handler's ctx parameter +# and delete the helper. +# ============================================================================= + + +@dataclass +class RequestContext: + """ + Per-request caller identity for DAP command handlers. + + Every ``on_*`` handler receives a ``ctx`` parameter built by ``on_receive`` + before dispatch. Handlers use ``ctx.account_info`` for user identity and + ``ctx.conn_id`` for resource-scoping (file handles, profiler sessions). + + Attributes: + account_info: The authenticated user's AccountInfo. None only for + pre-auth commands (``on_auth``) and for engine-subprocess + contexts (``RequestContext.engine()`` — no account exists + in that process; the storage layer sandboxes it instead). + conn_id: Stable identifier for the originating client connection, + e.g. ``"orch-1:4527"`` (pod mode) or ``"conn-5"`` (OSS). + Used to scope and clean up per-connection resources. + source: ``'local'`` for OSS standalone connections or + ``'orchestrator'`` for commands forwarded by the + Orchestrator via an internal pod connection. + """ + + account_info: Optional[AccountInfo] = None + conn_id: str = '' + source: str = 'local' + + # ── Additions on this branch (see MERGE NOTE above) ────────────────────── + # source == 'engine' marks an ENGINE-SUBPROCESS context (tool nodes): no + # account exists in that process and its paths originate from LLM tool + # calls, so the storage layer sandboxes it to plain user-scope paths. + + @classmethod + def internal(cls, subsystem: str) -> 'RequestContext': + """Identity for a trusted server-internal subsystem. + + Follows the pod-service convention: an AccountInfo carrying the + ``internal`` sysPermission, which the permission resolvers expand to + the full team set and the storage policy accepts for internal-only + rules (e.g. writing run-log content). Greppable: every trusted caller + is a ``RequestContext.internal(...)`` call site. + + Args: + subsystem: Short name of the calling subsystem (e.g. 'run-log', + 'scheduler', 'fetch') — lands in conn_id for tracing. + """ + return cls( + account_info=AccountInfo( + userId=f'internal:{subsystem}', + displayName=f'internal:{subsystem}', + sysPermissions=['internal'], + ), + conn_id=f'internal:{subsystem}', + source='local', + ) + + @classmethod + def engine(cls, client_id: str) -> 'RequestContext': + """Identity for engine-subprocess tool nodes (most restricted). + + No account exists in the subprocess and paths come from LLM tool + calls: the storage layer allows plain user-scope paths only — every + ``@`` scope and reserved subtree is rejected. + + Args: + client_id: The task owner's client id (path scoping only). + """ + return cls(account_info=None, conn_id=f'engine:{client_id}', source='engine') + + # ============================================================================= # DEPLOYMENT RECORD # ============================================================================= @@ -177,6 +272,12 @@ def resolve_task_permissions(account_info: AccountInfo, task_team_id: str) -> li """ Return the caller's effective permissions for a task owned by the given team. + CONTRACT — RETURNS ``[]`` on no membership (never raises). Its sibling + ``resolve_team_permissions`` RAISES for the same condition; the two sit one + line apart with opposite failure modes, so read the name at each call site. + A rename to carry the difference (e.g. ``get_*`` vs ``require_*``) is + deferred to the feat/alb dedupe, where both branches touch these functions. + Unlike ``resolve_team_permissions`` this does **not** raise when the caller has no relationship to the team — it returns an empty list instead, signalling "no access". @@ -196,6 +297,12 @@ def resolve_task_permissions(account_info: AccountInfo, task_team_id: str) -> li Effective permission list, or empty list if the caller has no membership in the task's team. """ + # sys.admin and internal (pod service) credentials have full access to + # all tasks — identical lines exist on feat/alb; dedupe on merge. + sys_perms = getattr(account_info, 'sysPermissions', []) or [] + if 'sys.admin' in sys_perms or 'internal' in sys_perms: + return list(_FULL_TEAM_PERMISSIONS) + org = account_info.organization if not org: return [] @@ -212,6 +319,12 @@ def resolve_team_permissions(account_info: AccountInfo, team_id: str) -> list[st Return caller's permissions for a specific team. Expands org.admin to the full permission set. + CONTRACT — RAISES ``PermissionError`` on no membership. Its sibling + ``resolve_task_permissions`` RETURNS ``[]`` for the same condition; the two + sit one line apart with opposite failure modes, so read the name at each + call site. A rename to carry the difference (e.g. ``get_*`` vs + ``require_*``) is deferred to the feat/alb dedupe. + Raises PermissionError if the user has no membership in the given team. Args: @@ -229,6 +342,14 @@ def resolve_team_permissions(account_info: AccountInfo, team_id: str) -> list[st Raises: PermissionError: If ``team_id`` is not found in the user's org. """ + # sys.admin and internal (pod service) credentials get full access + # regardless of team membership — mirrors resolve_task_permissions so the + # team-scoped and task-scoped permission surfaces agree for these callers. + # Identical lines exist on feat/alb; dedupe on merge. + sys_perms = getattr(account_info, 'sysPermissions', []) or [] + if 'sys.admin' in sys_perms or 'internal' in sys_perms: + return list(_FULL_TEAM_PERMISSIONS) + org = account_info.organization if org: for team in org.get('teams', []): diff --git a/packages/ai/src/ai/account/store.py b/packages/ai/src/ai/account/store.py index 8414e2005..89978c0c7 100644 --- a/packages/ai/src/ai/account/store.py +++ b/packages/ai/src/ai/account/store.py @@ -6,7 +6,8 @@ - AWS S3: Amazon S3 object storage - Azure Blob: Azure Blob Storage -Configuration via STORE_URL environment variable. +Configuration via RR_STORE_URL environment variable (the legacy STORE_URL +name hard-fails at startup so a stale deployment cannot silently fall back). Defaults to filesystem://~/.rocketlib/dtc if not set (user home directory). Falls back to temp directory if home directory cannot be determined. """ @@ -15,6 +16,7 @@ import os import re import tempfile +import threading from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Any, Optional @@ -415,7 +417,7 @@ class Store: from ai.account.store import Store from ai.account import AccountInfo - # Create store (uses STORE_URL env var or defaults to filesystem) + # Create store (uses RR_STORE_URL env var or defaults to filesystem) store = Store.create() # Or specify backend explicitly @@ -447,18 +449,133 @@ class Store: # Initialization # ========================================================================= + # The process-wide singleton. A server has exactly ONE store (one + # RR_STORE_URL) — production code accesses it via Store.instance() / + # Store.file_store(ctx), never by constructing stores ad hoc. The + # constructor stays reachable for backend-specific tests only. + _instance: 'Optional[Store]' = None + + # Guards lazy creation: two threads racing instance() (e.g. node init in + # the engine subprocess via engine_file_store) must never each build a + # Store — the loser's copy would carry its own _shared_handles / + # _shared_write_locks and silently defeat cross-instance write exclusion. + _instance_lock = threading.Lock() + def __init__(self, store: IStore): """ Initialize Store wrapper with an IStore backend. - Note: Typically you should use Store.create() factory method instead - of calling this constructor directly. + Note: Production code uses the process singleton (Store.instance() / + Store.file_store(ctx)); constructing directly is for backend-specific + tests and tools. Args: store: Backend storage implementation (FilesystemStore, S3Store, AzureBlobStore) """ self._store = store - self._file_stores: dict = {} + # SHARED single-process guards for every FileStore constructed from + # this Store: the open-handle registry and the set of physical paths + # currently open for writing. They live HERE (not on FileStore) + # because instances are per-consumer and identity-bound — two users' + # instances addressing the same team file must still exclude each + # other. NOT distributed locks: cross-node consistency remains the + # backend's whole-object atomicity / CAS. + self._shared_handles: dict = {} + self._shared_write_locks: set = set() + + # ========================================================================= + # Singleton access + # ========================================================================= + + @classmethod + def instance(cls) -> 'Store': + """ + The process-wide store singleton, connected lazily on first use. + + Resolves the backend from RR_STORE_URL / RR_STORE_SECRET_KEY (see + create() for the resolution and failure rules). Both the server and + the engine subprocess use this — the subprocess inherits the env, so + the SAME mechanism works on both sides of the process boundary. + """ + # Double-checked locking: the fast path stays lock-free once built; + # the lock only serializes first-use creation so exactly one Store + # (and one shared handle/lock registry) ever exists per process. + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls.create() + return cls._instance + + @classmethod + def reset(cls) -> None: + """ + Drop the process singleton (next instance() reconnects from the env). + + Lifecycle hook: used between test cases and by any future + reconfigure-on-the-fly path. Does not close backend connections — + backends are stateless per-operation. + """ + cls._instance = None + + @classmethod + def file_store(cls, ctx, client_id: Optional[str] = None, root: Optional[str] = None) -> 'FileStore': + """ + The one-liner every call site uses: an identity-bound FileStore view + over the process singleton. + + Args: + ctx: The per-request RequestContext (the handler's ctx, or + RequestContext.internal()/engine() for server subsystems). + client_id: Whose plain-path namespace the view anchors to. + Sessions may omit it (derived from ctx.account_info.userId) + and may NOT name a foreign one (mismatch raises) — only + internal/engine identities address other users' namespaces, + and must pass it explicitly. + root: ENGINE contexts only — the task-file storage anchor + (``storage.root``) all plain paths resolve under; see + FileStore.__init__. + + Returns: + A new FileStore bound to (singleton store, client_id, ctx). + """ + return cls.instance()._file_store(ctx, client_id, root) + + @staticmethod + def _get_current_task() -> 'Optional[dict]': + """The engine's currently-executing task file (rocketlib.getTask). + + Isolated for testability and for running outside the engine (plain + python has no rocketlib) — both simply report 'no task'. + """ + try: + from rocketlib import getTask + except ImportError: + return None + return getTask() + + @classmethod + def engine_file_store(cls) -> 'Optional[FileStore]': + """A FileStore for the CURRENT engine task — fully transparent to + nodes: identity and the storage anchor come from the task file the + engine published (rocketlib.getTask), never from the environment + and never from per-node jobConfig plumbing. + + Returns: + An engine-context FileStore anchored at the task's + ``storage.root`` (the owner's tree for dev runs, a + task-specific team subtree for deploy runs), or None when no + task is running or it carries no identity. + """ + from .file_store import FileStore # noqa: F401 (return type) + from .models import RequestContext + + task = cls._get_current_task() or {} + identity = task.get('identity') or {} + client_id = str(identity.get('userId') or '').strip() + if not client_id: + return None + root = str((task.get('storage') or {}).get('root') or '').strip() or None + return cls.file_store(RequestContext.engine(client_id), client_id=client_id, root=root) # ========================================================================= # Public Static Methods @@ -473,10 +590,16 @@ def create( Create storage instance. Args: - url: Storage URL (overrides STORE_URL env var) + url: Storage URL (overrides the RR_STORE_URL env var) Default: filesystem://~/.rocketlib/dtc (user home directory) Fallback: filesystem:///.rocketlib/dtc (if home unavailable) - secret_key: Authentication credentials (overrides STORE_SECRET_KEY env var) + secret_key: Authentication credentials (overrides RR_STORE_SECRET_KEY) + + Raises: + EnvironmentError: If the LEGACY variable (STORE_URL / + STORE_SECRET_KEY) is set while its RR_-prefixed replacement is + not — a hard fail so stale deployments get updated instead of + silently flipping to the default directory. Returns: Store instance wrapping the appropriate storage backend @@ -484,16 +607,28 @@ def create( Raises: ValueError: If URL format is invalid or backend not supported """ - # Get configuration from environment if not provided + # Get configuration from environment if not provided. The variables + # live in the RR_* server-internal tier; the LEGACY unprefixed names + # are a HARD ERROR (no fallback — a fallback would never get updated, + # and silently ignoring the legacy value would flip that deployment + # onto the default directory, which is worse than failing). if url is None: - url = os.environ.get('STORE_URL') + url = os.environ.get('RR_STORE_URL') + if url is None and os.environ.get('STORE_URL'): + raise EnvironmentError( + 'STORE_URL has been renamed to RR_STORE_URL — update the deployment configuration.' + ) # Use default if not provided if url is None: url = Store._get_default_storage_url() if secret_key is None: - secret_key = os.environ.get('STORE_SECRET_KEY') + secret_key = os.environ.get('RR_STORE_SECRET_KEY') + if secret_key is None and os.environ.get('STORE_SECRET_KEY'): + raise EnvironmentError( + 'STORE_SECRET_KEY has been renamed to RR_STORE_SECRET_KEY — update the deployment configuration.' + ) # Expand environment variables url = Store._expand_url_path(url) @@ -527,24 +662,111 @@ def create( # Wrap backend in Store instance return Store(backend) - def get_file_store(self, client_id: str) -> 'FileStore': + def _file_store(self, ctx, client_id: 'Optional[str]' = None, root: 'Optional[str]' = None) -> 'FileStore': + """ + Construct an identity-bound FileStore view (see Store.file_store). + + Instances are cheap per-consumer wrappers and identity must NEVER be + cached — a user's concurrent sessions can carry different permission + envelopes (PAT-scoped sessions, synthetic tk_/pk_ sessions). The + shared write-lock/handle registries live on this Store, so + non-caching loses no coordination. + + The client_id rules make cross-namespace mistakes unwritable: + - session ctx: client_id is DERIVED from ctx.account_info.userId; + an explicit value must match (a session can never anchor to a + foreign user's namespace). + - internal/engine ctx: client_id is REQUIRED (whose namespace the + subsystem operates in — e.g. the run-log writer targets the task + owner's area while acting as 'internal'). + """ + from .file_store import FileStore + from .models import RequestContext + + if not isinstance(ctx, RequestContext): + raise TypeError('ctx must be a RequestContext') + + sys_perms = (ctx.account_info.sysPermissions or []) if ctx.account_info else [] + # Trusted (engine/internal) contexts name the namespace they operate + # in. Account-LESS contexts also take the explicit-client_id branch: + # they stay constructible (pre-auth paths, backend tests) because + # resolve_scope already denies their every operation with + # 'Not authenticated' — nothing is reachable through them. + is_session = ctx.account_info is not None and ctx.source != 'engine' and 'internal' not in sys_perms + + if is_session: + # Sessions anchor to their own namespace, always — derived from + # the authenticated identity, never from an explicit client_id. + # A session-shaped ctx with no userId (e.g. a task-scoped + # AccountInfo built from an empty control.userId) is rejected + # outright: accepting a client_id for it would unlock an + # arbitrary user's store. + session_user = getattr(ctx.account_info, 'userId', '') or '' + if not session_user: + raise PermissionError('Session identity carries no userId — cannot anchor a file store') + if client_id and client_id != session_user: + raise PermissionError('A session cannot anchor to a foreign user namespace') + client_id = session_user + elif not client_id: + raise ValueError('internal/engine contexts must pass an explicit client_id') + + return FileStore(self, client_id, ctx, root) + + async def close_all_handles(self, connection_id) -> None: + """ + Force-close every open file handle owned by a connection. + + Disconnect-time cleanup over the SHARED handle registry (handles are + recorded store-wide, so this covers every FileStore instance the + connection ever constructed). Write handles commit whatever was + written; failures are logged and swallowed (best-effort, mirrors + FileStore._force_close_handle). + + Args: + connection_id: The ctx.conn_id string (or legacy int, coerced). """ - Get a FileStore instance scoped to a specific account. + conn_id = str(connection_id) + doomed = [h for h in self._shared_handles.values() if h.connection_id == conn_id] + for handle in doomed: + await self._teardown_handle(handle) - FileStore instances are cached per client_id to avoid repeated - instantiation for the same account. + async def _teardown_handle(self, handle) -> None: + """ + Force-close ONE handle: commit/close at the backend, then drop its + registry entry and release any write lock. Best-effort — backend + failures are logged and swallowed. - Args: - client_id: Account identifier for path scoping. + THE single per-handle teardown sequence: both disconnect-time cleanup + (close_all_handles above) and FileStore._force_close_handle delegate + here so the close/commit/mark-closed/release-lock steps cannot drift. - Returns: - FileStore instance scoped to the given account. + Args: + handle: A FileHandle from the shared registry (None/closed = no-op). """ - if client_id not in self._file_stores: - from .file_store import FileStore + from rocketlib import debug - self._file_stores[client_id] = FileStore(self._store, client_id) - return self._file_stores[client_id] + from .file_store import FileHandleMode + + if handle is None or handle.closed: + return + try: + # Commit/close at the backend according to the handle mode. + if handle.mode == FileHandleMode.WRITE: + await self._store.close_write(handle.path, handle.context) + else: + await self._store.close_read(handle.path, handle.context) + except Exception as exc: + # Best-effort cleanup — log at debug level so commit failures are + # traceable rather than silently lost. + debug( + f'Store._teardown_handle: failed to close {handle.handle_id} mode={handle.mode.value} path={handle.path}: {exc}' + ) + finally: + # Drop the registry entry and release any write lock. + handle.closed = True + self._shared_handles.pop(handle.handle_id, None) + if handle.mode == FileHandleMode.WRITE: + self._shared_write_locks.discard(handle.path) # ========================================================================= # Private Static Methods diff --git a/packages/ai/src/ai/account/store_providers/filesystem/__init__.py b/packages/ai/src/ai/account/store_providers/filesystem/__init__.py new file mode 100644 index 000000000..865ade496 --- /dev/null +++ b/packages/ai/src/ai/account/store_providers/filesystem/__init__.py @@ -0,0 +1,5 @@ +"""FilesystemStore package.""" + +from .filesystem import FilesystemStore + +__all__ = ['FilesystemStore'] diff --git a/packages/ai/src/ai/account/store_providers/filesystem.py b/packages/ai/src/ai/account/store_providers/filesystem/filesystem.py similarity index 99% rename from packages/ai/src/ai/account/store_providers/filesystem.py rename to packages/ai/src/ai/account/store_providers/filesystem/filesystem.py index 7dcbc37e0..434acede7 100644 --- a/packages/ai/src/ai/account/store_providers/filesystem.py +++ b/packages/ai/src/ai/account/store_providers/filesystem/filesystem.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Optional import fnmatch -from ..store import IStore, StorageError, VersionMismatchError +from ...store import IStore, StorageError, VersionMismatchError # Import platform-specific locking if sys.platform == 'win32': diff --git a/packages/ai/src/ai/account/store_providers/memory/__init__.py b/packages/ai/src/ai/account/store_providers/memory/__init__.py new file mode 100644 index 000000000..14b7063ae --- /dev/null +++ b/packages/ai/src/ai/account/store_providers/memory/__init__.py @@ -0,0 +1,5 @@ +"""MemoryStore package (in-memory backend for tests and ephemeral use).""" + +from .memory import MemoryStore + +__all__ = ['MemoryStore'] diff --git a/packages/ai/src/ai/account/store_providers/memory.py b/packages/ai/src/ai/account/store_providers/memory/memory.py similarity index 98% rename from packages/ai/src/ai/account/store_providers/memory.py rename to packages/ai/src/ai/account/store_providers/memory/memory.py index b427a1cc1..354550e63 100644 --- a/packages/ai/src/ai/account/store_providers/memory.py +++ b/packages/ai/src/ai/account/store_providers/memory/memory.py @@ -26,7 +26,7 @@ from typing import Optional import posixpath -from ..store import IStore, StorageError, VersionMismatchError +from ...store import IStore, StorageError, VersionMismatchError class MemoryStore(IStore): diff --git a/packages/ai/src/ai/common/models/audio/requirements_whisper.txt b/packages/ai/src/ai/common/models/audio/requirements_whisper.txt index 3b571f290..f55b0688d 100644 --- a/packages/ai/src/ai/common/models/audio/requirements_whisper.txt +++ b/packages/ai/src/ai/common/models/audio/requirements_whisper.txt @@ -5,6 +5,10 @@ faster-whisper numpy # onnxruntime is an indirect dep (via faster-whisper), not imported here. -# Pin 1.20.1 to match the one shared install (rtmlib needs exactly that). -onnxruntime-gpu==1.20.1; platform_system != 'Darwin' -onnxruntime==1.20.1; platform_system == 'Darwin' +# One shared version across every onnxruntime consumer, so the ~200 MB runtime installs once. +# rtmlib/gliner declare it unpinned and faster-whisper wants <2,>=1.14 -- the exact number is +# ours to choose, but all these files must agree. 1.20.1 was withdrawn from PyPI for the -gpu +# build (the CPU build of it survives), which is why this moved; 1.20.2 is the mirror trap -- +# it exists only for -gpu, so pinning it would break macOS. +onnxruntime-gpu==1.22.0; platform_system != 'Darwin' +onnxruntime==1.22.0; platform_system == 'Darwin' diff --git a/packages/ai/src/ai/common/models/gliner/requirements_gliner.txt b/packages/ai/src/ai/common/models/gliner/requirements_gliner.txt index 57d23ab67..a2b485caf 100644 --- a/packages/ai/src/ai/common/models/gliner/requirements_gliner.txt +++ b/packages/ai/src/ai/common/models/gliner/requirements_gliner.txt @@ -3,7 +3,11 @@ gliner # needed for gliner_ko python-mecab-ko # onnxruntime is an indirect dep (via gliner), not imported here. -# Pin 1.20.1 to match the one shared install (rtmlib needs exactly that). -onnxruntime-gpu==1.20.1; platform_system != 'Darwin' -onnxruntime==1.20.1; platform_system == 'Darwin' +# One shared version across every onnxruntime consumer, so the ~200 MB runtime installs once. +# rtmlib/gliner declare it unpinned and faster-whisper wants <2,>=1.14 -- the exact number is +# ours to choose, but all these files must agree. 1.20.1 was withdrawn from PyPI for the -gpu +# build (the CPU build of it survives), which is why this moved; 1.20.2 is the mirror trap -- +# it exists only for -gpu, so pinning it would break macOS. +onnxruntime-gpu==1.22.0; platform_system != 'Darwin' +onnxruntime==1.22.0; platform_system == 'Darwin' diff --git a/packages/ai/src/ai/common/models/vision/requirements_pose.txt b/packages/ai/src/ai/common/models/vision/requirements_pose.txt index 2e164d3b9..754e5fb8d 100644 --- a/packages/ai/src/ai/common/models/vision/requirements_pose.txt +++ b/packages/ai/src/ai/common/models/vision/requirements_pose.txt @@ -5,6 +5,6 @@ rtmlib>=0.0.13 # onnxruntime is an indirect dep (via rtmlib), not imported here. # Pin 1.20.1 — the version rtmlib needs. -onnxruntime-gpu==1.20.1; platform_system != 'Darwin' -onnxruntime==1.20.1; platform_system == 'Darwin' +onnxruntime-gpu==1.22.0; platform_system != 'Darwin' +onnxruntime==1.22.0; platform_system == 'Darwin' Pillow diff --git a/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py b/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py index b2811ba48..64d86999b 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py @@ -120,8 +120,10 @@ async def _proxy_to_task(self, request: Dict[str, Any], target: str) -> Dict[str Returns: DAP response with the subprocess result body. """ - # Look up the task control entry by token - control = self._server.get_task_control(target) + # Look up the task control entry by token, requiring task.control on + # the TASK'S team. Without this, any defaultTeam task.control holder + # could drive the profiler inside another team's engine subprocess. + control = self._server.get_task_control(target, self._account_info, require='task.control') task = control.task # Wait for the task to reach running state diff --git a/packages/ai/src/ai/modules/task/commands/cmd_debug.py b/packages/ai/src/ai/modules/task/commands/cmd_debug.py index bbd614029..4d1e4e8ea 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_debug.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_debug.py @@ -50,7 +50,7 @@ The actual task execution and management is delegated to the TaskServer. """ -from typing import TYPE_CHECKING, Dict, Any, Optional +from typing import TYPE_CHECKING, Dict, Any from ai.common.dap import DAPConn, TransportBase # Only import for type checking to avoid circular import errors @@ -203,9 +203,6 @@ async def on_launch(self, request: Dict[str, Any]) -> Dict[str, Any]: Exception: If task creation or debugger attachment fails """ try: - # Verify permission - don't have a task yet - self.verify_permission('task.debug') - # Each debug session must have it's own unique connection if self._debug_token: raise RuntimeError('Debugger already active on this session') @@ -214,18 +211,18 @@ async def on_launch(self, request: Dict[str, Any]) -> Dict[str, Any]: args = request.get('arguments') or {} team_id = args.get('teamId') or self._account_info.defaultTeam - # Resolve org_id from the user's single organization. - org_id: Optional[str] = None - org = self._account_info.organization - if org: - for team in org.get('teams', []): - if team.get('id') == team_id: - org_id = org.get('id', '') - break - if org_id is None: - raise PermissionError( - f'Team {team_id!r} does not belong to any organisation for user {self._account_info.userId!r}' - ) + # Verify task.debug ON THE TARGET TEAM — membership alone is not + # enough (the previous check only proved the team exists in the + # caller's org), and a defaultTeam check alone would miss a + # foreign teamId entirely. + self.verify_team_permission(team_id, 'task.debug') + + # Resolve the org that owns the TARGET team. Members resolve via + # their own org; callers passing the permission check without + # membership (sys.admin, internal) resolve via the account backend + # so the task file never carries an empty orgId as trusted + # identity (rejected if the team's org cannot be determined). + org_id = await self.resolve_org_for_team(team_id) # Create and start the new task, obtaining a unique token response = await self._server.start_task( diff --git a/packages/ai/src/ai/modules/task/commands/cmd_log.py b/packages/ai/src/ai/modules/task/commands/cmd_log.py index 2560e367c..6581aa535 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_log.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_log.py @@ -34,6 +34,7 @@ from typing import TYPE_CHECKING, Any, Dict +from ai.account import Store from ai.common.dap import DAPConn, TransportBase from ai.modules.task.run_log import RunLogReader @@ -145,11 +146,15 @@ def _reader_for(self, args: Dict[str, Any]) -> RunLogReader: raise ValueError(f'runKind must be one of {sorted(_VALID_RUN_KINDS)}') # Store scoping comes from the AUTHENTICATED user, never from input: - # v1 serves each caller their own streams only. The account-scoped - # FileStore owns the users//files/ prefix (and refuses an empty - # user id), so log paths stay relative ('.logs/…'). + # v1 serves each caller their own streams only. .logs is a SYSTEM + # TREE — the file API denies it to every session identity — so the + # reader acts through an INTERNAL identity anchored at the caller's + # namespace. The DOMAIN permission gate is this command layer's own + # verify_permission('task.monitor'/'task.control') checks above. + from ai.account import RequestContext + return RunLogReader( - self._server.store.get_file_store(self._account_info.userId), + Store.file_store(RequestContext.internal('log-reader'), client_id=self._account_info.userId), self._account_info.userId, project_id, source, diff --git a/packages/ai/src/ai/modules/task/commands/cmd_store.py b/packages/ai/src/ai/modules/task/commands/cmd_store.py index c756eec98..a531b8d1a 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_store.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_store.py @@ -33,6 +33,7 @@ """ from typing import TYPE_CHECKING, Dict, Any +from ai.account.file_store import SYSTEM_TREES, normalize_path from ai.common.dap import DAPConn, TransportBase if TYPE_CHECKING: @@ -93,8 +94,12 @@ async def on_rrext_store(self, request: Dict[str, Any]) -> Dict[str, Any]: DAP response (format depends on subcommand). """ try: - # Require store permission (once for all subcommands) - self.verify_permission('task.store') + # NO permission check here: the STORE is the security boundary. + # Every path resolution inside the identity-bound FileStore + # enforces the policy-required permission for the addressed scope + # (plain paths behave like the old defaultTeam task.store hoist; + # @/Team/@/Org paths check the addressed team/org; reserved + # subtrees like .logs apply their own rules). # Extract subcommand args = request.get('arguments', {}) @@ -119,15 +124,16 @@ async def on_rrext_store(self, request: Dict[str, Any]) -> Dict[str, Any]: def _get_file_store(self): """ - Get a FileStore scoped to the authenticated user. + Get a FileStore bound to the authenticated session's identity. Returns: - FileStore instance that isolates all paths under the current - user's storage namespace. + FileStore whose every path resolution authorizes against THIS + session (plain paths under the user's namespace as before; the + @/Team|@/Org grammar against the addressed scope's permissions). """ - # Scope the file store to the calling user so users cannot access each - # other's files through the store API. - return self._server.store.get_file_store(self._account_info.userId) + from ai.account import Store + + return Store.file_store(self.request_context()) # ========================================================================= # FS SUBCOMMAND HANDLERS @@ -150,11 +156,11 @@ async def _store_fs_open(self, request: Dict[str, Any], args: Dict[str, Any]) -> if mode == 'w': # Create a write handle tied to this connection for cleanup on disconnect - handle_id = await fs.open_write(path, self._connection_id) + handle_id = await fs.open_write(path) return self.build_response(request, body={'handle': handle_id}) else: # Open for reading; returns handle ID plus file metadata - result = await fs.open_read(path, self._connection_id) + result = await fs.open_read(path) return self.build_response(request, body=result) async def _store_fs_read(self, request: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: @@ -182,7 +188,7 @@ async def _store_fs_read(self, request: Dict[str, Any], args: Dict[str, Any]) -> length = min(length, 4_194_304) # Read the chunk - data = await fs.read_chunk(handle, offset, length, connection_id=self._connection_id) + data = await fs.read_chunk(handle, offset, length) # body carries byte count; arguments carries raw data separately response = self.build_response(request, body={'size': len(data)}) @@ -208,7 +214,7 @@ async def _store_fs_write(self, request: Dict[str, Any], args: Dict[str, Any]) - if isinstance(data, str): data = data.encode('utf-8') - written = await fs.write_chunk(handle, data, connection_id=self._connection_id) + written = await fs.write_chunk(handle, data) return self.build_response(request, body={'bytesWritten': written}) async def _store_fs_close(self, request: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: @@ -227,9 +233,9 @@ async def _store_fs_close(self, request: Dict[str, Any], args: Dict[str, Any]) - mode = args.get('mode', 'r') if mode == 'w': - await fs.close_write(handle, connection_id=self._connection_id) + await fs.close_write(handle) else: - await fs.close_read(handle, connection_id=self._connection_id) + await fs.close_read(handle) return self.build_response(request) async def _store_fs_delete(self, request: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: @@ -246,9 +252,73 @@ async def _store_fs_delete(self, request: Dict[str, Any], args: Dict[str, Any]) await self._get_file_store().delete(args.get('path')) return self.build_response(request) + def _virtual_scope_mounts(self) -> list: + """The '@' listing: the joined filesystem's scope mounts. + + `User/` and `Team/` always appear; `Org/` only for org.admin (or + sys.admin) holders — org storage is admin-only, a dead entry would + just confuse. Entry names compose by ordinary path joining + ('@' + '/' + 'Team' -> '@/Team'). Entries are VIRTUAL — synthesized + from the session, never from a physical listing (which would leak + other orgs' scopes). + """ + org = getattr(self._account_info, 'organization', None) or {} + sys_perms = getattr(self._account_info, 'sysPermissions', None) or [] + entries = [ + {'name': 'User', 'type': 'dir', 'virtual': True}, + {'name': 'Team', 'type': 'dir', 'virtual': True}, + ] + if 'org.admin' in (org.get('permissions') or []) or 'sys.admin' in sys_perms: + entries.append({'name': 'Org', 'type': 'dir', 'virtual': True}) + return entries + + def _list_scope_mount(self, mount: str) -> Dict[str, Any]: + """Directory listing for the virtual mounts ('@' and '@/Team'). + + '@' lists the scope mounts themselves; '@/Team' lists the caller's + teams by DISPLAY NAME with the id in the entry body, so the file + browser can show names while scripts address ids. ('@/User' and + '@/Org' are REAL trees — the store lists them.) + """ + if mount == '@': + entries = self._virtual_scope_mounts() + return {'entries': entries, 'count': len(entries)} + org = getattr(self._account_info, 'organization', None) or {} + entries = [ + {'name': row.get('name') or row['id'], 'type': 'dir', 'id': row['id'], 'virtual': True} + for row in (org.get('teams') or []) + if row.get('id') + ] + return {'entries': entries, 'count': len(entries)} + + @staticmethod + def _is_scope_root(path: str) -> bool: + """True when ``path`` lists the TOP of a user/team/org tree. + + Scope roots are where the system trees live and where reserved + names get filtered: '' and '@/User' (own root), '@/Org' (own org + root), '@/Team/' (a team root), and the cross-boundary + '@/User/=' / '@/Org/=' spellings. + """ + if not path: + return True + segments = path.split('/') + if segments[0] != '@' or len(segments) < 2: + return False + if segments[1] in ('User', 'Org'): + return len(segments) == 2 or (len(segments) == 3 and segments[2].startswith('=')) + return segments[1] == 'Team' and len(segments) == 3 + async def _store_fs_list_dir(self, request: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: """ - List directory contents. + List directory contents of the joined filesystem. + + Plain paths list the caller's own tree (simple mode). '@' lists the + scope mounts and '@/Team' the caller's memberships — both virtual. + Everything else ('@/User/...', '@/Team//...', '@/Org/...') is + a real store listing. Scope-root listings hide the system trees + from non-sys.admin callers and drop reserved '@'/'='-prefixed + physical names (unaddressable legacy artifacts). Args: request: Original DAP request. @@ -257,7 +327,33 @@ async def _store_fs_list_dir(self, request: Dict[str, Any], args: Dict[str, Any] Returns: DAP response with directory listing. """ - result = await self._get_file_store().list_dir(args.get('path', '')) + # ONE normalization (the store's own) drives BOTH the scope-root + # filter decision and the store resolution below — deciding on a raw + # spelling while resolving the normalized one would let '@//User'- + # style paths reach the user root with the system trees unfiltered. + path = normalize_path(args.get('path', '') or '') + + # The virtual mounts list from the session, not storage. + if path in ('@', '@/Team'): + return self.build_response(request, body=self._list_scope_mount(path)) + + result = await self._get_file_store().list_dir(path) + + if self._is_scope_root(path): + entries = result['entries'] + # SYSTEM TREES (.logs, .deployments) are invisible at scope + # roots for everyone except sys.admin: they are engine-written, + # served by their own domain APIs, and may hold data the user + # must not see raw. + sys_perms = getattr(self._account_info, 'sysPermissions', None) or [] + if 'sys.admin' not in sys_perms: + entries = [e for e in entries if e['name'] not in SYSTEM_TREES] + # Reserved sigils: physical '@*' / '=*' names at a scope root + # predate the grammar, cannot be created or addressed anymore — + # drop them rather than show dead entries. + entries = [e for e in entries if not e['name'].startswith(('@', '='))] + result = {'entries': entries, 'count': len(entries)} + return self.build_response(request, body=result) async def _store_fs_mkdir(self, request: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: @@ -308,7 +404,18 @@ async def _store_fs_stat(self, request: Dict[str, Any], args: Dict[str, Any]) -> Returns: DAP response with metadata (size, modified time, type, etc.). """ - result = await self._get_file_store().stat(args.get('path')) + # The scope mounts stat as directories: '@' and '@/Team' have no + # physical existence at all, and '@/User'/'@/Org' roots always + # exist conceptually (access control applies on ENTRY, not on the + # mount). Same rule as _store_fs_list_dir: ONE normalization (the + # store's own) drives BOTH the mount decision and the store + # resolution below — and it rejects traversal outright instead of + # letting a raw spelling slide through to the resolver. + path = normalize_path(args.get('path') or '') + if path in ('@', '@/User', '@/Team', '@/Org'): + return self.build_response(request, body={'exists': True, 'type': 'dir', 'virtual': True}) + + result = await self._get_file_store().stat(path) return self.build_response(request, body=result) async def _store_fs_rename(self, request: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]: diff --git a/packages/ai/src/ai/modules/task/commands/cmd_task.py b/packages/ai/src/ai/modules/task/commands/cmd_task.py index 188798f39..042fd08d4 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_task.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_task.py @@ -125,27 +125,28 @@ async def on_execute(self, request: Dict[str, Any]) -> Dict[str, Any]: Exception: If task creation or execution startup fails """ try: - # Verify permission - self.verify_permission('task.control') + # Use client-supplied teamId if present, otherwise fall back to defaultTeam. + args = request.get('arguments') or {} + team_id = args.get('teamId') or self._account_info.defaultTeam + + # Verify task.control ON THE TARGET TEAM — not defaultTeam — and + # BEFORE any secret handling. A client-supplied teamId outside the + # caller's permissions previously slipped through with org_id='' + # and pulled that team's secrets in the env merge below. + self.verify_team_permission(team_id, 'task.control') # Verify required pipeline plans - args = request.get('arguments') or {} pipeline = args.get('pipeline') if pipeline is not None: # Check that the pipeline's required plan is available for this account. self.verify_plans(self._account_info, pipeline) - # Use client-supplied teamId if present, otherwise fall back to defaultTeam. - team_id = args.get('teamId') or self._account_info.defaultTeam - - # Resolve org_id from the user's single organization. - org_id = '' - org = self._account_info.organization - if org: - for team in org.get('teams', []): - if team.get('id') == team_id: - org_id = org.get('id', '') - break + # Resolve the org that owns the TARGET team (same resolution as + # on_launch): members via their own org, sys.admin/internal via + # the account backend — the task file must never carry an empty + # orgId as trusted identity, and the secret merge below must pull + # the TARGET team's real org layer. + org_id = await self.resolve_org_for_team(team_id) # Build merged environment for pipeline variable resolution. # Combines .env → org → team → user secrets (SaaS) or just .env (OSS). @@ -210,8 +211,12 @@ async def on_restart(self, request: Dict[str, Any]) -> Dict[str, Any]: Exception: If task creation or execution startup fails """ try: - # Verify permission - self.verify_permission('task.control') + # Authorize against the TASK'S team, not defaultTeam: get_task + # resolves the token to its control entry and requires + # task.control on that team (sys.admin bypasses). A defaultTeam + # check alone let any task.control holder restart other teams' + # token-addressed tasks. + self.get_task(request, 'task.control') # Start the task without debugger attachment response = await self._server.restart_task( diff --git a/packages/ai/src/ai/modules/task/fetch.py b/packages/ai/src/ai/modules/task/fetch.py index 76842622e..582389522 100644 --- a/packages/ai/src/ai/modules/task/fetch.py +++ b/packages/ai/src/ai/modules/task/fetch.py @@ -45,8 +45,9 @@ async def handle_fetch(request: Request): Serve a file from the filesystem store, authenticated via JWT query param. The JWT payload must contain: - - ``sub``: userId (scopes the file store) - - ``path``: relative file path within the user's store + - ``sub``: userId that the URL was issued to (audit trail only) + - ``path``: RESOLVED physical store path (the capability — scope + resolution and authorization ran at issuance, in FileStore.get_url) - ``exp``: expiration timestamp (standard JWT claim) """ # ── Extract and validate JWT ───────────────────────────────────────── @@ -73,19 +74,20 @@ async def handle_fetch(request: Request): if not user_id or not path: return JSONResponse({'error': 'Token missing required claims'}, status_code=400) - # ── Resolve the absolute file path via the store ───────────────────── - task_server = request.app.state.task - file_store = task_server.store.get_file_store(user_id) + # ── Map the signed store path to its filesystem location ───────────── + # The `path` claim is the RESOLVED physical store path: authorization + # happened at URL ISSUANCE (FileStore.get_url resolved+authorized under + # the requesting session's identity), and the signed JWT is the + # capability. Nothing is re-resolved here — scope re-resolution under an + # internal identity would deny name-based '@/Team/' and '@/Org' + # spellings (no name dictionary, no org context) and 500 the fetch. + from ai.account import Store - # Build the full storage path and resolve it through the filesystem backend. - # A malformed `path` claim can raise ValueError; return 400 rather than 500. + backend = Store.instance()._store + # _get_full_path guards traversal (raises on escape); a malformed claim + # is indistinguishable from a missing file to the caller. try: - full_store_path = file_store._full_path(path) - except ValueError: - return JSONResponse({'error': 'Invalid path'}, status_code=400) - backend = file_store._store - try: - abs_path = backend._get_full_path(full_store_path) + abs_path = backend._get_full_path(path) except Exception: return JSONResponse({'error': 'File not found'}, status_code=404) diff --git a/packages/ai/src/ai/modules/task/task_conn.py b/packages/ai/src/ai/modules/task/task_conn.py index a94a19c86..079c278c7 100644 --- a/packages/ai/src/ai/modules/task/task_conn.py +++ b/packages/ai/src/ai/modules/task/task_conn.py @@ -77,7 +77,7 @@ from .commands.cmd_deploy import DeployCommands from .commands.cmd_log import LogCommands from .commands.cmd_store import StoreCommands -from ai.account.models import AccountInfo, resolve_task_permissions, resolve_team_permissions +from ai.account.models import AccountInfo, RequestContext, resolve_task_permissions, resolve_team_permissions from ai.common.account import AccountPipelineValidation # Only import for type checking to avoid circular import errors @@ -415,6 +415,92 @@ def verify_permission(self, perm: str) -> None: if not self.has_permission(perm): raise PermissionError(f'Permission {perm!r} denied') + def request_context(self) -> 'RequestContext': + """Build the per-request identity context from connection state. + + MERGE NOTE (feat/alb): this is the PRE-alb local builder. feat/alb + constructs the ctx once in on_receive (_build_request_context, which + also honours orchestrator-forwarded ``arguments._ctx``) and passes it + into every on_* handler. When feat/alb lands: replace calls to this + helper with the handler's ``ctx`` parameter and delete this method. + """ + return RequestContext( + account_info=self._account_info, + conn_id=str(self._connection_id), + source='local', + ) + + def verify_team_permission(self, team_id: str, perm: str) -> None: + """Raise PermissionError unless the user holds ``perm`` on the GIVEN team. + + The counterpart to verify_permission (which resolves only against the + caller's defaultTeam): use this whenever a command targets an object + that belongs to a SPECIFIC team — executing onto a team, touching a + team's deployment, reading a team's logs. ``sys.admin`` bypasses, the + same as the get_task path. A team outside the caller's org resolves to + no permissions and is denied — indistinguishable from a real team the + caller cannot access (no existence leak). + """ + # Step 1: an unauthenticated connection can hold no permissions. + if not self._account_info: + raise PermissionError('Not authenticated') + + # Step 2: platform admins bypass team scoping (parity with get_task). + if 'sys.admin' in (self._account_info.sysPermissions or []): + return + + # Step 3: resolve the caller's permissions ON THAT team (returns [] + # for unknown/foreign teams rather than raising — uniform denial). + perms = resolve_task_permissions(self._account_info, team_id) + if not perms: + raise PermissionError(f'Access denied: no permissions for team {team_id!r}') + if perm not in perms: + raise PermissionError(f'Permission {perm!r} denied for team {team_id!r}') + + async def resolve_org_for_team(self, team_id: str) -> str: + """Resolve the org id that owns ``team_id`` for task registration. + + Fast path: the caller's own membership (their single org). Callers + that pass verify_team_permission WITHOUT membership — sys.admin, and + internal via resolve_task_permissions — fall through to the account + backend so the task is registered with the team's REAL org: an empty + orgId would otherwise travel in the task file as trusted identity and + anchor org-scoped storage/secrets to nothing. + + Args: + team_id: The team the task is being registered under. + + Returns: + The owning organization id (never empty). + + Raises: + PermissionError: The team's org cannot be resolved (unknown team, + or a backend without team records) — uniform denial message. + """ + # Deferred import: ai.account instantiates the Account singleton on + # import; task_conn is imported during bootstrap before it is ready. + from ai.account import account + + # Membership fast path — but do NOT return from inside the loop: a + # membership record whose org carries no id must still fall through + # to the backend lookup and the single empty-org guard below, or an + # empty orgId would ride the task file as trusted identity again. + org = self._account_info.organization if self._account_info else None + org_id = '' + if isinstance(org, dict) and any(t.get('id') == team_id for t in org.get('teams', [])): + org_id = org.get('id') or '' + if not org_id: + try: + team = await account.get_team(team_id) + org_id = (team or {}).get('orgId') or '' + except Exception: + org_id = '' + if not org_id: + # Unknown team / OSS backend without team records: deny with the + # SAME message as the permission check (no existence leak). + raise PermissionError(f'Access denied: no permissions for team {team_id!r}') + return org_id + def require_zitadel_auth(self) -> None: """Verify the connection is authenticated and not waitlisted.""" if not self._authenticated or not self._account_info: diff --git a/packages/ai/src/ai/modules/task/task_engine.py b/packages/ai/src/ai/modules/task/task_engine.py index 1f80ad773..85384ddfd 100644 --- a/packages/ai/src/ai/modules/task/task_engine.py +++ b/packages/ai/src/ai/modules/task/task_engine.py @@ -217,6 +217,8 @@ def __init__( provider: str = None, ttl: int = 900, client_id: str = '', + team_id: str = '', + org_id: str = '', env: Dict[str, str] = None, **kwargs, ) -> None: @@ -231,6 +233,8 @@ def __init__( launch_type: Task creation mode (launch/attach) ttl: Time-to-live in seconds for idle tasks (default: 900 = 15 minutes; 0 = no timeout) client_id: Account identifier for store access scoping + team_id: Owning team id (rides the task file as trusted identity) + org_id: Owning org id (rides the task file as trusted identity) **kwargs: Additional DAP configuration """ # Store authentication @@ -240,6 +244,8 @@ def __init__( self.token = token self.public_auth = public_auth self.client_id = client_id + self.team_id = team_id + self.org_id = org_id # TTL management - count-up timer approach self._ttl = ttl # Maximum idle time in seconds @@ -447,8 +453,50 @@ def _build_task(self, pipeline: Dict[str, Any]) -> Dict[str, Any]: 'nodeId': '9a0b9f66-f693-4b3b-a85b-bb810261c26e', 'taskId': self.token, 'type': 'pipeline', + # Trusted identity for in-process tools (surfaced to nodes as + # IEndpoint.endpoint.jobConfig['identity']). Rides the 0600 task + # file — point-to-point, never the environment, so caller env can + # never pollute it and descendants never inherit it. + 'identity': { + 'userId': self.client_id, + 'teamId': self.team_id, + 'orgId': self.org_id, + }, + # Storage anchor for in-process tools (chroot semantics): node + # paths are always plain and relative, and resolve under this + # root — dev runs get the owner's whole tree (today's behavior); + # deploy runs get a task-specific subtree of TEAM storage so a + # deployed task has no user dependency and concurrent + # deployments never share working storage. Node-level paths are + # therefore identical in both modes. + 'storage': { + 'root': self._storage_root(), + }, } + def _storage_root(self) -> str: + """The task's storage anchor (see the 'storage' task-file block). + + Validated HERE so a malformed component — project_id is + client-supplied and only uuid-defaulted when absent — fails the + launch with a clear error instead of surfacing later inside the + subprocess as tool_filesystem disabling itself mid-run. + """ + from ai.account.file_store import validate_storage_root + + if self._run_kind == 'deploy': + if not self.team_id: + raise ValueError('deploy runs require a team_id for their storage anchor') + return validate_storage_root(f'teams/{self.team_id}/files/tasks/{self.project_id}') + # Anonymous dev runs (client_id='' — OSS/standalone launches) carry + # NO anchor instead of failing the launch: identity.userId rides + # empty too, so engine_file_store() yields None and the storage + # tools disable themselves — the same degradable posture as the + # run-log writer ('users//files' must never be composed). + if not self.client_id: + return '' + return validate_storage_root(f'users/{self.client_id}/files') + async def _write_task_file(self, pipeline: Dict[str, Any]) -> str: """ Write task configuration to temporary file. @@ -1928,9 +1976,10 @@ async def start_task(self) -> None: await self._send_status_update() - # Launch subprocess - pass environment with account context for store access + # Launch subprocess. Identity travels in the TASK FILE (see + # _build_task's 'identity' block), never the environment — the + # ROCKETRIDE_* env namespace is caller-influenced by design. subprocess_env = os.environ.copy() - subprocess_env['ROCKETRIDE_CLIENT_ID'] = self.client_id # avoidMocks: strip ROCKETRIDE_MOCK so node.py loads real libraries if self._pipeline.get('avoidMocks'): @@ -1968,8 +2017,13 @@ async def start_task(self) -> None: # The account-scoped FileStore handles user path scoping (and # REFUSES an empty client_id — such a task runs unlogged and # says so, rather than writing into a collapsed users/ path). + # Internal identity: the run-log writer is the ONLY legal + # writer of .logs content (the store's policy denies every + # user identity — internal-only entry). + from ai.account import RequestContext, Store + self._run_log = RunLogWriter( - self._server.store.get_file_store(self.client_id), + Store.file_store(RequestContext.internal('run-log'), client_id=self.client_id), self.client_id, self.project_id, self.source, diff --git a/packages/ai/src/ai/modules/task/task_server.py b/packages/ai/src/ai/modules/task/task_server.py index 950a4ef8e..794f5a1db 100644 --- a/packages/ai/src/ai/modules/task/task_server.py +++ b/packages/ai/src/ai/modules/task/task_server.py @@ -83,7 +83,7 @@ from ai.common.dap import TransportWebSocket, DAPBase from rocketride import TASK_STATUS, EVENT_TYPE from ai.web import WebServer -from ai.account.models import AccountInfo, resolve_task_permissions, resolve_team_permissions +from ai.account.models import AccountInfo, resolve_task_permissions from ai.account.store import Store from ai.account.deployment_store import DeploymentStore from .task_conn import TaskConn @@ -214,7 +214,6 @@ def __init__(self, server: WebServer, **kwargs) -> None: self._allocated_ports: List[int] = [] # Shared store instance (lazy-loaded via property) - self._store_instance: Optional[Store] = None self._deployments_instance: Optional[DeploymentStore] = None # Start background tasks that must be cancelled on shutdown. @@ -247,9 +246,10 @@ def store(self) -> Store: Returns: Store: The shared store instance """ - if self._store_instance is None: - self._store_instance = Store.create() - return self._store_instance + # The process-wide singleton — TaskServer no longer owns a private + # instance, so server code and Store.file_store(ctx) call sites can + # never diverge onto different stores. + return Store.instance() @property def deployments(self) -> DeploymentStore: @@ -515,14 +515,13 @@ async def _dapbase_on_disconnected(self, conn: TaskConn) -> None: # Log cleanup errors but continue processing other tasks self.debug_message(f'Error during disconnection cleanup for task "{control.id}": {e}') - # Close any open file store handles for this connection - if hasattr(conn, '_account_info') and conn._account_info: - try: - client_id = conn._account_info.userId - if client_id in self.store._file_stores: - await self.store._file_stores[client_id].close_all_handles(connection_id) - except Exception as e: - self.debug_message(f'Error closing file handles for connection {connection_id}: {e}') + # Close any open file store handles for this connection. The handle + # registry is Store-wide (shared across all FileStore instances), so + # this covers every store the connection ever constructed. + try: + await self.store.close_all_handles(connection_id) + except Exception as e: + self.debug_message(f'Error closing file handles for connection {connection_id}: {e}') # Log successful disconnection cleanup self.debug_message(f'Connection {connection_id} disconnected and cleaned up.') @@ -660,8 +659,14 @@ def get_task_control( if not control: raise RuntimeError('Your pipeline is not running') + # Resolve against the TASK'S team (the old resolve_team_permissions + # call raised on foreign teams instead of denying uniformly). + # sys.admin and internal identities bypass INSIDE the resolver — it + # returns the full permission set for them — so no outer short-circuit. if account_info is not None and require: - perms = resolve_team_permissions(account_info, control.teamId) + perms = resolve_task_permissions(account_info, control.teamId) + if not perms: + raise PermissionError('Access denied: no permissions for this task') if require not in perms: raise PermissionError(f'Permission {require!r} denied for this task') @@ -1151,6 +1156,8 @@ def _return_results(control: TASK_CONTROL) -> str: provider=control.provider, ttl=ttl, client_id=control.client_id, + team_id=control.teamId, + org_id=control.orgId, env=env or {}, ) diff --git a/packages/ai/tests/ai/account/test_file_store.py b/packages/ai/tests/ai/account/test_file_store.py index 648d57396..a79d6b87d 100644 --- a/packages/ai/tests/ai/account/test_file_store.py +++ b/packages/ai/tests/ai/account/test_file_store.py @@ -15,6 +15,7 @@ from ai.account.store import Store, StorageError from ai.account.file_store import FileStore, DIR_MARKER +from ai.account.models import RequestContext from ai.account.store_providers.filesystem import FilesystemStore @@ -39,21 +40,25 @@ def istore(temp_dir): @pytest.fixture -def fs(istore): - """Create a FileStore for test-user-1.""" - return FileStore(istore, 'test-user-1') +def store(istore): + """Create a Store wrapper (owner of the shared registries).""" + return Store(istore) @pytest.fixture -def fs2(istore): - """Create a FileStore for test-user-2 (isolation tests).""" - return FileStore(istore, 'test-user-2') +def fs(store): + """Create a FileStore for test-user-1. + + Internal identity: these suites pin the I/O and path semantics that + predate authorization; the permission matrix lives in test_store_auth.py. + """ + return FileStore(store, 'test-user-1', RequestContext.internal('test')) @pytest.fixture -def store(istore): - """Create a Store wrapper (for get_file_store tests).""" - return Store(istore) +def fs2(store): + """Create a FileStore for test-user-2 (isolation tests).""" + return FileStore(store, 'test-user-2', RequestContext.internal('test')) # ============================================================================ @@ -64,25 +69,34 @@ def store(istore): class TestFileStoreInit: """Test FileStore initialization.""" - def test_requires_client_id(self, istore): + def test_requires_client_id(self, store): """FileStore requires a non-empty client_id.""" with pytest.raises(ValueError, match='client_id is required'): - FileStore(istore, '') + FileStore(store, '', RequestContext.internal('test')) - def test_creates_with_valid_client_id(self, istore): + def test_creates_with_valid_client_id(self, store): """FileStore creates successfully with a valid client_id.""" - fs = FileStore(istore, 'user-123') + fs = FileStore(store, 'user-123', RequestContext.internal('test')) assert fs._client_id == 'user-123' - def test_rejects_client_id_with_slash(self, istore): + def test_rejects_client_id_with_slash(self, store): """client_id with path separators is rejected.""" with pytest.raises(ValueError, match='path separators'): - FileStore(istore, 'user/evil') + FileStore(store, 'user/evil', RequestContext.internal('test')) - def test_rejects_client_id_dotdot(self, istore): + def test_rejects_client_id_dotdot(self, store): """client_id of '..' is rejected.""" with pytest.raises(ValueError, match=r'\.\.'): - FileStore(istore, '..') + FileStore(store, '..', RequestContext.internal('test')) + + def test_requires_request_context(self, store): + """Omitting or mistyping the ctx is a TypeError — never an + unauthorized-by-accident store. + """ + with pytest.raises(TypeError): + FileStore(store, 'user-123') # auth deliberately omitted + with pytest.raises(TypeError, match='RequestContext'): + FileStore(store, 'user-123', 'not-an-auth') # ============================================================================ @@ -90,25 +104,30 @@ def test_rejects_client_id_dotdot(self, istore): # ============================================================================ -class TestGetFileStore: - """Test Store.get_file_store() factory method.""" +class TestFileStoreFactory: + """Test Store.file_store() factory method.""" def test_returns_file_store(self, store): - """get_file_store returns a FileStore instance.""" - fs = store.get_file_store('user-123') + """file_store returns a FileStore instance.""" + fs = store._file_store(RequestContext.internal('test'), client_id='user-123') assert isinstance(fs, FileStore) - def test_caches_per_client_id(self, store): - """get_file_store returns the same instance for the same client_id.""" - fs1 = store.get_file_store('user-123') - fs2 = store.get_file_store('user-123') - assert fs1 is fs2 + def test_instances_are_not_cached(self, store): + """Identity is bound per instance, so instances are never cached — + two calls yield two wrappers over the SAME shared registries. + """ + fs1 = store._file_store(RequestContext.internal('test'), client_id='user-123') + fs2 = store._file_store(RequestContext.internal('test'), client_id='user-123') + assert fs1 is not fs2 + assert fs1._handles is fs2._handles + assert fs1._write_locks is fs2._write_locks - def test_different_client_ids(self, store): - """get_file_store returns different instances for different client_ids.""" - fs1 = store.get_file_store('user-1') - fs2 = store.get_file_store('user-2') + def test_different_client_ids_share_registries(self, store): + """Different users' instances still coordinate on one registry.""" + fs1 = store._file_store(RequestContext.internal('test'), client_id='user-1') + fs2 = store._file_store(RequestContext.internal('test'), client_id='user-2') assert fs1 is not fs2 + assert fs1._write_locks is fs2._write_locks # ============================================================================ @@ -391,7 +410,7 @@ class TestHandleIO: @pytest.mark.asyncio async def test_write_chunks_and_read(self, fs): """Write multiple chunks via handle, then read back.""" - handle_id = await fs.open_write('chunked.bin', connection_id=1) + handle_id = await fs.open_write('chunked.bin') await fs.write_chunk(handle_id, b'part-1-') await fs.write_chunk(handle_id, b'part-2-') await fs.write_chunk(handle_id, b'part-3') @@ -405,7 +424,7 @@ async def test_read_chunks(self, fs): """Read a file in chunks via handle with explicit offsets.""" await fs.write('big.bin', b'A' * 100) - info = await fs.open_read('big.bin', connection_id=1) + info = await fs.open_read('big.bin') assert info['size'] == 100 chunk = await fs.read_chunk(info['handle'], offset=0, length=40) @@ -423,33 +442,36 @@ async def test_read_chunks(self, fs): await fs.close_read(info['handle']) @pytest.mark.asyncio - async def test_write_lock(self, fs): - """Cannot open the same file for writing twice.""" - handle_id = await fs.open_write('locked.bin', connection_id=1) + async def test_write_lock(self, fs, store): + """Cannot open the same file for writing twice — even from another + connection's view (the lock registry is store-wide). + """ + other = FileStore(store, 'test-user-1', RequestContext.internal('conn2')) + handle_id = await fs.open_write('locked.bin') with pytest.raises(StorageError, match='already open for writing'): - await fs.open_write('locked.bin', connection_id=2) + await other.open_write('locked.bin') await fs.close_write(handle_id) # After close, can open again - handle_id2 = await fs.open_write('locked.bin', connection_id=2) - await fs.close_write(handle_id2) + handle_id2 = await other.open_write('locked.bin') + await other.close_write(handle_id2) @pytest.mark.asyncio async def test_close_all_handles(self, fs): - """close_all_handles commits data and releases locks.""" - handle_id = await fs.open_write('orphan.bin', connection_id=99) + """close_all_handles() commits this connection's data and releases locks.""" + handle_id = await fs.open_write('orphan.bin') await fs.write_chunk(handle_id, b'orphan-data') - await fs.close_all_handles(connection_id=99) + await fs.close_all_handles() # Data should be committed data = await fs.read('orphan.bin') assert data == b'orphan-data' # Lock should be released - handle_id2 = await fs.open_write('orphan.bin', connection_id=1) + handle_id2 = await fs.open_write('orphan.bin') await fs.close_write(handle_id2) @pytest.mark.asyncio @@ -465,7 +487,7 @@ async def test_invalid_handle(self, fs): async def test_wrong_mode(self, fs): """Using a read handle for writing (or vice versa) raises StorageError.""" await fs.write('mode-test.bin', b'data') - info = await fs.open_read('mode-test.bin', connection_id=1) + info = await fs.open_read('mode-test.bin') with pytest.raises(StorageError, match='Wrong handle mode'): await fs.write_chunk(info['handle'], b'nope') @@ -501,23 +523,23 @@ async def test_rmdir_slash_path_rejected(self, fs): @pytest.mark.asyncio async def test_rmdir_refuses_when_writer_open(self, fs): """Open writer under the prefix must block rmdir.""" - handle_id = await fs.open_write('project/data.bin', connection_id=1) + handle_id = await fs.open_write('project/data.bin') try: with pytest.raises(StorageError, match='open for writing'): await fs.rmdir('project', recursive=True) finally: - await fs.close_write(handle_id, connection_id=1) + await fs.close_write(handle_id) @pytest.mark.asyncio async def test_rmdir_refuses_when_reader_open(self, fs): """Open reader under the prefix must block rmdir.""" await fs.write('project/data.bin', b'x') - info = await fs.open_read('project/data.bin', connection_id=1) + info = await fs.open_read('project/data.bin') try: with pytest.raises(StorageError, match='handle open under'): await fs.rmdir('project', recursive=True) finally: - await fs.close_read(info['handle'], connection_id=1) + await fs.close_read(info['handle']) class TestRenameGuards: @@ -564,34 +586,34 @@ async def test_rename_refuses_existing_dir_destination(self, fs): @pytest.mark.asyncio async def test_rename_file_refuses_when_source_open(self, fs): """Refuse rename if the source file is open for writing.""" - handle_id = await fs.open_write('src.bin', connection_id=1) + handle_id = await fs.open_write('src.bin') try: with pytest.raises(StorageError, match='open for writing'): await fs.rename('src.bin', 'dest.bin') finally: - await fs.close_write(handle_id, connection_id=1) + await fs.close_write(handle_id) @pytest.mark.asyncio async def test_rename_file_refuses_when_destination_locked(self, fs): """Refuse rename if the destination file is open for writing.""" await fs.write('src.bin', b'source') - handle_id = await fs.open_write('dest.bin', connection_id=1) + handle_id = await fs.open_write('dest.bin') try: with pytest.raises(StorageError, match='open for writing'): await fs.rename('src.bin', 'dest.bin', overwrite=True) finally: - await fs.close_write(handle_id, connection_id=1) + await fs.close_write(handle_id) @pytest.mark.asyncio async def test_rename_dir_refuses_when_subtree_writer_open(self, fs): """Directory rename refuses if any file under the source prefix is open.""" await fs.write('src_dir/a.bin', b'a') - handle_id = await fs.open_write('src_dir/b.bin', connection_id=1) + handle_id = await fs.open_write('src_dir/b.bin') try: with pytest.raises(StorageError, match='open for writing'): await fs.rename('src_dir', 'dest_dir') finally: - await fs.close_write(handle_id, connection_id=1) + await fs.close_write(handle_id) class TestHandleCap: @@ -599,30 +621,35 @@ class TestHandleCap: @pytest.mark.asyncio async def test_write_handle_cap_enforced(self, fs): - """Opening beyond the cap on a real connection_id raises StorageError.""" + """Opening beyond the cap on an owned connection raises StorageError.""" from ai.account.file_store import MAX_HANDLES_PER_CONNECTION handles = [] for i in range(MAX_HANDLES_PER_CONNECTION): - handles.append(await fs.open_write(f'file-{i}.bin', connection_id=7)) + handles.append(await fs.open_write(f'file-{i}.bin')) with pytest.raises(StorageError, match='Too many open handles'): - await fs.open_write('overflow.bin', connection_id=7) + await fs.open_write('overflow.bin') # Cleanup for h in handles: - await fs.close_write(h, connection_id=7) + await fs.close_write(h) @pytest.mark.asyncio - async def test_handle_cap_not_enforced_for_connection_zero(self, fs): - """connection_id=0 ('unowned', used by tests) bypasses the cap.""" + async def test_handle_cap_not_enforced_for_unowned_context(self, store): + """A ctx without a conn_id ('' = unowned) bypasses the cap.""" from ai.account.file_store import MAX_HANDLES_PER_CONNECTION + # RequestContext with no account is unauthenticated for user auth — + # use an internal identity with an empty conn_id instead. + ctx = RequestContext.internal('cap') + ctx.conn_id = '' + unowned = FileStore(store, 'test-user-1', ctx) handles = [] - for i in range(MAX_HANDLES_PER_CONNECTION + 5): - handles.append(await fs.open_write(f'nocap-{i}.bin', connection_id=0)) + for i in range(MAX_HANDLES_PER_CONNECTION + 2): + handles.append(await unowned.open_write(f'nocap-{i}.bin')) for h in handles: - await fs.close_write(h) + await unowned.close_write(h) class TestPathValidationColon: diff --git a/packages/ai/tests/ai/account/test_store.py b/packages/ai/tests/ai/account/test_store.py index 48f61bb75..f49155cd5 100644 --- a/packages/ai/tests/ai/account/test_store.py +++ b/packages/ai/tests/ai/account/test_store.py @@ -23,6 +23,7 @@ import pytest from ai.account.file_store import FileStore +from ai.account.models import RequestContext from ai.account.store import STORE_MAX_RETRY_ATTEMPTS, Store, StorageError from ai.account.store_providers.azure import AzureBlobStore from ai.account.store_providers.filesystem import FilesystemStore @@ -559,10 +560,14 @@ def test_create_filesystem_store(self, tmp_path): assert isinstance(store._store, FilesystemStore) assert store._store._root_path == tmp_path - def test_create_with_default_url(self): + def test_create_with_default_url(self, monkeypatch): """Test creating store with default URL.""" - # Clear environment - os.environ.pop('STORE_URL', None) + # Clear BOTH names: with a lingering legacy STORE_URL and no + # RR_STORE_URL, create() hard-fails by design (stale-deployment + # guard) — this test asserts the default, not that migration error. + # monkeypatch restores the caller's environment afterwards. + monkeypatch.delenv('RR_STORE_URL', raising=False) + monkeypatch.delenv('STORE_URL', raising=False) store = Store.create() @@ -573,7 +578,7 @@ def test_create_with_default_url(self): def test_create_with_env_var(self, tmp_path): """Test creating store from STORE_URL environment variable.""" - os.environ['STORE_URL'] = f'filesystem://{tmp_path}' + os.environ['RR_STORE_URL'] = f'filesystem://{tmp_path}' store = Store.create() @@ -582,7 +587,7 @@ def test_create_with_env_var(self, tmp_path): assert store._store._root_path == tmp_path # Cleanup - os.environ.pop('STORE_URL', None) + os.environ.pop('RR_STORE_URL', None) def test_env_var_expansion_windows(self, tmp_path): """Test environment variable expansion (Windows style).""" @@ -955,7 +960,7 @@ def test_verify_credential_chain_without_mock(self, tmp_path, monkeypatch): python rocketlib-ai/tests/ai/account/check_aws_credentials.py """ # Temporarily remove STORE_SECRET_KEY if set - original_store_secret = os.environ.pop('STORE_SECRET_KEY', None) + original_store_secret = os.environ.pop('RR_STORE_SECRET_KEY', None) original_aws_key = os.environ.pop('AWS_ACCESS_KEY_ID', None) original_aws_secret = os.environ.pop('AWS_SECRET_ACCESS_KEY', None) @@ -1033,7 +1038,7 @@ def test_verify_credential_chain_without_mock(self, tmp_path, monkeypatch): finally: # Restore environment variables if original_store_secret: - os.environ['STORE_SECRET_KEY'] = original_store_secret + os.environ['RR_STORE_SECRET_KEY'] = original_store_secret if original_aws_key: os.environ['AWS_ACCESS_KEY_ID'] = original_aws_key if original_aws_secret: @@ -1068,7 +1073,7 @@ def test_verify_credential_source_with_file(self, tmp_path, monkeypatch): # Remove any existing AWS env vars original_aws_key = os.environ.pop('AWS_ACCESS_KEY_ID', None) original_aws_secret = os.environ.pop('AWS_SECRET_ACCESS_KEY', None) - original_store_secret = os.environ.pop('STORE_SECRET_KEY', None) + original_store_secret = os.environ.pop('RR_STORE_SECRET_KEY', None) try: # Create store without secret_key - should use credentials file @@ -1109,7 +1114,7 @@ def test_verify_credential_source_with_file(self, tmp_path, monkeypatch): if original_aws_secret: os.environ['AWS_SECRET_ACCESS_KEY'] = original_aws_secret if original_store_secret: - os.environ['STORE_SECRET_KEY'] = original_store_secret + os.environ['RR_STORE_SECRET_KEY'] = original_store_secret # ============================================================================ @@ -1243,24 +1248,27 @@ def store(self, temp_dir): url = f'filesystem://{temp_dir}' return Store.create(url=url) - def test_get_file_store_returns_file_store(self, store): - """Test that get_file_store returns a FileStore instance.""" - fs = store.get_file_store('test-user') + def test_file_store_returns_file_store(self, store): + """Test that file_store returns a FileStore instance.""" + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') assert isinstance(fs, FileStore) - def test_get_file_store_caches_by_client_id(self, store): - """Test that the same FileStore is returned for the same client_id.""" - fs1 = store.get_file_store('user-1') - fs2 = store.get_file_store('user-1') - fs3 = store.get_file_store('user-2') + def test_file_store_instances_share_registries(self, store): + """Instances are never cached (identity is per-session), but all of + them coordinate on the Store's shared write-lock/handle registries. + """ + fs1 = store._file_store(RequestContext.internal('test'), client_id='user-1') + fs2 = store._file_store(RequestContext.internal('test'), client_id='user-1') + fs3 = store._file_store(RequestContext.internal('test'), client_id='user-2') - assert fs1 is fs2 - assert fs1 is not fs3 + assert fs1 is not fs2 + assert fs1._write_locks is fs2._write_locks is fs3._write_locks + assert fs1._handles is fs2._handles is fs3._handles @pytest.mark.asyncio async def test_file_store_write_and_read(self, store): """Test writing and reading via FileStore.""" - fs = store.get_file_store('test-user') + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') await fs.write('test.txt', b'Hello, World!') data = await fs.read('test.txt') @@ -1270,8 +1278,8 @@ async def test_file_store_write_and_read(self, store): @pytest.mark.asyncio async def test_file_store_isolation(self, store): """Test that different client_ids have isolated storage.""" - fs1 = store.get_file_store('user-1') - fs2 = store.get_file_store('user-2') + fs1 = store._file_store(RequestContext.internal('test'), client_id='user-1') + fs2 = store._file_store(RequestContext.internal('test'), client_id='user-2') await fs1.write('shared-name.txt', b'user-1 data') await fs2.write('shared-name.txt', b'user-2 data') @@ -1295,7 +1303,7 @@ async def test_end_to_end_workflow(self, temp_dir): """Test complete workflow: create store, get FileStore, write, read.""" url = f'filesystem://{temp_dir}' store = Store.create(url=url) - fs = store.get_file_store('test-user') + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') # Write multiple files await fs.write('logs/app.log', b'Application started\n') @@ -1314,7 +1322,7 @@ async def test_concurrent_operations(self, temp_dir): """Test concurrent file operations.""" url = f'filesystem://{temp_dir}' store = Store.create(url=url) - fs = store.get_file_store('test-user') + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') # Write multiple files concurrently tasks = [fs.write(f'file{i}.txt', f'Content {i}'.encode()) for i in range(10)] @@ -1333,17 +1341,17 @@ async def test_handle_based_write_and_read(self, temp_dir): """Test handle-based streaming write and read.""" url = f'filesystem://{temp_dir}' store = Store.create(url=url) - fs = store.get_file_store('test-user') + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') # Write in chunks via handles - handle_id = await fs.open_write('chunked.bin', connection_id=1) + handle_id = await fs.open_write('chunked.bin') await fs.write_chunk(handle_id, b'chunk-1-') await fs.write_chunk(handle_id, b'chunk-2-') await fs.write_chunk(handle_id, b'chunk-3') await fs.close_write(handle_id) # Read back via handles - info = await fs.open_read('chunked.bin', connection_id=1) + info = await fs.open_read('chunked.bin') assert info['size'] == 23 # len('chunk-1-chunk-2-chunk-3') data = await fs.read_chunk(info['handle'], offset=0) await fs.close_read(info['handle']) @@ -1355,21 +1363,21 @@ async def test_close_all_handles_on_disconnect(self, temp_dir): """Test that close_all_handles commits and cleans up.""" url = f'filesystem://{temp_dir}' store = Store.create(url=url) - fs = store.get_file_store('test-user') + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') # Open a write handle and write some data - handle_id = await fs.open_write('disconnect.bin', connection_id=42) + handle_id = await fs.open_write('disconnect.bin') await fs.write_chunk(handle_id, b'partial-data') - # Simulate disconnect — should commit what was written - await fs.close_all_handles(connection_id=42) + # Simulate disconnect — Store-wide cleanup by connection id + await store.close_all_handles(fs._ctx.conn_id) # Data should be committed and readable data = await fs.read('disconnect.bin') assert data == b'partial-data' # Write lock should be released — a new write must succeed - handle_id2 = await fs.open_write('disconnect.bin', connection_id=43) + handle_id2 = await fs.open_write('disconnect.bin') await fs.close_write(handle_id2) @pytest.mark.asyncio @@ -1377,12 +1385,12 @@ async def test_write_lock_prevents_double_open(self, temp_dir): """Test that opening the same file for writing twice raises an error.""" url = f'filesystem://{temp_dir}' store = Store.create(url=url) - fs = store.get_file_store('test-user') + fs = store._file_store(RequestContext.internal('test'), client_id='test-user') - handle_id = await fs.open_write('locked.bin', connection_id=1) + handle_id = await fs.open_write('locked.bin') with pytest.raises(StorageError) as exc_info: - await fs.open_write('locked.bin', connection_id=2) + await fs.open_write('locked.bin') assert 'already open for writing' in str(exc_info.value) # Clean up diff --git a/packages/ai/tests/ai/account/test_store_auth.py b/packages/ai/tests/ai/account/test_store_auth.py new file mode 100644 index 000000000..5f000bad0 --- /dev/null +++ b/packages/ai/tests/ai/account/test_store_auth.py @@ -0,0 +1,687 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Authorization matrix for the storage scope grammar (FileStore + RequestContext). + +The store is the security boundary: these tests drive REAL FileStore +operations over a real filesystem backend and assert what each identity may +resolve — scope grammar (@/Team, @/Org, @/User; ids and names), the system-tree rule +(.logs / .deployments: internal|sys.admin only), the identity kinds +(session / internal / engine), sys.admin and org.admin expansion, uniform denial (no existence +leak), the normalization-bypass fuzz family, and cross-user write-lock +exclusion on shared team paths. +""" + +import shutil +import tempfile +from types import SimpleNamespace + +import pytest + +from ai.account import file_store +from ai.account.file_store import parse_scope +from ai.account.models import RequestContext +from ai.account.store import Store, StorageError +from ai.account.store_providers.filesystem import FilesystemStore + + +# ============================================================================ +# Fixtures + account stubs +# ============================================================================ + + +@pytest.fixture +def store(): + """Store over a throwaway filesystem backend.""" + temp_path = tempfile.mkdtemp() + yield Store(FilesystemStore(f'filesystem://{temp_path}')) + shutil.rmtree(temp_path, ignore_errors=True) + + +def _account( + *, + user_id='user-1', + team_perms=('task.store', 'task.monitor', 'task.control'), + team_id='team-1', + team_name='Development', + org_perms=(), + sys_perms=(), + default_team='team-1', + extra_teams=(), +): + """AccountInfo-shaped stub with one org and one primary team.""" + teams = [{'id': team_id, 'name': team_name, 'permissions': list(team_perms)}] + teams.extend(extra_teams) + return SimpleNamespace( + userId=user_id, + auth='ak_x', + userToken='tk', + defaultTeam=default_team, + organization={'id': 'org-1', 'name': 'Acme', 'permissions': list(org_perms), 'teams': teams}, + sysPermissions=list(sys_perms), + ) + + +def _ctx(account) -> RequestContext: + """An ordinary session context, as a handler would carry it.""" + return RequestContext(account_info=account, conn_id='conn-1', source='local') + + +def _fs(store, account): + """Session-bound FileStore for the stubbed account (namespace derived).""" + return store._file_store(_ctx(account)) + + +# ============================================================================ +# Scope grammar parsing +# ============================================================================ + + +class TestParseScope: + """parse_scope splits normalized paths into (kind, ref, rest) — the + joined-filesystem grammar rooted at @/: Team takes a mandatory + reference, User and Org are implicitly "me"/"my org" unless followed + by an =id reference. Case-exact, alias-free. + """ + + def test_plain_path_is_user_scope(self): + assert parse_scope('a/b/c.txt') == ('own', None, 'a/b/c.txt') + + def test_root_is_user_scope(self): + assert parse_scope('') == ('own', None, '') + + def test_team_with_rest(self): + assert parse_scope('@/Team/t1/reports/q3.md') == ('team', 't1', 'reports/q3.md') + + def test_user_is_implicitly_me(self): + # No reference: everything after the sigil is already the path. + assert parse_scope('@/User') == ('user', None, '') + assert parse_scope('@/User/My Files/a.txt') == ('user', None, 'My Files/a.txt') + + def test_org_is_implicitly_my_org(self): + assert parse_scope('@/Org') == ('org', None, '') + assert parse_scope('@/Org/policies/hr.md') == ('org', None, 'policies/hr.md') + + def test_explicit_id_references(self): + # '=' next segment: cross-boundary reference, not a path. + assert parse_scope('@/User/=u1/.logs/x') == ('user', '=u1', '.logs/x') + assert parse_scope('@/Org/=o1/x') == ('org', '=o1', 'x') + + def test_bare_at_and_reserved_sigils_rejected(self): + # '@' itself is a virtual mount listing owned by cmd_store. + for spelling in ('@', '@anything/x'): + with pytest.raises(ValueError, match='Reserved'): + parse_scope(spelling) + + def test_team_without_reference_rejected(self): + with pytest.raises(ValueError, match='reference segment'): + parse_scope('@/Team') + + def test_unknown_and_wrong_case_children_rejected(self): + # Case-exact, alias-free: '@/team' is an error, not a spelling. + for spelling in ('@/Files/x', '@/team/t1/x', '@/user/x', '@/ORG/x'): + with pytest.raises(ValueError, match='Unknown scope'): + parse_scope(spelling) + + def test_top_of_scope_equals_names_reserved(self): + # A real '=x' entry would shadow id references — uncreatable. + for spelling in ('=abc', '@/User/=u1/=x', '@/Team/t1/=x', '@/Org/=o1/=x'): + with pytest.raises(ValueError, match='reserved for id references'): + parse_scope(spelling) + + def test_nested_equals_names_allowed(self): + # Only the FIRST segment of a scope is reserved. + assert parse_scope('docs/=weird.txt') == ('own', None, 'docs/=weird.txt') + + +# ============================================================================ +# Session identity — team scope +# ============================================================================ + + +class TestSessionTeamScope: + """A real session resolving @/Team paths.""" + + @pytest.mark.asyncio + async def test_team_by_id_resolves_and_writes(self, store): + fs = _fs(store, _account()) + await fs.write('@/Team/=team-1/docs/readme.md', b'shared') + # Physical location is the TEAM area, ids on disk. + assert fs._full_path('@/Team/=team-1/docs/readme.md') == 'teams/team-1/files/docs/readme.md' + + @pytest.mark.asyncio + async def test_team_by_display_name_resolves_to_id(self, store): + fs = _fs(store, _account()) + await fs.write('@/Team/Development/docs/readme.md', b'shared') + # Names on the wire, ids on disk — both spellings hit ONE location. + assert (await fs.read('@/Team/=team-1/docs/readme.md')) == b'shared' + + @pytest.mark.asyncio + async def test_two_members_share_one_location(self, store): + member_a = store._file_store(_ctx(_account(user_id='user-a'))) + member_b = store._file_store(_ctx(_account(user_id='user-b'))) + await member_a.write('@/Team/=team-1/tmp/x.txt', b'from A') + assert (await member_b.read('@/Team/=team-1/tmp/x.txt')) == b'from A' + + def test_foreign_team_denied_uniformly(self, store): + fs = _fs(store, _account()) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Team/=team-elsewhere/x.txt') + + def test_member_without_task_store_denied_same_message(self, store): + fs = _fs(store, _account(team_perms=['task.monitor'])) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Team/=team-1/x.txt') + + def test_ambiguous_name_denied_same_message(self, store): + # Two teams sharing a display name: name addressing fails closed with + # the SAME message as unknown/foreign (id addressing always works). + account = _account(extra_teams=[{'id': 'team-2', 'name': 'Development', 'permissions': ['task.store']}]) + fs = store._file_store(_ctx(account)) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Team/Development/x.txt') + assert fs._full_path('@/Team/=team-1/x.txt') == 'teams/team-1/files/x.txt' + + def test_names_and_ids_cannot_shadow(self, store): + # v2: a bare segment is ONLY a name, '=x' is ONLY an id — a team + # NAMED like another team's id resolves by name to its own id. + account = _account(extra_teams=[{'id': 'team-x', 'name': 'team-1', 'permissions': ['task.store']}]) + fs = store._file_store(_ctx(account)) + assert fs._full_path('@/Team/team-1/f').startswith('teams/team-x/') + assert fs._full_path('@/Team/=team-1/f').startswith('teams/team-1/') + + def test_id_reference_traversal_guard(self, store): + # '=..' / '=.' / '=' must never embed into the physical path. + fs = _fs(store, _account(sys_perms=['sys.admin'])) + for evil in ('@/Team/=../x', '@/Team/=./x', '@/Team/=/x', '@/User/=../x'): + with pytest.raises(ValueError, match='Invalid id reference|reference segment'): + fs._full_path(evil) + + def test_no_org_session_denied(self, store): + account = _account() + account.organization = None + fs = store._file_store(_ctx(account)) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Team/=team-1/x') + + def test_unauthenticated_context_denied(self, store): + fs = store._file_store(RequestContext(account_info=None), client_id='user-1') + with pytest.raises(PermissionError, match='Not authenticated'): + fs._full_path('anything.txt') + + def test_sys_admin_bypasses_team_membership(self, store): + fs = _fs(store, _account(team_perms=[], sys_perms=['sys.admin'])) + assert fs._full_path('@/Team/=team-1/x') == 'teams/team-1/files/x' + + def test_sys_admin_crosses_boundaries_by_id_only(self, store): + # The support capability: foreign teams/orgs/users by =id and ONLY + # by =id (names still resolve through the admin's own dictionary). + fs = _fs(store, _account(sys_perms=['sys.admin'])) + assert fs._full_path('@/Team/=foreign-team/x') == 'teams/foreign-team/files/x' + assert fs._full_path('@/Org/=foreign-org/x') == 'orgs/foreign-org/files/x' + assert fs._full_path('@/User/=other-user/notes.txt') == 'users/other-user/files/notes.txt' + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Team/TheirTeamName/x') + + def test_sys_admin_full_access_to_foreign_system_trees(self, store): + # sys.admin may do ANYTHING with system trees, anywhere. + fs = _fs(store, _account(sys_perms=['sys.admin'])) + assert fs._full_path('@/User/=other-user/.logs/p1/x.jsonl') + assert fs._full_path('@/User/=other-user/.deployments/p1.json') + + def test_ordinary_session_cannot_use_user_scope(self, store): + fs = _fs(store, _account()) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/User/=user-1/x') # not even for yourself + + +# ============================================================================ +# Session identity — org scope +# ============================================================================ + + +class TestSessionOrgScope: + """@/Org is implicitly the caller's ONE org — org.admin-only.""" + + def test_org_admin_resolves_implicitly_and_by_id(self, store): + fs = _fs(store, _account(org_perms=['org.admin'])) + # Implicit spelling: the segment after @/Org is already the path. + assert fs._full_path('@/Org/policies/hr.md') == 'orgs/org-1/files/policies/hr.md' + # Explicit own-org id reference still resolves to the same tree. + assert fs._full_path('@/Org/=org-1/x') == 'orgs/org-1/files/x' + + def test_plain_member_denied(self, store): + fs = _fs(store, _account()) + for spelling in ('@/Org/x', '@/Org/=org-1/x'): + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path(spelling) + + def test_foreign_org_denied(self, store): + fs = _fs(store, _account(org_perms=['org.admin'])) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Org/=other-org/x') + + def test_no_org_session_denied(self, store): + account = _account(org_perms=['org.admin']) + account.organization = None + fs = _fs(store, account) + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Org/x') + + +# ============================================================================ +# Session identity — the @/User joined-mode alias +# ============================================================================ + + +class TestJoinedUserAlias: + """@/User/ is the joined-mode spelling of the caller's OWN tree.""" + + @pytest.mark.asyncio + async def test_both_spellings_hit_one_tree(self, store): + fs = _fs(store, _account()) + await fs.write('@/User/My Files/a.txt', b'joined') + # Simple-mode spelling reads the same physical file. + assert (await fs.read('My Files/a.txt')) == b'joined' + assert fs._full_path('@/User/My Files/a.txt') == fs._full_path('My Files/a.txt') + + def test_alias_carries_own_permission_rule(self, store): + # Same task.store requirement as plain paths — an alias, not a hole. + fs = _fs(store, _account(team_perms=['task.monitor'])) + with pytest.raises(PermissionError, match="'task.store' denied"): + fs._full_path('@/User/x.txt') + + def test_alias_system_trees_still_denied(self, store): + fs = _fs(store, _account()) + with pytest.raises(PermissionError, match='system-owned'): + fs._full_path('@/User/.logs/p1/x.jsonl') + + +# ============================================================================ +# System-owned trees (.logs / .deployments) +# ============================================================================ + + +class TestSystemTrees: + """.logs and .deployments are SYSTEM-OWNED through the file API: + engine-written, served by their domain APIs (rrext_log / rrext_deploy) — + every session identity is denied ALL operations, except sys.admin, who + may do anything. Internal identities pass mechanically. + """ + + def test_sessions_fully_denied_own_namespace(self, store): + # Even full team permissions grant NOTHING on system trees via the + # file API — the domain APIs are the only user-facing route. + fs = _fs(store, _account()) + for tree in ('.logs/p1/x.jsonl', '.deployments/p1.json'): + with pytest.raises(PermissionError, match='system-owned'): + fs._full_path(tree) + + def test_sessions_fully_denied_team_scope(self, store): + fs = _fs(store, _account()) + with pytest.raises(PermissionError, match='system-owned'): + fs._full_path('@/Team/=team-1/.logs/p1/x.jsonl') + with pytest.raises(PermissionError, match='system-owned'): + fs._full_path('@/Team/Development/.deployments/p1.json') + + def test_sys_admin_may_do_anything(self, store): + fs = _fs(store, _account(sys_perms=['sys.admin'])) + assert fs._full_path('.logs/p1/x.jsonl') == 'users/user-1/files/.logs/p1/x.jsonl' + assert fs._full_path('@/Team/=team-1/.deployments/p1.json') == ('teams/team-1/files/.deployments/p1.json') + + def test_default_subtree_needs_task_store(self, store): + fs = _fs(store, _account(team_perms=['task.monitor', 'task.control'])) + with pytest.raises(PermissionError, match="'task.store' denied"): + fs._full_path('workspace/file.txt') + + def test_internal_identity_requires_id_references(self, store): + # Internal has no name dictionary: bare (name) references deny. + fs = store._file_store(RequestContext.internal('run-log'), client_id='user-1') + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path('@/Team/Development/x') + assert fs._full_path('@/Team/=team-1/x') == 'teams/team-1/files/x' + + @pytest.mark.asyncio + async def test_internal_identity_writes_logs(self, store): + # The internal identity (run-log writer, domain APIs) passes through. + # This is ALSO the ordering pin: internal resolves BEFORE the + # system-tree gate by design — reordering resolve_scope turns this red. + fs = store._file_store(RequestContext.internal('run-log'), client_id='user-1') + await fs.write('.logs/p1/seg.jsonl', b'{}') + await fs.write('@/Team/=team-1/.logs/p1/seg.jsonl', b'{}') + + +class TestSysAdminCrossingAudit: + """sys.admin ``=id`` resolutions are the ONE place resolve_scope crosses + the boundary instead of enforcing it — every crossing must leave an audit + record, while member resolutions and the wire response stay + indistinguishable from ordinary resolves. + """ + + @pytest.fixture + def audited(self, monkeypatch): + # Record every _audit_crossing call — the trace itself goes through + # rocketlib.debug (native), so we pin the hook, not the sink. + calls = [] + monkeypatch.setattr( + file_store, '_audit_crossing', lambda ctx, client_id, target: calls.append((client_id, target)) + ) + return calls + + def test_foreign_team_id_crossing_is_audited(self, store, audited): + fs = _fs(store, _account(sys_perms=['sys.admin'])) + assert fs._full_path('@/Team/=team-9/x') == 'teams/team-9/files/x' + assert ('user-1', 'teams/team-9') in audited + + def test_foreign_org_and_user_crossings_are_audited(self, store, audited): + fs = _fs(store, _account(sys_perms=['sys.admin'])) + fs._full_path('@/Org/=org-9/x') + fs._full_path('@/User/=user-9/x') + targets = [t for _, t in audited] + assert 'orgs/org-9' in targets and 'users/user-9' in targets + + def test_member_id_resolution_is_not_audited(self, store, audited): + # A sys.admin resolving a team they are a MEMBER of crosses nothing — + # the audit channel records crossings, not routine member access. + fs = _fs(store, _account(sys_perms=['sys.admin'])) + assert fs._full_path('@/Team/=team-1/x') == 'teams/team-1/files/x' + assert audited == [] + + +# ============================================================================ +# Engine identity — subprocess tool nodes +# ============================================================================ + + +class TestEngineIdentity: + """RequestContext.engine: user scope only, reserved subtrees denied.""" + + def test_scoped_paths_rejected(self, store): + # EVERY sigil spelling is rejected — including @/User, which merely + # aliases the own tree for sessions: LLM-generated paths inside the + # subprocess must never speak the scope grammar at all. + fs = store._file_store(RequestContext.engine('user-1'), client_id='user-1') + for spelling in ('@/Team/=team-1/x', '@/Org/=org-1/x', '@/Org/x', '@/User/x'): + with pytest.raises(PermissionError, match='engine context'): + fs._full_path(spelling) + + def test_reserved_subtrees_rejected(self, store): + fs = store._file_store(RequestContext.engine('user-1'), client_id='user-1') + for subtree in ('.logs/p/x', '.deployments/p.json'): + with pytest.raises(PermissionError, match='engine context'): + fs._full_path(subtree) + + @pytest.mark.asyncio + async def test_plain_paths_work_unchecked(self, store): + fs = store._file_store(RequestContext.engine('user-1'), client_id='user-1') + await fs.write('notes/todo.md', b'x') + assert (await fs.read('notes/todo.md')) == b'x' + + @pytest.mark.asyncio + async def test_storage_anchor_chroots_deploy_runs(self, store): + # Deploy runs anchor at a task-specific TEAM subtree; the node's + # plain relative paths are identical to dev — only the anchor moves. + fs = store._file_store( + RequestContext.engine('user-1'), + client_id='user-1', + root='teams/team-1/files/tasks/proj-1', + ) + assert fs._full_path('out/report.csv') == 'teams/team-1/files/tasks/proj-1/out/report.csv' + await fs.write('out/report.csv', b'rows') + assert (await fs.read('out/report.csv')) == b'rows' + + def test_storage_anchor_still_rejects_scopes_and_system_trees(self, store): + # The anchor widens NOTHING: sigils and system trees stay denied. + fs = store._file_store( + RequestContext.engine('user-1'), + client_id='user-1', + root='teams/team-1/files/tasks/proj-1', + ) + with pytest.raises(PermissionError, match='engine context'): + fs._full_path('@/Team/=team-2/x') + with pytest.raises(PermissionError, match='engine context'): + fs._full_path('.logs/p/x') + + def test_storage_anchor_validation(self, store): + # Malformed anchors (fed back from the task file) fail closed. + for bad in ('', 'foo/bar', 'users/u1', 'teams/../files/x', 'teams/t/files/../up', 'users/u1/files/@x'): + with pytest.raises(ValueError): + store._file_store(RequestContext.engine('user-1'), client_id='user-1', root=bad) + + def test_storage_anchor_engine_only(self, store): + # Sessions/internal identities have their namespace as their anchor — + # an override would be an authorization bypass, so it is a hard error. + with pytest.raises(ValueError, match='engine contexts only'): + store._file_store(_ctx(_account()), root='users/user-1/files') + + def test_engine_file_store_binds_task_identity_and_anchor(self, store, monkeypatch): + # The transparent path: identity + anchor come from the engine's + # published task (rocketlib.getTask) — zero node-level plumbing. + monkeypatch.setattr(Store, 'instance', classmethod(lambda cls: store)) + monkeypatch.setattr( + Store, + '_get_current_task', + staticmethod( + lambda: { + 'identity': {'userId': 'user-1', 'teamId': 'team-1', 'orgId': 'org-1'}, + 'storage': {'root': 'teams/team-1/files/tasks/proj-1'}, + } + ), + ) + fs = Store.engine_file_store() + assert fs._client_id == 'user-1' + assert fs._full_path('out/x.csv') == 'teams/team-1/files/tasks/proj-1/out/x.csv' + + def test_engine_file_store_none_without_task(self, monkeypatch): + # Outside a running task (or without identity) there is no store. + for task in (None, {}, {'identity': {}}): + monkeypatch.setattr(Store, '_get_current_task', staticmethod(lambda t=task: t)) + assert Store.engine_file_store() is None + + +# ============================================================================ +# Normalization-bypass fuzz +# ============================================================================ + + +class TestNormalizationBypass: + """Every path spelling that NORMALIZES into the @ grammar must be + authorized as the @ grammar — never resolved around it. + """ + + BYPASS_SPELLINGS = [ + '\\@\\Team\\=team-else\\x', + '/@/Team/=team-else/x', + './@/Team/=team-else/x', + '@/Team//=team-else/x', + '//@/Team/=team-else/x', + '@/Team/./=team-else/x', + ] + + @pytest.mark.parametrize('spelling', BYPASS_SPELLINGS) + def test_bypass_spellings_still_denied(self, store, spelling): + fs = _fs(store, _account()) # no access to 'team-else' + with pytest.raises(PermissionError, match='Access denied for scoped path'): + fs._full_path(spelling) + + @pytest.mark.parametrize('spelling', BYPASS_SPELLINGS) + def test_bypass_spellings_resolve_into_grammar_for_members(self, store, spelling): + account = _account(extra_teams=[{'id': 'team-else', 'name': 'Else', 'permissions': ['task.store']}]) + fs = store._file_store(_ctx(account)) + assert fs._full_path(spelling) == 'teams/team-else/files/x' + + def test_traversal_still_rejected(self, store): + fs = _fs(store, _account()) + with pytest.raises(ValueError, match='traversal'): + fs._full_path('@/Team/=team-1/../../users/other/files/x') + + +# ============================================================================ +# Scope-root guards + shared write locks +# ============================================================================ + + +class TestScopeRootGuards: + """Scope roots cannot be deleted, rmdir'd, or renamed.""" + + @pytest.mark.asyncio + async def test_rmdir_scope_root_rejected(self, store): + fs = _fs(store, _account()) + with pytest.raises(StorageError, match='scope root'): + await fs.rmdir('@/Team/=team-1', recursive=True) + + @pytest.mark.asyncio + async def test_delete_scope_root_rejected(self, store): + fs = _fs(store, _account()) + with pytest.raises(StorageError, match='scope root'): + await fs.delete('@/Team/=team-1') + + @pytest.mark.asyncio + async def test_rename_scope_root_rejected(self, store): + fs = _fs(store, _account()) + with pytest.raises(StorageError, match='scope root'): + await fs.rename('@/Team/=team-1', '@/Team/=team-1/backup') + + @pytest.mark.asyncio + async def test_joined_mount_roots_rejected(self, store): + # The joined-mode mounts are scope roots too. + fs = _fs(store, _account(org_perms=['org.admin'])) + with pytest.raises(StorageError, match='scope root'): + await fs.rmdir('@/User', recursive=True) + with pytest.raises(StorageError, match='scope root'): + await fs.delete('@/Org') + + @pytest.mark.asyncio + async def test_own_root_rejected(self, store): + # The caller's OWN account root is a root too — this is the case the + # UNIVERSAL (`not rest`, not kind-conditional) guard exists for: + # delete('')/rename('') would otherwise be whole-account operations. + # Reverting the guard to `kind != 'own'` must turn this red. + fs = _fs(store, _account()) + with pytest.raises(StorageError, match='scope root'): + await fs.delete('') + with pytest.raises(StorageError, match='scope root'): + await fs.rename('', 'backup') + with pytest.raises(StorageError, match='scope root'): + await fs.rename('somedir', '') + + +class TestSharedWriteLocks: + """Two users' instances exclude each other on one physical team file.""" + + @pytest.mark.asyncio + async def test_cross_user_exclusion_on_team_path(self, store): + member_a = store._file_store(_ctx(_account(user_id='user-a'))) + member_b = store._file_store(_ctx(_account(user_id='user-b'))) + + handle = await member_a.open_write('@/Team/=team-1/shared.bin') + with pytest.raises(StorageError, match='already open for writing'): + await member_b.open_write('@/Team/=team-1/shared.bin') + await member_a.close_write(handle) + + # Released — B may now write. + handle_b = await member_b.open_write('@/Team/=team-1/shared.bin') + await member_b.close_write(handle_b) + + @pytest.mark.asyncio + async def test_store_close_all_handles_covers_all_instances(self, store): + fs = store._file_store(_ctx(_account(user_id='user-a'))) + await fs.open_write('@/Team/=team-1/dangling.bin') + # Disconnect-style cleanup through the Store, not the instance. + await store.close_all_handles(fs._ctx.conn_id) + # Lock released — reopening succeeds. + handle = await fs.open_write('@/Team/=team-1/dangling.bin') + await fs.close_write(handle) + + +# ============================================================================ +# Signed fetch URLs (get_url -> /task/fetch capability contract) +# ============================================================================ + + +class TestSignedFetchUrls: + """get_url signs the RESOLVED physical path — the /task/fetch capability. + + Regression for PR #1686: the claim used to carry the WIRE spelling, which + the fetch handler re-resolved under an internal identity — an identity + with no name dictionary and no org context, so '@/Team/' and + '@/Org' URLs died with an unhandled PermissionError (HTTP 500). The + claim now IS the physical path; the handler serves it without resolving. + """ + + @staticmethod + def _claim(url: str) -> dict: + """Decode the JWT claim out of a generated fetch URL.""" + import jwt + + token = url.split('token=', 1)[1] + return jwt.decode(token, 'test-signing-key', algorithms=['HS256']) + + @pytest.fixture(autouse=True) + def _signing_env(self, monkeypatch): + """The env get_url's local-JWT branch requires.""" + monkeypatch.setenv('RR_SIGNING_KEY', 'test-signing-key') + monkeypatch.setenv('RR_BASE_URL', 'http://localhost:5565') + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ('wire_path', 'physical'), + [ + # The reviewer's failure matrix: every wire spelling must sign + # its RESOLVED location — including the two that used to 500. + ('reports/q1.csv', 'users/user-1/files/reports/q1.csv'), + ('@/User/reports/q1.csv', 'users/user-1/files/reports/q1.csv'), + ('@/Team/Development/q1.csv', 'teams/team-1/files/q1.csv'), + ('@/Team/=team-1/q1.csv', 'teams/team-1/files/q1.csv'), + ], + ) + async def test_claim_carries_resolved_path(self, store, wire_path, physical): + fs = _fs(store, _account()) + url = await fs.get_url(wire_path) + assert self._claim(url)['path'] == physical + + @pytest.mark.asyncio + async def test_org_claim_carries_resolved_path(self, store): + # '@/Org' needs org.admin — the second spelling that used to 500. + fs = _fs(store, _account(org_perms=('org.admin',))) + url = await fs.get_url('@/Org/q1.csv') + assert self._claim(url)['path'] == 'orgs/org-1/files/q1.csv' + + @pytest.mark.asyncio + async def test_claim_serves_without_scope_resolution(self, store): + # The fetch handler's whole job now: backend._get_full_path(claim). + # Write through the scope grammar, then locate the file the way the + # handler does — no FileStore, no identity, no resolve_scope. + fs = _fs(store, _account()) + await fs.write('@/Team/Development/q1.csv', b'data') + url = await fs.get_url('@/Team/Development/q1.csv') + abs_path = store._store._get_full_path(self._claim(url)['path']) + assert abs_path.is_file() + assert abs_path.read_bytes() == b'data' + + @pytest.mark.asyncio + async def test_authorization_still_gates_issuance(self, store): + # Signing the physical path must not weaken issuance: a session + # without org.admin cannot mint an @/Org capability at all. + fs = _fs(store, _account()) + with pytest.raises(PermissionError): + await fs.get_url('@/Org/q1.csv') diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_cprofile.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_cprofile.py new file mode 100644 index 000000000..0be478620 --- /dev/null +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_cprofile.py @@ -0,0 +1,157 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Unit tests for ai.modules.task.commands.cmd_cprofile.CProfileCommands. + +Coverage focus: PROXY-MODE AUTHORIZATION. The proxy path forwards profiler +commands into a pipeline's engine subprocess, so the target lookup must +require ``task.control`` on the TARGET TASK'S team — a defaultTeam holder +must not be able to profile another team's engine (the cross-team hole this +suite pins closed). Direct mode (no target) keeps its defaultTeam check. + +Same harness conventions as the sibling suites: __init__ bypassed via +``__new__``; ``_server``/``_account_info`` seeded; DAP helpers mocked. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai.modules.task.commands.cmd_cprofile import CProfileCommands + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_conn(*, account_info=None, server=None, connection_id=7): + """Build a CProfileCommands instance with __init__ bypassed.""" + conn = CProfileCommands.__new__(CProfileCommands) + conn._account_info = account_info + conn._server = server or MagicMock() + conn._connection_id = connection_id + conn.build_response = MagicMock(side_effect=lambda req, body=None: {'type': 'response', 'body': body}) + conn.verify_permission = MagicMock() # defaultTeam gate, granted by default + return conn + + +def _account_info(): + """AccountInfo-shaped stub for an apikey session.""" + return SimpleNamespace( + userId='user-1', + auth='ak_x', + userToken='token-user-1', + defaultTeam='team-1', + organization={'id': 'org-1', 'teams': [{'id': 'team-1', 'permissions': ['task.control']}]}, + sysPermissions=[], + ) + + +def _running_control(): + """TASK_CONTROL stub whose task is running and echoes _send_data calls.""" + task = SimpleNamespace( + wait_for_running=AsyncMock(), + _send_data=AsyncMock(return_value={'body': {'status': 'started'}}), + ) + return SimpleNamespace(teamId='team-1', task=task) + + +# --------------------------------------------------------------------------- +# Proxy-mode authorization +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_proxy_requires_task_control_on_the_targets_team(): + """The target lookup passes account_info + require='task.control' so + authorization resolves against the TASK's team, not defaultTeam. + """ + account = _account_info() + server = MagicMock() + server.get_task_control = MagicMock(return_value=_running_control()) + conn = _make_conn(account_info=account, server=server) + + response = await CProfileCommands.on_rrext_cprofile_start( + conn, {'arguments': {'target': 'tk_target', 'session': 's'}} + ) + + server.get_task_control.assert_called_once_with('tk_target', account, require='task.control') + assert response['body'] == {'status': 'started'} + + +@pytest.mark.asyncio +async def test_proxy_cross_team_target_denied_before_forwarding(): + """A denied target lookup aborts BEFORE anything reaches the engine + subprocess — the cross-team profiling hole this closes. + """ + server = MagicMock() + server.get_task_control = MagicMock(side_effect=PermissionError('denied for this task')) + conn = _make_conn(account_info=_account_info(), server=server) + + with pytest.raises(PermissionError, match='denied for this task'): + await CProfileCommands.on_rrext_cprofile_stop(conn, {'arguments': {'target': 'tk_other_team'}}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'handler', + [ + CProfileCommands.on_rrext_cprofile_start, + CProfileCommands.on_rrext_cprofile_stop, + CProfileCommands.on_rrext_cprofile_status, + CProfileCommands.on_rrext_cprofile_report, + CProfileCommands.on_rrext_cprofile_report_tree, + ], +) +async def test_every_proxy_handler_authorizes_the_target(handler): + """All five cprofile handlers route proxy targets through the authorized + lookup — none may keep the old unchecked get_task_control(target) form. + """ + account = _account_info() + server = MagicMock() + server.get_task_control = MagicMock(return_value=_running_control()) + conn = _make_conn(account_info=account, server=server) + + await handler(conn, {'arguments': {'target': 'tk_target'}}) + + server.get_task_control.assert_called_once_with('tk_target', account, require='task.control') + + +# --------------------------------------------------------------------------- +# Direct mode is untouched +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_direct_mode_does_not_touch_task_lookup(): + """Without a target, the local profiler runs and no task lookup happens.""" + server = MagicMock() + conn = _make_conn(account_info=_account_info(), server=server) + + response = await CProfileCommands.on_rrext_cprofile_status(conn, {'arguments': {}}) + + server.get_task_control.assert_not_called() + assert 'active' in (response['body'] or {}) diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py index 076826d86..74d62b9f1 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py @@ -14,7 +14,7 @@ from __future__ import annotations -from types import SimpleNamespace +from types import MethodType, SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -37,7 +37,14 @@ def _make_conn(*, account_info=None, server=None, debug_token=None, debug_id=Non conn.send_event = AsyncMock() conn.debug_message = MagicMock() conn.verify_permission = MagicMock() + conn.verify_team_permission = MagicMock() # granted by default conn.get_task = MagicMock() + # Bind the REAL org resolver (defined on TaskConn, next to + # verify_team_permission) so on_launch exercises real membership-based + # resolution against the stub AccountInfo's organization. + from ai.modules.task.task_conn import TaskConn + + conn.resolve_org_for_team = MethodType(TaskConn.resolve_org_for_team, conn) conn.get_task_token = MagicMock(return_value='tk_x') # request() is defined on TaskConn (not DebugCommands); on_pause / # on_continue / on_configurationDone / on_threads call self.request(). @@ -106,12 +113,30 @@ async def test_on_launch_rejects_when_already_debugging(): @pytest.mark.asyncio -async def test_on_launch_rejects_when_default_team_not_in_any_org(): - """If the default team is not part of any org, PermissionError is raised.""" - account = _account_info(organization={'id': 'org-X', 'teams': [{'id': 'team-other'}]}) - conn = _make_conn(account_info=account) - with pytest.raises(PermissionError, match='does not belong to any organisation'): - await DebugCommands.on_launch(conn, {'arguments': {}}) +async def test_on_launch_rejects_foreign_or_unpermitted_team(): + """A team the caller lacks task.debug on is denied by the team permission + check before any task is started. + """ + server = MagicMock() + server.start_task = AsyncMock() + conn = _make_conn(account_info=_account_info(), server=server) + conn.verify_team_permission = MagicMock(side_effect=PermissionError('denied for team')) + with pytest.raises(PermissionError, match='denied for team'): + await DebugCommands.on_launch(conn, {'arguments': {'teamId': 'team-foreign'}}) + server.start_task.assert_not_called() + + +@pytest.mark.asyncio +async def test_on_launch_checks_task_debug_on_target_team(): + """on_launch verifies task.debug against the client-supplied teamId.""" + server = MagicMock() + server.start_task = AsyncMock(return_value={'id': 'task-1', 'token': 'tk_1'}) + # The target team must exist in the caller's org for org resolution (the + # permission stub grants, but resolve_org_for_team runs for real). + organization = {'id': 'org-1', 'teams': [{'id': 'team-1'}, {'id': 'team-target'}]} + conn = _make_conn(account_info=_account_info(organization=organization), server=server) + await DebugCommands.on_launch(conn, {'arguments': {'teamId': 'team-target'}}) + conn.verify_team_permission.assert_called_once_with('team-target', 'task.debug') # --------------------------------------------------------------------------- diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py index b680e88a7..2445a286a 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py @@ -24,6 +24,18 @@ from ai.modules.task.commands.cmd_store import StoreCommands +def _patch_store_file_store(monkeypatch, fs): + """Route the Store.file_store classmethod (the singleton one-liner the + handlers call) at the mocked FileStore for this test. + """ + from ai.account.store import Store + + # Mirrors the real classmethod signature (ctx, client_id=None, root=None) + # so a call site passing the engine storage anchor exercises the handler + # instead of dying in the patch with an opaque TypeError. + monkeypatch.setattr(Store, 'file_store', classmethod(lambda cls, ctx, client_id=None, root=None: fs)) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -38,11 +50,25 @@ def _make_conn(*, account_info=None, server=None, connection_id=1): conn.build_response = MagicMock(side_effect=lambda req, body=None: {'type': 'response', 'body': body}) conn.debug_message = MagicMock() conn.verify_permission = MagicMock() # granted by default + conn.verify_team_permission = MagicMock() # granted by default conn.verify_plans = MagicMock(return_value=True) conn.get_task = MagicMock() - # File-store access lives on StoreCommands; bind the real method so the - # fs_* handlers can resolve _get_file_store on this __init__-bypassed stub. + # Bind the REAL org resolver (defined on TaskConn, next to + # verify_team_permission) so on_execute exercises real membership-based + # resolution against the stub AccountInfo's organization. + from ai.modules.task.task_conn import TaskConn + + conn.resolve_org_for_team = MethodType(TaskConn.resolve_org_for_team, conn) + # Identity context builder (TaskConn.request_context) — the file store is + # mocked in these tests, so a stub ctx suffices. + conn.request_context = MagicMock(return_value=SimpleNamespace(account_info=account_info)) + # File-store access lives on StoreCommands; bind the real methods so the + # fs_* handlers can resolve them on this __init__-bypassed stub. conn._get_file_store = MethodType(StoreCommands._get_file_store, conn) + conn._virtual_scope_mounts = MethodType(StoreCommands._virtual_scope_mounts, conn) + conn._list_scope_mount = MethodType(StoreCommands._list_scope_mount, conn) + conn._is_scope_root = StoreCommands._is_scope_root # staticmethod — no binding + # Re-build the dispatch table that StoreCommands.__init__ would have created. conn._store_subcommand_handlers = { 'fs_open': lambda req, args: StoreCommands._store_fs_open(conn, req, args), @@ -61,13 +87,18 @@ def _make_conn(*, account_info=None, server=None, connection_id=1): def _account_info(*, user_id='user-1', auth='ak_x', default_team='team-1', organization=None): - """Build an AccountInfo-shaped stub.""" + """Build an AccountInfo-shaped stub. + + The default organization contains the default team so the real org + resolver (resolve_org_for_team) succeeds via membership; pass an explicit + organization to model other shapes. + """ return SimpleNamespace( userId=user_id, auth=auth, userToken='token-' + user_id, defaultTeam=default_team, - organization=organization, + organization=organization if organization is not None else {'id': 'org-1', 'teams': [{'id': default_team}]}, sysPermissions=[], ) @@ -100,14 +131,58 @@ async def test_on_execute_starts_task_with_resolved_org_id(): @pytest.mark.asyncio async def test_on_execute_requires_task_control_permission(): - """A PermissionError from verify_permission bubbles up after logging.""" + """A PermissionError from the team permission check bubbles up after logging.""" conn = _make_conn(account_info=_account_info()) - conn.verify_permission = MagicMock(side_effect=PermissionError('no control')) + conn.verify_team_permission = MagicMock(side_effect=PermissionError('no control')) with pytest.raises(PermissionError, match='no control'): await TaskCommands.on_execute(conn, {'arguments': {}}) conn.debug_message.assert_called() +@pytest.mark.asyncio +async def test_on_execute_checks_permission_on_the_target_team(monkeypatch): + """The task.control check runs against the CLIENT-SUPPLIED teamId (not + defaultTeam) so a foreign team cannot be targeted. + """ + from ai.account import account as account_mod + + # The stubbed permission check grants, so execution continues into the + # secret merge — patch it out so this test asserts only the check target. + monkeypatch.setattr(account_mod, 'get_merged_env', AsyncMock(return_value={})) + + organization = {'id': 'org-1', 'teams': [{'id': 'team-1'}, {'id': 'team-target'}]} + server = MagicMock() + server.start_task = AsyncMock(return_value={'token': 'tk_new'}) + conn = _make_conn(account_info=_account_info(default_team='team-1', organization=organization), server=server) + + await TaskCommands.on_execute(conn, {'arguments': {'teamId': 'team-target'}}) + + conn.verify_team_permission.assert_called_once_with('team-target', 'task.control') + + +@pytest.mark.asyncio +async def test_on_execute_foreign_team_denied_before_secret_merge(monkeypatch): + """A denied teamId aborts BEFORE the env/secret merge and before start_task — + the cross-team secret-exfiltration hole this check closes. + """ + from ai.account import account as account_mod + + merged_env = AsyncMock() + monkeypatch.setattr(account_mod, 'get_merged_env', merged_env) + + server = MagicMock() + server.start_task = AsyncMock() + conn = _make_conn(account_info=_account_info(), server=server) + conn.verify_team_permission = MagicMock(side_effect=PermissionError('denied')) + + with pytest.raises(PermissionError, match='denied'): + await TaskCommands.on_execute(conn, {'arguments': {'teamId': 'team-foreign'}}) + + # Neither the secret merge nor the task start may have been reached. + merged_env.assert_not_awaited() + server.start_task.assert_not_called() + + @pytest.mark.asyncio async def test_on_execute_checks_plan_for_pipeline(): """When the request includes a pipeline, verify_plans is invoked.""" @@ -160,6 +235,25 @@ async def test_on_restart_propagates_server_errors(): conn.debug_message.assert_called() +@pytest.mark.asyncio +async def test_on_restart_authorizes_against_the_tasks_team(): + """on_restart routes authorization through get_task (task-team resolution) + and never reaches restart_task when the caller lacks task.control on the + TASK's team — the cross-team restart hole this closes. + """ + server = MagicMock() + server.restart_task = AsyncMock() + conn = _make_conn(account_info=_account_info(), server=server) + conn.get_task = MagicMock(side_effect=PermissionError('denied for this task')) + + with pytest.raises(PermissionError, match='denied for this task'): + await TaskCommands.on_restart(conn, {'arguments': {'token': 'tk_other_team'}}) + + conn.get_task.assert_called_once() + assert conn.get_task.call_args.args[1] == 'task.control' + server.restart_task.assert_not_called() + + # --------------------------------------------------------------------------- # on_rrext_get_task_status # --------------------------------------------------------------------------- @@ -285,13 +379,13 @@ async def test_on_rrext_get_tasks_falls_back_to_source_name(): @pytest.mark.asyncio -async def test_on_rrext_store_dispatches_to_known_subcommand(): +async def test_on_rrext_store_dispatches_to_known_subcommand(monkeypatch): """A known subcommand is dispatched via _store_subcommand_handlers.""" server = MagicMock() server.store = MagicMock() fs = MagicMock() fs.stat = AsyncMock(return_value={'exists': True, 'size': 0}) - server.store.get_file_store = MagicMock(return_value=fs) + _patch_store_file_store(monkeypatch, fs) conn = _make_conn(account_info=_account_info(), server=server) response = await StoreCommands.on_rrext_store(conn, {'arguments': {'subcommand': 'fs_stat', 'path': 'foo.txt'}}) @@ -320,41 +414,41 @@ async def test_on_rrext_store_missing_subcommand_raises(): @pytest.mark.asyncio -async def test_store_fs_open_write_returns_handle_id(): +async def test_store_fs_open_write_returns_handle_id(monkeypatch): """fs_open with mode='w' creates a write handle and returns its id.""" server = MagicMock() fs = MagicMock() fs.open_write = AsyncMock(return_value='h-123') - server.store.get_file_store = MagicMock(return_value=fs) + _patch_store_file_store(monkeypatch, fs) conn = _make_conn(account_info=_account_info(), server=server, connection_id=42) args = {'path': 'foo.txt', 'mode': 'w'} response = await StoreCommands._store_fs_open(conn, {}, args) - fs.open_write.assert_awaited_once_with('foo.txt', 42) + fs.open_write.assert_awaited_once_with('foo.txt') assert response['body'] == {'handle': 'h-123'} @pytest.mark.asyncio -async def test_store_fs_open_read_returns_metadata(): +async def test_store_fs_open_read_returns_metadata(monkeypatch): """fs_open default mode opens for reading and returns the metadata dict.""" server = MagicMock() fs = MagicMock() fs.open_read = AsyncMock(return_value={'handle': 'h-456', 'size': 1024}) - server.store.get_file_store = MagicMock(return_value=fs) + _patch_store_file_store(monkeypatch, fs) conn = _make_conn(account_info=_account_info(), server=server, connection_id=7) response = await StoreCommands._store_fs_open(conn, {}, {'path': 'foo.txt'}) - fs.open_read.assert_awaited_once_with('foo.txt', 7) + fs.open_read.assert_awaited_once_with('foo.txt') assert response['body'] == {'handle': 'h-456', 'size': 1024} @pytest.mark.asyncio -async def test_store_fs_read_clamps_negative_offset(): +async def test_store_fs_read_clamps_negative_offset(monkeypatch): """Negative offset is reset to 0 before forwarding to FileStore.""" server = MagicMock() fs = MagicMock() fs.read_chunk = AsyncMock(return_value=b'data') - server.store.get_file_store = MagicMock(return_value=fs) + _patch_store_file_store(monkeypatch, fs) conn = _make_conn(account_info=_account_info(), server=server) args = {'handle': 'h-1', 'offset': -50, 'length': 100} @@ -368,6 +462,202 @@ async def test_store_fs_read_clamps_negative_offset(): assert call_args.args[1] == 0 +# --------------------------------------------------------------------------- +# Virtual scope mounts ('@' / '@/Team') in fs_list_dir / fs_stat +# --------------------------------------------------------------------------- + + +def _org_account(*, teams=None, org_perms=()): + """Account stub with an organization for scope-mount tests.""" + return _account_info( + organization={ + 'id': 'org-1', + 'name': 'Acme', + 'permissions': list(org_perms), + 'teams': teams + if teams is not None + else [{'id': 'team-1', 'name': 'Development', 'permissions': ['task.store']}], + } + ) + + +@pytest.mark.asyncio +async def test_root_listing_is_pure_and_filters_reserved_names(monkeypatch): + """Simple mode: the root listing is the caller's own tree ONLY — no + injected mounts — and reserved '@'/'=' physical names are dropped. + """ + server = MagicMock() + fs = MagicMock() + fs.list_dir = AsyncMock( + return_value={ + 'entries': [ + {'name': 'docs', 'type': 'dir'}, + {'name': '@legacy', 'type': 'dir'}, + {'name': '=old', 'type': 'file'}, + ], + 'count': 3, + } + ) + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_org_account(org_perms=['org.admin']), server=server) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': ''}) + + names = [e['name'] for e in response['body']['entries']] + assert names == ['docs'] + assert response['body']['count'] == 1 + + +@pytest.mark.asyncio +async def test_at_listing_shows_mounts_by_capability(monkeypatch): + """Joined mode: '@' lists User/Team always, Org only for org.admin.""" + server = MagicMock() + fs = MagicMock() + fs.list_dir = AsyncMock() + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_org_account(org_perms=['org.admin']), server=server) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': '@'}) + assert [e['name'] for e in response['body']['entries']] == ['User', 'Team', 'Org'] + + conn = _make_conn(account_info=_org_account(), server=server) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': '@'}) + assert [e['name'] for e in response['body']['entries']] == ['User', 'Team'] + + # The mounts are virtual — the store is never consulted. + fs.list_dir.assert_not_called() + + +@pytest.mark.asyncio +async def test_listing_team_mount_returns_memberships_not_storage(monkeypatch): + """Listing '@/Team' is VIRTUAL: the caller's teams by display name with the + id in the entry body — never a physical teams/ listing. + """ + server = MagicMock() + fs = MagicMock() + fs.list_dir = AsyncMock() + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_org_account(), server=server) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': '@/Team'}) + + fs.list_dir.assert_not_called() + assert response['body']['entries'] == [{'name': 'Development', 'type': 'dir', 'id': 'team-1', 'virtual': True}] + + +@pytest.mark.asyncio +async def test_stat_scope_mounts_synthesize_directories(monkeypatch): + """fs_stat on the bare mounts reports a virtual directory.""" + server = MagicMock() + fs = MagicMock() + fs.stat = AsyncMock() + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_org_account(), server=server) + for path in ('@', '@/User', '@/Team', '@/Org', '/@/Team/'): + response = await StoreCommands._store_fs_stat(conn, {}, {'path': path}) + assert response['body'] == {'exists': True, 'type': 'dir', 'virtual': True} + fs.stat.assert_not_called() + + +@pytest.mark.asyncio +async def test_no_org_session_at_listing(monkeypatch): + """A session without an organization still gets User/Team (its team + list is simply empty) but never Org. + """ + server = MagicMock() + fs = MagicMock() + fs.list_dir = AsyncMock() + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_account_info(), server=server) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': '@'}) + + assert [e['name'] for e in response['body']['entries']] == ['User', 'Team'] + + +# --------------------------------------------------------------------------- +# System trees (.logs / .deployments) hidden from listings +# --------------------------------------------------------------------------- + + +def _fs_with_system_entries(): + """File-store mock whose listing includes the system trees.""" + fs = MagicMock() + fs.list_dir = AsyncMock( + return_value={ + 'entries': [ + {'name': '.logs', 'type': 'dir'}, + {'name': '.deployments', 'type': 'dir'}, + {'name': 'docs', 'type': 'dir'}, + ], + 'count': 3, + } + ) + return fs + + +@pytest.mark.asyncio +async def test_listing_hides_system_trees_from_ordinary_sessions(monkeypatch): + """.logs/.deployments are system-owned: invisible at every scope root + for callers without sys.admin. + """ + fs = _fs_with_system_entries() + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_org_account(), server=MagicMock()) + # The tail of the matrix is the normalization-bypass family: spellings + # that reach a scope root only AFTER normalize_path collapses them + # ('@//User', '@/./User', '\\@\\User', '/'). The filter and the store + # must judge the SAME normalized path — filtering on the raw spelling + # would let these list the user root with the system trees visible. + for path in ( + '', + '@/User', + '@/Org', + '@/Team/=team-1', + '@/User/=other-user', + '@//User', + '@/./User', + '\\@\\User', + '/', + ): + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': path}) + names = [e['name'] for e in response['body']['entries']] + assert '.logs' not in names and '.deployments' not in names + assert 'docs' in names + + +@pytest.mark.asyncio +async def test_listing_shows_system_trees_to_sys_admin(monkeypatch): + """sys.admin may do anything with the system trees — including see them.""" + fs = _fs_with_system_entries() + _patch_store_file_store(monkeypatch, fs) + + account = _org_account() + account.sysPermissions = ['sys.admin'] + conn = _make_conn(account_info=account, server=MagicMock()) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': ''}) + + names = [e['name'] for e in response['body']['entries']] + assert '.logs' in names and '.deployments' in names + + +@pytest.mark.asyncio +async def test_nested_listing_keeps_user_dirs_named_like_system_trees(monkeypatch): + """Only SCOPE ROOTS host system trees — a nested dir a user happened to + name '.logs' stays visible (the store would resolve it normally too). + """ + fs = _fs_with_system_entries() + _patch_store_file_store(monkeypatch, fs) + + conn = _make_conn(account_info=_org_account(), server=MagicMock()) + response = await StoreCommands._store_fs_list_dir(conn, {}, {'path': 'docs/sub'}) + + names = [e['name'] for e in response['body']['entries']] + assert '.logs' in names and '.deployments' in names + + # --------------------------------------------------------------------------- # Constructor — exercises the dispatch-table population # --------------------------------------------------------------------------- diff --git a/packages/ai/tests/ai/modules/task/test_log_stream.py b/packages/ai/tests/ai/modules/task/test_log_stream.py index 0d5d20346..7e28015a4 100644 --- a/packages/ai/tests/ai/modules/task/test_log_stream.py +++ b/packages/ai/tests/ai/modules/task/test_log_stream.py @@ -41,7 +41,6 @@ import pytest import ai.modules.task.run_log as run_log -from ai.account.file_store import FileStore from ai.account.store_providers.filesystem import FilesystemStore from rocketride import log_stream as rr_log_stream from rocketride._log_codec import normalize_stamps @@ -54,6 +53,7 @@ PROJECT, SOURCE, flow_op, + make_file_store, make_stamp, open_writer, output_event, @@ -149,7 +149,14 @@ async def seed_rich(istore, spool_root, monkeypatch): await writer._drain_uploads() await writer.end_run('ok') - return run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + return run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) def sse_event(pid, message): @@ -360,7 +367,14 @@ def emit(ev): ) await writer._drain_uploads() await writer.end_run('ok') - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) golden = await read_golden(reader, from_seq=0) # Sanity: the scenario really spans multiple segments. @@ -437,7 +451,14 @@ async def test_get_trace_reaches_active_segment_growth(self, istore, spool_root, writer = await open_writer(istore, spool_root, stamp, raise_floor) writer.append(stamp(flow_op('begin', pid=3, component='x'))) writer.append(stamp(flow_op('end', pid=3, component='x'))) - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) session = open_session(reader) # Cache the active segment NOW — it goes stale the moment more @@ -473,7 +494,14 @@ async def test_live_arrivals_deliver_through_the_pump(self, istore, spool_root, stamp, raise_floor, state = make_stamp() writer = await open_writer(istore, spool_root, stamp, raise_floor) writer.append(stamp(output_event('boot'))) - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) session = open_session(reader) delivered = [] @@ -511,7 +539,14 @@ async def test_raced_fetch_still_delivers_through_poke(self, istore, spool_root, stamp, raise_floor, state = make_stamp() writer = await open_writer(istore, spool_root, stamp, raise_floor) writer.append(stamp(output_event('boot'))) - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) session = open_session(reader) delivered = [] diff --git a/packages/ai/tests/ai/modules/task/test_run_log.py b/packages/ai/tests/ai/modules/task/test_run_log.py index 23a446bf0..4fa131e46 100644 --- a/packages/ai/tests/ai/modules/task/test_run_log.py +++ b/packages/ai/tests/ai/modules/task/test_run_log.py @@ -53,6 +53,8 @@ truncate_event, ) from ai.account.file_store import FileStore +from ai.account.store import Store +from ai.account.models import RequestContext from ai.account.store_providers.filesystem import FilesystemStore CLIENT = 'user-1' @@ -131,12 +133,30 @@ def status_event(**fields): return {'type': 'event', 'event': 'apaevt_status_update', 'body': dict(fields)} +def make_file_store(istore) -> FileStore: + """An internal-identity FileStore over the shared test istore. + + Each call deliberately wraps the istore in its OWN Store: separate + handle/lock registries per consumer, modeling independent subsystems + (writer vs reader vs a fresh process) coordinating only through the + backend — the shape production has across processes. + """ + return FileStore(Store(istore), CLIENT, RequestContext.internal('test')) + + async def open_writer(istore, spool_root, stamp=None, raise_floor=None, kind=KIND): """Create + open a writer with fake stamping callbacks.""" if stamp is None: stamp, raise_floor, _ = make_stamp() writer = RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, kind, stamp, raise_floor, spool_root=spool_root + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + kind, + stamp, + raise_floor, + spool_root=spool_root, ) await writer.open(trigger='manual', user=CLIENT, pipeline_hash='abc123', trace_level='summary') return writer @@ -408,7 +428,14 @@ async def test_resumed_stream_marks_first_keyframe_incomplete(self, istore, spoo # A FRESH process (new writer) resumes the stream: its first keyframe # must be marked incomplete (pre-existing open state is unknown). writer2 = RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, stamp, raise_floor, spool_root=spool_root + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + stamp, + raise_floor, + spool_root=spool_root, ) await writer2.open(trigger='manual', user=CLIENT, pipeline_hash='h2', trace_level=None) writer2.append(stamp(output_event('run-2'))) @@ -548,7 +575,14 @@ async def test_two_runs_share_stream_chapters_and_seq_continue(self, istore, spo # so the catalog floor lift is what carries the continuum forward. stamp2, raise_floor2, state2 = make_stamp() writer2 = RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, stamp2, raise_floor2, spool_root=spool_root + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + stamp2, + raise_floor2, + spool_root=spool_root, ) await writer2.open(trigger='manual', user=CLIENT, pipeline_hash='abc123', trace_level='summary') writer2.append(stamp2(output_event('run2'))) @@ -648,7 +682,14 @@ async def test_chapters_capped_at_constant(self, istore, spool_root, monkeypatch writer = None for i in range(5): writer = RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, stamp, raise_floor, spool_root=spool_root + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + stamp, + raise_floor, + spool_root=spool_root, ) await writer.open(trigger='manual', user=CLIENT, pipeline_hash='h', trace_level=None) await writer.end_run('ok') @@ -689,7 +730,14 @@ async def test_recovery_trusts_uploaded_drops_spooled(self, istore, spool_root, try: stamp2, raise_floor2, state2 = make_stamp() writer2 = RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, stamp2, raise_floor2, spool_root=fresh_spool + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + stamp2, + raise_floor2, + spool_root=fresh_spool, ) await writer2.open(trigger='manual', user=CLIENT, pipeline_hash='h', trace_level=None) diff --git a/packages/ai/tests/ai/modules/task/test_run_log_reader.py b/packages/ai/tests/ai/modules/task/test_run_log_reader.py index fb3152d43..1a04d9f46 100644 --- a/packages/ai/tests/ai/modules/task/test_run_log_reader.py +++ b/packages/ai/tests/ai/modules/task/test_run_log_reader.py @@ -51,11 +51,11 @@ SOURCE, STORE_PREFIX, STREAM, + make_file_store, make_stamp, open_writer, output_event, ) -from ai.account.file_store import FileStore # ============================================================================= @@ -88,7 +88,14 @@ async def seed_stream(istore, spool_root, monkeypatch, events=30): await writer.end_run('ok') writer2 = run_log.RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, stamp, raise_floor, spool_root=spool_root + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + stamp, + raise_floor, + spool_root=spool_root, ) await writer2.open(trigger='manual', user=CLIENT, pipeline_hash='h2', trace_level=None) for i in range(5): @@ -96,7 +103,14 @@ async def seed_stream(istore, spool_root, monkeypatch, events=30): await writer2._drain_uploads() await writer2.end_run('error', 'boom') - return run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + return run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) # ============================================================================= @@ -119,7 +133,14 @@ async def test_chapters_shape(self, istore, spool_root, monkeypatch): @pytest.mark.asyncio async def test_missing_stream_raises(self, istore, spool_root): - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, 'nope', SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + 'nope', + SOURCE, + KIND, + spool_root=spool_root, + ) with pytest.raises(FileNotFoundError): await reader.chapters() @@ -175,7 +196,14 @@ async def test_jumbo_line_ships_whole(self, istore, spool_root, monkeypatch): writer.append(stamp(output_event('big-' + 'y' * 5000))) await writer._drain_uploads() await writer.end_run('ok') - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) # Ceiling smaller than the line: the line must still arrive whole chunk = await reader.segment_raw(0, offset=0, max_bytes=64) first_line = chunk['data'].splitlines()[0] @@ -255,7 +283,14 @@ async def test_below_horizon_flags_truncation(self, istore, spool_root, monkeypa monkeypatch.setattr(run_log, 'CONST_LOG_SEGMENTS', 1) stamp, raise_floor, _ = make_stamp(start=SEED + 10**9) writer = run_log.RunLogWriter( - FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, stamp, raise_floor, spool_root=spool_root + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + stamp, + raise_floor, + spool_root=spool_root, ) await writer.open(trigger='manual', user=CLIENT, pipeline_hash='h3', trace_level=None) for _ in range(30): @@ -329,7 +364,14 @@ async def test_reader_prefers_live_writer_control(self, istore, spool_root, monk writer.append(stamp(output_event('live-' + 'z' * 40))) # No drain, no end_run: sealed segments exist only as spool copies and # the freshest control lives on the writer, not in the store. - reader = run_log.RunLogReader(FileStore(istore, CLIENT), CLIENT, PROJECT, SOURCE, KIND, spool_root=spool_root) + reader = run_log.RunLogReader( + make_file_store(istore), + CLIENT, + PROJECT, + SOURCE, + KIND, + spool_root=spool_root, + ) chapters = await reader.chapters() assert chapters['completed'] is False body = await reader.read(from_seq=0) diff --git a/packages/ai/tests/ai/modules/task/test_task_conn.py b/packages/ai/tests/ai/modules/task/test_task_conn.py index 63feead01..e32a9d30a 100644 --- a/packages/ai/tests/ai/modules/task/test_task_conn.py +++ b/packages/ai/tests/ai/modules/task/test_task_conn.py @@ -339,6 +339,81 @@ def test_verify_permission_passes_when_present(monkeypatch): conn.verify_permission('task.control') # must not raise +# --------------------------------------------------------------------------- +# verify_team_permission — real resolver, real org shapes +# --------------------------------------------------------------------------- + + +def _org(team_perms, *, team_id='team-1', org_perms=()): + """One-org/one-team organization dict in the AccountInfo session shape.""" + return { + 'id': 'org-1', + 'permissions': list(org_perms), + 'teams': [{'id': team_id, 'name': 'Team One', 'permissions': list(team_perms)}], + } + + +def test_verify_team_permission_grants_on_that_team(): + """The permission is resolved against the ADDRESSED team, not defaultTeam.""" + account = _make_account_info(default_team='team-other') + account.organization = _org(['task.control'], team_id='team-1') + conn = _make_conn(account_info=account) + conn.verify_team_permission('team-1', 'task.control') # must not raise + + +def test_verify_team_permission_denies_missing_permission(): + """Membership without the required permission is denied.""" + account = _make_account_info() + account.organization = _org(['task.monitor']) + conn = _make_conn(account_info=account) + with pytest.raises(PermissionError, match="'task.control' denied"): + conn.verify_team_permission('team-1', 'task.control') + + +def test_verify_team_permission_denies_foreign_team_uniformly(): + """A team outside the caller's org denies exactly like a no-permission team + (no existence leak). + """ + account = _make_account_info() + account.organization = _org(['task.control']) + conn = _make_conn(account_info=account) + with pytest.raises(PermissionError, match='no permissions for team'): + conn.verify_team_permission('team-foreign', 'task.control') + + +def test_verify_team_permission_denies_without_org(): + """A no-org session holds no team permissions at all.""" + account = _make_account_info() + account.organization = None + conn = _make_conn(account_info=account) + with pytest.raises(PermissionError, match='no permissions for team'): + conn.verify_team_permission('team-1', 'task.control') + + +def test_verify_team_permission_org_admin_expands(): + """org.admin implies the full team permission set (resolver expansion).""" + account = _make_account_info() + account.organization = _org([], org_perms=['org.admin']) + conn = _make_conn(account_info=account) + conn.verify_team_permission('team-1', 'task.control') # must not raise + + +def test_verify_team_permission_sys_admin_bypasses(): + """sys.admin bypasses team scoping entirely, even for foreign teams.""" + account = _make_account_info() + account.organization = None + account.sysPermissions = ['sys.admin'] + conn = _make_conn(account_info=account) + conn.verify_team_permission('team-anything', 'task.control') # must not raise + + +def test_verify_team_permission_requires_authentication(): + """No account info -> PermissionError before any resolution.""" + conn = _make_conn(authenticated=False, account_info=None) + with pytest.raises(PermissionError, match='Not authenticated'): + conn.verify_team_permission('team-1', 'task.control') + + # --------------------------------------------------------------------------- # require_zitadel_auth # --------------------------------------------------------------------------- diff --git a/packages/ai/tests/ai/modules/task/test_task_engine.py b/packages/ai/tests/ai/modules/task/test_task_engine.py index bb9e23a06..ddea26881 100644 --- a/packages/ai/tests/ai/modules/task/test_task_engine.py +++ b/packages/ai/tests/ai/modules/task/test_task_engine.py @@ -62,6 +62,9 @@ def _task(*, source='src-id', task_name=None, pipeline=None, status=None): t = Task.__new__(Task) t.id = 'task-test' t.token = 'tk_test' + t.client_id = 'user-1' + t.team_id = 'team-1' + t.org_id = 'org-1' t.source = source # Real tasks always carry their project id; _forward_task_event stamps # it into every forwarded body (identity safety net). @@ -70,6 +73,7 @@ def _task(*, source='src-id', task_name=None, pipeline=None, status=None): t._pipeline = pipeline if pipeline is not None else {} t._threads = 4 t._pipelineTraceLevel = None + t._run_kind = 'dev' t._status = status if status is not None else SimpleNamespace(name='', state=0, exitMessage='') t._debugger = None t._debug_port = None @@ -190,6 +194,53 @@ def test_build_task_returns_subprocess_config_shape(tmp_path, monkeypatch): 'components': [{'id': 'src'}], } assert config['config']['keystore'] == 'kvsfile://data/keystore.json' + # Trusted identity travels IN THE TASK FILE (never the environment). + assert config['identity'] == {'userId': 'user-1', 'teamId': 'team-1', 'orgId': 'org-1'} + # Dev runs anchor node storage at the owner's whole tree. + assert config['storage'] == {'root': 'users/user-1/files'} + + +def test_build_task_deploy_storage_anchor(monkeypatch, tmp_path): + """Deploy runs anchor node storage at a task-specific TEAM subtree — + no user dependency, and concurrent deployments never share storage. + """ + monkeypatch.setattr(sys, 'executable', str(tmp_path / 'engine.exe')) + monkeypatch.setattr(os, 'makedirs', lambda p, exist_ok=False: None) + + pipeline = {'source': 'src', 'components': []} + t = _task(pipeline=pipeline) + t._run_kind = 'deploy' + config = Task._build_task(t, pipeline) + assert config['storage'] == {'root': 'teams/team-1/files/tasks/proj-test'} + + +def test_build_task_deploy_without_team_refuses(monkeypatch, tmp_path): + """A deploy run with no team has no valid anchor — fail loudly.""" + monkeypatch.setattr(sys, 'executable', str(tmp_path / 'engine.exe')) + monkeypatch.setattr(os, 'makedirs', lambda p, exist_ok=False: None) + + t = _task(pipeline={'components': []}) + t._run_kind = 'deploy' + t.team_id = '' + with pytest.raises(ValueError, match='team_id'): + Task._build_task(t, {'components': []}) + + +def test_build_task_dev_without_client_gets_no_anchor(monkeypatch, tmp_path): + """An anonymous dev run (client_id='' — OSS/standalone launch) carries NO + anchor rather than failing the launch: identity.userId rides empty too, + so the subprocess's engine_file_store() yields None and the storage + tools disable themselves. The pin: the shared 'users//files' prefix must + never be composed as an anchor. + """ + monkeypatch.setattr(sys, 'executable', str(tmp_path / 'engine.exe')) + monkeypatch.setattr(os, 'makedirs', lambda p, exist_ok=False: None) + + pipeline = {'source': 'src', 'components': []} + t = _task(pipeline=pipeline) + t.client_id = '' + config = Task._build_task(t, pipeline) + assert config['storage'] == {'root': ''} def test_build_task_supplies_pipeline_version_default(monkeypatch, tmp_path): diff --git a/packages/ai/tests/ai/modules/task/test_task_server.py b/packages/ai/tests/ai/modules/task/test_task_server.py index 07d77e3da..e093f66e1 100644 --- a/packages/ai/tests/ai/modules/task/test_task_server.py +++ b/packages/ai/tests/ai/modules/task/test_task_server.py @@ -208,15 +208,29 @@ def test_get_task_control_unknown_token_raises_runtime(): ts.get_task_control('tk_unknown') +def test_get_task_control_with_require_denies_without_membership(monkeypatch): + """No permissions on the task's team -> uniform access-denied error.""" + from ai.modules.task import task_server as ts_mod + + monkeypatch.setattr(ts_mod, 'resolve_task_permissions', lambda info, team: []) + + ts = _make_server() + ts._task_control['tk_1'] = _make_control() + account = SimpleNamespace(userId='u', defaultTeam='t', sysPermissions=[]) + + with pytest.raises(PermissionError, match='no permissions for this task'): + ts.get_task_control('tk_1', account_info=account, require='task.debug') + + def test_get_task_control_with_require_enforces_permission(monkeypatch): - """When account_info+require are provided, missing perms raise PermissionError.""" + """Membership without the required permission raises PermissionError.""" from ai.modules.task import task_server as ts_mod - monkeypatch.setattr(ts_mod, 'resolve_team_permissions', lambda info, team: set()) + monkeypatch.setattr(ts_mod, 'resolve_task_permissions', lambda info, team: ['task.monitor']) ts = _make_server() ts._task_control['tk_1'] = _make_control() - account = SimpleNamespace(userId='u', defaultTeam='t') + account = SimpleNamespace(userId='u', defaultTeam='t', sysPermissions=[]) with pytest.raises(PermissionError, match="'task.debug' denied"): ts.get_task_control('tk_1', account_info=account, require='task.debug') @@ -226,12 +240,26 @@ def test_get_task_control_with_require_passes_when_granted(monkeypatch): """A granted permission lets get_task_control return the control unmodified.""" from ai.modules.task import task_server as ts_mod - monkeypatch.setattr(ts_mod, 'resolve_team_permissions', lambda info, team: {'task.debug'}) + monkeypatch.setattr(ts_mod, 'resolve_task_permissions', lambda info, team: ['task.debug']) ts = _make_server() control = _make_control() ts._task_control['tk_1'] = control - account = SimpleNamespace(userId='u', defaultTeam='t') + account = SimpleNamespace(userId='u', defaultTeam='t', sysPermissions=[]) + + assert ts.get_task_control('tk_1', account_info=account, require='task.debug') is control + + +def test_get_task_control_with_require_sys_admin_bypasses(): + """sys.admin is granted INSIDE resolve_task_permissions (it returns the + full permission set) — deliberately no outer short-circuit to keep in + sync (parity with get_task). + """ + ts = _make_server() + control = _make_control() + ts._task_control['tk_1'] = control + # No organization at all: only the resolver's sys.admin expansion grants. + account = SimpleNamespace(userId='u', defaultTeam='t', sysPermissions=['sys.admin'], organization=None) assert ts.get_task_control('tk_1', account_info=account, require='task.debug') is control @@ -389,21 +417,18 @@ async def test_broadcast_task_event_logs_other_exceptions(): # --------------------------------------------------------------------------- -def test_store_property_creates_once_and_caches(monkeypatch): - """First access creates a Store via Store.create(); subsequent access reuses it.""" +def test_store_property_returns_process_singleton(monkeypatch): + """TaskServer.store IS the process singleton — server code and + Store.file_store(ctx) call sites can never diverge onto different stores. + """ from ai.modules.task import task_server as ts_mod fake_store = MagicMock(name='store') - create_mock = MagicMock(return_value=fake_store) - monkeypatch.setattr(ts_mod.Store, 'create', create_mock) + monkeypatch.setattr(ts_mod.Store, '_instance', fake_store) ts = _make_server() - s1 = ts.store - s2 = ts.store - - assert s1 is fake_store - assert s2 is fake_store - create_mock.assert_called_once() # cached + assert ts.store is fake_store + assert ts.store is ts_mod.Store.instance() # --------------------------------------------------------------------------- diff --git a/packages/ai/tests/conftest.py b/packages/ai/tests/conftest.py index 6a3f46fc5..d854901b0 100644 --- a/packages/ai/tests/conftest.py +++ b/packages/ai/tests/conftest.py @@ -1,9 +1,28 @@ import sys from pathlib import Path +import pytest + # Reactively strip the debugger / pytest rootdir entries that point at # stale dist content. Triggers only under the VS Code debug attach. # sys.path[0] = packages/ai (pytest rootdir from pyproject.toml) # sys.path[1] = '' (cwd = dist/server, injected by debugpy) if len(sys.path) >= 2 and Path(sys.path[0]) == Path(__file__).parent.parent and sys.path[1] == '': del sys.path[0:2] + + +@pytest.fixture(autouse=True) +def _reset_store_singleton(): + """Drop the Store process singleton around every test. + + The singleton is built lazily from RR_STORE_URL on first Store.instance() + call and carries the process-wide _shared_handles / _shared_write_locks + registries. Without this reset, the first test to touch the singleton + would pin whatever env was live at that moment for the REST of the + session, making any singleton-reaching test order-dependent and stateful. + """ + from ai.account.store import Store + + Store.reset() + yield + Store.reset() diff --git a/packages/server/engine-lib/engLib/store/python/bindings.cpp b/packages/server/engine-lib/engLib/store/python/bindings.cpp index 5e583ba1a..1bd97f8d0 100644 --- a/packages/server/engine-lib/engLib/store/python/bindings.cpp +++ b/packages/server/engine-lib/engLib/store/python/bindings.cpp @@ -264,6 +264,24 @@ PYBIND11_EMBEDDED_MODULE(engLib, engLib) { engine::python::executePythonArguments(argv); }); + //------------------------------------------------------------- + /// @details + /// Returns the complete task-file JSON of the task currently + /// executing in this process, or None when no task is + /// running. This is how subprocess python reads trusted + /// task-file data (identity, storage anchor) without + /// per-endpoint plumbing — published around the task's + /// begin/end window by ITask::execute. + ///------------------------------------------------------------ + engLib.PYBIND_FUNCTION(getTask, []() -> py::object { + // Read the published task (a copy; null when none is running) + auto task = engine::task::ITask::currentTask(); + if (task.isNull()) return py::none(); + + // Hand python its own dict representation + return pyjson::jsonToDict(task); + }); + //------------------------------------------------------------- /// @details /// Declares a debug output function which can accept diff --git a/packages/server/engine-lib/engLib/task/core/task.cpp b/packages/server/engine-lib/engLib/task/core/task.cpp index 282ee8b24..436b47517 100644 --- a/packages/server/engine-lib/engLib/task/core/task.cpp +++ b/packages/server/engine-lib/engLib/task/core/task.cpp @@ -24,6 +24,19 @@ #include namespace engine::task { +namespace { +//------------------------------------------------------------------------- +/// @details +/// The task-file JSON of the task currently executing in this +/// process (null when none). Set/cleared and read on the main task +/// thread only (the getTask() binding is consumed at endpoint +/// construction, inside the publish window) — publish, reads, and +/// clear are strictly sequential on that one thread, so no +/// synchronization is needed. +//------------------------------------------------------------------------- +json::Value g_currentTask; +} // namespace + //------------------------------------------------------------------------- /// @details /// Signal the the task is beginning @@ -72,6 +85,13 @@ Error ITask::execute() noexcept { // Debug LOGT("Started", type()); + // Publish this task for rocketlib.getTask(). Set BEFORE beginTask — + // endpoint construction (and the python nodes' global initialization) + // happens inside it — and cleared by the guard on EVERY exit path, so + // an errored task never leaves a stale publication behind. + util::Guard taskScope{[&] { setCurrentTask(m_args.cmd); }, + [&] { setCurrentTask({}); }}; + // Allow the task to read any configuration it needs if (auto ccode = beginTask()) return ccode; @@ -92,6 +112,24 @@ Error ITask::execute() noexcept { //------------------------------------------------------------------------- json::Value &ITask::jobConfig() noexcept { return m_args.cmd; } +//------------------------------------------------------------------------- +/// @details +/// Return a copy of the currently-executing task's file JSON — +/// null when no task is running. Main-task-thread only (see +/// g_currentTask above): publish, read, and clear are sequential +/// on that one thread. +//------------------------------------------------------------------------- +json::Value ITask::currentTask() noexcept { return g_currentTask; } + +//------------------------------------------------------------------------- +/// @details +/// Publish (or, with a null value, clear) the currently-executing +/// task for currentTask() readers +//------------------------------------------------------------------------- +void ITask::setCurrentTask(const json::Value &task) noexcept { + g_currentTask = task; +} + //------------------------------------------------------------------------- /// @details /// Return a reference to the task configuration diff --git a/packages/server/engine-lib/engLib/task/headers/task.hpp b/packages/server/engine-lib/engLib/task/headers/task.hpp index 0314463c2..0fb8299b1 100644 --- a/packages/server/engine-lib/engLib/task/headers/task.hpp +++ b/packages/server/engine-lib/engLib/task/headers/task.hpp @@ -118,6 +118,22 @@ class ITask { virtual Error execute() noexcept; static Text buildType(json::Value &cmd) noexcept; + //----------------------------------------------------------------- + /// @details + /// The complete task-file JSON of the task currently executing + /// in this process — null when no task is running. Published + /// for the python binding rocketlib.getTask() so subprocess + /// python (e.g. the storage layer reading identity and the + /// storage anchor) needs no per-endpoint plumbing. + /// + /// MAIN-TASK-THREAD ONLY: publish (before beginTask), every + /// read (endpoint/node-global construction inside the publish + /// window), and clear (task exit) all run sequentially on the + /// single main task thread — the value is unsynchronized by + /// design. Do not call from worker threads. + //----------------------------------------------------------------- + static json::Value currentTask() noexcept; + protected: //----------------------------------------------------------------- // Protected interfaces which can/must be implemented @@ -133,6 +149,12 @@ class ITask { file::Path taskPath; private: + //----------------------------------------------------------------- + /// @details + /// Publish/clear the currently-executing task (see currentTask). + //----------------------------------------------------------------- + static void setCurrentTask(const json::Value &task) noexcept; + //----------------------------------------------------------------- /// @details /// Contains the arguments passed to our constructor which diff --git a/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.py b/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.py index a0e22bd90..ca6b11824 100644 --- a/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.py +++ b/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.py @@ -32,6 +32,7 @@ from .engine import expand from .engine import getServiceDefinition from .engine import getServiceDefinitions +from .engine import getTask from .engine import getVersion from .engine import isAppMonitor from .engine import isLevelEnabled @@ -108,6 +109,7 @@ 'getObject', 'getServiceDefinition', 'getServiceDefinitions', + 'getTask', 'getVersion', 'IControl', 'IDict', diff --git a/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.pyi b/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.pyi index c9ea1aac4..02e8f932a 100644 --- a/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.pyi +++ b/packages/server/engine-lib/rocketlib-python/lib/rocketlib/__init__.pyi @@ -838,6 +838,16 @@ def processArguments(args: List[str]) -> None: """Process engine command-line arguments.""" ... +def getTask() -> Optional[Dict[str, Any]]: + """Return the complete task-file JSON of the task currently executing + in this process, or None when no task is running. + + Trusted point-to-point task data (identity, storage anchor) for + subprocess python — published by the engine around the task's + begin/end window, never sourced from the environment. + """ + ... + def outputEndpointParameters(endpoint: IEndpointBase) -> None: """Log all configuration parameters of the given endpoint (debug helper).""" ... diff --git a/packages/server/engine-lib/rocketlib-python/lib/rocketlib/engine.py b/packages/server/engine-lib/rocketlib-python/lib/rocketlib/engine.py index 9608efa3f..bcbd914c9 100644 --- a/packages/server/engine-lib/rocketlib-python/lib/rocketlib/engine.py +++ b/packages/server/engine-lib/rocketlib-python/lib/rocketlib/engine.py @@ -31,7 +31,7 @@ import re import sys import traceback -from typing import Dict, List +from typing import Dict, List, Optional from urllib.parse import urlparse, unquote from .filters import IEndpointBase @@ -321,6 +321,19 @@ def processArguments(args: List[str]): processArguments = engLib.processArguments # noqa +def getTask() -> 'Optional[dict]': + """ + Return the complete task-file JSON of the task currently executing in + this process, or None when no task is running. Trusted point-to-point + task data (identity, storage anchor) — published by the engine around + the task's begin/end window, never sourced from the environment. + """ + ... + + +getTask = engLib.getTask # noqa + + # ----------------------------------------------------------------------------- # Additional functionality # ----------------------------------------------------------------------------- diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 742866330..a9a548252 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -72,7 +72,7 @@ "@rsbuild/plugin-sass": "~1.5.3", "@rsbuild/plugin-svgr": "^2.0.3", "@rslib/core": "~0.22.0", - "@types/react": "~18.3.29", + "@types/react": "~18.3.31", "@types/react-dom": "~18.3.7", "@types/react-syntax-highlighter": "^15.5.13", "@types/tabulator-tables": "~6.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c81a4a6c7..b0deabb1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,11 +73,11 @@ importers: specifier: ^20.19.41 version: 20.19.41 '@types/react': - specifier: 18.2.79 - version: 18.2.79 + specifier: ~18.3.31 + version: 18.3.31 '@types/react-dom': - specifier: 18.2.25 - version: 18.2.25 + specifier: ~18.3.7 + version: 18.3.7(@types/react@18.3.31) adm-zip: specifier: ^0.5.17 version: 0.5.17 @@ -189,7 +189,7 @@ importers: version: 18.3.1(react@18.3.1) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@18.2.79)(react@18.3.1) + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) react-syntax-highlighter: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) @@ -231,11 +231,11 @@ importers: specifier: ^0.5.16 version: 0.5.19(tailwindcss@3.4.19(tsx@4.23.1)) '@types/react': - specifier: ^18.2.43 - version: 18.2.79 + specifier: ~18.3.31 + version: 18.3.31 '@types/react-dom': - specifier: ^18.2.17 - version: 18.2.25 + specifier: ~18.3.7 + version: 18.3.7(@types/react@18.3.31) '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 @@ -262,7 +262,7 @@ importers: dependencies: '@textea/json-viewer': specifier: ^4.0.1 - version: 4.0.1(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.2.79)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.1(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -277,7 +277,7 @@ importers: version: 18.3.1(react@18.3.1) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@18.2.79)(react@18.3.1) + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) react-syntax-highlighter: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) @@ -319,11 +319,11 @@ importers: specifier: ^0.5.16 version: 0.5.19(tailwindcss@3.4.19(tsx@4.23.1)) '@types/react': - specifier: ^18.2.43 - version: 18.2.79 + specifier: ~18.3.31 + version: 18.3.31 '@types/react-dom': - specifier: ^18.2.17 - version: 18.2.25 + specifier: ~18.3.7 + version: 18.3.7(@types/react@18.3.31) '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 @@ -562,11 +562,11 @@ importers: specifier: ~1.3.5 version: 1.3.5(@rsbuild/core@2.0.11(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.48.0))(@rspack/core@2.0.6(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(tslib@2.8.1)(typescript@5.9.3) '@types/react': - specifier: ~18.2.21 - version: 18.2.79 + specifier: ~18.3.31 + version: 18.3.31 '@types/react-dom': - specifier: ~18.2.7 - version: 18.2.25 + specifier: ~18.3.7 + version: 18.3.7(@types/react@18.3.31) dts-bundle-generator: specifier: ^9.5.1 version: 9.5.1 @@ -657,11 +657,11 @@ importers: specifier: ^4.1.4 version: 4.1.4 '@types/react': - specifier: ^18.0.0 - version: 18.2.79 + specifier: ^18.3.31 + version: 18.3.31 '@types/react-dom': - specifier: ^18.0.0 - version: 18.2.25 + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.31) '@types/vscode': specifier: ~1.99.0 version: 1.99.1 @@ -769,19 +769,19 @@ importers: dependencies: '@docusaurus/core': specifier: 3.8.1 - version: 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + version: 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/plugin-client-redirects': specifier: 3.8.1 - version: 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + version: 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/preset-classic': specifier: 3.8.1 - version: 3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(@types/react@18.2.79)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3) + version: 3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(@types/react@18.3.31)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3) '@easyops-cn/docusaurus-search-local': specifier: ^0.55.1 - version: 0.55.2(@docusaurus/theme-common@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + version: 0.55.2(@docusaurus/theme-common@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@mdx-js/react': specifier: ^3.0.0 - version: 3.1.1(@types/react@18.2.79)(react@19.2.7) + version: 3.1.1(@types/react@18.3.31)(react@19.2.7) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -803,10 +803,10 @@ importers: version: 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/plugin-content-docs': specifier: 3.8.1 - version: 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + version: 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/theme-common': specifier: 3.8.1 - version: 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/tsconfig': specifier: 3.8.1 version: 3.8.1 @@ -836,10 +836,10 @@ importers: version: 3.2.2(react@18.3.1) '@emotion/react': specifier: ~11.13.0 - version: 11.13.5(@types/react@18.3.29)(react@18.3.1) + version: 11.13.5(@types/react@18.3.31)(react@18.3.1) '@emotion/styled': specifier: ~11.13.0 - version: 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + version: 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) '@fontsource/quicksand': specifier: ~5.0.18 version: 5.0.20 @@ -854,25 +854,25 @@ importers: version: 5.0.22 '@mui/icons-material': specifier: ~6.1.5 - version: 6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + version: 6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) '@mui/material': specifier: ~6.1.5 - version: 6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/styles': specifier: ~5.16.7 - version: 5.16.14(@types/react@18.3.29)(react@18.3.1) + version: 5.16.14(@types/react@18.3.31)(react@18.3.1) '@mui/x-data-grid': specifier: ~7.19.0 - version: 7.19.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.19.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/x-date-pickers': specifier: ~7.18.0 - version: 7.18.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(date-fns@2.30.0)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.18.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(date-fns@2.30.0)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@rjsf/core': specifier: ~5.20.1 version: 5.20.1(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1) '@rjsf/mui': specifier: ~5.20.1 - version: 5.20.1(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/icons-material@6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@rjsf/core@5.20.1(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1))(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1) + version: 5.20.1(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/icons-material@6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@rjsf/core@5.20.1(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1))(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1) '@rjsf/utils': specifier: ~5.20.1 version: 5.20.1(react@18.3.1) @@ -887,7 +887,7 @@ importers: version: 5.10.0 '@xyflow/react': specifier: ~12.3.4 - version: 12.3.6(@types/react@18.3.29)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 12.3.6(@types/react@18.3.31)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) chart.js: specifier: ^4.4.0 version: 4.5.1 @@ -911,7 +911,7 @@ importers: version: 3.1.0 html-react-parser: specifier: ~5.2.3 - version: 5.2.15(@types/react@18.3.29)(react@18.3.1) + version: 5.2.15(@types/react@18.3.31)(react@18.3.1) i18next: specifier: ~23.11.3 version: 23.11.5 @@ -956,13 +956,13 @@ importers: version: 14.1.3(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-joyride: specifier: ~2.9.3 - version: 2.9.3(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.9.3(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-json-view: specifier: ~1.21.3 - version: 1.21.3(@types/react@18.3.29)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.21.3(@types/react@18.3.31)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@18.3.29)(react@18.3.1) + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) react-router-dom: specifier: ~6.26.1 version: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1022,11 +1022,11 @@ importers: specifier: ~0.22.0 version: 0.22.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.48.0)(typescript@5.9.3) '@types/react': - specifier: ~18.3.29 - version: 18.3.29 + specifier: ~18.3.31 + version: 18.3.31 '@types/react-dom': specifier: ~18.3.7 - version: 18.3.7(@types/react@18.3.29) + version: 18.3.7(@types/react@18.3.31) '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 @@ -4795,9 +4795,6 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.2.25': - resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -4820,11 +4817,8 @@ packages: peerDependencies: '@types/react': '*' - '@types/react@18.2.79': - resolution: {integrity: sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==} - - '@types/react@18.3.29': - resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -13702,14 +13696,14 @@ snapshots: '@docsearch/css@3.9.0': {} - '@docsearch/react@3.9.0(@algolia/client-search@5.53.0)(@types/react@18.2.79)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)': + '@docsearch/react@3.9.0(@algolia/client-search@5.53.0)(@types/react@18.3.31)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)': dependencies: '@algolia/autocomplete-core': 1.17.9(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)(search-insights@2.17.3) '@algolia/autocomplete-preset-algolia': 1.17.9(@algolia/client-search@5.53.0)(algoliasearch@5.53.0) '@docsearch/css': 3.9.0 algoliasearch: 5.53.0 optionalDependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) search-insights: 2.17.3 @@ -13871,7 +13865,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: '@docusaurus/babel': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/bundler': 3.10.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) @@ -13880,7 +13874,7 @@ snapshots: '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mdx-js/react': 3.1.1(@types/react@18.2.79)(react@19.2.7) + '@mdx-js/react': 3.1.1(@types/react@18.3.31)(react@19.2.7) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -13940,7 +13934,7 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/core@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/core@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: '@docusaurus/babel': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/bundler': 3.8.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) @@ -13949,7 +13943,7 @@ snapshots: '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mdx-js/react': 3.1.1(@types/react@18.2.79)(react@19.2.7) + '@mdx-js/react': 3.1.1(@types/react@18.3.31)(react@19.2.7) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -14126,7 +14120,7 @@ snapshots: dependencies: '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 19.2.7 @@ -14153,7 +14147,7 @@ snapshots: dependencies: '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 19.2.7 @@ -14176,9 +14170,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-client-redirects@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-client-redirects@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.8.1 '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14213,13 +14207,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-blog@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-content-blog@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.8.1 '@docusaurus/mdx-loader': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14260,13 +14254,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.10.1 '@docusaurus/mdx-loader': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/module-type-aliases': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14306,13 +14300,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.8.1 '@docusaurus/mdx-loader': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/module-type-aliases': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14352,9 +14346,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-content-pages@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/mdx-loader': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14388,9 +14382,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-css-cascade-layers@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14421,9 +14415,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-debug@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) fs-extra: 11.3.5 @@ -14455,9 +14449,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-google-analytics@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 @@ -14487,9 +14481,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-google-gtag@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/gtag.js': 0.0.12 @@ -14520,9 +14514,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-google-tag-manager@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 @@ -14552,9 +14546,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-sitemap@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.8.1 '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14589,9 +14583,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/plugin-svgr@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14625,22 +14619,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(@types/react@18.2.79)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3)': - dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-content-blog': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-content-pages': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-css-cascade-layers': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-debug': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-google-analytics': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-google-gtag': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-google-tag-manager': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-sitemap': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-svgr': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/theme-classic': 3.8.1(@types/react@18.2.79)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/theme-search-algolia': 3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(@types/react@18.2.79)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3) + '@docusaurus/preset-classic@3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(@types/react@18.3.31)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3)': + dependencies: + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-blog': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-pages': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-css-cascade-layers': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-debug': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-google-analytics': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-google-gtag': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-google-tag-manager': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-sitemap': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-svgr': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/theme-classic': 3.8.1(@types/react@18.3.31)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/theme-search-algolia': 3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(@types/react@18.3.31)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3) '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -14673,25 +14667,25 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@19.2.7)': dependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 react: 19.2.7 - '@docusaurus/theme-classic@3.8.1(@types/react@18.2.79)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@docusaurus/theme-classic@3.8.1(@types/react@18.3.31)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.8.1 '@docusaurus/mdx-loader': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/module-type-aliases': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/plugin-content-blog': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/plugin-content-pages': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/plugin-content-blog': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-pages': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/theme-translations': 3.8.1 '@docusaurus/types': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mdx-js/react': 3.1.1(@types/react@18.2.79)(react@19.2.7) + '@mdx-js/react': 3.1.1(@types/react@18.3.31)(react@19.2.7) clsx: 2.1.1 copy-text-to-clipboard: 3.2.2 infima: 0.2.0-alpha.45 @@ -14729,15 +14723,15 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@docusaurus/mdx-loader': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/module-type-aliases': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -14762,15 +14756,15 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-common@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@docusaurus/theme-common@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@docusaurus/mdx-loader': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/module-type-aliases': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -14795,13 +14789,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(@types/react@18.2.79)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3)': + '@docusaurus/theme-search-algolia@3.8.1(@algolia/client-search@5.53.0)(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(@types/react@18.3.31)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@5.6.3)': dependencies: - '@docsearch/react': 3.9.0(@algolia/client-search@5.53.0)(@types/react@18.2.79)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3) - '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docsearch/react': 3.9.0(@algolia/client-search@5.53.0)(@types/react@18.3.31)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3) + '@docusaurus/core': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) '@docusaurus/logger': 3.8.1 - '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/plugin-content-docs': 3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/theme-translations': 3.8.1 '@docusaurus/utils': 3.8.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-validation': 3.8.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -14859,7 +14853,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 18.2.79 + '@types/react': 18.3.31 commander: 5.1.0 joi: 18.2.1 react: 19.2.7 @@ -14888,7 +14882,7 @@ snapshots: dependencies: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 commander: 5.1.0 joi: 18.2.1 react: 19.2.7 @@ -15100,10 +15094,10 @@ snapshots: cssesc: 3.0.0 immediate: 3.3.0 - '@easyops-cn/docusaurus-search-local@0.55.2(@docusaurus/theme-common@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': + '@easyops-cn/docusaurus-search-local@0.55.2(@docusaurus/theme-common@3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3)': dependencies: - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) - '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(debug@4.4.3)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3) + '@docusaurus/theme-common': 3.8.1(@docusaurus/plugin-content-docs@3.8.1(@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.6.3))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/theme-translations': 3.10.1 '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -15193,7 +15187,7 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1)': + '@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 '@emotion/babel-plugin': 11.13.5 @@ -15205,23 +15199,7 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 18.3.1 optionalDependencies: - '@types/react': 18.2.79 - transitivePeerDependencies: - - supports-color - - '@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.28.6 - '@emotion/babel-plugin': 11.13.5 - '@emotion/cache': 11.14.0 - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - hoist-non-react-statics: 3.3.2 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 transitivePeerDependencies: - supports-color @@ -15235,33 +15213,18 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1)': + '@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.4.0 - '@emotion/react': 11.13.5(@types/react@18.2.79)(react@18.3.1) + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) '@emotion/serialize': 1.3.3 '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) '@emotion/utils': 1.4.2 react: 18.3.1 optionalDependencies: - '@types/react': 18.2.79 - transitivePeerDependencies: - - supports-color - - '@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.28.6 - '@emotion/babel-plugin': 11.13.5 - '@emotion/is-prop-valid': 1.4.0 - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) - '@emotion/utils': 1.4.2 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 transitivePeerDependencies: - supports-color @@ -15979,10 +15942,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@18.2.79)(react@19.2.7)': + '@mdx-js/react@3.1.1(@types/react@18.3.31)(react@19.2.7)': dependencies: '@types/mdx': 2.0.14 - '@types/react': 18.2.79 + '@types/react': 18.3.31 react: 19.2.7 '@module-federation/bridge-react-webpack-plugin@2.5.1(node-fetch@2.7.0(encoding@0.1.13))': @@ -16177,44 +16140,23 @@ snapshots: '@mui/core-downloads-tracker@6.5.0': {} - '@mui/icons-material@6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.28.6 - '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/icons-material@6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 - '@mui/core-downloads-tracker': 6.5.0 - '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1) - '@mui/types': 7.4.11(@types/react@18.2.79) - '@mui/utils': 6.4.9(@types/react@18.2.79)(react@18.3.1) - '@popperjs/core': 2.11.8 - '@types/react-transition-group': 4.4.12(@types/react@18.2.79) - clsx: 2.1.1 - csstype: 3.2.3 - prop-types: 15.8.1 + '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 - react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.2.79)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1) - '@types/react': 18.2.79 + '@types/react': 18.3.31 - '@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 '@mui/core-downloads-tracker': 6.5.0 - '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@mui/types': 7.4.11(@types/react@18.3.29) - '@mui/utils': 6.4.9(@types/react@18.3.29)(react@18.3.1) + '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/types': 7.4.11(@types/react@18.3.31) + '@mui/utils': 6.4.9(@types/react@18.3.31)(react@18.3.1) '@popperjs/core': 2.11.8 - '@types/react-transition-group': 4.4.12(@types/react@18.3.29) + '@types/react-transition-group': 4.4.12(@types/react@18.3.31) clsx: 2.1.1 csstype: 3.2.3 prop-types: 15.8.1 @@ -16223,51 +16165,29 @@ snapshots: react-is: 18.3.1 react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@types/react': 18.3.29 - - '@mui/private-theming@5.17.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@mui/utils': 5.17.1(@types/react@18.3.29)(react@18.3.1) - prop-types: 15.8.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@mui/private-theming@6.4.9(@types/react@18.2.79)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@mui/utils': 6.4.9(@types/react@18.2.79)(react@18.3.1) - prop-types: 15.8.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.79 + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 - '@mui/private-theming@6.4.9(@types/react@18.3.29)(react@18.3.1)': + '@mui/private-theming@5.17.1(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@mui/utils': 6.4.9(@types/react@18.3.29)(react@18.3.1) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@mui/styled-engine@6.5.0(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1)': + '@mui/private-theming@6.4.9(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@emotion/cache': 11.14.0 - '@emotion/serialize': 1.3.3 - '@emotion/sheet': 1.4.0 - csstype: 3.2.3 + '@mui/utils': 6.4.9(@types/react@18.3.31)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.2.79)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1) + '@types/react': 18.3.31 - '@mui/styled-engine@6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(react@18.3.1)': + '@mui/styled-engine@6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 '@emotion/cache': 11.14.0 @@ -16277,16 +16197,16 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) - '@mui/styles@5.16.14(@types/react@18.3.29)(react@18.3.1)': + '@mui/styles@5.16.14(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 '@emotion/hash': 0.9.2 - '@mui/private-theming': 5.17.1(@types/react@18.3.29)(react@18.3.1) - '@mui/types': 7.4.10(@types/react@18.3.29) - '@mui/utils': 5.17.1(@types/react@18.3.29)(react@18.3.1) + '@mui/private-theming': 5.17.1(@types/react@18.3.31)(react@18.3.1) + '@mui/types': 7.4.10(@types/react@18.3.31) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) clsx: 2.1.1 csstype: 3.2.3 hoist-non-react-statics: 3.3.2 @@ -16301,146 +16221,108 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1)': + '@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@mui/private-theming': 6.4.9(@types/react@18.2.79)(react@18.3.1) - '@mui/styled-engine': 6.5.0(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1) - '@mui/types': 7.2.24(@types/react@18.2.79) - '@mui/utils': 6.4.9(@types/react@18.2.79)(react@18.3.1) + '@mui/private-theming': 6.4.9(@types/react@18.3.31)(react@18.3.1) + '@mui/styled-engine': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + '@mui/types': 7.2.24(@types/react@18.3.31) + '@mui/utils': 6.4.9(@types/react@18.3.31)(react@18.3.1) clsx: 2.1.1 csstype: 3.2.3 prop-types: 15.8.1 react: 18.3.1 optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.2.79)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1) - '@types/react': 18.2.79 + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 - '@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@mui/private-theming': 6.4.9(@types/react@18.3.29)(react@18.3.1) - '@mui/styled-engine': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(react@18.3.1) - '@mui/types': 7.2.24(@types/react@18.3.29) - '@mui/utils': 6.4.9(@types/react@18.3.29)(react@18.3.1) - clsx: 2.1.1 - csstype: 3.2.3 - prop-types: 15.8.1 - react: 18.3.1 - optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@types/react': 18.3.29 - - '@mui/types@7.2.24(@types/react@18.2.79)': + '@mui/types@7.2.24(@types/react@18.3.31)': optionalDependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 - '@mui/types@7.2.24(@types/react@18.3.29)': - optionalDependencies: - '@types/react': 18.3.29 - - '@mui/types@7.4.10(@types/react@18.3.29)': + '@mui/types@7.4.10(@types/react@18.3.31)': dependencies: '@babel/runtime': 7.29.7 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@mui/types@7.4.11(@types/react@18.2.79)': + '@mui/types@7.4.11(@types/react@18.3.31)': dependencies: '@babel/runtime': 7.29.7 optionalDependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 - '@mui/types@7.4.11(@types/react@18.3.29)': + '@mui/utils@5.17.1(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - optionalDependencies: - '@types/react': 18.3.29 - - '@mui/utils@5.17.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@mui/types': 7.2.24(@types/react@18.3.29) - '@types/prop-types': 15.7.15 - clsx: 2.1.1 - prop-types: 15.8.1 - react: 18.3.1 - react-is: 19.2.4 - optionalDependencies: - '@types/react': 18.3.29 - - '@mui/utils@6.4.9(@types/react@18.2.79)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@mui/types': 7.2.24(@types/react@18.2.79) + '@mui/types': 7.2.24(@types/react@18.3.31) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 react-is: 19.2.4 optionalDependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 - '@mui/utils@6.4.9(@types/react@18.3.29)(react@18.3.1)': + '@mui/utils@6.4.9(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@mui/types': 7.2.24(@types/react@18.3.29) + '@mui/types': 7.2.24(@types/react@18.3.31) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 react-is: 19.2.4 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@mui/x-data-grid@7.19.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/x-data-grid@7.19.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 - '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@mui/utils': 5.17.1(@types/react@18.3.29)(react@18.3.1) - '@mui/x-internals': 7.18.0(@types/react@18.3.29)(react@18.3.1) + '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) + '@mui/x-internals': 7.18.0(@types/react@18.3.31)(react@18.3.1) clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) reselect: 5.1.1 optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) transitivePeerDependencies: - '@types/react' - '@mui/x-date-pickers@7.18.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(date-fns@2.30.0)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/x-date-pickers@7.18.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(date-fns@2.30.0)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 - '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@mui/utils': 5.17.1(@types/react@18.3.29)(react@18.3.1) - '@mui/x-internals': 7.18.0(@types/react@18.3.29)(react@18.3.1) - '@types/react-transition-group': 4.4.12(@types/react@18.3.29) + '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/system': 6.5.0(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) + '@mui/x-internals': 7.18.0(@types/react@18.3.31)(react@18.3.1) + '@types/react-transition-group': 4.4.12(@types/react@18.3.31) clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) date-fns: 2.30.0 luxon: 3.7.2 moment: 2.30.1 transitivePeerDependencies: - '@types/react' - '@mui/x-internals@7.18.0(@types/react@18.3.29)(react@18.3.1)': + '@mui/x-internals@7.18.0(@types/react@18.3.31)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@mui/utils': 5.17.1(@types/react@18.3.29)(react@18.3.1) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 transitivePeerDependencies: - '@types/react' @@ -16803,12 +16685,12 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 - '@rjsf/mui@5.20.1(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/icons-material@6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@rjsf/core@5.20.1(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1))(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1)': + '@rjsf/mui@5.20.1(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/icons-material@6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@rjsf/core@5.20.1(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1))(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1)': dependencies: - '@emotion/react': 11.13.5(@types/react@18.3.29)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@mui/icons-material': 6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) - '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/icons-material': 6.1.10(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@rjsf/core': 5.20.1(@rjsf/utils@5.20.1(react@18.3.1))(react@18.3.1) '@rjsf/utils': 5.20.1(react@18.3.1) react: 18.3.1 @@ -17368,16 +17250,16 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19(tsx@4.23.1) - '@textea/json-viewer@4.0.1(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.2.79)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@textea/json-viewer@4.0.1(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@mui/material@6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@emotion/react': 11.13.5(@types/react@18.2.79)(react@18.3.1) - '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1) - '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react@18.3.1))(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@emotion/react': 11.13.5(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/material': 6.1.10(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 copy-to-clipboard: 3.3.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.7(@types/react@18.2.79)(immer@9.0.21)(react@18.3.1) + zustand: 4.5.7(@types/react@18.3.31)(immer@9.0.21)(react@18.3.1) transitivePeerDependencies: - '@types/react' - immer @@ -17738,49 +17620,36 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@18.2.25': - dependencies: - '@types/react': 18.2.79 - - '@types/react-dom@18.3.7(@types/react@18.3.29)': + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-router': 5.1.20 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 18.2.79 + '@types/react': 18.3.31 '@types/react-syntax-highlighter@15.5.13': dependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 - '@types/react-transition-group@4.4.12(@types/react@18.2.79)': + '@types/react-transition-group@4.4.12(@types/react@18.3.31)': dependencies: - '@types/react': 18.2.79 + '@types/react': 18.3.31 - '@types/react-transition-group@4.4.12(@types/react@18.3.29)': - dependencies: - '@types/react': 18.3.29 - - '@types/react@18.2.79': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - - '@types/react@18.3.29': + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 csstype: 3.2.3 @@ -18201,13 +18070,13 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.3.6(@types/react@18.3.29)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@xyflow/react@12.3.6(@types/react@18.3.31)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@xyflow/system': 0.0.47 classcat: 5.0.5 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.7(@types/react@18.3.29)(immer@9.0.21)(react@18.3.1) + zustand: 4.5.7(@types/react@18.3.31)(immer@9.0.21)(react@18.3.1) transitivePeerDependencies: - '@types/react' - immer @@ -20878,7 +20747,7 @@ snapshots: dependencies: void-elements: 3.1.0 - html-react-parser@5.2.15(@types/react@18.3.29)(react@18.3.1): + html-react-parser@5.2.15(@types/react@18.3.31)(react@18.3.1): dependencies: domhandler: 5.0.3 html-dom-parser: 5.1.4 @@ -20886,7 +20755,7 @@ snapshots: react-property: 2.0.2 style-to-js: 1.1.21 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 html-tags@3.3.1: {} @@ -24290,9 +24159,9 @@ snapshots: dependencies: react: 19.2.7 - react-innertext@1.1.5(@types/react@18.3.29)(react@18.3.1): + react-innertext@1.1.5(@types/react@18.3.31)(react@18.3.1): dependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 react: 18.3.1 react-is@16.13.1: {} @@ -24303,7 +24172,7 @@ snapshots: react-is@19.2.6: {} - react-joyride@2.9.3(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-joyride@2.9.3(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@gilbarbara/deep-equal': 0.3.1 deep-diff: 1.0.2 @@ -24312,7 +24181,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-floater: 0.7.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-innertext: 1.1.5(@types/react@18.3.29)(react@18.3.1) + react-innertext: 1.1.5(@types/react@18.3.31)(react@18.3.1) react-is: 16.13.1 scroll: 3.0.1 scrollparent: 2.1.0 @@ -24325,14 +24194,14 @@ snapshots: dependencies: react: 19.2.7 - react-json-view@1.21.3(@types/react@18.3.29)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-json-view@1.21.3(@types/react@18.3.31)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: flux: 4.0.4(encoding@0.1.13)(react@18.3.1) react: 18.3.1 react-base16-styling: 0.6.0 react-dom: 18.3.1(react@18.3.1) react-lifecycles-compat: 3.0.4 - react-textarea-autosize: 8.5.9(@types/react@18.3.29)(react@18.3.1) + react-textarea-autosize: 8.5.9(@types/react@18.3.31)(react@18.3.1) transitivePeerDependencies: - '@types/react' - encoding @@ -24345,29 +24214,11 @@ snapshots: react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.7)' webpack: 5.104.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15) - react-markdown@10.1.0(@types/react@18.2.79)(react@18.3.1): - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/react': 18.2.79 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.1 - react: 18.3.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - react-markdown@10.1.0(@types/react@18.3.29)(react@18.3.1): + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 18.3.29 + '@types/react': 18.3.31 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 @@ -24445,12 +24296,12 @@ snapshots: react: 18.3.1 refractor: 3.6.0 - react-textarea-autosize@8.5.9(@types/react@18.3.29)(react@18.3.1): + react-textarea-autosize@8.5.9(@types/react@18.3.31)(react@18.3.1): dependencies: '@babel/runtime': 7.29.7 react: 18.3.1 - use-composed-ref: 1.4.0(@types/react@18.3.29)(react@18.3.1) - use-latest: 1.3.0(@types/react@18.3.29)(react@18.3.1) + use-composed-ref: 1.4.0(@types/react@18.3.31)(react@18.3.1) + use-latest: 1.3.0(@types/react@18.3.31)(react@18.3.1) transitivePeerDependencies: - '@types/react' @@ -26161,24 +26012,24 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@5.104.1(postcss@8.5.15)) - use-composed-ref@1.4.0(@types/react@18.3.29)(react@18.3.1): + use-composed-ref@1.4.0(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - use-isomorphic-layout-effect@1.2.1(@types/react@18.3.29)(react@18.3.1): + use-isomorphic-layout-effect@1.2.1(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - use-latest@1.3.0(@types/react@18.3.29)(react@18.3.1): + use-latest@1.3.0(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 - use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.29)(react@18.3.1) + use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.31)(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 use-sync-external-store@1.6.0(react@18.3.1): dependencies: @@ -26684,19 +26535,11 @@ snapshots: yoctocolors@2.1.2: {} - zustand@4.5.7(@types/react@18.2.79)(immer@9.0.21)(react@18.3.1): - dependencies: - use-sync-external-store: 1.6.0(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.79 - immer: 9.0.21 - react: 18.3.1 - - zustand@4.5.7(@types/react@18.3.29)(immer@9.0.21)(react@18.3.1): + zustand@4.5.7(@types/react@18.3.31)(immer@9.0.21)(react@18.3.1): dependencies: use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 immer: 9.0.21 react: 18.3.1