From 93d7c7639f52fa778d00b8f058cd2db6a5294efa Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 10:00:25 -0500 Subject: [PATCH 01/19] docs: clarify read-write mounts and WORKSPACE_SUBDIR defaults Update quick-start examples to mount OpenClaw home as read-write with WORKSPACE_SUBDIR=., and narrow :ro guidance to explicit read-only use cases so file-management features are configured correctly. --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0ec5ca9..d20d4ea 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Lightweight HTTP service that exposes OpenClaw workspace files over REST API. Th - 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. +- Mount workspace volumes as read-only (`:ro`) only when write operations are intentionally disabled. See [SECURITY.md](SECURITY.md) for the full threat model and vulnerability reporting process. @@ -39,8 +39,9 @@ services: environment: WORKSPACE_SERVICE_TOKEN: your-secure-token # required WORKSPACE_ROOT: /workspace + WORKSPACE_SUBDIR: . volumes: - - openclaw-workspace:/workspace:ro + - openclaw-home:/workspace ports: - "8080:8080" ``` @@ -52,11 +53,15 @@ 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 WORKSPACE_SUBDIR=. \ + -v /path/to/.openclaw:/workspace \ -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 and expose the mounted root with `WORKSPACE_SUBDIR=.`. + ## Environment Variables | Variable | Default | Description | From 19ac1ec39cab0e6a0b1f6bb7aaa984d1679cdab0 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 12:55:19 -0500 Subject: [PATCH 02/19] refactor: split workspace and config filesystem roots Replace the workspace root/subdir model with explicit WORKSPACE_FS_ROOT and CONFIG_FS_ROOT, route openclaw.json/org-chart.json to config root, and update health/status plus test coverage for split-root error scenarios. --- .gitignore | 1 + README.md | 33 ++-- SECURITY.md | 2 +- SETUP.md | 4 +- __tests__/auth.test.js | 20 ++- __tests__/files-api.test.js | 127 ++++++++------ __tests__/health-status.test.js | 76 ++++++--- __tests__/index.test.js | 173 ++++--------------- __tests__/symlink-remap.test.js | 292 +++++++++++--------------------- src/app.js | 232 ++++++++++++++++--------- src/index.js | 26 +-- 11 files changed, 456 insertions(+), 530 deletions(-) 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/README.md b/README.md index d20d4ea..acb71e4 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ services: image: ghcr.io/bymosbot/mosbot-workspace-service:latest environment: WORKSPACE_SERVICE_TOKEN: your-secure-token # required - WORKSPACE_ROOT: /workspace - WORKSPACE_SUBDIR: . + WORKSPACE_FS_ROOT: /workspace + CONFIG_FS_ROOT: /openclaw-config volumes: - - openclaw-home:/workspace + - /path/to/openclaw-workspace:/workspace + - /path/to/openclaw-config:/openclaw-config ports: - "8080:8080" ``` @@ -52,31 +53,37 @@ services: docker run -d \ --name mosbot-workspace \ -e WORKSPACE_SERVICE_TOKEN=your-secure-token \ - -e WORKSPACE_ROOT=/workspace \ - -e WORKSPACE_SUBDIR=. \ + -e WORKSPACE_FS_ROOT=/workspace \ + -e CONFIG_FS_ROOT=/openclaw-config \ -v /path/to/.openclaw:/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 and expose the mounted root with `WORKSPACE_SUBDIR=.`. +For full MosBot integration (agent discovery via `openclaw.json` + Projects/Skills/Docs CRUD), use +read-write mounts for both roots. ## 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) | +| `WORKSPACE_FS_ROOT` | `/workspace` | Root directory for workspace files (Projects, Skills, Docs, agent workspaces) | +| `CONFIG_FS_ROOT` | `/openclaw-config` | Root directory for config files (`openclaw.json`, `org-chart.json`) | | `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 +Legacy variables `WORKSPACE_ROOT`, `WORKSPACE_SUBDIR`, `WORKSPACE_PATH`, and `AUTH_TOKEN` are no +longer honored. + +## Migration from Previous Env Model + +- Old model: `WORKSPACE_ROOT` + `WORKSPACE_SUBDIR` +- New model: `WORKSPACE_FS_ROOT` + `CONFIG_FS_ROOT` +- Config files (`/openclaw.json`, `/org-chart.json`) always resolve under `CONFIG_FS_ROOT` +- All other file paths always resolve under `WORKSPACE_FS_ROOT` ## API Endpoints diff --git a/SECURITY.md b/SECURITY.md index 24fbfe0..83dd2a0 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 `WORKSPACE_FS_ROOT` or `CONFIG_FS_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..e74318d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -89,8 +89,10 @@ docker build -t mosbot-workspace-service:test . docker run -d \ --name mosbot-workspace-test \ -e WORKSPACE_SERVICE_TOKEN=test-token \ - -e WORKSPACE_ROOT=/workspace \ + -e WORKSPACE_FS_ROOT=/workspace \ + -e CONFIG_FS_ROOT=/openclaw-config \ -v /tmp/test-workspace:/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..d9969e5 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"); + workspaceRoot = path.join(tmpDir, "workspace-root"); + configRoot = path.join(tmpDir, "config-root"); + + await fs.mkdir(workspaceRoot, { recursive: true }); + await fs.mkdir(configRoot, { 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", + workspaceFsRoot: workspaceRoot, + configFsRoot: configRoot, 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", + workspaceFsRoot: workspaceRoot, + configFsRoot: configRoot, token: undefined, symlinkRemapPrefixes: [], }); diff --git a/__tests__/files-api.test.js b/__tests__/files-api.test.js index 966da94..594b78c 100644 --- a/__tests__/files-api.test.js +++ b/__tests__/files-api.test.js @@ -8,21 +8,28 @@ 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"); + workspaceRoot = path.join(tmpDir, "workspace-root"); + configRoot = path.join(tmpDir, "config-root"); + + await fs.mkdir(path.join(workspaceRoot, "subdir"), { recursive: true }); + await fs.mkdir(configRoot, { 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, "openclaw.json"), '{"models":[]}'); app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + workspaceFsRoot: workspaceRoot, + configFsRoot: configRoot, token: undefined, symlinkRemapPrefixes: [], }); @@ -32,28 +39,25 @@ 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("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 +73,26 @@ 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 400 when path parameter is missing", async () => { const res = await request(app).get("/files/content"); expect(res.status).toBe(400); @@ -107,10 +112,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 +122,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 +164,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 +178,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 +215,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 +254,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..285d75e 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 }); + workspaceRoot = path.join(tmpDir, "workspace-root"); + configRoot = path.join(tmpDir, "config-root"); + + await fs.mkdir(workspaceRoot, { recursive: true }); + await fs.mkdir(configRoot, { recursive: true }); app = createApp({ - workspaceRoot: tmpDir, - workspaceSubdir: "workspace", + workspaceFsRoot: workspaceRoot, + configFsRoot: configRoot, token: undefined, symlinkRemapPrefixes: [], }); @@ -33,36 +39,68 @@ 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.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({ + workspaceFsRoot: "/nonexistent/path/that/does/not/exist", + configFsRoot: configRoot, + 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.workspace).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({ + workspaceFsRoot: workspaceRoot, + configFsRoot: "/nonexistent/config/path/that/does/not/exist", 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(true); + expect(res.body.configAccessible).toBe(false); + 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..66d10c6 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,45 @@ 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.WORKSPACE_FS_ROOT = "/tmp/workspace"; + process.env.CONFIG_FS_ROOT = "/tmp/config"; 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({ + workspaceFsRoot: "/tmp/workspace", + configFsRoot: "/tmp/config", + 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.WORKSPACE_FS_ROOT = "/tmp/workspace"; + process.env.CONFIG_FS_ROOT = "/tmp/config"; 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({ + workspaceFsRoot: "/tmp/workspace", + configFsRoot: "/tmp/config", + 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"; - - 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(); }); 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 +131,29 @@ 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.WORKSPACE_FS_ROOT = "/tmp/workspace"; + process.env.CONFIG_FS_ROOT = "/tmp/config"; 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(/Workspace FS root:/)); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Config 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 +164,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: "", + WORKSPACE_FS_ROOT: "/tmp/workspace", + CONFIG_FS_ROOT: "/tmp/config", _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..d99aaba 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-")); + wsRoot = path.join(tmpDir, "workspace-root"); + configRoot = path.join(tmpDir, "config-root"); + + 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", + workspaceFsRoot: wsRoot, + configFsRoot: configRoot, token: undefined, symlinkRemapPrefixes: [FOREIGN_PREFIX], }); @@ -70,97 +41,110 @@ 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("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")); + }); + }); + 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("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("treats non-string input as root", () => { + const result = app._resolveSafePath(null); + expect(result).toBe(wsRoot); + }); + + 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 +158,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 +251,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: ".", + workspaceFsRoot: path.parse(process.cwd()).root, + configFsRoot: configRoot, 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 +293,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..c4a2620 100644 --- a/src/app.js +++ b/src/app.js @@ -4,25 +4,22 @@ const express = require("express"); const fs = require("fs").promises; const path = require("path"); +const CONFIG_FILE_NAMES = new Set(["openclaw.json", "org-chart.json"]); + /** * 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.workspaceFsRoot - Absolute path to workspace files root + * @param {string} opts.configFsRoot - Absolute path to OpenClaw config files 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 { workspaceFsRoot, configFsRoot, token, symlinkRemapPrefixes } = opts; - const EXPOSED_ROOT = path.resolve( - workspaceRoot, - workspaceSubdir && workspaceSubdir !== "." ? workspaceSubdir : ".", - ); + const WORKSPACE_FS_ROOT = path.resolve(workspaceFsRoot); + const CONFIG_FS_ROOT = path.resolve(configFsRoot); const app = express(); app.use(express.json({ limit: "10mb" })); @@ -49,44 +46,59 @@ 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 selectFsRootForPath(normalizedPath) { + const relative = normalizedPath.replace(/^\/+/, ""); + return CONFIG_FILE_NAMES.has(relative) ? CONFIG_FS_ROOT : WORKSPACE_FS_ROOT; + } + /** - * 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 resolvePathContext(relativePath) { + const normalizedPath = normalizeRelativePath(relativePath); + const rootPath = selectFsRootForPath(normalizedPath); + const relWithinRoot = normalizedPath.replace(/^\/+/, ""); + + const resolvedPath = path.resolve(rootPath, relWithinRoot); + assertWithinRoot(rootPath, resolvedPath); + + return { + normalizedPath, + rootPath, + resolvedPath, + }; + } + 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(/^\/+/, ""); - - const resolved = path.resolve(EXPOSED_ROOT, relWithinRoot); - assertWithinRoot(resolved); - return resolved; + 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 = WORKSPACE_FS_ROOT) { try { await fs.stat(fsPath); return fsPath; @@ -95,12 +107,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 +127,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 +149,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 +162,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 +196,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 +205,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 +225,68 @@ 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, + workspaceFsRoot: WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_FS_ROOT, + // compatibility keys + workspace: WORKSPACE_FS_ROOT, + exposedRoot: WORKSPACE_FS_ROOT, timestamp: new Date().toISOString(), }); }); app.get("/status", optionalAuth, async (req, res) => { - try { - const stats = await fs.stat(EXPOSED_ROOT); + const workspaceState = await inspectRoot(WORKSPACE_FS_ROOT); + const configState = await inspectRoot(CONFIG_FS_ROOT); + + const payload = { + workspaceFsRoot: WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_FS_ROOT, + // compatibility keys + workspace: WORKSPACE_FS_ROOT, + exposedRoot: WORKSPACE_FS_ROOT, + exists: workspaceState.exists, + accessible: workspaceState.accessible, + workspaceExists: workspaceState.exists, + workspaceAccessible: workspaceState.accessible, + workspaceModified: workspaceState.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 (!workspaceState.accessible || !configState.accessible) { + payload.error = + workspaceState.error || configState.error || "Filesystem root inaccessible"; + payload.errors = {}; + if (workspaceState.error) payload.errors.workspace = workspaceState.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 +294,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 +336,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 +345,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 +372,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 +400,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 +432,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 +463,15 @@ function createApp(opts) { // Expose helpers for testing app._assertWithinRoot = assertWithinRoot; + app._normalizeRelativePath = normalizeRelativePath; + app._selectFsRootForPath = selectFsRootForPath; + app._resolvePathContext = resolvePathContext; app._resolveSafePath = resolveSafePath; app._remapSymlinkTarget = remapSymlinkTarget; app._resolveWithRemap = resolveWithRemap; app._listDirectory = listDirectory; - app._exposedRoot = EXPOSED_ROOT; + app._workspaceFsRoot = WORKSPACE_FS_ROOT; + app._configFsRoot = CONFIG_FS_ROOT; return app; } diff --git a/src/index.js b/src/index.js index 2958752..24c3878 100644 --- a/src/index.js +++ b/src/index.js @@ -6,14 +6,10 @@ const { createApp } = require("./app"); const PORT = process.env.PORT || 8080; -const WORKSPACE_ROOT = - process.env.WORKSPACE_ROOT || process.env.WORKSPACE_PATH || "/workspace"; +const WORKSPACE_FS_ROOT = process.env.WORKSPACE_FS_ROOT || "/workspace"; +const CONFIG_FS_ROOT = process.env.CONFIG_FS_ROOT || "/openclaw-config"; -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"; @@ -35,29 +31,21 @@ if (!WORKSPACE_SERVICE_TOKEN && !ALLOW_ANONYMOUS) { } const app = createApp({ - workspaceRoot: WORKSPACE_ROOT, - workspaceSubdir: WORKSPACE_SUBDIR, + workspaceFsRoot: WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_FS_ROOT, 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(`Workspace FS root: ${WORKSPACE_FS_ROOT}`); + console.log(`Config FS root: ${CONFIG_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.", From 458699fec6b5edb5f961e28d6260ccef5f341256 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 16:36:49 -0500 Subject: [PATCH 03/19] feat(openclaw): enforce config-root + main-workspace-dir path law --- .env.example | 9 ++-- README.md | 44 +++++++++------- SECURITY.md | 2 +- SETUP.md | 5 +- __tests__/auth.test.js | 12 ++--- __tests__/files-api.test.js | 24 +++++++-- __tests__/health-status.test.js | 24 +++++---- __tests__/index.test.js | 38 +++++++++----- __tests__/symlink-remap.test.js | 10 ++-- src/app.js | 90 +++++++++++++++++++++++---------- src/index.js | 27 +++++++--- 11 files changed, 185 insertions(+), 100 deletions(-) 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/README.md b/README.md index acb71e4..377fbb3 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`) only when write operations are intentionally disabled. +- 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,11 +38,10 @@ services: image: ghcr.io/bymosbot/mosbot-workspace-service:latest environment: WORKSPACE_SERVICE_TOKEN: your-secure-token # required - WORKSPACE_FS_ROOT: /workspace - CONFIG_FS_ROOT: /openclaw-config + CONFIG_ROOT: /openclaw-config + MAIN_WORKSPACE_DIR: workspace volumes: - - /path/to/openclaw-workspace:/workspace - - /path/to/openclaw-config:/openclaw-config + - /path/to/.openclaw:/openclaw-config ports: - "8080:8080" ``` @@ -53,37 +52,44 @@ services: docker run -d \ --name mosbot-workspace \ -e WORKSPACE_SERVICE_TOKEN=your-secure-token \ - -e WORKSPACE_FS_ROOT=/workspace \ - -e CONFIG_FS_ROOT=/openclaw-config \ - -v /path/to/.openclaw:/workspace \ + -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 -read-write mounts for both roots. +a read-write mount for `CONFIG_ROOT`. ## Environment Variables | Variable | Default | Description | | ----------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- | | `PORT` | `8080` | HTTP server port | -| `WORKSPACE_FS_ROOT` | `/workspace` | Root directory for workspace files (Projects, Skills, Docs, agent workspaces) | -| `CONFIG_FS_ROOT` | `/openclaw-config` | Root directory for config files (`openclaw.json`, `org-chart.json`) | +| `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.** | -Legacy variables `WORKSPACE_ROOT`, `WORKSPACE_SUBDIR`, `WORKSPACE_PATH`, and `AUTH_TOKEN` are no -longer honored. +Removed and no longer honored: `WORKSPACE_FS_ROOT`, `CONFIG_FS_ROOT`, `WORKSPACE_ROOT`, +`WORKSPACE_SUBDIR`, `WORKSPACE_PATH`, `AUTH_TOKEN`. -## Migration from Previous Env Model +## Filesystem and Virtual Path Contract -- Old model: `WORKSPACE_ROOT` + `WORKSPACE_SUBDIR` -- New model: `WORKSPACE_FS_ROOT` + `CONFIG_FS_ROOT` -- Config files (`/openclaw.json`, `/org-chart.json`) always resolve under `CONFIG_FS_ROOT` -- All other file paths always resolve under `WORKSPACE_FS_ROOT` +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 paths: `/` and any other non-config-root path + +Canonical main workspace virtual path is `/` only (no `/workspace` alias). ## API Endpoints diff --git a/SECURITY.md b/SECURITY.md index 83dd2a0..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 `WORKSPACE_FS_ROOT` or `CONFIG_FS_ROOT`. 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 e74318d..1d76d99 100644 --- a/SETUP.md +++ b/SETUP.md @@ -89,9 +89,8 @@ docker build -t mosbot-workspace-service:test . docker run -d \ --name mosbot-workspace-test \ -e WORKSPACE_SERVICE_TOKEN=test-token \ - -e WORKSPACE_FS_ROOT=/workspace \ - -e CONFIG_FS_ROOT=/openclaw-config \ - -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 d9969e5..48187d3 100644 --- a/__tests__/auth.test.js +++ b/__tests__/auth.test.js @@ -15,11 +15,11 @@ describe("Authentication middleware", () => { beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-auth-test-")); - workspaceRoot = path.join(tmpDir, "workspace-root"); configRoot = path.join(tmpDir, "config-root"); + workspaceRoot = path.join(configRoot, "workspace"); - await fs.mkdir(workspaceRoot, { recursive: true }); 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"), "{}"); }); @@ -31,8 +31,8 @@ describe("Authentication middleware", () => { describe("when token is configured", () => { beforeAll(() => { app = createApp({ - workspaceFsRoot: workspaceRoot, - configFsRoot: configRoot, + configRoot, + mainWorkspaceDir: "workspace", token: TOKEN, symlinkRemapPrefixes: [], }); @@ -74,8 +74,8 @@ describe("Authentication middleware", () => { describe("when no token is configured (anonymous mode)", () => { beforeAll(() => { app = createApp({ - workspaceFsRoot: workspaceRoot, - configFsRoot: configRoot, + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); diff --git a/__tests__/files-api.test.js b/__tests__/files-api.test.js index 594b78c..75e3b4a 100644 --- a/__tests__/files-api.test.js +++ b/__tests__/files-api.test.js @@ -14,22 +14,26 @@ describe("Files API", () => { beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-files-test-")); - workspaceRoot = path.join(tmpDir, "workspace-root"); configRoot = path.join(tmpDir, "config-root"); + workspaceRoot = path.join(configRoot, "workspace"); - await fs.mkdir(path.join(workspaceRoot, "subdir"), { recursive: true }); 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(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({ - workspaceFsRoot: workspaceRoot, - configFsRoot: configRoot, + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); @@ -53,6 +57,18 @@ describe("Files API", () => { 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); diff --git a/__tests__/health-status.test.js b/__tests__/health-status.test.js index 285d75e..256fd87 100644 --- a/__tests__/health-status.test.js +++ b/__tests__/health-status.test.js @@ -14,15 +14,15 @@ describe("Health and status endpoints", () => { beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-health-test-")); - workspaceRoot = path.join(tmpDir, "workspace-root"); configRoot = path.join(tmpDir, "config-root"); + workspaceRoot = path.join(configRoot, "workspace"); - await fs.mkdir(workspaceRoot, { recursive: true }); await fs.mkdir(configRoot, { recursive: true }); + await fs.mkdir(workspaceRoot, { recursive: true }); app = createApp({ - workspaceFsRoot: workspaceRoot, - configFsRoot: configRoot, + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); @@ -41,6 +41,9 @@ describe("Health and status endpoints", () => { it("includes split-root fields", async () => { const res = await request(app).get("/health"); + 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(); @@ -59,8 +62,8 @@ describe("Health and status endpoints", () => { it("returns 500 when workspace root does not exist", async () => { const missingWorkspaceApp = createApp({ - workspaceFsRoot: "/nonexistent/path/that/does/not/exist", - configFsRoot: configRoot, + configRoot, + mainWorkspaceDir: "missing-main-workspace", token: undefined, symlinkRemapPrefixes: [], }); @@ -69,21 +72,22 @@ describe("Health and status endpoints", () => { expect(res.status).toBe(500); expect(res.body.workspaceAccessible).toBe(false); expect(res.body.configAccessible).toBe(true); - expect(res.body.errors.workspace).toBeDefined(); + expect(res.body.errors.mainWorkspace).toBeDefined(); }); it("returns 500 when config root does not exist", async () => { const missingConfigApp = createApp({ - workspaceFsRoot: workspaceRoot, - configFsRoot: "/nonexistent/config/path/that/does/not/exist", + configRoot: "/nonexistent/config/path/that/does/not/exist", + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [], }); const res = await request(missingConfigApp).get("/status"); expect(res.status).toBe(500); - expect(res.body.workspaceAccessible).toBe(true); + expect(res.body.workspaceAccessible).toBe(false); expect(res.body.configAccessible).toBe(false); + expect(res.body.errors.mainWorkspace).toBeDefined(); expect(res.body.errors.config).toBeDefined(); }); diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 66d10c6..d4601b2 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -79,8 +79,8 @@ 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_FS_ROOT = "/tmp/workspace"; - process.env.CONFIG_FS_ROOT = "/tmp/config"; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "workspace"; process.env.PORT = "0"; delete require.cache[require.resolve("../src/index")]; @@ -88,8 +88,8 @@ describe("src/index.js — direct require (coverage)", () => { expect(appModule.createApp).toHaveBeenCalledWith( expect.objectContaining({ - workspaceFsRoot: "/tmp/workspace", - configFsRoot: "/tmp/config", + configRoot: "/tmp/config", + mainWorkspaceDir: "workspace", token: "test-token", }), ); @@ -99,8 +99,8 @@ describe("src/index.js — direct require (coverage)", () => { it("starts the server when WORKSPACE_SERVICE_ALLOW_ANONYMOUS=true", () => { process.env.WORKSPACE_SERVICE_TOKEN = ""; process.env.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = "true"; - process.env.WORKSPACE_FS_ROOT = "/tmp/workspace"; - process.env.CONFIG_FS_ROOT = "/tmp/config"; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "workspace"; process.env.PORT = "0"; delete require.cache[require.resolve("../src/index")]; @@ -108,13 +108,22 @@ describe("src/index.js — direct require (coverage)", () => { expect(appModule.createApp).toHaveBeenCalledWith( expect.objectContaining({ - workspaceFsRoot: "/tmp/workspace", - configFsRoot: "/tmp/config", + configRoot: "/tmp/config", + mainWorkspaceDir: "workspace", token: "", }), ); }); + it("calls process.exit(1) when MAIN_WORKSPACE_DIR is invalid", () => { + process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "../workspace"; + + 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.WORKSPACE_SERVICE_ALLOW_ANONYMOUS = "true"; @@ -131,8 +140,8 @@ describe("src/index.js — direct require (coverage)", () => { it("logs startup information on listen", () => { process.env.WORKSPACE_SERVICE_TOKEN = "test-token"; - process.env.WORKSPACE_FS_ROOT = "/tmp/workspace"; - process.env.CONFIG_FS_ROOT = "/tmp/config"; + process.env.CONFIG_ROOT = "/tmp/config"; + process.env.MAIN_WORKSPACE_DIR = "workspace"; process.env.PORT = "0"; const logSpy = jest.spyOn(console, "log"); @@ -142,8 +151,9 @@ describe("src/index.js — direct require (coverage)", () => { expect(logSpy).toHaveBeenCalledWith( expect.stringMatching(/MosBot Workspace Service running on port/), ); - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Workspace FS root:/)); - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Config FS 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(); @@ -164,8 +174,8 @@ describe("src/index.js — process entrypoint (child process)", () => { const result = await spawnIndex({ WORKSPACE_SERVICE_TOKEN: "test-token", PORT: "0", - WORKSPACE_FS_ROOT: "/tmp/workspace", - CONFIG_FS_ROOT: "/tmp/config", + CONFIG_ROOT: "/tmp/config", + MAIN_WORKSPACE_DIR: "workspace", _KILL_AFTER_MS: "500", }); diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index d99aaba..b1f67c6 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -14,8 +14,8 @@ describe("Symlink remapping", () => { beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-symlink-test-")); - wsRoot = path.join(tmpDir, "workspace-root"); 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 }); @@ -30,8 +30,8 @@ describe("Symlink remapping", () => { await fs.symlink(`${FOREIGN_PREFIX}/shared`, path.join(wsRoot, "link-to-foreign")); app = createApp({ - workspaceFsRoot: wsRoot, - configFsRoot: configRoot, + configRoot, + mainWorkspaceDir: "workspace", token: undefined, symlinkRemapPrefixes: [FOREIGN_PREFIX], }); @@ -254,8 +254,8 @@ describe("Symlink remapping", () => { describe("assertWithinRoot root-separator branch", () => { it("covers root path with trailing separator behavior", () => { const rootApp = createApp({ - workspaceFsRoot: path.parse(process.cwd()).root, - configFsRoot: configRoot, + configRoot: path.parse(process.cwd()).root, + mainWorkspaceDir: "tmp", token: undefined, symlinkRemapPrefixes: [], }); diff --git a/src/app.js b/src/app.js index c4a2620..da2e33a 100644 --- a/src/app.js +++ b/src/app.js @@ -5,21 +5,23 @@ 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. * * @param {object} opts - * @param {string} opts.workspaceFsRoot - Absolute path to workspace files root - * @param {string} opts.configFsRoot - Absolute path to OpenClaw config files root + * @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 { workspaceFsRoot, configFsRoot, token, symlinkRemapPrefixes } = opts; + const { configRoot, mainWorkspaceDir, token, symlinkRemapPrefixes } = opts; - const WORKSPACE_FS_ROOT = path.resolve(workspaceFsRoot); - const CONFIG_FS_ROOT = path.resolve(configFsRoot); + 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" })); @@ -52,9 +54,23 @@ function createApp(opts) { 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) { - const relative = normalizedPath.replace(/^\/+/, ""); - return CONFIG_FILE_NAMES.has(relative) ? CONFIG_FS_ROOT : WORKSPACE_FS_ROOT; + return isConfigRootPath(normalizedPath) ? CONFIG_ROOT : MAIN_WORKSPACE_FS_ROOT; } /** @@ -68,6 +84,9 @@ function createApp(opts) { } } + // 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 rootPath = selectFsRootForPath(normalizedPath); @@ -98,7 +117,7 @@ function createApp(opts) { return null; } - async function resolveWithRemap(fsPath, rootPath = WORKSPACE_FS_ROOT) { + async function resolveWithRemap(fsPath, rootPath = MAIN_WORKSPACE_FS_ROOT) { try { await fs.stat(fsPath); return fsPath; @@ -248,40 +267,52 @@ function createApp(opts) { app.get("/health", (req, res) => { res.json({ status: "ok", - workspaceFsRoot: WORKSPACE_FS_ROOT, - configFsRoot: CONFIG_FS_ROOT, + configRoot: CONFIG_ROOT, + mainWorkspaceDir, + mainWorkspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + workspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_ROOT, // compatibility keys - workspace: WORKSPACE_FS_ROOT, - exposedRoot: WORKSPACE_FS_ROOT, + workspace: MAIN_WORKSPACE_FS_ROOT, + exposedRoot: MAIN_WORKSPACE_FS_ROOT, timestamp: new Date().toISOString(), }); }); app.get("/status", optionalAuth, async (req, res) => { - const workspaceState = await inspectRoot(WORKSPACE_FS_ROOT); - const configState = await inspectRoot(CONFIG_FS_ROOT); + const mainWorkspaceState = await inspectRoot(MAIN_WORKSPACE_FS_ROOT); + const configState = await inspectRoot(CONFIG_ROOT); const payload = { - workspaceFsRoot: WORKSPACE_FS_ROOT, - configFsRoot: CONFIG_FS_ROOT, + configRoot: CONFIG_ROOT, + mainWorkspaceDir, + mainWorkspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + workspaceFsRoot: MAIN_WORKSPACE_FS_ROOT, + configFsRoot: CONFIG_ROOT, // compatibility keys - workspace: WORKSPACE_FS_ROOT, - exposedRoot: WORKSPACE_FS_ROOT, - exists: workspaceState.exists, - accessible: workspaceState.accessible, - workspaceExists: workspaceState.exists, - workspaceAccessible: workspaceState.accessible, - workspaceModified: workspaceState.modified, + 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, }; - if (!workspaceState.accessible || !configState.accessible) { + if (!mainWorkspaceState.accessible || !configState.accessible) { payload.error = - workspaceState.error || configState.error || "Filesystem root inaccessible"; + mainWorkspaceState.error || configState.error || "Filesystem root inaccessible"; payload.errors = {}; - if (workspaceState.error) payload.errors.workspace = workspaceState.error; + 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); } @@ -470,8 +501,11 @@ function createApp(opts) { app._remapSymlinkTarget = remapSymlinkTarget; app._resolveWithRemap = resolveWithRemap; app._listDirectory = listDirectory; - app._workspaceFsRoot = WORKSPACE_FS_ROOT; - app._configFsRoot = CONFIG_FS_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 24c3878..11a7ee6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,15 @@ "use strict"; require("dotenv").config(); +const path = require("path"); const { createApp } = require("./app"); const PORT = process.env.PORT || 8080; -const WORKSPACE_FS_ROOT = process.env.WORKSPACE_FS_ROOT || "/workspace"; -const CONFIG_FS_ROOT = process.env.CONFIG_FS_ROOT || "/openclaw-config"; +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_SERVICE_TOKEN = process.env.WORKSPACE_SERVICE_TOKEN; @@ -20,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( @@ -30,17 +37,25 @@ 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({ - workspaceFsRoot: WORKSPACE_FS_ROOT, - configFsRoot: CONFIG_FS_ROOT, + 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 FS root: ${WORKSPACE_FS_ROOT}`); - console.log(`Config FS root: ${CONFIG_FS_ROOT}`); + 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)"}`, ); From ffc95836b35a105bdbfbc1a8f43a0e14921b8146 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 18:39:12 -0500 Subject: [PATCH 04/19] Support /workspace/* virtual mapping for main workspace - resolve /workspace and /workspace/* directly into CONFIG_ROOT/MAIN_WORKSPACE_DIR\n- strip the virtual workspace segment before filesystem resolution to prevent workspace/workspace nesting\n- add tests covering /workspace alias resolution and non-nested child lookups\n- update README to document /workspace as canonical main virtual path --- README.md | 6 +++--- __tests__/files-api.test.js | 18 ++++++++++++++++++ __tests__/symlink-remap.test.js | 6 ++++++ src/app.js | 23 +++++++++++++++++++++-- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 377fbb3..eb734cd 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,9 @@ Given `CONFIG_ROOT=/openclaw-config` and `MAIN_WORKSPACE_DIR=workspace`: Routing rules: - Config-root paths: `/openclaw.json`, `/org-chart.json`, `/projects/**`, `/skills/**`, `/docs/**`, `/workspace-/**` -- Main workspace paths: `/` and any other non-config-root path +- Main workspace canonical paths: `/workspace` and `/workspace/**` (mapped to `CONFIG_ROOT/MAIN_WORKSPACE_DIR`) -Canonical main workspace virtual path is `/` only (no `/workspace` alias). +Canonical main workspace virtual path is `/workspace`. ## API Endpoints @@ -113,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/__tests__/files-api.test.js b/__tests__/files-api.test.js index 75e3b4a..e27226b 100644 --- a/__tests__/files-api.test.js +++ b/__tests__/files-api.test.js @@ -57,6 +57,18 @@ describe("Files API", () => { expect(res.body.files.some((f) => f.name === "nested.txt")).toBe(true); }); + 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); @@ -109,6 +121,12 @@ describe("Files API", () => { 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); diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index b1f67c6..5103db8 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -53,6 +53,12 @@ describe("Symlink remapping", () => { 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", () => { diff --git a/src/app.js b/src/app.js index da2e33a..ad8ea5a 100644 --- a/src/app.js +++ b/src/app.js @@ -73,6 +73,18 @@ function createApp(opts) { 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 `rootPath`. Throws if it escapes. * Exported for direct unit testing of the defence-in-depth guard. @@ -89,14 +101,20 @@ function createApp(opts) { function resolvePathContext(relativePath) { const normalizedPath = normalizeRelativePath(relativePath); - const rootPath = selectFsRootForPath(normalizedPath); - const relWithinRoot = normalizedPath.replace(/^\/+/, ""); + 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, }; @@ -496,6 +514,7 @@ function createApp(opts) { app._assertWithinRoot = assertWithinRoot; app._normalizeRelativePath = normalizeRelativePath; app._selectFsRootForPath = selectFsRootForPath; + app._getMainWorkspaceAliasPath = getMainWorkspaceAliasPath; app._resolvePathContext = resolvePathContext; app._resolveSafePath = resolveSafePath; app._remapSymlinkTarget = remapSymlinkTarget; From b2e8cdf55bf24a519dc963b612e31f050a6baa75 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 08:12:01 -0500 Subject: [PATCH 05/19] Add Gitleaks license key --- .github/workflows/gitleaks.yml | 1 + 1 file changed, 1 insertion(+) 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 From d0301a66cbdf99b6c3843d3ef3b827a11d224f54 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 08:15:46 -0500 Subject: [PATCH 06/19] Use org secret for Gitleaks license --- .github/workflows/gitleaks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 2ab9c8a..a7d8f0b 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -27,4 +27,4 @@ jobs: uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE_KEY: A17B1B-97A03F-6EECE6-BAE41F-65FBAF-V3 + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} From ebafa6d7ad8e2baf93742efa614a99c554a93ff7 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 08:53:47 -0500 Subject: [PATCH 07/19] update changelog --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From deee36b7339ebb1af63ff6d2bf0908a139000c7e Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 09:16:07 -0500 Subject: [PATCH 08/19] Fix coverage for workspace path helpers --- __tests__/index.test.js | 9 +++++++-- __tests__/symlink-remap.test.js | 6 ++++++ src/app.js | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/__tests__/index.test.js b/__tests__/index.test.js index d4601b2..e8ddfc3 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -115,10 +115,15 @@ describe("src/index.js — direct require (coverage)", () => { ); }); - it("calls process.exit(1) when MAIN_WORKSPACE_DIR is invalid", () => { + 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 = "../workspace"; + process.env.MAIN_WORKSPACE_DIR = value; expect(() => require("../src/index")).toThrow("process.exit called"); expect(exitMock).toHaveBeenCalledWith(1); diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index 5103db8..d45e20b 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -48,6 +48,12 @@ describe("Symlink remapping", () => { 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); diff --git a/src/app.js b/src/app.js index ad8ea5a..3324847 100644 --- a/src/app.js +++ b/src/app.js @@ -79,7 +79,7 @@ function createApp(opts) { } if (normalizedPath.startsWith("/workspace/")) { - return normalizedPath.substring("/workspace".length) || "/"; + return normalizedPath.substring("/workspace".length); } return null; From a0371c01194ae4279b5959a28f40aa1b8c82bca1 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 08:53:47 -0500 Subject: [PATCH 09/19] update changelog --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From c8da6b8a66dfd0191c50bf00b8e6833f039cf87a Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 09:16:07 -0500 Subject: [PATCH 10/19] Fix coverage for workspace path helpers --- __tests__/index.test.js | 9 +++++++-- __tests__/symlink-remap.test.js | 6 ++++++ src/app.js | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/__tests__/index.test.js b/__tests__/index.test.js index d4601b2..e8ddfc3 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -115,10 +115,15 @@ describe("src/index.js — direct require (coverage)", () => { ); }); - it("calls process.exit(1) when MAIN_WORKSPACE_DIR is invalid", () => { + 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 = "../workspace"; + process.env.MAIN_WORKSPACE_DIR = value; expect(() => require("../src/index")).toThrow("process.exit called"); expect(exitMock).toHaveBeenCalledWith(1); diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index 5103db8..d45e20b 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -48,6 +48,12 @@ describe("Symlink remapping", () => { 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); diff --git a/src/app.js b/src/app.js index ad8ea5a..3324847 100644 --- a/src/app.js +++ b/src/app.js @@ -79,7 +79,7 @@ function createApp(opts) { } if (normalizedPath.startsWith("/workspace/")) { - return normalizedPath.substring("/workspace".length) || "/"; + return normalizedPath.substring("/workspace".length); } return null; From 69f4465b74531bc4715c734d23c0a773bf775cd5 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 11:24:22 -0500 Subject: [PATCH 11/19] Enforce strict split-root workspace routing --- README.md | 4 ++- __tests__/files-api.test.js | 49 ++++++++++++++++++++++++--------- __tests__/symlink-remap.test.js | 33 +++++++++++++--------- src/app.js | 28 +++---------------- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index eb734cd..4fac323 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,10 @@ Given `CONFIG_ROOT=/openclaw-config` and `MAIN_WORKSPACE_DIR=workspace`: 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`) +- Config-root paths: every other absolute path (`/*`) including: + `/openclaw.json`, `/org-chart.json`, `/projects/**`, `/skills/**`, `/docs/**`, + `/workspace-/**`, and legacy archive paths such as `/_archived_workspace_main/**` Canonical main workspace virtual path is `/workspace`. diff --git a/__tests__/files-api.test.js b/__tests__/files-api.test.js index e27226b..ac0b0c2 100644 --- a/__tests__/files-api.test.js +++ b/__tests__/files-api.test.js @@ -21,6 +21,9 @@ describe("Files API", () => { 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.mkdir(path.join(configRoot, "_archived_workspace_main"), { + recursive: true, + }); await fs.writeFile(path.join(workspaceRoot, "hello.txt"), "hello world"); await fs.writeFile( @@ -29,6 +32,10 @@ describe("Files API", () => { ); 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, "_archived_workspace_main", "archived.txt"), + "archived content", + ); await fs.writeFile(path.join(configRoot, "openclaw.json"), '{"models":[]}'); app = createApp({ @@ -44,15 +51,17 @@ describe("Files API", () => { }); describe("GET /files", () => { - it("lists root directory contents from workspace root", async () => { + it("lists root directory contents from config 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); + expect(res.body.files.some((f) => f.name === "workspace")).toBe(true); + expect(res.body.files.some((f) => f.name === "projects")).toBe(true); }); it("lists a specific workspace subdirectory", async () => { - const res = await request(app).get("/files?path=/subdir"); + 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); }); @@ -81,6 +90,12 @@ describe("Files API", () => { expect(res.body.files.some((f) => f.name === "project.txt")).toBe(true); }); + it("routes /_archived_workspace_main paths to config root", async () => { + const res = await request(app).get("/files?path=/_archived_workspace_main"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "archived.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); @@ -109,7 +124,7 @@ describe("Files API", () => { describe("GET /files/content", () => { it("returns workspace file content", async () => { - const res = await request(app).get("/files/content?path=/hello.txt"); + const res = await request(app).get("/files/content?path=/workspace/hello.txt"); expect(res.status).toBe(200); expect(res.body.content).toBe("hello world"); expect(res.body.encoding).toBe("utf8"); @@ -127,6 +142,14 @@ describe("Files API", () => { expect(res.body.content).toBe("hello world"); }); + it("returns content for archived workspace files from config root", async () => { + const res = await request(app).get( + "/files/content?path=/_archived_workspace_main/archived.txt", + ); + expect(res.status).toBe(200); + expect(res.body.content).toBe("archived content"); + }); + it("returns 400 when path parameter is missing", async () => { const res = await request(app).get("/files/content"); expect(res.status).toBe(400); @@ -134,7 +157,7 @@ describe("Files API", () => { }); it("returns 400 when path is a directory", async () => { - const res = await request(app).get("/files/content?path=/subdir"); + const res = await request(app).get("/files/content?path=/workspace/subdir"); expect(res.status).toBe(400); expect(res.body.error).toBe("Cannot read directory as file"); }); @@ -149,7 +172,7 @@ describe("Files API", () => { describe("POST /files", () => { it("creates a new workspace file and returns 201", async () => { const res = await request(app).post("/files").send({ - path: "/created.txt", + path: "/workspace/created.txt", content: "created content", }); expect(res.status).toBe(201); @@ -162,7 +185,7 @@ describe("Files API", () => { it("creates parent directories in workspace root", async () => { const res = await request(app).post("/files").send({ - path: "/deep/nested/file.txt", + path: "/workspace/deep/nested/file.txt", content: "deep content", }); expect(res.status).toBe(201); @@ -206,7 +229,7 @@ describe("Files API", () => { it("updates an existing workspace file and returns 200", async () => { const res = await request(app).put("/files").send({ - path: "/updatable.txt", + path: "/workspace/updatable.txt", content: "updated content", }); expect(res.status).toBe(200); @@ -229,7 +252,7 @@ describe("Files API", () => { it("returns 404 when file does not exist", async () => { const res = await request(app).put("/files").send({ - path: "/nonexistent.txt", + path: "/workspace/nonexistent.txt", content: "x", }); expect(res.status).toBe(404); @@ -252,7 +275,7 @@ describe("Files API", () => { describe("DELETE /files", () => { 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"); + const res = await request(app).delete("/files?path=/workspace/to-delete.txt"); expect(res.status).toBe(204); await expect( @@ -263,7 +286,7 @@ describe("Files API", () => { 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"); + const res = await request(app).delete("/files?path=/workspace/dir-to-delete"); expect(res.status).toBe(204); }); @@ -323,7 +346,7 @@ describe("Files API", () => { const boom = new Error("unexpected readFile error"); fsModule.readFile = jest.fn().mockRejectedValueOnce(boom); - const res = await request(app).get("/files/content?path=/hello.txt"); + const res = await request(app).get("/files/content?path=/workspace/hello.txt"); fsModule.readFile = originalReadFile; @@ -355,7 +378,7 @@ describe("Files API", () => { fsModule.writeFile = jest.fn().mockRejectedValueOnce(boom); const res = await request(app).put("/files").send({ - path: "/updatable.txt", + path: "/workspace/updatable.txt", content: "content", }); @@ -372,7 +395,7 @@ describe("Files API", () => { boom.code = "EACCES"; fsModule.stat = jest.fn().mockRejectedValueOnce(boom); - const res = await request(app).delete("/files?path=/hello.txt"); + const res = await request(app).delete("/files?path=/workspace/hello.txt"); fsModule.stat = originalStat; diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index d45e20b..ef33094 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -19,7 +19,9 @@ describe("Symlink remapping", () => { await fs.mkdir(path.join(wsRoot, "real"), { recursive: true }); await fs.mkdir(configRoot, { recursive: true }); + await fs.mkdir(path.join(configRoot, "real"), { recursive: true }); await fs.writeFile(path.join(wsRoot, "real", "file.txt"), "real content"); + await fs.writeFile(path.join(configRoot, "real", "file.txt"), "config-root content"); await fs.writeFile(path.join(configRoot, "openclaw.json"), "{}"); await fs.symlink(path.join(wsRoot, "real"), path.join(wsRoot, "link-to-real")); @@ -54,10 +56,10 @@ describe("Symlink remapping", () => { ); }); - it("routes workspace paths to workspace root", () => { + it("routes unprefixed paths to config root", () => { const ctx = app._resolvePathContext("/real/file.txt"); - expect(ctx.rootPath).toBe(wsRoot); - expect(ctx.resolvedPath).toBe(path.join(wsRoot, "real", "file.txt")); + expect(ctx.rootPath).toBe(configRoot); + expect(ctx.resolvedPath).toBe(path.join(configRoot, "real", "file.txt")); }); it("routes /workspace/* virtual paths to workspace root without double nesting", () => { @@ -92,32 +94,37 @@ describe("Symlink remapping", () => { describe("resolveSafePath", () => { it("resolves a normal relative path", () => { const result = app._resolveSafePath("real/file.txt"); - expect(result).toBe(path.join(wsRoot, "real", "file.txt")); + expect(result).toBe(path.join(configRoot, "real", "file.txt")); }); it("resolves an absolute path", () => { const result = app._resolveSafePath("/real/file.txt"); + expect(result).toBe(path.join(configRoot, "real", "file.txt")); + }); + + it("resolves /workspace/* aliases to the main workspace root", () => { + const result = app._resolveSafePath("/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(wsRoot); + expect(result).toBe(configRoot); }); it("resolves root path (slash)", () => { const result = app._resolveSafePath("/"); - expect(result).toBe(wsRoot); + expect(result).toBe(configRoot); }); it("normalises backslashes", () => { const result = app._resolveSafePath("real\\file.txt"); - expect(result).toBe(path.join(wsRoot, "real", "file.txt")); + expect(result).toBe(path.join(configRoot, "real", "file.txt")); }); it("treats non-string input as root", () => { const result = app._resolveSafePath(null); - expect(result).toBe(wsRoot); + expect(result).toBe(configRoot); }); it("throws on traversal when resolved path escapes explicit root", () => { @@ -232,7 +239,7 @@ describe("Symlink remapping", () => { const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); const supertest = require("supertest"); - const res = await supertest(app).get("/files"); + const res = await supertest(app).get("/files?path=/workspace"); expect(res.status).toBe(200); const warnCalls = warnSpy.mock.calls.map((c) => c[0]); @@ -251,7 +258,7 @@ describe("Symlink remapping", () => { const supertest = require("supertest"); try { - const res = await supertest(app).get("/files"); + const res = await supertest(app).get("/files?path=/workspace"); expect(res.status).toBe(200); const link = res.body.files.find((f) => f.name === "link-to-real"); expect(link).toBeTruthy(); @@ -312,7 +319,7 @@ describe("Symlink remapping", () => { describe("GET /files with symlinks", () => { it("lists workspace root including symlink entries", async () => { const supertest = require("supertest"); - const res = await supertest(app).get("/files"); + const res = await supertest(app).get("/files?path=/workspace"); expect(res.status).toBe(200); const names = res.body.files.map((f) => f.name); expect(names).toContain("real"); @@ -322,14 +329,14 @@ describe("Symlink remapping", () => { it("lists contents of a reachable symlink directory", async () => { const supertest = require("supertest"); - const res = await supertest(app).get("/files?path=/link-to-real"); + const res = await supertest(app).get("/files?path=/workspace/link-to-real"); expect(res.status).toBe(200); expect(res.body.files.some((f) => f.name === "file.txt")).toBe(true); }); it("lists contents of a remapped symlink directory", async () => { const supertest = require("supertest"); - const res = await supertest(app).get("/files?path=/link-to-foreign"); + const res = await supertest(app).get("/files?path=/workspace/link-to-foreign"); expect(res.status).toBe(200); expect(res.body.files.some((f) => f.name === "remapped.txt")).toBe(true); }); diff --git a/src/app.js b/src/app.js index 3324847..a4adea0 100644 --- a/src/app.js +++ b/src/app.js @@ -4,10 +4,6 @@ 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. * @@ -54,23 +50,10 @@ function createApp(opts) { 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; + return normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/") + ? MAIN_WORKSPACE_FS_ROOT + : CONFIG_ROOT; } function getMainWorkspaceAliasPath(normalizedPath) { @@ -103,10 +86,7 @@ function createApp(opts) { const normalizedPath = normalizeRelativePath(relativePath); const mainWorkspaceAliasPath = getMainWorkspaceAliasPath(normalizedPath); const routedPath = mainWorkspaceAliasPath || normalizedPath; - const rootPath = - mainWorkspaceAliasPath !== null - ? MAIN_WORKSPACE_FS_ROOT - : selectFsRootForPath(routedPath); + const rootPath = selectFsRootForPath(normalizedPath); const relWithinRoot = routedPath.replace(/^\/+/, ""); const resolvedPath = path.resolve(rootPath, relWithinRoot); From b1771d07d13be16f40d9231f32cccd15607408b5 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 11:46:32 -0500 Subject: [PATCH 12/19] Document strict split-root routing behavior --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eab345b..21f1aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +- Coverage for strict split-root path routing in files API and symlink remap tests ### Changed - Docker publish workflow hardened for multi-platform builds and SHA prefix handling -- Documentation clarified for read/write mounts and `WORKSPACE_SUBDIR` defaults +- Documentation clarified for read/write mounts and `MAIN_WORKSPACE_DIR` behavior +- Path routing is now strict split-root: only `/workspace` and `/workspace/**` resolve under + `MAIN_WORKSPACE_FS_ROOT`; all other absolute paths resolve under `CONFIG_ROOT` +- Legacy archived paths such as `/_archived_workspace_main/**` now resolve under `CONFIG_ROOT` ### Fixed From a54bbcd676b5a433108183e12c321c0c1682f6c6 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 12:03:20 -0500 Subject: [PATCH 13/19] Reinstate workspace path allowlist and policy errors --- CHANGELOG.md | 13 ++- README.md | 7 +- __tests__/auth.test.js | 2 +- __tests__/files-api.test.js | 144 ++++++++++++++++++++++++++------ __tests__/symlink-remap.test.js | 57 ++++++++----- src/app.js | 65 +++++++++++++- src/index.js | 1 + 7 files changed, 232 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f1aac..366b0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/workspace/*` virtual mapping for the main workspace - Claude Code configuration and project rules - Coverage for strict split-root path routing in files API and symlink remap tests +- Explicit virtual-path allowlist coverage and policy rejection assertions (`PATH_NOT_ALLOWED`) ### Changed - Docker publish workflow hardened for multi-platform builds and SHA prefix handling - Documentation clarified for read/write mounts and `MAIN_WORKSPACE_DIR` behavior -- Path routing is now strict split-root: only `/workspace` and `/workspace/**` resolve under - `MAIN_WORKSPACE_FS_ROOT`; all other absolute paths resolve under `CONFIG_ROOT` -- Legacy archived paths such as `/_archived_workspace_main/**` now resolve under `CONFIG_ROOT` +- Path routing now combines strict split-root with explicit config-root allowlist: + only `/workspace` and `/workspace/**` resolve under the main workspace root, while + config-root access is limited to `/openclaw.json`, `/agents.json`, `/projects/**`, + `/skills/**`, `/docs/**`, `/workspace-/**`, and `/_archived_workspace_main/**` +- Disallowed virtual paths now return `403 PATH_NOT_ALLOWED` across file endpoints, including `/` + +### Removed + +- `org-chart.json` from workspace-service allowlisted config paths ### Fixed diff --git a/README.md b/README.md index 4fac323..098321f 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,11 @@ Given `CONFIG_ROOT=/openclaw-config` and `MAIN_WORKSPACE_DIR=workspace`: Routing rules: - Main workspace canonical paths: `/workspace` and `/workspace/**` (mapped to `CONFIG_ROOT/MAIN_WORKSPACE_DIR`) -- Config-root paths: every other absolute path (`/*`) including: - `/openclaw.json`, `/org-chart.json`, `/projects/**`, `/skills/**`, `/docs/**`, +- Config-root allowlist: + `/openclaw.json`, `/agents.json`, `/projects/**`, `/skills/**`, `/docs/**`, `/workspace-/**`, and legacy archive paths such as `/_archived_workspace_main/**` +- All other absolute paths are denied with `403` and code `PATH_NOT_ALLOWED` +- Virtual root `/` is not allowlisted and is denied with `403 PATH_NOT_ALLOWED` Canonical main workspace virtual path is `/workspace`. @@ -120,6 +122,7 @@ Authorization: Bearer ``` List files and directories. Use `recursive=true` for recursive listing. +`path=/` (or omitted `path`) is denied with `403 PATH_NOT_ALLOWED`. ### Get File Content diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js index 48187d3..7db5bae 100644 --- a/__tests__/auth.test.js +++ b/__tests__/auth.test.js @@ -87,7 +87,7 @@ describe("Authentication middleware", () => { }); it("allows /files without any Authorization header", async () => { - const res = await request(app).get("/files"); + const res = await request(app).get("/files?path=/workspace"); expect(res.status).toBe(200); }); }); diff --git a/__tests__/files-api.test.js b/__tests__/files-api.test.js index ac0b0c2..79006a3 100644 --- a/__tests__/files-api.test.js +++ b/__tests__/files-api.test.js @@ -21,6 +21,8 @@ describe("Files API", () => { 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.mkdir(path.join(configRoot, "skills"), { recursive: true }); + await fs.mkdir(path.join(configRoot, "docs"), { recursive: true }); await fs.mkdir(path.join(configRoot, "_archived_workspace_main"), { recursive: true, }); @@ -32,11 +34,14 @@ describe("Files API", () => { ); 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, "skills", "skill.txt"), "skill"); + await fs.writeFile(path.join(configRoot, "docs", "readme.md"), "docs"); await fs.writeFile( path.join(configRoot, "_archived_workspace_main", "archived.txt"), "archived content", ); await fs.writeFile(path.join(configRoot, "openclaw.json"), '{"models":[]}'); + await fs.writeFile(path.join(configRoot, "agents.json"), '{"agents":[]}'); app = createApp({ configRoot, @@ -51,13 +56,21 @@ describe("Files API", () => { }); describe("GET /files", () => { - it("lists root directory contents from config root", async () => { + it("denies root path when path is omitted", 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); - expect(res.body.files.some((f) => f.name === "workspace")).toBe(true); - expect(res.body.files.some((f) => f.name === "projects")).toBe(true); + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: "Path not allowed", + code: "PATH_NOT_ALLOWED", + path: "/", + }); + }); + + it("denies explicit root path", async () => { + const res = await request(app).get("/files?path=/"); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/"); }); it("lists a specific workspace subdirectory", async () => { @@ -90,6 +103,18 @@ describe("Files API", () => { expect(res.body.files.some((f) => f.name === "project.txt")).toBe(true); }); + it("routes /skills paths to config root", async () => { + const res = await request(app).get("/files?path=/skills"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "skill.txt")).toBe(true); + }); + + it("routes /docs paths to config root", async () => { + const res = await request(app).get("/files?path=/docs"); + expect(res.status).toBe(200); + expect(res.body.files.some((f) => f.name === "readme.md")).toBe(true); + }); + it("routes /_archived_workspace_main paths to config root", async () => { const res = await request(app).get("/files?path=/_archived_workspace_main"); expect(res.status).toBe(200); @@ -103,22 +128,51 @@ describe("Files API", () => { expect(res.body.files[0].name).toBe("openclaw.json"); }); + it("returns agents config file info from config root", async () => { + const res = await request(app).get("/files?path=/agents.json"); + expect(res.status).toBe(200); + expect(res.body.files).toHaveLength(1); + expect(res.body.files[0].name).toBe("agents.json"); + }); + it("lists recursively when recursive=true", async () => { - const res = await request(app).get("/files?recursive=true"); + const res = await request(app).get("/files?path=/workspace&recursive=true"); expect(res.status).toBe(200); const names = res.body.files.map((f) => f.name); expect(names).toContain("nested.txt"); }); it("returns 404 for a non-existent path", async () => { - const res = await request(app).get("/files?path=/does-not-exist.txt"); + const res = await request(app).get("/files?path=/workspace/does-not-exist.txt"); expect(res.status).toBe(404); expect(res.body.error).toBe("Path not found"); }); - it("normalises traversal sequences safely within selected root", async () => { - const res = await request(app).get("/files?path=/../../../etc/passwd"); - expect(res.status).toBe(404); + it("denies non-allowlisted paths", async () => { + const res = await request(app).get("/files?path=/foo"); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/foo"); + }); + + it("rejects disallowed paths before filesystem calls", async () => { + const fsModule = require("fs").promises; + const statSpy = jest.spyOn(fsModule, "stat"); + + const res = await request(app).get("/files?path=/tmp"); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(statSpy).not.toHaveBeenCalled(); + + statSpy.mockRestore(); + }); + + it("denies traversal-style paths after normalization", async () => { + const res = await request(app).get("/files?path=/workspace/../../../etc/passwd"); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/etc/passwd"); }); }); @@ -136,6 +190,12 @@ describe("Files API", () => { expect(res.body.content).toContain("models"); }); + it("returns agents config content from config root", async () => { + const res = await request(app).get("/files/content?path=/agents.json"); + expect(res.status).toBe(200); + expect(res.body.content).toContain("agents"); + }); + 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); @@ -163,10 +223,17 @@ describe("Files API", () => { }); it("returns 404 for a non-existent file", async () => { - const res = await request(app).get("/files/content?path=/missing.txt"); + const res = await request(app).get("/files/content?path=/workspace/missing.txt"); expect(res.status).toBe(404); expect(res.body.error).toBe("File not found"); }); + + it("returns 403 for disallowed content path", async () => { + const res = await request(app).get("/files/content?path=/tmp/secret.txt"); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/tmp/secret.txt"); + }); }); describe("POST /files", () => { @@ -197,14 +264,14 @@ describe("Files API", () => { expect(actual).toBe("deep content"); }); - it("creates config file under config root", async () => { + it("creates agents config file under config root", async () => { const res = await request(app).post("/files").send({ - path: "/org-chart.json", + path: "/agents.json", content: '{"version":1}', }); expect(res.status).toBe(201); - const actual = await fs.readFile(path.join(configRoot, "org-chart.json"), "utf8"); + const actual = await fs.readFile(path.join(configRoot, "agents.json"), "utf8"); expect(actual).toContain("version"); }); @@ -219,12 +286,22 @@ describe("Files API", () => { expect(res.status).toBe(400); expect(res.body.error).toBe("Path and content are required"); }); + + it("returns 403 for disallowed create path", async () => { + const res = await request(app).post("/files").send({ + path: "/tmp/new-file.txt", + content: "blocked", + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/tmp/new-file.txt"); + }); }); describe("PUT /files", () => { beforeAll(async () => { await fs.writeFile(path.join(workspaceRoot, "updatable.txt"), "original"); - await fs.writeFile(path.join(configRoot, "org-chart.json"), '{"version":1}'); + await fs.writeFile(path.join(configRoot, "agents.json"), '{"version":1}'); }); it("updates an existing workspace file and returns 200", async () => { @@ -241,12 +318,12 @@ describe("Files API", () => { it("updates an existing config file and returns 200", async () => { const res = await request(app).put("/files").send({ - path: "/org-chart.json", + path: "/agents.json", content: '{"version":2}', }); expect(res.status).toBe(200); - const actual = await fs.readFile(path.join(configRoot, "org-chart.json"), "utf8"); + const actual = await fs.readFile(path.join(configRoot, "agents.json"), "utf8"); expect(actual).toContain('"version":2'); }); @@ -270,6 +347,16 @@ describe("Files API", () => { expect(res.status).toBe(400); expect(res.body.error).toBe("Path and content are required"); }); + + it("returns 403 for disallowed update path", async () => { + const res = await request(app).put("/files").send({ + path: "/tmp/agents.json", + content: "{}", + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/tmp/agents.json"); + }); }); describe("DELETE /files", () => { @@ -291,11 +378,11 @@ describe("Files API", () => { }); 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"); + await fs.writeFile(path.join(configRoot, "agents.json"), '{"version":2}'); + const res = await request(app).delete("/files?path=/agents.json"); expect(res.status).toBe(204); - await expect(fs.access(path.join(configRoot, "org-chart.json"))).rejects.toThrow(); + await expect(fs.access(path.join(configRoot, "agents.json"))).rejects.toThrow(); }); it("returns 400 when path parameter is missing", async () => { @@ -305,10 +392,17 @@ describe("Files API", () => { }); it("returns 404 for a non-existent path", async () => { - const res = await request(app).delete("/files?path=/missing.txt"); + const res = await request(app).delete("/files?path=/workspace/missing.txt"); expect(res.status).toBe(404); expect(res.body.error).toBe("Path not found"); }); + + it("returns 403 for disallowed delete path", async () => { + const res = await request(app).delete("/files?path=/tmp/secret.txt"); + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(res.body.path).toBe("/tmp/secret.txt"); + }); }); describe("Error handler (next(error) paths)", () => { @@ -318,7 +412,7 @@ describe("Files API", () => { const boom = new Error("unexpected readdir error"); fsModule.readdir = jest.fn().mockRejectedValueOnce(boom); - const res = await request(app).get("/files"); + const res = await request(app).get("/files?path=/workspace"); fsModule.readdir = originalReaddir; @@ -332,7 +426,7 @@ describe("Files API", () => { const boom = new Error(); fsModule.readdir = jest.fn().mockRejectedValueOnce(boom); - const res = await request(app).get("/files"); + const res = await request(app).get("/files?path=/workspace"); fsModule.readdir = originalReaddir; @@ -361,7 +455,7 @@ describe("Files API", () => { fsModule.mkdir = jest.fn().mockRejectedValueOnce(boom); const res = await request(app).post("/files").send({ - path: "/new-file.txt", + path: "/workspace/new-file.txt", content: "content", }); diff --git a/__tests__/symlink-remap.test.js b/__tests__/symlink-remap.test.js index ef33094..89ddfbb 100644 --- a/__tests__/symlink-remap.test.js +++ b/__tests__/symlink-remap.test.js @@ -56,10 +56,8 @@ describe("Symlink remapping", () => { ); }); - it("routes unprefixed paths to config root", () => { - const ctx = app._resolvePathContext("/real/file.txt"); - expect(ctx.rootPath).toBe(configRoot); - expect(ctx.resolvedPath).toBe(path.join(configRoot, "real", "file.txt")); + it("rejects unprefixed paths that are outside the allowlist", () => { + expect(() => app._resolvePathContext("/real/file.txt")).toThrow("Path not allowed"); }); it("routes /workspace/* virtual paths to workspace root without double nesting", () => { @@ -92,14 +90,12 @@ describe("Symlink remapping", () => { }); describe("resolveSafePath", () => { - it("resolves a normal relative path", () => { - const result = app._resolveSafePath("real/file.txt"); - expect(result).toBe(path.join(configRoot, "real", "file.txt")); + it("rejects disallowed relative paths", () => { + expect(() => app._resolveSafePath("real/file.txt")).toThrow("Path not allowed"); }); - it("resolves an absolute path", () => { - const result = app._resolveSafePath("/real/file.txt"); - expect(result).toBe(path.join(configRoot, "real", "file.txt")); + it("rejects disallowed absolute paths", () => { + expect(() => app._resolveSafePath("/real/file.txt")).toThrow("Path not allowed"); }); it("resolves /workspace/* aliases to the main workspace root", () => { @@ -107,24 +103,25 @@ describe("Symlink remapping", () => { expect(result).toBe(path.join(wsRoot, "real", "file.txt")); }); - it("resolves root path (empty string)", () => { - const result = app._resolveSafePath(""); - expect(result).toBe(configRoot); + it("rejects root path (empty string)", () => { + expect(() => app._resolveSafePath("")).toThrow("Path not allowed"); + }); + + it("rejects root path (slash)", () => { + expect(() => app._resolveSafePath("/")).toThrow("Path not allowed"); }); - it("resolves root path (slash)", () => { - const result = app._resolveSafePath("/"); - expect(result).toBe(configRoot); + it("normalises backslashes before allowlist checks", () => { + expect(() => app._resolveSafePath("real\\file.txt")).toThrow("Path not allowed"); }); - it("normalises backslashes", () => { - const result = app._resolveSafePath("real\\file.txt"); - expect(result).toBe(path.join(configRoot, "real", "file.txt")); + it("treats non-string input as root and rejects it", () => { + expect(() => app._resolveSafePath(null)).toThrow("Path not allowed"); }); - it("treats non-string input as root", () => { - const result = app._resolveSafePath(null); - expect(result).toBe(configRoot); + it("allows openclaw config path in config root", () => { + const result = app._resolveSafePath("/openclaw.json"); + expect(result).toBe(path.join(configRoot, "openclaw.json")); }); it("throws on traversal when resolved path escapes explicit root", () => { @@ -307,7 +304,7 @@ describe("Symlink remapping", () => { const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); const supertest = require("supertest"); - const res = await supertest(app).get("/files"); + const res = await supertest(app).get("/files?path=/workspace"); fsModule.lstat = originalLstat; errorSpy.mockRestore(); @@ -317,6 +314,20 @@ describe("Symlink remapping", () => { }); describe("GET /files with symlinks", () => { + it("rejects disallowed paths before filesystem operations", async () => { + const fsModule = require("fs").promises; + const statSpy = jest.spyOn(fsModule, "stat"); + const supertest = require("supertest"); + + const res = await supertest(app).get("/files?path=/tmp"); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("PATH_NOT_ALLOWED"); + expect(statSpy).not.toHaveBeenCalled(); + + statSpy.mockRestore(); + }); + it("lists workspace root including symlink entries", async () => { const supertest = require("supertest"); const res = await supertest(app).get("/files?path=/workspace"); diff --git a/src/app.js b/src/app.js index a4adea0..b74132a 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,16 @@ const express = require("express"); const fs = require("fs").promises; const path = require("path"); +const ALLOWED_CONFIG_FILE_NAMES = new Set(["openclaw.json", "agents.json"]); +const ALLOWED_CONFIG_PREFIXES = [ + "projects", + "skills", + "docs", + "_archived_workspace_main", +]; +const WORKSPACE_AGENT_PATH_PATTERN = /^\/workspace-[^/]+(?:\/.*)?$/; +const PATH_NOT_ALLOWED_CODE = "PATH_NOT_ALLOWED"; + /** * Build and return an Express app configured with the given options. * @@ -17,6 +27,7 @@ function createApp(opts) { const { configRoot, mainWorkspaceDir, token, symlinkRemapPrefixes } = opts; const CONFIG_ROOT = path.resolve(configRoot); + // Internal absolute root derived from public MAIN_WORKSPACE_DIR contract. const MAIN_WORKSPACE_FS_ROOT = path.resolve(CONFIG_ROOT, mainWorkspaceDir); const app = express(); @@ -50,10 +61,45 @@ function createApp(opts) { return path.posix.normalize(asPosix.startsWith("/") ? asPosix : `/${asPosix}`); } + function isMainWorkspacePath(normalizedPath) { + return normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/"); + } + + function isAllowedConfigRootPath(normalizedPath) { + if (ALLOWED_CONFIG_FILE_NAMES.has(normalizedPath.replace(/^\/+/, ""))) { + return true; + } + + if (WORKSPACE_AGENT_PATH_PATTERN.test(normalizedPath)) { + return true; + } + + return ALLOWED_CONFIG_PREFIXES.some( + (prefix) => + normalizedPath === `/${prefix}` || normalizedPath.startsWith(`/${prefix}/`), + ); + } + + function isAllowedVirtualPath(normalizedPath) { + return isMainWorkspacePath(normalizedPath) || isAllowedConfigRootPath(normalizedPath); + } + + function createPathNotAllowedError(normalizedPath) { + const error = new Error("Path not allowed"); + error.statusCode = 403; + error.code = PATH_NOT_ALLOWED_CODE; + error.normalizedPath = normalizedPath; + return error; + } + + function assertAllowedVirtualPath(normalizedPath) { + if (!isAllowedVirtualPath(normalizedPath)) { + throw createPathNotAllowedError(normalizedPath); + } + } + function selectFsRootForPath(normalizedPath) { - return normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/") - ? MAIN_WORKSPACE_FS_ROOT - : CONFIG_ROOT; + return isMainWorkspacePath(normalizedPath) ? MAIN_WORKSPACE_FS_ROOT : CONFIG_ROOT; } function getMainWorkspaceAliasPath(normalizedPath) { @@ -84,6 +130,8 @@ function createApp(opts) { function resolvePathContext(relativePath) { const normalizedPath = normalizeRelativePath(relativePath); + assertAllowedVirtualPath(normalizedPath); + const mainWorkspaceAliasPath = getMainWorkspaceAliasPath(normalizedPath); const routedPath = mainWorkspaceAliasPath || normalizedPath; const rootPath = selectFsRootForPath(normalizedPath); @@ -483,6 +531,14 @@ function createApp(opts) { // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { + if (err.code === PATH_NOT_ALLOWED_CODE) { + return res.status(403).json({ + error: err.message || "Path not allowed", + code: PATH_NOT_ALLOWED_CODE, + path: err.normalizedPath || null, + }); + } + console.error("Error:", err); res.status(500).json({ error: err.message || "Internal server error", @@ -493,6 +549,9 @@ function createApp(opts) { // Expose helpers for testing app._assertWithinRoot = assertWithinRoot; app._normalizeRelativePath = normalizeRelativePath; + app._isMainWorkspacePath = isMainWorkspacePath; + app._isAllowedConfigRootPath = isAllowedConfigRootPath; + app._isAllowedVirtualPath = isAllowedVirtualPath; app._selectFsRootForPath = selectFsRootForPath; app._getMainWorkspaceAliasPath = getMainWorkspaceAliasPath; app._resolvePathContext = resolvePathContext; diff --git a/src/index.js b/src/index.js index 11a7ee6..4bb3c14 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ const PORT = process.env.PORT || 8080; const CONFIG_ROOT = process.env.CONFIG_ROOT || "/openclaw-config"; const MAIN_WORKSPACE_DIR = (process.env.MAIN_WORKSPACE_DIR || "workspace").trim(); +// Internal absolute root derived from public MAIN_WORKSPACE_DIR. const MAIN_WORKSPACE_FS_ROOT = path.resolve(CONFIG_ROOT, MAIN_WORKSPACE_DIR); const WORKSPACE_SERVICE_TOKEN = process.env.WORKSPACE_SERVICE_TOKEN; From 0beba21da3a8d4d6ff29e5fdd1c83aef1af7556d Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 12:35:29 -0500 Subject: [PATCH 14/19] Add PATH_NOT_ALLOWED payload helper coverage tests --- __tests__/app.errors.test.js | 30 ++++++++++++++++++++++++++++++ src/app.js | 16 ++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 __tests__/app.errors.test.js diff --git a/__tests__/app.errors.test.js b/__tests__/app.errors.test.js new file mode 100644 index 0000000..7b2e7d5 --- /dev/null +++ b/__tests__/app.errors.test.js @@ -0,0 +1,30 @@ +"use strict"; + +const { buildPathNotAllowedErrorPayload } = require("../src/app"); + +describe("PATH_NOT_ALLOWED payload helper", () => { + it("uses provided message and normalizedPath", () => { + const payload = buildPathNotAllowedErrorPayload({ + message: "Custom deny message", + normalizedPath: "/tmp/secret.txt", + }); + + expect(payload).toEqual({ + error: "Custom deny message", + code: "PATH_NOT_ALLOWED", + path: "/tmp/secret.txt", + }); + }); + + it("uses fallback values when message/path are missing", () => { + const payload = buildPathNotAllowedErrorPayload({ + message: "", + }); + + expect(payload).toEqual({ + error: "Path not allowed", + code: "PATH_NOT_ALLOWED", + path: null, + }); + }); +}); diff --git a/src/app.js b/src/app.js index b74132a..c9960e2 100644 --- a/src/app.js +++ b/src/app.js @@ -14,6 +14,14 @@ const ALLOWED_CONFIG_PREFIXES = [ const WORKSPACE_AGENT_PATH_PATTERN = /^\/workspace-[^/]+(?:\/.*)?$/; const PATH_NOT_ALLOWED_CODE = "PATH_NOT_ALLOWED"; +function buildPathNotAllowedErrorPayload(err) { + return { + error: err?.message || "Path not allowed", + code: PATH_NOT_ALLOWED_CODE, + path: err?.normalizedPath || null, + }; +} + /** * Build and return an Express app configured with the given options. * @@ -532,11 +540,7 @@ function createApp(opts) { // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { if (err.code === PATH_NOT_ALLOWED_CODE) { - return res.status(403).json({ - error: err.message || "Path not allowed", - code: PATH_NOT_ALLOWED_CODE, - path: err.normalizedPath || null, - }); + return res.status(403).json(buildPathNotAllowedErrorPayload(err)); } console.error("Error:", err); @@ -568,4 +572,4 @@ function createApp(opts) { return app; } -module.exports = { createApp }; +module.exports = { createApp, buildPathNotAllowedErrorPayload }; From 3c9ccc2074f3a74e8cc5e0a477ab3c2ef1f6c2bc Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 12:49:17 -0500 Subject: [PATCH 15/19] Add docs-only symlink bootstrap endpoint --- CHANGELOG.md | 3 + README.md | 20 ++++ __tests__/symlink-bootstrap.test.js | 143 ++++++++++++++++++++++++++++ src/app.js | 103 ++++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 __tests__/symlink-bootstrap.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 366b0b8..a5fab70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Claude Code configuration and project rules - Coverage for strict split-root path routing in files API and symlink remap tests - Explicit virtual-path allowlist coverage and policy rejection assertions (`PATH_NOT_ALLOWED`) +- `POST /symlinks/ensure` endpoint to bootstrap shared docs symlink projection under + main and agent workspaces ### Changed @@ -24,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 config-root access is limited to `/openclaw.json`, `/agents.json`, `/projects/**`, `/skills/**`, `/docs/**`, `/workspace-/**`, and `/_archived_workspace_main/**` - Disallowed virtual paths now return `403 PATH_NOT_ALLOWED` across file endpoints, including `/` +- Documentation now includes shared docs symlink bootstrap behavior and conflict semantics ### Removed diff --git a/README.md b/README.md index 098321f..3166403 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,26 @@ DELETE /files?path=/path/to/file Authorization: Bearer ``` +### Ensure Shared Docs Symlinks + +```bash +POST /symlinks/ensure +Authorization: Bearer +``` + +Ensures shared docs projection under `CONFIG_ROOT`: + +- real shared directory: `CONFIG_ROOT/docs` +- workspace symlinks: + - `CONFIG_ROOT//docs` + - `CONFIG_ROOT/workspace-*/docs` (for existing workspace directories) + +Behavior: + +- idempotent when symlinks already point to `CONFIG_ROOT/docs` +- returns `409` with conflict details when a destination exists and is not the expected symlink +- never overwrites conflicting files/directories + ## Development ### Local Development diff --git a/__tests__/symlink-bootstrap.test.js b/__tests__/symlink-bootstrap.test.js new file mode 100644 index 0000000..07bdae9 --- /dev/null +++ b/__tests__/symlink-bootstrap.test.js @@ -0,0 +1,143 @@ +"use strict"; + +const request = require("supertest"); +const os = require("os"); +const path = require("path"); +const fs = require("fs").promises; +const fsPromises = require("fs").promises; +const { createApp } = require("../src/app"); + +describe("Docs symlink bootstrap", () => { + let tmpDir; + let configRoot; + let app; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-symlink-bootstrap-")); + configRoot = path.join(tmpDir, "config-root"); + + await fs.mkdir(path.join(configRoot, "workspace"), { recursive: true }); + await fs.mkdir(path.join(configRoot, "workspace-cto"), { recursive: true }); + await fs.writeFile(path.join(configRoot, "README.txt"), "ignore me"); + + app = createApp({ + configRoot, + mainWorkspaceDir: "workspace", + token: undefined, + symlinkRemapPrefixes: [], + }); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates docs root and workspace docs symlinks", async () => { + const res = await request(app).post("/symlinks/ensure"); + + expect(res.status).toBe(200); + expect(res.body.sharedDir).toBe("/docs"); + expect(res.body.scannedWorkspaces).toBe(2); + expect(res.body.created).toEqual( + expect.arrayContaining(["/workspace/docs", "/workspace-cto/docs"]), + ); + expect(res.body.conflicts).toEqual([]); + + const docsRootStats = await fs.stat(path.join(configRoot, "docs")); + expect(docsRootStats.isDirectory()).toBe(true); + + const mainDocsLstat = await fs.lstat(path.join(configRoot, "workspace", "docs")); + const agentDocsLstat = await fs.lstat(path.join(configRoot, "workspace-cto", "docs")); + expect(mainDocsLstat.isSymbolicLink()).toBe(true); + expect(agentDocsLstat.isSymbolicLink()).toBe(true); + }); + + it("is idempotent when symlinks are already correct", async () => { + const first = await request(app).post("/symlinks/ensure"); + expect(first.status).toBe(200); + + const second = await request(app).post("/symlinks/ensure"); + expect(second.status).toBe(200); + expect(second.body.created).toEqual([]); + expect(second.body.existing).toEqual( + expect.arrayContaining(["/workspace/docs", "/workspace-cto/docs"]), + ); + expect(second.body.conflicts).toEqual([]); + }); + + it("returns conflict when destination exists as non-symlink", async () => { + await fs.mkdir(path.join(configRoot, "workspace", "docs"), { recursive: true }); + + const res = await request(app).post("/symlinks/ensure"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("DOCS_SYMLINK_CONFLICT"); + expect(res.body.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/workspace/docs", + reason: "Path exists and is not a symlink", + }), + ]), + ); + expect(res.body.created).toContain("/workspace-cto/docs"); + }); + + it("returns conflict when destination symlink points to wrong target", async () => { + await fs.mkdir(path.join(configRoot, "somewhere-else"), { recursive: true }); + await fs.symlink( + path.join(configRoot, "somewhere-else"), + path.join(configRoot, "workspace", "docs"), + ); + + const res = await request(app).post("/symlinks/ensure"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("DOCS_SYMLINK_CONFLICT"); + expect(res.body.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/workspace/docs", + reason: "Symlink points to unexpected target", + }), + ]), + ); + expect(res.body.created).toContain("/workspace-cto/docs"); + }); + + it("returns 500 when listing docs symlinks hits a non-ENOENT fs error", async () => { + const originalLstat = fsPromises.lstat.bind(fsPromises); + const lstatSpy = jest.spyOn(fsPromises, "lstat"); + lstatSpy.mockImplementation(async (targetPath, ...args) => { + if (String(targetPath).endsWith(path.join("workspace", "docs"))) { + const error = new Error("Permission denied"); + error.code = "EACCES"; + throw error; + } + return originalLstat(targetPath, ...args); + }); + + const res = await request(app).post("/symlinks/ensure"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Permission denied"); + }); + + it("creates a dot-relative target when workspace dir equals docs root", async () => { + const customRoot = path.join(tmpDir, "config-root-main-docs"); + await fs.mkdir(path.join(customRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(customRoot, "README.txt"), "ignore me"); + + const customApp = createApp({ + configRoot: customRoot, + mainWorkspaceDir: "docs", + token: undefined, + symlinkRemapPrefixes: [], + }); + + const res = await request(customApp).post("/symlinks/ensure"); + expect(res.status).toBe(200); + expect(res.body.created).toContain("/docs/docs"); + + const linkTarget = await fs.readlink(path.join(customRoot, "docs", "docs")); + expect(linkTarget).toBe("."); + }); +}); diff --git a/src/app.js b/src/app.js index c9960e2..7f99f19 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ const ALLOWED_CONFIG_PREFIXES = [ ]; const WORKSPACE_AGENT_PATH_PATTERN = /^\/workspace-[^/]+(?:\/.*)?$/; const PATH_NOT_ALLOWED_CODE = "PATH_NOT_ALLOWED"; +const SHARED_DOCS_DIR = "docs"; function buildPathNotAllowedErrorPayload(err) { return { @@ -316,6 +317,87 @@ function createApp(opts) { } } + function pathExists(error) { + return error && error.code === "ENOENT"; + } + + async function listWorkspaceDirsForDocsProjection() { + const entries = await fs.readdir(CONFIG_ROOT, { withFileTypes: true }); + const dirs = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + if (entry.name === mainWorkspaceDir || entry.name.startsWith("workspace-")) { + dirs.push(path.resolve(CONFIG_ROOT, entry.name)); + } + } + + return dirs; + } + + async function ensureDocsSymlinkProjection() { + const docsRootPath = path.resolve(CONFIG_ROOT, SHARED_DOCS_DIR); + assertWithinRoot(CONFIG_ROOT, docsRootPath); + + const created = []; + const existing = []; + const conflicts = []; + + await fs.mkdir(docsRootPath, { recursive: true }); + + const workspaceDirs = await listWorkspaceDirsForDocsProjection(); + + for (const workspaceDir of workspaceDirs) { + assertWithinRoot(CONFIG_ROOT, workspaceDir); + + const linkPath = path.resolve(workspaceDir, SHARED_DOCS_DIR); + assertWithinRoot(CONFIG_ROOT, linkPath); + + try { + const lstat = await fs.lstat(linkPath); + + if (!lstat.isSymbolicLink()) { + conflicts.push({ + path: `/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`, + reason: "Path exists and is not a symlink", + }); + continue; + } + + const linkTarget = await fs.readlink(linkPath); + const resolvedLinkTarget = path.resolve(path.dirname(linkPath), linkTarget); + if (resolvedLinkTarget !== docsRootPath) { + conflicts.push({ + path: `/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`, + reason: "Symlink points to unexpected target", + symlinkTarget: linkTarget, + }); + continue; + } + + existing.push(`/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`); + } catch (error) { + if (!pathExists(error)) { + throw error; + } + + const relativeTarget = path.relative(workspaceDir, docsRootPath) || "."; + await fs.symlink(relativeTarget, linkPath); + created.push(`/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`); + } + } + + return { + sharedDir: `/${SHARED_DOCS_DIR}`, + sharedDirPath: docsRootPath, + scannedWorkspaces: workspaceDirs.length, + created, + existing, + conflicts, + }; + } + // ── Routes ───────────────────────────────────────────────────────────────── app.get("/health", (req, res) => { @@ -535,6 +617,26 @@ function createApp(opts) { } }); + app.post("/symlinks/ensure", optionalAuth, async (req, res, next) => { + try { + const result = await ensureDocsSymlinkProjection(); + if (result.conflicts.length > 0) { + return res.status(409).json({ + error: "Docs symlink projection has conflicts", + code: "DOCS_SYMLINK_CONFLICT", + ...result, + }); + } + + return res.json({ + message: "Docs symlink projection ensured", + ...result, + }); + } catch (error) { + next(error); + } + }); + // ── Error handler ────────────────────────────────────────────────────────── // eslint-disable-next-line no-unused-vars @@ -563,6 +665,7 @@ function createApp(opts) { app._remapSymlinkTarget = remapSymlinkTarget; app._resolveWithRemap = resolveWithRemap; app._listDirectory = listDirectory; + app._ensureDocsSymlinkProjection = ensureDocsSymlinkProjection; app._workspaceFsRoot = MAIN_WORKSPACE_FS_ROOT; app._configFsRoot = CONFIG_ROOT; app._configRoot = CONFIG_ROOT; From 3ba336f67ac1bbc6fd5d6b3a9c03088b89046628 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Wed, 4 Mar 2026 13:39:50 -0500 Subject: [PATCH 16/19] feat: replace symlink ensure with typed docs link resource --- CHANGELOG.md | 8 +- README.md | 54 ++++-- __tests__/links-api.test.js | 289 ++++++++++++++++++++++++++++ __tests__/symlink-bootstrap.test.js | 143 -------------- src/app.js | 246 +++++++++++++++++------ 5 files changed, 520 insertions(+), 220 deletions(-) create mode 100644 __tests__/links-api.test.js delete mode 100644 __tests__/symlink-bootstrap.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fab70..bc3a4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Claude Code configuration and project rules - Coverage for strict split-root path routing in files API and symlink remap tests - Explicit virtual-path allowlist coverage and policy rejection assertions (`PATH_NOT_ALLOWED`) -- `POST /symlinks/ensure` endpoint to bootstrap shared docs symlink projection under - main and agent workspaces +- Typed per-agent link management endpoints: + `GET /links/:type/:agentId`, `PUT /links/:type/:agentId`, + and `DELETE /links/:type/:agentId` (docs-only for now) ### Changed @@ -26,11 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 config-root access is limited to `/openclaw.json`, `/agents.json`, `/projects/**`, `/skills/**`, `/docs/**`, `/workspace-/**`, and `/_archived_workspace_main/**` - Disallowed virtual paths now return `403 PATH_NOT_ALLOWED` across file endpoints, including `/` -- Documentation now includes shared docs symlink bootstrap behavior and conflict semantics +- Docs link management is now per-agent and system-triggerable instead of bulk projection ### Removed - `org-chart.json` from workspace-service allowlisted config paths +- Legacy bulk endpoint `POST /symlinks/ensure` ### Fixed diff --git a/README.md b/README.md index 3166403..0630f6a 100644 --- a/README.md +++ b/README.md @@ -168,25 +168,57 @@ DELETE /files?path=/path/to/file Authorization: Bearer ``` -### Ensure Shared Docs Symlinks +### Get Link State ```bash -POST /symlinks/ensure +GET /links/:type/:agentId Authorization: Bearer ``` -Ensures shared docs projection under `CONFIG_ROOT`: +Returns per-agent link state for supported types. -- real shared directory: `CONFIG_ROOT/docs` -- workspace symlinks: - - `CONFIG_ROOT//docs` - - `CONFIG_ROOT/workspace-*/docs` (for existing workspace directories) +- Supported `type`: `docs` +- `agentId`: + - `main` maps to `MAIN_WORKSPACE_DIR` + - any other valid slug maps to `workspace-` +- Valid states: + - `linked` + - `missing` + - `conflict` (includes `conflict.reason`, and `conflict.symlinkTarget` when relevant) -Behavior: +### Ensure Link -- idempotent when symlinks already point to `CONFIG_ROOT/docs` -- returns `409` with conflict details when a destination exists and is not the expected symlink -- never overwrites conflicting files/directories +```bash +PUT /links/:type/:agentId +Authorization: Bearer +``` + +For `type=docs`: + +- ensures `CONFIG_ROOT/docs` exists +- ensures target workspace directory exists +- creates a managed `docs` symlink only when missing +- returns `action: "created"` or `action: "unchanged"` +- returns `409 LINK_CONFLICT` for non-managed/conflicting existing paths + +### Delete Managed Link + +```bash +DELETE /links/:type/:agentId +Authorization: Bearer +``` + +For `type=docs`: + +- removes only the managed symlink targeting `CONFIG_ROOT/docs` +- returns `action: "deleted"` or `action: "unchanged"` (when already missing) +- returns `409 LINK_CONFLICT` for non-managed/conflicting paths + +Error codes: + +- `LINK_TYPE_UNSUPPORTED` for unsupported `:type` +- `INVALID_AGENT_ID` for invalid `:agentId` +- `LINK_CONFLICT` for conflicting existing paths ## Development diff --git a/__tests__/links-api.test.js b/__tests__/links-api.test.js new file mode 100644 index 0000000..c37eb33 --- /dev/null +++ b/__tests__/links-api.test.js @@ -0,0 +1,289 @@ +"use strict"; + +const request = require("supertest"); +const os = require("os"); +const path = require("path"); +const fs = require("fs").promises; +const fsPromises = require("fs").promises; +const { createApp } = require("../src/app"); + +describe("Typed docs link API", () => { + let tmpDir; + let configRoot; + let app; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-links-api-")); + configRoot = path.join(tmpDir, "config-root"); + + await fs.mkdir(path.join(configRoot, "workspace"), { recursive: true }); + await fs.mkdir(path.join(configRoot, "workspace-cto"), { recursive: true }); + + app = createApp({ + configRoot, + mainWorkspaceDir: "workspace", + token: undefined, + symlinkRemapPrefixes: [], + }); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("GET /links/docs/main returns missing by default", async () => { + const res = await request(app).get("/links/docs/main"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "docs", + agentId: "main", + workspaceVirtualPath: "/workspace", + linkVirtualPath: "/workspace/docs", + targetVirtualPath: "/docs", + state: "missing", + }); + }); + + it("GET /links/docs/cto returns linked when a managed symlink exists", async () => { + await fs.mkdir(path.join(configRoot, "docs"), { recursive: true }); + await fs.symlink("../docs", path.join(configRoot, "workspace-cto", "docs")); + + const res = await request(app).get("/links/docs/cto"); + expect(res.status).toBe(200); + expect(res.body.state).toBe("linked"); + expect(res.body.workspaceVirtualPath).toBe("/workspace-cto"); + }); + + it("GET /links/docs/main returns conflict when link path is not a symlink", async () => { + await fs.mkdir(path.join(configRoot, "workspace", "docs"), { recursive: true }); + + const res = await request(app).get("/links/docs/main"); + expect(res.status).toBe(200); + expect(res.body.state).toBe("conflict"); + expect(res.body.conflict.reason).toBe("Path exists and is not a symlink"); + }); + + it("PUT /links/docs/main creates docs root and managed symlink", async () => { + const res = await request(app).put("/links/docs/main"); + + expect(res.status).toBe(200); + expect(res.body.action).toBe("created"); + expect(res.body.state).toBe("linked"); + + const docsStats = await fs.stat(path.join(configRoot, "docs")); + expect(docsStats.isDirectory()).toBe(true); + + const docsLinkStats = await fs.lstat(path.join(configRoot, "workspace", "docs")); + expect(docsLinkStats.isSymbolicLink()).toBe(true); + }); + + it("PUT /links/docs/cto creates a missing workspace directory before linking", async () => { + await fs.rm(path.join(configRoot, "workspace-cto"), { recursive: true, force: true }); + + const res = await request(app).put("/links/docs/cto"); + expect(res.status).toBe(200); + expect(res.body.action).toBe("created"); + expect(res.body.workspaceVirtualPath).toBe("/workspace-cto"); + + const workspaceStats = await fs.stat(path.join(configRoot, "workspace-cto")); + expect(workspaceStats.isDirectory()).toBe(true); + + const linkStats = await fs.lstat(path.join(configRoot, "workspace-cto", "docs")); + expect(linkStats.isSymbolicLink()).toBe(true); + }); + + it("PUT /links/docs/main is idempotent when link is already managed", async () => { + const first = await request(app).put("/links/docs/main"); + expect(first.status).toBe(200); + expect(first.body.action).toBe("created"); + + const second = await request(app).put("/links/docs/main"); + expect(second.status).toBe(200); + expect(second.body.action).toBe("unchanged"); + expect(second.body.state).toBe("linked"); + }); + + it("PUT /links/docs/main returns 409 for non-symlink conflicts", async () => { + await fs.mkdir(path.join(configRoot, "workspace", "docs"), { recursive: true }); + + const res = await request(app).put("/links/docs/main"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("LINK_CONFLICT"); + expect(res.body.conflict.reason).toBe("Path exists and is not a symlink"); + }); + + it("PUT /links/docs/main returns 409 for wrong symlink target conflicts", async () => { + await fs.mkdir(path.join(configRoot, "docs"), { recursive: true }); + await fs.mkdir(path.join(configRoot, "elsewhere"), { recursive: true }); + await fs.symlink("../elsewhere", path.join(configRoot, "workspace", "docs")); + + const res = await request(app).put("/links/docs/main"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("LINK_CONFLICT"); + expect(res.body.conflict.reason).toBe("Symlink points to unexpected target"); + expect(res.body.conflict.symlinkTarget).toBe("../elsewhere"); + }); + + it("PUT /links/docs/main uses dot relative target when main workspace dir is docs", async () => { + const customRoot = path.join(tmpDir, "config-main-docs"); + await fs.mkdir(path.join(customRoot, "docs"), { recursive: true }); + + const customApp = createApp({ + configRoot: customRoot, + mainWorkspaceDir: "docs", + token: undefined, + symlinkRemapPrefixes: [], + }); + + const res = await request(customApp).put("/links/docs/main"); + expect(res.status).toBe(200); + expect(res.body.action).toBe("created"); + + const symlinkTarget = await fs.readlink(path.join(customRoot, "docs", "docs")); + expect(symlinkTarget).toBe("."); + }); + + it("DELETE /links/docs/main removes a managed symlink", async () => { + await request(app).put("/links/docs/main"); + + const res = await request(app).delete("/links/docs/main"); + expect(res.status).toBe(200); + expect(res.body.action).toBe("deleted"); + expect(res.body.state).toBe("missing"); + + await expect( + fs.lstat(path.join(configRoot, "workspace", "docs")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("DELETE /links/docs/main is unchanged when link is already missing", async () => { + const res = await request(app).delete("/links/docs/main"); + expect(res.status).toBe(200); + expect(res.body.action).toBe("unchanged"); + expect(res.body.state).toBe("missing"); + }); + + it("DELETE /links/docs/main returns conflict for non-managed paths", async () => { + await fs.mkdir(path.join(configRoot, "workspace", "docs"), { recursive: true }); + + const res = await request(app).delete("/links/docs/main"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("LINK_CONFLICT"); + expect(res.body.conflict.reason).toBe("Path exists and is not a symlink"); + }); + + it("returns 400 for unsupported link types", async () => { + const res = await request(app).get("/links/projects/main"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Unsupported link type", + code: "LINK_TYPE_UNSUPPORTED", + type: "projects", + }); + }); + + it("PUT returns 400 for unsupported link types", async () => { + const res = await request(app).put("/links/projects/main"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Unsupported link type", + code: "LINK_TYPE_UNSUPPORTED", + type: "projects", + }); + }); + + it("DELETE returns 400 for unsupported link types", async () => { + const res = await request(app).delete("/links/projects/main"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Unsupported link type", + code: "LINK_TYPE_UNSUPPORTED", + type: "projects", + }); + }); + + it("returns 400 for invalid agent IDs", async () => { + const res = await request(app).get("/links/docs/Bad.Agent"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid agent ID", + code: "INVALID_AGENT_ID", + agentId: "Bad.Agent", + }); + }); + + it("PUT returns 400 for invalid agent IDs", async () => { + const res = await request(app).put("/links/docs/Bad.Agent"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid agent ID", + code: "INVALID_AGENT_ID", + agentId: "Bad.Agent", + }); + }); + + it("DELETE returns 400 for invalid agent IDs", async () => { + const res = await request(app).delete("/links/docs/Bad.Agent"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid agent ID", + code: "INVALID_AGENT_ID", + agentId: "Bad.Agent", + }); + }); + + it("returns 500 when link inspection hits a non-ENOENT fs error", async () => { + const originalLstat = fsPromises.lstat.bind(fsPromises); + jest.spyOn(fsPromises, "lstat").mockImplementation(async (targetPath, ...args) => { + if (String(targetPath).endsWith(path.join("workspace", "docs"))) { + const error = new Error("Permission denied"); + error.code = "EACCES"; + throw error; + } + return originalLstat(targetPath, ...args); + }); + + const res = await request(app).get("/links/docs/main"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Permission denied"); + }); + + it("returns 500 when PUT link creation hits a non-ENOENT fs error", async () => { + const originalMkdir = fsPromises.mkdir.bind(fsPromises); + jest.spyOn(fsPromises, "mkdir").mockImplementation(async (targetPath, ...args) => { + if (String(targetPath).endsWith(path.join("workspace"))) { + const error = new Error("mkdir failed"); + error.code = "EACCES"; + throw error; + } + return originalMkdir(targetPath, ...args); + }); + + const res = await request(app).put("/links/docs/main"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("mkdir failed"); + }); + + it("returns 500 when DELETE unlink hits a non-ENOENT fs error", async () => { + await request(app).put("/links/docs/main"); + + jest.spyOn(fsPromises, "unlink").mockRejectedValue( + Object.assign(new Error("unlink failed"), { + code: "EACCES", + }), + ); + + const res = await request(app).delete("/links/docs/main"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("unlink failed"); + }); + + it("legacy /symlinks/ensure endpoint is removed", async () => { + const res = await request(app).post("/symlinks/ensure"); + expect(res.status).toBe(404); + }); +}); diff --git a/__tests__/symlink-bootstrap.test.js b/__tests__/symlink-bootstrap.test.js deleted file mode 100644 index 07bdae9..0000000 --- a/__tests__/symlink-bootstrap.test.js +++ /dev/null @@ -1,143 +0,0 @@ -"use strict"; - -const request = require("supertest"); -const os = require("os"); -const path = require("path"); -const fs = require("fs").promises; -const fsPromises = require("fs").promises; -const { createApp } = require("../src/app"); - -describe("Docs symlink bootstrap", () => { - let tmpDir; - let configRoot; - let app; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ws-symlink-bootstrap-")); - configRoot = path.join(tmpDir, "config-root"); - - await fs.mkdir(path.join(configRoot, "workspace"), { recursive: true }); - await fs.mkdir(path.join(configRoot, "workspace-cto"), { recursive: true }); - await fs.writeFile(path.join(configRoot, "README.txt"), "ignore me"); - - app = createApp({ - configRoot, - mainWorkspaceDir: "workspace", - token: undefined, - symlinkRemapPrefixes: [], - }); - }); - - afterEach(async () => { - jest.restoreAllMocks(); - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it("creates docs root and workspace docs symlinks", async () => { - const res = await request(app).post("/symlinks/ensure"); - - expect(res.status).toBe(200); - expect(res.body.sharedDir).toBe("/docs"); - expect(res.body.scannedWorkspaces).toBe(2); - expect(res.body.created).toEqual( - expect.arrayContaining(["/workspace/docs", "/workspace-cto/docs"]), - ); - expect(res.body.conflicts).toEqual([]); - - const docsRootStats = await fs.stat(path.join(configRoot, "docs")); - expect(docsRootStats.isDirectory()).toBe(true); - - const mainDocsLstat = await fs.lstat(path.join(configRoot, "workspace", "docs")); - const agentDocsLstat = await fs.lstat(path.join(configRoot, "workspace-cto", "docs")); - expect(mainDocsLstat.isSymbolicLink()).toBe(true); - expect(agentDocsLstat.isSymbolicLink()).toBe(true); - }); - - it("is idempotent when symlinks are already correct", async () => { - const first = await request(app).post("/symlinks/ensure"); - expect(first.status).toBe(200); - - const second = await request(app).post("/symlinks/ensure"); - expect(second.status).toBe(200); - expect(second.body.created).toEqual([]); - expect(second.body.existing).toEqual( - expect.arrayContaining(["/workspace/docs", "/workspace-cto/docs"]), - ); - expect(second.body.conflicts).toEqual([]); - }); - - it("returns conflict when destination exists as non-symlink", async () => { - await fs.mkdir(path.join(configRoot, "workspace", "docs"), { recursive: true }); - - const res = await request(app).post("/symlinks/ensure"); - expect(res.status).toBe(409); - expect(res.body.code).toBe("DOCS_SYMLINK_CONFLICT"); - expect(res.body.conflicts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: "/workspace/docs", - reason: "Path exists and is not a symlink", - }), - ]), - ); - expect(res.body.created).toContain("/workspace-cto/docs"); - }); - - it("returns conflict when destination symlink points to wrong target", async () => { - await fs.mkdir(path.join(configRoot, "somewhere-else"), { recursive: true }); - await fs.symlink( - path.join(configRoot, "somewhere-else"), - path.join(configRoot, "workspace", "docs"), - ); - - const res = await request(app).post("/symlinks/ensure"); - expect(res.status).toBe(409); - expect(res.body.code).toBe("DOCS_SYMLINK_CONFLICT"); - expect(res.body.conflicts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: "/workspace/docs", - reason: "Symlink points to unexpected target", - }), - ]), - ); - expect(res.body.created).toContain("/workspace-cto/docs"); - }); - - it("returns 500 when listing docs symlinks hits a non-ENOENT fs error", async () => { - const originalLstat = fsPromises.lstat.bind(fsPromises); - const lstatSpy = jest.spyOn(fsPromises, "lstat"); - lstatSpy.mockImplementation(async (targetPath, ...args) => { - if (String(targetPath).endsWith(path.join("workspace", "docs"))) { - const error = new Error("Permission denied"); - error.code = "EACCES"; - throw error; - } - return originalLstat(targetPath, ...args); - }); - - const res = await request(app).post("/symlinks/ensure"); - expect(res.status).toBe(500); - expect(res.body.error).toBe("Permission denied"); - }); - - it("creates a dot-relative target when workspace dir equals docs root", async () => { - const customRoot = path.join(tmpDir, "config-root-main-docs"); - await fs.mkdir(path.join(customRoot, "docs"), { recursive: true }); - await fs.writeFile(path.join(customRoot, "README.txt"), "ignore me"); - - const customApp = createApp({ - configRoot: customRoot, - mainWorkspaceDir: "docs", - token: undefined, - symlinkRemapPrefixes: [], - }); - - const res = await request(customApp).post("/symlinks/ensure"); - expect(res.status).toBe(200); - expect(res.body.created).toContain("/docs/docs"); - - const linkTarget = await fs.readlink(path.join(customRoot, "docs", "docs")); - expect(linkTarget).toBe("."); - }); -}); diff --git a/src/app.js b/src/app.js index 7f99f19..a7a8438 100644 --- a/src/app.js +++ b/src/app.js @@ -14,6 +14,11 @@ const ALLOWED_CONFIG_PREFIXES = [ const WORKSPACE_AGENT_PATH_PATTERN = /^\/workspace-[^/]+(?:\/.*)?$/; const PATH_NOT_ALLOWED_CODE = "PATH_NOT_ALLOWED"; const SHARED_DOCS_DIR = "docs"; +const SUPPORTED_LINK_TYPE = "docs"; +const LINK_TYPE_UNSUPPORTED_CODE = "LINK_TYPE_UNSUPPORTED"; +const INVALID_AGENT_ID_CODE = "INVALID_AGENT_ID"; +const LINK_CONFLICT_CODE = "LINK_CONFLICT"; +const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; function buildPathNotAllowedErrorPayload(err) { return { @@ -317,84 +322,133 @@ function createApp(opts) { } } - function pathExists(error) { + function pathNotFound(error) { return error && error.code === "ENOENT"; } - async function listWorkspaceDirsForDocsProjection() { - const entries = await fs.readdir(CONFIG_ROOT, { withFileTypes: true }); - const dirs = []; + function buildUnsupportedLinkTypePayload(linkType) { + return { + error: "Unsupported link type", + code: LINK_TYPE_UNSUPPORTED_CODE, + type: linkType, + }; + } - for (const entry of entries) { - if (!entry.isDirectory()) continue; + function buildInvalidAgentIdPayload(agentId) { + return { + error: "Invalid agent ID", + code: INVALID_AGENT_ID_CODE, + agentId, + }; + } - if (entry.name === mainWorkspaceDir || entry.name.startsWith("workspace-")) { - dirs.push(path.resolve(CONFIG_ROOT, entry.name)); - } + function resolveAgentWorkspaceDirName(agentId) { + if (agentId === "main") { + return mainWorkspaceDir; } - return dirs; + if (!AGENT_ID_PATTERN.test(agentId)) { + return null; + } + + return `workspace-${agentId}`; } - async function ensureDocsSymlinkProjection() { - const docsRootPath = path.resolve(CONFIG_ROOT, SHARED_DOCS_DIR); - assertWithinRoot(CONFIG_ROOT, docsRootPath); + function resolveWorkspaceVirtualPath(agentId) { + return agentId === "main" ? "/workspace" : `/workspace-${agentId}`; + } - const created = []; - const existing = []; - const conflicts = []; + function buildDocsLinkContext(linkType, agentId) { + if (linkType !== SUPPORTED_LINK_TYPE) { + return { + ok: false, + status: 400, + payload: buildUnsupportedLinkTypePayload(linkType), + }; + } - await fs.mkdir(docsRootPath, { recursive: true }); + const workspaceDirName = resolveAgentWorkspaceDirName(agentId); + if (!workspaceDirName) { + return { + ok: false, + status: 400, + payload: buildInvalidAgentIdPayload(agentId), + }; + } - const workspaceDirs = await listWorkspaceDirsForDocsProjection(); + const workspacePath = path.resolve(CONFIG_ROOT, workspaceDirName); + const targetPath = path.resolve(CONFIG_ROOT, SHARED_DOCS_DIR); + const linkPath = path.resolve(workspacePath, SHARED_DOCS_DIR); + assertWithinRoot(CONFIG_ROOT, workspacePath); + assertWithinRoot(CONFIG_ROOT, targetPath); + assertWithinRoot(CONFIG_ROOT, linkPath); - for (const workspaceDir of workspaceDirs) { - assertWithinRoot(CONFIG_ROOT, workspaceDir); + const workspaceVirtualPath = resolveWorkspaceVirtualPath(agentId); - const linkPath = path.resolve(workspaceDir, SHARED_DOCS_DIR); - assertWithinRoot(CONFIG_ROOT, linkPath); + return { + ok: true, + linkType, + agentId, + workspacePath, + targetPath, + linkPath, + workspaceVirtualPath, + linkVirtualPath: `${workspaceVirtualPath}/${SHARED_DOCS_DIR}`, + targetVirtualPath: `/${SHARED_DOCS_DIR}`, + }; + } - try { - const lstat = await fs.lstat(linkPath); + async function inspectDocsLinkState(context) { + try { + const lstat = await fs.lstat(context.linkPath); - if (!lstat.isSymbolicLink()) { - conflicts.push({ - path: `/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`, + if (!lstat.isSymbolicLink()) { + return { + state: "conflict", + conflict: { reason: "Path exists and is not a symlink", - }); - continue; - } + }, + }; + } - const linkTarget = await fs.readlink(linkPath); - const resolvedLinkTarget = path.resolve(path.dirname(linkPath), linkTarget); - if (resolvedLinkTarget !== docsRootPath) { - conflicts.push({ - path: `/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`, + const symlinkTarget = await fs.readlink(context.linkPath); + const resolvedLinkTarget = path.resolve( + path.dirname(context.linkPath), + symlinkTarget, + ); + if (resolvedLinkTarget !== context.targetPath) { + return { + state: "conflict", + conflict: { reason: "Symlink points to unexpected target", - symlinkTarget: linkTarget, - }); - continue; - } - - existing.push(`/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`); - } catch (error) { - if (!pathExists(error)) { - throw error; - } + symlinkTarget, + }, + }; + } - const relativeTarget = path.relative(workspaceDir, docsRootPath) || "."; - await fs.symlink(relativeTarget, linkPath); - created.push(`/${path.relative(CONFIG_ROOT, linkPath).replace(/\\/g, "/")}`); + return { + state: "linked", + symlinkTarget, + }; + } catch (error) { + if (pathNotFound(error)) { + return { + state: "missing", + }; } + throw error; } + } + function buildLinkResponsePayload(context, stateResult) { return { - sharedDir: `/${SHARED_DOCS_DIR}`, - sharedDirPath: docsRootPath, - scannedWorkspaces: workspaceDirs.length, - created, - existing, - conflicts, + type: context.linkType, + agentId: context.agentId, + workspaceVirtualPath: context.workspaceVirtualPath, + linkVirtualPath: context.linkVirtualPath, + targetVirtualPath: context.targetVirtualPath, + state: stateResult.state, + ...(stateResult.conflict ? { conflict: stateResult.conflict } : {}), }; } @@ -617,20 +671,87 @@ function createApp(opts) { } }); - app.post("/symlinks/ensure", optionalAuth, async (req, res, next) => { + app.get("/links/:type/:agentId", optionalAuth, async (req, res, next) => { + try { + const contextResult = buildDocsLinkContext(req.params.type, req.params.agentId); + if (!contextResult.ok) { + return res.status(contextResult.status).json(contextResult.payload); + } + + const stateResult = await inspectDocsLinkState(contextResult); + return res.json(buildLinkResponsePayload(contextResult, stateResult)); + } catch (error) { + next(error); + } + }); + + app.put("/links/:type/:agentId", optionalAuth, async (req, res, next) => { try { - const result = await ensureDocsSymlinkProjection(); - if (result.conflicts.length > 0) { + const contextResult = buildDocsLinkContext(req.params.type, req.params.agentId); + if (!contextResult.ok) { + return res.status(contextResult.status).json(contextResult.payload); + } + + await fs.mkdir(contextResult.targetPath, { recursive: true }); + await fs.mkdir(contextResult.workspacePath, { recursive: true }); + + const stateResult = await inspectDocsLinkState(contextResult); + if (stateResult.state === "conflict") { return res.status(409).json({ - error: "Docs symlink projection has conflicts", - code: "DOCS_SYMLINK_CONFLICT", - ...result, + error: "Link conflict", + code: LINK_CONFLICT_CODE, + ...buildLinkResponsePayload(contextResult, stateResult), + }); + } + + if (stateResult.state === "linked") { + return res.json({ + action: "unchanged", + ...buildLinkResponsePayload(contextResult, stateResult), + }); + } + + const relativeTarget = + path.relative(contextResult.workspacePath, contextResult.targetPath) || "."; + await fs.symlink(relativeTarget, contextResult.linkPath); + + const createdState = await inspectDocsLinkState(contextResult); + return res.json({ + action: "created", + ...buildLinkResponsePayload(contextResult, createdState), + }); + } catch (error) { + next(error); + } + }); + + app.delete("/links/:type/:agentId", optionalAuth, async (req, res, next) => { + try { + const contextResult = buildDocsLinkContext(req.params.type, req.params.agentId); + if (!contextResult.ok) { + return res.status(contextResult.status).json(contextResult.payload); + } + + const stateResult = await inspectDocsLinkState(contextResult); + if (stateResult.state === "conflict") { + return res.status(409).json({ + error: "Link conflict", + code: LINK_CONFLICT_CODE, + ...buildLinkResponsePayload(contextResult, stateResult), + }); + } + + if (stateResult.state === "missing") { + return res.json({ + action: "unchanged", + ...buildLinkResponsePayload(contextResult, stateResult), }); } + await fs.unlink(contextResult.linkPath); return res.json({ - message: "Docs symlink projection ensured", - ...result, + action: "deleted", + ...buildLinkResponsePayload(contextResult, { state: "missing" }), }); } catch (error) { next(error); @@ -665,7 +786,6 @@ function createApp(opts) { app._remapSymlinkTarget = remapSymlinkTarget; app._resolveWithRemap = resolveWithRemap; app._listDirectory = listDirectory; - app._ensureDocsSymlinkProjection = ensureDocsSymlinkProjection; app._workspaceFsRoot = MAIN_WORKSPACE_FS_ROOT; app._configFsRoot = CONFIG_ROOT; app._configRoot = CONFIG_ROOT; From 2620f621289490da0f70114c9c3b584f6153bc62 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Thu, 5 Mar 2026 12:29:54 -0500 Subject: [PATCH 17/19] chore: update engines.node to >=25.0.0 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 +- .nvmrc | 1 + Dockerfile | 38 ++++++++++++++++++++++++++++++++------ package.json | 2 +- 4 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6c2b5..011399d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 25 cache: npm - name: Install dependencies diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..7273c0f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +25 diff --git a/Dockerfile b/Dockerfile index e5c9108..1c13055 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,49 @@ # MosBot Workspace Service - Multi-stage Docker build # Use Debian slim for better multi-platform (arm64) build compatibility under QEMU -FROM node:18-bookworm-slim AS base +FROM node:25-alpine3.22 AS base # Install security updates and dumb-init for proper signal handling -RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y --no-install-recommends dumb-init && \ - apt-get clean && rm -rf /var/lib/apt/lists/* +RUN apk update && \ + apk upgrade && \ + apk add --no-cache dumb-init && \ + rm -rf /var/cache/apk/* # App directory (node user already exists in official image) WORKDIR /app # Production dependencies stage -FROM base AS dependencies +FROM base AS dev-dependencies WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev --ignore-scripts && \ npm cache clean --force +# Development stage (for local development with hot reload) +FROM base AS development + +# Set development environment +ENV NODE_ENV=development + +WORKDIR /app + +# Copy all dependencies (including dev dependencies) +COPY --from=dev-dependencies /app/node_modules ./node_modules + +# Copy application source +COPY --chown=node:node . . + +# Switch to non-root user +USER node + +# Expose port +EXPOSE 8080 + +# Use dumb-init to handle signals properly +ENTRYPOINT ["dumb-init", "--"] + +# Start with nodemon for hot reload +CMD ["npm", "run", "start"] + # Final production stage FROM base AS production diff --git a/package.json b/package.json index 0003181..ead5e40 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "supertest": "^7.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=25.0.0" }, "lint-staged": { "*.{js,json,md}": "prettier --write" From ed20638f61a1f2ede6002a2b039e2fb0f2ff6d2a Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Thu, 5 Mar 2026 13:24:56 -0500 Subject: [PATCH 18/19] chore: change default port from 8080 to 18780 Co-Authored-By: Claude Sonnet 4.6 --- .cursor/rules/security.mdc | 2 +- .env.example | 4 ++-- Dockerfile | 6 +++--- README.md | 8 ++++---- SECURITY.md | 2 +- SETUP.md | 4 ++-- src/index.js | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.cursor/rules/security.mdc b/.cursor/rules/security.mdc index fbd735e..5332648 100644 --- a/.cursor/rules/security.mdc +++ b/.cursor/rules/security.mdc @@ -30,7 +30,7 @@ This service can read/write/delete files on the mounted workspace volume. These - Don’t widen filesystem access by default: - Avoid changing defaults that expand browsing outside `WORKSPACE_ROOT` + `WORKSPACE_SUBDIR`. - Don’t follow/resolve symlinks without the existing remap and safety checks. -- Don’t expose port `8080` to the public internet in documentation or examples. +- Don’t expose port `18780` to the public internet in documentation or examples. - Don’t add environment variables intended to be secrets to committed files with real values. ## If a secret is accidentally committed diff --git a/.env.example b/.env.example index 2e2d763..5d03193 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ # Generate with: openssl rand -hex 32 WORKSPACE_SERVICE_TOKEN= -# Optional: HTTP server port (default: 8080) -PORT=8080 +# Optional: HTTP server port (default: 18780) +PORT=18780 # Optional: Absolute OpenClaw root mount path (default: /openclaw-config) CONFIG_ROOT=/openclaw-config diff --git a/Dockerfile b/Dockerfile index 1c13055..0d47049 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ COPY --chown=node:node . . USER node # Expose port -EXPOSE 8080 +EXPOSE 18780 # Use dumb-init to handle signals properly ENTRYPOINT ["dumb-init", "--"] @@ -63,11 +63,11 @@ COPY --chown=node:node src/ ./src/ USER node # Expose port -EXPOSE 8080 +EXPOSE 18780 # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + CMD node -e "require('http').get('http://localhost:18780/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" # Use dumb-init to handle signals properly ENTRYPOINT ["dumb-init", "--"] diff --git a/README.md b/README.md index 0630f6a..6198848 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Lightweight HTTP service that exposes OpenClaw workspace files over REST API. Th > **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. +- **Never expose port 18780 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. @@ -43,7 +43,7 @@ services: volumes: - /path/to/.openclaw:/openclaw-config ports: - - "8080:8080" + - "18780:18780" ``` ### Docker Run @@ -55,7 +55,7 @@ docker run -d \ -e CONFIG_ROOT=/openclaw-config \ -e MAIN_WORKSPACE_DIR=workspace \ -v /path/to/.openclaw:/openclaw-config \ - -p 8080:8080 \ + -p 18780:18780 \ ghcr.io/bymosbot/mosbot-workspace-service:latest ``` @@ -66,7 +66,7 @@ a read-write mount for `CONFIG_ROOT`. | Variable | Default | Description | | ----------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- | -| `PORT` | `8080` | HTTP server port | +| `PORT` | `18780` | HTTP server port | | `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. | diff --git a/SECURITY.md b/SECURITY.md index 36782fa..7bd02dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,6 +38,6 @@ We aim to acknowledge reports within **48 hours** and provide a resolution timel - Always set `WORKSPACE_SERVICE_TOKEN` to a strong random value (e.g. `openssl rand -hex 32`) - Mount workspace volumes as read-only (`:ro`) when write access is not required -- Never expose port 8080 directly to the public internet — use a VPN, internal network, or Kubernetes `ClusterIP` service +- Never expose port 18780 directly to the public internet — use a VPN, internal network, or Kubernetes `ClusterIP` service - Run the container as a non-root user (the default `node` user is used in the official Docker image) - Keep the image up to date to receive security patches diff --git a/SETUP.md b/SETUP.md index 1d76d99..7f16948 100644 --- a/SETUP.md +++ b/SETUP.md @@ -92,11 +92,11 @@ docker run -d \ -e CONFIG_ROOT=/openclaw-config \ -e MAIN_WORKSPACE_DIR=workspace \ -v /tmp/test-config:/openclaw-config \ - -p 8080:8080 \ + -p 18780:18780 \ mosbot-workspace-service:test # Test health endpoint -curl http://localhost:8080/health +curl http://localhost:18780/health # Cleanup docker stop mosbot-workspace-test diff --git a/src/index.js b/src/index.js index 4bb3c14..a98a517 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,7 @@ const path = require("path"); const { createApp } = require("./app"); -const PORT = process.env.PORT || 8080; +const PORT = process.env.PORT || 18780; const CONFIG_ROOT = process.env.CONFIG_ROOT || "/openclaw-config"; const MAIN_WORKSPACE_DIR = (process.env.MAIN_WORKSPACE_DIR || "workspace").trim(); From aef372c06a168b8d4d8f0bc659dd95b585b37710 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Thu, 5 Mar 2026 13:25:38 -0500 Subject: [PATCH 19/19] chore: add port change to changelog Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3a4e7..6bfe13b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Default port changed from `8080` to `18780` — update any hardcoded references in `.env`, Docker port mappings, and `kubectl port-forward` commands - Docker publish workflow hardened for multi-platform builds and SHA prefix handling - Documentation clarified for read/write mounts and `MAIN_WORKSPACE_DIR` behavior - Path routing now combines strict split-root with explicit config-root allowlist: