From 9d2900db027873ca94b7c2bd9cb5cb57ef0cbc19 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 26 Apr 2026 09:22:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E4=B8=8E=E5=90=AF=E5=8A=A8=E9=93=BE=E8=B7=AF=E7=9A=84=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=E6=95=B4=E6=94=B9?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E4=BB=A5=E4=BB=93=E5=BA=93=E6=A0=B9?= =?UTF-8?q?=E4=B8=BA=E8=B7=AF=E5=BE=84=E5=9F=BA=E5=87=86=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20oclaw=20=E5=8F=8C=E5=B1=82=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同时补齐路径回归测试与开源自检文档,确保从非仓库 cwd 启动时 workspace 根、配置和 wiki 路径仍稳定可用。 Made-with: Cursor --- docs/OCLAW_MIGRATION_GUIDE.md | 13 ++++++++-- docs/RUNBOOK.md | 17 +++++++++++-- interfaces/admin/routes.py | 6 ++--- interfaces/gateway/server_methods/config.py | 2 +- interfaces/http/fastapi_app.py | 6 ++--- platform/config/paths.py | 9 +++---- platform/config/runtime_paths.py | 2 +- prompts/loader.py | 2 +- runtime/agent_context/loader.py | 4 ++-- runtime/extensions/memory-wiki/api.py | 6 ++--- runtime/gateway.py | 2 +- .../hooks/bundled/wiki-auto-inject/handler.py | 4 ++-- runtime/hooks/user_config_hooks.py | 4 ++-- runtime/hooks_runtime.py | 4 ++-- runtime/memory_stage.py | 2 +- runtime/operations/hooks_cmd.py | 6 ++++- runtime/operations/mcp_env.py | 5 ++-- runtime/operations/mcp_registry_export.py | 2 +- runtime/operations/memory.py | 6 ++--- runtime/operations/scripts/start_all.ps1 | 7 +++++- runtime/operations/scripts/start_desktop.ps1 | 4 ++++ runtime/operations/scripts/start_gateway.ps1 | 10 +++++--- runtime/operations/scripts/start_ops.ps1 | 8 +++++-- .../operations/scripts/start_wiki_worker.ps1 | 20 ++++++++++++++-- runtime/operations/scripts/status_ops.ps1 | 8 +++++-- runtime/operations/scripts/stop_ops.ps1 | 8 +++++-- .../scripts/wiki_auto_smoke_test.py | 4 ++-- runtime/project_context_prompt.py | 2 +- runtime/skills.py | 7 ++++-- runtime/tools/experts/memory/wiki_tools.py | 4 ++-- runtime/tools/experts/workspace/fs_tools.py | 22 ++++++++++++++++- runtime/tools/workspace_indexer.py | 2 +- runtime/workers/wiki/main.py | 12 +++++++--- runtime/workspaces/experts.py | 2 +- tests/test_oclaw_operations_hooks_cmd.py | 21 ++++++++++++++++ ...test_oclaw_startup_workspace_resolution.py | 7 ++++++ tests/test_prompt_ci_guard.py | 24 +++++++++---------- tests/test_prompt_hardcode_guard.py | 6 ++--- tests/test_workspace_path_guard.py | 17 ++++++++++++- tests/test_workspaces_prompt_contract.py | 8 +++---- 40 files changed, 223 insertions(+), 82 deletions(-) diff --git a/docs/OCLAW_MIGRATION_GUIDE.md b/docs/OCLAW_MIGRATION_GUIDE.md index 86fec679..9d776d48 100644 --- a/docs/OCLAW_MIGRATION_GUIDE.md +++ b/docs/OCLAW_MIGRATION_GUIDE.md @@ -1,12 +1,21 @@ # Oclaw Migration Guide -This repository is migrating from legacy `oclaw/` runtime wiring to the new `oclaw/` architecture root. +This repository now uses **repo-root relative** runtime wiring. ## Current status -- `oclaw/` is the target root for new code. +- The repository root is the only runtime base path. - `oclaw/app_server/*` compatibility modules have been removed. - Gateway dispatch now routes through shared `server_methods` handlers for both WS and HTTP method endpoints. +## Path baseline (important for open-source portability) +- Do not hardcode `PROJECT_ROOT / "oclaw" / ...`. +- Use repo-root relative paths such as: + - config: `oclaw.json` + - data: `data/ai_ops.sqlite` + - workspaces: `runtime/workspaces/*` + - wiki root default: `docs/memory-system/wiki` +- Startup scripts should run from repo root and explicitly set workspace env vars; avoid relying on parent-directory cwd. + ## Developer rules - Add new business logic under `oclaw/` (interfaces/application/domain/infrastructure/shared). - Avoid adding new core logic into legacy `oclaw/` modules. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 87663f12..115ae92a 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -15,6 +15,19 @@ 补充:工具脚本也统一放在 `scripts/`(例如 `seed_mcp_registry.py`、`ws_probe.py`)。 +路径基准约定(开源必读): + +- 运行目录固定为仓库根(`.../oclaw`),不要把网关/worker 常驻进程放到上一级目录。 +- 默认配置文件路径为仓库根下的 `oclaw.json`(可用 `OCLAW_CONFIG_PATH` 覆盖)。 +- 默认数据目录为仓库根下的 `data/`(主库:`data/ai_ops.sqlite`)。 +- 相对路径一律按仓库根解析;不要在代码里拼接 `oclaw/...` 二级前缀。 + +开源前路径自检清单: + +- 把仓库目录临时重命名后,`scripts/start_gateway.ps1 -SkipInstall -Background` 仍能启动。 +- `http://127.0.0.1:8787/chat` 返回 200,且后台日志中不出现 `.../oclaw/oclaw/...` 路径。 +- `python -m pytest tests/test_workspace_path_guard.py tests/test_oclaw_startup_workspace_resolution.py -q` 通过。 + --- ## 2. 首次初始化 @@ -362,7 +375,7 @@ AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto ## 13. memory-wiki 插件启用与排障 -### 13.1 启用配置(oclaw/oclaw.json) +### 13.1 启用配置(oclaw.json) 将 `memory-wiki` 放入启用列表,并建议把 memory slot 指向它: @@ -375,7 +388,7 @@ AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto }, "entries": { "memory-wiki": { - "wiki_root": "oclaw/docs/memory-system/wiki", + "wiki_root": "docs/memory-system/wiki", "max_search_results": 20, "max_get_lines": 800, "auto": { diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 29e7d3c6..b918a532 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -811,7 +811,7 @@ def build_admin_router() -> APIRouter: def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(SqliteStore(db_path()), authorization) _require_permission(ctx, "admin:read") - root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() + root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() allowed_prefixes = ("history_entries_", "state_scan_") items: list[dict[str, Any]] = [] if root.exists() and root.is_dir(): @@ -836,7 +836,7 @@ def build_admin_router() -> APIRouter: def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(SqliteStore(db_path()), authorization) _require_permission(ctx, "admin:runtime:write") - root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() + root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() allowed_prefixes = ("history_entries_", "state_scan_") removed = 0 if root.exists() and root.is_dir(): @@ -858,7 +858,7 @@ def build_admin_router() -> APIRouter: body = payload or {} ctx = _resolve_auth(SqliteStore(db_path()), authorization) _require_permission(ctx, "admin:runtime:write") - root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() + root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() try: keep_latest = int(body.get("keep_latest", 20)) except Exception: diff --git a/interfaces/gateway/server_methods/config.py b/interfaces/gateway/server_methods/config.py index 0a995bcc..219cd06f 100644 --- a/interfaces/gateway/server_methods/config.py +++ b/interfaces/gateway/server_methods/config.py @@ -152,7 +152,7 @@ def _config_open_file_handler(opts: dict[str, Any]) -> Any: return None if not assert_valid_params(params, _validate_config_get_params, "config.openFile", respond): return None - config_path = os.getenv("OCLAW_CONFIG_PATH") or "oclaw/oclaw.json" + config_path = os.getenv("OCLAW_CONFIG_PATH") or "oclaw.json" respond(True, {"ok": True, "path": config_path}, None, None) return None diff --git a/interfaces/http/fastapi_app.py b/interfaces/http/fastapi_app.py index 22c4f089..89cd1926 100644 --- a/interfaces/http/fastapi_app.py +++ b/interfaces/http/fastapi_app.py @@ -36,11 +36,11 @@ def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str: default_agent_id = resolve_default_agent_id(cfg) ws = resolve_agent_workspace_dir(cfg, default_agent_id) ws_text = str(ws or "").strip() - if ws_text: + if ws_text and ws_text not in {".", "./"}: return ws_text except Exception: pass - return str((PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve()) + return str((PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve()) def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]: @@ -78,7 +78,7 @@ def _relocate_root_scan_artifacts() -> None: Normalize them into runtime/data/scan on gateway startup. """ root = PROJECT_ROOT.resolve() - target_dir = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() + target_dir = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() target_dir.mkdir(parents=True, exist_ok=True) patterns = ("history_entries_*.json", "state_scan_*.json") for pat in patterns: diff --git a/platform/config/paths.py b/platform/config/paths.py index a6b3a534..f815e4e4 100644 --- a/platform/config/paths.py +++ b/platform/config/paths.py @@ -14,7 +14,8 @@ from pathlib import Path def _project_root() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent - return Path(__file__).resolve().parents[3] + # Source layout: /platform/config/paths.py + return Path(__file__).resolve().parents[2] PROJECT_ROOT = _project_root() @@ -22,7 +23,7 @@ _DATA_MIGRATION_DONE = False def _canonical_data_root() -> Path: - return (PROJECT_ROOT / "oclaw" / "data").resolve() + return (PROJECT_ROOT / "data").resolve() def _legacy_platform_data_root() -> Path: @@ -30,7 +31,7 @@ def _legacy_platform_data_root() -> Path: def _legacy_root_data_root() -> Path: - return (PROJECT_ROOT / "data").resolve() + return (PROJECT_ROOT.parent / "data").resolve() def _backup_keep_count() -> int: @@ -177,7 +178,7 @@ def _run_default_data_migration(canonical_db: Path) -> None: def db_path() -> str: global _DATA_MIGRATION_DONE - p = os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH") or "oclaw/data/ai_ops.sqlite" + p = os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH") or "data/ai_ops.sqlite" path = Path(p) if not path.is_absolute(): path = (PROJECT_ROOT / path).resolve() diff --git a/platform/config/runtime_paths.py b/platform/config/runtime_paths.py index a4870b00..ad839722 100644 --- a/platform/config/runtime_paths.py +++ b/platform/config/runtime_paths.py @@ -6,7 +6,7 @@ from oclaw.platform.config.paths import PROJECT_ROOT def oclaw_root() -> Path: - return (PROJECT_ROOT / "oclaw").resolve() + return Path(PROJECT_ROOT).resolve() def runtime_root() -> Path: diff --git a/prompts/loader.py b/prompts/loader.py index 95abfa0a..7aacecda 100644 --- a/prompts/loader.py +++ b/prompts/loader.py @@ -19,7 +19,7 @@ class PromptDoc: def _prompts_root() -> Path: - return (PROJECT_ROOT / "oclaw" / "prompts").resolve() + return (PROJECT_ROOT / "prompts").resolve() def _runtime_prompts_root() -> Path: diff --git a/runtime/agent_context/loader.py b/runtime/agent_context/loader.py index 83ca446c..31d599bf 100644 --- a/runtime/agent_context/loader.py +++ b/runtime/agent_context/loader.py @@ -30,7 +30,7 @@ def _workspace_for_role(role: str) -> str: def _role_workspace_signature(role_id: str) -> tuple[Any, ...]: - base = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() + base = (PROJECT_ROOT / "runtime" / "workspaces").resolve() role_root = (base / role_id).resolve() if not role_root.exists() or not role_root.is_dir(): return ("missing", role_id, str(base)) @@ -85,7 +85,7 @@ def build_role_system_context(role: str, template_vars: dict[str, Any] | None = cached = _ROLE_CONTEXT_CACHE.get(cache_key) if isinstance(cached, str) and cached.strip(): return cached - base = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() + base = (PROJECT_ROOT / "runtime" / "workspaces").resolve() roots: list[Path] = [] role_root = (base / role_id).resolve() if role_root.exists() and role_root.is_dir(): diff --git a/runtime/extensions/memory-wiki/api.py b/runtime/extensions/memory-wiki/api.py index 1ada6b36..01aec477 100644 --- a/runtime/extensions/memory-wiki/api.py +++ b/runtime/extensions/memory-wiki/api.py @@ -17,7 +17,7 @@ def build_plugin_config_schema(*_args, **_kwargs) -> dict[str, Any]: "wiki_root": { "type": "string", "description": "Wiki root directory relative to workspace root.", - "default": "oclaw/docs/memory-system/wiki", + "default": "docs/memory-system/wiki", }, "max_search_results": { "type": "integer", @@ -45,9 +45,9 @@ class WikiRuntime: def _resolve_runtime(api: Any) -> WikiRuntime: cfg = dict(getattr(api, "plugin_config", {}) or {}) - root_cfg = str(cfg.get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() + root_cfg = str(cfg.get("wiki_root") or "docs/memory-system/wiki").strip() if not root_cfg: - root_cfg = "oclaw/docs/memory-system/wiki" + root_cfg = "docs/memory-system/wiki" root = Path(root_cfg) if not root.is_absolute(): root = (_project_root() / root).resolve() diff --git a/runtime/gateway.py b/runtime/gateway.py index 85ed43d8..c8d4b9ed 100644 --- a/runtime/gateway.py +++ b/runtime/gateway.py @@ -434,7 +434,7 @@ class OclawGateway: @staticmethod def _tabular_limits_from_config() -> dict[str, int]: cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip() - cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json") + cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw.json") if not cfg_path.is_absolute(): cfg_path = (Path(PROJECT_ROOT) / cfg_path).resolve() try: diff --git a/runtime/hooks/bundled/wiki-auto-inject/handler.py b/runtime/hooks/bundled/wiki-auto-inject/handler.py index b4c6785d..8f79c58d 100644 --- a/runtime/hooks/bundled/wiki-auto-inject/handler.py +++ b/runtime/hooks/bundled/wiki-auto-inject/handler.py @@ -11,7 +11,7 @@ def _project_root() -> Path: def _load_config() -> dict[str, Any]: - cfg_path = _project_root() / "oclaw" / "oclaw.json" + cfg_path = _project_root() / "oclaw.json" if not cfg_path.exists(): return {} try: @@ -29,7 +29,7 @@ def _resolve_wiki_entry(cfg: dict[str, Any]) -> dict[str, Any]: def _resolve_runtime(entry: dict[str, Any]) -> tuple[Path, int, int, bool, int, bool]: - root_cfg = str(entry.get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() + root_cfg = str(entry.get("wiki_root") or "docs/memory-system/wiki").strip() root = Path(root_cfg) if not root.is_absolute(): root = (_project_root() / root).resolve() diff --git a/runtime/hooks/user_config_hooks.py b/runtime/hooks/user_config_hooks.py index 3ef273ed..6f16f9d0 100644 --- a/runtime/hooks/user_config_hooks.py +++ b/runtime/hooks/user_config_hooks.py @@ -13,7 +13,7 @@ def resolve_hooks_config_storage_path() -> Path: Path used for persistent ``hooks.internal.entries`` edits. Matches ``resolve_runtime_config`` file resolution: ``OCLAW_CONFIG_PATH`` (optional - relative to ``PROJECT_ROOT``), else ``/oclaw/oclaw.json``. + relative to ``PROJECT_ROOT``), else ``/oclaw.json``. """ raw = str(os.getenv("OCLAW_CONFIG_PATH") or "").strip() if raw: @@ -21,7 +21,7 @@ def resolve_hooks_config_storage_path() -> Path: if not p.is_absolute(): p = (Path(PROJECT_ROOT) / p).resolve() return p - return (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() + return (Path(PROJECT_ROOT) / "oclaw.json").resolve() def load_storage_config_document() -> dict[str, Any]: diff --git a/runtime/hooks_runtime.py b/runtime/hooks_runtime.py index a9679efb..56370de0 100644 --- a/runtime/hooks_runtime.py +++ b/runtime/hooks_runtime.py @@ -58,7 +58,7 @@ def _reset_hooks_runtime_state_for_test() -> None: def _ensure_oclaw_path() -> Path: - oclaw_dir = (Path(PROJECT_ROOT) / "oclaw").resolve() + oclaw_dir = Path(PROJECT_ROOT).resolve() if str(oclaw_dir) not in sys.path: sys.path.insert(0, str(oclaw_dir)) return oclaw_dir @@ -111,7 +111,7 @@ def resolve_runtime_config() -> dict[str, Any]: cfg = _deep_merge_dict(cfg, file_cfg) return cfg - default_cfg_path = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() + default_cfg_path = (Path(PROJECT_ROOT) / "oclaw.json").resolve() file_cfg = _read_json_file(default_cfg_path) if isinstance(file_cfg, dict): cfg = _deep_merge_dict(cfg, file_cfg) diff --git a/runtime/memory_stage.py b/runtime/memory_stage.py index 4c69d069..3ee66b65 100644 --- a/runtime/memory_stage.py +++ b/runtime/memory_stage.py @@ -169,7 +169,7 @@ def after_turn_memory( except Exception: pass try: - cfg_path = Path(__file__).resolve().parents[2] / "oclaw" / "oclaw.json" + cfg_path = Path(__file__).resolve().parents[1] / "oclaw.json" cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {} plugins = cfg.get("plugins") if isinstance(cfg, dict) else {} entries = plugins.get("entries") if isinstance(plugins, dict) else {} diff --git a/runtime/operations/hooks_cmd.py b/runtime/operations/hooks_cmd.py index 003b9944..05057927 100644 --- a/runtime/operations/hooks_cmd.py +++ b/runtime/operations/hooks_cmd.py @@ -33,13 +33,17 @@ from oclaw.runtime.hooks.user_config_hooks import ( ) from oclaw.runtime.hooks.workspace import load_workspace_hook_entries from oclaw.runtime.hooks_runtime import resolve_runtime_config +from oclaw.runtime.tools.experts.workspace.workspace_base import workspace_root def _resolve_cli_workspace(ns: argparse.Namespace) -> str: w = getattr(ns, "workspace", None) if isinstance(w, str) and w.strip(): return w.strip() - return str(os.getenv("OCLAW_WORKSPACE") or os.getcwd()).strip() or "." + env_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip() + if env_ws: + return env_ws + return str(workspace_root()) def prepare_hooks_cli_config(cfg: dict[str, Any] | None) -> dict[str, Any]: diff --git a/runtime/operations/mcp_env.py b/runtime/operations/mcp_env.py index 13107f17..2b52c52e 100644 --- a/runtime/operations/mcp_env.py +++ b/runtime/operations/mcp_env.py @@ -53,7 +53,8 @@ def _expand_env_value(v: str) -> str: def _mcp_local_env_paths_in_load_order() -> list[Path]: return [ Path(db_path()).resolve().parent / "mcp_local.env", - (PROJECT_ROOT / "oclaw" / "_local" / "mcp_local.env").resolve(), + (PROJECT_ROOT / "_local" / "mcp_local.env").resolve(), + (PROJECT_ROOT.parent / "_local" / "mcp_local.env").resolve(), ] @@ -66,7 +67,7 @@ def mcp_local_env_merged() -> dict[str, str]: def mcp_local_env_file_path() -> Path: - return (PROJECT_ROOT / "oclaw" / "_local" / "mcp_local.env").resolve() + return (PROJECT_ROOT / "_local" / "mcp_local.env").resolve() def gateway_mcp_env_extras() -> dict[str, str]: diff --git a/runtime/operations/mcp_registry_export.py b/runtime/operations/mcp_registry_export.py index a91f2300..3e12301b 100644 --- a/runtime/operations/mcp_registry_export.py +++ b/runtime/operations/mcp_registry_export.py @@ -13,7 +13,7 @@ from oclaw.platform.persistence.sqlite_store import SqliteStore from oclaw.runtime.tools.mcp.registry import McpRegistry _EXPORT_FILENAME = "mcp_registry_migrated.json" -_LOCAL_DIR = (PROJECT_ROOT / "oclaw" / "_local").resolve() +_LOCAL_DIR = (PROJECT_ROOT / "_local").resolve() _EXPORT_PATH = (_LOCAL_DIR / _EXPORT_FILENAME).resolve() diff --git a/runtime/operations/memory.py b/runtime/operations/memory.py index f073cc9c..eb954b93 100644 --- a/runtime/operations/memory.py +++ b/runtime/operations/memory.py @@ -144,19 +144,19 @@ def register_memory_parser(root_sub: argparse._SubParsersAction[argparse.Argumen memory_sub = memory.add_subparsers(dest="memory_cmd", required=True) status = memory_sub.add_parser("status", help="Show memory workload status") - status.add_argument("--base-dir", default="oclaw/docs/memory-system", help="Memory system base directory") + status.add_argument("--base-dir", default="docs/memory-system", help="Memory system base directory") status.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog") status.set_defaults(func=_cmd_memory_status) daily = memory_sub.add_parser("daily", help="Generate today's automated memory run") - daily.add_argument("--base-dir", default="oclaw/docs/memory-system", help="Memory system base directory") + daily.add_argument("--base-dir", default="docs/memory-system", help="Memory system base directory") daily.add_argument("--date", default=None, help="Run date in YYYY-MM-DD") daily.add_argument("--focus", default=None, help="Override focus topic") daily.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog") daily.set_defaults(func=_cmd_memory_daily) weekly = memory_sub.add_parser("weekly", help="Generate weekly review run sheet") - weekly.add_argument("--base-dir", default="oclaw/docs/memory-system", help="Memory system base directory") + weekly.add_argument("--base-dir", default="docs/memory-system", help="Memory system base directory") weekly.add_argument("--date", default=None, help="Run date in YYYY-MM-DD") weekly.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog") weekly.set_defaults(func=_cmd_memory_weekly) diff --git a/runtime/operations/scripts/start_all.ps1 b/runtime/operations/scripts/start_all.ps1 index f4c4e19d..65652c20 100644 --- a/runtime/operations/scripts/start_all.ps1 +++ b/runtime/operations/scripts/start_all.ps1 @@ -17,7 +17,12 @@ function Write-Step([string]$msg) { $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoParent = Split-Path -Parent $repoRoot -Set-Location $repoParent +Set-Location $repoRoot +$env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot Write-Step "Starting gateway + desktop" diff --git a/runtime/operations/scripts/start_desktop.ps1 b/runtime/operations/scripts/start_desktop.ps1 index a7c4cc36..f7fe80d5 100644 --- a/runtime/operations/scripts/start_desktop.ps1 +++ b/runtime/operations/scripts/start_desktop.ps1 @@ -22,6 +22,10 @@ if (-not (Test-Path $desktopDir)) { Set-Location $desktopDir $env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot if (-not $KeepExistingGateway) { Write-Host "==> Cleaning previous gateway listener" -ForegroundColor Cyan diff --git a/runtime/operations/scripts/start_gateway.ps1 b/runtime/operations/scripts/start_gateway.ps1 index f926cf27..98d1dd57 100644 --- a/runtime/operations/scripts/start_gateway.ps1 +++ b/runtime/operations/scripts/start_gateway.ps1 @@ -19,16 +19,20 @@ function Fail([string]$msg) { $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoParent = Split-Path -Parent $repoRoot -Set-Location $repoParent +Set-Location $repoRoot $runDir = Join-Path $PSScriptRoot ".run" New-Item -ItemType Directory -Force -Path $runDir | Out-Null $pidFile = Join-Path $runDir "gateway.pid" Write-Step "Project root: $repoRoot" -Write-Step "Working directory: $repoParent" +Write-Step "Working directory: $repoRoot" $env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" if (Test-Path $venvPython) { @@ -70,7 +74,7 @@ if ($WithWikiWorker) { if ($Background) { Write-Step "Starting gateway in background" - $p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","oclaw.runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoParent -PassThru -WindowStyle Hidden + $p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","oclaw.runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii Write-Host "gateway.pid = $pidFile" -ForegroundColor DarkGray Write-Host "PID = $($p.Id)" -ForegroundColor Green diff --git a/runtime/operations/scripts/start_ops.ps1 b/runtime/operations/scripts/start_ops.ps1 index c0018cc7..ca2ab14f 100644 --- a/runtime/operations/scripts/start_ops.ps1 +++ b/runtime/operations/scripts/start_ops.ps1 @@ -17,12 +17,16 @@ function Fail([string]$msg) { $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoParent = Split-Path -Parent $repoRoot -Set-Location $repoParent +Set-Location $repoRoot Write-Step "Project root: $repoRoot" -Write-Step "Working directory: $repoParent" +Write-Step "Working directory: $repoRoot" $env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" if (Test-Path $venvPython) { diff --git a/runtime/operations/scripts/start_wiki_worker.ps1 b/runtime/operations/scripts/start_wiki_worker.ps1 index d43088ce..36219177 100644 --- a/runtime/operations/scripts/start_wiki_worker.ps1 +++ b/runtime/operations/scripts/start_wiki_worker.ps1 @@ -6,10 +6,11 @@ $ErrorActionPreference = "Stop" function Resolve-RepoRoot { $here = Split-Path -Parent $PSCommandPath - return (Resolve-Path (Join-Path $here "..")).Path + return (Resolve-Path (Join-Path $here "..\\..\\..")).Path } $repoRoot = Resolve-RepoRoot +$repoParent = Split-Path -Parent $repoRoot $runDir = Join-Path $PSScriptRoot ".run" $null = New-Item -ItemType Directory -Force -Path $runDir -ErrorAction SilentlyContinue $pidFile = Join-Path $runDir "wiki_worker.pid" @@ -25,6 +26,16 @@ function Test-AlivePid([int]$procId) { } } +function Test-IsWikiWorkerPid([int]$procId) { + try { + $wmi = Get-CimInstance Win32_Process -Filter "ProcessId = $procId" -ErrorAction Stop + $cmd = [string]($wmi.CommandLine) + return $cmd -like "*oclaw.runtime.workers.wiki.main*" + } catch { + return $false + } +} + $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" if (Test-Path $venvPython) { $pythonExe = $venvPython @@ -35,6 +46,11 @@ if (Test-Path $venvPython) { } $pythonExe = "python" } +$env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot if (-not $Background) { & $pythonExe -m oclaw.runtime.workers.wiki.main @@ -45,7 +61,7 @@ if (Test-Path $pidFile) { $raw = (Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1) $existing = 0 [void][int]::TryParse([string]$raw, [ref]$existing) - if ($existing -gt 0 -and (Test-AlivePid $existing)) { + if ($existing -gt 0 -and (Test-AlivePid $existing) -and (Test-IsWikiWorkerPid $existing)) { Write-Host "[ok] wiki worker already running pid=$existing" exit 0 } diff --git a/runtime/operations/scripts/status_ops.ps1 b/runtime/operations/scripts/status_ops.ps1 index 858465ac..50262ec4 100644 --- a/runtime/operations/scripts/status_ops.ps1 +++ b/runtime/operations/scripts/status_ops.ps1 @@ -6,8 +6,12 @@ function Write-Step([string]$msg) { $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoParent = Split-Path -Parent $repoRoot -Set-Location $repoParent +Set-Location $repoRoot $env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" if (Test-Path $venvPython) { @@ -17,7 +21,7 @@ if (Test-Path $venvPython) { } Write-Step "Project root: $repoRoot" -Write-Step "Working directory: $repoParent" +Write-Step "Working directory: $repoRoot" Write-Step "Stack status" & $pythonExe -m oclaw.runtime.operations stack status diff --git a/runtime/operations/scripts/stop_ops.ps1 b/runtime/operations/scripts/stop_ops.ps1 index f1f5aff1..bf7ee510 100644 --- a/runtime/operations/scripts/stop_ops.ps1 +++ b/runtime/operations/scripts/stop_ops.ps1 @@ -10,8 +10,12 @@ function Write-Step([string]$msg) { $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoParent = Split-Path -Parent $repoRoot -Set-Location $repoParent +Set-Location $repoRoot $env:PYTHONPATH = $repoParent +$env:PYTHONSAFEPATH = "1" +$env:AIA_WORKSPACE_ROOT = $repoRoot +$env:OPS_WORKSPACE_ROOT = $repoRoot +$env:OCLAW_WORKSPACE = $repoRoot $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" if (Test-Path $venvPython) { @@ -21,7 +25,7 @@ if (Test-Path $venvPython) { } Write-Step "Project root: $repoRoot" -Write-Step "Working directory: $repoParent" +Write-Step "Working directory: $repoRoot" Write-Step "Stopping stack services..." try { diff --git a/runtime/operations/scripts/wiki_auto_smoke_test.py b/runtime/operations/scripts/wiki_auto_smoke_test.py index 4e7c07d0..775650ae 100644 --- a/runtime/operations/scripts/wiki_auto_smoke_test.py +++ b/runtime/operations/scripts/wiki_auto_smoke_test.py @@ -16,7 +16,7 @@ from oclaw.platform.persistence.sqlite_store import SqliteStore def _load_cfg() -> dict: - cfg_file = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() + cfg_file = (Path(PROJECT_ROOT) / "oclaw.json").resolve() if not cfg_file.exists(): return {} try: @@ -30,7 +30,7 @@ def _wiki_root_from_cfg(cfg: dict) -> Path: plugins = cfg.get("plugins") if isinstance(cfg, dict) else {} entries = plugins.get("entries") if isinstance(plugins, dict) else {} mw = entries.get("memory-wiki") if isinstance(entries, dict) else {} - root_cfg = str((mw or {}).get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() + root_cfg = str((mw or {}).get("wiki_root") or "docs/memory-system/wiki").strip() root = Path(root_cfg) if not root.is_absolute(): root = (Path(PROJECT_ROOT) / root).resolve() diff --git a/runtime/project_context_prompt.py b/runtime/project_context_prompt.py index 320649ce..b483fff7 100644 --- a/runtime/project_context_prompt.py +++ b/runtime/project_context_prompt.py @@ -21,7 +21,7 @@ def _project_context_roots() -> tuple[Path, ...]: raw_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip() if raw_ws: roots.append(Path(raw_ws).expanduser()) - roots.append(Path(PROJECT_ROOT) / "oclaw" / "runtime" / "workspaces" / "main") + roots.append(Path(PROJECT_ROOT) / "runtime" / "workspaces" / "main") roots.append(Path(PROJECT_ROOT)) out: list[Path] = [] seen: set[str] = set() diff --git a/runtime/skills.py b/runtime/skills.py index 11c66e93..fee9a8c0 100644 --- a/runtime/skills.py +++ b/runtime/skills.py @@ -87,12 +87,15 @@ def default_skills_root() -> Path: # Optional hard-disable for legacy fallback lookups. if str(os.getenv("AIA_DISABLE_LEGACY_SKILLS_FALLBACK") or "").strip().lower() in {"1", "true", "yes", "on"}: return preferred - typo_legacy = (PROJECT_ROOT / "oclaw" / "sills").resolve() + typo_legacy = (PROJECT_ROOT / "sills").resolve() if typo_legacy.exists() and typo_legacy.is_dir(): return typo_legacy - legacy = (PROJECT_ROOT / "oclaw" / "skills").resolve() + legacy = (PROJECT_ROOT / "skills").resolve() if legacy.exists() and legacy.is_dir(): return legacy + legacy_parent = (PROJECT_ROOT.parent / "skills").resolve() + if legacy_parent.exists() and legacy_parent.is_dir(): + return legacy_parent return preferred diff --git a/runtime/tools/experts/memory/wiki_tools.py b/runtime/tools/experts/memory/wiki_tools.py index 98b776e6..0f91aac7 100644 --- a/runtime/tools/experts/memory/wiki_tools.py +++ b/runtime/tools/experts/memory/wiki_tools.py @@ -9,7 +9,7 @@ from oclaw.runtime.tools.base import ToolSpec def _plugin_cfg() -> dict[str, Any]: - cfg_path = (PROJECT_ROOT / "oclaw" / "oclaw.json").resolve() + cfg_path = (PROJECT_ROOT / "oclaw.json").resolve() if not cfg_path.exists(): return {} try: @@ -25,7 +25,7 @@ def _plugin_cfg() -> dict[str, Any]: def _wiki_handlers() -> dict[str, Callable[[dict[str, Any]], dict[str, Any]]]: - api_path = (PROJECT_ROOT / "oclaw" / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve() + api_path = (PROJECT_ROOT / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve() spec = importlib.util.spec_from_file_location("memory_wiki_api", str(api_path)) if spec is None or spec.loader is None: return {} diff --git a/runtime/tools/experts/workspace/fs_tools.py b/runtime/tools/experts/workspace/fs_tools.py index 63dafb85..94a16ae6 100644 --- a/runtime/tools/experts/workspace/fs_tools.py +++ b/runtime/tools/experts/workspace/fs_tools.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +from pathlib import Path from typing import Any from oclaw.runtime.tools.base import ToolSpec @@ -59,11 +60,30 @@ def read_file_tool() -> ToolSpec: def write_file_tool() -> ToolSpec: + def _normalize_write_path(path: str) -> str: + raw = str(path or "").strip().strip('"').strip("'") + if not raw: + raise ValueError("path_required") + p = Path(raw) + if p.is_absolute(): + return str(p) + # Keep generated files out of repo root: default relative writes go under data//... + ws = resolve_workspace_path(".") + ws_name = ws.name or "workspace" + rel = raw.lstrip("./\\") + if not rel: + raise ValueError("path_required") + return str(Path("data") / ws_name / rel) + def handler(args: dict[str, Any]) -> dict[str, Any]: path = str(args.get("path") or "").strip() content = str(args.get("content") or "") mode = str(args.get("mode") or "overwrite").strip().lower() - p = resolve_workspace_path(path) + try: + normalized = _normalize_write_path(path) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + p = resolve_workspace_path(normalized) p.parent.mkdir(parents=True, exist_ok=True) if mode not in ("overwrite", "append"): return {"ok": False, "error": "invalid_mode", "allowed": ["overwrite", "append"]} diff --git a/runtime/tools/workspace_indexer.py b/runtime/tools/workspace_indexer.py index 152d4e4b..4f3d5ce6 100644 --- a/runtime/tools/workspace_indexer.py +++ b/runtime/tools/workspace_indexer.py @@ -61,7 +61,7 @@ def index_workspace( max_chars_per_chunk: int = 1600, ) -> IndexStats: r = Path(root).resolve() if root else _default_workspace_root() - pats = patterns or ["oclaw/**/*.py", "README*.md", "**/*.md"] + pats = patterns or ["**/*.py", "README*.md", "**/*.md"] files = _iter_files(r, pats, max_files=max(1, int(max_files))) client = build_default_embedding_client() chunks_upserted = 0 diff --git a/runtime/workers/wiki/main.py b/runtime/workers/wiki/main.py index 38a166db..7939c4b7 100644 --- a/runtime/workers/wiki/main.py +++ b/runtime/workers/wiki/main.py @@ -6,6 +6,7 @@ import time import importlib.util import hashlib import re +import sys from pathlib import Path from types import SimpleNamespace from typing import Any @@ -15,7 +16,7 @@ from oclaw.platform.persistence.sqlite_store import OclawTask, SqliteStore def _load_oclaw_config() -> dict[str, Any]: - cfg_path = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() + cfg_path = (Path(PROJECT_ROOT) / "oclaw.json").resolve() if not cfg_path.exists(): return {} try: @@ -39,7 +40,7 @@ def _is_worker_enabled(entry: dict[str, Any]) -> bool: def _resolve_wiki_root(plugin_cfg: dict[str, Any]) -> Path: - root_cfg = str(plugin_cfg.get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() + root_cfg = str(plugin_cfg.get("wiki_root") or "docs/memory-system/wiki").strip() root = Path(root_cfg) if not root.is_absolute(): root = (Path(PROJECT_ROOT) / root).resolve() @@ -76,11 +77,16 @@ def _resolve_topic_rules(plugin_cfg: dict[str, Any]) -> list[dict[str, Any]]: def _tool_handlers(plugin_cfg: dict[str, Any]) -> dict[str, Any]: - api_path = (Path(PROJECT_ROOT) / "oclaw" / "extensions" / "memory-wiki" / "api.py").resolve() + candidates = [ + (Path(PROJECT_ROOT) / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve(), + (Path(PROJECT_ROOT) / "extensions" / "memory-wiki" / "api.py").resolve(), + ] + api_path = next((p for p in candidates if p.exists()), candidates[0]) spec = importlib.util.spec_from_file_location("oclaw_memory_wiki_api_worker", str(api_path)) if spec is None or spec.loader is None: return {} mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod spec.loader.exec_module(mod) # type: ignore[assignment] build_wiki_tool_specs = getattr(mod, "build_wiki_tool_specs", None) if not callable(build_wiki_tool_specs): diff --git a/runtime/workspaces/experts.py b/runtime/workspaces/experts.py index 485d50d5..b8e02b06 100644 --- a/runtime/workspaces/experts.py +++ b/runtime/workspaces/experts.py @@ -19,7 +19,7 @@ _SPECIALIST_IDS_CACHE: dict[tuple[Any, ...], tuple[str, ...]] = {} def workspaces_root() -> Path: - return (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() + return (PROJECT_ROOT / "runtime" / "workspaces").resolve() def normalize_expert_id(raw: Any) -> str: diff --git a/tests/test_oclaw_operations_hooks_cmd.py b/tests/test_oclaw_operations_hooks_cmd.py index 3be6356c..90b2ea0f 100644 --- a/tests/test_oclaw_operations_hooks_cmd.py +++ b/tests/test_oclaw_operations_hooks_cmd.py @@ -90,3 +90,24 @@ def test_main_hooks_list_invocation(tmp_path, monkeypatch) -> None: assert code == 0 obj = json.loads(buf.getvalue()) assert "hooks" in obj + + +def test_resolve_cli_workspace_uses_env_over_cwd(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + expected = tmp_path / "repo-root" + expected.mkdir(parents=True) + monkeypatch.setenv("OCLAW_WORKSPACE", str(expected)) + from oclaw.runtime.operations.hooks_cmd import _resolve_cli_workspace + + ns = argparse.Namespace(workspace="") + assert _resolve_cli_workspace(ns) == str(expected) + + +def test_resolve_cli_workspace_falls_back_to_workspace_root(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("OCLAW_WORKSPACE", raising=False) + monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path)) + from oclaw.runtime.operations.hooks_cmd import _resolve_cli_workspace + + ns = argparse.Namespace(workspace="") + assert _resolve_cli_workspace(ns) == str(tmp_path.resolve()) diff --git a/tests/test_oclaw_startup_workspace_resolution.py b/tests/test_oclaw_startup_workspace_resolution.py index 2a215ace..dc38c14a 100644 --- a/tests/test_oclaw_startup_workspace_resolution.py +++ b/tests/test_oclaw_startup_workspace_resolution.py @@ -1,5 +1,6 @@ from __future__ import annotations +from oclaw.platform.config.paths import PROJECT_ROOT from oclaw.interfaces.http.fastapi_app import _resolve_startup_workspace_dir, _resolve_startup_workspace_dirs @@ -30,3 +31,9 @@ def test_resolve_startup_workspace_dirs_all_agents() -> None: assert ("main", "D:/project/chatgpt/oclaw/workspace-main") in got_norm assert ("social", "D:/project/chatgpt/oclaw/workspace-social") in got_norm + +def test_resolve_startup_workspace_dir_fallback_to_repo_runtime_main() -> None: + got = _resolve_startup_workspace_dir({}) + expect = str((PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve()).replace("\\", "/") + assert got.replace("\\", "/") == expect + diff --git a/tests/test_prompt_ci_guard.py b/tests/test_prompt_ci_guard.py index a7d0500d..bd886591 100644 --- a/tests/test_prompt_ci_guard.py +++ b/tests/test_prompt_ci_guard.py @@ -6,16 +6,16 @@ from oclaw.platform.config.paths import PROJECT_ROOT _PROMPT_CRITICAL_FILES = ( - "oclaw/runtime/chat/agent.py", - "oclaw/runtime/agents/network_ops_agent.py", - "oclaw/runtime/agents/factory.py", - "oclaw/runtime/chat/agent_messages.py", - "oclaw/runtime/system_prompt.py", - "oclaw/runtime/memory_stage.py", - "oclaw/runtime/project_context_prompt.py", - "oclaw/runtime/chat/agent_errors.py", - "oclaw/platform/llm/image_message_client.py", - "oclaw/runtime/gateway.py", + "runtime/chat/agent.py", + "runtime/agents/network_ops_agent.py", + "runtime/agents/factory.py", + "runtime/chat/agent_messages.py", + "runtime/system_prompt.py", + "runtime/memory_stage.py", + "runtime/project_context_prompt.py", + "runtime/chat/agent_errors.py", + "platform/llm/image_message_client.py", + "runtime/gateway.py", ) @@ -33,8 +33,8 @@ def test_prompt_critical_paths_use_prompt_templates() -> None: def test_prompt_markdown_frontmatter_keys_present() -> None: roots = [ - PROJECT_ROOT / "oclaw" / "prompts", - PROJECT_ROOT / "oclaw" / "prompts_runtime", + PROJECT_ROOT / "prompts", + PROJECT_ROOT / "prompts_runtime", ] for prompts_root in roots: for p in prompts_root.rglob("*.md"): diff --git a/tests/test_prompt_hardcode_guard.py b/tests/test_prompt_hardcode_guard.py index 1bdc32c8..ebbc6389 100644 --- a/tests/test_prompt_hardcode_guard.py +++ b/tests/test_prompt_hardcode_guard.py @@ -12,18 +12,18 @@ def _read(rel: str) -> str: def test_no_legacy_inline_manager_prompts() -> None: # Manager orchestrator was removed; keep this test as a smoke-guard for any new inline # manager-style prompt leakage into the runtime entrypoints. - content = _read("oclaw/runtime/gateway.py") + content = _read("runtime/gateway.py") assert "【重要:只返回合法 JSON】" not in content assert "你是 AI 助手的路由(Routing)智能体" not in content def test_no_legacy_inline_specialist_prompts() -> None: - content = _read("oclaw/runtime/agents/specialists.py") + content = _read("runtime/agents/specialists.py") assert "【最高优先级执行规则(必须遵守)】" not in content assert "【职责范围】" not in content def test_no_legacy_inline_runtime_prompt() -> None: - content = _read("oclaw/runtime/chat/agent.py") + content = _read("runtime/chat/agent.py") assert "你是一个通用 AI 助手。" not in content diff --git a/tests/test_workspace_path_guard.py b/tests/test_workspace_path_guard.py index b1c98451..17a03d63 100644 --- a/tests/test_workspace_path_guard.py +++ b/tests/test_workspace_path_guard.py @@ -12,7 +12,7 @@ from fastapi.testclient import TestClient from oclaw.interfaces.http.fastapi_app import create_app from oclaw.platform.config.paths import db_path from oclaw.platform.persistence.sqlite_store import SqliteStore -from oclaw.runtime.tools.experts.workspace.fs_tools import list_files_tool +from oclaw.runtime.tools.experts.workspace.fs_tools import list_files_tool, write_file_tool from oclaw.runtime.tools.experts.workspace.workspace_base import ( access_from_env, build_workspace_path_access, @@ -100,6 +100,21 @@ class WorkspacePathGuardTests(unittest.TestCase): p = resolve_workspace_path(str(f)) self.assertEqual(p, f.resolve()) + def test_write_file_relative_path_defaults_to_data_workspace_subdir(self) -> None: + with mock.patch.dict( + os.environ, + {"OPS_WORKSPACE_ROOT": str(self.root), "OPS_WORKSPACE_EXTRA_ROOTS": "", "OPS_WORKSPACE_ALLOW_ANY_PATH": ""}, + clear=False, + ): + clear_workspace_path_access_for_tests() + spec = write_file_tool() + with workspace_path_access_scope(None, None): + r = spec.handler({"path": "generated.py", "content": "print('ok')\n", "mode": "overwrite"}) + self.assertTrue(r.get("ok"), r) + expected = (self.root / "data" / self.root.name / "generated.py").resolve() + self.assertEqual(str(expected), str(r.get("path"))) + self.assertTrue(expected.exists()) + def test_per_user_extra_roots_from_db(self) -> None: f = self.extra / "u.txt" f.write_text("u", encoding="utf-8") diff --git a/tests/test_workspaces_prompt_contract.py b/tests/test_workspaces_prompt_contract.py index 48b290a1..b076c7c7 100644 --- a/tests/test_workspaces_prompt_contract.py +++ b/tests/test_workspaces_prompt_contract.py @@ -10,7 +10,7 @@ def _read(path: Path) -> str: def test_main_dynamic_placeholder_only_in_role_system() -> None: - root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve() + root = (PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve() role_system = _read(root / "ROLE_SYSTEM.md") soul = _read(root / "SOUL.md") assert "{{MANAGER_DYNAMIC_EXPERTS_HINT}}" in role_system @@ -22,7 +22,7 @@ def test_main_dynamic_placeholder_only_in_role_system() -> None: def test_main_hard_routing_rule_only_in_role_system() -> None: - root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve() + root = (PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve() role_system = _read(root / "ROLE_SYSTEM.md") assert "回退 `generalist`" in role_system for name in ("AGENTS.md", "IDENTITY.md", "USER.md", "SOUL.md"): @@ -32,7 +32,7 @@ def test_main_hard_routing_rule_only_in_role_system() -> None: def test_every_workspace_has_role_system() -> None: - root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() + root = (PROJECT_ROOT / "runtime" / "workspaces").resolve() for item in root.iterdir(): if not item.is_dir(): continue @@ -42,7 +42,7 @@ def test_every_workspace_has_role_system() -> None: def test_workspace_legacy_prompt_files_removed() -> None: - root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() + root = (PROJECT_ROOT / "runtime" / "workspaces").resolve() legacy = ("AGENTS.md", "IDENTITY.md", "USER.md") for item in root.iterdir(): if not item.is_dir():