Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
93d7c76
docs: clarify read-write mounts and WORKSPACE_SUBDIR defaults
kalinon Mar 3, 2026
19ac1ec
refactor: split workspace and config filesystem roots
kalinon Mar 3, 2026
458699f
feat(openclaw): enforce config-root + main-workspace-dir path law
kalinon Mar 3, 2026
ffc9583
Support /workspace/* virtual mapping for main workspace
kalinon Mar 3, 2026
b2e8cdf
Add Gitleaks license key
kalinon Mar 4, 2026
d0301a6
Use org secret for Gitleaks license
kalinon Mar 4, 2026
d84e6c5
Merge pull request #1 from moltar-forge/codex/feature-openclaw-split-…
kalinon Mar 4, 2026
ebafa6d
update changelog
kalinon Mar 4, 2026
deee36b
Fix coverage for workspace path helpers
kalinon Mar 4, 2026
bb7ee9b
Merge pull request #2 from moltar-forge/codex/fix-workspace-coverage
kalinon Mar 4, 2026
a0371c0
update changelog
kalinon Mar 4, 2026
c8da6b8
Fix coverage for workspace path helpers
kalinon Mar 4, 2026
ed2d845
Merge pull request #3 from moltar-forge/codex/feature-openclaw-split-…
kalinon Mar 4, 2026
69f4465
Enforce strict split-root workspace routing
kalinon Mar 4, 2026
b1771d0
Document strict split-root routing behavior
kalinon Mar 4, 2026
a54bbcd
Reinstate workspace path allowlist and policy errors
kalinon Mar 4, 2026
0beba21
Add PATH_NOT_ALLOWED payload helper coverage tests
kalinon Mar 4, 2026
a56b923
Merge pull request #4 from moltar-forge/codex/strict-split-root-routing
kalinon Mar 4, 2026
3c9ccc2
Add docs-only symlink bootstrap endpoint
kalinon Mar 4, 2026
3ba336f
feat: replace symlink ensure with typed docs link resource
kalinon Mar 4, 2026
18dd063
Merge pull request #5 from moltar-forge/codex/docs-only-symlink-boots…
kalinon Mar 4, 2026
2620f62
chore: update engines.node to >=25.0.0
kalinon Mar 5, 2026
ed20638
chore: change default port from 8080 to 18780
kalinon Mar 5, 2026
aef372c
chore: add port change to changelog
kalinon Mar 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/security.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
# 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: 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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ jobs:
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ npm-debug.log
.DS_Store
*.log
coverage/
.idea
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
25
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@ 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
- 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`)
- Typed per-agent link management endpoints:
`GET /links/:type/:agentId`, `PUT /links/:type/:agentId`,
and `DELETE /links/:type/:agentId` (docs-only for now)

### 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:
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-<agent>/**`, and `/_archived_workspace_main/**`
- Disallowed virtual paths now return `403 PATH_NOT_ALLOWED` across file endpoints, including `/`
- 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

- 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
Expand Down
42 changes: 34 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 18780

# 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

Expand All @@ -37,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", "--"]
Expand Down
109 changes: 92 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **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.
- Mount workspace volumes as read-only (`:ro`) when write access is not required.
- For normal MosBot usage, mount the OpenClaw root read-write so Projects/Skills/Docs and config edits can succeed.

See [SECURITY.md](SECURITY.md) for the full threat model and vulnerability reporting process.

Expand All @@ -38,11 +38,12 @@ services:
image: ghcr.io/bymosbot/mosbot-workspace-service:latest
environment:
WORKSPACE_SERVICE_TOKEN: your-secure-token # required
WORKSPACE_ROOT: /workspace
CONFIG_ROOT: /openclaw-config
MAIN_WORKSPACE_DIR: workspace
volumes:
- openclaw-workspace:/workspace:ro
- /path/to/.openclaw:/openclaw-config
ports:
- "8080:8080"
- "18780:18780"
```

### Docker Run
Expand All @@ -51,27 +52,48 @@ services:
docker run -d \
--name mosbot-workspace \
-e WORKSPACE_SERVICE_TOKEN=your-secure-token \
-e WORKSPACE_ROOT=/workspace \
-v /path/to/openclaw/workspace:/workspace:ro \
-p 8080:8080 \
-e CONFIG_ROOT=/openclaw-config \
-e MAIN_WORKSPACE_DIR=workspace \
-v /path/to/.openclaw:/openclaw-config \
-p 18780:18780 \
ghcr.io/bymosbot/mosbot-workspace-service:latest
```

For full MosBot integration (agent discovery via `openclaw.json` + Projects/Skills/Docs CRUD), use
a read-write mount for `CONFIG_ROOT`.

## Environment Variables

| Variable | Default | Description |
| ----------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | `8080` | HTTP server port |
| `WORKSPACE_ROOT` | `/workspace` | Root directory where workspace is mounted |
| `WORKSPACE_SUBDIR` | `workspace` | Subdirectory within `WORKSPACE_ROOT` to expose (prevents browsing the entire filesystem) |
| `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. |
| `SYMLINK_REMAP_PREFIXES` | `/home/node/.openclaw` | Comma-separated list of symlink prefixes to remap (for cross-container symlinks) |
| `WORKSPACE_SERVICE_ALLOW_ANONYMOUS` | — | Set to `true` to disable auth requirement. **For local development only. Never use in production.** |

> **Deprecated aliases** (still accepted for backward compatibility):
>
> - `WORKSPACE_PATH` → use `WORKSPACE_ROOT` instead
> - `AUTH_TOKEN` → use `WORKSPACE_SERVICE_TOKEN` instead
Removed and no longer honored: `WORKSPACE_FS_ROOT`, `CONFIG_FS_ROOT`, `WORKSPACE_ROOT`,
`WORKSPACE_SUBDIR`, `WORKSPACE_PATH`, `AUTH_TOKEN`.

## Filesystem and Virtual Path Contract

Given `CONFIG_ROOT=/openclaw-config` and `MAIN_WORKSPACE_DIR=workspace`:

- Main workspace filesystem root: `/openclaw-config/workspace`
- Sub-agent workspaces: `/openclaw-config/workspace-<agent>`
- Shared directories: `/openclaw-config/projects`, `/openclaw-config/skills`, `/openclaw-config/docs`

Routing rules:

- Main workspace canonical paths: `/workspace` and `/workspace/**` (mapped to `CONFIG_ROOT/MAIN_WORKSPACE_DIR`)
- Config-root allowlist:
`/openclaw.json`, `/agents.json`, `/projects/**`, `/skills/**`, `/docs/**`,
`/workspace-<agent>/**`, 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`.

## API Endpoints

Expand All @@ -95,11 +117,12 @@ Returns workspace accessibility status.
### List Files

```bash
GET /files?path=/&recursive=false
GET /files?path=/workspace&recursive=false
Authorization: Bearer <token>
```

List files and directories. Use `recursive=true` for recursive listing.
`path=/` (or omitted `path`) is denied with `403 PATH_NOT_ALLOWED`.

### Get File Content

Expand Down Expand Up @@ -145,6 +168,58 @@ DELETE /files?path=/path/to/file
Authorization: Bearer <token>
```

### Get Link State

```bash
GET /links/:type/:agentId
Authorization: Bearer <token>
```

Returns per-agent link state for supported types.

- Supported `type`: `docs`
- `agentId`:
- `main` maps to `MAIN_WORKSPACE_DIR`
- any other valid slug maps to `workspace-<agentId>`
- Valid states:
- `linked`
- `missing`
- `conflict` (includes `conflict.reason`, and `conflict.symlinkTarget` when relevant)

### Ensure Link

```bash
PUT /links/:type/:agentId
Authorization: Bearer <token>
```

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 <token>
```

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

### Local Development
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Key risks to be aware of:

- **File write/delete access**: The `POST /files`, `PUT /files`, and `DELETE /files` endpoints can modify or remove files on the mounted workspace volume. Always use a strong `WORKSPACE_SERVICE_TOKEN` and restrict network access.
- **Path traversal**: Built-in path traversal protection rejects requests that escape the configured `WORKSPACE_ROOT`/`WORKSPACE_SUBDIR`. Do not disable or weaken this check.
- **Path traversal**: Built-in path traversal protection rejects requests that escape `CONFIG_ROOT` or `CONFIG_ROOT/<MAIN_WORKSPACE_DIR>`. 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.

Expand All @@ -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
9 changes: 5 additions & 4 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,14 @@ docker build -t mosbot-workspace-service:test .
docker run -d \
--name mosbot-workspace-test \
-e WORKSPACE_SERVICE_TOKEN=test-token \
-e WORKSPACE_ROOT=/workspace \
-v /tmp/test-workspace:/workspace \
-p 8080:8080 \
-e CONFIG_ROOT=/openclaw-config \
-e MAIN_WORKSPACE_DIR=workspace \
-v /tmp/test-config:/openclaw-config \
-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
Expand Down
30 changes: 30 additions & 0 deletions __tests__/app.errors.test.js
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading