diff --git a/.env.example b/.env.example index 74a7d7d..2e2d763 100644 --- a/.env.example +++ b/.env.example @@ -9,11 +9,12 @@ WORKSPACE_SERVICE_TOKEN= # Optional: HTTP server port (default: 8080) PORT=8080 -# Optional: Root directory where workspace is mounted (default: /workspace) -WORKSPACE_ROOT=/workspace +# Optional: Absolute OpenClaw root mount path (default: /openclaw-config) +CONFIG_ROOT=/openclaw-config -# Optional: Subdirectory within WORKSPACE_ROOT to expose (default: workspace) -WORKSPACE_SUBDIR=workspace +# Optional: Main workspace folder under CONFIG_ROOT (default: workspace) +# Must be a single folder name (no /, \\, ., ..) +MAIN_WORKSPACE_DIR=workspace # Optional: Comma-separated symlink prefixes to remap (default: /home/node/.openclaw) SYMLINK_REMAP_PREFIXES=/home/node/.openclaw diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index ddac3c5..2ab9c8a 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -27,3 +27,4 @@ jobs: uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE_KEY: A17B1B-97A03F-6EECE6-BAE41F-65FBAF-V3 diff --git a/.gitignore b/.gitignore index b0c2e9f..97d6f42 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ npm-debug.log .DS_Store *.log coverage/ +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 0845950..eab345b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Split workspace/config filesystem roots and enforce config-root + main-workspace-dir path law +- `/workspace/*` virtual mapping for the main workspace +- Claude Code configuration and project rules + +### Changed + +- Docker publish workflow hardened for multi-platform builds and SHA prefix handling +- Documentation clarified for read/write mounts and `WORKSPACE_SUBDIR` defaults + +### Fixed + +- Dockerfile now includes the application source directory in image builds + +### Security + +- Switched Gitleaks license key to an organization secret + ## [0.1.0] - 2026-03-03 - Initial release diff --git a/README.md b/README.md index 0ec5ca9..eb734cd 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ Lightweight HTTP service that exposes OpenClaw workspace files over REST API. Th ## Security -> **This service can read, write, and delete files on the mounted workspace volume. Treat it as a privileged internal API.** +> **This service can read, write, and delete files under the mounted OpenClaw root. Treat it as a privileged internal API.** - **Authentication is required** — `WORKSPACE_SERVICE_TOKEN` must be set. The service will refuse to start without it. - **Never expose port 8080 to the public internet** — use a VPN, private network, or Kubernetes `ClusterIP` service. - Always use a strong, randomly generated bearer token (`openssl rand -hex 32`). - The service runs as a non-root user inside the container. - Path traversal protection is built-in and cannot be bypassed via the API. -- Mount workspace volumes as read-only (`:ro`) when write access is not required. +- For normal MosBot usage, mount the OpenClaw root read-write so Projects/Skills/Docs and config edits can succeed. See [SECURITY.md](SECURITY.md) for the full threat model and vulnerability reporting process. @@ -38,9 +38,10 @@ services: image: ghcr.io/bymosbot/mosbot-workspace-service:latest environment: WORKSPACE_SERVICE_TOKEN: your-secure-token # required - WORKSPACE_ROOT: /workspace + CONFIG_ROOT: /openclaw-config + MAIN_WORKSPACE_DIR: workspace volumes: - - openclaw-workspace:/workspace:ro + - /path/to/.openclaw:/openclaw-config ports: - "8080:8080" ``` @@ -51,27 +52,44 @@ services: docker run -d \ --name mosbot-workspace \ -e WORKSPACE_SERVICE_TOKEN=your-secure-token \ - -e WORKSPACE_ROOT=/workspace \ - -v /path/to/openclaw/workspace:/workspace:ro \ + -e CONFIG_ROOT=/openclaw-config \ + -e MAIN_WORKSPACE_DIR=workspace \ + -v /path/to/.openclaw:/openclaw-config \ -p 8080:8080 \ ghcr.io/bymosbot/mosbot-workspace-service:latest ``` +For full MosBot integration (agent discovery via `openclaw.json` + Projects/Skills/Docs CRUD), use +a read-write mount for `CONFIG_ROOT`. + ## Environment Variables | Variable | Default | Description | | ----------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- | | `PORT` | `8080` | HTTP server port | -| `WORKSPACE_ROOT` | `/workspace` | Root directory where workspace is mounted | -| `WORKSPACE_SUBDIR` | `workspace` | Subdirectory within `WORKSPACE_ROOT` to expose (prevents browsing the entire filesystem) | +| `CONFIG_ROOT` | `/openclaw-config` | Absolute OpenClaw root mount containing config, shared dirs, and agent workspaces | +| `MAIN_WORKSPACE_DIR` | `workspace` | Main workspace directory name under `CONFIG_ROOT` (single folder name only; no `/`, `\`, `.`, `..`) | | `WORKSPACE_SERVICE_TOKEN` | — | **Required.** Bearer token for authentication. The service will not start without this. | | `SYMLINK_REMAP_PREFIXES` | `/home/node/.openclaw` | Comma-separated list of symlink prefixes to remap (for cross-container symlinks) | | `WORKSPACE_SERVICE_ALLOW_ANONYMOUS` | — | Set to `true` to disable auth requirement. **For local development only. Never use in production.** | -> **Deprecated aliases** (still accepted for backward compatibility): -> -> - `WORKSPACE_PATH` → use `WORKSPACE_ROOT` instead -> - `AUTH_TOKEN` → use `WORKSPACE_SERVICE_TOKEN` instead +Removed and no longer honored: `WORKSPACE_FS_ROOT`, `CONFIG_FS_ROOT`, `WORKSPACE_ROOT`, +`WORKSPACE_SUBDIR`, `WORKSPACE_PATH`, `AUTH_TOKEN`. + +## Filesystem and Virtual Path Contract + +Given `CONFIG_ROOT=/openclaw-config` and `MAIN_WORKSPACE_DIR=workspace`: + +- Main workspace filesystem root: `/openclaw-config/workspace` +- Sub-agent workspaces: `/openclaw-config/workspace-` +- Shared directories: `/openclaw-config/projects`, `/openclaw-config/skills`, `/openclaw-config/docs` + +Routing rules: + +- Config-root paths: `/openclaw.json`, `/org-chart.json`, `/projects/**`, `/skills/**`, `/docs/**`, `/workspace-/**` +- Main workspace canonical paths: `/workspace` and `/workspace/**` (mapped to `CONFIG_ROOT/MAIN_WORKSPACE_DIR`) + +Canonical main workspace virtual path is `/workspace`. ## API Endpoints @@ -95,7 +113,7 @@ Returns workspace accessibility status. ### List Files ```bash -GET /files?path=/&recursive=false +GET /files?path=/workspace&recursive=false Authorization: Bearer ``` diff --git a/SECURITY.md b/SECURITY.md index 24fbfe0..36782fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Key risks to be aware of: - **File write/delete access**: The `POST /files`, `PUT /files`, and `DELETE /files` endpoints can modify or remove files on the mounted workspace volume. Always use a strong `WORKSPACE_SERVICE_TOKEN` and restrict network access. -- **Path traversal**: Built-in path traversal protection rejects requests that escape the configured `WORKSPACE_ROOT`/`WORKSPACE_SUBDIR`. Do not disable or weaken this check. +- **Path traversal**: Built-in path traversal protection rejects requests that escape `CONFIG_ROOT` or `CONFIG_ROOT/`. Do not disable or weaken this check. - **Symlink following**: The service follows symlinks to support cross-container paths. Ensure the workspace volume only contains trusted content. - **Token exposure**: Never log or expose `WORKSPACE_SERVICE_TOKEN` in application logs, metrics, or error responses. diff --git a/SETUP.md b/SETUP.md index 1dfa75a..1d76d99 100644 --- a/SETUP.md +++ b/SETUP.md @@ -89,8 +89,9 @@ docker build -t mosbot-workspace-service:test . docker run -d \ --name mosbot-workspace-test \ -e WORKSPACE_SERVICE_TOKEN=test-token \ - -e WORKSPACE_ROOT=/workspace \ - -v /tmp/test-workspace:/workspace \ + -e CONFIG_ROOT=/openclaw-config \ + -e MAIN_WORKSPACE_DIR=workspace \ + -v /tmp/test-config:/openclaw-config \ -p 8080:8080 \ mosbot-workspace-service:test diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js index 4b42047..48187d3 100644 --- a/__tests__/auth.test.js +++ b/__tests__/auth.test.js @@ -8,14 +8,20 @@ const { createApp } = require("../src/app"); describe("Authentication middleware", () => { let tmpDir; + let workspaceRoot; + let configRoot; let app; const TOKEN = "test-token-abc123"; beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-auth-test-")); - // Create a minimal workspace structure - await fs.mkdir(path.join(tmpDir, "workspace"), { recursive: true }); - await fs.writeFile(path.join(tmpDir, "workspace", "hello.txt"), "hello"); + configRoot = path.join(tmpDir, "config-root"); + workspaceRoot = path.join(configRoot, "workspace"); + + await fs.mkdir(configRoot, { recursive: true }); + await fs.mkdir(workspaceRoot, { recursive: true }); + await fs.writeFile(path.join(workspaceRoot, "hello.txt"), "hello"); + await fs.writeFile(path.join(configRoot, "openclaw.json"), "{}"); }); afterAll(async () => { @@ -25,8 +31,8 @@ describe("Authentication middleware", () => { describe("when token is configured", () => { beforeAll(() => { app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + configRoot, + mainWorkspaceDir: "workspace", token: TOKEN, symlinkRemapPrefixes: [], }); @@ -68,8 +74,8 @@ describe("Authentication middleware", () => { describe("when no token is configured (anonymous mode)", () => { beforeAll(() => { app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); diff --git a/__tests__/files-api.test.js b/__tests__/files-api.test.js index 966da94..e27226b 100644 --- a/__tests__/files-api.test.js +++ b/__tests__/files-api.test.js @@ -8,21 +8,32 @@ const { createApp } = require("../src/app"); describe("Files API", () => { let tmpDir; + let workspaceRoot; + let configRoot; let app; beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-files-test-")); - // workspace root is tmpDir, exposed subdir is "workspace" - await fs.mkdir(path.join(tmpDir, "workspace", "subdir"), { recursive: true }); - await fs.writeFile(path.join(tmpDir, "workspace", "hello.txt"), "hello world"); + configRoot = path.join(tmpDir, "config-root"); + workspaceRoot = path.join(configRoot, "workspace"); + + await fs.mkdir(configRoot, { recursive: true }); + await fs.mkdir(path.join(workspaceRoot, "subdir"), { recursive: true }); + await fs.mkdir(path.join(configRoot, "workspace-cto"), { recursive: true }); + await fs.mkdir(path.join(configRoot, "projects"), { recursive: true }); + + await fs.writeFile(path.join(workspaceRoot, "hello.txt"), "hello world"); await fs.writeFile( - path.join(tmpDir, "workspace", "subdir", "nested.txt"), + path.join(workspaceRoot, "subdir", "nested.txt"), "nested content", ); + await fs.writeFile(path.join(configRoot, "workspace-cto", "agent.txt"), "cto"); + await fs.writeFile(path.join(configRoot, "projects", "project.txt"), "project"); + await fs.writeFile(path.join(configRoot, "openclaw.json"), '{"models":[]}'); app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); @@ -32,28 +43,49 @@ describe("Files API", () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - // ── GET /files ───────────────────────────────────────────────────────────── - describe("GET /files", () => { - it("lists root directory contents", async () => { + it("lists root directory contents from workspace root", async () => { const res = await request(app).get("/files"); expect(res.status).toBe(200); expect(Array.isArray(res.body.files)).toBe(true); expect(res.body.count).toBeGreaterThanOrEqual(2); }); - it("lists a specific subdirectory", async () => { + it("lists a specific workspace subdirectory", async () => { const res = await request(app).get("/files?path=/subdir"); expect(res.status).toBe(200); expect(res.body.files.some((f) => f.name === "nested.txt")).toBe(true); }); - it("returns single file info when path points to a file", async () => { - const res = await request(app).get("/files?path=/hello.txt"); + it("maps /workspace to the main workspace root", async () => { + const res = await request(app).get("/files?path=/workspace"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "hello.txt")).toBe(true); + }); + + it("maps /workspace/* paths to main workspace children without nesting", async () => { + const res = await request(app).get("/files?path=/workspace/subdir"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "nested.txt")).toBe(true); + }); + + it("routes /workspace- paths to config root", async () => { + const res = await request(app).get("/files?path=/workspace-cto"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "agent.txt")).toBe(true); + }); + + it("routes /projects paths to config root", async () => { + const res = await request(app).get("/files?path=/projects"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "project.txt")).toBe(true); + }); + + it("returns config file info from config root", async () => { + const res = await request(app).get("/files?path=/openclaw.json"); expect(res.status).toBe(200); expect(res.body.files).toHaveLength(1); - expect(res.body.files[0].name).toBe("hello.txt"); - expect(res.body.files[0].type).toBe("file"); + expect(res.body.files[0].name).toBe("openclaw.json"); }); it("lists recursively when recursive=true", async () => { @@ -69,25 +101,32 @@ describe("Files API", () => { expect(res.body.error).toBe("Path not found"); }); - it("normalises traversal sequences safely within the workspace root", async () => { - // /../../../etc/passwd normalises to /etc/passwd which resolves to - // EXPOSED_ROOT/etc/passwd — safely inside the workspace. The path just - // won't exist, so we get a 404, not a traversal error. + it("normalises traversal sequences safely within selected root", async () => { const res = await request(app).get("/files?path=/../../../etc/passwd"); expect(res.status).toBe(404); }); }); - // ── GET /files/content ───────────────────────────────────────────────────── - describe("GET /files/content", () => { - it("returns file content", async () => { + it("returns workspace file content", async () => { const res = await request(app).get("/files/content?path=/hello.txt"); expect(res.status).toBe(200); expect(res.body.content).toBe("hello world"); expect(res.body.encoding).toBe("utf8"); }); + it("returns config file content from config root", async () => { + const res = await request(app).get("/files/content?path=/openclaw.json"); + expect(res.status).toBe(200); + expect(res.body.content).toContain("models"); + }); + + it("returns content for /workspace/* paths from main workspace root", async () => { + const res = await request(app).get("/files/content?path=/workspace/hello.txt"); + expect(res.status).toBe(200); + expect(res.body.content).toBe("hello world"); + }); + it("returns 400 when path parameter is missing", async () => { const res = await request(app).get("/files/content"); expect(res.status).toBe(400); @@ -107,10 +146,8 @@ describe("Files API", () => { }); }); - // ── POST /files ──────────────────────────────────────────────────────────── - describe("POST /files", () => { - it("creates a new file and returns 201", async () => { + it("creates a new workspace file and returns 201", async () => { const res = await request(app).post("/files").send({ path: "/created.txt", content: "created content", @@ -119,19 +156,33 @@ describe("Files API", () => { expect(res.body.message).toBe("File created successfully"); expect(res.body.name).toBe("created.txt"); - const actual = await fs.readFile( - path.join(tmpDir, "workspace", "created.txt"), - "utf8", - ); + const actual = await fs.readFile(path.join(workspaceRoot, "created.txt"), "utf8"); expect(actual).toBe("created content"); }); - it("creates parent directories as needed", async () => { + it("creates parent directories in workspace root", async () => { const res = await request(app).post("/files").send({ path: "/deep/nested/file.txt", content: "deep content", }); expect(res.status).toBe(201); + + const actual = await fs.readFile( + path.join(workspaceRoot, "deep", "nested", "file.txt"), + "utf8", + ); + expect(actual).toBe("deep content"); + }); + + it("creates config file under config root", async () => { + const res = await request(app).post("/files").send({ + path: "/org-chart.json", + content: '{"version":1}', + }); + expect(res.status).toBe(201); + + const actual = await fs.readFile(path.join(configRoot, "org-chart.json"), "utf8"); + expect(actual).toContain("version"); }); it("returns 400 when path is missing", async () => { @@ -147,14 +198,13 @@ describe("Files API", () => { }); }); - // ── PUT /files ───────────────────────────────────────────────────────────── - describe("PUT /files", () => { beforeAll(async () => { - await fs.writeFile(path.join(tmpDir, "workspace", "updatable.txt"), "original"); + await fs.writeFile(path.join(workspaceRoot, "updatable.txt"), "original"); + await fs.writeFile(path.join(configRoot, "org-chart.json"), '{"version":1}'); }); - it("updates an existing file and returns 200", async () => { + it("updates an existing workspace file and returns 200", async () => { const res = await request(app).put("/files").send({ path: "/updatable.txt", content: "updated content", @@ -162,13 +212,21 @@ describe("Files API", () => { expect(res.status).toBe(200); expect(res.body.message).toBe("File updated successfully"); - const actual = await fs.readFile( - path.join(tmpDir, "workspace", "updatable.txt"), - "utf8", - ); + const actual = await fs.readFile(path.join(workspaceRoot, "updatable.txt"), "utf8"); expect(actual).toBe("updated content"); }); + it("updates an existing config file and returns 200", async () => { + const res = await request(app).put("/files").send({ + path: "/org-chart.json", + content: '{"version":2}', + }); + expect(res.status).toBe(200); + + const actual = await fs.readFile(path.join(configRoot, "org-chart.json"), "utf8"); + expect(actual).toContain('"version":2'); + }); + it("returns 404 when file does not exist", async () => { const res = await request(app).put("/files").send({ path: "/nonexistent.txt", @@ -191,31 +249,32 @@ describe("Files API", () => { }); }); - // ── DELETE /files ────────────────────────────────────────────────────────── - describe("DELETE /files", () => { - it("deletes a file and returns 204", async () => { - await fs.writeFile(path.join(tmpDir, "workspace", "to-delete.txt"), "bye"); + it("deletes a workspace file and returns 204", async () => { + await fs.writeFile(path.join(workspaceRoot, "to-delete.txt"), "bye"); const res = await request(app).delete("/files?path=/to-delete.txt"); expect(res.status).toBe(204); await expect( - fs.access(path.join(tmpDir, "workspace", "to-delete.txt")), + fs.access(path.join(workspaceRoot, "to-delete.txt")), ).rejects.toThrow(); }); - it("deletes a directory recursively and returns 204", async () => { - await fs.mkdir(path.join(tmpDir, "workspace", "dir-to-delete"), { - recursive: true, - }); - await fs.writeFile( - path.join(tmpDir, "workspace", "dir-to-delete", "file.txt"), - "x", - ); + it("deletes a workspace directory recursively and returns 204", async () => { + await fs.mkdir(path.join(workspaceRoot, "dir-to-delete"), { recursive: true }); + await fs.writeFile(path.join(workspaceRoot, "dir-to-delete", "file.txt"), "x"); const res = await request(app).delete("/files?path=/dir-to-delete"); expect(res.status).toBe(204); }); + it("deletes a config file and returns 204", async () => { + await fs.writeFile(path.join(configRoot, "org-chart.json"), '{"version":2}'); + const res = await request(app).delete("/files?path=/org-chart.json"); + expect(res.status).toBe(204); + + await expect(fs.access(path.join(configRoot, "org-chart.json"))).rejects.toThrow(); + }); + it("returns 400 when path parameter is missing", async () => { const res = await request(app).delete("/files"); expect(res.status).toBe(400); @@ -229,8 +288,6 @@ describe("Files API", () => { }); }); - // ── Error handler ────────────────────────────────────────────────────────── - describe("Error handler (next(error) paths)", () => { it("GET /files: returns 500 for unexpected errors (via mocked fs.readdir)", async () => { const fsModule = require("fs").promises; diff --git a/__tests__/health-status.test.js b/__tests__/health-status.test.js index c695acb..256fd87 100644 --- a/__tests__/health-status.test.js +++ b/__tests__/health-status.test.js @@ -8,15 +8,21 @@ const { createApp } = require("../src/app"); describe("Health and status endpoints", () => { let tmpDir; + let workspaceRoot; + let configRoot; let app; beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-health-test-")); - await fs.mkdir(path.join(tmpDir, "workspace"), { recursive: true }); + configRoot = path.join(tmpDir, "config-root"); + workspaceRoot = path.join(configRoot, "workspace"); + + await fs.mkdir(configRoot, { recursive: true }); + await fs.mkdir(workspaceRoot, { recursive: true }); app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); @@ -33,36 +39,72 @@ describe("Health and status endpoints", () => { expect(res.body.status).toBe("ok"); }); - it("includes workspace and exposedRoot fields", async () => { + it("includes split-root fields", async () => { const res = await request(app).get("/health"); - expect(res.body.workspace).toBe(tmpDir); - expect(res.body.exposedRoot).toBe(path.join(tmpDir, "workspace")); - expect(res.body.workspaceSubdir).toBe("workspace"); + expect(res.body.configRoot).toBe(configRoot); + expect(res.body.mainWorkspaceDir).toBe("workspace"); + expect(res.body.mainWorkspaceFsRoot).toBe(workspaceRoot); + expect(res.body.workspaceFsRoot).toBe(workspaceRoot); + expect(res.body.configFsRoot).toBe(configRoot); expect(res.body.timestamp).toBeDefined(); }); }); describe("GET /status", () => { - it("returns 200 with accessible: true when workspace exists", async () => { + it("returns 200 with both roots accessible", async () => { const res = await request(app).get("/status"); expect(res.status).toBe(200); - expect(res.body.exists).toBe(true); - expect(res.body.accessible).toBe(true); - expect(res.body.workspace).toBe(tmpDir); + expect(res.body.workspaceAccessible).toBe(true); + expect(res.body.configAccessible).toBe(true); + expect(res.body.workspaceExists).toBe(true); + expect(res.body.configExists).toBe(true); + }); + + it("returns 500 when workspace root does not exist", async () => { + const missingWorkspaceApp = createApp({ + configRoot, + mainWorkspaceDir: "missing-main-workspace", + token: undefined, + symlinkRemapPrefixes: [], + }); + + const res = await request(missingWorkspaceApp).get("/status"); + expect(res.status).toBe(500); + expect(res.body.workspaceAccessible).toBe(false); + expect(res.body.configAccessible).toBe(true); + expect(res.body.errors.mainWorkspace).toBeDefined(); }); - it("returns 500 with accessible: false when workspace does not exist", async () => { - const missingApp = createApp({ - workspaceRoot: "/nonexistent/path/that/does/not/exist", - workspaceSubdir: "workspace", + it("returns 500 when config root does not exist", async () => { + const missingConfigApp = createApp({ + configRoot: "/nonexistent/config/path/that/does/not/exist", + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); - const res = await request(missingApp).get("/status"); + + const res = await request(missingConfigApp).get("/status"); expect(res.status).toBe(500); - expect(res.body.exists).toBe(false); - expect(res.body.accessible).toBe(false); - expect(res.body.error).toBeDefined(); + expect(res.body.workspaceAccessible).toBe(false); + expect(res.body.configAccessible).toBe(false); + expect(res.body.errors.mainWorkspace).toBeDefined(); + expect(res.body.errors.config).toBeDefined(); + }); + + it("uses fallback status error message when root errors are blank", async () => { + const fsModule = require("fs").promises; + const originalStat = fsModule.stat; + const blankError = new Error(); + blankError.message = ""; + fsModule.stat = jest.fn().mockRejectedValue(blankError); + + try { + const res = await request(app).get("/status"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Filesystem root inaccessible"); + } finally { + fsModule.stat = originalStat; + } }); }); }); diff --git a/__tests__/index.test.js b/__tests__/index.test.js index a6593f2..e8ddfc3 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -2,10 +2,6 @@ /** * Tests for src/index.js — the process entrypoint. - * - * We test two ways: - * 1. Direct require with jest.mock — for coverage instrumentation - * 2. Child process spawn — for testing process.exit() behaviour */ const { execFile } = require("child_process"); @@ -13,30 +9,21 @@ const path = require("path"); const INDEX_PATH = path.join(__dirname, "..", "src", "index.js"); -// ── Mock dotenv to prevent .env file from interfering with tests ────────────── jest.mock("dotenv", () => ({ config: jest.fn(), })); -// ── Mock app module so index.js doesn't bind to a real port ────────────────── -// jest.mock is hoisted, so the factory must be self-contained. - jest.mock("../src/app", () => { const listenFn = jest.fn((port, cb) => { if (cb) cb(); }); - const mockApp = { - listen: listenFn, - _exposedRoot: "/tmp", - }; + const mockApp = { listen: listenFn }; return { createApp: jest.fn(() => mockApp), __mockApp: mockApp, }; }); -// ── Child-process helper ────────────────────────────────────────────────────── - function spawnIndex(env, timeoutMs = 3000) { return new Promise((resolve) => { const child = execFile( @@ -53,8 +40,6 @@ function spawnIndex(env, timeoutMs = 3000) { }); } -// ── Direct-require tests (for coverage) ────────────────────────────────────── - describe("src/index.js — direct require (coverage)", () => { let originalEnv; let exitMock; @@ -63,13 +48,11 @@ describe("src/index.js — direct require (coverage)", () => { beforeEach(() => { jest.resetModules(); originalEnv = { ...process.env }; - // Clear index.js from cache so each test re-executes the module delete require.cache[require.resolve("../src/index")]; + appModule = require("../src/app"); - // Clear mock call history appModule.createApp.mockClear(); appModule.__mockApp.listen.mockClear(); - // Ensure createApp always returns the mock app appModule.createApp.mockReturnValue(appModule.__mockApp); exitMock = jest.spyOn(process, "exit").mockImplementation(() => { @@ -78,11 +61,8 @@ describe("src/index.js — direct require (coverage)", () => { }); afterEach(() => { - // Restore env vars: delete keys added by tests, restore original values for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) { - delete process.env[key]; - } + if (!(key in originalEnv)) delete process.env[key]; } Object.assign(process.env, originalEnv); jest.restoreAllMocks(); @@ -91,7 +71,6 @@ describe("src/index.js — direct require (coverage)", () => { it("calls process.exit(1) when token is missing and ALLOW_ANONYMOUS is not set", () => { process.env.WORKSPACE_SERVICE_TOKEN = ""; - process.env.AUTH_TOKEN = ""; process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = ""; expect(() => require("../src/index")).toThrow("process.exit called"); @@ -100,111 +79,59 @@ describe("src/index.js — direct require (coverage)", () => { it("starts the server when WORKSPACE_SERVICE_TOKEN is set", () => { process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "workspace"; process.env.PORT = "0"; - // Initialize mock to ensure it's set up correctly - appModule.createApp({ - workspaceRoot: "/tmp", - workspaceSubdir: "", - token: "test-token", - symlinkRemapPrefixes: [], - }); - appModule.createApp.mockClear(); - appModule.__mockApp.listen.mockClear(); - delete require.cache[require.resolve("../src/index")]; expect(() => require("../src/index")).not.toThrow(); - expect(appModule.createApp).toHaveBeenCalled(); + + expect(appModule.createApp).toHaveBeenCalledWith( + expect.objectContaining({ + configRoot: "/tmp/config", + mainWorkspaceDir: "workspace", + token: "test-token", + }), + ); expect(appModule.__mockApp.listen).toHaveBeenCalled(); }); it("starts the server when WORKSPACE_SERVICE_ALLOW_ANONYMOUS=true", () => { process.env.WORKSPACE_SERVICE_TOKEN = ""; - process.env.AUTH_TOKEN = ""; process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = "true"; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "workspace"; process.env.PORT = "0"; - // Initialize mock to ensure it's set up correctly - appModule.createApp({ - workspaceRoot: "/tmp", - workspaceSubdir: "", - token: undefined, - symlinkRemapPrefixes: [], - }); - appModule.createApp.mockClear(); - appModule.__mockApp.listen.mockClear(); - delete require.cache[require.resolve("../src/index")]; expect(() => require("../src/index")).not.toThrow(); - expect(appModule.createApp).toHaveBeenCalled(); - expect(appModule.__mockApp.listen).toHaveBeenCalled(); - }); - it("accepts legacy AUTH_TOKEN as a fallback", () => { - process.env.WORKSPACE_SERVICE_TOKEN = ""; - process.env.AUTH_TOKEN = "legacy-token"; - process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = ""; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; - process.env.PORT = "0"; - - // Initialize mock to ensure it's set up correctly - appModule.createApp({ - workspaceRoot: "/tmp", - workspaceSubdir: "", - token: "legacy-token", - symlinkRemapPrefixes: [], - }); - appModule.createApp.mockClear(); - appModule.__mockApp.listen.mockClear(); - - delete require.cache[require.resolve("../src/index")]; - expect(() => require("../src/index")).not.toThrow(); - expect(appModule.createApp).toHaveBeenCalled(); - expect(appModule.__mockApp.listen).toHaveBeenCalled(); - }); - - it("logs deprecation warning when WORKSPACE_PATH is used without WORKSPACE_ROOT", () => { - process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; - process.env.WORKSPACE_PATH = "/tmp"; - process.env.WORKSPACE_ROOT = ""; - process.env.WORKSPACE_SUBDIR = ""; - process.env.PORT = "0"; - - const warnSpy = jest.spyOn(console, "warn"); - delete require.cache[require.resolve("../src/index")]; - expect(() => require("../src/index")).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringMatching(/deprecated WORKSPACE_PATH/), + expect(appModule.createApp).toHaveBeenCalledWith( + expect.objectContaining({ + configRoot: "/tmp/config", + mainWorkspaceDir: "workspace", + token: "", + }), ); - warnSpy.mockRestore(); }); - it("logs deprecation warning when AUTH_TOKEN is used without WORKSPACE_SERVICE_TOKEN", () => { - process.env.AUTH_TOKEN = "legacy-token"; - process.env.WORKSPACE_SERVICE_TOKEN = ""; - process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = ""; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; - process.env.PORT = "0"; + it.each([ + { value: " ", label: "blank" }, + { value: ".", label: "dot" }, + { value: "..", label: "dotdot" }, + { value: "../workspace", label: "path" }, + ])("calls process.exit(1) when MAIN_WORKSPACE_DIR is invalid (%s)", ({ value }) => { + process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = value; - const warnSpy = jest.spyOn(console, "warn"); - delete require.cache[require.resolve("../src/index")]; - expect(() => require("../src/index")).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/deprecated AUTH_TOKEN/)); - warnSpy.mockRestore(); + expect(() => require("../src/index")).toThrow("process.exit called"); + expect(exitMock).toHaveBeenCalledWith(1); }); it("logs warning when WORKSPACE_SERVICE_ALLOW_ANONYMOUS=true", () => { process.env.WORKSPACE_SERVICE_TOKEN = ""; - process.env.AUTH_TOKEN = ""; process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = "true"; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; process.env.PORT = "0"; const warnSpy = jest.spyOn(console, "warn"); @@ -218,58 +145,30 @@ describe("src/index.js — direct require (coverage)", () => { it("logs startup information on listen", () => { process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "workspace"; process.env.PORT = "0"; const logSpy = jest.spyOn(console, "log"); delete require.cache[require.resolve("../src/index")]; expect(() => require("../src/index")).not.toThrow(); + expect(logSpy).toHaveBeenCalledWith( expect.stringMatching(/MosBot Workspace Service running on port/), ); - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Workspace root:/)); - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Exposed root:/)); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Config root:/)); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Main workspace dir:/)); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Main workspace FS root:/)); expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Health check:/)); - logSpy.mockRestore(); - }); - - it("shows 'Auth: enabled' when token is set", () => { - process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; - process.env.PORT = "0"; - const logSpy = jest.spyOn(console, "log"); - delete require.cache[require.resolve("../src/index")]; - expect(() => require("../src/index")).not.toThrow(); - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Auth: enabled/)); - logSpy.mockRestore(); - }); - - it("shows 'Auth: disabled' when ALLOW_ANONYMOUS is set", () => { - process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = "true"; - process.env.WORKSPACE_SERVICE_TOKEN = ""; - process.env.AUTH_TOKEN = ""; - process.env.WORKSPACE_ROOT = "/tmp"; - process.env.WORKSPACE_SUBDIR = ""; - process.env.PORT = "0"; - - const logSpy = jest.spyOn(console, "log"); - delete require.cache[require.resolve("../src/index")]; - expect(() => require("../src/index")).not.toThrow(); - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Auth: disabled/)); logSpy.mockRestore(); }); }); -// ── Child-process tests (for process.exit verification) ────────────────────── - describe("src/index.js — process entrypoint (child process)", () => { it("exits with code 1 when WORKSPACE_SERVICE_TOKEN is not set", async () => { const result = await spawnIndex({ WORKSPACE_SERVICE_TOKEN: "", - AUTH_TOKEN: "", WORKSPACE_SERVICE_ALLOW_ANONYMOUS: "", }); expect(result.code).toBe(1); @@ -280,10 +179,11 @@ describe("src/index.js — process entrypoint (child process)", () => { const result = await spawnIndex({ WORKSPACE_SERVICE_TOKEN: "test-token", PORT: "0", - WORKSPACE_ROOT: "/tmp", - WORKSPACE_SUBDIR: "", + CONFIG_ROOT: "/tmp/config", + MAIN_WORKSPACE_DIR: "workspace", _KILL_AFTER_MS: "500", }); + expect(result.stderr).not.toMatch(/WORKSPACE_SERVICE_TOKEN is required/); }); }); diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index b5dc4d4..d45e20b 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -5,62 +5,33 @@ const path = require("path"); const fs = require("fs").promises; const { createApp } = require("../src/app"); -/** - * These tests exercise the symlink remapping logic that handles cross-container - * absolute symlinks. The scenario mirrors the real deployment: - * - * - The openclaw container creates symlinks with absolute targets like - * /home/node/.openclaw/shared/docs - * - The workspace-service container mounts the same PVC at /workspace - * - SYMLINK_REMAP_PREFIXES=/home/node/.openclaw tells the service to - * translate those paths to /workspace/... - */ describe("Symlink remapping", () => { let tmpDir; + let wsRoot; + let configRoot; let app; - // Simulate the "foreign" prefix (as seen from the openclaw container) const FOREIGN_PREFIX = "/home/node/.openclaw"; beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-symlink-test-")); + configRoot = path.join(tmpDir, "config-root"); + wsRoot = path.join(configRoot, "workspace"); + + await fs.mkdir(path.join(wsRoot, "real"), { recursive: true }); + await fs.mkdir(configRoot, { recursive: true }); + await fs.writeFile(path.join(wsRoot, "real", "file.txt"), "real content"); + await fs.writeFile(path.join(configRoot, "openclaw.json"), "{}"); + + await fs.symlink(path.join(wsRoot, "real"), path.join(wsRoot, "link-to-real")); + + await fs.mkdir(path.join(wsRoot, "shared"), { recursive: true }); + await fs.writeFile(path.join(wsRoot, "shared", "remapped.txt"), "remapped content"); + + await fs.symlink(`${FOREIGN_PREFIX}/shared`, path.join(wsRoot, "link-to-foreign")); - // Layout: - // tmpDir/workspace/ ← EXPOSED_ROOT - // tmpDir/workspace/real/ ← real directory - // tmpDir/workspace/real/file.txt ← real file - // tmpDir/workspace/link-to-real ← symlink → tmpDir/workspace/real (reachable) - // tmpDir/workspace/link-to-foreign ← symlink → FOREIGN_PREFIX/shared (unreachable) - // tmpDir/shared/ ← what FOREIGN_PREFIX/shared remaps to - // tmpDir/shared/remapped.txt ← file reachable via remap - - await fs.mkdir(path.join(tmpDir, "workspace", "real"), { recursive: true }); - await fs.writeFile( - path.join(tmpDir, "workspace", "real", "file.txt"), - "real content", - ); - - // Reachable symlink (points within the same tmpDir tree) - await fs.symlink( - path.join(tmpDir, "workspace", "real"), - path.join(tmpDir, "workspace", "link-to-real"), - ); - - // Unreachable symlink (absolute path from "foreign" container) - // The target FOREIGN_PREFIX/shared does not exist on this machine, - // but tmpDir/shared does — that's what the remap translates it to. - await fs.mkdir(path.join(tmpDir, "shared"), { recursive: true }); - await fs.writeFile(path.join(tmpDir, "shared", "remapped.txt"), "remapped content"); - - // Create the symlink pointing to the foreign absolute path - await fs.symlink( - `${FOREIGN_PREFIX}/shared`, - path.join(tmpDir, "workspace", "link-to-foreign"), - ); - - // SYMLINK_REMAP_PREFIXES: translate FOREIGN_PREFIX → tmpDir app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [FOREIGN_PREFIX], }); @@ -70,97 +41,122 @@ describe("Symlink remapping", () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); + describe("root selection", () => { + it("routes openclaw.json to config root", () => { + const ctx = app._resolvePathContext("/openclaw.json"); + expect(ctx.rootPath).toBe(configRoot); + expect(ctx.resolvedPath).toBe(path.join(configRoot, "openclaw.json")); + }); + + it("maps /workspace/* to workspace-relative path", () => { + expect(app._getMainWorkspaceAliasPath("/workspace/real/file.txt")).toBe( + "/real/file.txt", + ); + }); + + it("routes workspace paths to workspace root", () => { + const ctx = app._resolvePathContext("/real/file.txt"); + expect(ctx.rootPath).toBe(wsRoot); + expect(ctx.resolvedPath).toBe(path.join(wsRoot, "real", "file.txt")); + }); + + it("routes /workspace/* virtual paths to workspace root without double nesting", () => { + const ctx = app._resolvePathContext("/workspace/real/file.txt"); + expect(ctx.rootPath).toBe(wsRoot); + expect(ctx.resolvedPath).toBe(path.join(wsRoot, "real", "file.txt")); + }); + }); + describe("remapSymlinkTarget", () => { it("returns null for relative symlink targets", () => { - const result = app._remapSymlinkTarget("relative/path"); + const result = app._remapSymlinkTarget("relative/path", wsRoot); expect(result).toBeNull(); }); it("returns null when target does not match any prefix", () => { - const result = app._remapSymlinkTarget("/some/other/path"); + const result = app._remapSymlinkTarget("/some/other/path", wsRoot); expect(result).toBeNull(); }); it("remaps a target that exactly matches the prefix", () => { - const result = app._remapSymlinkTarget(FOREIGN_PREFIX); - expect(result).toBe(tmpDir); + const result = app._remapSymlinkTarget(FOREIGN_PREFIX, wsRoot); + expect(result).toBe(wsRoot); }); it("remaps a target that starts with the prefix", () => { - const result = app._remapSymlinkTarget(`${FOREIGN_PREFIX}/shared`); - expect(result).toBe(path.join(tmpDir, "shared")); + const result = app._remapSymlinkTarget(`${FOREIGN_PREFIX}/shared`, wsRoot); + expect(result).toBe(path.join(wsRoot, "shared")); }); }); describe("resolveSafePath", () => { it("resolves a normal relative path", () => { const result = app._resolveSafePath("real/file.txt"); - expect(result).toBe(path.join(tmpDir, "workspace", "real", "file.txt")); + expect(result).toBe(path.join(wsRoot, "real", "file.txt")); }); - it("resolves an absolute path (leading slash stripped)", () => { + it("resolves an absolute path", () => { const result = app._resolveSafePath("/real/file.txt"); - expect(result).toBe(path.join(tmpDir, "workspace", "real", "file.txt")); + expect(result).toBe(path.join(wsRoot, "real", "file.txt")); }); it("resolves root path (empty string)", () => { const result = app._resolveSafePath(""); - expect(result).toBe(path.join(tmpDir, "workspace")); + expect(result).toBe(wsRoot); }); it("resolves root path (slash)", () => { const result = app._resolveSafePath("/"); - expect(result).toBe(path.join(tmpDir, "workspace")); + expect(result).toBe(wsRoot); }); it("normalises backslashes", () => { const result = app._resolveSafePath("real\\file.txt"); - expect(result).toBe(path.join(tmpDir, "workspace", "real", "file.txt")); + expect(result).toBe(path.join(wsRoot, "real", "file.txt")); + }); + + it("treats non-string input as root", () => { + const result = app._resolveSafePath(null); + expect(result).toBe(wsRoot); }); - it("throws on path traversal when resolved path escapes EXPOSED_ROOT", () => { - // assertWithinRoot is the defence-in-depth guard. We call it directly - // with a path that is outside EXPOSED_ROOT to exercise the throw branch. - expect(() => app._assertWithinRoot("/completely/different/path")).toThrow( + it("throws on traversal when resolved path escapes explicit root", () => { + expect(() => app._assertWithinRoot(wsRoot, "/completely/different/path")).toThrow( "Path traversal detected", ); }); - it("does not throw when resolved path equals EXPOSED_ROOT", () => { - expect(() => app._assertWithinRoot(app._exposedRoot)).not.toThrow(); + it("does not throw when resolved path equals root", () => { + expect(() => app._assertWithinRoot(wsRoot, wsRoot)).not.toThrow(); }); - it("does not throw when resolved path is inside EXPOSED_ROOT", () => { + it("does not throw when resolved path is inside root", () => { expect(() => - app._assertWithinRoot(path.join(app._exposedRoot, "subdir")), + app._assertWithinRoot(wsRoot, path.join(wsRoot, "subdir")), ).not.toThrow(); }); - - it("treats non-string input as root", () => { - const result = app._resolveSafePath(null); - expect(result).toBe(path.join(tmpDir, "workspace")); - }); }); describe("resolveWithRemap", () => { it("returns the path directly when it is reachable", async () => { - const fsPath = path.join(tmpDir, "workspace", "real", "file.txt"); - const result = await app._resolveWithRemap(fsPath); + const fsPath = path.join(wsRoot, "real", "file.txt"); + const result = await app._resolveWithRemap(fsPath, wsRoot); expect(result).toBe(fsPath); }); - it("follows a reachable symlink without remapping (fast path)", async () => { - // The symlink itself is stat-able, so the fast path returns immediately - const fsPath = path.join(tmpDir, "workspace", "link-to-real"); + it("uses default workspace root when root argument is omitted", async () => { + const fsPath = path.join(wsRoot, "real", "file.txt"); const result = await app._resolveWithRemap(fsPath); expect(result).toBe(fsPath); }); - it("walks through a reachable symlink component in the path (component-by-component)", async () => { - // Normally, fs.stat() on the FULL path succeeds even if it passes through a - // symlink (because stat follows symlinks). To exercise the component loop - // (and cover the `current = candidate; continue` branch), we force the - // initial fast-path stat() to fail once. + it("follows a reachable symlink without remapping", async () => { + const fsPath = path.join(wsRoot, "link-to-real"); + const result = await app._resolveWithRemap(fsPath, wsRoot); + expect(result).toBe(fsPath); + }); + + it("walks through a reachable symlink component in slow path", async () => { const fsModule = require("fs").promises; const originalStat = fsModule.stat; let firstCall = true; @@ -174,149 +170,82 @@ describe("Symlink remapping", () => { return originalStat(...args); }); - const fsPath = path.join(tmpDir, "workspace", "link-to-real", "file.txt"); + const fsPath = path.join(wsRoot, "link-to-real", "file.txt"); try { - const result = await app._resolveWithRemap(fsPath); + const result = await app._resolveWithRemap(fsPath, wsRoot); expect(result).toBe(fsPath); } finally { fsModule.stat = originalStat; } }); - it("resolves when input path does not start with EXPOSED_ROOT", async () => { - // fs.stat("/real/file.txt") fails, then the component loop resolves it - // relative to EXPOSED_ROOT by walking segments ["real", "file.txt"]. - const result = await app._resolveWithRemap("/real/file.txt"); - expect(result).toBe(path.join(tmpDir, "workspace", "real", "file.txt")); + it("resolves when input path does not start with root", async () => { + const result = await app._resolveWithRemap("/real/file.txt", wsRoot); + expect(result).toBe(path.join(wsRoot, "real", "file.txt")); }); - it("walks through regular directory components before hitting a remapped symlink", async () => { - // Create: workspace/subdir/deep-link → FOREIGN_PREFIX/shared (unreachable) - // Path: workspace/subdir/deep-link/remapped.txt - // Fast path stat fails; component loop walks 'subdir' (real dir → line 133) - // then 'deep-link' (symlink → remap) then appends 'remapped.txt'. - // This exercises: current = candidate (line 133) and return current (line 139) - // via the remap early-return path. - await fs.mkdir(path.join(tmpDir, "workspace", "subdir"), { - recursive: true, - }); + it("walks normal dirs before remapped symlink", async () => { + await fs.mkdir(path.join(wsRoot, "subdir"), { recursive: true }); await fs.symlink( `${FOREIGN_PREFIX}/shared`, - path.join(tmpDir, "workspace", "subdir", "deep-link"), + path.join(wsRoot, "subdir", "deep-link"), ); - const fsPath = path.join( - tmpDir, - "workspace", - "subdir", - "deep-link", - "remapped.txt", - ); - const result = await app._resolveWithRemap(fsPath); - expect(result).toBe(path.join(tmpDir, "shared", "remapped.txt")); - }); - - it("returns the final current path when loop completes without symlinks", async () => { - // Walk a path where all components are real directories/files. - // The fast path stat fails for a non-existent leaf; the loop walks - // real components (line 133) and throws ENOENT at the missing leaf (line 139 - // is NOT reached in this case — it's reached when the loop completes). - // To reach line 139 (return current), we need all segments to resolve - // without hitting a symlink. Create a real nested dir and pass its path - // after making the fast-path fail by using a path that resolveWithRemap - // receives that doesn't start with EXPOSED_ROOT (so rel = full path). - // Simplest: pass a path outside EXPOSED_ROOT that maps to a real dir - // via the loop. Actually the loop uses EXPOSED_ROOT as the base, so - // we need to construct a path where all segments are real. - // The path must fail fast-path stat. Use a path with a non-existent - // intermediate component to force the loop, but that will throw ENOENT. - // The only way to reach 'return current' is if ALL segments resolve. - // That means the full path IS reachable, which means fast-path succeeds. - // So line 139 is only reachable if the fast path fails but all components - // resolve — which can happen if the path contains a symlink that IS - // reachable (stat succeeds on the symlink component → line 114-115 runs, - // current = candidate, continue; then remaining segments are real). - // The test "walks through a reachable symlink component" covers this. - // Here we verify the ENOENT propagation from the loop for completeness. - const fsPath = path.join(tmpDir, "workspace", "subdir", "no-such-file"); - await expect(app._resolveWithRemap(fsPath)).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("remaps an unreachable absolute symlink to the correct path", async () => { - const fsPath = path.join(tmpDir, "workspace", "link-to-foreign"); - const result = await app._resolveWithRemap(fsPath); - expect(result).toBe(path.join(tmpDir, "shared")); - }); - - it("remaps and appends remaining path segments", async () => { - const fsPath = path.join(tmpDir, "workspace", "link-to-foreign", "remapped.txt"); - const result = await app._resolveWithRemap(fsPath); - expect(result).toBe(path.join(tmpDir, "shared", "remapped.txt")); + const fsPath = path.join(wsRoot, "subdir", "deep-link", "remapped.txt"); + const result = await app._resolveWithRemap(fsPath, wsRoot); + expect(result).toBe(path.join(wsRoot, "shared", "remapped.txt")); }); it("throws ENOENT for a completely missing path", async () => { - const fsPath = path.join(tmpDir, "workspace", "does-not-exist"); - await expect(app._resolveWithRemap(fsPath)).rejects.toMatchObject({ + const fsPath = path.join(wsRoot, "does-not-exist"); + await expect(app._resolveWithRemap(fsPath, wsRoot)).rejects.toMatchObject({ code: "ENOENT", }); }); - it("throws ENOENT for a broken symlink with no matching remap prefix", async () => { - // Create a symlink pointing to a foreign prefix that is NOT in the remap list - const brokenLink = path.join(tmpDir, "workspace", "broken-link"); + it("throws ENOENT for a broken symlink with no remap prefix", async () => { + const brokenLink = path.join(wsRoot, "broken-link"); try { await fs.unlink(brokenLink); } catch (_) { - // ignore if it doesn't exist + // ignore } await fs.symlink("/some/other/foreign/path", brokenLink); - const fsPath = path.join(tmpDir, "workspace", "broken-link"); - await expect(app._resolveWithRemap(fsPath)).rejects.toMatchObject({ + await expect(app._resolveWithRemap(brokenLink, wsRoot)).rejects.toMatchObject({ code: "ENOENT", }); }); }); - describe("getFileInfo with broken remapped symlink", () => { - it("warns when a symlink remaps but the remapped path also fails", async () => { - // Create a symlink that remaps to a path that doesn't exist under tmpDir - const brokenRemapLink = path.join(tmpDir, "workspace", "broken-remap-link"); + describe("getFileInfo edge branches", () => { + it("warns when remapped path is also missing", async () => { + const brokenRemapLink = path.join(wsRoot, "broken-remap-link"); try { await fs.unlink(brokenRemapLink); } catch (_) { // ignore } - // Points to FOREIGN_PREFIX/nonexistent — remap gives tmpDir/nonexistent await fs.symlink(`${FOREIGN_PREFIX}/nonexistent`, brokenRemapLink); const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); - // GET /files on this specific entry triggers getFileInfo which hits line 160 const supertest = require("supertest"); const res = await supertest(app).get("/files"); expect(res.status).toBe(200); - // The warn should have been called for the broken-remap-link entry const warnCalls = warnSpy.mock.calls.map((c) => c[0]); expect(warnCalls.some((msg) => msg.includes("broken-remap-link"))).toBe(true); warnSpy.mockRestore(); }); - }); - describe("getFileInfo symlinkTarget optional field", () => { - it("does not include symlinkTarget when readlink returns an empty string", async () => { + it("omits symlinkTarget when readlink returns empty string", async () => { const fsModule = require("fs").promises; const originalReadlink = fsModule.readlink; fsModule.readlink = jest.fn(async (...args) => { - const p = args[0]; - if (p === path.join(tmpDir, "workspace", "link-to-real")) { - return ""; - } + if (args[0] === path.join(wsRoot, "link-to-real")) return ""; return originalReadlink(...args); }); @@ -334,39 +263,29 @@ describe("Symlink remapping", () => { }); }); - describe("EXPOSED_ROOT normalization branches", () => { - it("treats workspaceSubdir='.' as exposing workspaceRoot", () => { - const appDot = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: ".", - token: undefined, - symlinkRemapPrefixes: [], - }); - expect(appDot._exposedRoot).toBe(path.resolve(tmpDir, ".")); - }); - - it("covers assertWithinRoot when EXPOSED_ROOT ends with path.sep (root '/')", () => { + describe("assertWithinRoot root-separator branch", () => { + it("covers root path with trailing separator behavior", () => { const rootApp = createApp({ - workspaceRoot: path.parse(process.cwd()).root, - workspaceSubdir: ".", + configRoot: path.parse(process.cwd()).root, + mainWorkspaceDir: "tmp", token: undefined, symlinkRemapPrefixes: [], }); - expect(() => rootApp._assertWithinRoot("/etc")).not.toThrow(); + expect(() => + rootApp._assertWithinRoot(path.parse(process.cwd()).root, "/etc"), + ).not.toThrow(); }); }); describe("listDirectory error handling", () => { it("uses recursive=false default when omitted", async () => { - const dirPath = path.join(tmpDir, "workspace", "real"); - const results = await app._listDirectory(dirPath, "/real"); + const dirPath = path.join(wsRoot, "real"); + const results = await app._listDirectory(dirPath, "/real", wsRoot); expect(Array.isArray(results)).toBe(true); expect(results.some((f) => f.name === "file.txt")).toBe(true); }); - it("skips entries that cannot be read and logs an error", async () => { - // Mock fs.lstat to throw a non-ENOENT error for one entry to exercise - // the catch block in listDirectory (line 210). + it("skips unreadable entries and logs an error", async () => { const fsModule = require("fs").promises; const originalLstat = fsModule.lstat; let callCount = 0; @@ -386,13 +305,12 @@ describe("Symlink remapping", () => { fsModule.lstat = originalLstat; errorSpy.mockRestore(); - // The request should still succeed — the bad entry is skipped expect(res.status).toBe(200); }); }); describe("GET /files with symlinks", () => { - it("lists the workspace root including symlink entries", async () => { + it("lists workspace root including symlink entries", async () => { const supertest = require("supertest"); const res = await supertest(app).get("/files"); expect(res.status).toBe(200); diff --git a/src/app.js b/src/app.js index 98db322..3324847 100644 --- a/src/app.js +++ b/src/app.js @@ -4,25 +4,24 @@ const express = require("express"); const fs = require("fs").promises; const path = require("path"); +const CONFIG_FILE_NAMES = new Set(["openclaw.json", "org-chart.json"]); +const CONFIG_PREFIXES = ["projects", "skills", "docs"]; +const WORKSPACE_AGENT_PATH_PATTERN = /^\/workspace-[^/]+(?:\/.*)?$/; + /** * Build and return an Express app configured with the given options. * - * Separating app creation from server startup makes the app fully testable - * without binding to a real port. - * * @param {object} opts - * @param {string} opts.workspaceRoot - Absolute path to the mounted workspace root - * @param {string} opts.workspaceSubdir - Subdirectory within workspaceRoot to expose + * @param {string} opts.configRoot - Absolute path to OpenClaw config root + * @param {string} opts.mainWorkspaceDir - Main workspace directory name under config root * @param {string|undefined} opts.token - Bearer token; undefined means anonymous access * @param {string[]} opts.symlinkRemapPrefixes - Absolute path prefixes to remap for symlinks */ function createApp(opts) { - const { workspaceRoot, workspaceSubdir, token, symlinkRemapPrefixes } = opts; + const { configRoot, mainWorkspaceDir, token, symlinkRemapPrefixes } = opts; - const EXPOSED_ROOT = path.resolve( - workspaceRoot, - workspaceSubdir && workspaceSubdir !== "." ? workspaceSubdir : ".", - ); + const CONFIG_ROOT = path.resolve(configRoot); + const MAIN_WORKSPACE_FS_ROOT = path.resolve(CONFIG_ROOT, mainWorkspaceDir); const app = express(); app.use(express.json({ limit: "10mb" })); @@ -49,44 +48,94 @@ function createApp(opts) { // ── Path helpers ─────────────────────────────────────────────────────────── + function normalizeRelativePath(relativePath) { + const raw = typeof relativePath === "string" ? relativePath : "/"; + const asPosix = raw.replace(/\\/g, "/"); + return path.posix.normalize(asPosix.startsWith("/") ? asPosix : `/${asPosix}`); + } + + function isConfigRootPath(normalizedPath) { + if (CONFIG_FILE_NAMES.has(normalizedPath.replace(/^\/+/, ""))) { + return true; + } + + if (WORKSPACE_AGENT_PATH_PATTERN.test(normalizedPath)) { + return true; + } + + return CONFIG_PREFIXES.some( + (prefix) => + normalizedPath === `/${prefix}` || normalizedPath.startsWith(`/${prefix}/`), + ); + } + + function selectFsRootForPath(normalizedPath) { + return isConfigRootPath(normalizedPath) ? CONFIG_ROOT : MAIN_WORKSPACE_FS_ROOT; + } + + function getMainWorkspaceAliasPath(normalizedPath) { + if (normalizedPath === "/workspace") { + return "/"; + } + + if (normalizedPath.startsWith("/workspace/")) { + return normalizedPath.substring("/workspace".length); + } + + return null; + } + /** - * Assert that `resolved` is within EXPOSED_ROOT. Throws if it escapes. + * Assert that `resolved` is within `rootPath`. Throws if it escapes. * Exported for direct unit testing of the defence-in-depth guard. */ - function assertWithinRoot(resolved) { - const rootWithSep = EXPOSED_ROOT.endsWith(path.sep) - ? EXPOSED_ROOT - : `${EXPOSED_ROOT}${path.sep}`; - if (resolved !== EXPOSED_ROOT && !resolved.startsWith(rootWithSep)) { + function assertWithinRoot(rootPath, resolved) { + const rootWithSep = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; + if (resolved !== rootPath && !resolved.startsWith(rootWithSep)) { throw new Error("Path traversal detected"); } } - function resolveSafePath(relativePath) { - const raw = typeof relativePath === "string" ? relativePath : "/"; - const asPosix = raw.replace(/\\/g, "/"); - const normalized = path.posix.normalize( - asPosix.startsWith("/") ? asPosix : `/${asPosix}`, - ); - const relWithinRoot = normalized.replace(/^\/+/, ""); + // Defence in depth: main workspace must stay under CONFIG_ROOT. + assertWithinRoot(CONFIG_ROOT, MAIN_WORKSPACE_FS_ROOT); + + function resolvePathContext(relativePath) { + const normalizedPath = normalizeRelativePath(relativePath); + const mainWorkspaceAliasPath = getMainWorkspaceAliasPath(normalizedPath); + const routedPath = mainWorkspaceAliasPath || normalizedPath; + const rootPath = + mainWorkspaceAliasPath !== null + ? MAIN_WORKSPACE_FS_ROOT + : selectFsRootForPath(routedPath); + const relWithinRoot = routedPath.replace(/^\/+/, ""); + + const resolvedPath = path.resolve(rootPath, relWithinRoot); + assertWithinRoot(rootPath, resolvedPath); + + return { + normalizedPath, + routedPath, + rootPath, + resolvedPath, + }; + } - const resolved = path.resolve(EXPOSED_ROOT, relWithinRoot); - assertWithinRoot(resolved); - return resolved; + function resolveSafePath(relativePath) { + return resolvePathContext(relativePath).resolvedPath; } - function remapSymlinkTarget(target) { + function remapSymlinkTarget(target, rootPath) { if (!target || !path.isAbsolute(target)) return null; for (const prefix of symlinkRemapPrefixes) { if (target === prefix || target.startsWith(prefix + "/")) { const relative = target.substring(prefix.length); - return path.join(workspaceRoot, relative); + return path.join(rootPath, relative); } } return null; } - async function resolveWithRemap(fsPath) { + async function resolveWithRemap(fsPath, rootPath = MAIN_WORKSPACE_FS_ROOT) { try { await fs.stat(fsPath); return fsPath; @@ -95,12 +144,12 @@ function createApp(opts) { } let rel = fsPath; - if (fsPath.startsWith(EXPOSED_ROOT)) { - rel = fsPath.substring(EXPOSED_ROOT.length); + if (fsPath.startsWith(rootPath)) { + rel = fsPath.substring(rootPath.length); } const segments = rel.split("/").filter(Boolean); - let current = EXPOSED_ROOT; + let current = rootPath; for (let i = 0; i < segments.length; i++) { const candidate = path.join(current, segments[i]); @@ -115,7 +164,7 @@ function createApp(opts) { continue; } catch (_) { const target = await fs.readlink(candidate); - const remapped = remapSymlinkTarget(target); + const remapped = remapSymlinkTarget(target, rootPath); if (remapped) { const remaining = segments.slice(i + 1).join("/"); const fullRemapped = remaining ? path.join(remapped, remaining) : remapped; @@ -137,7 +186,7 @@ function createApp(opts) { return current; } - async function getFileInfo(filePath, relativePath) { + async function getFileInfo(filePath, relativePath, rootPath) { const lstat = await fs.lstat(filePath); const isSymlink = lstat.isSymbolicLink(); @@ -150,7 +199,7 @@ function createApp(opts) { try { stats = await fs.stat(filePath); } catch (error) { - const remapped = remapSymlinkTarget(symlinkTarget); + const remapped = remapSymlinkTarget(symlinkTarget, rootPath); if (remapped) { try { stats = await fs.stat(remapped); @@ -184,7 +233,7 @@ function createApp(opts) { return fileInfo; } - async function listDirectory(dirPath, relativePath, recursive = false) { + async function listDirectory(dirPath, relativePath, rootPath, recursive = false) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const results = []; @@ -193,11 +242,16 @@ function createApp(opts) { const entryRelativePath = path.join(relativePath, entry.name); try { - const info = await getFileInfo(entryPath, entryRelativePath); + const info = await getFileInfo(entryPath, entryRelativePath, rootPath); results.push(info); if (recursive && entry.isDirectory()) { - const subResults = await listDirectory(entryPath, entryRelativePath, true); + const subResults = await listDirectory( + entryPath, + entryRelativePath, + rootPath, + true, + ); results.push(...subResults); } } catch (error) { @@ -208,40 +262,80 @@ function createApp(opts) { return results; } + async function inspectRoot(rootPath) { + try { + const stats = await fs.stat(rootPath); + return { + exists: true, + accessible: true, + modified: stats.mtime.toISOString(), + }; + } catch (error) { + return { + exists: false, + accessible: false, + modified: null, + error: error.message, + }; + } + } + // ── Routes ───────────────────────────────────────────────────────────────── app.get("/health", (req, res) => { res.json({ status: "ok", - workspace: workspaceRoot, - exposedRoot: EXPOSED_ROOT, - workspaceSubdir, + configRoot: CONFIG_ROOT, + mainWorkspaceDir, + mainWorkspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + workspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_ROOT, + // compatibility keys + workspace: MAIN_WORKSPACE_FS_ROOT, + exposedRoot: MAIN_WORKSPACE_FS_ROOT, timestamp: new Date().toISOString(), }); }); app.get("/status", optionalAuth, async (req, res) => { - try { - const stats = await fs.stat(EXPOSED_ROOT); + const mainWorkspaceState = await inspectRoot(MAIN_WORKSPACE_FS_ROOT); + const configState = await inspectRoot(CONFIG_ROOT); + + const payload = { + configRoot: CONFIG_ROOT, + mainWorkspaceDir, + mainWorkspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + workspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_ROOT, + // compatibility keys + workspace: MAIN_WORKSPACE_FS_ROOT, + exposedRoot: MAIN_WORKSPACE_FS_ROOT, + exists: mainWorkspaceState.exists, + accessible: mainWorkspaceState.accessible, + workspaceExists: mainWorkspaceState.exists, + workspaceAccessible: mainWorkspaceState.accessible, + workspaceModified: mainWorkspaceState.modified, + mainWorkspaceExists: mainWorkspaceState.exists, + mainWorkspaceAccessible: mainWorkspaceState.accessible, + mainWorkspaceModified: mainWorkspaceState.modified, + configExists: configState.exists, + configAccessible: configState.accessible, + configModified: configState.modified, + }; - res.json({ - workspace: workspaceRoot, - exposedRoot: EXPOSED_ROOT, - workspaceSubdir, - exists: true, - accessible: true, - modified: stats.mtime.toISOString(), - }); - } catch (error) { - res.status(500).json({ - workspace: workspaceRoot, - exposedRoot: EXPOSED_ROOT, - workspaceSubdir, - exists: false, - accessible: false, - error: error.message, - }); + if (!mainWorkspaceState.accessible || !configState.accessible) { + payload.error = + mainWorkspaceState.error || configState.error || "Filesystem root inaccessible"; + payload.errors = {}; + if (mainWorkspaceState.error) { + payload.errors.mainWorkspace = mainWorkspaceState.error; + payload.errors.workspace = mainWorkspaceState.error; + } + if (configState.error) payload.errors.config = configState.error; + return res.status(500).json(payload); } + + return res.json(payload); }); app.get("/files", optionalAuth, async (req, res, next) => { @@ -249,21 +343,30 @@ function createApp(opts) { const { path: relativePath = "/", recursive = "false" } = req.query; const isRecursive = recursive === "true"; - const fullPath = resolveSafePath(relativePath); - const resolvedPath = await resolveWithRemap(fullPath); + const context = resolvePathContext(relativePath); + const resolvedPath = await resolveWithRemap(context.resolvedPath, context.rootPath); const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { - const info = await getFileInfo(resolvedPath, relativePath); + const info = await getFileInfo( + resolvedPath, + context.normalizedPath, + context.rootPath, + ); return res.json({ files: [info], count: 1 }); } - const files = await listDirectory(resolvedPath, relativePath, isRecursive); + const files = await listDirectory( + resolvedPath, + context.normalizedPath, + context.rootPath, + isRecursive, + ); res.json({ files, count: files.length, - path: relativePath, + path: context.normalizedPath, recursive: isRecursive, }); } catch (error) { @@ -282,8 +385,8 @@ function createApp(opts) { return res.status(400).json({ error: "Path parameter is required" }); } - const fullPath = resolveSafePath(relativePath); - const resolvedPath = await resolveWithRemap(fullPath); + const context = resolvePathContext(relativePath); + const resolvedPath = await resolveWithRemap(context.resolvedPath, context.rootPath); const stats = await fs.stat(resolvedPath); if (stats.isDirectory()) { @@ -291,7 +394,11 @@ function createApp(opts) { } const content = await fs.readFile(resolvedPath, encoding); - const info = await getFileInfo(resolvedPath, relativePath); + const info = await getFileInfo( + resolvedPath, + context.normalizedPath, + context.rootPath, + ); res.json({ ...info, @@ -314,12 +421,16 @@ function createApp(opts) { return res.status(400).json({ error: "Path and content are required" }); } - const fullPath = resolveSafePath(relativePath); - const dirPath = path.dirname(fullPath); + const context = resolvePathContext(relativePath); + const dirPath = path.dirname(context.resolvedPath); await fs.mkdir(dirPath, { recursive: true }); - await fs.writeFile(fullPath, content, encoding); + await fs.writeFile(context.resolvedPath, content, encoding); - const info = await getFileInfo(fullPath, relativePath); + const info = await getFileInfo( + context.resolvedPath, + context.normalizedPath, + context.rootPath, + ); res.status(201).json({ ...info, @@ -338,16 +449,20 @@ function createApp(opts) { return res.status(400).json({ error: "Path and content are required" }); } - const fullPath = resolveSafePath(relativePath); + const context = resolvePathContext(relativePath); try { - await fs.access(fullPath); + await fs.access(context.resolvedPath); } catch (error) { return res.status(404).json({ error: "File not found" }); } - await fs.writeFile(fullPath, content, encoding); - const info = await getFileInfo(fullPath, relativePath); + await fs.writeFile(context.resolvedPath, content, encoding); + const info = await getFileInfo( + context.resolvedPath, + context.normalizedPath, + context.rootPath, + ); res.json({ ...info, @@ -366,13 +481,13 @@ function createApp(opts) { return res.status(400).json({ error: "Path parameter is required" }); } - const fullPath = resolveSafePath(relativePath); - const stats = await fs.stat(fullPath); + const context = resolvePathContext(relativePath); + const stats = await fs.stat(context.resolvedPath); if (stats.isDirectory()) { - await fs.rm(fullPath, { recursive: true, force: true }); + await fs.rm(context.resolvedPath, { recursive: true, force: true }); } else { - await fs.unlink(fullPath); + await fs.unlink(context.resolvedPath); } res.status(204).send(); @@ -397,11 +512,19 @@ function createApp(opts) { // Expose helpers for testing app._assertWithinRoot = assertWithinRoot; + app._normalizeRelativePath = normalizeRelativePath; + app._selectFsRootForPath = selectFsRootForPath; + app._getMainWorkspaceAliasPath = getMainWorkspaceAliasPath; + app._resolvePathContext = resolvePathContext; app._resolveSafePath = resolveSafePath; app._remapSymlinkTarget = remapSymlinkTarget; app._resolveWithRemap = resolveWithRemap; app._listDirectory = listDirectory; - app._exposedRoot = EXPOSED_ROOT; + app._workspaceFsRoot = MAIN_WORKSPACE_FS_ROOT; + app._configFsRoot = CONFIG_ROOT; + app._configRoot = CONFIG_ROOT; + app._mainWorkspaceDir = mainWorkspaceDir; + app._mainWorkspaceFsRoot = MAIN_WORKSPACE_FS_ROOT; return app; } diff --git a/src/index.js b/src/index.js index 2958752..11a7ee6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,19 +1,17 @@ "use strict"; require("dotenv").config(); +const path = require("path"); const { createApp } = require("./app"); const PORT = process.env.PORT || 8080; -const WORKSPACE_ROOT = - process.env.WORKSPACE_ROOT || process.env.WORKSPACE_PATH || "/workspace"; +const CONFIG_ROOT = process.env.CONFIG_ROOT || "/openclaw-config"; +const MAIN_WORKSPACE_DIR = (process.env.MAIN_WORKSPACE_DIR || "workspace").trim(); +const MAIN_WORKSPACE_FS_ROOT = path.resolve(CONFIG_ROOT, MAIN_WORKSPACE_DIR); -const WORKSPACE_SUBDIR = - process.env.WORKSPACE_SUBDIR === undefined ? "workspace" : process.env.WORKSPACE_SUBDIR; - -const WORKSPACE_SERVICE_TOKEN = - process.env.WORKSPACE_SERVICE_TOKEN || process.env.AUTH_TOKEN; +const WORKSPACE_SERVICE_TOKEN = process.env.WORKSPACE_SERVICE_TOKEN; const ALLOW_ANONYMOUS = process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS === "true"; @@ -24,6 +22,11 @@ const SYMLINK_REMAP_PREFIXES = ( .map((p) => p.trim()) .filter(Boolean); +function isValidMainWorkspaceDir(value) { + if (!value || value === "." || value === "..") return false; + return !value.includes("/") && !value.includes("\\"); +} + // Enforce auth required unless explicitly opted out for local dev if (!WORKSPACE_SERVICE_TOKEN && !ALLOW_ANONYMOUS) { console.error( @@ -34,30 +37,30 @@ if (!WORKSPACE_SERVICE_TOKEN && !ALLOW_ANONYMOUS) { process.exit(1); } +if (!isValidMainWorkspaceDir(MAIN_WORKSPACE_DIR)) { + console.error( + "ERROR: MAIN_WORKSPACE_DIR must be a single directory name (no slashes, '\\\\', '.' or '..').", + ); + process.exit(1); +} + const app = createApp({ - workspaceRoot: WORKSPACE_ROOT, - workspaceSubdir: WORKSPACE_SUBDIR, + configRoot: CONFIG_ROOT, + mainWorkspaceDir: MAIN_WORKSPACE_DIR, token: WORKSPACE_SERVICE_TOKEN, symlinkRemapPrefixes: SYMLINK_REMAP_PREFIXES, }); app.listen(PORT, () => { console.log(`MosBot Workspace Service running on port ${PORT}`); - console.log(`Workspace root: ${WORKSPACE_ROOT}`); - console.log(`Exposed root: ${app._exposedRoot} (subdir: ${WORKSPACE_SUBDIR || "."})`); + console.log(`Config root: ${CONFIG_ROOT}`); + console.log(`Main workspace dir: ${MAIN_WORKSPACE_DIR}`); + console.log(`Main workspace FS root: ${MAIN_WORKSPACE_FS_ROOT}`); console.log( `Auth: ${WORKSPACE_SERVICE_TOKEN ? "enabled" : "disabled (WORKSPACE_SERVICE_ALLOW_ANONYMOUS=true)"}`, ); console.log(`Health check: http://localhost:${PORT}/health`); - if (process.env.WORKSPACE_PATH && !process.env.WORKSPACE_ROOT) { - console.warn("WARNING: Using deprecated WORKSPACE_PATH — rename to WORKSPACE_ROOT"); - } - if (process.env.AUTH_TOKEN && !process.env.WORKSPACE_SERVICE_TOKEN) { - console.warn( - "WARNING: Using deprecated AUTH_TOKEN — rename to WORKSPACE_SERVICE_TOKEN", - ); - } if (ALLOW_ANONYMOUS) { console.warn( "WARNING: WORKSPACE_SERVICE_ALLOW_ANONYMOUS=true — authentication is disabled. Do not use in production.",